use crate::common::{ManifestBuilder, TestProject};
use anyhow::Result;
#[tokio::test]
async fn test_backtracking_oscillation_detection() -> Result<()> {
let project = TestProject::new().await?;
let source_repo = project.create_source_repo("source").await?;
source_repo.add_resource("snippets", "snippet-x", "# Snippet X v1.0.0").await?;
source_repo.commit_all("Snippet X v1.0.0")?;
source_repo.tag_version("x-v1.0.0")?;
source_repo.add_resource("snippets", "snippet-x", "# Snippet X v2.0.0 CHANGED").await?;
source_repo.commit_all("Snippet X v2.0.0")?;
source_repo.tag_version("x-v2.0.0")?;
let agent_a_v1 = r#"---
dependencies:
snippets:
- path: snippets/snippet-x.md
version: x-v1.0.0
---
# Agent A v1.0.0"#;
source_repo.add_resource("agents", "agent-a", agent_a_v1).await?;
source_repo.commit_all("Agent A v1.0.0")?;
source_repo.tag_version("a-v1.0.0")?;
let agent_a_v2 = r#"---
dependencies:
snippets:
- path: snippets/snippet-x.md
version: x-v2.0.0
---
# Agent A v2.0.0 CHANGED"#;
source_repo.add_resource("agents", "agent-a", agent_a_v2).await?;
source_repo.commit_all("Agent A v2.0.0")?;
source_repo.tag_version("a-v2.0.0")?;
let agent_b_v1 = r#"---
dependencies:
snippets:
- path: snippets/snippet-x.md
version: x-v2.0.0
---
# Agent B v1.0.0"#;
source_repo.add_resource("agents", "agent-b", agent_b_v1).await?;
source_repo.commit_all("Agent B v1.0.0")?;
source_repo.tag_version("b-v1.0.0")?;
let agent_b_v2 = r#"---
dependencies:
snippets:
- path: snippets/snippet-x.md
version: x-v1.0.0
---
# Agent B v2.0.0 CHANGED"#;
source_repo.add_resource("agents", "agent-b", agent_b_v2).await?;
source_repo.commit_all("Agent B v2.0.0")?;
source_repo.tag_version("b-v2.0.0")?;
let command_d_v1 = r#"---
dependencies:
agents:
- path: agents/agent-a.md
version: a-v1.0.0
- path: agents/agent-b.md
version: b-v1.0.0
---
# Command D v1.0.0"#;
source_repo.add_resource("commands", "deploy", command_d_v1).await?;
source_repo.commit_all("Command D v1.0.0")?;
source_repo.tag_version("d-v1.0.0")?;
let command_d_v2 = r#"---
dependencies:
agents:
- path: agents/agent-a.md
version: a-v2.0.0
- path: agents/agent-b.md
version: b-v2.0.0
---
# Command D v2.0.0 CHANGED"#;
source_repo.add_resource("commands", "deploy", command_d_v2).await?;
source_repo.commit_all("Command D v2.0.0")?;
source_repo.tag_version("d-v2.0.0")?;
let manifest = ManifestBuilder::new()
.add_source("source", &source_repo.bare_file_url(project.sources_path()).await?)
.add_command("deploy", |d| {
d.source("source").path("commands/deploy.md").version("d->=v1.0.0")
})
.build();
project.write_manifest(&manifest).await?;
let output = project.run_agpm_with_env(
&["install"],
&[("RUST_LOG", "fs::retry=warn,version_resolver=debug,agpm_cli::resolver=debug")],
)?;
assert!(output.success, "Install should succeed via backtracking. Stderr: {}", output.stderr);
let lockfile = project.read_lockfile().await?;
assert!(
lockfile.contains("d-v2.0.0"),
"Lockfile should contain d-v2.0.0 (highest matching version with deterministic prefix filtering)"
);
assert!(
lockfile.contains("snippets/snippet-x"),
"Lockfile should contain snippet-x (transitive dependency)"
);
assert!(
lockfile.contains("x-v1.0.0") || lockfile.contains("x-v2.0.0"),
"Lockfile should contain either x-v1.0.0 or x-v2.0.0 (both are valid backtracking resolutions)"
);
assert!(project.project_path().join(".claude/commands/agpm/deploy.md").exists());
assert!(project.project_path().join(".claude/agents/agpm/agent-a.md").exists());
assert!(project.project_path().join(".claude/agents/agpm/agent-b.md").exists());
assert!(project.project_path().join(".claude/snippets/agpm/snippet-x.md").exists());
assert!(
!output.stderr.contains("panic") && !output.stderr.contains("Error"),
"Should not have errors. Stderr: {}",
output.stderr
);
Ok(())
}
#[tokio::test]
async fn test_backtracking_multiple_simultaneous_conflicts() -> Result<()> {
let project = TestProject::new().await?;
let source_repo = project.create_source_repo("source").await?;
source_repo.add_resource("agents", "helper", "# Helper v1.0.0").await?;
source_repo.add_resource("snippets", "utils", "# Utils v1.0.0").await?;
source_repo.add_resource("commands", "deploy", "# Deploy v1.0.0").await?;
source_repo.commit_all("v1.0.0")?;
source_repo.tag_version("v1.0.0")?;
source_repo.add_resource("agents", "helper", "# Helper v2.0.0 CHANGED").await?;
source_repo.add_resource("snippets", "utils", "# Utils v2.0.0 CHANGED").await?;
source_repo.add_resource("commands", "deploy", "# Deploy v2.0.0 CHANGED").await?;
source_repo.commit_all("v2.0.0")?;
source_repo.tag_version("v2.0.0")?;
let manifest = ManifestBuilder::new()
.add_source("source", &source_repo.bare_file_url(project.sources_path()).await?)
.add_agent("helper-v1", |d| d.source("source").path("agents/helper.md").version("v1.0.0"))
.add_agent("helper-v2", |d| d.source("source").path("agents/helper.md").version("v2.0.0"))
.add_snippet("utils-v1", |d| d.source("source").path("snippets/utils.md").version("v1.0.0"))
.add_snippet("utils-v2", |d| d.source("source").path("snippets/utils.md").version("v2.0.0"))
.add_command("deploy-v1", |d| {
d.source("source").path("commands/deploy.md").version("v1.0.0")
})
.add_command("deploy-v2", |d| {
d.source("source").path("commands/deploy.md").version("v2.0.0")
})
.build();
project.write_manifest(&manifest).await?;
let output = project.run_agpm(&["install"])?;
assert!(
!output.success,
"Install should fail with multiple conflicts. Stderr: {}",
output.stderr
);
assert!(
output.stderr.contains("Version conflicts detected")
|| output.stderr.contains("automatic resolution failed")
|| output.stderr.contains("Unresolvable SHA conflicts detected"),
"Should report version conflicts. Stderr: {}",
output.stderr
);
assert!(
output.stderr.contains("helper"),
"Should mention helper conflict. Stderr: {}",
output.stderr
);
assert!(
output.stderr.contains("utils"),
"Should mention utils conflict. Stderr: {}",
output.stderr
);
assert!(
output.stderr.contains("deploy"),
"Should mention deploy conflict. Stderr: {}",
output.stderr
);
assert!(!output.stderr.contains("panic"), "Should not panic. Stderr: {}", output.stderr);
Ok(())
}
#[tokio::test]
async fn test_backtracking_error_handling() -> Result<()> {
{
let project = TestProject::new().await?;
let manifest = ManifestBuilder::new()
.add_source("nonexistent", "file:///nonexistent/repo.git")
.add_agent("agent", |d| d.source("nonexistent").path("agents/agent.md"))
.build();
project.write_manifest(&manifest).await?;
let output = project.run_agpm(&["install"])?;
assert!(
!output.success,
"Should fail gracefully with missing source. Stderr: {}",
output.stderr
);
assert!(
output.stderr.contains("Failed")
|| output.stderr.contains("Error")
|| output.stderr.contains("not found"),
"Should have helpful error message. Stderr: {}",
output.stderr
);
assert!(!output.stderr.contains("panicked"), "Should not panic. Stderr: {}", output.stderr);
}
{
let project = TestProject::new().await?;
let source_repo = project.create_source_repo("source").await?;
source_repo.add_resource("agents", "agent-a", "# Agent A v1.0.0").await?;
source_repo.commit_all("v1.0.0")?;
source_repo.tag_version("v1.0.0")?;
source_repo.add_resource("agents", "agent-a", "# Agent A v2.0.0 CHANGED").await?;
source_repo.commit_all("v2.0.0")?;
source_repo.tag_version("v2.0.0")?;
let manifest = ManifestBuilder::new()
.add_source("source", &source_repo.bare_file_url(project.sources_path()).await?)
.add_agent("agent-v1", |d| {
d.source("source").path("agents/agent-a.md").version("v1.0.0")
})
.add_agent("agent-v2", |d| {
d.source("source").path("agents/agent-a.md").version("v2.0.0")
})
.build();
project.write_manifest(&manifest).await?;
let output = project.run_agpm(&["install"])?;
assert!(
!output.success,
"Should fail with incompatible versions. Stderr: {}",
output.stderr
);
assert!(
output.stderr.contains("no compatible version")
|| output.stderr.contains("Version conflicts detected")
|| output.stderr.contains("automatic resolution failed")
|| output.stderr.contains("Unresolvable SHA conflicts detected"),
"Should report inability to resolve. Stderr: {}",
output.stderr
);
assert!(
output.stderr.contains("agents/agent-a"),
"Should mention the conflicting resource. Stderr: {}",
output.stderr
);
assert!(!output.stderr.contains("panicked"), "Should not panic. Stderr: {}", output.stderr);
}
Ok(())
}
#[tokio::test]
async fn test_backtracking_deep_chain_conflict() -> Result<()> {
let project = TestProject::new().await?;
let source_repo = project.create_source_repo("source").await?;
source_repo.add_resource("snippets", "snippet-e", "# Snippet E v1.0.0").await?;
source_repo.commit_all("Snippet E v1.0.0")?;
source_repo.tag_version("e-v1.0.0")?;
source_repo.add_resource("snippets", "snippet-e", "# Snippet E v2.0.0 CHANGED").await?;
source_repo.commit_all("Snippet E v2.0.0")?;
source_repo.tag_version("e-v2.0.0")?;
let command_d_v1 = r#"---
dependencies:
snippets:
- path: snippets/snippet-e.md
version: e-v2.0.0
---
# Command D v1.0.0"#;
source_repo.add_resource("commands", "command-d", command_d_v1).await?;
source_repo.commit_all("Command D v1.0.0")?;
source_repo.tag_version("d-v1.0.0")?;
let command_d_v2 = r#"---
dependencies:
snippets:
- path: snippets/snippet-e.md
version: e-v1.0.0
---
# Command D v2.0.0 CHANGED"#;
source_repo.add_resource("commands", "command-d", command_d_v2).await?;
source_repo.commit_all("Command D v2.0.0")?;
source_repo.tag_version("d-v2.0.0")?;
let agent_c_v1 = r#"---
dependencies:
commands:
- path: commands/command-d.md
version: d-v1.0.0
---
# Agent C v1.0.0"#;
source_repo.add_resource("agents", "agent-c", agent_c_v1).await?;
source_repo.commit_all("Agent C v1.0.0")?;
source_repo.tag_version("c-v1.0.0")?;
let agent_b_v1 = r#"---
dependencies:
agents:
- path: agents/agent-c.md
version: c-v1.0.0
---
# Agent B v1.0.0"#;
source_repo.add_resource("agents", "agent-b", agent_b_v1).await?;
source_repo.commit_all("Agent B v1.0.0")?;
source_repo.tag_version("b-v1.0.0")?;
let agent_a_v1 = r#"---
dependencies:
agents:
- path: agents/agent-b.md
version: b-v1.0.0
---
# Agent A v1.0.0"#;
source_repo.add_resource("agents", "agent-a", agent_a_v1).await?;
source_repo.commit_all("Agent A v1.0.0")?;
source_repo.tag_version("a-v1.0.0")?;
let agent_a_v2 = r#"---
dependencies:
agents:
- path: agents/agent-b.md
version: b-v1.0.0
---
# Agent A v2.0.0 CHANGED"#;
source_repo.add_resource("agents", "agent-a", agent_a_v2).await?;
source_repo.commit_all("Agent A v2.0.0")?;
source_repo.tag_version("a-v2.0.0")?;
let manifest = ManifestBuilder::new()
.add_source("source", &source_repo.bare_file_url(project.sources_path()).await?)
.add_agent("agent-a", |d| d.source("source").path("agents/agent-a.md").version("a-^v1.0.0"))
.add_snippet("snippet-e", |d| {
d.source("source").path("snippets/snippet-e.md").version("e-v2.0.0")
})
.build();
project.write_manifest(&manifest).await?;
let output = project.run_agpm(&["install"])?;
let lockfile = project.read_lockfile().await?;
eprintln!("\n=== FULL LOCKFILE ===");
eprintln!("{}", lockfile);
assert!(output.success, "Install should succeed via backtracking. Stderr: {}", output.stderr);
assert!(
lockfile.contains("agents/agent-a") && lockfile.contains("a-v1.0.0"),
"Lockfile should contain agent-a at v1.0.0 (compatible path)"
);
assert!(
lockfile.contains("snippets/snippet-e") && lockfile.contains("e-v2.0.0"),
"Lockfile should contain snippet-e at v2.0.0 (no conflict)"
);
assert!(project.project_path().join(".claude/agents/agpm/agent-a.md").exists());
assert!(project.project_path().join(".claude/agents/agpm/agent-b.md").exists());
assert!(project.project_path().join(".claude/agents/agpm/agent-c.md").exists());
assert!(project.project_path().join(".claude/commands/agpm/command-d.md").exists());
assert!(project.project_path().join(".agpm/snippets/snippet-e.md").exists());
assert!(
!output.stderr.contains("panic") && !output.stderr.contains("Error"),
"Should not have errors. Stderr: {}",
output.stderr
);
Ok(())
}
#[tokio::test]
async fn test_backtracking_multiple_branch_conflicts() -> Result<()> {
let project = TestProject::new().await?;
let source_repo = project.create_source_repo("source").await?;
source_repo.add_resource("snippets", "snippet-x", "# Snippet X v1.0.0").await?;
source_repo.commit_all("Snippet X v1.0.0")?;
source_repo.tag_version("x-v1.0.0")?;
source_repo.add_resource("snippets", "snippet-x", "# Snippet X v2.0.0 CHANGED").await?;
source_repo.commit_all("Snippet X v2.0.0")?;
source_repo.tag_version("x-v2.0.0")?;
let command_d_v1 = r#"---
dependencies:
snippets:
- path: snippets/snippet-x.md
version: x-v1.0.0
---
# Command D v1.0.0"#;
source_repo.add_resource("commands", "command-d", command_d_v1).await?;
source_repo.commit_all("Command D v1.0.0")?;
source_repo.tag_version("d-v1.0.0")?;
let command_d_v2 = r#"---
dependencies:
snippets:
- path: snippets/snippet-x.md
version: x-v2.0.0
---
# Command D v2.0.0 CHANGED"#;
source_repo.add_resource("commands", "command-d", command_d_v2).await?;
source_repo.commit_all("Command D v2.0.0")?;
source_repo.tag_version("d-v2.0.0")?;
let command_e_v1 = r#"---
dependencies:
snippets:
- path: snippets/snippet-x.md
version: x-v1.0.0
---
# Command E v1.0.0"#;
source_repo.add_resource("commands", "command-e", command_e_v1).await?;
source_repo.commit_all("Command E v1.0.0")?;
source_repo.tag_version("e-v1.0.0")?;
let command_e_v2 = r#"---
dependencies:
snippets:
- path: snippets/snippet-x.md
version: x-v2.0.0
---
# Command E v2.0.0 CHANGED"#;
source_repo.add_resource("commands", "command-e", command_e_v2).await?;
source_repo.commit_all("Command E v2.0.0")?;
source_repo.tag_version("e-v2.0.0")?;
let agent_b_v1 = r#"---
dependencies:
commands:
- path: commands/command-d.md
version: d-v1.0.0
---
# Agent B v1.0.0"#;
source_repo.add_resource("agents", "agent-b", agent_b_v1).await?;
source_repo.commit_all("Agent B v1.0.0")?;
source_repo.tag_version("b-v1.0.0")?;
let agent_b_v2 = r#"---
dependencies:
commands:
- path: commands/command-d.md
version: d-v2.0.0
---
# Agent B v2.0.0 CHANGED"#;
source_repo.add_resource("agents", "agent-b", agent_b_v2).await?;
source_repo.commit_all("Agent B v2.0.0")?;
source_repo.tag_version("b-v2.0.0")?;
let agent_c_v1 = r#"---
dependencies:
commands:
- path: commands/command-e.md
version: e-v1.0.0
---
# Agent C v1.0.0"#;
source_repo.add_resource("agents", "agent-c", agent_c_v1).await?;
source_repo.commit_all("Agent C v1.0.0")?;
source_repo.tag_version("c-v1.0.0")?;
let agent_c_v2 = r#"---
dependencies:
commands:
- path: commands/command-e.md
version: e-v2.0.0
---
# Agent C v2.0.0 CHANGED"#;
source_repo.add_resource("agents", "agent-c", agent_c_v2).await?;
source_repo.commit_all("Agent C v2.0.0")?;
source_repo.tag_version("c-v2.0.0")?;
let agent_a_v1 = r#"---
dependencies:
agents:
- path: agents/agent-b.md
version: b-v1.0.0
- path: agents/agent-c.md
version: c-v1.0.0
---
# Agent A v1.0.0"#;
source_repo.add_resource("agents", "agent-a", agent_a_v1).await?;
source_repo.commit_all("Agent A v1.0.0")?;
source_repo.tag_version("a-v1.0.0")?;
let agent_a_v2 = r#"---
dependencies:
agents:
- path: agents/agent-b.md
version: b-v2.0.0
- path: agents/agent-c.md
version: c-v2.0.0
---
# Agent A v2.0.0 CHANGED"#;
source_repo.add_resource("agents", "agent-a", agent_a_v2).await?;
source_repo.commit_all("Agent A v2.0.0")?;
source_repo.tag_version("a-v2.0.0")?;
let manifest = ManifestBuilder::new()
.add_source("source", &source_repo.bare_file_url(project.sources_path()).await?)
.add_agent("agent-a", |d| d.source("source").path("agents/agent-a.md").version("a-^v1.0.0"))
.add_snippet("snippet-x", |d| {
d.source("source").path("snippets/snippet-x.md").version("x-v1.0.0")
})
.build();
project.write_manifest(&manifest).await?;
let output = project.run_agpm(&["install"])?;
assert!(output.success, "Install should succeed via backtracking. Stderr: {}", output.stderr);
let lockfile = project.read_lockfile().await?;
assert!(
lockfile.contains("agents/agent-a") && lockfile.contains("a-v1.0.0"),
"Lockfile should contain agent-a at v1.0.0 (compatible path)"
);
assert!(
lockfile.contains("snippets/snippet-x") && lockfile.contains("x-v1.0.0"),
"Lockfile should contain snippet-x at v1.0.0 (no conflict)"
);
assert!(project.project_path().join(".claude/agents/agpm/agent-a.md").exists());
assert!(project.project_path().join(".claude/agents/agpm/agent-b.md").exists());
assert!(project.project_path().join(".claude/agents/agpm/agent-c.md").exists());
assert!(project.project_path().join(".claude/commands/agpm/command-d.md").exists());
assert!(project.project_path().join(".claude/commands/agpm/command-e.md").exists());
assert!(project.project_path().join(".agpm/snippets/snippet-x.md").exists());
assert!(
!output.stderr.contains("panic") && !output.stderr.contains("Error"),
"Should not have errors. Stderr: {}",
output.stderr
);
Ok(())
}
#[tokio::test]
async fn test_backtracking_circular_dependency_detection() -> Result<()> {
let project = TestProject::new().await?;
let source_repo = project.create_source_repo("source").await?;
source_repo
.add_resource(
"agents",
"agent-a",
r#"---
dependencies:
agents:
- path: ./agent-b.md
version: v1.0.0
---
# Agent A v1.0.0
Depends on Agent B
"#,
)
.await?;
source_repo
.add_resource(
"agents",
"agent-b",
r#"---
dependencies:
agents:
- path: ./agent-c.md
version: v1.0.0
---
# Agent B v1.0.0
Depends on Agent C
"#,
)
.await?;
source_repo
.add_resource(
"agents",
"agent-c",
r#"---
dependencies:
agents:
- path: ./agent-a.md
version: v1.0.0
---
# Agent C v1.0.0
Depends on Agent A (completes cycle)
"#,
)
.await?;
source_repo.commit_all("Add agents with circular dependencies")?;
source_repo.tag_version("v1.0.0")?;
let manifest = ManifestBuilder::new()
.add_source("source", &source_repo.bare_file_url(project.sources_path()).await?)
.add_agent("agent-a", |d| d.source("source").path("agents/agent-a.md").version("v1.0.0"))
.build();
project.write_manifest(&manifest).await?;
let output = project.run_agpm(&["install"])?;
assert!(
!output.success,
"Install should fail due to circular dependency. Stderr: {}",
output.stderr
);
assert!(
output.stderr.to_lowercase().contains("circular")
|| output.stderr.to_lowercase().contains("cycle")
|| output.stderr.to_lowercase().contains("dependency cycle"),
"Should report circular dependency. Stderr: {}",
output.stderr
);
assert!(!output.stderr.contains("panicked"), "Should not panic. Stderr: {}", output.stderr);
Ok(())
}
#[tokio::test]
async fn test_backtracking_complex_circular_dependency() -> Result<()> {
let project = TestProject::new().await?;
let source_repo = project.create_source_repo("source").await?;
let agent_a_v1 = r#"---
dependencies:
agents:
- path: ./agent-b.md
version: v1.0.0
---
# Agent A v1.0.0
Depends on Agent B
"#;
source_repo.add_resource("agents", "agent-a", agent_a_v1).await?;
source_repo.commit_all("Agent A v1.0.0")?;
source_repo.tag_version("a-v1.0.0")?;
let agent_b_v1 = r#"---
dependencies:
commands:
- path: commands/command-c.md
version: v1.0.0
---
# Agent B v1.0.0
Depends on Command C
"#;
source_repo.add_resource("agents", "agent-b", agent_b_v1).await?;
source_repo.commit_all("Agent B v1.0.0")?;
source_repo.tag_version("b-v1.0.0")?;
let command_c_v1 = r#"---
dependencies:
agents:
- path: agents/agent-d.md
version: v1.0.0
---
# Command C v1.0.0
Depends on Agent D
"#;
source_repo.add_resource("commands", "command-c", command_c_v1).await?;
let agent_d_v1 = r#"---
dependencies:
agents:
- path: agents/agent-a.md
version: v1.0.0
---
# Agent D v1.0.0
Depends on Agent A (completes 4-node cycle)
"#;
source_repo.add_resource("agents", "agent-d", agent_d_v1).await?;
source_repo.commit_all("Add resources with 4-node circular dependency")?;
source_repo.tag_version("v1.0.0")?;
let manifest = ManifestBuilder::new()
.add_source("source", &source_repo.bare_file_url(project.sources_path()).await?)
.add_agent("agent-a", |d| d.source("source").path("agents/agent-a.md").version("v1.0.0"))
.build();
project.write_manifest(&manifest).await?;
let output = project.run_agpm(&["install"])?;
assert!(
!output.success,
"Install should fail due to circular dependency. Stderr: {}",
output.stderr
);
assert!(
output.stderr.to_lowercase().contains("circular")
|| output.stderr.to_lowercase().contains("cycle")
|| output.stderr.to_lowercase().contains("dependency cycle"),
"Should report circular dependency. Stderr: {}",
output.stderr
);
assert!(!output.stderr.contains("panicked"), "Should not panic. Stderr: {}", output.stderr);
Ok(())
}