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 user: Option<PathBuf>,
pub catalog: Option<PathBuf>,
pub language_scope: Option<AgentSet>,
}
impl ManifestSources {
pub fn resolve(project_dir: &Path, framework_root: &Path, catalog_root: &Path) -> Self {
Self {
project: Some(project_dir.join(".trusty-mpm").join(MANIFEST_FILE)),
user: Some(framework_root.join(MANIFEST_FILE)),
catalog: Some(
catalog_root
.join("repo")
.join(".claude")
.join(MANIFEST_FILE),
),
language_scope: super::project_lang::language_agent_scope(project_dir),
}
}
}
pub fn resolve_manifest(sources: &ManifestSources) -> HarnessManifest {
let mut manifest = default_manifest();
if let Some(scope) = &sources.language_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.user
&& let Some(layer) = read_layer(path, "user")
{
manifest = manifest.merge(layer);
}
if let Some(path) = &sources.project
&& let Some(layer) = read_layer(path, "project")
{
manifest = manifest.merge(layer);
}
manifest
}
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"),
Path::new("/home/.trusty-mpm/catalog"),
);
assert_eq!(
sources.project.unwrap(),
PathBuf::from("/proj/.trusty-mpm/manifest.toml")
);
assert_eq!(
sources.user.unwrap(),
PathBuf::from("/home/.trusty-mpm/manifest.toml")
);
assert_eq!(
sources.catalog.unwrap(),
PathBuf::from("/home/.trusty-mpm/catalog/repo/.claude/manifest.toml")
);
}
#[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")),
user: Some(tmp.path().join("u").join("manifest.toml")),
catalog: Some(tmp.path().join("c").join("manifest.toml")),
language_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 user = write(
&tmp.path().join("u"),
"manifest.toml",
"[style]\nactive = \"trusty-mpm-teacher\"\n",
);
let project = write(
&tmp.path().join("p"),
"manifest.toml",
"[style]\nactive = \"trusty-mpm\"\n",
);
let sources = ManifestSources {
project: Some(project),
user: Some(user),
catalog: Some(catalog),
language_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 user and catalog"
);
}
#[test]
fn resolve_user_over_catalog() {
let tmp = TempDir::new().unwrap();
let catalog = write(
&tmp.path().join("c"),
"manifest.toml",
"[style]\nactive = \"trusty-mpm-research\"\n",
);
let user = write(
&tmp.path().join("u"),
"manifest.toml",
"[style]\nactive = \"trusty-mpm-teacher\"\n",
);
let sources = ManifestSources {
project: Some(tmp.path().join("p").join("manifest.toml")), user: Some(user),
catalog: Some(catalog),
language_scope: None,
};
let m = resolve_manifest(&sources);
assert_eq!(
m.style.and_then(|s| s.active),
Some("trusty-mpm-teacher".to_string())
);
}
#[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,
user: None,
catalog: Some(catalog),
language_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),
user: None,
catalog: None,
language_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 user = write(
&tmp.path().join("u"),
"manifest.toml",
"this is not toml {{{",
);
let sources = ManifestSources {
project: None,
user: Some(user),
catalog: None,
language_scope: None,
};
assert_eq!(resolve_manifest(&sources), default_manifest());
}
#[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 fw = TempDir::new().unwrap();
let catalog = TempDir::new().unwrap();
let sources = ManifestSources::resolve(proj.path(), fw.path(), catalog.path());
assert!(
sources.language_scope.is_some(),
"a Cargo project must be language-scoped"
);
let m = resolve_manifest(&sources);
let agents = m.agents.expect("agents section present");
assert!(
!agents.exclude.contains(&"rust-engineer".to_string()),
"rust-engineer must survive scoping"
);
assert!(
agents.exclude.contains(&"python-engineer".to_string()),
"python-engineer must be excluded from a Rust-only project: {:?}",
agents.exclude
);
}
#[test]
fn resolve_unknown_project_deploys_everything() {
let proj = TempDir::new().unwrap();
std::fs::write(proj.path().join("README.md"), "# hi\n").unwrap();
let fw = TempDir::new().unwrap();
let catalog = TempDir::new().unwrap();
let sources = ManifestSources::resolve(proj.path(), fw.path(), catalog.path());
assert!(sources.language_scope.is_none());
let agents = resolve_manifest(&sources).agents.expect("agents present");
assert!(
agents.exclude.is_empty(),
"unknown project excludes nothing"
);
assert!(agents.include.is_empty(), "unknown project includes all");
}
}