Files
swagger-cli/src/cli/cache_cmd.rs
teernisse 0b9a8a36c5 CLI: propagate alias rename errors, doctor config resilience
aliases: rename now propagates every meta.json I/O and parse error
instead of silently ignoring failures. Previously, a corrupt or
unreadable meta.json after directory rename would leave the alias
in an inconsistent state with no user-visible error.

doctor: Config::load failure now falls back to Config::default()
with a warning instead of aborting the entire health check. Doctor
should still run diagnostics even when config is missing or corrupt
— that's exactly when you need it most.
2026-02-12 16:57:09 -05:00

587 lines
17 KiB
Rust

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<u64>,
/// 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<AliasStats>,
total_bytes: u64,
}
#[derive(Debug, Serialize)]
struct AliasStats {
name: String,
size_bytes: u64,
endpoint_count: usize,
fetched_at: DateTime<Utc>,
last_accessed: DateTime<Utc>,
}
#[derive(Debug, Serialize)]
struct PathOutput {
path: String,
}
#[derive(Debug, Serialize)]
struct PruneOutput {
pruned: Vec<String>,
dry_run: bool,
}
#[derive(Debug, Serialize)]
struct EvictOutput {
evicted: Vec<String>,
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<Utc>) -> 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<Utc>,
a_fetched: &DateTime<Utc>,
b_last: &DateTime<Utc>,
b_fetched: &DateTime<Utc>,
) -> 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<AliasStats> = 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<StatsRow> = 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<String> = 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<Utc>, DateTime<Utc>)> = 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<String> = 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"));
}
}