use std::path::{Path, PathBuf};
use crate::core::agent_builder::{AgentBuildError, compose_agent, source_chain};
use crate::core::agent_deployer::is_agent_file;
use crate::core::agent_manifest::{
AgentManifest, ManifestEntry, ManifestError, ManifestLoad, Origin, atomic_write, checksum,
with_agent_manifest_lock,
};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ResetResult {
pub recomposed: Vec<String>,
pub adopted: Vec<String>,
pub backed_up: Vec<String>,
pub not_found: Vec<String>,
pub deselected: Vec<String>,
pub retracted: Vec<String>,
}
pub const RESET_BACKUP_SUFFIX: &str = ".bak-";
fn backup_path(target_path: &Path) -> PathBuf {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let name = target_path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "agent.md".to_string());
target_path.with_file_name(format!("{name}{RESET_BACKUP_SUFFIX}{ts}"))
}
pub fn reset_agents(
source_dir: &Path,
target_dir: &Path,
names: Option<&[String]>,
) -> Result<ResetResult, AgentBuildError> {
if !source_dir.is_dir() {
return Ok(ResetResult::default());
}
with_agent_manifest_lock(target_dir, || {
reset_agents_locked(source_dir, target_dir, names)
})
}
fn reset_agents_locked(
source_dir: &Path,
target_dir: &Path,
names: Option<&[String]>,
) -> Result<ResetResult, AgentBuildError> {
let mut result = ResetResult::default();
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 available = available_agent_names(source_dir)?;
let targets: Vec<String> = match names {
None => available.clone(),
Some(requested) => {
for n in requested {
if !available.contains(n) {
result.not_found.push(n.clone());
}
}
let mut filtered: Vec<String> = available
.iter()
.filter(|a| requested.contains(a))
.cloned()
.collect();
filtered.sort_unstable();
filtered
}
};
for name in targets {
let filename = format!("{name}.md");
let composed = compose_agent(&name, source_dir)?;
let target_path = target_dir.join(&filename);
let fresh_checksum = checksum(&composed);
let needs_backup = if target_path.exists() {
let current = std::fs::read_to_string(&target_path)?;
let current_checksum = checksum(¤t);
if current_checksum == fresh_checksum {
manifest.managed.insert(
filename.clone(),
ManifestEntry {
source_chain: source_chain(&name, source_dir)?,
checksum: fresh_checksum,
deployed_at: now.clone(),
origin: Origin::Bundled,
},
);
result.adopted.push(filename);
continue;
}
let old_checksum = manifest.managed.get(&filename).map(|e| e.checksum.clone());
old_checksum.as_deref() != Some(current_checksum.as_str())
} else {
false
};
if needs_backup {
let dest = backup_path(&target_path);
std::fs::copy(&target_path, &dest)?;
tracing::warn!(
original = %target_path.display(),
backup = %dest.display(),
"tm install --reset-agents: preserved diverged content before recompose"
);
result.backed_up.push(filename.clone());
}
std::fs::create_dir_all(target_dir)?;
atomic_write(&target_path, &composed).map_err(|e| match e {
ManifestError::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: fresh_checksum,
deployed_at: now.clone(),
origin: Origin::Bundled,
},
);
result.recomposed.push(filename);
}
manifest.save(target_dir).map_err(|e| match e {
ManifestError::Io(io) => AgentBuildError::Io(io),
other => AgentBuildError::FrontmatterParse(other.to_string()),
})?;
Ok(result)
}
fn available_agent_names(source_dir: &Path) -> std::io::Result<Vec<String>> {
let mut available: 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) {
available.push(name.trim_end_matches(".md").to_string());
}
}
available.sort_unstable();
Ok(available)
}
#[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 reset_writes_all_by_default() {
let src = TempDir::new().unwrap();
let tgt = TempDir::new().unwrap();
write_sources(src.path());
let result = reset_agents(src.path(), tgt.path(), None).unwrap();
assert_eq!(result.recomposed.len(), 2);
assert!(result.adopted.is_empty());
assert!(result.backed_up.is_empty());
assert!(result.not_found.is_empty());
let manifest = AgentManifest::load(tgt.path());
assert!(manifest.is_managed("engineer.md"));
assert!(manifest.is_managed("base-agent.md"));
}
#[test]
fn reset_filters_by_name() {
let src = TempDir::new().unwrap();
let tgt = TempDir::new().unwrap();
write_sources(src.path());
let result = reset_agents(src.path(), tgt.path(), Some(&["engineer".to_string()])).unwrap();
assert_eq!(result.recomposed, vec!["engineer.md".to_string()]);
assert!(!tgt.path().join("base-agent.md").exists());
}
#[test]
fn reset_reports_unknown_names() {
let src = TempDir::new().unwrap();
let tgt = TempDir::new().unwrap();
write_sources(src.path());
let result = reset_agents(
src.path(),
tgt.path(),
Some(&["engineer".to_string(), "nonexistent".to_string()]),
)
.unwrap();
assert_eq!(result.recomposed, vec!["engineer.md".to_string()]);
assert_eq!(result.not_found, vec!["nonexistent".to_string()]);
}
#[test]
fn reset_adopts_matching_untracked_file() {
let src = TempDir::new().unwrap();
let tgt = TempDir::new().unwrap();
write_sources(src.path());
let composed = compose_agent("engineer", src.path()).unwrap();
fs::write(tgt.path().join("engineer.md"), &composed).unwrap();
let before = fs::metadata(tgt.path().join("engineer.md"))
.unwrap()
.modified()
.unwrap();
let result = reset_agents(src.path(), tgt.path(), Some(&["engineer".to_string()])).unwrap();
assert_eq!(result.adopted, vec!["engineer.md".to_string()]);
assert!(result.recomposed.is_empty());
assert!(result.backed_up.is_empty());
let after = fs::metadata(tgt.path().join("engineer.md"))
.unwrap()
.modified()
.unwrap();
assert_eq!(before, after, "adopted file must not be rewritten");
let manifest = AgentManifest::load(tgt.path());
assert!(manifest.is_managed("engineer.md"));
}
#[test]
fn reset_backs_up_diverged_file() {
let src = TempDir::new().unwrap();
let tgt = TempDir::new().unwrap();
write_sources(src.path());
fs::write(
tgt.path().join("engineer.md"),
"USER OR STALE CONTENT — not the current bundle\n",
)
.unwrap();
let result = reset_agents(src.path(), tgt.path(), Some(&["engineer".to_string()])).unwrap();
assert_eq!(result.recomposed, vec!["engineer.md".to_string()]);
assert_eq!(result.backed_up, vec!["engineer.md".to_string()]);
let fresh = fs::read_to_string(tgt.path().join("engineer.md")).unwrap();
assert!(fresh.contains("Engineer content."));
let backups: Vec<_> = fs::read_dir(tgt.path())
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n.starts_with("engineer.md.bak-"))
.collect();
assert_eq!(backups.len(), 1, "expected exactly one backup file");
let backup_content = fs::read_to_string(tgt.path().join(&backups[0])).unwrap();
assert_eq!(
backup_content,
"USER OR STALE CONTENT — not the current bundle\n"
);
let manifest = AgentManifest::load(tgt.path());
assert!(manifest.is_managed("engineer.md"));
}
#[test]
fn reset_recomposes_without_backup_when_matching_manifest_checksum() {
let src = TempDir::new().unwrap();
let tgt = TempDir::new().unwrap();
write_sources(src.path());
crate::core::agent_deployer::deploy_agents(src.path(), tgt.path()).unwrap();
fs::write(
src.path().join("engineer.md"),
"---\nname: engineer\nrole: engineer\nextends: base-agent\nmodel: sonnet\n---\n\n# Engineer\n\nUPDATED engineer content.\n",
)
.unwrap();
let result = reset_agents(src.path(), tgt.path(), Some(&["engineer".to_string()])).unwrap();
assert_eq!(result.recomposed, vec!["engineer.md".to_string()]);
assert!(
result.backed_up.is_empty(),
"no backup expected for a routine bundle update"
);
let fresh = fs::read_to_string(tgt.path().join("engineer.md")).unwrap();
assert!(fresh.contains("UPDATED engineer content."));
}
#[test]
fn reset_missing_source_dir_is_empty_result() {
let tgt = TempDir::new().unwrap();
let result = reset_agents(
Path::new("/nonexistent/trusty-mpm/agents"),
tgt.path(),
None,
)
.unwrap();
assert_eq!(result, ResetResult::default());
}
}