ccd-cli 1.0.0-alpha.8

Bootstrap and validate Continuous Context Development repositories
// Marker helpers are reused by bootstrap flows, CLI commands, and tests, so a
// small amount of dead-code noise is expected across build combinations.

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

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

pub const MARKER_FILE: &str = ".ccd.toml";
const MARKER_SCHEMA_VERSION_V2: u32 = 2;

#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum MarkerSubstrate {
    #[default]
    Git,
    Directory,
}

#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
struct RawRepoMarker {
    #[serde(default = "default_version")]
    version: u32,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    project_id: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    substrate: Option<MarkerSubstrate>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    display_name: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RepoMarker {
    pub version: u32,
    pub locality_id: String,
    pub display_name: Option<String>,
    substrate: MarkerSubstrate,
    include_substrate: bool,
}

impl RepoMarker {
    pub fn new(locality_id: impl Into<String>, display_name: Option<String>) -> Result<Self> {
        Self::new_internal(
            MARKER_SCHEMA_VERSION_V2,
            locality_id.into(),
            display_name,
            MarkerSubstrate::Git,
            false,
        )
    }

    pub(crate) fn new_directory(
        locality_id: impl Into<String>,
        display_name: Option<String>,
    ) -> Result<Self> {
        Self::new_internal(
            MARKER_SCHEMA_VERSION_V2,
            locality_id.into(),
            display_name,
            MarkerSubstrate::Directory,
            true,
        )
    }

    pub(crate) fn rewrite_with_locality_id(
        &self,
        locality_id: impl Into<String>,
        display_name: Option<String>,
    ) -> Result<Self> {
        Self::new_internal(
            self.version,
            locality_id.into(),
            display_name,
            self.substrate,
            self.include_substrate,
        )
    }

    pub(crate) fn substrate(&self) -> MarkerSubstrate {
        self.substrate
    }

    pub fn from_toml(contents: &str) -> Result<Self> {
        let raw: RawRepoMarker = toml::from_str(contents).context("failed to parse marker TOML")?;
        Self::from_raw(raw)
    }

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

    fn validate(&self) -> Result<()> {
        if self.version != MARKER_SCHEMA_VERSION_V2 {
            bail!(
                "unsupported marker schema version `{}`; only version 2 is supported — re-run `ccd link --path .` to upgrade",
                self.version
            );
        }

        if self.substrate != MarkerSubstrate::Git && !self.include_substrate {
            bail!("version 2 markers must serialize non-git substrates explicitly");
        }

        validate_locality_id(&self.locality_id)?;

        if let Some(display_name) = &self.display_name {
            if display_name.is_empty() {
                bail!("display_name cannot be empty");
            }
        }

        Ok(())
    }

    fn new_internal(
        version: u32,
        locality_id: String,
        display_name: Option<String>,
        substrate: MarkerSubstrate,
        include_substrate: bool,
    ) -> Result<Self> {
        let marker = Self {
            version,
            locality_id,
            display_name,
            substrate,
            include_substrate,
        };
        marker.validate()?;
        Ok(marker)
    }

    fn from_raw(raw: RawRepoMarker) -> Result<Self> {
        if raw.version != MARKER_SCHEMA_VERSION_V2 {
            bail!(
                "version {} markers are no longer supported; re-run `ccd link --path .` to upgrade to version 2",
                raw.version
            );
        }

        let locality_id = raw
            .project_id
            .ok_or_else(|| anyhow::anyhow!("version 2 markers require `project_id`"))?;
        let include_substrate = raw.substrate.is_some();
        Self::new_internal(
            MARKER_SCHEMA_VERSION_V2,
            locality_id,
            raw.display_name,
            raw.substrate.unwrap_or_default(),
            include_substrate,
        )
    }

