ccd-cli 1.0.0-beta.2

Bootstrap and validate Continuous Context Development repositories
use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};

use crate::paths::state::StateLayout;

#[derive(Debug)]
pub(crate) enum MigrationReport {
    AlreadyCanonical,
    Migrated { from: Vec<PathBuf> },
    NothingToMigrate,
}

pub(crate) fn migrate_repo_overlay_if_needed(
    layout: &StateLayout,
    locality_id: &str,
) -> Result<MigrationReport> {
    let config_path = layout.repo_overlay_config_path(locality_id)?;

    // If config.toml already exists with content, treat as canonical.
    if config_path.exists() {
        let contents = fs::read_to_string(&config_path)
            .with_context(|| format!("failed to read {}", config_path.display()))?;
        if !contents.trim().is_empty() {
            return Ok(MigrationReport::AlreadyCanonical);
        }
    }

    let overlay_root = layout.repo_overlay_root(locality_id)?;

    // Scan legacy files and attempt migration for each.
    let manifest_path = overlay_root.join("manifest.md");
    let validation_path = overlay_root.join("validation.toml");
    let has_any_legacy = manifest_path.exists() || validation_path.exists();

    if !has_any_legacy {
        return Ok(MigrationReport::NothingToMigrate);
    }

    // Build config.toml content from legacy files. Track which files
    // were successfully migrated so we only rename those.
    let mut sections: Vec<String> = Vec::new();
    let mut migrated_paths: Vec<PathBuf> = Vec::new();

    if manifest_path.exists() {
        if let Some(section) = migrate_manifest(&manifest_path)? {
            sections.push(section);
            migrated_paths.push(manifest_path);
        }
    }

    if validation_path.exists() {
        if let Some(section) = migrate_validation(&validation_path)? {
            sections.push(section);
            migrated_paths.push(validation_path);
        }
    }

    if sections.is_empty() {
        return Ok(MigrationReport::NothingToMigrate);
    }

    // Write merged config.toml.
    let config_content = sections.join("\n");
    fs::write(&config_path, &config_content)
        .with_context(|| format!("failed to write {}", config_path.display()))?;

    // Rename successfully migrated legacy files to *.migrated.
    let mut migrated_from = Vec::new();
    for path in &migrated_paths {
        let migrated = path.with_extension(format!(
            "{}.migrated",
            path.extension()
                .map(|e| e.to_str().unwrap_or(""))
                .unwrap_or("")
        ));
        fs::rename(path, &migrated).with_context(|| {
            format!(
                "failed to rename {} to {}",
                path.display(),
                migrated.display()
            )
        })?;
        migrated_from.push(path.clone());
    }

    Ok(MigrationReport::Migrated {
        from: migrated_from,
    })
}

fn migrate_manifest(path: &Path) -> Result<Option<String>> {
    let contents =
        fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;

    // Use the canonical manifest parser so numbered entries, backtick-
    // wrapped entries, and all other accepted formats are normalized
    // identically to the runtime loader.
    let entries = crate::repo::truth::parse_manifest_entries(&contents)
        .with_context(|| format!("failed to parse manifest {}", path.display()))?;

    if entries.is_empty() {
        return Ok(None);
    }

    let values: Vec<toml::Value> = entries
        .iter()
        .map(|e| toml::Value::String(e.display().to_string()))
        .collect();

    let mut table = toml::map::Map::new();
    table.insert("always".to_owned(), toml::Value::Array(values));
    let mut root = toml::map::Map::new();
    root.insert("sources".to_owned(), toml::Value::Table(table));

    Ok(Some(toml::to_string_pretty(&root)?))
}

fn migrate_validation(path: &Path) -> Result<Option<String>> {
    let contents =
        fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;

    if contents.trim().is_empty() {
        return Ok(None);
    }

    // Parse the old validation.toml which supports both flat keys
    // ([doctor] handoff_freshness = "error") and nested keys
    // ([doctor.handoff] freshness = "error"). We normalize to the new
    // config.toml [doctor.handoff] format.
    let parsed: std::collections::BTreeMap<String, toml::Value> =
        toml::from_str(&contents).with_context(|| format!("failed to parse {}", path.display()))?;

    let doctor = match parsed.get("doctor") {
        Some(toml::Value::Table(t)) => t,
        _ => return Ok(None),
    };

    fn is_valid_severity(val: &str) -> bool {
        matches!(val, "warning" | "error")
    }

    let mut handoff_table = toml::map::Map::new();

    // Flat keys take lower priority than nested [doctor.handoff] keys.
    let flat_keys = [
        ("handoff_freshness", "freshness"),
        ("handoff_missing", "missing"),
        ("handoff_sections", "sections"),
        ("handoff_sections_empty", "sections_empty"),
        ("handoff_narration", "narration"),
    ];
    let mut has_invalid = false;
    for (old_key, new_key) in flat_keys {
        if let Some(val) = doctor.get(old_key).and_then(|v| v.as_str()) {
            if is_valid_severity(val) {
                handoff_table.insert(new_key.to_owned(), toml::Value::String(val.to_owned()));
            } else {
                has_invalid = true;
            }
        }
    }

    // Nested [doctor.handoff] keys override flat keys. Only accept known
    // field names so that typos (e.g. `freshnes`) are not silently migrated
    // — the legacy file stays in place and the strict legacy loader reports
    // the error via deny_unknown_fields.
    const KNOWN_HANDOFF_KEYS: &[&str] = &[
        "missing",
        "sections",
        "sections_empty",
        "narration",
        "freshness",
    ];

    if let Some(toml::Value::Table(handoff)) = doctor.get("handoff") {
        for (key, value) in handoff {
            if !KNOWN_HANDOFF_KEYS.contains(&key.as_str()) {
                has_invalid = true;
                continue;
            }
            if let Some(s) = value.as_str() {
                if is_valid_severity(s) {
                    handoff_table.insert(key.clone(), toml::Value::String(s.to_owned()));
                } else {
                    has_invalid = true;
                }
            }
        }
    }

    // If any values are invalid, skip migration for this file so the legacy
    // loader can report the parse error.
    if has_invalid {
        return Ok(None);
    }

    if handoff_table.is_empty() {
        return Ok(None);
    }

    let mut doctor_table = toml::map::Map::new();
    doctor_table.insert("handoff".to_owned(), toml::Value::Table(handoff_table));
    let mut root = toml::map::Map::new();
    root.insert("doctor".to_owned(), toml::Value::Table(doctor_table));

    Ok(Some(toml::to_string_pretty(&root)?))
}

