Wave 4: Full CLI command implementations - fetch, list, show, search, tags, aliases, doctor, cache lifecycle (bd-16o, bd-3km, bd-1dj, bd-acf, bd-3bl, bd-30a, bd-2s6, bd-1d4)
This commit is contained in:
@@ -1,23 +1,598 @@
|
||||
use clap::Args as ClapArgs;
|
||||
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;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI args
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Manage the spec cache
|
||||
#[derive(Debug, ClapArgs)]
|
||||
pub struct Args {
|
||||
/// Show cache location
|
||||
/// 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,
|
||||
|
||||
/// Clear the entire cache
|
||||
/// Remove aliases whose fetched_at exceeds the stale threshold
|
||||
#[arg(long)]
|
||||
pub clear: bool,
|
||||
pub prune_stale: bool,
|
||||
|
||||
/// Show cache size
|
||||
/// Days before an alias is considered stale (default: 90)
|
||||
#[arg(long, default_value_t = 90)]
|
||||
pub prune_threshold: u32,
|
||||
|
||||
/// Evict least-recently-used aliases until total size is under this limit (MB)
|
||||
#[arg(long)]
|
||||
pub size: bool,
|
||||
pub max_total_mb: Option<u64>,
|
||||
|
||||
/// Report what would happen without deleting anything
|
||||
#[arg(long)]
|
||||
pub dry_run: bool,
|
||||
}
|
||||
|
||||
pub async fn execute(_args: &Args, _robot: bool) -> Result<(), SwaggerCliError> {
|
||||
Err(SwaggerCliError::Usage("cache not yet implemented".into()))
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Walk every file in `dir` (non-recursive) and sum metadata().len().
|
||||
fn dir_size(dir: &std::path::Path) -> u64 {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return 0;
|
||||
};
|
||||
entries
|
||||
.filter_map(Result::ok)
|
||||
.filter_map(|e| e.metadata().ok())
|
||||
.filter(|m| m.is_file())
|
||||
.map(|m| m.len())
|
||||
.sum()
|
||||
}
|
||||
|
||||
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 stale.is_empty() {
|
||||
println!(
|
||||
"No stale aliases (threshold: {} days).",
|
||||
args.prune_threshold
|
||||
);
|
||||
} else {
|
||||
println!("Pruned {} stale alias(es).", stale.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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user