sac-cli 0.1.0

Terminal-based AI coding agent — fork of NAC with extended backend support and context management
Documentation
use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use serde::Deserialize;

use crate::paths::{sac_config_path, sac_home_dir};

const AGENTS_MD_MAX_BYTES: usize = 4 * 1024 * 1024;
const AGENTS_MD_NOTICE: &str =
    "Below are instructions from the user's AGENTS.md configuration files. More specific files override broader ones when they conflict.";

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AgentsMdFile {
    pub path: PathBuf,
    pub content: String,
}

#[derive(Clone, Debug, Default)]
pub struct AgentsMdBundle {
    files: Vec<AgentsMdFile>,
}

#[derive(Debug, Default, Deserialize)]
struct AgentsMdConfigFile {
    #[serde(default)]
    agents_md: AgentsMdConfigSection,
    project_doc_fallback_filenames: Option<Vec<String>>,
    project_doc_max_bytes: Option<usize>,
}

#[derive(Debug, Default, Deserialize)]
struct AgentsMdConfigSection {
    fallback_filenames: Option<Vec<String>>,
    max_bytes: Option<usize>,
}

#[derive(Debug, Clone)]
struct AgentsMdSettings {
    fallback_filenames: Vec<String>,
    max_bytes: usize,
}

impl AgentsMdBundle {
    pub fn load(workspace_dir: Option<&Path>) -> Result<Self> {
        let settings = load_settings();
        let mut files = Vec::new();

        if let Some(global_file) = select_non_empty_file(
            sac_home_dir().as_deref(),
            &["AGENTS.override.md", "AGENTS.md"],
        )? {
            files.push(global_file);
        }

        if let Some(workspace_dir) = workspace_dir {
            let dirs = if let Some(root) = find_project_root(workspace_dir) {
                dirs_from_root_to_scope(&root, workspace_dir)
            } else {
                vec![workspace_dir.to_path_buf()]
            };
            for dir in dirs {
                let mut names = vec!["AGENTS.override.md", "AGENTS.md"];
                for fallback in &settings.fallback_filenames {
                    names.push(fallback.as_str());
                }
                if let Some(file) = select_non_empty_file(Some(&dir), &names)? {
                    files.push(file);
                }
            }
        }

        Ok(Self {
            files: truncate_files_to_limit(files, settings.max_bytes),
        })
    }

    pub fn status_text(&self) -> String {
        match self.files.len() {
            0 => "off".to_string(),
            1 => "1 file loaded".to_string(),
            count => format!("{count} files loaded"),
        }
    }

    pub fn system_message(&self) -> Option<String> {
        if self.files.is_empty() {
            return None;
        }

        Some(render_system_message(&self.files))
    }

    pub fn files(&self) -> &[AgentsMdFile] {
        &self.files
    }
}

fn render_system_message(files: &[AgentsMdFile]) -> String {
    let docs = files
        .iter()
        .map(|file| file.content.as_str())
        .collect::<Vec<_>>()
        .join("\n\n");
    format!("{AGENTS_MD_NOTICE}\n\n{docs}")
}

fn truncate_files_to_limit(files: Vec<AgentsMdFile>, max_bytes: usize) -> Vec<AgentsMdFile> {
    let mut kept = Vec::new();
    let mut used_bytes = 0usize;

    for mut file in files {
        let separator_bytes = if kept.is_empty() { 0 } else { 2 };
        let remaining = max_bytes.saturating_sub(used_bytes + separator_bytes);
        if remaining == 0 {
            break;
        }

        if file.content.len() > remaining {
            file.content = truncate_utf8_to_bytes(&file.content, remaining);
            kept.push(file);
            break;
        }

        used_bytes += separator_bytes + file.content.len();
        kept.push(file);
    }

    kept
}

