use anyhow::{Context, Result, bail};
use clap::Parser;
use colored::Colorize;
use std::path::{Path, PathBuf};
use crate::cli::install::InstallCommand;
use crate::lockfile::LockFile;
const AGPM_MANAGED_ENTRIES: &str = "# AGPM managed entries";
const CCPM_MANAGED_ENTRIES: &str = "# CCPM managed entries";
const AGPM_MANAGED_END: &str = "# End of AGPM managed entries";
const CCPM_MANAGED_END: &str = "# End of CCPM managed entries";
pub(crate) const AGPM_MANAGED_PATHS: &str = "# AGPM managed paths";
pub(crate) const AGPM_MANAGED_PATHS_END: &str = "# End of AGPM managed paths";
const OLD_STYLE_PATHS: &[&str] = &[
"\"agents\"",
"\"commands\"",
"\"snippets\"",
"\"scripts\"",
"\"skills\"",
"\"agent\"", "\"command\"",
"\"snippet\"",
];
const COMMENTED_TOOLS_SECTION: &str = r#"# Tool type configurations (multi-tool support)
# Built-in defaults are applied automatically. Uncomment and modify to customize.
#
# [tools.claude-code]
# path = ".claude"
# resources = { agents = { path = "agents/agpm", flatten = true }, commands = { path = "commands/agpm", flatten = true }, hooks = { merge-target = ".claude/settings.local.json" }, mcp-servers = { merge-target = ".mcp.json" }, scripts = { path = "scripts/agpm", flatten = false }, skills = { path = "skills/agpm", flatten = false }, snippets = { path = "snippets/agpm", flatten = false } }
#
# [tools.opencode]
# enabled = false # Enable if you want to use OpenCode resources
# path = ".opencode"
# resources = { agents = { path = "agent/agpm", flatten = true }, commands = { path = "command/agpm", flatten = true }, mcp-servers = { merge-target = ".opencode/opencode.json" }, snippets = { path = "snippet/agpm", flatten = false } }
#
# [tools.agpm]
# path = ".agpm"
# resources = { snippets = { path = "snippets", flatten = false } }
"#;
#[derive(Debug, Default)]
pub struct OldFormatDetection {
pub old_resource_paths: Vec<PathBuf>,
pub has_managed_gitignore_section: bool,
pub has_old_tools_config: bool,
}
impl OldFormatDetection {
pub fn needs_migration(&self) -> bool {
!self.old_resource_paths.is_empty()
|| self.has_managed_gitignore_section
|| self.has_old_tools_config
}
}
fn detect_old_tools_config(project_dir: &Path) -> bool {
let manifest_path = project_dir.join("agpm.toml");
if !manifest_path.exists() {
return false;
}
let Ok(content) = std::fs::read_to_string(&manifest_path) else {
return false;
};
if !content.contains("[tools") {
return false;
}
for old_path in OLD_STYLE_PATHS {
let pattern = format!("path = {}", old_path);
if content.contains(&pattern) {
let migrated_pattern = format!("path = {}/agpm\"", &old_path[..old_path.len() - 1]);
if !content.contains(&migrated_pattern) {
return true;
}
}
}
false
}
fn is_new_format_path(path: &str) -> bool {
if path.contains("/agpm/") {
return true;
}
if path.starts_with(".agpm/") {
return true;
}
let merge_targets = [".claude/settings.local.json", ".mcp.json", ".opencode/opencode.json"];
if merge_targets.contains(&path) {
return true;
}
false
}
pub fn detect_old_format(project_dir: &Path) -> OldFormatDetection {
let mut detection = OldFormatDetection::default();
let lockfile_path = project_dir.join("agpm.lock");
if let Ok(lockfile) = LockFile::load(&lockfile_path) {
for resource in lockfile.all_resources() {
if !is_new_format_path(&resource.installed_at) {
let full_path = project_dir.join(&resource.installed_at);
if full_path.exists() {
detection.old_resource_paths.push(full_path);
}
}
}
}
let gitignore_path = project_dir.join(".gitignore");
if gitignore_path.exists() {
if let Ok(content) = std::fs::read_to_string(&gitignore_path) {
if content.contains(AGPM_MANAGED_ENTRIES) || content.contains(CCPM_MANAGED_ENTRIES) {
detection.has_managed_gitignore_section = true;
}
}
}
detection.has_old_tools_config = detect_old_tools_config(project_dir);
detection
}
pub async fn run_format_migration(project_dir: &Path) -> Result<()> {
let detection = detect_old_format(project_dir);
if !detection.needs_migration() {
println!("✅ {}", "No format migration needed - project already uses new format.".green());
return Ok(());
}
println!("📦 {}", "Migrating AGPM installation to new format...".cyan());
if !detection.old_resource_paths.is_empty() {
println!(
"\n Moving {} resources to agpm/ subdirectories:",
detection.old_resource_paths.len()
);
for old_path in &detection.old_resource_paths {
if let Some(new_path) = compute_new_path(old_path, project_dir) {
let old_rel = old_path.strip_prefix(project_dir).unwrap_or(old_path);
let new_rel = new_path.strip_prefix(project_dir).unwrap_or(&new_path);
println!(" {} → {}", old_rel.display(), new_rel.display());
if let Some(parent) = new_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::rename(old_path, &new_path)?;
cleanup_empty_dir_chain(old_path);
}
}
}
if detection.has_managed_gitignore_section {
println!("\n Updating .gitignore with new agpm/ subdirectory paths...");
replace_managed_gitignore_section(project_dir)?;
}
println!("\n Updating agpm.lock with new paths...");
update_lockfile_paths(project_dir)?;
if detection.has_old_tools_config {
println!("\n Updating tools configuration to use built-in defaults...");
replace_tools_section(project_dir)?;
}
println!("\n✅ {}", "Format migration complete!".green().bold());
println!(
"\n{} If Claude Code can't find installed resources, run {} in Claude Code",
"💡".cyan(),
"/config".bright_white()
);
println!(" and set {} to {}.", "Respect .gitignore in file picker".yellow(), "false".green());
Ok(())
}
fn compute_new_path(old_path: &Path, project_dir: &Path) -> Option<PathBuf> {
let relative = old_path.strip_prefix(project_dir).ok()?;
let components: Vec<_> = relative.components().collect();
for (i, component) in components.iter().enumerate() {
if let std::path::Component::Normal(name) = component {
let name_str = name.to_str()?;
if matches!(
name_str,
"agents" | "commands" | "snippets" | "scripts" | "agent" | "command" | "snippet"
) {
let mut new_path = project_dir.to_path_buf();
for (j, c) in components.iter().enumerate() {
new_path.push(c);
if j == i {
new_path.push("agpm");
}
}
return Some(new_path);
}
}
}
None
}
fn cleanup_empty_dir_chain(file_path: &Path) {
let mut current = file_path.parent();
while let Some(dir) = current {
if dir.ends_with(".claude") || dir.ends_with(".opencode") || dir.parent().is_none() {
break;
}
match std::fs::remove_dir(dir) {
Ok(()) => current = dir.parent(),
Err(_) => break, }
}
}
fn replace_managed_gitignore_section(project_dir: &Path) -> Result<()> {
let gitignore_path = project_dir.join(".gitignore");
let content = std::fs::read_to_string(&gitignore_path)?;
let mut new_lines = Vec::new();
let mut in_managed_section = false;
let mut replaced = false;
for line in content.lines() {
if line.contains(AGPM_MANAGED_ENTRIES) || line.contains(CCPM_MANAGED_ENTRIES) {
in_managed_section = true;
if !replaced {
new_lines.push(AGPM_MANAGED_PATHS);
new_lines.push(".claude/*/agpm/");
new_lines.push(".opencode/*/agpm/");
new_lines.push(".agpm/");
new_lines.push("agpm.private.toml");
new_lines.push("agpm.private.lock");
new_lines.push(AGPM_MANAGED_PATHS_END);
replaced = true;
}
continue;
}
if in_managed_section
&& (line.contains(AGPM_MANAGED_END) || line.contains(CCPM_MANAGED_END))
{
in_managed_section = false;
continue;
}
if !in_managed_section {
new_lines.push(line);
}
}
while new_lines.last().is_some_and(|l| l.is_empty()) {
new_lines.pop();
}
std::fs::write(&gitignore_path, new_lines.join("\n") + "\n")?;
Ok(())
}
fn update_lockfile_paths(project_dir: &Path) -> Result<()> {
let lockfile_path = project_dir.join("agpm.lock");
if !lockfile_path.exists() {
return Ok(());
}
let content = std::fs::read_to_string(&lockfile_path)?;
let final_content = migrate_installed_at_paths(&content);
std::fs::write(&lockfile_path, final_content)?;
Ok(())
}
fn migrate_installed_at_paths(content: &str) -> String {
let path_patterns = [
(".claude/agents/", ".claude/agents/agpm/"),
(".claude/commands/", ".claude/commands/agpm/"),
(".claude/snippets/", ".claude/snippets/agpm/"),
(".claude/scripts/", ".claude/scripts/agpm/"),
(".opencode/agent/", ".opencode/agent/agpm/"),
(".opencode/command/", ".opencode/command/agpm/"),
(".opencode/snippet/", ".opencode/snippet/agpm/"),
];
let mut result = content.to_string();
for (old_prefix, new_prefix) in path_patterns {
let old_pattern = format!("installed_at = \"{}", old_prefix);
let new_pattern = format!("installed_at = \"{}", new_prefix);
let already_migrated = format!("installed_at = \"{}agpm/", old_prefix);
result = result
.lines()
.map(|line| {
if line.contains(&old_pattern) && !line.contains(&already_migrated) {
line.replace(&old_pattern, &new_pattern)
} else {
line.to_string()
}
})
.collect::<Vec<_>>()
.join("\n");
}
result
}
fn replace_tools_section(project_dir: &Path) -> Result<()> {
let manifest_path = project_dir.join("agpm.toml");
if !manifest_path.exists() {
return Ok(());
}
let content = std::fs::read_to_string(&manifest_path)?;
let mut new_lines = Vec::new();
let mut in_tools_section = false;
let mut tools_section_replaced = false;
let lines: Vec<&str> = content.lines().collect();
for (i, line) in lines.iter().enumerate() {
let trimmed = line.trim();
if trimmed == "[tools]" || trimmed.starts_with("[tools.") {
if !in_tools_section {
if !tools_section_replaced {
if !new_lines.is_empty() && !new_lines.last().unwrap_or(&"").is_empty() {
new_lines.push("");
}
for comment_line in COMMENTED_TOOLS_SECTION.lines() {
new_lines.push(comment_line);
}
tools_section_replaced = true;
}
in_tools_section = true;
}
continue;
}
if trimmed.starts_with('[') && !trimmed.starts_with("[tools") {
in_tools_section = false;
}
if in_tools_section {
if i + 1 < lines.len() {
let next_trimmed = lines[i + 1].trim();
if next_trimmed.starts_with('[') && !next_trimmed.starts_with("[tools") {
in_tools_section = false;
}
}
continue;
}
new_lines.push(line);
}
let mut final_lines = Vec::new();
let mut consecutive_blanks = 0;
for line in new_lines {
if line.is_empty() {
consecutive_blanks += 1;
if consecutive_blanks <= 2 {
final_lines.push(line);
}
} else {
consecutive_blanks = 0;
final_lines.push(line);
}
}
while final_lines.last().is_some_and(|l| l.is_empty()) {
final_lines.pop();
}
std::fs::write(&manifest_path, final_lines.join("\n") + "\n")?;
Ok(())
}
#[derive(Parser, Debug)]
#[command(name = "migrate")]
pub struct MigrateCommand {
#[arg(short, long)]
path: Option<PathBuf>,
#[arg(long)]
dry_run: bool,
#[arg(long)]
skip_install: bool,
#[arg(long)]
format_only: bool,
}
impl MigrateCommand {
#[must_use]
pub fn new(path: Option<PathBuf>, dry_run: bool, skip_install: bool) -> Self {
Self {
path,
dry_run,
skip_install,
format_only: false,
}
}
pub async fn execute(self) -> Result<()> {
let dir = self.path.as_deref().unwrap_or_else(|| Path::new("."));
let dir = dir.canonicalize().context("Failed to resolve directory path")?;
let mut any_migration_performed = false;
if !self.format_only {
any_migration_performed |= self.run_ccpm_migration(&dir).await?;
}
let format_detection = detect_old_format(&dir);
if format_detection.needs_migration() {
println!("\n🔍 Checking for old-format AGPM installation...");
if !format_detection.old_resource_paths.is_empty() {
println!(
"\n Found {} resources at old paths:",
format_detection.old_resource_paths.len()
);
for path in &format_detection.old_resource_paths {
let rel = path.strip_prefix(&dir).unwrap_or(path);
println!(" • {}", rel.display());
}
}
if format_detection.has_managed_gitignore_section {
println!("\n Found AGPM/CCPM managed section in .gitignore");
}
if format_detection.has_old_tools_config {
println!("\n Found old-style [tools] configuration (without /agpm paths)");
}
if self.dry_run {
println!(
"\n{} (use without --dry-run to perform migration)",
"Format migration preview complete".yellow()
);
} else {
run_format_migration(&dir).await?;
any_migration_performed = true;
}
} else if !self.format_only {
println!("\n✅ {}", "Project already uses new agpm/ subdirectory format.".green());
}
if any_migration_performed && !self.skip_install && !self.dry_run {
println!("\n📦 {}", "Running installation to finalize artifact locations...".cyan());
let install_cmd = InstallCommand::new();
let manifest_path = dir.join("agpm.toml");
match install_cmd.execute_from_path(Some(&manifest_path)).await {
Ok(()) => {
println!("✅ {}", "Artifacts finalized in correct locations".green());
}
Err(e) => {
eprintln!("\n⚠️ {}", "Warning: Installation failed".yellow());
eprintln!(" {}", format!("Error: {}", e).yellow());
eprintln!(" {}", "You may need to run 'agpm install' manually".yellow());
}
}
}
if any_migration_performed && !self.dry_run {
println!(
"\n💡 Remember to:\n • Review the changes\n • Run {} to verify\n • Commit the changes to version control",
"agpm validate".cyan()
);
} else if !any_migration_performed {
println!("\n✅ {}", "No migrations needed - project is up to date.".green());
}
Ok(())
}
async fn run_ccpm_migration(&self, dir: &Path) -> Result<bool> {
println!("🔍 Checking for legacy CCPM files in: {}", dir.display());
let ccpm_toml = dir.join("ccpm.toml");
let ccpm_lock = dir.join("ccpm.lock");
let agpm_toml = dir.join("agpm.toml");
let agpm_lock = dir.join("agpm.lock");
let ccpm_toml_exists = ccpm_toml.exists();
let ccpm_lock_exists = ccpm_lock.exists();
let agpm_toml_exists = agpm_toml.exists();
let agpm_lock_exists = agpm_lock.exists();
if !ccpm_toml_exists && !ccpm_lock_exists {
println!("✅ {}", "No legacy CCPM files found.".green());
return Ok(false);
}
let mut conflicts = Vec::new();
if ccpm_toml_exists && agpm_toml_exists {
conflicts.push("agpm.toml already exists");
}
if ccpm_lock_exists && agpm_lock_exists {
conflicts.push("agpm.lock already exists");
}
if !conflicts.is_empty() {
bail!(
"Migration conflict: {}. Please resolve conflicts manually.",
conflicts.join(" and ")
);
}
println!("\n📦 CCPM files to migrate:");
if ccpm_toml_exists {
println!(" • ccpm.toml → agpm.toml");
}
if ccpm_lock_exists {
println!(" • ccpm.lock → agpm.lock");
}
if self.dry_run {
println!(
"\n{} (use without --dry-run to perform migration)",
"CCPM naming migration preview complete".yellow()
);
return Ok(false);
}
if ccpm_toml_exists {
std::fs::rename(&ccpm_toml, &agpm_toml)
.context("Failed to rename ccpm.toml to agpm.toml")?;
println!("✅ {}", "Renamed ccpm.toml → agpm.toml".green());
}
if ccpm_lock_exists {
std::fs::rename(&ccpm_lock, &agpm_lock)
.context("Failed to rename ccpm.lock to agpm.lock")?;
println!("✅ {}", "Renamed ccpm.lock → agpm.lock".green());
}
println!("\n🎉 {}", "CCPM naming migration completed successfully!".green().bold());
Ok(true)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[tokio::test]
async fn test_migrate_no_files() -> Result<()> {
let temp_dir = TempDir::new()?;
let cmd = MigrateCommand {
path: Some(temp_dir.path().to_path_buf()),
dry_run: false,
skip_install: true,
format_only: false,
};
cmd.execute().await?;
Ok(())
}
#[tokio::test]
async fn test_migrate_both_files() -> Result<()> {
let temp_dir = TempDir::new()?;
let ccpm_toml = temp_dir.path().join("ccpm.toml");
let ccpm_lock = temp_dir.path().join("ccpm.lock");
fs::write(&ccpm_toml, "[sources]\n")?;
fs::write(&ccpm_lock, "# lockfile\n")?;
let cmd = MigrateCommand {
path: Some(temp_dir.path().to_path_buf()),
dry_run: false,
skip_install: true,
format_only: false,
};
cmd.execute().await?;
assert!(!ccpm_toml.exists());
assert!(!ccpm_lock.exists());
assert!(temp_dir.path().join("agpm.toml").exists());
assert!(temp_dir.path().join("agpm.lock").exists());
Ok(())
}
#[tokio::test]
async fn test_migrate_dry_run() -> Result<()> {
let temp_dir = TempDir::new()?;
let ccpm_toml = temp_dir.path().join("ccpm.toml");
fs::write(&ccpm_toml, "[sources]\n")?;
let cmd = MigrateCommand {
path: Some(temp_dir.path().to_path_buf()),
dry_run: true,
skip_install: true,
format_only: false,
};
cmd.execute().await?;
assert!(ccpm_toml.exists());
assert!(!temp_dir.path().join("agpm.toml").exists());
Ok(())
}
#[tokio::test]
async fn test_migrate_conflict() -> Result<()> {
let temp_dir = TempDir::new()?;
let ccpm_toml = temp_dir.path().join("ccpm.toml");
let agpm_toml = temp_dir.path().join("agpm.toml");
fs::write(&ccpm_toml, "[sources]\n")?;
fs::write(&agpm_toml, "[sources]\n").unwrap();
let cmd = MigrateCommand {
path: Some(temp_dir.path().to_path_buf()),
dry_run: false,
skip_install: true,
format_only: false,
};
let result = cmd.execute().await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("conflict"));
Ok(())
}
#[tokio::test]
async fn test_migrate_only_toml() -> Result<()> {
let temp_dir = TempDir::new()?;
let ccpm_toml = temp_dir.path().join("ccpm.toml");
fs::write(&ccpm_toml, "[sources]\n")?;
let cmd = MigrateCommand {
path: Some(temp_dir.path().to_path_buf()),
dry_run: false,
skip_install: true,
format_only: false,
};
cmd.execute().await?;
assert!(!ccpm_toml.exists());
assert!(temp_dir.path().join("agpm.toml").exists());
Ok(())
}
#[tokio::test]
async fn test_migrate_only_lock() -> Result<()> {
let temp_dir = TempDir::new()?;
let ccpm_lock = temp_dir.path().join("ccpm.lock");
fs::write(&ccpm_lock, "# lockfile\n")?;
let cmd = MigrateCommand {
path: Some(temp_dir.path().to_path_buf()),
dry_run: false,
skip_install: true,
format_only: false,
};
cmd.execute().await?;
assert!(!ccpm_lock.exists());
assert!(temp_dir.path().join("agpm.lock").exists());
Ok(())
}
#[tokio::test]
async fn test_migrate_with_automatic_installation() -> Result<()> {
let temp_dir = TempDir::new()?;
let ccpm_toml = temp_dir.path().join("ccpm.toml");
fs::write(&ccpm_toml, "[sources]\n")?;
let cmd = MigrateCommand {
path: Some(temp_dir.path().to_path_buf()),
dry_run: false,
skip_install: false, format_only: false,
};
let result = cmd.execute().await;
assert!(result.is_ok(), "Migration with automatic installation should succeed");
assert!(!ccpm_toml.exists());
assert!(temp_dir.path().join("agpm.toml").exists());
assert!(temp_dir.path().join("agpm.lock").exists());
Ok(())
}
#[tokio::test]
async fn test_migrate_handles_installation_failure() -> Result<()> {
let temp_dir = TempDir::new()?;
let ccpm_toml = temp_dir.path().join("ccpm.toml");
fs::write(
&ccpm_toml,
"[sources]\ntest = \"https://github.com/nonexistent/repo.git\"\n\n\
[agents]\ntest-agent = { source = \"test\", path = \"agents/test.md\", version = \"v1.0.0\" }",
)?;
let cmd = MigrateCommand {
path: Some(temp_dir.path().to_path_buf()),
dry_run: false,
skip_install: false, format_only: false,
};
let result = cmd.execute().await;
assert!(result.is_ok(), "Migration should succeed even if installation fails");
assert!(!ccpm_toml.exists());
assert!(temp_dir.path().join("agpm.toml").exists());
Ok(())
}
#[tokio::test]
async fn test_detect_old_format_no_resources() -> Result<()> {
let temp_dir = TempDir::new()?;
let detection = detect_old_format(temp_dir.path());
assert!(!detection.needs_migration());
assert!(detection.old_resource_paths.is_empty());
assert!(!detection.has_managed_gitignore_section);
Ok(())
}
#[tokio::test]
async fn test_detect_old_format_with_managed_gitignore() -> Result<()> {
let temp_dir = TempDir::new()?;
let gitignore = temp_dir.path().join(".gitignore");
fs::write(
&gitignore,
"# AGPM managed entries - do not edit\nsome-entry\n# End of AGPM managed entries\n",
)?;
let detection = detect_old_format(temp_dir.path());
assert!(detection.needs_migration());
assert!(detection.has_managed_gitignore_section);
Ok(())
}
#[tokio::test]
async fn test_detect_old_format_with_old_resources() -> Result<()> {
let temp_dir = TempDir::new()?;
let agents_dir = temp_dir.path().join(".claude/agents");
fs::create_dir_all(&agents_dir)?;
fs::write(agents_dir.join("test.md"), "# Test Agent")?;
let lockfile = r#"version = 1
[[agents]]
name = "test"
source = "test"
path = "agents/test.md"
version = "v1.0.0"
resolved_commit = "abc123"
checksum = "sha256:abc"
context_checksum = "sha256:def"
installed_at = ".claude/agents/test.md"
dependencies = []
resource_type = "Agent"
tool = "claude-code"
"#;
fs::write(temp_dir.path().join("agpm.lock"), lockfile)?;
let detection = detect_old_format(temp_dir.path());
assert!(detection.needs_migration());
assert_eq!(detection.old_resource_paths.len(), 1);
Ok(())
}
#[tokio::test]
async fn test_detect_old_format_ignores_user_files() -> Result<()> {
let temp_dir = TempDir::new()?;
let agents_dir = temp_dir.path().join(".claude/agents");
fs::create_dir_all(&agents_dir)?;
fs::write(agents_dir.join("user-agent.md"), "# User Agent")?;
fs::write(temp_dir.path().join("agpm.lock"), "version = 1\n")?;
let detection = detect_old_format(temp_dir.path());
assert!(!detection.needs_migration());
assert!(detection.old_resource_paths.is_empty());
Ok(())
}
#[tokio::test]
async fn test_detect_old_format_with_extensionless_files() -> Result<()> {
let temp_dir = TempDir::new()?;
let agents_dir = temp_dir.path().join(".claude/agents");
fs::create_dir_all(&agents_dir)?;
fs::write(agents_dir.join("backend-engineer-rust"), "# Agent")?;
let lockfile = r#"version = 1
[[agents]]
name = "backend-engineer-rust"
source = "test"
path = "agents/backend-engineer-rust"
version = "v1.0.0"
resolved_commit = "abc123"
checksum = "sha256:abc"
context_checksum = "sha256:def"
installed_at = ".claude/agents/backend-engineer-rust"
dependencies = []
resource_type = "Agent"
tool = "claude-code"
"#;
fs::write(temp_dir.path().join("agpm.lock"), lockfile)?;
let detection = detect_old_format(temp_dir.path());
assert!(detection.needs_migration());
assert_eq!(detection.old_resource_paths.len(), 1);
Ok(())
}
#[tokio::test]
async fn test_detect_old_format_skips_new_format_paths() -> Result<()> {
let temp_dir = TempDir::new()?;
let agents_dir = temp_dir.path().join(".claude/agents/agpm");
fs::create_dir_all(&agents_dir)?;
fs::write(agents_dir.join("test.md"), "# Test Agent")?;
let lockfile = r#"version = 1
[[agents]]
name = "test"
source = "test"
path = "agents/test.md"
version = "v1.0.0"
resolved_commit = "abc123"
checksum = "sha256:abc"
context_checksum = "sha256:def"
installed_at = ".claude/agents/agpm/test.md"
dependencies = []
resource_type = "Agent"
tool = "claude-code"
"#;
fs::write(temp_dir.path().join("agpm.lock"), lockfile)?;
let detection = detect_old_format(temp_dir.path());
assert!(!detection.needs_migration());
assert!(detection.old_resource_paths.is_empty());
Ok(())
}
#[tokio::test]
async fn test_detect_old_format_skips_agpm_directory() -> Result<()> {
let temp_dir = TempDir::new()?;
let snippets_dir = temp_dir.path().join(".agpm/snippets/claude-code/mcp-servers");
fs::create_dir_all(&snippets_dir)?;
fs::write(snippets_dir.join("context7.json"), "{}")?;
let lockfile = r#"version = 1
[[snippets]]
name = "context7"
source = "test"
path = "snippets/context7.json"
version = "v1.0.0"
resolved_commit = "abc123"
checksum = "sha256:abc"
context_checksum = "sha256:def"
installed_at = ".agpm/snippets/claude-code/mcp-servers/context7.json"
dependencies = []
resource_type = "Snippet"
tool = "agpm"
"#;
fs::write(temp_dir.path().join("agpm.lock"), lockfile)?;
let detection = detect_old_format(temp_dir.path());
assert!(!detection.needs_migration());
assert!(detection.old_resource_paths.is_empty());
Ok(())
}
#[tokio::test]
async fn test_detect_old_format_skips_merge_targets() -> Result<()> {
let temp_dir = TempDir::new()?;
let claude_dir = temp_dir.path().join(".claude");
fs::create_dir_all(&claude_dir)?;
fs::write(claude_dir.join("settings.local.json"), "{}")?;
let lockfile = r#"version = 1
[[hooks]]
name = "test-hook"
source = "test"
path = "hooks/test.json"
version = "v1.0.0"
resolved_commit = "abc123"
checksum = "sha256:abc"
context_checksum = "sha256:def"
installed_at = ".claude/settings.local.json"
dependencies = []
resource_type = "Hook"
tool = "claude-code"
"#;
fs::write(temp_dir.path().join("agpm.lock"), lockfile)?;
let detection = detect_old_format(temp_dir.path());
assert!(!detection.needs_migration());
assert!(detection.old_resource_paths.is_empty());
Ok(())
}
#[tokio::test]
async fn test_format_migration_moves_resources() -> Result<()> {
let temp_dir = TempDir::new()?;
let agents_dir = temp_dir.path().join(".claude/agents");
fs::create_dir_all(&agents_dir)?;
fs::write(agents_dir.join("test.md"), "# Test Agent")?;
let lockfile = r#"version = 1
[[agents]]
name = "test"
source = "test"
path = "agents/test.md"
version = "v1.0.0"
resolved_commit = "abc123"
checksum = "sha256:abc"
context_checksum = "sha256:def"
installed_at = ".claude/agents/test.md"
dependencies = []
resource_type = "Agent"
tool = "claude-code"
"#;
fs::write(temp_dir.path().join("agpm.lock"), lockfile)?;
let gitignore = temp_dir.path().join(".gitignore");
fs::write(
&gitignore,
"user-entry\n# AGPM managed entries - do not edit\nold-entry\n# End of AGPM managed entries\n",
)?;
run_format_migration(temp_dir.path()).await?;
assert!(!agents_dir.join("test.md").exists());
assert!(agents_dir.join("agpm/test.md").exists());
let new_gitignore = fs::read_to_string(&gitignore)?;
assert!(new_gitignore.contains("user-entry"));
assert!(!new_gitignore.contains("AGPM managed entries - do not edit"));
assert!(new_gitignore.contains("# AGPM managed paths"));
assert!(new_gitignore.contains(".claude/*/agpm/"));
assert!(new_gitignore.contains("# End of AGPM managed paths"));
Ok(())
}
#[tokio::test]
async fn test_detect_old_tools_config_with_old_paths() -> Result<()> {
let temp_dir = TempDir::new()?;
let manifest = r#"[sources]
[tools.claude-code]
path = ".claude"
resources = { agents = { path = "agents", flatten = true } }
[agents]
"#;
fs::write(temp_dir.path().join("agpm.toml"), manifest)?;
let detection = detect_old_format(temp_dir.path());
assert!(detection.has_old_tools_config);
assert!(detection.needs_migration());
Ok(())
}
#[tokio::test]
async fn test_detect_old_tools_config_with_new_paths() -> Result<()> {
let temp_dir = TempDir::new()?;
let manifest = r#"[sources]
[tools.claude-code]
path = ".claude"
resources = { agents = { path = "agents/agpm", flatten = true } }
[agents]
"#;
fs::write(temp_dir.path().join("agpm.toml"), manifest)?;
let detection = detect_old_format(temp_dir.path());
assert!(!detection.has_old_tools_config);
Ok(())
}
#[tokio::test]
async fn test_detect_old_tools_config_no_tools_section() -> Result<()> {
let temp_dir = TempDir::new()?;
let manifest = r#"[sources]
[agents]
"#;
fs::write(temp_dir.path().join("agpm.toml"), manifest)?;
let detection = detect_old_format(temp_dir.path());
assert!(!detection.has_old_tools_config);
Ok(())
}
#[tokio::test]
async fn test_replace_tools_section() -> Result<()> {
let temp_dir = TempDir::new()?;
let manifest = r#"[sources]
community = "https://example.com/repo.git"
[tools]
[tools.claude-code]
path = ".claude"
resources = { agents = { path = "agents", flatten = true }, commands = { path = "commands", flatten = true } }
[tools.opencode]
enabled = false
path = ".opencode"
resources = { agents = { path = "agent", flatten = true } }
[tools.agpm]
path = ".agpm"
resources = { snippets = { path = "snippets", flatten = false } }
[agents]
my-agent = { source = "community", path = "agents/test.md" }
"#;
fs::write(temp_dir.path().join("agpm.toml"), manifest)?;
replace_tools_section(temp_dir.path())?;
let new_content = fs::read_to_string(temp_dir.path().join("agpm.toml"))?;
assert!(new_content.contains("# [tools.claude-code]"));
assert!(new_content.contains("# path = \".claude\""));
assert!(new_content.contains("agents/agpm"));
assert!(!new_content.contains("[tools.claude-code]\npath"));
assert!(!new_content.contains("path = \"agents\""));
assert!(new_content.contains("[sources]"));
assert!(new_content.contains("community"));
assert!(new_content.contains("[agents]"));
assert!(new_content.contains("my-agent"));
Ok(())
}
#[tokio::test]
async fn test_replace_tools_section_preserves_project() -> Result<()> {
let temp_dir = TempDir::new()?;
let manifest = r#"[sources]
[project]
language = "rust"
[tools.claude-code]
path = ".claude"
resources = { agents = { path = "agents" } }
[agents]
"#;
fs::write(temp_dir.path().join("agpm.toml"), manifest)?;
replace_tools_section(temp_dir.path())?;
let new_content = fs::read_to_string(temp_dir.path().join("agpm.toml"))?;
assert!(new_content.contains("[project]"));
assert!(new_content.contains("language = \"rust\""));
assert!(new_content.contains("# [tools.claude-code]"));
Ok(())
}
#[tokio::test]
async fn test_format_migration_includes_tools() -> Result<()> {
let temp_dir = TempDir::new()?;
let manifest = r#"[sources]
[tools.claude-code]
path = ".claude"
resources = { agents = { path = "agents", flatten = true } }
[agents]
"#;
fs::write(temp_dir.path().join("agpm.toml"), manifest)?;
run_format_migration(temp_dir.path()).await?;
let new_content = fs::read_to_string(temp_dir.path().join("agpm.toml"))?;
assert!(new_content.contains("# [tools.claude-code]"));
assert!(new_content.contains("# Built-in defaults are applied automatically"));
Ok(())
}
#[tokio::test]
async fn test_format_migration_cleans_empty_directories() -> Result<()> {
let temp_dir = TempDir::new()?;
let nested_dir = temp_dir.path().join(".claude/agents/subdir");
fs::create_dir_all(&nested_dir)?;
fs::write(nested_dir.join("test.md"), "# Test Agent")?;
let lockfile = r#"version = 1
[[agents]]
name = "subdir/test"
source = "test"
path = "agents/subdir/test.md"
version = "v1.0.0"
resolved_commit = "abc123"
checksum = "sha256:abc"
context_checksum = "sha256:def"
installed_at = ".claude/agents/subdir/test.md"
dependencies = []
resource_type = "Agent"
tool = "claude-code"
"#;
fs::write(temp_dir.path().join("agpm.lock"), lockfile)?;
run_format_migration(temp_dir.path()).await?;
let new_path = temp_dir.path().join(".claude/agents/agpm/subdir/test.md");
assert!(new_path.exists(), "Resource should be moved to agpm/ subdirectory");
let old_subdir = temp_dir.path().join(".claude/agents/subdir");
assert!(!old_subdir.exists(), "Empty subdir should be removed after migration");
let claude_dir = temp_dir.path().join(".claude");
assert!(claude_dir.exists(), "Tool root .claude should still exist");
Ok(())
}
#[tokio::test]
async fn test_format_migration_preserves_non_empty_directories() -> Result<()> {
let temp_dir = TempDir::new()?;
let agents_dir = temp_dir.path().join(".claude/agents");
fs::create_dir_all(&agents_dir)?;
fs::write(agents_dir.join("managed.md"), "# Managed Agent")?;
fs::write(agents_dir.join("user-file.md"), "# User Agent")?;
let lockfile = r#"version = 1
[[agents]]
name = "managed"
source = "test"
path = "agents/managed.md"
version = "v1.0.0"
resolved_commit = "abc123"
checksum = "sha256:abc"
context_checksum = "sha256:def"
installed_at = ".claude/agents/managed.md"
dependencies = []
resource_type = "Agent"
tool = "claude-code"
"#;
fs::write(temp_dir.path().join("agpm.lock"), lockfile)?;
run_format_migration(temp_dir.path()).await?;
let new_path = temp_dir.path().join(".claude/agents/agpm/managed.md");
assert!(new_path.exists(), "Managed resource should be moved");
assert!(agents_dir.exists(), "agents dir should be preserved (contains user file)");
assert!(agents_dir.join("user-file.md").exists(), "User file should remain untouched");
Ok(())
}
}