use anyhow::Result;
use clap::{Args, Subcommand};
use colored::Colorize;
use crate::cache::Cache;
use crate::manifest::{Manifest, find_manifest_with_optional};
use std::path::PathBuf;
#[derive(Args)]
pub struct CacheCommand {
#[command(subcommand)]
command: Option<CacheSubcommands>,
}
#[derive(Subcommand)]
enum CacheSubcommands {
Clean {
#[arg(long)]
all: bool,
},
Info,
}
impl CacheCommand {
#[allow(dead_code)] pub async fn execute(self) -> Result<()> {
let cache = Cache::new()?;
self.execute_with_cache_and_manifest(cache, None).await
}
pub async fn execute_with_manifest_path(self, manifest_path: Option<PathBuf>) -> Result<()> {
let cache = Cache::new()?;
self.execute_with_cache_and_manifest(cache, manifest_path).await
}
#[allow(dead_code)] pub async fn execute_with_cache(self, cache: Cache) -> Result<()> {
self.execute_with_cache_and_manifest(cache, None).await
}
async fn execute_with_cache_and_manifest(
self,
cache: Cache,
manifest_path: Option<PathBuf>,
) -> Result<()> {
match self.command {
Some(CacheSubcommands::Clean {
all,
}) => {
if all {
self.clean_all(cache).await
} else {
self.clean_unused(cache, manifest_path).await
}
}
Some(CacheSubcommands::Info) | None => self.show_info(cache).await,
}
}
async fn clean_all(&self, cache: Cache) -> Result<()> {
println!("🗑️ Cleaning all cache...");
let cache_dir = cache.cache_dir();
if let Ok(removed) = crate::cache::lock::cleanup_stale_locks(cache_dir, 3600).await
&& removed > 0
{
println!(" Removed {removed} stale lock files");
}
cache.clear_all().await?;
println!("{}", "✅ Cache cleared successfully".green().bold());
Ok(())
}
async fn clean_unused(&self, cache: Cache, manifest_path: Option<PathBuf>) -> Result<()> {
println!("🔍 Scanning for unused cache entries...");
let active_sources = if let Ok(manifest_path) = find_manifest_with_optional(manifest_path) {
let manifest = Manifest::load(&manifest_path)?;
manifest.sources.keys().cloned().collect::<Vec<_>>()
} else {
println!("⚠️ No agpm.toml found. Use --all to clear entire cache.");
return Ok(());
};
let removed = cache.clean_unused(&active_sources).await?;
let cache_dir = cache.cache_dir();
let lock_removed =
crate::cache::lock::cleanup_stale_locks(cache_dir, 3600).await.unwrap_or(0);
if removed > 0 || lock_removed > 0 {
let mut messages = Vec::new();
if removed > 0 {
messages.push(format!("{removed} unused cache entries"));
}
if lock_removed > 0 {
messages.push(format!("{lock_removed} stale lock files"));
}
println!("{}", format!("✅ Removed {}", messages.join(" and ")).green().bold());
} else {
println!("✨ Cache is already clean - no unused entries found");
}
Ok(())
}
async fn show_info(&self, cache: Cache) -> Result<()> {
let location = cache.get_cache_location();
let size = cache.get_cache_size().await?;
println!("{}", "Cache Information".bold());
println!(" Location: {}", location.display());
println!(" Size: {}", format_size(size));
if location.exists() {
let mut entries = tokio::fs::read_dir(location).await?;
let mut repos = Vec::new();
while let Some(entry) = entries.next_entry().await? {
if entry.path().is_dir()
&& let Some(name) = entry.path().file_name()
{
repos.push(name.to_string_lossy().to_string());
}
}
if !repos.is_empty() {
println!("\n{}", "Cached repositories:".bold());
for repo in repos {
println!(" • {repo}");
}
}
}
println!("\n{}", "Tip:".yellow());
println!(" Use 'agpm cache clean' to remove unused cache");
println!(" Use 'agpm cache clean --all' to clear all cache");
Ok(())
}
}
fn format_size(bytes: u64) -> String {
const UNITS: &[&str] = &["B", "KB", "MB", "GB"];
if bytes == 0 {
return "0 B".to_string();
}
#[allow(clippy::cast_precision_loss)]
let mut size = bytes as f64;
let mut unit_index = 0;
while size >= 1024.0 && unit_index < UNITS.len() - 1 {
size /= 1024.0;
unit_index += 1;
}
if unit_index == 0 {
format!("{} {}", bytes, UNITS[unit_index])
} else {
format!("{:.2} {}", size, UNITS[unit_index])
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_size() {
assert_eq!(format_size(0), "0 B");
assert_eq!(format_size(512), "512 B");
assert_eq!(format_size(1024), "1.00 KB");
assert_eq!(format_size(1536), "1.50 KB");
assert_eq!(format_size(1048576), "1.00 MB");
assert_eq!(format_size(1073741824), "1.00 GB");
}
#[test]
fn test_format_size_edge_cases() {
assert_eq!(format_size(1023), "1023 B");
assert_eq!(format_size(1025), "1.00 KB");
assert_eq!(format_size(1048575), "1024.00 KB");
assert_eq!(format_size(1048577), "1.00 MB");
assert_eq!(format_size(2097152), "2.00 MB");
assert_eq!(format_size(5242880), "5.00 MB");
}
#[tokio::test]
async fn test_cache_info_command() -> Result<()> {
use tempfile::TempDir;
let temp_dir = TempDir::new()?;
let cache = Cache::with_dir(temp_dir.path().to_path_buf())?;
let cmd = CacheCommand {
command: Some(CacheSubcommands::Info),
};
let cache_dir = temp_dir.path().join("test-repo");
std::fs::create_dir_all(&cache_dir)?;
std::fs::write(cache_dir.join("test.txt"), "test content")?;
cmd.execute_with_cache(cache).await?;
Ok(())
}
#[tokio::test]
async fn test_cache_clean_all() -> Result<()> {
use tempfile::TempDir;
let temp_dir = TempDir::new()?;
let cache = Cache::with_dir(temp_dir.path().to_path_buf())?;
let repo1 = temp_dir.path().join("repo1");
let repo2 = temp_dir.path().join("repo2");
std::fs::create_dir_all(&repo1)?;
std::fs::create_dir_all(&repo2)?;
std::fs::write(repo1.join("file.txt"), "content")?;
std::fs::write(repo2.join("file.txt"), "content")?;
assert!(repo1.exists());
assert!(repo2.exists());
let cmd = CacheCommand {
command: Some(CacheSubcommands::Clean {
all: true,
}),
};
cmd.execute_with_cache(cache).await?;
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
assert!(!repo1.exists());
assert!(!repo2.exists());
assert!(
!temp_dir.path().exists()
|| temp_dir.path().read_dir().map(|mut d| d.next().is_none()).unwrap_or(false)
);
Ok(())
}
#[tokio::test]
async fn test_cache_clean_unused_no_manifest() -> Result<()> {
use tempfile::TempDir;
let temp_dir = TempDir::new()?;
let work_dir = TempDir::new()?;
let cache = Cache::with_dir(temp_dir.path().to_path_buf())?;
let cmd = CacheCommand {
command: Some(CacheSubcommands::Clean {
all: false,
}),
};
let non_existent_manifest = work_dir.path().join("agpm.toml");
assert!(!non_existent_manifest.exists());
cmd.execute_with_cache_and_manifest(cache, Some(non_existent_manifest)).await?;
Ok(())
}
#[tokio::test]
async fn test_cache_clean_unused_with_manifest() -> Result<()> {
use tempfile::TempDir;
let temp_dir = TempDir::new()?;
let work_dir = TempDir::new()?;
let cache = Cache::with_dir(temp_dir.path().to_path_buf())?;
let manifest = Manifest {
sources: std::collections::HashMap::from([(
"active".to_string(),
"https://github.com/test/active.git".to_string(),
)]),
..Default::default()
};
let manifest_path = work_dir.path().join("agpm.toml");
manifest.save(&manifest_path)?;
let active_cache = temp_dir.path().join("active");
let unused_cache = temp_dir.path().join("unused-test-source");
std::fs::create_dir_all(&active_cache)?;
std::fs::create_dir_all(&unused_cache)?;
assert!(active_cache.exists());
assert!(unused_cache.exists());
let cmd = CacheCommand {
command: Some(CacheSubcommands::Clean {
all: false,
}),
};
cmd.execute_with_cache_and_manifest(cache, Some(manifest_path)).await?;
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
assert!(active_cache.exists());
assert!(!unused_cache.exists());
Ok(())
}
#[tokio::test]
async fn test_cache_default_command() -> Result<()> {
use tempfile::TempDir;
let temp_dir = TempDir::new()?;
let cache = Cache::with_dir(temp_dir.path().to_path_buf())?;
let cmd = CacheCommand {
command: None,
};
cmd.execute_with_cache(cache).await?;
Ok(())
}
#[tokio::test]
async fn test_cache_info_with_empty_cache() -> Result<()> {
use tempfile::TempDir;
let temp_dir = TempDir::new()?;
let cache = Cache::with_dir(temp_dir.path().to_path_buf())?;
cache.ensure_cache_dir().await?;
let cmd = CacheCommand {
command: Some(CacheSubcommands::Info),
};
cmd.execute_with_cache(cache).await?;
Ok(())
}
#[tokio::test]
async fn test_cache_info_with_content() -> Result<()> {
use tempfile::TempDir;
let temp_dir = TempDir::new()?;
let cache = Cache::with_dir(temp_dir.path().to_path_buf())?;
cache.ensure_cache_dir().await?;
let source_dir = temp_dir.path().join("test-source");
std::fs::create_dir_all(&source_dir)?;
std::fs::write(source_dir.join("file1.txt"), "content1")?;
std::fs::write(source_dir.join("file2.txt"), "content2 with more data")?;
let nested = source_dir.join("nested");
std::fs::create_dir_all(&nested)?;
std::fs::write(nested.join("file3.txt"), "nested content")?;
let size = cache.get_cache_size().await?;
assert!(size > 0);
let cmd = CacheCommand {
command: Some(CacheSubcommands::Info),
};
cmd.execute_with_cache(cache).await?;
Ok(())
}
#[tokio::test]
async fn test_cache_execute_without_dir() -> Result<()> {
let cmd = CacheCommand {
command: Some(CacheSubcommands::Info),
};
cmd.execute().await?;
Ok(())
}
#[tokio::test]
async fn test_cache_clean_all_empty_cache() -> Result<()> {
use tempfile::TempDir;
let temp_dir = TempDir::new()?;
let cache = Cache::with_dir(temp_dir.path().to_path_buf())?;
let cmd = CacheCommand {
command: Some(CacheSubcommands::Clean {
all: true,
}),
};
cmd.execute_with_cache(cache).await?;
assert!(!temp_dir.path().exists() || temp_dir.path().read_dir()?.next().is_none());
Ok(())
}
#[tokio::test]
async fn test_cache_clean_with_multiple_sources() -> Result<()> {
use tempfile::TempDir;
let temp_dir = TempDir::new()?;
let work_dir = TempDir::new()?;
let cache = Cache::with_dir(temp_dir.path().to_path_buf())?;
let manifest = Manifest {
sources: std::collections::HashMap::from([
("source1".to_string(), "https://github.com/test/repo1.git".to_string()),
("source2".to_string(), "https://github.com/test/repo2.git".to_string()),
]),
..Default::default()
};
let manifest_path = work_dir.path().join("agpm.toml");
manifest.save(&manifest_path)?;
let source1_cache = temp_dir.path().join("source1");
let source2_cache = temp_dir.path().join("source2");
let unused_cache = temp_dir.path().join("unused");
std::fs::create_dir_all(&source1_cache)?;
std::fs::create_dir_all(&source2_cache)?;
std::fs::create_dir_all(&unused_cache)?;
let cmd = CacheCommand {
command: Some(CacheSubcommands::Clean {
all: false,
}),
};
cmd.execute_with_cache_and_manifest(cache, Some(manifest_path)).await?;
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
assert!(source1_cache.exists());
assert!(source2_cache.exists());
assert!(!unused_cache.exists());
Ok(())
}
#[tokio::test]
async fn test_cache_info_formatting() -> Result<()> {
use tempfile::TempDir;
let temp_dir = TempDir::new()?;
let cache = Cache::with_dir(temp_dir.path().to_path_buf())?;
cache.ensure_cache_dir().await?;
let test_file = temp_dir.path().join("test.txt");
let content = vec![b'a'; 1024];
std::fs::write(&test_file, content)?;
let cmd = CacheCommand {
command: Some(CacheSubcommands::Info),
};
cmd.execute_with_cache(cache).await?;
Ok(())
}
#[tokio::test]
async fn test_cache_clean_no_manifest_warning() -> Result<()> {
use tempfile::TempDir;
let temp_dir = TempDir::new()?;
let work_dir = TempDir::new()?;
let cache = Cache::with_dir(temp_dir.path().to_path_buf())?;
let non_existent_manifest = work_dir.path().join("agpm.toml");
assert!(!non_existent_manifest.exists());
let cache_dir1 = temp_dir.path().join("source1");
std::fs::create_dir_all(&cache_dir1)?;
let cmd = CacheCommand {
command: Some(CacheSubcommands::Clean {
all: false,
}),
};
cmd.execute_with_cache_and_manifest(cache, Some(non_existent_manifest)).await?;
assert!(cache_dir1.exists());
Ok(())
}
#[tokio::test]
async fn test_cache_size_calculation() -> Result<()> {
use tempfile::TempDir;
let temp_dir = TempDir::new()?;
let cache = Cache::with_dir(temp_dir.path().to_path_buf())?;
cache.ensure_cache_dir().await?;
std::fs::write(temp_dir.path().join("file1.txt"), vec![b'a'; 100])?;
std::fs::write(temp_dir.path().join("file2.txt"), vec![b'b'; 200])?;
let sub_dir = temp_dir.path().join("subdir");
std::fs::create_dir_all(&sub_dir)?;
std::fs::write(sub_dir.join("file3.txt"), vec![b'c'; 300])?;
let size = cache.get_cache_size().await?;
assert_eq!(size, 600);
Ok(())
}
#[tokio::test]
async fn test_cache_clean_preserves_lockfile_sources() -> Result<()> {
use crate::lockfile::{LockFile, LockedSource};
use tempfile::TempDir;
let temp_dir = TempDir::new()?;
let work_dir = TempDir::new()?;
let cache = Cache::with_dir(temp_dir.path().to_path_buf())?;
let manifest = Manifest {
sources: std::collections::HashMap::from([(
"manifest-source".to_string(),
"https://github.com/test/repo.git".to_string(),
)]),
..Default::default()
};
let manifest_path = work_dir.path().join("agpm.toml");
manifest.save(&manifest_path)?;
let lockfile = LockFile {
version: 1,
sources: vec![
LockedSource {
name: "manifest-source".to_string(),
url: "https://github.com/test/repo.git".to_string(),
fetched_at: chrono::Utc::now().to_string(),
},
LockedSource {
name: "lockfile-only".to_string(),
url: "https://github.com/test/other.git".to_string(),
fetched_at: chrono::Utc::now().to_string(),
},
],
agents: vec![],
snippets: vec![],
commands: vec![],
mcp_servers: vec![],
scripts: vec![],
hooks: vec![],
skills: vec![],
manifest_hash: None,
has_mutable_deps: None,
resource_count: None,
};
lockfile.save(&work_dir.path().join("agpm.lock"))?;
let manifest_cache = temp_dir.path().join("manifest-source");
let lockfile_cache = temp_dir.path().join("lockfile-only");
let unused_cache = temp_dir.path().join("unused");
std::fs::create_dir_all(&manifest_cache)?;
std::fs::create_dir_all(&lockfile_cache)?;
std::fs::create_dir_all(&unused_cache)?;
let cmd = CacheCommand {
command: Some(CacheSubcommands::Clean {
all: false,
}),
};
cmd.execute_with_cache_and_manifest(cache, Some(manifest_path)).await?;
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
assert!(manifest_cache.exists());
assert!(!unused_cache.exists());
Ok(())
}
#[tokio::test]
async fn test_format_size_function() {
fn format_size(bytes: u64) -> String {
const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
let mut size = bytes as f64;
let mut unit_index = 0;
while size >= 1024.0 && unit_index < UNITS.len() - 1 {
size /= 1024.0;
unit_index += 1;
}
if unit_index == 0 {
format!("{} {}", size as u64, UNITS[unit_index])
} else {
format!("{:.1} {}", size, UNITS[unit_index])
}
}
assert_eq!(format_size(0), "0 B");
assert_eq!(format_size(100), "100 B");
assert_eq!(format_size(1024), "1.0 KB");
assert_eq!(format_size(1536), "1.5 KB");
assert_eq!(format_size(1048576), "1.0 MB");
assert_eq!(format_size(1073741824), "1.0 GB");
assert_eq!(format_size(1099511627776), "1.0 TB");
}
#[tokio::test]
async fn test_cache_path_display() -> Result<()> {
use tempfile::TempDir;
let temp_dir = TempDir::new()?;
let cache = Cache::with_dir(temp_dir.path().to_path_buf())?;
let location = cache.get_cache_location();
assert_eq!(location, temp_dir.path());
let path_str = location.display().to_string();
assert!(path_str.contains(temp_dir.path().file_name().unwrap().to_str().unwrap()));
Ok(())
}
}