fn select_non_empty_file(dir: Option<&Path>, filenames: &[&str]) -> Result<Option<AgentsMdFile>> {
    let Some(dir) = dir else {
        return Ok(None);
    };

    for name in filenames {
        let path = dir.join(name);
        if !path.is_file() {
            continue;
        }
        if let Some(file) = read_non_empty_file(&path)? {
            return Ok(Some(file));
        }
    }

    Ok(None)
}

fn read_non_empty_file(path: &Path) -> Result<Option<AgentsMdFile>> {
    let content = fs::read_to_string(path)
        .with_context(|| format!("failed to read AGENTS.md file '{}'", path.display()))?;
    let trimmed = content.trim();
    if trimmed.is_empty() {
        return Ok(None);
    }

    Ok(Some(AgentsMdFile {
        path: path.to_path_buf(),
        content: trimmed.to_string(),
    }))
}

fn find_project_root(workspace_dir: &Path) -> Option<PathBuf> {
    let mut current = Some(workspace_dir);
    while let Some(dir) = current {
        let git_marker = dir.join(".git");
        if git_marker.is_dir() || git_marker.is_file() {
            return Some(dir.to_path_buf());
        }
        current = dir.parent();
    }
    None
}

fn dirs_from_root_to_scope(root: &Path, scope: &Path) -> Vec<PathBuf> {
    if !scope.starts_with(root) {
        return vec![scope.to_path_buf()];
    }

    let mut dirs = vec![root.to_path_buf()];
    let mut current = root.to_path_buf();
    if let Ok(relative) = scope.strip_prefix(root) {
        for component in relative.components() {
            current.push(component.as_os_str());
            dirs.push(current.clone());
        }
    }
    dirs
}

fn truncate_utf8_to_bytes(content: &str, max_bytes: usize) -> String {
    if content.len() <= max_bytes {
        return content.to_string();
    }

    let mut end = max_bytes.min(content.len());
    while end > 0 && !content.is_char_boundary(end) {
        end -= 1;
    }
    content[..end].to_string()
}

