use std::path::{Path, PathBuf};
use super::default::default_manifest;
use super::schema::{AgentSet, HarnessManifest};
pub const MANIFEST_FILE: &str = "manifest.toml";
#[derive(Debug, Clone, Default)]
pub struct ManifestSources {
pub project: Option<PathBuf>,
pub catalog: Option<PathBuf>,
pub framework_scope: Option<AgentSet>,
}
impl ManifestSources {
pub fn resolve(project_dir: &Path, catalog_root: &Path) -> Self {
Self {
project: Some(
crate::core::harness_root::framework_dir(project_dir).join(MANIFEST_FILE),
),
catalog: Some(
catalog_root
.join("repo")
.join(".claude")
.join(MANIFEST_FILE),
),
framework_scope: Some(super::framework::framework_agent_scope(project_dir)),
}
}
}
pub fn resolve_manifest(sources: &ManifestSources) -> HarnessManifest {
let mut manifest = default_manifest();
if let Some(scope) = &sources.framework_scope {
manifest = manifest.merge(HarnessManifest {
agents: Some(scope.clone()),
..HarnessManifest::default()
});
}
if let Some(path) = &sources.catalog
&& let Some(layer) = read_layer(path, "catalog")
{
manifest = manifest.merge(layer);
}
if let Some(path) = &sources.project {
match read_layer(path, "project") {
Some(layer) => manifest = manifest.merge(layer),
None => {
if let Some(legacy) = stranded_legacy_manifest(path) {
tracing::warn!(
"{} is no longer read (#4832 moved the project manifest layer); \
move it to {} for it to take effect",
legacy.display(),
path.display()
);
}
}
}
}
manifest
}
fn stranded_legacy_manifest(new_path: &Path) -> Option<PathBuf> {
if new_path.exists() {
return None;
}
let legacy = new_path
.parent()
.and_then(Path::parent)?
.join(MANIFEST_FILE);
legacy.is_file().then_some(legacy)
}
fn read_layer(path: &Path, label: &str) -> Option<HarnessManifest> {
match std::fs::read_to_string(path) {
Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
Err(err) => {
tracing::warn!(
"could not read {label} manifest at {}: {err}; skipping layer",
path.display()
);
None
}
Ok(raw) => match HarnessManifest::from_toml(&raw) {
Ok(m) => {
tracing::debug!("loaded {label} manifest layer from {}", path.display());
Some(m)
}
Err(err) => {
tracing::warn!(
"{label} manifest at {} is malformed: {err}; skipping layer",
path.display()
);
None
}
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn write(dir: &Path, name: &str, body: &str) -> PathBuf {
std::fs::create_dir_all(dir).unwrap();
let path = dir.join(name);
std::fs::write(&path, body).unwrap();
path
}
#[test]
fn manifest_sources_resolve_canonical_paths() {
let sources =
ManifestSources::resolve(Path::new("/proj"), Path::new("/home/.trusty-mpm/catalog"));
assert_eq!(
sources.project.unwrap(),
PathBuf::from("/proj/.trusty-mpm/framework/manifest.toml")
);
assert_eq!(
sources.catalog.unwrap(),
PathBuf::from("/home/.trusty-mpm/catalog/repo/.claude/manifest.toml")
);
}
#[test]
fn stranded_legacy_manifest_is_detected() {
let tmp = TempDir::new().unwrap();
let harness = tmp.path().join(".trusty-mpm");
let legacy = write(&harness, MANIFEST_FILE, "[agents]\n");
let new_path = harness.join("framework").join(MANIFEST_FILE);
assert_eq!(
stranded_legacy_manifest(&new_path),
Some(legacy),
"an unread legacy manifest must be detectable so it can be reported"
);
}
#[test]
fn stranded_legacy_manifest_is_silent_once_moved() {
let tmp = TempDir::new().unwrap();
let harness = tmp.path().join(".trusty-mpm");
write(&harness, MANIFEST_FILE, "[agents]\n");
let new_path = write(&harness.join("framework"), MANIFEST_FILE, "[agents]\n");
assert_eq!(stranded_legacy_manifest(&new_path), None);
}
#[test]
fn stranded_legacy_manifest_is_silent_when_there_never_was_one() {
let tmp = TempDir::new().unwrap();
let new_path = tmp
.path()
.join(".trusty-mpm")
.join("framework")
.join(MANIFEST_FILE);
assert_eq!(stranded_legacy_manifest(&new_path), None);
}
#[test]
fn resolve_ignores_a_user_level_manifest() {
let tmp = TempDir::new().unwrap();
let home = tmp.path().join("home");
write(
&home,
"manifest.toml",
"[style]\nactive = \"trusty-mpm-teacher\"\n",
);
let sources = ManifestSources::resolve(&tmp.path().join("proj"), &tmp.path().join("cat"));
let m = resolve_manifest(&sources);
assert_ne!(
m.style.and_then(|s| s.active),
Some("trusty-mpm-teacher".to_string()),
"a user-level manifest must not be read"
);
}
#[test]
fn resolve_uses_default_when_no_layers() {
let tmp = TempDir::new().unwrap();
let sources = ManifestSources {
project: Some(tmp.path().join("p").join("manifest.toml")),
catalog: Some(tmp.path().join("c").join("manifest.toml")),
framework_scope: None,
};
assert_eq!(resolve_manifest(&sources), default_manifest());
}
#[test]
fn resolve_project_wins() {
let tmp = TempDir::new().unwrap();
let catalog = write(
&tmp.path().join("c"),
"manifest.toml",
"[style]\nactive = \"trusty-mpm-research\"\n",
);
let project = write(
&tmp.path().join("p"),
"manifest.toml",
"[style]\nactive = \"trusty-mpm\"\n",
);
let sources = ManifestSources {
project: Some(project),
catalog: Some(catalog),
framework_scope: None,
};
let m = resolve_manifest(&sources);
assert_eq!(
m.style.and_then(|s| s.active),
Some("trusty-mpm".to_string()),
"project layer must win over catalog"
);
}
#[test]
fn resolve_catalog_over_default() {
let tmp = TempDir::new().unwrap();
let catalog = write(
&tmp.path().join("c"),
"manifest.toml",
"[mcp]\ntrusty_search = false\n",
);
let sources = ManifestSources {
project: None,
catalog: Some(catalog),
framework_scope: None,
};
let m = resolve_manifest(&sources);
let mcp = m.mcp.expect("mcp section present");
assert_eq!(mcp.trusty_search, Some(false), "catalog disabled search");
assert_eq!(
mcp.trusty_memory,
Some(true),
"field-level merge keeps the default's trusty_memory toggle"
);
}
#[test]
fn resolve_partial_mcp_preserves_other_toggle() {
let tmp = TempDir::new().unwrap();
let project = write(
&tmp.path().join("p"),
"manifest.toml",
"[mcp]\ntrusty_search = false\n",
);
let sources = ManifestSources {
project: Some(project),
catalog: None,
framework_scope: None,
};
let mcp = resolve_manifest(&sources).mcp.expect("mcp present");
assert_eq!(mcp.trusty_search, Some(false));
assert_eq!(
mcp.trusty_memory,
Some(true),
"project partial [mcp] must not reset the default's trusty_memory"
);
}
#[test]
fn resolve_malformed_layer_is_skipped() {
let tmp = TempDir::new().unwrap();
let catalog = write(
&tmp.path().join("c"),
"manifest.toml",
"this is not toml {{{",
);
let sources = ManifestSources {
project: None,
catalog: Some(catalog),
framework_scope: None,
};
assert_eq!(resolve_manifest(&sources), default_manifest());
}
fn selected(m: &HarnessManifest, stem: &str) -> bool {
let agents = m.agents.as_ref().expect("agents section present");
super::super::schema::selection_matches(stem, &agents.include, &agents.exclude)
}
#[test]
fn resolve_scopes_agents_for_rust_project() {
let proj = TempDir::new().unwrap();
std::fs::write(proj.path().join("Cargo.toml"), "[package]\n").unwrap();
let catalog = TempDir::new().unwrap();
let sources = ManifestSources::resolve(proj.path(), catalog.path());
assert!(
sources.framework_scope.is_some(),
"resolve always applies the framework tier"
);
let m = resolve_manifest(&sources);
assert!(selected(&m, "rust-engineer"), "rust-engineer must survive");
assert!(
!selected(&m, "python-engineer"),
"python-engineer must be dropped from a Rust-only project"
);
assert!(selected(&m, "qa"), "universal agents are never stack-gated");
assert!(
!selected(&m, "elixir-engineer") && !selected(&m, "phoenix-engineer"),
"a Rust project gets neither the Elixir nor the Phoenix engineer"
);
assert!(
!selected(&m, "vercel-ops"),
"a project with no Vercel marker gets no platform agent"
);
}
#[test]
fn resolve_unknown_project_keeps_every_stack_engineer() {
let proj = TempDir::new().unwrap();
std::fs::write(proj.path().join("README.md"), "# hi\n").unwrap();
let catalog = TempDir::new().unwrap();
let sources = ManifestSources::resolve(proj.path(), catalog.path());
let m = resolve_manifest(&sources);
for stem in [
"rust-engineer",
"python-engineer",
"elixir-engineer",
"react-engineer",
"phoenix-engineer",
"tauri-engineer",
"engineer",
"qa",
] {
assert!(
selected(&m, stem),
"{stem} must deploy to an unknown project"
);
}
assert!(
!selected(&m, "gcp-ops") && !selected(&m, "vercel-ops"),
"platform has no unknown-project fallback"
);
}
#[test]
fn resolve_selects_platform_agent_on_marker() {
let proj = TempDir::new().unwrap();
std::fs::write(proj.path().join("Cargo.toml"), "[package]\n").unwrap();
std::fs::write(proj.path().join("vercel.json"), "{}\n").unwrap();
let catalog = TempDir::new().unwrap();
let m = resolve_manifest(&ManifestSources::resolve(proj.path(), catalog.path()));
assert!(selected(&m, "vercel-ops"));
assert!(!selected(&m, "gcp-ops"));
}
#[test]
fn project_manifest_agents_override_still_wins() {
let tmp = TempDir::new().unwrap();
let proj_dir = tmp.path().join("p");
std::fs::write(tmp.path().join("Cargo.toml"), "[package]\n").ok();
let project = write(
&proj_dir.join(".trusty-mpm"),
"manifest.toml",
"[agents]\ninclude = [\"engineer\"]\n",
);
let sources = ManifestSources {
project: Some(project),
catalog: None,
framework_scope: Some(super::super::schema::AgentSet {
include: vec!["qa".to_string()],
..Default::default()
}),
};
let m = resolve_manifest(&sources);
assert!(selected(&m, "engineer"), "project include wins");
assert!(!selected(&m, "qa"), "framework tier is fully replaced");
}
}