#[cfg(test)]
mod tests {
    use std::fs;

    use tempfile::tempdir;

    use super::*;
    use crate::profile::ProfileName;

    fn test_layout(temp: &tempfile::TempDir) -> (StateLayout, String) {
        let layout = StateLayout::new(
            temp.path().join(".ccd"),
            temp.path().join("repo/.git/ccd"),
            ProfileName::new("main").expect("profile"),
        );
        let locality_id = "ccdrepo_mig".to_owned();
        fs::create_dir_all(
            layout
                .repo_overlay_root(&locality_id)
                .expect("repo overlay"),
        )
        .expect("repo overlay");
        (layout, locality_id)
    }

    #[test]
    fn migration_creates_config_toml_from_legacy_files() {
        let temp = tempdir().expect("tempdir");
        let (layout, locality_id) = test_layout(&temp);
        let overlay = layout.repo_overlay_root(&locality_id).expect("overlay");

        fs::write(overlay.join("manifest.md"), "- AGENTS.md\n- README.md\n").expect("manifest");
        fs::write(
            overlay.join("validation.toml"),
            "[doctor.handoff]\nfreshness = \"error\"\n",
        )
        .expect("validation");

        let report = migrate_repo_overlay_if_needed(&layout, &locality_id).expect("migrate");

        match &report {
            MigrationReport::Migrated { from } => assert_eq!(from.len(), 2),
            other => panic!("expected Migrated, got {:?}", other),
        }

        // config.toml should exist and contain all kernel-owned sections
        let config = fs::read_to_string(overlay.join("config.toml")).expect("read config.toml");
        assert!(config.contains("[sources]"));
        assert!(config.contains("AGENTS.md"));
        assert!(config.contains("[doctor.handoff]"));
        assert!(config.contains("freshness"));

        // Legacy files should be renamed
        assert!(!overlay.join("manifest.md").exists());
        assert!(overlay.join("manifest.md.migrated").exists());
        assert!(!overlay.join("validation.toml").exists());
        assert!(overlay.join("validation.toml.migrated").exists());
    }

    #[test]
    fn migration_is_idempotent_when_config_exists() {
        let temp = tempdir().expect("tempdir");
        let (layout, locality_id) = test_layout(&temp);
        let overlay = layout.repo_overlay_root(&locality_id).expect("overlay");

        fs::write(
            overlay.join("config.toml"),
            "[sources]\nalways = [\"AGENTS.md\"]\n",
        )
        .expect("config");

        let report = migrate_repo_overlay_if_needed(&layout, &locality_id).expect("migrate");
        assert!(matches!(report, MigrationReport::AlreadyCanonical));
    }

    #[test]
    fn migration_returns_nothing_when_no_legacy_files() {
        let temp = tempdir().expect("tempdir");
        let (layout, locality_id) = test_layout(&temp);

        let report = migrate_repo_overlay_if_needed(&layout, &locality_id).expect("migrate");
        assert!(matches!(report, MigrationReport::NothingToMigrate));
    }

    #[test]
    fn migration_handles_partial_legacy_files() {
        let temp = tempdir().expect("tempdir");
        let (layout, locality_id) = test_layout(&temp);
        let overlay = layout.repo_overlay_root(&locality_id).expect("overlay");

        // Only manifest.md exists
        fs::write(overlay.join("manifest.md"), "- AGENTS.md\n").expect("manifest");

        let report = migrate_repo_overlay_if_needed(&layout, &locality_id).expect("migrate");

        match &report {
            MigrationReport::Migrated { from } => assert_eq!(from.len(), 1),
            other => panic!("expected Migrated, got {:?}", other),
        }

        let config = fs::read_to_string(overlay.join("config.toml")).expect("read config.toml");
        assert!(config.contains("[sources]"));
    }

    #[test]
    fn migration_skips_validation_with_unknown_handoff_keys() {
        let temp = tempdir().expect("tempdir");
        let (layout, locality_id) = test_layout(&temp);
        let overlay = layout.repo_overlay_root(&locality_id).expect("overlay");

        // validation.toml with a typo in the nested key
        fs::write(
            overlay.join("validation.toml"),
            "[doctor.handoff]\nfreshnes = \"error\"\n",
        )
        .expect("validation");

        let report = migrate_repo_overlay_if_needed(&layout, &locality_id).expect("migrate");

        // Migration should skip this file so the legacy deny_unknown_fields
        // loader reports the error.
        assert!(
            matches!(report, MigrationReport::NothingToMigrate),
            "expected NothingToMigrate for typo'd validation.toml, got: {:?}",
            report
        );
        // Original file should still be in place
        assert!(overlay.join("validation.toml").exists());
        assert!(!overlay.join("config.toml").exists());
    }
}