agent-atlas 0.1.1

Deterministic knowledge base indexer for AI agents
Documentation
//! `atlas init` command - Initialize .atlas directory

use crate::cache::tantivy_backend;
use crate::config::Config;
use crate::LogLevel;
use anyhow::{Context, Result};
use std::fs;
use std::io::ErrorKind;
use std::path::Path;

const ATLAS_DIR: &str = ".atlas";
const GITIGNORE_ATLAS_ENTRY: &str = ".atlas/";

pub fn run(root: &Path, log_level: LogLevel) -> Result<()> {
    let atlas_path = root.join(ATLAS_DIR);
    let already_initialized = atlas_path.exists();

    if already_initialized {
        if log_level != LogLevel::Quiet {
            println!("✓ .atlas already exists at {}", atlas_path.display());
        }
    } else {
        // Create directory structure
        fs::create_dir_all(atlas_path.join("cache/text"))
            .context("Failed to create cache/text directory")?;
        fs::create_dir_all(tantivy_backend::index_dir(&atlas_path))
            .context("Failed to create current tantivy index directory")?;
        fs::create_dir_all(atlas_path.join("global"))
            .context("Failed to create global directory")?;
        fs::create_dir_all(atlas_path.join("views/folders"))
            .context("Failed to create views/folders directory")?;

        // Write default config
        let config = Config::default();
        let config_path = atlas_path.join("config.toml");
        let config_toml = toml_string(&config);
        fs::write(&config_path, config_toml).context("Failed to write config.toml")?;
    }

    let gitignore_updated = ensure_gitignore_ignores_atlas(root)?;

    if log_level != LogLevel::Quiet {
        if !already_initialized {
            println!("✓ Initialized .atlas at {}", atlas_path.display());
            println!("  Edit .atlas/config.toml to customize settings");
        }
        if gitignore_updated {
            println!("✓ Added .atlas/ to {}", root.join(".gitignore").display());
        }
    }

    Ok(())
}

fn ensure_gitignore_ignores_atlas(root: &Path) -> Result<bool> {
    let gitignore_path = root.join(".gitignore");
    let existing = match fs::read_to_string(&gitignore_path) {
        Ok(content) => content,
        Err(error) if error.kind() == ErrorKind::NotFound => String::new(),
        Err(error) => {
            return Err(error)
                .with_context(|| format!("Failed to read {}", gitignore_path.display()))
        }
    };

    if gitignore_has_atlas_entry(&existing) {
        return Ok(false);
    }

    let mut updated = existing;
    if !updated.is_empty() && !updated.ends_with('\n') {
        updated.push('\n');
    }
    updated.push_str(GITIGNORE_ATLAS_ENTRY);
    updated.push('\n');

    fs::write(&gitignore_path, updated)
        .with_context(|| format!("Failed to write {}", gitignore_path.display()))?;

    Ok(true)
}

fn gitignore_has_atlas_entry(content: &str) -> bool {
    content
        .lines()
        .any(|line| matches!(line.trim(), ".atlas" | ".atlas/" | "/.atlas" | "/.atlas/"))
}

fn toml_string(config: &Config) -> String {
    // Manual TOML generation to keep comments and ordering stable
    let mut s = String::new();

    s.push_str("[scan]\n");
    s.push_str("# Patterns to ignore (in addition to .gitignore)\n");
    s.push_str("ignore = [\n");
    for pattern in &config.scan.ignore {
        s.push_str(&format!("    \"{}\",\n", pattern));
    }
    s.push_str("]\n\n");

    s.push_str("# File extensions to index\n");
    s.push_str("include_extensions = [\n");
    for ext in &config.scan.include_extensions {
        s.push_str(&format!("    \"{}\",\n", ext));
    }
    s.push_str("]\n\n");

    s.push_str("[extract]\n");
    s.push_str(&format!(
        "# Max file size to process (bytes)\nmax_file_size = {}\n\n",
        config.extract.max_file_size
    ));
    s.push_str(&format!(
        "# Snippet length (chars)\nsnippet_length = {}\n\n",
        config.extract.snippet_length
    ));
    s.push_str("# Path to pdftotext binary (auto-detected if not set)\n");
    s.push_str("# pdftotext_path = \"/usr/bin/pdftotext\"\n\n");

    s.push_str("[analyze]\n");
    s.push_str(&format!(
        "# Number of top terms per file\ntop_terms = {}\n\n",
        config.analyze.top_terms
    ));
    s.push_str(&format!(
        "# Number of top phrases per file\ntop_phrases = {}\n\n",
        config.analyze.top_phrases
    ));
    s.push_str(&format!(
        "# Minimum term length\nmin_term_length = {}\n\n",
        config.analyze.min_term_length
    ));
    s.push_str(&format!(
        "# Maximum term length\nmax_term_length = {}\n\n",
        config.analyze.max_term_length
    ));
    s.push_str(&format!(
        "# Maximum digit ratio in a term (0.0-1.0)\nmax_digit_ratio = {}\n\n",
        config.analyze.max_digit_ratio
    ));
    s.push_str(&format!(
        "# Minimum document frequency for a term\nmin_df = {}\n\n",
        config.analyze.min_df
    ));
    s.push_str(&format!(
        "# Maximum document frequency ratio (0.0-1.0)\nmax_df_ratio = {}\n\n",
        config.analyze.max_df_ratio
    ));

    s.push_str("[render]\n");
    s.push_str(&format!(
        "# Folder depth in ROOT_ATLAS.md\natlas_folder_depth = {}\n\n",
        config.render.atlas_folder_depth
    ));
    s.push_str(&format!(
        "# Max files to list per folder in atlas\natlas_max_files_per_folder = {}\n",
        config.render.atlas_max_files_per_folder
    ));

    s
}

