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,6 +1,21 @@
|
||||
use clap::Args as ClapArgs;
|
||||
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;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI arguments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Check cache health and diagnose issues
|
||||
#[derive(Debug, ClapArgs)]
|
||||
@@ -8,8 +23,747 @@ pub struct Args {
|
||||
/// Attempt to fix issues automatically
|
||||
#[arg(long)]
|
||||
pub fix: bool,
|
||||
|
||||
/// Check a specific alias only
|
||||
#[arg(long)]
|
||||
pub alias: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn execute(_args: &Args, _robot: bool) -> Result<(), SwaggerCliError> {
|
||||
Err(SwaggerCliError::Usage("doctor not yet implemented".into()))
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Compute total size of a directory (non-recursive into symlinks).
|
||||
fn dir_size(path: &PathBuf) -> u64 {
|
||||
let Ok(entries) = fs::read_dir(path) else {
|
||||
return 0;
|
||||
};
|
||||
let mut total: u64 = 0;
|
||||
for entry in entries.flatten() {
|
||||
if let Ok(md) = entry.metadata()
|
||||
&& md.is_file()
|
||||
{
|
||||
total += md.len();
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user