use std::fs; use std::path::PathBuf; use std::time::Instant; use clap::Args as ClapArgs; use colored::Colorize; use serde::Serialize; use crate::core::cache::{CacheManager, CacheMetadata, compute_hash}; use crate::core::config::{Config, cache_dir, config_path}; use crate::core::indexer::{build_index, resolve_pointer}; use crate::core::spec::SpecIndex; use crate::errors::SwaggerCliError; use crate::output::robot; use crate::utils::dir_size; // --------------------------------------------------------------------------- // CLI arguments // --------------------------------------------------------------------------- /// Check cache health and diagnose issues #[derive(Debug, ClapArgs)] pub struct Args { /// Attempt to fix issues automatically #[arg(long)] pub fix: bool, /// Check a specific alias only #[arg(long)] pub alias: Option, } // --------------------------------------------------------------------------- // Health status // --------------------------------------------------------------------------- #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] #[serde(rename_all = "lowercase")] enum HealthStatus { Healthy, Warning, Degraded, Unhealthy, } impl HealthStatus { fn as_str(self) -> &'static str { match self { Self::Healthy => "healthy", Self::Warning => "warning", Self::Degraded => "degraded", Self::Unhealthy => "unhealthy", } } fn colored_str(self) -> String { match self { Self::Healthy => "healthy".green().to_string(), Self::Warning => "warning".yellow().to_string(), Self::Degraded => "degraded".red().to_string(), Self::Unhealthy => "unhealthy".red().bold().to_string(), } } } // --------------------------------------------------------------------------- // Robot output structs // --------------------------------------------------------------------------- #[derive(Debug, Serialize)] struct DoctorOutput { health: String, aliases: Vec, warnings: Vec, total_disk_bytes: u64, fixable_count: usize, unfixable_count: usize, } #[derive(Debug, Serialize)] struct AliasReport { name: String, status: String, issues: Vec, disk_bytes: u64, endpoint_count: usize, } // --------------------------------------------------------------------------- // Internal check result // --------------------------------------------------------------------------- struct AliasCheckResult { name: String, status: HealthStatus, issues: Vec, disk_bytes: u64, endpoint_count: usize, fixable: bool, unfixable: bool, } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /// Discover all alias directory names in the cache dir, including those /// without a valid meta.json (which list_aliases would skip). fn discover_alias_dirs(cache_root: &PathBuf) -> Vec { let Ok(entries) = fs::read_dir(cache_root) else { return Vec::new(); }; let mut names = Vec::new(); for entry in entries.flatten() { let path = entry.path(); if path.is_dir() && let Some(name) = path.file_name().and_then(|n| n.to_str()) { // Skip hidden directories (e.g. .DS_Store dirs) if !name.starts_with('.') { names.push(name.to_string()); } } } names.sort(); names } /// Check a single alias for health issues. fn check_alias(cm: &CacheManager, alias: &str, stale_threshold_days: u32) -> AliasCheckResult { let mut issues: Vec = Vec::new(); let mut status = HealthStatus::Healthy; let mut endpoint_count: usize = 0; let mut fixable = false; let mut unfixable = false; let disk_bytes = dir_size(&cm.alias_dir(alias)); // Step 1: Try loading index (meta + index integrity) let index_result = cm.load_index(alias); let (index, meta): (Option, Option) = match index_result { Ok((idx, m)) => (Some(idx), Some(m)), Err(SwaggerCliError::AliasNotFound(_)) => { issues.push("meta.json missing".to_string()); status = HealthStatus::Degraded; // Check if raw.json exists -- if so this might be fixable if cm.alias_dir(alias).join("raw.json").exists() { fixable = true; } else { unfixable = true; } (None, None) } Err(SwaggerCliError::CacheIntegrity(msg)) => { issues.push(format!("index integrity: {msg}")); status = HealthStatus::Degraded; fixable = true; // Index can potentially be rebuilt from raw (None, None) } Err(e) => { issues.push(format!("load error: {e}")); status = HealthStatus::Unhealthy; unfixable = true; (None, None) } }; // Step 2: Try loading raw (validates raw_hash) let raw_value: Option = if let Some(ref m) = meta { match cm.load_raw(alias, m) { Ok(v) => Some(v), Err(SwaggerCliError::CacheIntegrity(msg)) => { issues.push(format!("raw integrity: {msg}")); status = status.max(HealthStatus::Degraded); unfixable = true; None } Err(e) => { issues.push(format!("raw load error: {e}")); status = status.max(HealthStatus::Unhealthy); unfixable = true; None } } } else if cm.alias_dir(alias).join("raw.json").exists() { // Meta is missing but raw.json exists -- try to parse it let raw_path = cm.alias_dir(alias).join("raw.json"); match fs::read(&raw_path) { Ok(bytes) => match serde_json::from_slice::(&bytes) { Ok(v) => Some(v), Err(e) => { issues.push(format!("raw.json unparseable: {e}")); unfixable = true; None } }, Err(e) => { issues.push(format!("raw.json unreadable: {e}")); unfixable = true; None } } } else { None }; // Step 3: Validate operation pointers if let (Some(idx), Some(raw)) = (&index, &raw_value) { endpoint_count = idx.endpoints.len(); let mut broken_ptrs = 0usize; for ep in &idx.endpoints { if !resolve_pointer(raw, &ep.operation_ptr) { broken_ptrs += 1; } } if broken_ptrs > 0 { issues.push(format!( "{broken_ptrs} endpoint pointer(s) do not resolve in raw" )); status = status.max(HealthStatus::Degraded); fixable = true; } } else if let Some(ref idx) = index { endpoint_count = idx.endpoints.len(); } // Step 4: Stale check if let Some(ref m) = meta && m.is_stale(stale_threshold_days) { issues.push(format!( "stale: fetched {} (threshold: {stale_threshold_days} days)", m.fetched_at.format("%Y-%m-%d") )); status = status.max(HealthStatus::Warning); } AliasCheckResult { name: alias.to_string(), status, issues, disk_bytes, endpoint_count, fixable, unfixable, } } /// Attempt to fix an alias by rebuilding the index from raw.json. fn try_fix_alias(cm: &CacheManager, alias: &str) -> Result, Vec> { let mut fixed: Vec = Vec::new(); let mut unfixed: Vec = Vec::new(); let alias_dir = cm.alias_dir(alias); // Read raw.json bytes let raw_json_path = alias_dir.join("raw.json"); let raw_json_bytes = match fs::read(&raw_json_path) { Ok(b) => b, Err(e) => { unfixed.push(format!("cannot read raw.json: {e}")); return Err(unfixed); } }; let raw_value: serde_json::Value = match serde_json::from_slice(&raw_json_bytes) { Ok(v) => v, Err(e) => { unfixed.push(format!("raw.json unparseable: {e}")); return Err(unfixed); } }; // Read raw.source if present, otherwise use raw.json bytes as source let raw_source_path = alias_dir.join("raw.source"); let raw_source_bytes = fs::read(&raw_source_path).unwrap_or_else(|_| raw_json_bytes.clone()); let content_hash = compute_hash(&raw_source_bytes); // Try to load existing meta for generation/url info let meta_path = alias_dir.join("meta.json"); let existing_meta: Option = fs::read(&meta_path) .ok() .and_then(|b| serde_json::from_slice(&b).ok()); let generation = existing_meta.as_ref().map_or(1, |m| m.generation); let url = existing_meta.as_ref().and_then(|m| m.url.clone()); let source_format = existing_meta .as_ref() .map_or("json".to_string(), |m| m.source_format.clone()); // Rebuild index let new_index = match build_index(&raw_value, &content_hash, generation) { Ok(idx) => idx, Err(e) => { unfixed.push(format!("index rebuild failed: {e}")); return Err(unfixed); } }; let spec_title = new_index.info.title.clone(); let spec_version = new_index.info.version.clone(); // Write everything back through the public API match cm.write_cache( alias, &raw_source_bytes, &raw_json_bytes, &new_index, url, &spec_version, &spec_title, &source_format, existing_meta.as_ref().and_then(|m| m.etag.clone()), existing_meta.as_ref().and_then(|m| m.last_modified.clone()), Some(generation.saturating_sub(1)), // previous_generation so new = generation ) { Ok(_) => { fixed.push("rebuilt index and meta from raw data".to_string()); } Err(e) => { unfixed.push(format!("cache write failed: {e}")); return Err(unfixed); } } if unfixed.is_empty() { Ok(fixed) } else { Err(unfixed) } } // --------------------------------------------------------------------------- // Execute // --------------------------------------------------------------------------- pub async fn execute(args: &Args, robot_mode: bool) -> Result<(), SwaggerCliError> { let start = Instant::now(); // Load config let cfg_path = config_path(None); let config = Config::load(&cfg_path)?; // Check config dir exists let mut warnings: Vec = Vec::new(); if let Some(parent) = cfg_path.parent() && !parent.exists() { warnings.push(format!( "config directory does not exist: {}", parent.display() )); } // Check cache dir let cache = cache_dir(); if !cache.exists() { warnings.push(format!( "cache directory does not exist: {}", cache.display() )); // No aliases to check -- output empty result let output = DoctorOutput { health: HealthStatus::Warning.as_str().to_string(), aliases: Vec::new(), warnings: warnings.clone(), total_disk_bytes: 0, fixable_count: 0, unfixable_count: 0, }; if robot_mode { robot::robot_success(output, "doctor", start.elapsed()); } else { println!("{} no cache directory found", "warning:".yellow().bold()); for w in &warnings { println!(" {w}"); } } return Ok(()); } let cm = CacheManager::new(cache.clone()); // Discover aliases (including broken ones without meta.json) let alias_names: Vec = if let Some(ref specific) = args.alias { // Verify the alias dir exists if !cm.alias_dir(specific).exists() { return Err(SwaggerCliError::AliasNotFound(specific.clone())); } vec![specific.clone()] } else { discover_alias_dirs(&cache) }; // Check each alias let mut results: Vec = Vec::new(); for alias in &alias_names { results.push(check_alias(&cm, alias, config.stale_threshold_days)); } // Apply fixes if requested if args.fix { for result in &mut results { if result.fixable && result.status >= HealthStatus::Degraded { match try_fix_alias(&cm, &result.name) { Ok(fixes) => { for fix in &fixes { result.issues.push(format!("FIXED: {fix}")); } // Re-check after fix let rechecked = check_alias(&cm, &result.name, config.stale_threshold_days); result.status = rechecked.status; result.endpoint_count = rechecked.endpoint_count; result.fixable = rechecked.fixable; result.unfixable = rechecked.unfixable; } Err(errs) => { for err in &errs { result.issues.push(format!("FIX FAILED: {err}")); } result.unfixable = true; } } } } } // Compute aggregates let total_disk_bytes: u64 = results.iter().map(|r| r.disk_bytes).sum(); let fixable_count = results.iter().filter(|r| r.fixable).count(); let unfixable_count = results.iter().filter(|r| r.unfixable).count(); let overall_status = results .iter() .map(|r| r.status) .max() .unwrap_or(HealthStatus::Healthy); // Build output let alias_reports: Vec = results .iter() .map(|r| AliasReport { name: r.name.clone(), status: r.status.as_str().to_string(), issues: r.issues.clone(), disk_bytes: r.disk_bytes, endpoint_count: r.endpoint_count, }) .collect(); let output = DoctorOutput { health: overall_status.as_str().to_string(), aliases: alias_reports, warnings: warnings.clone(), total_disk_bytes, fixable_count, unfixable_count, }; if robot_mode { robot::robot_success(output, "doctor", start.elapsed()); } else { // Human output println!( "{} {}", "Cache health:".bold(), overall_status.colored_str() ); println!(); if results.is_empty() { println!(" No cached specs found."); } for r in &results { let status_str = r.status.colored_str(); let size_kb = r.disk_bytes as f64 / 1024.0; println!( " {} [{}] {:.1} KB, {} endpoints", r.name.bold(), status_str, size_kb, r.endpoint_count, ); for issue in &r.issues { println!(" - {issue}"); } } if !warnings.is_empty() { println!(); for w in &warnings { println!("{} {w}", "warning:".yellow().bold()); } } println!(); println!( "Total: {} alias(es), {:.1} KB on disk", results.len(), total_disk_bytes as f64 / 1024.0, ); if fixable_count > 0 { println!( " {} fixable issue(s) -- run with {} to repair", fixable_count, "--fix".bold(), ); } if unfixable_count > 0 { println!( " {} unfixable issue(s) -- re-fetch required", unfixable_count, ); } } Ok(()) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::core::cache::CacheManager; use crate::core::indexer::build_index; use tempfile::TempDir; /// Create a minimal valid OpenAPI spec JSON value. fn minimal_spec() -> serde_json::Value { serde_json::json!({ "openapi": "3.0.3", "info": { "title": "Test API", "version": "1.0.0" }, "paths": { "/pets": { "get": { "summary": "List pets", "responses": { "200": { "description": "OK" } } } } } }) } /// Set up a healthy alias in a temp cache dir. fn setup_healthy_cache(tmp: &TempDir) -> (CacheManager, String) { let cache_path = tmp.path().join("cache"); fs::create_dir_all(&cache_path).unwrap(); let cm = CacheManager::new(cache_path); let spec = minimal_spec(); let raw_bytes = serde_json::to_vec_pretty(&spec).unwrap(); let content_hash = compute_hash(&raw_bytes); let index = build_index(&spec, &content_hash, 1).unwrap(); cm.write_cache( "testapi", &raw_bytes, &raw_bytes, &index, Some("https://example.com/api.json".to_string()), "1.0.0", "Test API", "json", None, None, None, ) .unwrap(); (cm, "testapi".to_string()) } #[test] fn test_healthy_cache_reports_healthy() { let tmp = TempDir::new().unwrap(); let (cm, alias) = setup_healthy_cache(&tmp); let result = check_alias(&cm, &alias, 30); assert_eq!(result.status, HealthStatus::Healthy); assert!( result.issues.is_empty(), "expected no issues, got: {:?}", result.issues ); assert_eq!(result.endpoint_count, 1); assert!(result.disk_bytes > 0); assert!(!result.fixable); assert!(!result.unfixable); } #[test] fn test_missing_meta_detected() { let tmp = TempDir::new().unwrap(); let (cm, alias) = setup_healthy_cache(&tmp); // Delete meta.json to simulate corruption let meta_path = cm.alias_dir(&alias).join("meta.json"); fs::remove_file(&meta_path).unwrap(); let result = check_alias(&cm, &alias, 30); assert!( result.status >= HealthStatus::Degraded, "expected Degraded or worse, got: {:?}", result.status, ); assert!( result .issues .iter() .any(|i| i.contains("meta.json missing")), "expected 'meta.json missing' issue, got: {:?}", result.issues, ); // raw.json still exists, so it should be fixable assert!(result.fixable); } #[test] fn test_corrupt_index_detected() { let tmp = TempDir::new().unwrap(); let (cm, alias) = setup_healthy_cache(&tmp); // Corrupt the index.json let index_path = cm.alias_dir(&alias).join("index.json"); fs::write(&index_path, b"not valid json").unwrap(); let result = check_alias(&cm, &alias, 30); assert!( result.status >= HealthStatus::Degraded, "expected Degraded or worse, got: {:?}", result.status, ); assert!( result.issues.iter().any(|i| i.contains("index integrity")), "expected index integrity issue, got: {:?}", result.issues, ); assert!(result.fixable); } #[test] fn test_missing_raw_is_unfixable() { let tmp = TempDir::new().unwrap(); let (cm, alias) = setup_healthy_cache(&tmp); // Delete both meta.json and raw.json let meta_path = cm.alias_dir(&alias).join("meta.json"); let raw_path = cm.alias_dir(&alias).join("raw.json"); fs::remove_file(&meta_path).unwrap(); fs::remove_file(&raw_path).unwrap(); let result = check_alias(&cm, &alias, 30); assert!( result.status >= HealthStatus::Degraded, "expected Degraded or worse, got: {:?}", result.status, ); assert!(result.unfixable); } #[test] fn test_stale_cache_warns() { let tmp = TempDir::new().unwrap(); let (cm, alias) = setup_healthy_cache(&tmp); // Manually modify meta to have an old fetched_at let meta_path = cm.alias_dir(&alias).join("meta.json"); let bytes = fs::read(&meta_path).unwrap(); let mut meta: CacheMetadata = serde_json::from_slice(&bytes).unwrap(); meta.fetched_at = chrono::Utc::now() - chrono::Duration::days(60); let updated = serde_json::to_vec_pretty(&meta).unwrap(); fs::write(&meta_path, &updated).unwrap(); let result = check_alias(&cm, &alias, 30); assert!( result.status >= HealthStatus::Warning, "expected Warning or worse for stale cache, got: {:?}", result.status, ); assert!( result.issues.iter().any(|i| i.contains("stale")), "expected 'stale' issue, got: {:?}", result.issues, ); } #[test] fn test_fix_rebuilds_index() { let tmp = TempDir::new().unwrap(); let (cm, alias) = setup_healthy_cache(&tmp); // Corrupt the index.json let index_path = cm.alias_dir(&alias).join("index.json"); fs::write(&index_path, b"corrupted data").unwrap(); // Verify it's broken let before = check_alias(&cm, &alias, 30); assert!(before.status >= HealthStatus::Degraded); // Fix it let fix_result = try_fix_alias(&cm, &alias); assert!( fix_result.is_ok(), "fix should succeed, got: {fix_result:?}" ); // Verify it's healthy now let after = check_alias(&cm, &alias, 30); assert_eq!( after.status, HealthStatus::Healthy, "expected healthy after fix, got: {:?}, issues: {:?}", after.status, after.issues, ); } #[test] fn test_discover_alias_dirs() { let tmp = TempDir::new().unwrap(); let cache_path = tmp.path().join("cache"); fs::create_dir_all(cache_path.join("alpha")).unwrap(); fs::create_dir_all(cache_path.join("beta")).unwrap(); fs::create_dir_all(cache_path.join(".hidden")).unwrap(); // Create a file (should be ignored) fs::write(cache_path.join("not-a-dir"), b"x").unwrap(); let dirs = discover_alias_dirs(&cache_path); assert_eq!(dirs, vec!["alpha", "beta"]); } #[test] fn test_dir_size_computes_bytes() { let tmp = TempDir::new().unwrap(); let dir = tmp.path().join("test"); fs::create_dir_all(&dir).unwrap(); fs::write(dir.join("a.txt"), b"hello").unwrap(); // 5 bytes fs::write(dir.join("b.txt"), b"world!").unwrap(); // 6 bytes let size = dir_size(&dir); assert_eq!(size, 11); } }