magellan 4.13.0

Deterministic codebase mapping tool for local development
Documentation
//! Project registry: read/write ~/.config/magellan/config.toml (project array)

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};

use super::types::ProjectEntry;

/// Resolve the default registry path at runtime: `$HOME/.config/magellan/config.toml`.
/// Matches the pattern in `src/config.rs::default_config_path`. Falls back to
/// `.config/magellan/config.toml` relative to cwd if `HOME` is unset (same
/// fallback as `config.rs`).
fn default_registry_path() -> PathBuf {
    let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
    PathBuf::from(home)
        .join(".config")
        .join("magellan")
        .join("config.toml")
}

/// Minimal struct for reading [[project]] array out of config.toml.
/// Unknown keys (language-model, registry, embeddings) are silently ignored.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RegistryFile {
    #[serde(default)]
    pub version: String,
    #[serde(default)]
    pub project: Vec<ProjectEntry>,
}

/// Manages the persistent project registry
#[derive(Debug, Clone)]
pub struct Registry {
    pub projects: Vec<ProjectEntry>,
    path: PathBuf,
}

impl Registry {
    /// Load registry from the default path
    pub fn load() -> Result<Self> {
        Self::load_from(default_registry_path())
    }

    /// Load registry from a specific path
    pub fn load_from(path: PathBuf) -> Result<Self> {
        let projects = if path.exists() {
            let content = fs::read_to_string(&path)
                .with_context(|| format!("Failed to read {}", path.display()))?;
            let file: RegistryFile =
                toml::from_str(&content).with_context(|| "Failed to parse registry TOML")?;
            file.project
        } else {
            Vec::new()
        };
        Ok(Self { projects, path })
    }

    /// Save registry to disk, merging the [[project]] array into config.toml
    /// while preserving all other sections (language-model, registry, embeddings).
    pub fn save(&self) -> Result<()> {
        let dir = self.path.parent().unwrap_or(Path::new("."));
        if !dir.exists() {
            fs::create_dir_all(dir)?;
        }

        // Load existing file as raw TOML table to preserve non-project keys
        let mut doc: toml::value::Table = if self.path.exists() {
            let content = fs::read_to_string(&self.path)
                .with_context(|| format!("Failed to read {}", self.path.display()))?;
            toml::from_str::<toml::value::Table>(&content).unwrap_or_default()
        } else {
            toml::value::Table::new()
        };

        // Replace [[project]] array; remove key entirely when empty
        if self.projects.is_empty() {
            doc.remove("project");
        } else {
            let projects_val =
                toml::Value::try_from(&self.projects).context("Failed to serialize projects")?;
            doc.insert("project".to_string(), projects_val);
        }

        let content = toml::to_string_pretty(&toml::Value::Table(doc))
            .context("Failed to serialize config.toml")?;
        fs::write(&self.path, &content)
            .with_context(|| format!("Failed to write {}", self.path.display()))?;
        Ok(())
    }

    /// List all registered projects
    pub fn list(&self) -> &[ProjectEntry] {
        &self.projects
    }

    /// Find a project by name (exact match)
    pub fn find(&self, name: &str) -> Option<&ProjectEntry> {
        self.projects.iter().find(|p| p.name == name)
    }

    /// Find a project by root path
    pub fn find_by_root(&self, root: &Path) -> Option<&ProjectEntry> {
        let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
        self.projects
            .iter()
            .find(|p| p.root.canonicalize().unwrap_or_else(|_| p.root.clone()) == root)
    }

    /// Check if a root path is already registered
    pub fn is_registered(&self, root: &Path) -> bool {
        self.find_by_root(root).is_some()
    }

    /// All unique project names
    #[allow(dead_code, reason = "Phase 7: used in tests")]
    pub fn names(&self) -> Vec<String> {
        self.projects.iter().map(|p| p.name.clone()).collect()
    }