    fn to_raw(&self) -> RawRepoMarker {
        RawRepoMarker {
            version: self.version,
            project_id: Some(self.locality_id.clone()),
            substrate: self.include_substrate.then_some(self.substrate),
            display_name: self.display_name.clone(),
        }
    }
}

pub fn load(repo_root: &Path) -> Result<Option<RepoMarker>> {
    let path = repo_root.join(MARKER_FILE);
    match fs::read_to_string(&path) {
        Ok(contents) => Ok(Some(RepoMarker::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(repo_root: &Path, marker: &RepoMarker) -> Result<PathBuf> {
    let path = repo_root.join(MARKER_FILE);
    let contents = marker.to_toml()?;
    fs::write(&path, contents).with_context(|| format!("failed to write {}", path.display()))?;
    Ok(path)
}

fn default_version() -> u32 {
    MARKER_SCHEMA_VERSION_V2
}

pub(crate) fn validate_locality_id(locality_id: &str) -> Result<&str> {
    if locality_id.is_empty() {
        bail!("project_id cannot be empty");
    }

    if locality_id == "." || locality_id == ".." {
        bail!("project_id cannot be `.` or `..`");
    }

    if locality_id.contains('/') || locality_id.contains('\\') {
        bail!("project_id cannot contain path separators");
    }

    if locality_id.as_bytes().contains(&0) {
        bail!("project_id cannot contain NUL bytes");
    }

    Ok(locality_id)
}

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

    use super::*;

    #[test]
    fn parses_v2_marker_with_project_id() {
        let marker = RepoMarker::from_toml(
            r#"
version = 2
project_id = "ccdrepo_123"
"#,
        )
        .expect("marker parses");

        assert_eq!(marker.version, 2);
        assert_eq!(marker.locality_id, "ccdrepo_123");
        assert_eq!(marker.display_name, None);
        assert_eq!(
            marker.to_toml().expect("marker toml"),
            "version = 2\nproject_id = \"ccdrepo_123\"\n"
        );
    }

    #[test]
    fn parses_v2_marker_with_project_id_and_substrate() {
        let marker = RepoMarker::from_toml(
            r#"
version = 2
project_id = "ccdrepo_123"
substrate = "git"
"#,
        )
        .expect("marker parses");

        assert_eq!(marker.version, 2);
        assert_eq!(marker.locality_id, "ccdrepo_123");
        assert_eq!(
            marker.to_toml().expect("marker toml"),
            "version = 2\nproject_id = \"ccdrepo_123\"\nsubstrate = \"git\"\n"
        );
    }

    #[test]
    fn parses_v2_marker_with_directory_substrate() {
        let marker = RepoMarker::from_toml(
            r#"
version = 2
project_id = "ccdrepo_123"
substrate = "directory"
"#,
        )
        .expect("marker parses");

        assert_eq!(marker.version, 2);
        assert_eq!(marker.locality_id, "ccdrepo_123");
        assert_eq!(
            marker.to_toml().expect("marker toml"),
            "version = 2\nproject_id = \"ccdrepo_123\"\nsubstrate = \"directory\"\n"
        );
    }

    #[test]
    fn parses_marker_without_explicit_version() {
        let marker = RepoMarker::from_toml(
            r#"
project_id = "ccdrepo_123"
"#,
        )
        .expect("marker parses — defaults to v2");

        assert_eq!(marker.version, 2);
        assert_eq!(marker.locality_id, "ccdrepo_123");
    }

    #[test]
    fn rejects_v1_markers() {
        let error = RepoMarker::from_toml(
            r#"
version = 1
project_id = "ccdrepo_123"
"#,
        )
        .expect_err("v1 should be rejected");

        assert!(error.to_string().contains("no longer supported"));
    }

    #[test]
    fn rejects_marker_with_no_project_id() {
        let error = RepoMarker::from_toml(
            r#"
version = 2
display_name = "ccd-guide"
"#,
        )
        .expect_err("missing project_id should fail");

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

    #[test]
    fn rejects_legacy_repo_id_field() {
        let error = RepoMarker::from_toml(
            r#"
version = 2
repo_id = "ccdrepo_123"
"#,
        )
        .expect_err("repo_id field should be rejected as unknown");

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

    #[test]
    fn rejects_legacy_marker_fields() {
        let error = RepoMarker::from_toml(
            r#"
version = 2
project_id = "ccdrepo_123"
branch_on_session = false
"#,
        )
        .expect_err("legacy fields should fail");

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

    #[test]
    fn writes_and_loads_marker_file() {
        let temp = tempdir().expect("tempdir");
        let marker = RepoMarker::new("ccdrepo_123", Some("ccd-guide".to_owned())).expect("marker");

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

        assert_eq!(path, temp.path().join(MARKER_FILE));
        assert_eq!(reloaded, Some(marker));
    }

    #[test]
    fn writes_and_loads_v2_directory_marker_file() {
        let temp = tempdir().expect("tempdir");
        let marker = RepoMarker::from_toml(
            r#"
version = 2
project_id = "ccdrepo_123"
substrate = "directory"
display_name = "ccd-guide"
"#,
        )
        .expect("marker");

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

        assert_eq!(path, temp.path().join(MARKER_FILE));
        assert_eq!(reloaded, Some(marker));
    }

    #[test]
    fn rewrite_preserves_project_id_encoding() {
        let marker = RepoMarker::from_toml(
            r#"
version = 2
project_id = "ccdrepo_123"
substrate = "git"
"#,
        )
        .expect("marker parses");

        let updated = marker
            .rewrite_with_locality_id("ccdrepo_456", Some("ccd-guide".to_owned()))
            .expect("marker rewrites");

        assert_eq!(
            updated.to_toml().expect("marker toml"),
            "version = 2\nproject_id = \"ccdrepo_456\"\nsubstrate = \"git\"\ndisplay_name = \"ccd-guide\"\n"
        );
    }

    #[test]
    fn rejects_invalid_project_ids() {
        let error = RepoMarker::new("../bad", None).expect_err("project_id should fail");

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