coyote-ai 0.8.3

An all-in-one, batteries included LLM CLI Tool
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<String> {
    DEFAULT_WORKSPACE_INSTRUCTIONS_FILES
        .iter()
        .map(|s| s.to_string())
        .collect()
}

pub fn discover_workspace_instructions(
    start: &Path,
    file_names: &[String],
) -> Option<WorkspaceInstructions> {
    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!(
        "<workspace_instructions source=\"{}\">\n{}\n</workspace_instructions>",
        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<String> {
        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<String> = 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("<workspace_instructions source=\"/ws/COYOTE.md\">"));
        assert!(section.contains("Do the thing."));
        assert!(section.ends_with("</workspace_instructions>"));
    }
}