ccd-cli 1.0.0-alpha.9

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

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

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

pub const LEGACY_ROADMAP_PATH: &str = "docs/roadmap.md";

pub const DEFAULT_PROJECT_TRUTH_CANDIDATES: &[&str] =
    &["AGENTS.md", "MEMORY.md", "README.md", "_MANIFEST.md"];

#[derive(Debug, Clone, Serialize)]
pub struct ManifestResolution {
    pub source_order: &'static str,
    pub manifest_path: PathBuf,
    pub manifest_status: &'static str,
    pub entries: Vec<PathBuf>,
    pub project_truth_paths: Vec<PathBuf>,
}

pub fn resolve_manifest(
    repo_root: &Path,
    layout: &StateLayout,
    locality_id: &str,
) -> Result<ManifestResolution> {
    // Check repo overlay config.toml [sources] first.
    if let Some(resolution) = resolve_from_overlay_config(repo_root, layout, locality_id)? {
        return Ok(resolution);
    }

    let project_truth_paths = DEFAULT_PROJECT_TRUTH_CANDIDATES
        .iter()
        .map(|candidate| repo_root.join(candidate))
        .filter(|path| path.is_file())
        .collect();

    Ok(ManifestResolution {
        source_order: "default",
        manifest_path: layout.repo_overlay_config_path(locality_id)?,
        manifest_status: "absent",
        entries: Vec::new(),
        project_truth_paths,
    })
}

pub fn parse_manifest_entries(contents: &str) -> Result<Vec<PathBuf>> {
    let mut entries = Vec::new();
    let mut seen = HashSet::new();

    for raw_line in contents.lines() {
        let Some(raw_entry) = extract_manifest_entry(raw_line) else {
            continue;
        };
        let entry = validate_manifest_entry(raw_entry)?;
        let key = entry.display().to_string();
        if !seen.insert(key.clone()) {
            bail!("manifest contains a duplicate source entry `{key}`");
        }
        entries.push(entry);
    }

    Ok(entries)
}

pub fn legacy_roadmap_exclusion_warning(
    repo_root: &Path,
    layout: &StateLayout,
    locality_id: &str,
) -> Result<Option<String>> {
    let resolution = resolve_manifest(repo_root, layout, locality_id)?;
    Ok(legacy_roadmap_exclusion_warning_from_resolution(
        repo_root,
        &resolution,
    ))
}

pub fn legacy_roadmap_exclusion_warning_from_resolution(
    repo_root: &Path,
    resolution: &ManifestResolution,
) -> Option<String> {
    let legacy_path = repo_root.join(LEGACY_ROADMAP_PATH);
    if !legacy_path.is_file()
        || resolution
            .project_truth_paths
            .iter()
            .any(|path| path == &legacy_path)
    {
        return None;
    }

    let source_order = match resolution.source_order {
        "config" => format!(
            "config.toml source order at {}",
            resolution.manifest_path.display()
        ),
        _ => "default source order".to_owned(),
    };
    let manifest_hint = match resolution.source_order {
        "config" => format!(
            "add `{LEGACY_ROADMAP_PATH}` to {} explicitly",
            resolution.manifest_path.display()
        ),
        _ => format!(
            "create {} and add `{LEGACY_ROADMAP_PATH}` explicitly",
            resolution.manifest_path.display()
        ),
    };

    Some(format!(
        "legacy roadmap file {} exists but is not loaded by {source_order}; move any remaining durable guidance into an active project-truth source, or {manifest_hint} if it still belongs in session context",
        legacy_path.display()
    ))
}

fn resolve_from_overlay_config(
    repo_root: &Path,
    layout: &StateLayout,
    locality_id: &str,
) -> Result<Option<ManifestResolution>> {
    let config = match layout.load_repo_overlay_config(locality_id)? {
        Some(config) => config,
        None => return Ok(None),
    };

    if config.sources.always.is_empty() {
        return Ok(None);
    }

    let config_path = layout.repo_overlay_config_path(locality_id)?;
    let mut entries = Vec::new();
    let mut project_truth_paths = Vec::new();

    for entry_str in &config.sources.always {
        let entry = validate_manifest_entry(entry_str)?;
        let path = repo_root.join(&entry);
        if !path.is_file() {
            bail!(
                "config.toml source `{}` does not exist as a file under {}",
                entry.display(),
                repo_root.display()
            );
        }
        project_truth_paths.push(path);
        entries.push(entry);
    }

    Ok(Some(ManifestResolution {
        source_order: "config",
        manifest_path: config_path,
        manifest_status: "loaded",
        entries,
        project_truth_paths,
    }))
}

fn extract_manifest_entry(line: &str) -> Option<&str> {
    let trimmed = line.trim();
    if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("<!--") {
        return None;
    }

    if let Some(rest) = trimmed
        .strip_prefix("- ")
        .or_else(|| trimmed.strip_prefix("* "))
        .or_else(|| trimmed.strip_prefix("+ "))
    {
        return Some(rest.trim());
    }

    let mut parts = trimmed.splitn(2, ". ");
    let first = parts.next()?;
    let second = parts.next();
    if !first.is_empty() && first.chars().all(|ch| ch.is_ascii_digit()) {
        return second.map(str::trim);
    }

    Some(trimmed)
}

fn validate_manifest_entry(entry: &str) -> Result<PathBuf> {
    let trimmed = entry.trim().trim_matches('`').trim();
    if trimmed.is_empty() {
        bail!("manifest entries cannot be empty");
    }

    let path = Path::new(trimmed);
    if path.is_absolute() {
        bail!("manifest entries must be relative paths: `{trimmed}`");
    }

    for component in path.components() {
        match component {
            Component::Normal(_) | Component::CurDir => {}
            Component::ParentDir => {
                bail!("manifest entries cannot use parent-directory traversal: `{trimmed}`")
            }
            Component::RootDir | Component::Prefix(_) => {
                bail!("manifest entries must be relative paths: `{trimmed}`")
            }
        }
    }

    Ok(PathBuf::from(trimmed))
}

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

    use tempfile::tempdir;

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

    #[test]
    fn config_toml_sources_takes_priority_over_manifest_md() {
        let temp = tempdir().expect("tempdir");
        let repo_root = temp.path().join("repo");
        fs::create_dir_all(&repo_root).expect("repo dir");
        fs::write(repo_root.join("AGENTS.md"), "# Agents").expect("AGENTS.md");
        fs::write(repo_root.join("README.md"), "# README").expect("README.md");

        let layout = StateLayout::new(
            temp.path().join(".ccd"),
            temp.path().join("repo/.git/ccd"),
            ProfileName::new("main").expect("profile"),
        );
        let locality_id = "ccdrepo_src";

        fs::create_dir_all(layout.repo_overlay_root(locality_id).expect("repo overlay"))
            .expect("repo overlay");

        // Write config.toml with [sources]
        fs::write(
            layout
                .repo_overlay_config_path(locality_id)
                .expect("config path"),
            r#"
[sources]
always = ["AGENTS.md", "README.md"]
"#,
        )
        .expect("config.toml");

        let resolution = resolve_manifest(&repo_root, &layout, locality_id).expect("resolve");

        assert_eq!(resolution.source_order, "config");
        assert!(resolution.manifest_path.ends_with("config.toml"));
        assert_eq!(resolution.entries.len(), 2);
        assert_eq!(resolution.project_truth_paths.len(), 2);
    }
}