use std::fs; use std::path::PathBuf; const MAX_CHARS: usize = 40_000; /// Read a file and return its trimmed contents, or None if the file doesn't exist. fn read_file_optional(path: &PathBuf) -> Option { match fs::read_to_string(path) { Ok(contents) => { let trimmed = contents.trim().to_string(); if trimmed.is_empty() { None } else { Some(trimmed) } } Err(_) => None, } } /// Load all SLUG.md config files in priority order and return a concatenated string. /// /// Priority (later overrides earlier): /// 1. ~/.slug/SLUG.md — global user preferences /// 2. ./SLUG.md — project-level (in working directory) /// 3. .slug/rules/*.md — modular rule files in the project /// 4. SLUG.local.md — private notes (gitignored) /// /// Returns an empty string if no config files are found. /// Truncates with a warning if the combined content exceeds 40,000 characters. pub fn load_slug_context() -> String { let mut sections: Vec<(String, String)> = Vec::new(); // 1. Global user preferences: ~/.slug/SLUG.md if let Some(home_dir) = dirs::home_dir() { let global_path = home_dir.join(".slug").join("SLUG.md"); if let Some(content) = read_file_optional(&global_path) { sections.push(("# Global Rules (~/.slug/SLUG.md)".to_string(), content)); } } // 2. Project-level: ./SLUG.md let project_path = PathBuf::from("SLUG.md"); if let Some(content) = read_file_optional(&project_path) { sections.push(("# Project Rules (SLUG.md)".to_string(), content)); } // 3. Modular rule files: .slug/rules/*.md let rules_dir = PathBuf::from(".slug/rules"); if rules_dir.is_dir() { let mut rule_files: Vec = match fs::read_dir(&rules_dir) { Ok(entries) => entries .filter_map(|e| e.ok()) .map(|e| e.path()) .filter(|p| p.extension().map(|ext| ext == "md").unwrap_or(false)) .collect(), Err(_) => Vec::new(), }; // Sort for deterministic ordering rule_files.sort(); for rule_file in rule_files { if let Some(content) = read_file_optional(&rule_file) { let filename = rule_file .file_name() .and_then(|n| n.to_str()) .unwrap_or("unknown"); let header = format!("# Project Rule (.slug/rules/{filename})"); sections.push((header, content)); } } } // 4. Private local notes: ./SLUG.local.md let local_path = PathBuf::from("SLUG.local.md"); if let Some(content) = read_file_optional(&local_path) { sections.push(( "# Local Notes (SLUG.local.md)".to_string(), content, )); } if sections.is_empty() { return String::new(); } // Concatenate sections with headers let mut result = String::new(); for (header, content) in §ions { if !result.is_empty() { result.push('\n'); } result.push_str(header); result.push('\n'); result.push_str(content); result.push('\n'); } // Enforce character budget if result.len() > MAX_CHARS { result.truncate(MAX_CHARS); // Try to truncate at a clean line boundary if let Some(last_newline) = result.rfind('\n') { result.truncate(last_newline + 1); } result.push_str("\n[SLUG context truncated: exceeded 40,000 character limit]\n"); eprintln!( "Warning: SLUG.md context exceeded {MAX_CHARS} characters and was truncated." ); } result } #[cfg(test)] mod tests { use super::*; #[test] fn test_load_slug_context_no_files() { // In a directory without any SLUG files, should return empty string // (This test is environment-dependent — it passes when run from a clean dir) let result = load_slug_context(); // Just verify it doesn't panic and returns a String let _ = result.len(); } #[test] fn test_truncation_logic() { let long_content = "x".repeat(MAX_CHARS + 100); let mut result = long_content; if result.len() > MAX_CHARS { result.truncate(MAX_CHARS); if let Some(last_newline) = result.rfind('\n') { result.truncate(last_newline + 1); } result.push_str("\n[SLUG context truncated: exceeded 40,000 character limit]\n"); } assert!(result.contains("truncated")); } }