Files
swagger-cli/src/cli/doctor.rs

755 lines
23 KiB
Rust

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<String>,
}
// ---------------------------------------------------------------------------
// 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<AliasReport>,
warnings: Vec<String>,
total_disk_bytes: u64,
fixable_count: usize,
unfixable_count: usize,
}
#[derive(Debug, Serialize)]
struct AliasReport {
name: String,
status: String,
issues: Vec<String>,
disk_bytes: u64,
endpoint_count: usize,
}
// ---------------------------------------------------------------------------
// Internal check result
// ---------------------------------------------------------------------------
struct AliasCheckResult {
name: String,
status: HealthStatus,
issues: Vec<String>,
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<String> {
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<String> = 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<SpecIndex>, Option<CacheMetadata>) = 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<serde_json::Value> = 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::<serde_json::Value>(&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<String>, Vec<String>> {
let mut fixed: Vec<String> = Vec::new();
let mut unfixed: Vec<String> = 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<CacheMetadata> = 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<String> = 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<String> = 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<AliasCheckResult> = 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<AliasReport> = 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);
}
}