use std::fs; use std::path::{Path, PathBuf}; use log::warn; pub const WORKSPACE_INSTRUCTIONS_FILE_NAME: &str = "COYOTE.md"; pub const DEFAULT_WORKSPACE_INSTRUCTIONS_FILES: [&str; 4] = [ WORKSPACE_INSTRUCTIONS_FILE_NAME, "AGENTS.md", "CLAUDE.md", "GEMINI.md", ]; const INSTRUCTIONS_SIZE_WARN_THRESHOLD: usize = 24_000; #[derive(Debug, Clone)] pub struct WorkspaceInstructions { pub path: PathBuf, pub content: String, } pub fn default_workspace_instructions_files() -> Vec { DEFAULT_WORKSPACE_INSTRUCTIONS_FILES .iter() .map(|s| s.to_string()) .collect() } pub fn discover_workspace_instructions( start: &Path, file_names: &[String], ) -> Option { for dir in start.ancestors() { for name in file_names { let candidate = dir.join(name); if !candidate.is_file() { continue; } match fs::read_to_string(&candidate) { Ok(content) if !content.trim().is_empty() => { return Some(WorkspaceInstructions { path: candidate, content, }); } Ok(_) => {} Err(e) => warn!( "failed to read workspace instructions at {}: {e}", candidate.display() ), } } } None } pub fn build_instructions_section(instructions: &WorkspaceInstructions) -> String { let char_count = instructions.content.chars().count(); if char_count > INSTRUCTIONS_SIZE_WARN_THRESHOLD { warn!( "workspace instructions at {} are large ({char_count} chars); \ consider moving detail into workspace memory drill files", instructions.path.display() ); } format!( "\n{}\n", instructions.path.display(), instructions.content.trim_end() ) } #[cfg(test)] mod tests { use super::*; use std::{env, time}; use time::SystemTime; fn temp_root(label: &str) -> PathBuf { let unique = SystemTime::now() .duration_since(time::UNIX_EPOCH) .unwrap() .as_nanos(); let root = env::temp_dir().join(format!("coyote-instructions-{label}-{unique}")); fs::create_dir_all(&root).unwrap(); root } fn defaults() -> Vec { default_workspace_instructions_files() } #[test] fn discovery_returns_none_when_no_file_exists() { let root = temp_root("none"); assert!(discover_workspace_instructions(&root, &defaults()).is_none()); let _ = fs::remove_dir_all(&root); } #[test] fn discovery_finds_coyote_md() { let root = temp_root("coyote"); fs::write(root.join("COYOTE.md"), "coyote instructions").unwrap(); let found = discover_workspace_instructions(&root, &defaults()).unwrap(); assert_eq!(found.path, root.join("COYOTE.md")); assert_eq!(found.content, "coyote instructions"); let _ = fs::remove_dir_all(&root); } #[test] fn discovery_falls_back_through_chain_in_order() { let root = temp_root("fallback"); fs::write(root.join("GEMINI.md"), "gemini instructions").unwrap(); let found = discover_workspace_instructions(&root, &defaults()).unwrap(); assert_eq!(found.path, root.join("GEMINI.md")); fs::write(root.join("CLAUDE.md"), "claude instructions").unwrap(); let found = discover_workspace_instructions(&root, &defaults()).unwrap(); assert_eq!(found.path, root.join("CLAUDE.md")); fs::write(root.join("AGENTS.md"), "agents instructions").unwrap(); let found = discover_workspace_instructions(&root, &defaults()).unwrap(); assert_eq!(found.path, root.join("AGENTS.md")); let _ = fs::remove_dir_all(&root); } #[test] fn discovery_prefers_coyote_md_over_fallbacks() { let root = temp_root("precedence"); fs::write(root.join("COYOTE.md"), "coyote").unwrap(); fs::write(root.join("AGENTS.md"), "agents").unwrap(); fs::write(root.join("CLAUDE.md"), "claude").unwrap(); let found = discover_workspace_instructions(&root, &defaults()).unwrap(); assert_eq!(found.path, root.join("COYOTE.md")); let _ = fs::remove_dir_all(&root); } #[test] fn discovery_walks_up_from_nested_dir() { let root = temp_root("walk_up"); fs::write(root.join("AGENTS.md"), "root instructions").unwrap(); let nested = root.join("src").join("deep"); fs::create_dir_all(&nested).unwrap(); let found = discover_workspace_instructions(&nested, &defaults()).unwrap(); assert_eq!(found.path, root.join("AGENTS.md")); let _ = fs::remove_dir_all(&root); } #[test] fn discovery_prefers_closer_file_over_higher_priority_name_above() { let root = temp_root("depth_first"); fs::write(root.join("COYOTE.md"), "root coyote").unwrap(); let nested = root.join("packages").join("app"); fs::create_dir_all(&nested).unwrap(); fs::write(nested.join("CLAUDE.md"), "nested claude").unwrap(); let found = discover_workspace_instructions(&nested, &defaults()).unwrap(); assert_eq!(found.path, nested.join("CLAUDE.md")); let _ = fs::remove_dir_all(&root); } #[test] fn discovery_skips_empty_files() { let root = temp_root("empty"); fs::write(root.join("COYOTE.md"), " \n").unwrap(); fs::write(root.join("AGENTS.md"), "real content").unwrap(); let found = discover_workspace_instructions(&root, &defaults()).unwrap(); assert_eq!(found.path, root.join("AGENTS.md")); let _ = fs::remove_dir_all(&root); } #[test] fn discovery_honors_custom_file_chain() { let root = temp_root("custom"); fs::write(root.join("CLAUDE.md"), "claude").unwrap(); let only_agents = vec!["AGENTS.md".to_string()]; assert!(discover_workspace_instructions(&root, &only_agents).is_none()); let empty: Vec = vec![]; assert!(discover_workspace_instructions(&root, &empty).is_none()); let _ = fs::remove_dir_all(&root); } #[test] fn build_section_wraps_content_with_source_path() { let instructions = WorkspaceInstructions { path: PathBuf::from("/ws/COYOTE.md"), content: "Do the thing.\n".into(), }; let section = build_instructions_section(&instructions); assert!(section.starts_with("")); assert!(section.contains("Do the thing.")); assert!(section.ends_with("")); } }