#![allow(dead_code)]
use agpm_cli::lockfile::LockFile;
use agpm_cli::utils::normalize_path_for_storage;
use anyhow::{Context, Result, bail};
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus, Stdio};
use tempfile::TempDir;
use tokio::fs;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio_retry::Retry;
use tokio_retry::strategy::ExponentialBackoff;
mod manifest_builder;
#[allow(unused_imports)] pub use manifest_builder::{
DependencyBuilder, ManifestBuilder, ResourceConfigBuilder, TargetConfigBuilder,
ToolConfigBuilder, ToolsConfigBuilder,
};
pub struct TestGit {
repo_path: PathBuf,
}
impl TestGit {
fn run_git_command(&self, args: &[&str], action: &str) -> Result<std::process::Output> {
let output = Command::new("git")
.args(args)
.current_dir(&self.repo_path)
.output()
.with_context(|| action.to_string())?;
if !output.status.success() {
bail!("{} failed: {}", action, String::from_utf8_lossy(&output.stderr));
}
Ok(output)
}
pub fn new(repo_path: impl Into<PathBuf>) -> Self {
Self {
repo_path: repo_path.into(),
}
}
pub fn init(&self) -> Result<()> {
self.run_git_command(&["init"], "Failed to initialize git repository")?;
Ok(())
}
pub fn init_bare(&self) -> Result<()> {
self.run_git_command(&["init", "--bare"], "Failed to initialize bare git repository")?;
Ok(())
}
pub fn config_user(&self) -> Result<()> {
self.run_git_command(
&["config", "user.email", "test@agpm.example"],
"Failed to configure git user email",
)?;
self.run_git_command(
&["config", "user.name", "Test User"],
"Failed to configure git user name",
)?;
Ok(())
}
pub fn add_all(&self) -> Result<()> {
self.run_git_command(&["add", "."], "Failed to add files to git")?;
Ok(())
}
pub fn commit(&self, message: &str) -> Result<()> {
self.run_git_command(
&["commit", "-m", message, "--allow-empty"],
"Failed to create git commit",
)?;
Ok(())
}
pub fn tag(&self, tag_name: &str) -> Result<()> {
self.run_git_command(&["tag", tag_name], &format!("Failed to create tag: {}", tag_name))?;
Ok(())
}
pub fn create_branch(&self, branch_name: &str) -> Result<()> {
self.run_git_command(
&["checkout", "-b", branch_name],
&format!("Failed to create branch: {}", branch_name),
)?;
Ok(())
}
pub fn checkout(&self, branch_name: &str) -> Result<()> {
self.run_git_command(
&["checkout", branch_name],
&format!("Failed to checkout branch: {}", branch_name),
)?;
Ok(())
}
pub fn ensure_branch(&self, branch_name: &str) -> Result<()> {
if self.checkout(branch_name).is_ok() {
return Ok(());
}
self.create_branch(branch_name)?;
Ok(())
}
pub fn set_head(&self, branch_name: &str) -> Result<()> {
self.run_git_command(
&["symbolic-ref", "HEAD", &format!("refs/heads/{}", branch_name)],
&format!("Failed to set HEAD to branch: {}", branch_name),
)?;
Ok(())
}
pub fn get_commit_hash(&self) -> Result<String> {
let output = self.run_git_command(&["rev-parse", "HEAD"], "Failed to get commit hash")?;
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn get_head_sha(&self) -> Result<String> {
self.get_commit_hash()
}
pub fn clone_to_bare(&self, target_path: &Path) -> Result<()> {
let output = Command::new("git")
.args([
"clone",
"--bare",
self.repo_path.to_str().unwrap(),
target_path.to_str().unwrap(),
])
.output()
.context("Failed to create bare repository")?;
if !output.status.success() {
bail!("Failed to create bare repository: {}", String::from_utf8_lossy(&output.stderr));
}
Ok(())
}
pub fn repo_path(&self) -> &Path {
&self.repo_path
}
pub fn status_porcelain(&self) -> Result<String> {
let output =
self.run_git_command(&["status", "--porcelain"], "Failed to get git status")?;
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
pub fn check_ignore(&self, path: &str) -> Result<bool> {
let output = Command::new("git")
.args(["check-ignore", path])
.current_dir(&self.repo_path)
.output()
.with_context(|| format!("Failed to run git check-ignore for {}", path))?;
Ok(output.status.success())
}
pub fn remote_add(&self, name: &str, url: &str) -> Result<()> {
self.run_git_command(
&["remote", "add", name, url],
&format!("Failed to add remote: {}", name),
)?;
Ok(())
}
pub fn fetch(&self) -> Result<()> {
self.run_git_command(&["fetch"], "Failed to fetch from remotes")?;
Ok(())
}
}
pub struct TestProject {
_temp_dir: TempDir, project_dir: PathBuf,
cache_dir: PathBuf,
sources_dir: PathBuf,
}
impl TestProject {
pub async fn new() -> Result<Self> {
let temp_dir = TempDir::new()?;
let project_dir = temp_dir.path().join("project");
let cache_dir = temp_dir.path().join(".agpm").join("cache");
let sources_dir = temp_dir.path().join("sources");
fs::create_dir_all(&project_dir).await?;
fs::create_dir_all(&cache_dir).await?;
fs::create_dir_all(&sources_dir).await?;
Ok(Self {
_temp_dir: temp_dir,
project_dir,
cache_dir,
sources_dir,
})
}
pub fn project_path(&self) -> &Path {
&self.project_dir
}
pub fn cache_path(&self) -> &Path {
&self.cache_dir
}
pub fn sources_path(&self) -> &Path {
&self.sources_dir
}
pub async fn write_manifest(&self, content: &str) -> Result<()> {
let manifest_path = self.project_dir.join("agpm.toml");
fs::write(&manifest_path, content)
.await
.with_context(|| format!("Failed to write manifest to {:?}", manifest_path))?;
Ok(())
}
pub async fn write_lockfile(&self, content: &str) -> Result<()> {
let lockfile_path = self.project_dir.join("agpm.lock");
fs::write(&lockfile_path, content)
.await
.with_context(|| format!("Failed to write lockfile to {:?}", lockfile_path))?;
Ok(())
}
pub async fn write_private_manifest(&self, content: &str) -> Result<()> {
let private_path = self.project_dir.join("agpm.private.toml");
fs::write(&private_path, content)
.await
.with_context(|| format!("Failed to write private manifest to {:?}", private_path))?;
Ok(())
}
pub async fn read_lockfile(&self) -> Result<String> {
let lockfile_path = self.project_dir.join("agpm.lock");
fs::read_to_string(&lockfile_path)
.await
.with_context(|| format!("Failed to read lockfile from {:?}", lockfile_path))
}
pub fn load_lockfile(&self) -> Result<LockFile> {
let lockfile_path = self.project_dir.join("agpm.lock");
LockFile::load(&lockfile_path)
}
pub async fn create_local_resource(&self, path: &str, content: &str) -> Result<()> {
let resource_path = self.project_dir.join(path);
if let Some(parent) = resource_path.parent() {
fs::create_dir_all(parent).await?;
}
fs::write(&resource_path, content).await?;
Ok(())
}
pub fn init_git_repo(&self) -> Result<TestGit> {
let git = TestGit::new(self.project_dir.clone());
git.init()?;
git.config_user()?;
Ok(git)
}
pub async fn create_source_repo(&self, name: &str) -> Result<TestSourceRepo> {
let source_dir = self.sources_dir.join(name);
fs::create_dir_all(&source_dir).await?;
let git = TestGit::new(&source_dir);
git.init()?;
git.config_user()?;
Ok(TestSourceRepo {
path: source_dir,
git,
})
}
pub async fn create_standard_v1_repo(&self, name: &str) -> Result<(TestSourceRepo, String)> {
let repo = self.create_source_repo(name).await?;
repo.create_standard_resources().await?;
repo.commit_all("Initial v1.0.0")?;
repo.tag_version("v1.0.0")?;
let url = repo.bare_file_url(self.sources_path()).await?;
Ok((repo, url))
}
pub fn run_agpm(&self, args: &[&str]) -> Result<CommandOutput> {
self.run_agpm_with_env(args, &[])
}
pub fn run_agpm_with_env(
&self,
args: &[&str],
env_vars: &[(&str, &str)],
) -> Result<CommandOutput> {
let agpm_binary = env!("CARGO_BIN_EXE_agpm");
let mut cmd = Command::new(agpm_binary);
cmd.args(args)
.current_dir(&self.project_dir)
.env("AGPM_CACHE_DIR", &self.cache_dir)
.env("NO_COLOR", "1");
for (key, value) in env_vars {
cmd.env(key, value);
}
let output = cmd.output().context("Failed to run agpm command")?;
Ok(CommandOutput {
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
success: output.status.success(),
code: output.status.code(),
})
}
pub async fn run_agpm_async(&self, args: &[&str]) -> Result<CommandOutput> {
self.run_agpm_async_with_env(
args,
&[("RUST_LOG", "agpm_cli::debug=debug,agpm_cli::cache=debug")],
)
.await
}
pub async fn run_agpm_async_with_env(
&self,
args: &[&str],
env_vars: &[(&str, &str)],
) -> Result<CommandOutput> {
let agpm_binary = env!("CARGO_BIN_EXE_agpm");
let mut cmd = tokio::process::Command::new(agpm_binary);
cmd.args(args)
.current_dir(&self.project_dir)
.env("AGPM_CACHE_DIR", &self.cache_dir)
.env("NO_COLOR", "1")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
for (key, value) in env_vars {
cmd.env(key, value);
}
let child = cmd.spawn().context("Failed to spawn agpm command")?;
let output = child.wait_with_output().await.context("Failed to wait for agpm command")?;
Ok(CommandOutput {
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
success: output.status.success(),
code: output.status.code(),
})
}
pub async fn run_agpm_async_streaming(
&self,
args: &[&str],
prefix: &str,
) -> Result<ExitStatus> {
self.run_agpm_async_streaming_with_env(args, prefix, &[]).await
}
pub async fn run_agpm_async_streaming_with_env(
&self,
args: &[&str],
prefix: &str,
env_vars: &[(&str, &str)],
) -> Result<ExitStatus> {
let agpm_binary = env!("CARGO_BIN_EXE_agpm");
let mut cmd = tokio::process::Command::new(agpm_binary);
cmd.args(args)
.current_dir(&self.project_dir)
.env("AGPM_CACHE_DIR", &self.cache_dir)
.env("NO_COLOR", "1")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
for (key, value) in env_vars {
cmd.env(key, value);
}
let mut child = cmd.spawn().context("Failed to spawn agpm command")?;
let stdout = child.stdout.take().expect("stdout piped");
let stderr = child.stderr.take().expect("stderr piped");
let prefix_out = prefix.to_string();
let prefix_err = prefix.to_string();
let stdout_task = tokio::spawn(async move {
let reader = BufReader::new(stdout);
let mut lines = reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
eprintln!("[{}:out] {}", prefix_out, line);
}
});
let stderr_task = tokio::spawn(async move {
let reader = BufReader::new(stderr);
let mut lines = reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
eprintln!("[{}:err] {}", prefix_err, line);
}
});
let status = child.wait().await.context("Failed to wait for agpm command")?;
let _ = stdout_task.await;
let _ = stderr_task.await;
Ok(status)
}
}
pub async fn run_agpm_streaming(
args: &[&str],
prefix: &str,
project_dir: &std::path::Path,
cache_dir: &std::path::Path,
) -> Result<ExitStatus> {
let agpm_binary = env!("CARGO_BIN_EXE_agpm");
let mut cmd = tokio::process::Command::new(agpm_binary);
cmd.args(args)
.current_dir(project_dir)
.env("AGPM_CACHE_DIR", cache_dir)
.env("NO_COLOR", "1")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child = cmd.spawn().context("Failed to spawn agpm command")?;
let stdout = child.stdout.take().expect("stdout piped");
let stderr = child.stderr.take().expect("stderr piped");
let prefix_out = prefix.to_string();
let prefix_err = prefix.to_string();
let stdout_task = tokio::spawn(async move {
let reader = BufReader::new(stdout);
let mut lines = reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
eprintln!("[{}:out] {}", prefix_out, line);
}
});
let stderr_task = tokio::spawn(async move {
let reader = BufReader::new(stderr);
let mut lines = reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
eprintln!("[{}:err] {}", prefix_err, line);
}
});
let status = child.wait().await.context("Failed to wait for agpm command")?;
let _ = stdout_task.await;
let _ = stderr_task.await;
Ok(status)
}
pub struct TestSourceRepo {
pub path: PathBuf,
pub git: TestGit,
}
impl TestSourceRepo {
pub async fn add_resource(&self, resource_type: &str, name: &str, content: &str) -> Result<()> {
let resource_dir = self.path.join(resource_type);
fs::create_dir_all(&resource_dir).await?;
let file_path = resource_dir.join(format!("{}.md", name));
if let Some(parent) = file_path.parent() {
fs::create_dir_all(parent).await?;
}
fs::write(&file_path, content).await?;
Ok(())
}
pub async fn create_skill(&self, name: &str, content: &str) -> Result<()> {
let skill_dir = self.path.join("skills").join(name);
fs::create_dir_all(&skill_dir).await?;
let skill_md_path = skill_dir.join("SKILL.md");
fs::write(&skill_md_path, content).await?;
Ok(())
}
pub async fn create_file(&self, path: &str, content: &str) -> Result<()> {
let file_path = self.path.join(path);
if let Some(parent) = file_path.parent() {
fs::create_dir_all(parent).await?;
}
fs::write(&file_path, content).await?;
Ok(())
}
pub async fn create_standard_resources(&self) -> Result<()> {
self.add_resource("agents", "test-agent", "# Test Agent\n\nA test agent").await?;
self.add_resource("snippets", "test-snippet", "# Test Snippet\n\nA test snippet").await?;
self.add_resource("commands", "test-command", "# Test Command\n\nA test command").await?;
Ok(())
}
pub async fn add_sequential_resources(
&self,
resource_type: &str,
prefix: &str,
count: usize,
) -> Result<()> {
for i in 0..count {
let name = format!("{}-{:02}", prefix, i);
let title = prefix
.split('-')
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
}
})
.collect::<Vec<_>>()
.join(" ");
let content = format!("# {} {:02}\n\nTest {} {}", title, i, resource_type, i);
self.add_resource(resource_type, &name, &content).await?;
}
Ok(())
}
pub fn commit_all(&self, message: &str) -> Result<()> {
self.git.add_all()?;
self.git.commit(message)?;
Ok(())
}
pub fn tag_version(&self, version: &str) -> Result<()> {
self.git.tag(version)?;
Ok(())
}
pub fn create_multiple_tags(&self, count: usize) -> Result<()> {
for i in 1..=count {
let version = format!("v{}.0.0", i);
let content = format!("# Test Repository\n\nVersion {}", version);
let readme_path = self.path.join("README.md");
std::fs::write(&readme_path, content)?;
self.commit_all(&format!("Version {}", version))?;
self.tag_version(&version)?;
}
Ok(())
}
pub fn file_url(&self) -> String {
format!("file://{}", normalize_path_for_storage(&self.path))
}
pub async fn to_bare_repo(&self, target_path: &Path) -> Result<PathBuf> {
let output = Command::new("git")
.args(["clone", "--bare", self.path.to_str().unwrap(), target_path.to_str().unwrap()])
.output()
.context("Failed to create bare repository")?;
if !output.status.success() {
return Err(anyhow::anyhow!(
"Failed to create bare repository: {}",
String::from_utf8_lossy(&output.stderr)
));
}
let head_path = target_path.join("HEAD");
let strategy = ExponentialBackoff::from_millis(10)
.max_delay(std::time::Duration::from_millis(50))
.take(5);
let _ = Retry::spawn(strategy, || {
let path = head_path.clone();
async move { tokio::fs::read_to_string(&path).await.map_err(|e| e.to_string()) }
})
.await;
Ok(target_path.to_path_buf())
}
pub async fn bare_file_url(&self, sources_dir: &Path) -> Result<String> {
self.git.ensure_branch("main")?;
let bare_name =
format!("{}.git", self.path.file_name().and_then(|n| n.to_str()).unwrap_or("repo"));
let bare_path = sources_dir.join(bare_name);
self.to_bare_repo(&bare_path).await?;
Ok(format!("file://{}", normalize_path_for_storage(&bare_path)))
}
}
pub struct CommandOutput {
pub stdout: String,
pub stderr: String,
pub success: bool,
pub code: Option<i32>,
}
impl CommandOutput {
pub fn assert_success(&self) -> &Self {
assert!(self.success, "Command failed with code {:?}\nStderr: {}", self.code, self.stderr);
self
}
pub fn assert_stdout_contains(&self, text: &str) -> &Self {
assert!(
self.stdout.contains(text),
"Expected stdout to contain '{}'\nActual stdout: {}",
text,
self.stdout
);
self
}
}
pub struct FileAssert;
impl FileAssert {
pub async fn exists(path: impl AsRef<Path>) {
let path = path.as_ref();
let exists = fs::metadata(path).await.is_ok();
assert!(exists, "Expected file to exist: {}", path.display());
}
pub async fn not_exists(path: impl AsRef<Path>) {
let path = path.as_ref();
let exists = fs::metadata(path).await.is_ok();
assert!(!exists, "Expected file to not exist: {}", path.display());
}
pub async fn contains(path: impl AsRef<Path>, expected: &str) {
let path = path.as_ref();
let content = fs::read_to_string(path)
.await
.unwrap_or_else(|e| panic!("Failed to read file {}: {}", path.display(), e));
assert!(
content.contains(expected),
"Expected file {} to contain '{}'\nActual content: {}",
path.display(),
expected,
content
);
}
pub async fn equals(path: impl AsRef<Path>, expected: &str) {
let path = path.as_ref();
let content = fs::read_to_string(path)
.await
.unwrap_or_else(|e| panic!("Failed to read file {}: {}", path.display(), e));
assert_eq!(content, expected, "File {} content mismatch", path.display());
}
}
pub struct DirAssert;
impl DirAssert {
pub async fn exists(path: impl AsRef<Path>) {
let path = path.as_ref();
let metadata = fs::metadata(path).await;
let is_dir = metadata.map(|m| m.is_dir()).unwrap_or(false);
assert!(is_dir, "Expected directory to exist: {}", path.display());
}
pub async fn contains_file(dir: impl AsRef<Path>, file_name: &str) {
let path = dir.as_ref().join(file_name);
let exists = fs::metadata(&path).await.is_ok();
assert!(
exists,
"Expected directory {} to contain file '{}'",
dir.as_ref().display(),
file_name
);
}
pub async fn is_empty(path: impl AsRef<Path>) {
let path = path.as_ref();
let mut read_dir = fs::read_dir(path)
.await
.unwrap_or_else(|e| panic!("Failed to read directory {}: {}", path.display(), e));
let mut count = 0;
while read_dir.next_entry().await.unwrap().is_some() {
count += 1;
}
assert_eq!(
count,
0,
"Expected directory {} to be empty, but it contains {} entries",
path.display(),
count
);
}
}