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.
587 lines
17 KiB
Rust
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"));
|
|
}
|
|
}
|