use std::path::Path;
use crate::core::agent_manifest::{self, AgentManifest, ManifestLoad};
use crate::core::bundle::OUTPUT_STYLES;
use crate::core::paths::FrameworkPaths;
use crate::core::skill_manifest;
mod expected_set;
use expected_set::{expected_agent_stems, expected_skill_stems};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DeploymentGap {
AgentManifestMissing,
AgentManifestCorrupt(String),
AgentMissing(String),
SkillManifestMissing,
SkillMissing(String),
SettingsMissing,
SettingsMalformed(String),
OutputStyleKeyMissing,
OutputStyleUnknownId(String),
OutputStyleFileMissing(String),
HooksMissing,
}
impl DeploymentGap {
pub fn describe(&self) -> String {
match self {
Self::AgentManifestMissing => format!(
"agent ownership manifest ({}) is missing",
agent_manifest::MANIFEST_FILE
),
Self::AgentManifestCorrupt(detail) => format!("agent manifest is corrupt: {detail}"),
Self::AgentMissing(name) => format!("agent `{name}` is not deployed"),
Self::SkillManifestMissing => format!(
"skill ownership manifest ({}) is missing",
skill_manifest::SKILL_MANIFEST_FILE
),
Self::SkillMissing(name) => format!("skill `{name}` is not deployed"),
Self::SettingsMissing => ".claude/settings.json is missing".to_string(),
Self::SettingsMalformed(detail) => {
format!(".claude/settings.json is not valid JSON: {detail}")
}
Self::OutputStyleKeyMissing => {
"settings.json has no outputStyle key configured".to_string()
}
Self::OutputStyleUnknownId(id) => {
format!("outputStyle {id:?} is not a known trusty-mpm style")
}
Self::OutputStyleFileMissing(id) => {
format!("outputStyle {id:?} has no deployed style file")
}
Self::HooksMissing => "settings.json has no hooks configured".to_string(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ValidationReport {
pub gaps: Vec<DeploymentGap>,
}
impl ValidationReport {
pub fn is_complete(&self) -> bool {
self.gaps.is_empty()
}
}
pub fn validate_workspace(fw: &FrameworkPaths) -> ValidationReport {
let mut gaps = Vec::new();
validate_agents(fw, &mut gaps);
validate_skills(fw, &mut gaps);
validate_settings(fw, &mut gaps);
ValidationReport { gaps }
}
fn validate_agents(fw: &FrameworkPaths, gaps: &mut Vec<DeploymentGap>) {
let target = fw.claude_agents_dir();
let manifest = match AgentManifest::load_checked(&target) {
ManifestLoad::Corrupt(detail) => {
gaps.push(DeploymentGap::AgentManifestCorrupt(detail));
None
}
ManifestLoad::Ok(m) => {
if !target.join(agent_manifest::MANIFEST_FILE).is_file() {
gaps.push(DeploymentGap::AgentManifestMissing);
}
Some(m)
}
};
for name in expected_agent_stems(fw, manifest.as_ref()) {
if !target.join(format!("{name}.md")).is_file() {
gaps.push(DeploymentGap::AgentMissing(name));
}
}
}
fn validate_skills(fw: &FrameworkPaths, gaps: &mut Vec<DeploymentGap>) {
let target = fw.claude_skills_dir();
let manifest_present = target.join(skill_manifest::SKILL_MANIFEST_FILE).is_file();
if !manifest_present {
gaps.push(DeploymentGap::SkillManifestMissing);
}
let manifest = manifest_present.then(|| skill_manifest::SkillManifest::load(&target));
for name in expected_skill_stems(fw, manifest.as_ref()) {
if !target.join(&name).join("SKILL.md").is_file() {
gaps.push(DeploymentGap::SkillMissing(name));
}
}
}
fn validate_settings(fw: &FrameworkPaths, gaps: &mut Vec<DeploymentGap>) {
let settings_path = fw.claude_home_dir().join(".claude").join("settings.json");
let text = match std::fs::read_to_string(&settings_path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
gaps.push(DeploymentGap::SettingsMissing);
return;
}
Err(e) => {
gaps.push(DeploymentGap::SettingsMalformed(e.to_string()));
return;
}
};
let value: serde_json::Value = match serde_json::from_str(&text) {
Ok(v) => v,
Err(e) => {
gaps.push(DeploymentGap::SettingsMalformed(e.to_string()));
return;
}
};
match value.get("outputStyle").and_then(|v| v.as_str()) {
None => gaps.push(DeploymentGap::OutputStyleKeyMissing),
Some(id) => match OUTPUT_STYLES.iter().find(|s| s.id == id) {
None => gaps.push(DeploymentGap::OutputStyleUnknownId(id.to_string())),
Some(style) => {
let style_path = fw
.claude_home_dir()
.join(".claude")
.join("output-styles")
.join(style.file_name);
let deployed = std::fs::metadata(&style_path)
.map(|m| m.len() > 0)
.unwrap_or(false);
if !deployed {
gaps.push(DeploymentGap::OutputStyleFileMissing(id.to_string()));
}
}
},
}
let hooks_present = value
.get("hooks")
.and_then(|h| h.as_object())
.is_some_and(|o| !o.is_empty());
if !hooks_present {
gaps.push(DeploymentGap::HooksMissing);
}
}
#[derive(Debug)]
pub struct RepairOutcome {
pub before: ValidationReport,
pub after: ValidationReport,
pub repaired: bool,
pub repair_error: Option<String>,
}
impl RepairOutcome {
pub fn is_complete(&self) -> bool {
self.after.is_complete()
}
}
pub fn validate_and_repair(
fw: &FrameworkPaths,
workspace: &Path,
repo_url: Option<&str>,
) -> RepairOutcome {
let before = validate_workspace(fw);
if before.is_complete() {
return RepairOutcome {
after: before.clone(),
before,
repaired: false,
repair_error: None,
};
}
let repair_error =
match crate::core::session_launch::prepare_session_with_repo_url(fw, workspace, repo_url) {
Ok(report) if !report.roster_errors.is_empty() => Some(report.roster_errors.join("; ")),
Ok(_) => None,
Err(e) => Some(e.to_string()),
};
let after = validate_workspace(fw);
RepairOutcome {
before,
after,
repaired: true,
repair_error,
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn hermetic_paths(base: &Path) -> FrameworkPaths {
let mut fw = FrameworkPaths::under(base);
fw.trusty_mpm_root = None;
fw
}
fn seed_agent_source(fw: &FrameworkPaths, names: &[&str]) {
std::fs::create_dir_all(&fw.agents).unwrap();
for name in names {
std::fs::write(
fw.agents.join(format!("{name}.md")),
format!("---\nname: {name}\ndescription: d\n---\n\nBody.\n"),
)
.unwrap();
}
}
fn seed_skill_source(fw: &FrameworkPaths, names: &[&str]) {
std::fs::create_dir_all(&fw.skills).unwrap();
for name in names {
std::fs::write(fw.skills.join(format!("{name}.md")), "skill body").unwrap();
}
}
fn write_settings(fw: &FrameworkPaths, json: &str) {
let claude_dir = fw.claude_home_dir().join(".claude");
std::fs::create_dir_all(&claude_dir).unwrap();
std::fs::write(claude_dir.join("settings.json"), json).unwrap();
}
fn deploy_style_file(fw: &FrameworkPaths) {
let style_dir = fw.claude_home_dir().join(".claude").join("output-styles");
std::fs::create_dir_all(&style_dir).unwrap();
let default = OUTPUT_STYLES[0];
std::fs::write(style_dir.join(default.file_name), default.content).unwrap();
}
fn sample_agent_entry(source_name: &str) -> crate::core::agent_manifest::ManifestEntry {
crate::core::agent_manifest::ManifestEntry {
source_chain: vec![source_name.to_string()],
checksum: agent_manifest::checksum("agent"),
deployed_at: "2026-01-01T00:00:00Z".to_string(),
origin: crate::core::agent_manifest::Origin::Bundled,
}
}
fn fully_provisioned(base: &Path) -> FrameworkPaths {
let fw = hermetic_paths(base);
seed_agent_source(&fw, &["engineer", "BASE-AGENT"]);
seed_skill_source(&fw, &["tm-doctor"]);
let agents_dir = fw.claude_agents_dir();
std::fs::create_dir_all(&agents_dir).unwrap();
std::fs::write(agents_dir.join("engineer.md"), "agent").unwrap();
std::fs::write(agents_dir.join("BASE-AGENT.md"), "base").unwrap();
AgentManifest::default().save(&agents_dir).unwrap();
let skills_dir = fw.claude_skills_dir();
std::fs::create_dir_all(skills_dir.join("tm-doctor")).unwrap();
std::fs::write(skills_dir.join("tm-doctor").join("SKILL.md"), "skill").unwrap();
crate::core::skill_manifest::SkillManifest::default()
.save(&skills_dir)
.unwrap();
deploy_style_file(&fw);
write_settings(
&fw,
r#"{"outputStyle": "trusty-mpm", "hooks": {"SessionStart": []}}"#,
);
fw
}
#[test]
fn validate_complete_workspace_has_no_gaps() {
let tmp = TempDir::new().unwrap();
let fw = fully_provisioned(tmp.path());
let report = validate_workspace(&fw);
assert!(
report.is_complete(),
"expected no gaps, got: {:?}",
report.gaps
);
}
#[test]
fn validate_filtered_but_manifest_matching_workspace_has_no_gaps() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path().join("workspace");
std::fs::create_dir_all(&workspace).unwrap();
let mut fw = FrameworkPaths::for_managed_project(tmp.path(), &workspace);
fw.trusty_mpm_root = None;
seed_agent_source(&fw, &["engineer", "rust-engineer"]);
seed_skill_source(&fw, &["tm-doctor"]);
let manifest_dir = workspace.join(".trusty-mpm");
std::fs::create_dir_all(&manifest_dir).unwrap();
std::fs::write(
manifest_dir.join("manifest.toml"),
"[agents]\nexclude = [\"engineer\"]\n",
)
.unwrap();
let agents_dir = fw.claude_agents_dir();
std::fs::create_dir_all(&agents_dir).unwrap();
std::fs::write(agents_dir.join("rust-engineer.md"), "agent").unwrap();
let mut agent_manifest = AgentManifest::default();
agent_manifest.managed.insert(
"rust-engineer.md".to_string(),
sample_agent_entry("rust-engineer"),
);
agent_manifest.save(&agents_dir).unwrap();
let skills_dir = fw.claude_skills_dir();
std::fs::create_dir_all(skills_dir.join("tm-doctor")).unwrap();
std::fs::write(skills_dir.join("tm-doctor").join("SKILL.md"), "skill").unwrap();
crate::core::skill_manifest::SkillManifest::default()
.save(&skills_dir)
.unwrap();
deploy_style_file(&fw);
write_settings(
&fw,
r#"{"outputStyle": "trusty-mpm", "hooks": {"SessionStart": []}}"#,
);
let report = validate_workspace(&fw);
assert!(
report.is_complete(),
"filtered-but-complete workspace must validate as complete, got: {:?}",
report.gaps
);
}
#[test]
fn validate_entry_missing_on_disk_but_in_manifest_is_still_a_gap() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path().join("workspace");
std::fs::create_dir_all(&workspace).unwrap();
let mut fw = FrameworkPaths::for_managed_project(tmp.path(), &workspace);
fw.trusty_mpm_root = None;
let agents_dir = fw.claude_agents_dir();
std::fs::create_dir_all(&agents_dir).unwrap();
std::fs::write(agents_dir.join("rust-engineer.md"), "agent").unwrap();
let mut agent_manifest = AgentManifest::default();
agent_manifest.managed.insert(
"rust-engineer.md".to_string(),
sample_agent_entry("rust-engineer"),
);
agent_manifest.managed.insert(
"python-engineer.md".to_string(),
sample_agent_entry("python-engineer"),
);
agent_manifest.save(&agents_dir).unwrap();
let report = validate_workspace(&fw);
assert!(
report
.gaps
.contains(&DeploymentGap::AgentMissing("python-engineer".to_string())),
"manifest-recorded entry missing on disk must still be reported, got: {:?}",
report.gaps
);
assert!(
!report
.gaps
.contains(&DeploymentGap::AgentMissing("rust-engineer".to_string())),
"the entry that IS on disk must not be falsely reported missing, got: {:?}",
report.gaps
);
}
#[test]
fn validate_missing_agent_manifest_is_a_gap() {
let tmp = TempDir::new().unwrap();
let fw = fully_provisioned(tmp.path());
std::fs::remove_file(fw.claude_agents_dir().join(agent_manifest::MANIFEST_FILE)).unwrap();
let report = validate_workspace(&fw);
assert!(report.gaps.contains(&DeploymentGap::AgentManifestMissing));
}
#[test]
fn validate_missing_agent_is_a_gap() {
let tmp = TempDir::new().unwrap();
let fw = fully_provisioned(tmp.path());
std::fs::remove_file(fw.claude_agents_dir().join("engineer.md")).unwrap();
let report = validate_workspace(&fw);
assert!(
report
.gaps
.contains(&DeploymentGap::AgentMissing("engineer".to_string()))
);
}
#[test]
fn validate_missing_skill_manifest_is_a_gap() {
let tmp = TempDir::new().unwrap();
let fw = fully_provisioned(tmp.path());
std::fs::remove_file(
fw.claude_skills_dir()
.join(skill_manifest::SKILL_MANIFEST_FILE),
)
.unwrap();
let report = validate_workspace(&fw);
assert!(report.gaps.contains(&DeploymentGap::SkillManifestMissing));
}
#[test]
fn validate_missing_skill_is_a_gap() {
let tmp = TempDir::new().unwrap();
let fw = fully_provisioned(tmp.path());
std::fs::remove_dir_all(fw.claude_skills_dir().join("tm-doctor")).unwrap();
let report = validate_workspace(&fw);
assert!(
report
.gaps
.contains(&DeploymentGap::SkillMissing("tm-doctor".to_string()))
);
}
#[test]
fn validate_settings_missing_is_a_gap() {
let tmp = TempDir::new().unwrap();
let fw = fully_provisioned(tmp.path());
std::fs::remove_file(fw.claude_home_dir().join(".claude").join("settings.json")).unwrap();
let report = validate_workspace(&fw);
assert!(report.gaps.contains(&DeploymentGap::SettingsMissing));
}
#[test]
fn validate_missing_output_style_key_is_a_gap() {
let tmp = TempDir::new().unwrap();
let fw = fully_provisioned(tmp.path());
write_settings(&fw, r#"{"hooks": {"SessionStart": []}}"#);
let report = validate_workspace(&fw);
assert!(report.gaps.contains(&DeploymentGap::OutputStyleKeyMissing));
}
#[test]
fn validate_unknown_output_style_id_is_a_gap() {
let tmp = TempDir::new().unwrap();
let fw = fully_provisioned(tmp.path());
write_settings(
&fw,
r#"{"outputStyle": "claude_mpm", "hooks": {"SessionStart": []}}"#,
);
let report = validate_workspace(&fw);
assert!(report.gaps.contains(&DeploymentGap::OutputStyleUnknownId(
"claude_mpm".to_string()
)));
}
#[test]
fn validate_output_style_file_missing_is_a_gap() {
let tmp = TempDir::new().unwrap();
let fw = fully_provisioned(tmp.path());
let style_dir = fw.claude_home_dir().join(".claude").join("output-styles");
std::fs::remove_dir_all(&style_dir).unwrap();
let report = validate_workspace(&fw);
assert!(report.gaps.contains(&DeploymentGap::OutputStyleFileMissing(
"trusty-mpm".to_string()
)));
}
#[test]
fn validate_missing_hooks_is_a_gap() {
let tmp = TempDir::new().unwrap();
let fw = fully_provisioned(tmp.path());
write_settings(&fw, r#"{"outputStyle": "trusty-mpm"}"#);
let report = validate_workspace(&fw);
assert!(report.gaps.contains(&DeploymentGap::HooksMissing));
}
#[test]
fn describe_is_non_empty_for_every_variant() {
let gaps = [
DeploymentGap::AgentManifestMissing,
DeploymentGap::AgentManifestCorrupt("bad".to_string()),
DeploymentGap::AgentMissing("engineer".to_string()),
DeploymentGap::SkillManifestMissing,
DeploymentGap::SkillMissing("tm-doctor".to_string()),
DeploymentGap::SettingsMissing,
DeploymentGap::SettingsMalformed("bad json".to_string()),
DeploymentGap::OutputStyleKeyMissing,
DeploymentGap::OutputStyleUnknownId("x".to_string()),
DeploymentGap::OutputStyleFileMissing("x".to_string()),
DeploymentGap::HooksMissing,
];
for gap in gaps {
assert!(!gap.describe().is_empty());
}
}
#[test]
fn repair_is_a_noop_on_already_complete_workspace() {
let tmp = TempDir::new().unwrap();
let fw = fully_provisioned(tmp.path());
let outcome = validate_and_repair(&fw, &fw.claude_home_dir(), None);
assert!(!outcome.repaired);
assert!(outcome.is_complete());
assert_eq!(outcome.before, outcome.after);
}
#[test]
fn repair_closes_gaps_on_incomplete_workspace() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path().join("workspace");
std::fs::create_dir_all(&workspace).unwrap();
let fw = FrameworkPaths::for_managed_project(tmp.path(), &workspace);
let mut fw = fw;
fw.trusty_mpm_root = None;
seed_agent_source(&fw, &["engineer"]);
seed_skill_source(&fw, &["tm-doctor"]);
let before = validate_workspace(&fw);
assert!(!before.is_complete(), "expected gaps before repair");
let outcome = validate_and_repair(&fw, &workspace, None);
assert!(outcome.repaired);
assert!(
outcome.is_complete(),
"expected repair to close every gap, remaining: {:?}",
outcome.after.gaps
);
}
}