    /// Enabled project names
    pub fn enabled_names(&self) -> Vec<String> {
        self.projects
            .iter()
            .filter_map(|p| {
                if p.enabled {
                    Some(p.name.clone())
                } else {
                    None
                }
            })
            .collect()
    }

    /// Add a new project, auto-disambiguating name collisions
    pub fn register(&mut self, entry: ProjectEntry) -> Result<()> {
        if self.is_registered(&entry.root) {
            anyhow::bail!(
                "Project at root '{}' is already registered",
                entry.root.display()
            );
        }
        let name = Self::disambiguate_name(&self.projects, &entry.name);
        let entry = ProjectEntry { name, ..entry };
        self.projects.push(entry);
        self.save()
    }

    /// Remove a project by name. Returns true if found.
    pub fn unregister(&mut self, name: &str) -> Result<bool> {
        let before = self.projects.len();
        self.projects.retain(|p| p.name != name);
        let removed = self.projects.len() != before;
        if removed {
            self.save()?;
        }
        Ok(removed)
    }

    /// Pause (disable) a project by name
    pub fn pause(&mut self, name: &str) -> Result<bool> {
        if let Some(p) = self.projects.iter_mut().find(|p| p.name == name) {
            p.enabled = false;
            self.save()?;
            Ok(true)
        } else {
            Ok(false)
        }
    }

    /// Resume (enable) a project by name
    pub fn resume(&mut self, name: &str) -> Result<bool> {
        if let Some(p) = self.projects.iter_mut().find(|p| p.name == name) {
            p.enabled = true;
            self.save()?;
            Ok(true)
        } else {
            Ok(false)
        }
    }

    /// Auto-disambiguate duplicate names: foo → foo-1, foo-1 → foo-2, etc.
    pub fn disambiguate_name(projects: &[ProjectEntry], base: &str) -> String {
        let names: HashSet<String> = projects.iter().map(|p| p.name.clone()).collect();
        if !names.contains(base) {
            return base.to_string();
        }
        let mut n = 1;
        loop {
            let candidate = format!("{}-{}", base, n);
            if !names.contains(&candidate) {
                return candidate;
            }
            n += 1;
            if n > 10_000 {
                // Safety valve
                return format!("{}-{}", base, uuid::Uuid::new_v4());
            }
        }
    }

    /// Derive the canonical database path for a project under ~/.magellan/
    pub fn canonical_db_path(name: &str) -> PathBuf {
        let home = std::env::var("HOME")
            .map(PathBuf::from)
            .unwrap_or_else(|_| PathBuf::from("/tmp"));
        home.join(".magellan")
            .join(name)
            .join(format!("{}.db", name))
    }

