use std::path::Path;
use trusty_agents_common::agents::frontmatter::validate_frontmatter;
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),
AgentFrontmatterInvalid(String, String),
SkillManifestMissing,
SkillManifestCorrupt(String),
SkillMissing(String),
SettingsMissing,
SettingsMalformed(String),
OutputStyleKeyMissing,
OutputStyleUnknownId(String),
OutputStyleFileMissing(String),
HooksMissing,
ProjectHookGroupMissing(String, String),
ProjectHookGroupStale(String, String),
ProjectHookDiagnosticIncomplete(String),
}
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::AgentFrontmatterInvalid(name, detail) => format!(
"agent `{name}` is deployed but its frontmatter is not valid YAML: {detail} \
— run `tm install --reset-agents` (or delete the file and redeploy) to refresh it"
),
Self::SkillManifestMissing => format!(
"skill ownership manifest ({}) is missing",
skill_manifest::SKILL_MANIFEST_FILE
),
Self::SkillManifestCorrupt(detail) => format!("skill manifest is corrupt: {detail}"),
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(),
Self::ProjectHookGroupMissing(event, command) => format!(
"settings.json has no `{event}` hook group running `{command}` — this \
project's config asks for it; run `tm validate --repair` to add it"
),
Self::ProjectHookGroupStale(event, command) => format!(
"settings.json carries a `{event}` hook group running `{command}` that this \
project's config no longer asks for; run `tm validate --repair` to remove it"
),
Self::ProjectHookDiagnosticIncomplete(detail) => format!(
"the toggle-driven hook-group check could not run, so this project's hook set \
is UNVERIFIED: {detail} — install tm (`cargo install trusty-mpm`) so the hook \
commands can be resolved, then re-run `tm validate --repair`"
),
}
}
fn is_project_hook_group(&self) -> bool {
matches!(
self,
Self::ProjectHookGroupMissing(..)
| Self::ProjectHookGroupStale(..)
| Self::ProjectHookDiagnosticIncomplete(..)
)
}
}
#[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 {
validate_workspace_with_exe(fw, None)
}
pub fn validate_workspace_with_exe(
fw: &FrameworkPaths,
hook_exe: Option<&Path>,
) -> ValidationReport {
validate_workspace_with_probe(fw, &|fw, project_dir, settings| {
crate::core::session_launch::project_hook_group_gaps(fw, project_dir, settings, hook_exe)
})
}
type HookGroupProbe<'a> = &'a dyn Fn(
&FrameworkPaths,
&Path,
&serde_json::Value,
) -> Result<
crate::core::session_launch::ProjectHookGroupGaps,
crate::core::standalone::hooks::StableHookExeError,
>;
pub(crate) fn validate_workspace_with_probe(
fw: &FrameworkPaths,
probe: HookGroupProbe<'_>,
) -> ValidationReport {
let mut gaps = Vec::new();
validate_agents(fw, &mut gaps);
validate_skills(fw, &mut gaps);
validate_settings(fw, probe, &mut gaps);
ValidationReport { gaps }
}
fn validate_agents(fw: &FrameworkPaths, gaps: &mut Vec<DeploymentGap>) {
let target = fw.agent_deploy_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()) {
let path = target.join(format!("{name}.md"));
if !path.is_file() {
gaps.push(DeploymentGap::AgentMissing(name));
continue;
}
if let Ok(content) = std::fs::read_to_string(&path)
&& let Err(detail) = validate_frontmatter(&content)
{
gaps.push(DeploymentGap::AgentFrontmatterInvalid(name, detail));
}
}
}
fn validate_skills(fw: &FrameworkPaths, gaps: &mut Vec<DeploymentGap>) {
let target = fw.skill_deploy_dir();
let manifest_present = target.join(skill_manifest::SKILL_MANIFEST_FILE).is_file();
if !manifest_present {
gaps.push(DeploymentGap::SkillManifestMissing);
}
let manifest = match manifest_present.then(|| skill_manifest::SkillManifest::load(&target)) {
None => None,
Some(Ok(m)) => Some(m),
Some(Err(e)) => {
gaps.push(DeploymentGap::SkillManifestCorrupt(format!(
"{}: {e}",
target.join(skill_manifest::SKILL_MANIFEST_FILE).display()
)));
None
}
};
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,
probe: HookGroupProbe<'_>,
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);
return;
}
let project_dir = fw.claude_home_dir();
let group_gaps = match probe(fw, &project_dir, &value) {
Ok(group_gaps) => group_gaps,
Err(e) => {
gaps.push(DeploymentGap::ProjectHookDiagnosticIncomplete(
e.to_string(),
));
return;
}
};
for (event, command) in group_gaps.missing {
gaps.push(DeploymentGap::ProjectHookGroupMissing(event, command));
}
for (event, command) in group_gaps.stale {
gaps.push(DeploymentGap::ProjectHookGroupStale(event, command));
}
}
#[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 {
validate_and_repair_with_exe(fw, workspace, repo_url, None)
}
pub fn validate_and_repair_reusing_memory(
fw: &FrameworkPaths,
workspace: &Path,
repo_url: Option<&str>,
memory_reachable: Option<bool>,
) -> RepairOutcome {
repair_with(
fw,
workspace,
repo_url,
None,
memory_reachable,
dirs::home_dir().as_deref(),
)
}
pub fn validate_and_repair_under(
fw: &FrameworkPaths,
workspace: &Path,
repo_url: Option<&str>,
memory_reachable: Option<bool>,
home: Option<&Path>,
) -> RepairOutcome {
repair_with(fw, workspace, repo_url, None, memory_reachable, home)
}
pub fn validate_and_repair_with_exe(
fw: &FrameworkPaths,
workspace: &Path,
repo_url: Option<&str>,
hook_exe: Option<&Path>,
) -> RepairOutcome {
repair_with(
fw,
workspace,
repo_url,
hook_exe,
None,
dirs::home_dir().as_deref(),
)
}
fn repair_with(
fw: &FrameworkPaths,
workspace: &Path,
repo_url: Option<&str>,
hook_exe: Option<&Path>,
memory_reachable: Option<bool>,
home: Option<&Path>,
) -> RepairOutcome {
let before = validate_workspace_with_exe(fw, hook_exe);
if before.is_complete() {
return RepairOutcome {
after: before.clone(),
before,
repaired: false,
repair_error: None,
};
}
if before.gaps.iter().all(DeploymentGap::is_project_hook_group) {
let repair_error =
crate::core::session_launch::ensure_project_hooks_with(fw, workspace, hook_exe)
.err()
.map(|e| e.to_string());
let after = validate_workspace_with_exe(fw, hook_exe);
let repaired = repair_error.is_none() && after.is_complete();
return RepairOutcome {
before,
after,
repaired,
repair_error,
};
}
let repair_error = match crate::core::session_launch::prepare_session_for_repair_under(
fw,
workspace,
repo_url,
hook_exe,
memory_reachable,
home,
) {
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_with_exe(fw, hook_exe);
let repaired = repair_error.is_none() && after.is_complete();
RepairOutcome {
before,
after,
repaired,
repair_error,
}
}
#[cfg(test)]
#[path = "tests.rs"]
mod tests;