755 lines
23 KiB
Rust
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);
|
|
}
|
|
}
|