fn load_settings() -> AgentsMdSettings {
    let mut settings = AgentsMdSettings {
        fallback_filenames: Vec::new(),
        max_bytes: AGENTS_MD_MAX_BYTES,
    };

    let Some(path) = sac_config_path() else {
        return settings;
    };
    let Ok(raw) = fs::read_to_string(&path) else {
        return settings;
    };
    let Ok(config) = toml::from_str::<AgentsMdConfigFile>(&raw) else {
        return settings;
    };

    settings.fallback_filenames = config
        .agents_md
        .fallback_filenames
        .or(config.project_doc_fallback_filenames)
        .unwrap_or_default();
    settings.max_bytes = config
        .agents_md
        .max_bytes
        .or(config.project_doc_max_bytes)
        .unwrap_or(AGENTS_MD_MAX_BYTES)
        .max(1);
    settings
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_env_lock;
    use std::env;
    use std::time::{SystemTime, UNIX_EPOCH};

    fn temp_dir(label: &str) -> PathBuf {
        let unique = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let dir = std::env::temp_dir().join(format!("sac_agents_md_{label}_{unique}"));
        fs::create_dir_all(&dir).unwrap();
        dir
    }

    #[test]
    fn loads_global_and_project_files_in_order() {
        let _guard = test_env_lock();
        let root = temp_dir("hierarchy");
        let sac_home = root.join("sac-home");
        let project_root = root.join("repo");
        let nested = project_root.join("src").join("deep");

        fs::create_dir_all(&sac_home).unwrap();
        fs::create_dir_all(project_root.join(".git")).unwrap();
        fs::create_dir_all(&nested).unwrap();
        fs::write(sac_home.join("AGENTS.md"), "global").unwrap();
        fs::write(project_root.join("AGENTS.md"), "root").unwrap();
        fs::write(
            project_root.join("src").join("AGENTS.override.md"),
            "src override",
        )
        .unwrap();
        fs::write(nested.join("AGENTS.md"), "deep").unwrap();

        let original_sac_home = env::var_os("SAC_HOME");
        unsafe {
            env::set_var("SAC_HOME", &sac_home);
        }

        let bundle = AgentsMdBundle::load(Some(&nested)).unwrap();
        let contents: Vec<&str> = bundle
            .files()
            .iter()
            .map(|file| file.content.as_str())
            .collect();
        assert_eq!(contents, vec!["global", "root", "src override", "deep"]);

        match original_sac_home {
            Some(value) => unsafe { env::set_var("SAC_HOME", value) },
            None => unsafe { env::remove_var("SAC_HOME") },
        }
    }

    #[test]
    fn git_file_marks_project_root() {
        let root = temp_dir("git_file_root");
        let project_root = root.join("repo");
        let nested = project_root.join("nested");
        fs::create_dir_all(&nested).unwrap();
        fs::write(project_root.join(".git"), "gitdir: /tmp/fake").unwrap();

        assert_eq!(find_project_root(&nested).unwrap(), project_root);
    }

    #[test]
    #[ignore]
    fn non_git_scope_walks_parent_to_child_without_git_root() {
        let _guard = test_env_lock();
        let root = temp_dir("non_git");
        let parent = root.join("parent");
        let child = parent.join("child");
        fs::create_dir_all(&child).unwrap();
        fs::write(parent.join("AGENTS.md"), "parent").unwrap();
        fs::write(child.join("AGENTS.md"), "child").unwrap();

        let bundle = AgentsMdBundle::load(Some(&child)).unwrap();
        let contents: Vec<&str> = bundle
            .files()
            .iter()
            .map(|file| file.content.as_str())
            .collect();
        assert_eq!(contents, vec!["parent", "child"]);
    }

    #[test]
    fn truncation_preserves_order_until_limit() {
        let files = vec![
            AgentsMdFile {
                path: PathBuf::from("/repo/AGENTS.md"),
                content: "broad broad broad broad".to_string(),
            },
            AgentsMdFile {
                path: PathBuf::from("/repo/src/AGENTS.md"),
                content: "specific specific specific".to_string(),
            },
        ];

        let trimmed = truncate_files_to_limit(files, 30);
        assert_eq!(trimmed.len(), 2);
        assert_eq!(trimmed[0].content, "broad broad broad broad");
        assert!(trimmed[1].content.len() < "specific specific specific".len());
    }

    #[test]
    fn empty_override_falls_back_to_agents_md() {
        let root = temp_dir("empty_override");
        fs::write(root.join("AGENTS.override.md"), "\n\n").unwrap();
        fs::write(root.join("AGENTS.md"), "fallback").unwrap();

        let selected =
            select_non_empty_file(Some(&root), &["AGENTS.override.md", "AGENTS.md"]).unwrap();
        assert_eq!(selected.unwrap().content, "fallback");
    }

    #[test]
    fn project_fallback_filenames_are_respected() {
        let _guard = test_env_lock();
        let root = temp_dir("fallback_names");
        let sac_home = root.join("sac-home");
        let project = root.join("repo");
        fs::create_dir_all(&sac_home).unwrap();
        fs::create_dir_all(project.join(".git")).unwrap();
        fs::write(
            sac_home.join("config.toml"),
            "[agents_md]\nfallback_filenames = [\"TEAM_GUIDE.md\"]\n",
        )
        .unwrap();
        fs::write(project.join("TEAM_GUIDE.md"), "team guide").unwrap();

        let original_sac_home = env::var_os("SAC_HOME");
        unsafe {
            env::set_var("SAC_HOME", &sac_home);
        }

        let bundle = AgentsMdBundle::load(Some(&project)).unwrap();
        assert_eq!(bundle.files().len(), 1);
        assert_eq!(bundle.files()[0].content, "team guide");

        match original_sac_home {
            Some(value) => unsafe { env::set_var("SAC_HOME", value) },
            None => unsafe { env::remove_var("SAC_HOME") },
        }
    }
}