use std::path::{Path, PathBuf};
use crate::core::agent_manifest::ManifestError;
use crate::core::agent_manifest::{atomic_write, checksum};
use crate::core::doctor_repair::RepairMode;
use crate::core::paths::FrameworkPaths;
use crate::core::skill_deploy_tiers::{SkillDeployTier, skill_deploy_tiers};
use crate::core::skill_drift::{
ManifestState, SkillDrift, SkillReference, audit_deployed_skills, deployed_path,
};
use crate::core::skill_manifest::{
SkillManifest, SkillManifestEntry, SkillManifestSave, with_skill_manifest_lock,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RepairAction {
Repaired { backup: Option<PathBuf> },
WouldRepair { existed: bool },
SkippedFrozen,
SkippedUnverifiable(String),
Failed(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RepairOutcome {
pub tier: &'static str,
pub stem: String,
pub path: PathBuf,
pub action: RepairAction,
}
impl RepairOutcome {
pub fn changed(&self) -> bool {
matches!(self.action, RepairAction::Repaired { .. })
}
}
pub fn repair_skills(
reference: &SkillReference,
paths: &FrameworkPaths,
project_dir: Option<&Path>,
include_frozen: bool,
backup_root: &Path,
) -> Vec<RepairOutcome> {
repair_skills_in_mode(
reference,
paths,
project_dir,
include_frozen,
backup_root,
RepairMode::Apply,
)
}
pub fn repair_skills_in_mode(
reference: &SkillReference,
paths: &FrameworkPaths,
project_dir: Option<&Path>,
include_frozen: bool,
backup_root: &Path,
mode: RepairMode,
) -> Vec<RepairOutcome> {
let mut outcomes = Vec::new();
for tier in skill_deploy_tiers(paths, project_dir) {
if !tier.dir.is_dir() {
continue;
}
let label = tier.label;
let dir = tier.dir.clone();
let locked = with_skill_manifest_lock::<_, ManifestError, _>(&tier.dir, || {
Ok(repair_tier_locked(
reference,
&tier,
include_frozen,
backup_root,
mode,
))
});
match locked {
Ok(tier_outcomes) => outcomes.extend(tier_outcomes),
Err(e) => outcomes.push(RepairOutcome {
tier: label,
stem: "<manifest>".to_string(),
path: dir,
action: RepairAction::Failed(format!(
"could not lock the deploy manifest: {e} — refusing to repair this tier \
unserialised"
)),
}),
}
}
outcomes
}
fn repair_tier_locked(
reference: &SkillReference,
tier: &SkillDeployTier,
include_frozen: bool,
backup_root: &Path,
mode: RepairMode,
) -> Vec<RepairOutcome> {
let mut outcomes = Vec::new();
let audit = audit_deployed_skills(reference, &tier.dir);
if let ManifestState::Unreadable(why) = &audit.manifest {
outcomes.push(RepairOutcome {
tier: tier.label,
stem: "<manifest>".to_string(),
path: tier.dir.clone(),
action: RepairAction::SkippedUnverifiable(format!(
"{why} — refusing to touch this tier; repairing it would rewrite an ownership ledger that cannot be read"
)),
});
return outcomes;
}
let mut manifest = SkillManifest::load(&tier.dir);
let base = manifest.clone();
let mut manifest_dirty = false;
for finding in audit.findings {
let target = deployed_path(&tier.dir, &finding.stem);
let action = match finding.state {
SkillDrift::Fresh => continue,
SkillDrift::Unverifiable(why) => RepairAction::SkippedUnverifiable(why),
SkillDrift::DriftedFrozen if !include_frozen => RepairAction::SkippedFrozen,
SkillDrift::Drifted | SkillDrift::DriftedFrozen | SkillDrift::Missing => {
let Some(content) = reference.assets.get(&finding.stem) else {
outcomes.push(RepairOutcome {
tier: tier.label,
stem: finding.stem,
path: target,
action: RepairAction::Failed(
"no bundled asset available to write".to_string(),
),
});
continue;
};
if mode == RepairMode::DryRun {
RepairAction::WouldRepair {
existed: target.exists(),
}
} else {
match rewrite_and_verify(
&tier.dir,
tier.label,
&finding.stem,
content,
backup_root,
) {
Ok(backup) => {
manifest.managed.insert(
finding.stem.clone(),
SkillManifestEntry {
checksum: checksum(content),
deployed_at: chrono::Utc::now().to_rfc3339(),
},
);
manifest_dirty = true;
RepairAction::Repaired { backup }
}
Err(e) => RepairAction::Failed(e),
}
}
}
};
outcomes.push(RepairOutcome {
tier: tier.label,
stem: finding.stem,
path: target,
action,
});
}
if manifest_dirty {
debug_assert_eq!(
mode,
RepairMode::Apply,
"a dry run must never mark the ledger dirty — it wrote nothing to record"
);
match manifest.save_merging(&tier.dir, &base) {
Ok(SkillManifestSave::Written | SkillManifestSave::OverwroteUnreadable) => {}
Ok(SkillManifestSave::Merged) => tracing::warn!(
tier = tier.label,
"the skill manifest changed during repair — a writer bypassed the \
ledger lock; its entries were merged rather than dropped"
),
Err(e) => outcomes.push(RepairOutcome {
tier: tier.label,
stem: "<manifest>".to_string(),
path: tier.dir.clone(),
action: RepairAction::Failed(format!("could not save the deploy manifest: {e}")),
}),
}
}
outcomes
}
fn rewrite_and_verify(
tier_dir: &Path,
tier_label: &str,
stem: &str,
content: &str,
backup_root: &Path,
) -> Result<Option<PathBuf>, String> {
let target = deployed_path(tier_dir, stem);
let backup = if target.exists() {
let rel = target
.strip_prefix(tier_dir)
.unwrap_or_else(|_| Path::new(stem));
let dest = backup_root.join(tier_label.replace(' ', "-")).join(rel);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("could not create backup dir {}: {e}", parent.display()))?;
}
std::fs::copy(&target, &dest).map_err(|e| {
format!(
"could not back up {} to {}: {e}",
target.display(),
dest.display()
)
})?;
Some(dest)
} else {
None
};
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("could not create {}: {e}", parent.display()))?;
}
atomic_write(&target, content)
.map_err(|e| format!("could not write {}: {e}", target.display()))?;
let observed = std::fs::read_to_string(&target)
.map_err(|e| format!("wrote {} but could not read it back: {e}", target.display()))?;
if observed != content {
return Err(format!(
"wrote {} but re-reading it returned different content — the repair did NOT take",
target.display()
));
}
Ok(backup)
}
pub fn backup_root_for(home: &Path, now: chrono::DateTime<chrono::Utc>) -> PathBuf {
home.join(".trusty-mpm").join(format!(
"backup-doctor-remediation-{}",
now.format("%Y%m%d-%H%M%S")
))
}
#[cfg(test)]
#[path = "skill_repair_tests.rs"]
mod tests;