ccd-cli 1.0.0-alpha.2

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

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

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

#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum ValidationSeverity {
    Warning,
    Error,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct DoctorValidationProfile {
    pub(crate) handoff: HandoffDoctorValidationProfile,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct HandoffDoctorValidationProfile {
    pub(crate) missing: ValidationSeverity,
    pub(crate) sections: ValidationSeverity,
    pub(crate) narration: ValidationSeverity,
    pub(crate) freshness: ValidationSeverity,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct ValidationProfile {
    pub(crate) doctor: DoctorValidationProfile,
}

pub(crate) struct LoadedValidationProfile {
    pub(crate) path: PathBuf,
    pub(crate) status: &'static str,
    pub(crate) contents: Option<String>,
    pub(crate) profile: ValidationProfile,
    pub(crate) error: Option<String>,
}

impl Default for ValidationProfile {
    fn default() -> Self {
        Self {
            doctor: DoctorValidationProfile {
                handoff: HandoffDoctorValidationProfile {
                    missing: ValidationSeverity::Warning,
                    sections: ValidationSeverity::Warning,
                    narration: ValidationSeverity::Warning,
                    freshness: ValidationSeverity::Warning,
                },
            },
        }
    }
}

#[derive(Debug, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct ValidationProfileFile {
    doctor: DoctorValidationProfileFile,
}

#[derive(Debug, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct DoctorValidationProfileFile {
    handoff: HandoffDoctorValidationProfileFile,
    handoff_freshness: Option<ValidationSeverity>,
    handoff_missing: Option<ValidationSeverity>,
    handoff_sections: Option<ValidationSeverity>,
    handoff_sections_empty: Option<ValidationSeverity>,
    handoff_narration: Option<ValidationSeverity>,
}

#[derive(Debug, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct HandoffDoctorValidationProfileFile {
    missing: Option<ValidationSeverity>,
    sections: Option<ValidationSeverity>,
    sections_empty: Option<ValidationSeverity>,
    narration: Option<ValidationSeverity>,
    freshness: Option<ValidationSeverity>,
}

impl From<ValidationProfileFile> for ValidationProfile {
    fn from(value: ValidationProfileFile) -> Self {
        let mut profile = Self::default();
        if let Some(severity) = value.doctor.handoff_freshness {
            profile.doctor.handoff.freshness = severity;
        }
        if let Some(severity) = value.doctor.handoff_missing {
            profile.doctor.handoff.missing = severity;
        }
        if let Some(severity) = value.doctor.handoff_sections {
            profile.doctor.handoff.sections = severity;
        }
        if let Some(severity) = value.doctor.handoff_sections_empty {
            profile.doctor.handoff.sections = severity;
        }
        if let Some(severity) = value.doctor.handoff_narration {
            profile.doctor.handoff.narration = severity;
        }
        if let Some(severity) = value.doctor.handoff.missing {
            profile.doctor.handoff.missing = severity;
        }
        if let Some(severity) = value.doctor.handoff.sections {
            profile.doctor.handoff.sections = severity;
        }
        if let Some(severity) = value.doctor.handoff.sections_empty {
            profile.doctor.handoff.sections = severity;
        }
        if let Some(severity) = value.doctor.handoff.narration {
            profile.doctor.handoff.narration = severity;
        }
        if let Some(severity) = value.doctor.handoff.freshness {
            profile.doctor.handoff.freshness = severity;
        }
        profile
    }
}

pub(crate) fn load_for_layout(
    layout: &StateLayout,
    locality_id: &str,
) -> Result<LoadedValidationProfile> {
    // Check repo overlay config.toml [doctor] first.
    if let Some(loaded) = load_from_overlay_config(layout, locality_id)? {
        return Ok(loaded);
    }

    let path = layout.repo_validation_path(locality_id)?;
    let contents = match fs::read_to_string(&path) {
        Ok(contents) => Some(contents),
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
            return Ok(LoadedValidationProfile {
                path,
                status: "missing",
                contents: None,
                profile: ValidationProfile::default(),
                error: None,
            })
        }
        Err(error) => {
            return Ok(LoadedValidationProfile {
                path,
                status: "invalid",
                contents: None,
                profile: ValidationProfile::default(),
                error: Some(format!("failed to read validation profile: {error}")),
            })
        }
    };

    let profile = parse_profile(&path, contents.as_deref().unwrap_or_default());
    match profile {
        Ok(profile) => Ok(LoadedValidationProfile {
            path,
            status: "loaded",
            contents,
            profile,
            error: None,
        }),
        Err(error) => Ok(LoadedValidationProfile {
            path,
            status: "invalid",
            contents,
            profile: ValidationProfile::default(),
            error: Some(format!("{error:#}")),
        }),
    }
}

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

    let h = &config.doctor.handoff;
    let has_content = h.missing.is_some()
        || h.sections.is_some()
        || h.sections_empty.is_some()
        || h.narration.is_some()
        || h.freshness.is_some();
    if !has_content {
        return Ok(None);
    }

    let config_path = layout.repo_overlay_config_path(locality_id)?;
    let mut profile = ValidationProfile::default();

    fn parse_severity(key: &str, value: &str) -> Result<ValidationSeverity> {
        match value {
            "warning" => Ok(ValidationSeverity::Warning),
            "error" => Ok(ValidationSeverity::Error),
            _ => bail!(
                "invalid severity `{value}` for [doctor.handoff].{key}; \
                 expected `warning` or `error`"
            ),
        }
    }

    let apply = (|| -> Result<()> {
        if let Some(ref val) = h.missing {
            profile.doctor.handoff.missing = parse_severity("missing", val)?;
        }
        if let Some(ref val) = h.sections {
            profile.doctor.handoff.sections = parse_severity("sections", val)?;
        }
        if let Some(ref val) = h.sections_empty {
            profile.doctor.handoff.sections = parse_severity("sections_empty", val)?;
        }
        if let Some(ref val) = h.narration {
            profile.doctor.handoff.narration = parse_severity("narration", val)?;
        }
        if let Some(ref val) = h.freshness {
            profile.doctor.handoff.freshness = parse_severity("freshness", val)?;
        }
        Ok(())
    })();

    match apply {
        Ok(()) => Ok(Some(LoadedValidationProfile {
            path: config_path,
            status: "loaded",
            contents: None,
            profile,
            error: None,
        })),
        Err(error) => Ok(Some(LoadedValidationProfile {
            path: config_path,
            status: "invalid",
            contents: None,
            profile: ValidationProfile::default(),
            error: Some(format!("{error:#}")),
        })),
    }
}

