use std::collections::BTreeMap;
use std::path::PathBuf;
use super::schema::{ContentSource, CustomMcpServer, HarnessManifest, selection_matches};
use crate::core::paths::FrameworkPaths;
#[derive(Debug, Clone)]
pub struct HarnessPlan {
pub agent_source: PathBuf,
pub agent_include: Vec<String>,
pub agent_exclude: Vec<String>,
pub agent_ignore_staleness: Vec<String>,
pub skill_source: PathBuf,
pub skill_include: Vec<String>,
pub skill_exclude: Vec<String>,
pub inject_trusty_memory: bool,
pub inject_trusty_search: bool,
pub custom_mcp_servers: BTreeMap<String, CustomMcpServer>,
pub style: Option<String>,
}
impl HarnessPlan {
pub fn from_manifest(
manifest: &HarnessManifest,
fw: &FrameworkPaths,
catalog_root: &std::path::Path,
) -> Self {
let catalog_agents = catalog_root.join("repo").join(".claude").join("agents");
let catalog_skills = catalog_root.join("repo").join(".claude").join("skills");
let (agent_source, agent_include, agent_exclude, agent_ignore_staleness) =
match &manifest.agents {
Some(set) => {
let source = match set.source {
ContentSource::Bundled => fw.agent_source_dir(),
ContentSource::Catalog => catalog_agents,
};
(
source,
set.include.clone(),
set.exclude.clone(),
set.ignore_staleness.clone(),
)
}
None => (fw.agent_source_dir(), Vec::new(), Vec::new(), Vec::new()),
};
let (skill_source, skill_include, skill_exclude) = match &manifest.skills {
Some(set) => {
let source = match set.source {
ContentSource::Bundled => fw.skill_source_dir(),
ContentSource::Catalog => catalog_skills,
};
(source, set.include.clone(), set.exclude.clone())
}
None => (fw.skill_source_dir(), Vec::new(), Vec::new()),
};
let (inject_trusty_memory, inject_trusty_search, custom_mcp_servers) = match &manifest.mcp {
Some(mcp) => (
mcp.trusty_memory.unwrap_or(true),
mcp.trusty_search.unwrap_or(true),
mcp.custom.clone(),
),
None => (true, true, BTreeMap::new()),
};
let style = manifest.style.as_ref().and_then(|s| s.active.clone());
Self {
agent_source,
agent_include,
agent_exclude,
agent_ignore_staleness,
skill_source,
skill_include,
skill_exclude,
inject_trusty_memory,
inject_trusty_search,
custom_mcp_servers,
style,
}
}
pub fn agent_selected(&self, stem: &str) -> bool {
selection_matches(stem, &self.agent_include, &self.agent_exclude)
}
pub fn agent_staleness_ignored(&self, stem: &str) -> bool {
super::schema::matches_any(stem, &self.agent_ignore_staleness)
}
pub fn skill_selected(&self, stem: &str) -> bool {
selection_matches(stem, &self.skill_include, &self.skill_exclude)
}
}
#[cfg(test)]
mod tests {
use super::super::default::default_manifest;
use super::super::schema::{AgentSet, McpServers, SkillSet, StyleSelection};
use super::*;
#[test]
fn plan_default_uses_bundled_sources() {
let fw = FrameworkPaths::under("/base");
let plan = HarnessPlan::from_manifest(
&default_manifest(),
&fw,
std::path::Path::new("/home/.trusty-mpm/catalog"),
);
assert_eq!(plan.agent_source, fw.agent_source_dir());
assert_eq!(plan.skill_source, fw.skill_source_dir());
assert!(plan.agent_include.is_empty());
assert!(plan.agent_selected("anything"));
assert!(plan.skill_selected("anything"));
assert!(plan.inject_trusty_memory);
assert!(plan.inject_trusty_search);
assert_eq!(plan.style.as_deref(), Some("trusty-mpm"));
}
#[test]
fn plan_catalog_source_paths() {
let fw = FrameworkPaths::under("/base");
let manifest = HarnessManifest {
agents: Some(AgentSet {
include: vec![],
exclude: vec![],
source: ContentSource::Catalog,
..AgentSet::default()
}),
..default_manifest()
};
let plan = HarnessPlan::from_manifest(
&manifest,
&fw,
std::path::Path::new("/home/.trusty-mpm/catalog"),
);
assert_eq!(
plan.agent_source,
PathBuf::from("/home/.trusty-mpm/catalog/repo/.claude/agents")
);
assert_eq!(plan.skill_source, fw.skill_source_dir());
}
#[test]
fn plan_selection_filters() {
let fw = FrameworkPaths::under("/base");
let manifest = HarnessManifest {
agents: Some(AgentSet {
include: vec!["*-engineer".into()],
exclude: vec!["php-engineer".into()],
source: ContentSource::Bundled,
..AgentSet::default()
}),
skills: Some(SkillSet {
include: vec!["example-skill".into()],
exclude: vec![],
source: ContentSource::Bundled,
}),
..default_manifest()
};
let plan = HarnessPlan::from_manifest(&manifest, &fw, std::path::Path::new("/c"));
assert!(plan.agent_selected("rust-engineer"));
assert!(!plan.agent_selected("php-engineer"), "exclude wins");
assert!(!plan.agent_selected("qa"), "not in include");
assert!(plan.skill_selected("example-skill"));
assert!(!plan.skill_selected("tm-doctor"));
}
#[test]
fn plan_ignore_staleness_does_not_affect_agent_selected() {
let fw = FrameworkPaths::under("/base");
let manifest = HarnessManifest {
agents: Some(AgentSet {
include: vec![],
exclude: vec![],
ignore_staleness: vec!["engineer".into()],
source: ContentSource::Bundled,
}),
..default_manifest()
};
let plan = HarnessPlan::from_manifest(&manifest, &fw, std::path::Path::new("/c"));
assert!(
plan.agent_selected("engineer"),
"ignore_staleness must not remove an agent from the deploy roster"
);
assert!(plan.agent_staleness_ignored("engineer"));
assert!(
!plan.agent_staleness_ignored("qa"),
"unrelated agent unaffected"
);
}
#[test]
fn plan_mcp_toggles() {
let fw = FrameworkPaths::under("/base");
let manifest = HarnessManifest {
mcp: Some(McpServers {
trusty_memory: Some(false),
trusty_search: Some(true),
..McpServers::default()
}),
..default_manifest()
};
let plan = HarnessPlan::from_manifest(&manifest, &fw, std::path::Path::new("/c"));
assert!(!plan.inject_trusty_memory);
assert!(plan.inject_trusty_search);
}
#[test]
fn plan_custom_mcp_servers_propagate() {
use super::super::schema::CustomMcpServer;
let fw = FrameworkPaths::under("/base");
let manifest = HarnessManifest {
mcp: Some(McpServers {
custom: std::collections::BTreeMap::from([(
"duetto-memory".to_string(),
CustomMcpServer::Http {
url: "https://mcp-services.dev.duettosystems.com/memory/mcp".to_string(),
headers: std::collections::BTreeMap::new(),
},
)]),
..McpServers::default()
}),
..default_manifest()
};
let plan = HarnessPlan::from_manifest(&manifest, &fw, std::path::Path::new("/c"));
assert!(plan.custom_mcp_servers.contains_key("duetto-memory"));
let none_plan = HarnessPlan::from_manifest(
&HarnessManifest::default(),
&fw,
std::path::Path::new("/c"),
);
assert!(none_plan.custom_mcp_servers.is_empty());
}
#[test]
fn plan_style_default_lowest_precedence() {
let fw = FrameworkPaths::under("/base");
let manifest = HarnessManifest {
style: Some(StyleSelection {
active: Some("trusty-mpm-research".into()),
}),
..HarnessManifest::default()
};
let plan = HarnessPlan::from_manifest(&manifest, &fw, std::path::Path::new("/c"));
assert_eq!(plan.style.as_deref(), Some("trusty-mpm-research"));
let none_plan = HarnessPlan::from_manifest(
&HarnessManifest::default(),
&fw,
std::path::Path::new("/c"),
);
assert_eq!(none_plan.style, None);
}
}