    /// Ensure the database directory exists
    pub fn ensure_db_dir(name: &str) -> Result<PathBuf> {
        let path = Self::canonical_db_path(name);
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent)?;
        }
        Ok(path)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn tmp_registry() -> (Registry, tempfile::TempDir) {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("registry.toml");
        let reg = Registry::load_from(path).unwrap();
        (reg, dir)
    }

    fn make_entry(name: &str) -> ProjectEntry {
        ProjectEntry::new(
            name.to_string(),
            PathBuf::from(format!("/tmp/roots/{}", name)),
            PathBuf::from(format!("/tmp/dbs/{}.db", name)),
            "cargo".to_string(),
        )
    }

    #[test]
    fn registry_register_and_find() {
        let (mut reg, _dir) = tmp_registry();
        let e = make_entry("test");
        reg.register(e.clone()).unwrap();
        assert_eq!(reg.list().len(), 1);
        assert!(reg.find("test").is_some());
        assert_eq!(reg.find("test").unwrap().name, "test");
    }

    #[test]
    fn registry_unregister() {
        let (mut reg, _dir) = tmp_registry();
        reg.register(make_entry("a")).unwrap();
        reg.register(make_entry("b")).unwrap();
        assert!(reg.unregister("a").unwrap());
        assert!(reg.find("a").is_none());
        assert!(reg.find("b").is_some());
    }

    #[test]
    fn registry_roundtrip() {
        let (mut reg, _dir) = tmp_registry();
        reg.register(make_entry("x")).unwrap();
        // Reload via the same path
        let reg2 = Registry::load_from(reg.path.clone()).unwrap();
        assert_eq!(reg2.list().len(), 1);
        assert_eq!(reg2.find("x").unwrap().name, "x");
    }

    fn make_entry_with_root(name: &str, root_name: &str) -> ProjectEntry {
        ProjectEntry::new(
            name.to_string(),
            PathBuf::from(format!("/tmp/roots/{}", root_name)),
            PathBuf::from(format!("/tmp/dbs/{}.db", name)),
            "cargo".to_string(),
        )
    }

    #[test]
    fn registry_duplicate_name_disambiguates() {
        let (mut reg, _dir) = tmp_registry();
        reg.register(make_entry_with_root("foo", "foo-0")).unwrap();
        reg.register(make_entry_with_root("foo", "foo-1")).unwrap();
        let names = reg.names();
        assert!(names.contains(&"foo".to_string()));
        assert!(
            names.contains(&"foo-1".to_string()),
            "Expected foo-1 but got {:?}",
            names
        );
    }

    #[test]
    fn registry_include_exclude_roundtrip() {
        let (mut reg, _dir) = tmp_registry();
        let mut entry = make_entry("multi_path");
        entry.include = vec![
            "src/".to_string(),
            "kernels/".to_string(),
            "tests/".to_string(),
        ];
        entry.exclude = vec!["target/".to_string()];
        reg.register(entry).unwrap();

        let reg2 = Registry::load_from(reg.path.clone()).unwrap();
        let loaded = reg2.find("multi_path").unwrap();
        assert_eq!(
            loaded.include,
            vec!["src/", "kernels/", "tests/"],
            "include should round-trip through TOML"
        );
        assert_eq!(loaded.exclude, vec!["target/"]);
    }

    #[test]
    fn registry_backward_compat_without_include_exclude() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("registry.toml");
        let toml_content = r#"
version = "1"

[[project]]
name = "legacy"
root = "/tmp/legacy"
db = "/tmp/legacy.db"
source = "manual"
enabled = true
"#;
        std::fs::write(&path, toml_content).unwrap();

        let reg = Registry::load_from(path).unwrap();
        let entry = reg.find("legacy").unwrap();
        assert_eq!(entry.name, "legacy");
        assert!(
            entry.include.is_empty(),
            "legacy entries should have empty include"
        );
        assert!(
            entry.exclude.is_empty(),
            "legacy entries should have empty exclude"
        );
    }

    /// Regression for B-7: default registry path must NOT be hardcoded to a
    /// specific user's home directory. It must resolve `$HOME` at runtime so
    /// the crate works for any user, CI, or container.
    #[test]
    fn test_default_registry_path_respects_home() {
        // Save and restore HOME to avoid interfering with other tests.
        let original_home = std::env::var("HOME").ok();
        let dir = tempfile::tempdir().unwrap();
        let fake_home = dir.path().to_path_buf();

        std::env::set_var("HOME", &fake_home);
        let resolved = default_registry_path();
        // Restore HOME immediately after reading.
        match &original_home {
            Some(h) => std::env::set_var("HOME", h),
            None => std::env::remove_var("HOME"),
        }

        let expected = fake_home
            .join(".config")
            .join("magellan")
            .join("config.toml");
        assert_eq!(
            resolved, expected,
            "default_registry_path must resolve under $HOME, not a hardcoded path"
        );

        // Explicitly: must never contain a literal /home/feanor.
        let resolved_str = resolved.to_string_lossy();
        assert!(
            !resolved_str.contains("/home/feanor"),
            "default_registry_path must not be hardcoded to /home/feanor, got: {}",
            resolved_str
        );
    }
}