ccd-cli 1.0.0-beta.2

Bootstrap and validate Continuous Context Development repositories
// Registry helpers are exercised through attach/link/bootstrap flows and tests,
// so some paths are intentionally idle in narrower build combinations.

use std::env;
use std::fs;
use std::path::{Path, PathBuf};

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

pub const REPO_METADATA_FILE: &str = "repo.toml";
const REPO_REGISTRY_SCHEMA_VERSION: u32 = 1;

#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct RepoAliases {
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub remote_urls: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub root_commits: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub repo_basenames: Vec<String>,
}

#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct RepoRegistryEntry {
    #[serde(default = "default_version")]
    pub version: u32,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub display_name: Option<String>,
    #[serde(default)]
    pub aliases: RepoAliases,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub known_clone_roots: Vec<String>,
}

impl RepoRegistryEntry {
    #[cfg_attr(not(test), allow(dead_code))]
    pub fn new(display_name: Option<String>, aliases: RepoAliases) -> Result<Self> {
        Self::new_with_known_clone_roots(display_name, aliases, Vec::new())
    }

    pub fn new_with_known_clone_roots(
        display_name: Option<String>,
        aliases: RepoAliases,
        known_clone_roots: Vec<String>,
    ) -> Result<Self> {
        let entry = Self {
            version: REPO_REGISTRY_SCHEMA_VERSION,
            display_name,
            aliases,
            known_clone_roots,
        };
        entry.validate()?;
        Ok(entry)
    }

    pub fn from_toml(contents: &str) -> Result<Self> {
        let entry: Self = toml::from_str(contents).context("failed to parse repo registry TOML")?;
        entry.validate()?;
        Ok(entry)
    }

    pub fn to_toml(&self) -> Result<String> {
        self.validate()?;
        toml::to_string(self).context("failed to serialize repo registry TOML")
    }

    fn validate(&self) -> Result<()> {
        if let Some(display_name) = &self.display_name {
            if display_name.is_empty() {
                bail!("display_name cannot be empty");
            }
        }

        validate_string_values("remote_urls", &self.aliases.remote_urls)?;
        validate_string_values("root_commits", &self.aliases.root_commits)?;
        validate_string_values("repo_basenames", &self.aliases.repo_basenames)?;
        validate_string_values("known_clone_roots", &self.known_clone_roots)?;
        Ok(())
    }

    #[cfg_attr(not(test), allow(dead_code))]
    pub fn add_known_clone_root(&mut self, clone_root: String) {
        if self
            .known_clone_roots
            .iter()
            .any(|known| known == &clone_root)
        {
            return;
        }

        self.known_clone_roots.push(clone_root);
        self.known_clone_roots.sort();
    }

    pub fn remove_known_clone_root(&mut self, clone_root: &str) -> bool {
        let before = self.known_clone_roots.len();
        self.known_clone_roots.retain(|known| known != clone_root);
        before != self.known_clone_roots.len()
    }
}

pub fn load(path: &Path) -> Result<Option<RepoRegistryEntry>> {
    match fs::read_to_string(path) {
        Ok(contents) => Ok(Some(RepoRegistryEntry::from_toml(&contents)?)),
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
        Err(error) => Err(error).with_context(|| format!("failed to read {}", path.display())),
    }
}

pub fn write(path: &Path, entry: &RepoRegistryEntry) -> Result<PathBuf> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)
            .with_context(|| format!("failed to create directory {}", parent.display()))?;
    }

    let contents = entry.to_toml()?;
    fs::write(path, contents).with_context(|| format!("failed to write {}", path.display()))?;
    Ok(path.to_path_buf())
}

fn default_version() -> u32 {
    REPO_REGISTRY_SCHEMA_VERSION
}

pub fn normalize_clone_root(path: &Path) -> Result<String> {
    let absolute = if path.is_absolute() {
        path.to_path_buf()
    } else {
        env::current_dir()?.join(path)
    };
    let normalized = absolute.canonicalize().unwrap_or(absolute);
    Ok(normalized.display().to_string())
}

fn validate_string_values(label: &str, values: &[String]) -> Result<()> {
    if values.iter().any(|value| value.is_empty()) {
        bail!("{label} cannot contain empty values");
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use tempfile::tempdir;

    use super::*;

    #[test]
    fn parses_repo_registry_aliases() {
        let entry = RepoRegistryEntry::from_toml(
            r#"
version = 1
display_name = "ccd-guide"
known_clone_roots = ["/tmp/ccd-guide"]

[aliases]
remote_urls = ["git@github.com:example/ccd-guide.git"]
root_commits = ["abc123"]
repo_basenames = ["ccd-guide"]
"#,
        )
        .expect("repo registry parses");

        assert_eq!(entry.display_name.as_deref(), Some("ccd-guide"));
        assert_eq!(
            entry.aliases.remote_urls,
            vec!["git@github.com:example/ccd-guide.git"]
        );
        assert_eq!(entry.aliases.root_commits, vec!["abc123"]);
        assert_eq!(entry.aliases.repo_basenames, vec!["ccd-guide"]);
        assert_eq!(entry.known_clone_roots, vec!["/tmp/ccd-guide"]);
    }

    #[test]
    fn ignores_unknown_top_level_registry_fields() {
        let entry = RepoRegistryEntry::from_toml(
            r#"
version = 1
display_name = "ccd-guide"
future_field = true
"#,
        )
        .expect("unknown top-level field should be ignored");

        assert_eq!(entry.display_name.as_deref(), Some("ccd-guide"));
    }

    #[test]
    fn writes_and_loads_registry_entry() {
        let temp = tempdir().expect("tempdir");
        let path = temp.path().join("repos/ccdrepo_123/repo.toml");
        let entry = RepoRegistryEntry::new(
            Some("ccd-guide".to_owned()),
            RepoAliases {
                remote_urls: vec!["git@github.com:example/ccd-guide.git".to_owned()],
                root_commits: vec!["abc123".to_owned()],
                repo_basenames: vec!["ccd-guide".to_owned()],
            },
        )
        .expect("entry");

        write(&path, &entry).expect("entry written");
        let reloaded = load(&path).expect("entry loaded");

        assert_eq!(reloaded, Some(entry));
    }

    #[test]
    fn rejects_empty_alias_values() {
        let error = RepoRegistryEntry::new(
            None,
            RepoAliases {
                remote_urls: vec![String::new()],
                root_commits: Vec::new(),
                repo_basenames: Vec::new(),
            },
        )
        .expect_err("empty alias should fail");

        assert!(error.to_string().contains("remote_urls"));
    }

    #[test]
    fn rejects_unknown_alias_fields() {
        let error = RepoRegistryEntry::from_toml(
            r#"
version = 1

[aliases]
future_alias = "value"
"#,
        )
        .expect_err("unknown alias field should fail");

        let message = format!("{error:#}");
        assert!(message.contains("future_alias"));
    }

    #[test]
    fn add_known_clone_root_deduplicates_and_sorts() {
        let mut entry = RepoRegistryEntry::new(None, RepoAliases::default()).expect("entry");

        entry.add_known_clone_root("/tmp/z-repo".to_owned());
        entry.add_known_clone_root("/tmp/a-repo".to_owned());
        entry.add_known_clone_root("/tmp/z-repo".to_owned());

        assert_eq!(entry.known_clone_roots, vec!["/tmp/a-repo", "/tmp/z-repo"]);
    }
}