fn parse_profile(path: &Path, contents: &str) -> Result<ValidationProfile> {
    let profile = toml::from_str::<ValidationProfileFile>(contents).with_context(|| {
        format!(
            "failed to parse validation profile TOML at {}",
            path.display()
        )
    })?;

    Ok(profile.into())
}

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

    use tempfile::tempdir;

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

    #[test]
    fn config_toml_doctor_takes_priority_over_validation_toml() {
        let temp = tempdir().expect("tempdir");
        let layout = StateLayout::new(
            temp.path().join(".ccd"),
            temp.path().join("repo/.git/ccd"),
            ProfileName::new("main").expect("profile"),
        );
        let locality_id = "ccdrepo_val";

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

        // Write config.toml with [doctor.handoff]
        fs::write(
            layout
                .repo_overlay_config_path(locality_id)
                .expect("config path"),
            r#"
[doctor.handoff]
freshness = "error"
sections = "error"
"#,
        )
        .expect("config.toml");

        // Write legacy validation.toml (should be ignored)
        fs::write(
            layout.repo_validation_path(locality_id).expect("val path"),
            r#"
[doctor.handoff]
freshness = "warning"
"#,
        )
        .expect("validation.toml");

        let loaded = load_for_layout(&layout, locality_id).expect("load");

        assert!(loaded.path.ends_with("config.toml"));
        assert_eq!(loaded.status, "loaded");
        assert_eq!(
            loaded.profile.doctor.handoff.freshness,
            ValidationSeverity::Error
        );
        assert_eq!(
            loaded.profile.doctor.handoff.sections,
            ValidationSeverity::Error
        );
    }

    #[test]
    fn falls_back_to_validation_toml_when_config_toml_has_no_doctor() {
        let temp = tempdir().expect("tempdir");
        let layout = StateLayout::new(
            temp.path().join(".ccd"),
            temp.path().join("repo/.git/ccd"),
            ProfileName::new("main").expect("profile"),
        );
        let locality_id = "ccdrepo_val";

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

        // Config.toml with no [doctor] section
        fs::write(
            layout
                .repo_overlay_config_path(locality_id)
                .expect("config path"),
            "[sources]\nalways = [\"AGENTS.md\"]\n",
        )
        .expect("config.toml");

        // Legacy validation.toml
        fs::write(
            layout.repo_validation_path(locality_id).expect("val path"),
            "[doctor.handoff]\nfreshness = \"error\"\n",
        )
        .expect("validation.toml");

        let loaded = load_for_layout(&layout, locality_id).expect("load");

        assert!(loaded.path.ends_with("validation.toml"));
        assert_eq!(loaded.status, "loaded");
        assert_eq!(
            loaded.profile.doctor.handoff.freshness,
            ValidationSeverity::Error
        );
    }

    #[test]
    fn config_toml_doctor_rejects_invalid_severity_values() {
        let temp = tempdir().expect("tempdir");
        let layout = StateLayout::new(
            temp.path().join(".ccd"),
            temp.path().join("repo/.git/ccd"),
            ProfileName::new("main").expect("profile"),
        );
        let locality_id = "ccdrepo_val";

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

        fs::write(
            layout
                .repo_overlay_config_path(locality_id)
                .expect("config path"),
            "[doctor.handoff]\nfreshness = \"fatal\"\n",
        )
        .expect("config.toml");

        let loaded = load_for_layout(&layout, locality_id).expect("load");

        assert_eq!(loaded.status, "invalid");
        assert!(loaded.error.is_some());
        assert!(loaded
            .error
            .as_deref()
            .unwrap()
            .contains("invalid severity"));
        // Falls back to defaults
        assert_eq!(
            loaded.profile.doctor.handoff.freshness,
            ValidationSeverity::Warning
        );
    }
}