use std::cmp::Ordering; use std::time::Instant; use chrono::{DateTime, Utc}; use clap::Args as ClapArgs; use serde::Serialize; use tabled::Tabled; use crate::core::cache::CacheManager; use crate::core::config::cache_dir; use crate::errors::SwaggerCliError; use crate::output::robot::robot_success; use crate::output::table::render_table_or_empty; use crate::utils::dir_size; // --------------------------------------------------------------------------- // CLI args // --------------------------------------------------------------------------- /// Manage the spec cache #[derive(Debug, ClapArgs)] pub struct Args { /// Show cache statistics (default when no other flag given) #[arg(long)] pub stats: bool, /// Print the cache directory path and exit #[arg(long)] pub path: bool, /// Remove aliases whose fetched_at exceeds the stale threshold #[arg(long)] pub prune_stale: bool, /// Days before an alias is considered stale (default: 30, matching config) #[arg(long, default_value_t = 30)] pub prune_threshold: u32, /// Evict least-recently-used aliases until total size is under this limit (MB) #[arg(long)] pub max_total_mb: Option, /// Report what would happen without deleting anything #[arg(long)] pub dry_run: bool, } // --------------------------------------------------------------------------- // Robot output structs // --------------------------------------------------------------------------- #[derive(Debug, Serialize)] #[serde(tag = "kind")] enum CacheOutput { #[serde(rename = "stats")] Stats(StatsOutput), #[serde(rename = "path")] Path(PathOutput), #[serde(rename = "prune")] Prune(PruneOutput), #[serde(rename = "evict")] Evict(EvictOutput), } #[derive(Debug, Serialize)] struct StatsOutput { aliases: Vec, total_bytes: u64, } #[derive(Debug, Serialize)] struct AliasStats { name: String, size_bytes: u64, endpoint_count: usize, fetched_at: DateTime, last_accessed: DateTime, } #[derive(Debug, Serialize)] struct PathOutput { path: String, } #[derive(Debug, Serialize)] struct PruneOutput { pruned: Vec, dry_run: bool, } #[derive(Debug, Serialize)] struct EvictOutput { evicted: Vec, target_bytes: u64, actual_bytes: u64, dry_run: bool, } // --------------------------------------------------------------------------- // Human-readable table row // --------------------------------------------------------------------------- #[derive(Tabled)] struct StatsRow { #[tabled(rename = "Alias")] name: String, #[tabled(rename = "Size")] size: String, #[tabled(rename = "Endpoints")] endpoints: usize, #[tabled(rename = "Fetched")] fetched: String, #[tabled(rename = "Last Accessed")] accessed: String, } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- fn human_bytes(bytes: u64) -> String { const KB: u64 = 1024; const MB: u64 = KB * 1024; if bytes >= MB { format!("{:.1} MB", bytes as f64 / MB as f64) } else if bytes >= KB { format!("{:.1} KB", bytes as f64 / KB as f64) } else { format!("{bytes} B") } } fn short_datetime(dt: &DateTime) -> String { dt.format("%Y-%m-%d %H:%M").to_string() } /// Compare by last_accessed ASC, then fetched_at ASC as tie-breaker. fn lru_order( a_last: &DateTime, a_fetched: &DateTime, b_last: &DateTime, b_fetched: &DateTime, ) -> Ordering { a_last.cmp(b_last).then_with(|| a_fetched.cmp(b_fetched)) } // --------------------------------------------------------------------------- // Execute // --------------------------------------------------------------------------- pub async fn execute(args: &Args, robot: bool) -> Result<(), SwaggerCliError> { let start = Instant::now(); if args.path { return execute_path(robot, start); } if args.prune_stale { return execute_prune(args, robot, start); } if args.max_total_mb.is_some() { return execute_evict(args, robot, start); } // Default: stats execute_stats(robot, start) } // --------------------------------------------------------------------------- // Path // --------------------------------------------------------------------------- fn execute_path(robot: bool, start: Instant) -> Result<(), SwaggerCliError> { let cd = cache_dir(); if robot { robot_success( CacheOutput::Path(PathOutput { path: cd.display().to_string(), }), "cache", start.elapsed(), ); } else { println!("{}", cd.display()); } Ok(()) } // --------------------------------------------------------------------------- // Stats // --------------------------------------------------------------------------- fn execute_stats(robot: bool, start: Instant) -> Result<(), SwaggerCliError> { let cm = CacheManager::new(cache_dir()); let metas = cm.list_aliases()?; let mut alias_stats: Vec = Vec::with_capacity(metas.len()); for meta in &metas { let size = dir_size(&cm.alias_dir(&meta.alias)); alias_stats.push(AliasStats { name: meta.alias.clone(), size_bytes: size, endpoint_count: meta.endpoint_count, fetched_at: meta.fetched_at, last_accessed: meta.last_accessed, }); } let total_bytes: u64 = alias_stats.iter().map(|a| a.size_bytes).sum(); if robot { robot_success( CacheOutput::Stats(StatsOutput { aliases: alias_stats, total_bytes, }), "cache", start.elapsed(), ); } else { let rows: Vec = alias_stats .iter() .map(|a| StatsRow { name: a.name.clone(), size: human_bytes(a.size_bytes), endpoints: a.endpoint_count, fetched: short_datetime(&a.fetched_at), accessed: short_datetime(&a.last_accessed), }) .collect(); println!("{}", render_table_or_empty(&rows, "Cache is empty.")); println!("Total: {}", human_bytes(total_bytes)); } Ok(()) } // --------------------------------------------------------------------------- // Prune // --------------------------------------------------------------------------- fn execute_prune(args: &Args, robot: bool, start: Instant) -> Result<(), SwaggerCliError> { let cm = CacheManager::new(cache_dir()); let metas = cm.list_aliases()?; let stale: Vec<&str> = metas .iter() .filter(|m| m.is_stale(args.prune_threshold)) .map(|m| m.alias.as_str()) .collect(); if args.dry_run { if robot { robot_success( CacheOutput::Prune(PruneOutput { pruned: stale.iter().map(|s| (*s).to_string()).collect(), dry_run: true, }), "cache", start.elapsed(), ); } else if stale.is_empty() { println!( "No stale aliases (threshold: {} days).", args.prune_threshold ); } else { println!( "Would prune {} stale alias(es) (threshold: {} days):", stale.len(), args.prune_threshold ); for name in &stale { println!(" {name}"); } } return Ok(()); } let mut pruned: Vec = Vec::new(); for name in &stale { cm.delete_alias(name)?; pruned.push((*name).to_string()); } if robot { robot_success( CacheOutput::Prune(PruneOutput { pruned, dry_run: false, }), "cache", start.elapsed(), ); } else if pruned.is_empty() { println!( "No stale aliases (threshold: {} days).", args.prune_threshold ); } else { println!("Pruned {} stale alias(es).", pruned.len()); } Ok(()) } // --------------------------------------------------------------------------- // LRU eviction // --------------------------------------------------------------------------- fn execute_evict(args: &Args, robot: bool, start: Instant) -> Result<(), SwaggerCliError> { let target_bytes = args.max_total_mb.unwrap_or(0) * 1024 * 1024; let cm = CacheManager::new(cache_dir()); let metas = cm.list_aliases()?; // Build (alias, size, last_accessed, fetched_at) let mut entries: Vec<(String, u64, DateTime, DateTime)> = metas .iter() .map(|m| { let size = dir_size(&cm.alias_dir(&m.alias)); (m.alias.clone(), size, m.last_accessed, m.fetched_at) }) .collect(); let mut total: u64 = entries.iter().map(|(_, s, _, _)| s).sum(); // Sort LRU: oldest last_accessed first, then oldest fetched_at entries.sort_by(|a, b| lru_order(&a.2, &a.3, &b.2, &b.3)); let mut evicted: Vec = Vec::new(); for (name, size, _, _) in &entries { if total <= target_bytes { break; } evicted.push(name.clone()); total = total.saturating_sub(*size); } if args.dry_run { if robot { robot_success( CacheOutput::Evict(EvictOutput { evicted, target_bytes, actual_bytes: total, dry_run: true, }), "cache", start.elapsed(), ); } else if evicted.is_empty() { println!( "Cache already under {} MB target.", args.max_total_mb.unwrap_or(0) ); } else { println!( "Would evict {} alias(es) to reach {} MB target:", evicted.len(), args.max_total_mb.unwrap_or(0) ); for name in &evicted { println!(" {name}"); } } return Ok(()); } for name in &evicted { cm.delete_alias(name)?; } if robot { robot_success( CacheOutput::Evict(EvictOutput { evicted, target_bytes, actual_bytes: total, dry_run: false, }), "cache", start.elapsed(), ); } else if evicted.is_empty() { println!( "Cache already under {} MB target.", args.max_total_mb.unwrap_or(0) ); } else { println!( "Evicted {} alias(es). Cache now {}.", evicted.len(), human_bytes(total) ); } Ok(()) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::core::cache::CacheMetadata; use chrono::Duration; fn make_meta(alias: &str, days_old: i64, last_accessed_days_ago: i64) -> CacheMetadata { CacheMetadata { alias: alias.to_string(), url: None, fetched_at: Utc::now() - Duration::days(days_old), last_accessed: Utc::now() - Duration::days(last_accessed_days_ago), content_hash: String::new(), raw_hash: String::new(), etag: None, last_modified: None, spec_version: "1.0.0".to_string(), spec_title: format!("{alias} API"), endpoint_count: 5, schema_count: 3, raw_size_bytes: 1000, source_format: "json".to_string(), index_version: 1, generation: 1, index_hash: String::new(), } } /// Write a minimal cache entry with known content so we can measure size. fn write_test_alias(cm: &CacheManager, alias: &str, content: &[u8]) { cm.ensure_dirs(alias).unwrap(); let dir = cm.alias_dir(alias); std::fs::write(dir.join("raw.json"), content).unwrap(); let meta = CacheMetadata { alias: alias.to_string(), url: None, fetched_at: Utc::now(), last_accessed: Utc::now(), content_hash: String::new(), raw_hash: String::new(), etag: None, last_modified: None, spec_version: "1.0.0".to_string(), spec_title: "Test".to_string(), endpoint_count: 3, schema_count: 1, raw_size_bytes: content.len() as u64, source_format: "json".to_string(), index_version: 1, generation: 1, index_hash: String::new(), }; let meta_json = serde_json::to_vec_pretty(&meta).unwrap(); std::fs::write(dir.join("meta.json"), &meta_json).unwrap(); } #[test] fn test_stats_computes_sizes() { let tmp = tempfile::tempdir().unwrap(); let cm = CacheManager::new(tmp.path().to_path_buf()); let content_a = vec![0u8; 512]; let content_b = vec![0u8; 1024]; write_test_alias(&cm, "alpha", &content_a); write_test_alias(&cm, "bravo", &content_b); let metas = cm.list_aliases().unwrap(); assert_eq!(metas.len(), 2); let mut total: u64 = 0; for meta in &metas { let size = dir_size(&cm.alias_dir(&meta.alias)); assert!(size > 0, "alias {} should have nonzero size", meta.alias); total += size; } // Total should be at least the raw content sizes (each alias also has meta.json) assert!(total >= 512 + 1024, "total {total} should be >= 1536"); } #[test] fn test_prune_identifies_stale() { let threshold = 90; let fresh = make_meta("fresh-api", 10, 1); let stale = make_meta("old-api", 100, 50); let borderline = make_meta("edge-api", 91, 2); assert!(!fresh.is_stale(threshold), "fresh should not be stale"); assert!(stale.is_stale(threshold), "old should be stale"); assert!(borderline.is_stale(threshold), "91-day-old should be stale"); } #[test] fn test_path_output() { let path = cache_dir(); let display = path.display().to_string(); assert!( !display.is_empty(), "cache_dir should produce a non-empty path" ); } #[test] fn test_lru_order_sorts_oldest_first() { let now = Utc::now(); let old = now - Duration::days(30); let older = now - Duration::days(60); // older last_accessed should sort before newer assert_eq!( lru_order(&older, &now, &old, &now), Ordering::Less, "older last_accessed should come first" ); assert_eq!( lru_order(&old, &now, &older, &now), Ordering::Greater, "newer last_accessed should come second" ); } #[test] fn test_lru_tiebreak_uses_fetched_at() { let now = Utc::now(); let same_access = now - Duration::days(10); let older_fetch = now - Duration::days(60); let newer_fetch = now - Duration::days(30); assert_eq!( lru_order(&same_access, &older_fetch, &same_access, &newer_fetch), Ordering::Less, "older fetched_at should break tie" ); } #[test] fn test_human_bytes_formatting() { assert_eq!(human_bytes(500), "500 B"); assert_eq!(human_bytes(1024), "1.0 KB"); assert_eq!(human_bytes(1536), "1.5 KB"); assert_eq!(human_bytes(1_048_576), "1.0 MB"); assert_eq!(human_bytes(2_621_440), "2.5 MB"); } #[test] fn test_cache_output_serialization() { let output = CacheOutput::Stats(StatsOutput { aliases: vec![AliasStats { name: "test".to_string(), size_bytes: 1024, endpoint_count: 5, fetched_at: Utc::now(), last_accessed: Utc::now(), }], total_bytes: 1024, }); let json = serde_json::to_string(&output).unwrap(); assert!(json.contains("\"kind\":\"stats\"")); assert!(json.contains("\"total_bytes\":1024")); } #[test] fn test_prune_output_serialization() { let output = CacheOutput::Prune(PruneOutput { pruned: vec!["old-api".to_string()], dry_run: true, }); let json = serde_json::to_string(&output).unwrap(); assert!(json.contains("\"kind\":\"prune\"")); assert!(json.contains("\"dry_run\":true")); assert!(json.contains("old-api")); } #[test] fn test_evict_output_serialization() { let output = CacheOutput::Evict(EvictOutput { evicted: vec!["stale-api".to_string()], target_bytes: 10_485_760, actual_bytes: 5_000_000, dry_run: false, }); let json = serde_json::to_string(&output).unwrap(); assert!(json.contains("\"kind\":\"evict\"")); assert!(json.contains("\"target_bytes\":10485760")); assert!(json.contains("stale-api")); } }