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> {
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");
fs::write(
layout
.repo_overlay_config_path(locality_id)
.expect("config path"),
r#"
[doctor.handoff]
freshness = "error"
sections = "error"
"#,
)
.expect("config.toml");
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");
fs::write(
layout
.repo_overlay_config_path(locality_id)
.expect("config path"),
"[sources]\nalways = [\"AGENTS.md\"]\n",
)
.expect("config.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"));
assert_eq!(
loaded.profile.doctor.handoff.freshness,
ValidationSeverity::Warning
);
}
}