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_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)?;
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);
}
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);
}
let config_content = sections.join("\n");
fs::write(&config_path, &config_content)
.with_context(|| format!("failed to write {}", config_path.display()))?;
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()))?;
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);
}
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();
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;
}
}
}
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 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),
}
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"));
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");
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");
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");
assert!(
matches!(report, MigrationReport::NothingToMigrate),
"expected NothingToMigrate for typo'd validation.toml, got: {:?}",
report
);
assert!(overlay.join("validation.toml").exists());
assert!(!overlay.join("config.toml").exists());
}
}