#[cfg(test)]
mod tests {
    use super::{gitignore_has_atlas_entry, run, toml_string};
    use crate::config::{Config, DEFAULT_INCLUDE_EXTENSIONS};
    use crate::LogLevel;
    use std::fs;

    fn run_init_with_gitignore(content: Option<&str>) -> String {
        let temp = tempfile::tempdir().expect("tempdir should be created");
        let root = temp.path();
        let gitignore_path = root.join(".gitignore");

        if let Some(content) = content {
            fs::write(&gitignore_path, content).expect("gitignore should be seeded");
        }

        run(root, LogLevel::Quiet).expect("init should succeed");
        run(root, LogLevel::Quiet).expect("second init should succeed");

        fs::read_to_string(&gitignore_path).expect("gitignore should be readable")
    }

    #[test]
    fn renders_default_extensions_into_generated_config() {
        let rendered = toml_string(&Config::default());
        let parsed: Config = toml::from_str(&rendered).expect("generated config should parse");
        let expected: Vec<String> = DEFAULT_INCLUDE_EXTENSIONS
            .iter()
            .map(|ext| (*ext).to_string())
            .collect();

        assert_eq!(parsed.scan.include_extensions, expected);
    }

    #[test]
    fn initializes_atlas_and_adds_gitignore_entry_idempotently() {
        let temp = tempfile::tempdir().expect("tempdir should be created");
        let root = temp.path();
        let gitignore_path = root.join(".gitignore");

        fs::write(&gitignore_path, "target\n").expect("gitignore should be seeded");

        run(root, LogLevel::Quiet).expect("init should succeed");
        run(root, LogLevel::Quiet).expect("second init should succeed");

        let gitignore = fs::read_to_string(&gitignore_path).expect("gitignore should be readable");
        assert!(root.join(".atlas/config.toml").is_file());
        assert_eq!(gitignore.matches(".atlas/").count(), 1);
        assert!(gitignore_has_atlas_entry(&gitignore));
    }

    #[test]
    fn creates_missing_gitignore_with_atlas_entry() {
        let gitignore = run_init_with_gitignore(None);

        assert_eq!(gitignore, ".atlas/\n");
    }

    #[test]
    fn adds_missing_gitignore_entry_when_atlas_already_exists() {
        let temp = tempfile::tempdir().expect("tempdir should be created");
        let root = temp.path();
        let gitignore_path = root.join(".gitignore");

        fs::create_dir_all(root.join(".atlas")).expect(".atlas should be seeded");

        run(root, LogLevel::Quiet).expect("init should succeed");
        run(root, LogLevel::Quiet).expect("second init should succeed");

        let gitignore = fs::read_to_string(&gitignore_path).expect("gitignore should be readable");
        assert_eq!(gitignore, ".atlas/\n");
    }

    #[test]
    fn recognizes_existing_atlas_gitignore_variants() {
        for variant in [".atlas", ".atlas/", "/.atlas", "/.atlas/"] {
            let gitignore = run_init_with_gitignore(Some(&format!("target\n{variant}\n")));

            assert_eq!(gitignore, format!("target\n{variant}\n"));
            assert!(gitignore_has_atlas_entry(&gitignore));
        }

        assert!(!gitignore_has_atlas_entry("# .atlas/\n"));
    }
}