use std::path::Path;
use crate::core::agent_builder::{AgentBuildError, compose_agent, source_chain};
use crate::core::agent_manifest::{
AgentManifest, ManifestEntry, ManifestLoad, Origin, atomic_write, checksum,
};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DeployResult {
pub deployed: Vec<String>,
pub skipped: Vec<String>,
pub unchanged: Vec<String>,
}
fn is_agent_file(name: &str) -> bool {
name.ends_with(".md")
}
pub fn deploy_agents(
source_dir: &Path,
target_dir: &Path,
) -> Result<DeployResult, AgentBuildError> {
let mut result = DeployResult::default();
if !source_dir.is_dir() {
return Ok(result);
}
let mut manifest = match AgentManifest::load_checked(target_dir) {
ManifestLoad::Ok(m) => m,
ManifestLoad::Corrupt(detail) => {
return Err(AgentBuildError::FrontmatterParse(format!(
"agent manifest is corrupt and cannot be safely loaded; \
run `tm repair deploy` to recover. Detail: {detail}"
)));
}
};
let now = chrono::Utc::now().to_rfc3339();
let mut names: Vec<String> = Vec::new();
for entry in std::fs::read_dir(source_dir)? {
let entry = entry?;
let file_name = entry.file_name();
let Some(name) = file_name.to_str() else {
continue;
};
if entry.file_type()?.is_file() && is_agent_file(name) {
names.push(name.trim_end_matches(".md").to_string());
}
}
names.sort_unstable();
for name in names {
let filename = format!("{name}.md");
let composed = compose_agent(&name, source_dir)?;
let target_path = target_dir.join(&filename);
if target_path.exists() {
if !manifest.is_managed(&filename) {
result.skipped.push(filename);
continue;
}
let current = std::fs::read_to_string(&target_path)?;
if manifest.checksum_matches(&filename, ¤t) {
if checksum(&composed) == checksum(¤t) {
result.unchanged.push(filename);
continue;
}
} else {
result.skipped.push(filename);
continue;
}
}
std::fs::create_dir_all(target_dir)?;
atomic_write(&target_path, &composed).map_err(|e| match e {
crate::core::error::Error::Io(io) => AgentBuildError::Io(io),
other => AgentBuildError::FrontmatterParse(other.to_string()),
})?;
manifest.managed.insert(
filename.clone(),
ManifestEntry {
source_chain: source_chain(&name, source_dir)?,
checksum: checksum(&composed),
deployed_at: now.clone(),
origin: Origin::Bundled,
},
);
result.deployed.push(filename);
}
manifest.save(target_dir).map_err(|e| match e {
crate::core::error::Error::Io(io) => AgentBuildError::Io(io),
other => AgentBuildError::FrontmatterParse(other.to_string()),
})?;
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn write_sources(dir: &Path) {
fs::write(
dir.join("base-agent.md"),
"---\nname: base-agent\nrole: base\n---\n\n# Base\n\nBase content.\n",
)
.unwrap();
fs::write(
dir.join("engineer.md"),
"---\nname: engineer\nrole: engineer\nextends: base-agent\nmodel: sonnet\n---\n\n# Engineer\n\nEngineer content.\n",
)
.unwrap();
}
#[test]
fn deploy_new_agent() {
let src = TempDir::new().unwrap();
let tgt = TempDir::new().unwrap();
write_sources(src.path());
let result = deploy_agents(src.path(), tgt.path()).unwrap();
assert_eq!(result.deployed.len(), 2);
assert!(result.deployed.contains(&"engineer.md".to_string()));
assert!(result.skipped.is_empty());
assert!(result.unchanged.is_empty());
let engineer = fs::read_to_string(tgt.path().join("engineer.md")).unwrap();
assert!(engineer.contains("Base content."));
assert!(engineer.contains("Engineer content."));
let manifest = AgentManifest::load(tgt.path());
assert!(manifest.is_managed("engineer.md"));
assert_eq!(
manifest.managed["engineer.md"].source_chain,
vec!["base-agent", "engineer"]
);
}
#[test]
fn deploy_skips_user_modified() {
let src = TempDir::new().unwrap();
let tgt = TempDir::new().unwrap();
write_sources(src.path());
deploy_agents(src.path(), tgt.path()).unwrap();
fs::write(
tgt.path().join("engineer.md"),
"---\nname: engineer\n---\n\nUSER HAND-EDIT\n",
)
.unwrap();
let result = deploy_agents(src.path(), tgt.path()).unwrap();
assert!(result.skipped.contains(&"engineer.md".to_string()));
assert!(!result.deployed.contains(&"engineer.md".to_string()));
let still = fs::read_to_string(tgt.path().join("engineer.md")).unwrap();
assert!(still.contains("USER HAND-EDIT"));
}
#[test]
fn deploy_unchanged_no_write() {
let src = TempDir::new().unwrap();
let tgt = TempDir::new().unwrap();
write_sources(src.path());
deploy_agents(src.path(), tgt.path()).unwrap();
let before = fs::metadata(tgt.path().join("engineer.md"))
.unwrap()
.modified()
.unwrap();
let result = deploy_agents(src.path(), tgt.path()).unwrap();
assert!(result.unchanged.contains(&"engineer.md".to_string()));
assert!(result.deployed.is_empty());
let after = fs::metadata(tgt.path().join("engineer.md"))
.unwrap()
.modified()
.unwrap();
assert_eq!(before, after, "unchanged file must not be rewritten");
}
#[test]
fn deploy_user_owned_skipped() {
let src = TempDir::new().unwrap();
let tgt = TempDir::new().unwrap();
write_sources(src.path());
fs::write(
tgt.path().join("engineer.md"),
"USER OWNED — not trusty-mpm's\n",
)
.unwrap();
let result = deploy_agents(src.path(), tgt.path()).unwrap();
assert!(result.skipped.contains(&"engineer.md".to_string()));
let content = fs::read_to_string(tgt.path().join("engineer.md")).unwrap();
assert_eq!(content, "USER OWNED — not trusty-mpm's\n");
assert!(result.deployed.contains(&"base-agent.md".to_string()));
}
#[test]
fn deploy_missing_source_dir_is_empty_result() {
let tgt = TempDir::new().unwrap();
let result =
deploy_agents(Path::new("/nonexistent/trusty-mpm/agents"), tgt.path()).unwrap();
assert_eq!(result, DeployResult::default());
}
#[test]
fn deploy_aborts_on_corrupt_manifest() {
let src = TempDir::new().unwrap();
let tgt = TempDir::new().unwrap();
write_sources(src.path());
fs::write(
tgt.path().join(crate::core::agent_manifest::MANIFEST_FILE),
b"not valid json{{{",
)
.unwrap();
let result = deploy_agents(src.path(), tgt.path());
assert!(
result.is_err(),
"corrupt manifest must cause an error, not a silent reset to empty"
);
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("corrupt") || err_msg.contains("repair"),
"error message must mention corruption and repair: {err_msg}"
);
}
#[test]
fn deploy_content_file_is_atomic() {
let src = TempDir::new().unwrap();
let tgt = TempDir::new().unwrap();
write_sources(src.path());
deploy_agents(src.path(), tgt.path()).unwrap();
for entry in fs::read_dir(tgt.path()).unwrap() {
let entry = entry.unwrap();
let name = entry.file_name();
let name_str = name.to_string_lossy();
assert!(
!name_str.ends_with(".tmp"),
"stale .tmp file found after deploy: {name_str}"
);
}
}
}