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:
teernisse
2026-02-12 12:54:10 -05:00
parent 9b29490f5f
commit faa6281790
13 changed files with 5157 additions and 110 deletions

View File

@@ -1,6 +1,18 @@
use clap::Args as ClapArgs; use std::time::Instant;
use chrono::{DateTime, Utc};
use clap::Args as ClapArgs;
use serde::Serialize;
use tabled::Tabled;
use crate::core::cache::{CacheManager, CacheMetadata, validate_alias};
use crate::core::config::{Config, cache_dir, config_path};
use crate::errors::SwaggerCliError; use crate::errors::SwaggerCliError;
use crate::output::{robot, table};
// ---------------------------------------------------------------------------
// CLI args
// ---------------------------------------------------------------------------
/// Manage spec aliases /// Manage spec aliases
#[derive(Debug, ClapArgs)] #[derive(Debug, ClapArgs)]
@@ -9,15 +21,589 @@ pub struct Args {
#[arg(long)] #[arg(long)]
pub list: bool, pub list: bool,
/// Remove an alias /// Show full details for an alias
#[arg(long)] #[arg(long)]
pub remove: Option<String>, pub show: Option<String>,
/// Rename an alias (old=new) /// Rename an alias (old new)
#[arg(long, num_args = 2, value_names = ["OLD", "NEW"])]
pub rename: Option<Vec<String>>,
/// Delete an alias
#[arg(long)] #[arg(long)]
pub rename: Option<String>, pub delete: Option<String>,
/// Set the default alias
#[arg(long)]
pub set_default: Option<String>,
} }
pub async fn execute(_args: &Args, _robot: bool) -> Result<(), SwaggerCliError> { // ---------------------------------------------------------------------------
Err(SwaggerCliError::Usage("aliases not yet implemented".into())) // Robot-mode output structs
// ---------------------------------------------------------------------------
#[derive(Debug, Serialize)]
struct AliasListEntry {
name: String,
source_url: Option<String>,
version: String,
is_default: bool,
cached_at: DateTime<Utc>,
endpoints: usize,
schemas: usize,
}
#[derive(Debug, Serialize)]
struct AliasListOutput {
aliases: Vec<AliasListEntry>,
default_alias: Option<String>,
count: usize,
}
#[derive(Debug, Serialize)]
struct AliasShowOutput {
name: String,
source_url: Option<String>,
title: String,
version: String,
is_default: bool,
cached_at: DateTime<Utc>,
last_accessed: DateTime<Utc>,
endpoints: usize,
schemas: usize,
source_format: String,
content_hash: String,
raw_size_bytes: u64,
generation: u64,
}
#[derive(Debug, Serialize)]
struct AliasRenameOutput {
old_name: String,
new_name: String,
updated_default: bool,
}
#[derive(Debug, Serialize)]
struct AliasDeleteOutput {
name: String,
cleared_default: bool,
}
#[derive(Debug, Serialize)]
struct AliasSetDefaultOutput {
name: String,
previous_default: Option<String>,
}
// ---------------------------------------------------------------------------
// Human-mode table row
// ---------------------------------------------------------------------------
#[derive(Tabled)]
struct AliasRow {
#[tabled(rename = "Alias")]
name: String,
#[tabled(rename = "Version")]
version: String,
#[tabled(rename = "Endpoints")]
endpoints: usize,
#[tabled(rename = "Schemas")]
schemas: usize,
#[tabled(rename = "Source")]
source: String,
#[tabled(rename = "Default")]
default_marker: String,
}
// ---------------------------------------------------------------------------
// Execute
// ---------------------------------------------------------------------------
pub async fn execute(args: &Args, robot: bool) -> Result<(), SwaggerCliError> {
let start = Instant::now();
if let Some(ref alias) = args.show {
return cmd_show(alias, robot, start);
}
if let Some(ref names) = args.rename {
return cmd_rename(names, robot, start);
}
if let Some(ref alias) = args.delete {
return cmd_delete(alias, robot, start);
}
if let Some(ref alias) = args.set_default {
return cmd_set_default(alias, robot, start);
}
// Default: list
cmd_list(robot, start)
}
// ---------------------------------------------------------------------------
// List
// ---------------------------------------------------------------------------
fn cmd_list(robot: bool, start: Instant) -> Result<(), SwaggerCliError> {
let cm = CacheManager::new(cache_dir());
let cfg = Config::load(&config_path(None))?;
let default_alias = cfg.default_alias.clone();
let metas = cm.list_aliases()?;
if robot {
let entries: Vec<AliasListEntry> = metas
.iter()
.map(|m| meta_to_list_entry(m, &default_alias))
.collect();
let count = entries.len();
let output = AliasListOutput {
aliases: entries,
default_alias,
count,
};
robot::robot_success(output, "aliases", start.elapsed());
} else {
print_human_list(&metas, &default_alias);
}
Ok(())
}
fn meta_to_list_entry(m: &CacheMetadata, default_alias: &Option<String>) -> AliasListEntry {
AliasListEntry {
name: m.alias.clone(),
source_url: m.url.clone(),
version: m.spec_version.clone(),
is_default: default_alias.as_deref() == Some(m.alias.as_str()),
cached_at: m.fetched_at,
endpoints: m.endpoint_count,
schemas: m.schema_count,
}
}
fn print_human_list(metas: &[CacheMetadata], default_alias: &Option<String>) {
if metas.is_empty() {
println!("No cached aliases. Use 'swagger-cli fetch <url>' to cache a spec.");
return;
}
let rows: Vec<AliasRow> = metas
.iter()
.map(|m| {
let is_default = default_alias.as_deref() == Some(m.alias.as_str());
AliasRow {
name: m.alias.clone(),
version: m.spec_version.clone(),
endpoints: m.endpoint_count,
schemas: m.schema_count,
source: m.url.as_deref().unwrap_or("-").to_string(),
default_marker: if is_default {
"*".to_string()
} else {
String::new()
},
}
})
.collect();
println!("{}", table::render_table(&rows));
if let Some(d) = default_alias {
println!("\n default: {d}");
}
}
// ---------------------------------------------------------------------------
// Show
// ---------------------------------------------------------------------------
fn cmd_show(alias: &str, robot: bool, start: Instant) -> Result<(), SwaggerCliError> {
let cm = CacheManager::new(cache_dir());
let cfg = Config::load(&config_path(None))?;
let default_alias = cfg.default_alias.clone();
let (_index, meta) = cm.load_index(alias)?;
if robot {
let output = meta_to_show_output(&meta, &default_alias);
robot::robot_success(output, "aliases", start.elapsed());
} else {
print_human_show(&meta, &default_alias);
}
Ok(())
}
fn meta_to_show_output(m: &CacheMetadata, default_alias: &Option<String>) -> AliasShowOutput {
AliasShowOutput {
name: m.alias.clone(),
source_url: m.url.clone(),
title: m.spec_title.clone(),
version: m.spec_version.clone(),
is_default: default_alias.as_deref() == Some(m.alias.as_str()),
cached_at: m.fetched_at,
last_accessed: m.last_accessed,
endpoints: m.endpoint_count,
schemas: m.schema_count,
source_format: m.source_format.clone(),
content_hash: m.content_hash.clone(),
raw_size_bytes: m.raw_size_bytes,
generation: m.generation,
}
}
fn print_human_show(m: &CacheMetadata, default_alias: &Option<String>) {
let is_default = default_alias.as_deref() == Some(m.alias.as_str());
println!("Alias: {}", m.alias);
println!("Title: {}", m.spec_title);
println!("Version: {}", m.spec_version);
println!("Source URL: {}", m.url.as_deref().unwrap_or("-"));
println!("Source format: {}", m.source_format);
println!("Endpoints: {}", m.endpoint_count);
println!("Schemas: {}", m.schema_count);
println!("Cached at: {}", m.fetched_at);
println!("Last accessed: {}", m.last_accessed);
println!("Content hash: {}", m.content_hash);
println!("Raw size: {} bytes", m.raw_size_bytes);
println!("Generation: {}", m.generation);
println!("Default: {}", if is_default { "yes" } else { "no" });
}
// ---------------------------------------------------------------------------
// Rename
// ---------------------------------------------------------------------------
fn cmd_rename(names: &[String], robot: bool, start: Instant) -> Result<(), SwaggerCliError> {
if names.len() != 2 {
return Err(SwaggerCliError::Usage(
"--rename requires exactly two values: OLD NEW".into(),
));
}
let old = &names[0];
let new = &names[1];
validate_alias(new)?;
let cm = CacheManager::new(cache_dir());
if !cm.alias_exists(old) {
return Err(SwaggerCliError::AliasNotFound(old.clone()));
}
if cm.alias_exists(new) {
return Err(SwaggerCliError::AliasExists(new.clone()));
}
let old_dir = cm.alias_dir(old);
let new_dir = cm.alias_dir(new);
std::fs::rename(&old_dir, &new_dir).map_err(|e| {
SwaggerCliError::Cache(format!(
"Failed to rename {} -> {}: {e}",
old_dir.display(),
new_dir.display()
))
})?;
// Update meta.json alias field
let meta_path = new_dir.join("meta.json");
if let Ok(bytes) = std::fs::read(&meta_path)
&& let Ok(mut meta) = serde_json::from_slice::<CacheMetadata>(&bytes)
{
meta.alias = new.clone();
if let Ok(updated_bytes) = serde_json::to_vec_pretty(&meta) {
let _ = std::fs::write(&meta_path, updated_bytes);
}
}
// Update config if old was the default
let cfg_path = config_path(None);
let mut cfg = Config::load(&cfg_path)?;
let updated_default = cfg.default_alias.as_deref() == Some(old.as_str());
if updated_default {
cfg.default_alias = Some(new.clone());
cfg.save(&cfg_path)?;
}
if robot {
let output = AliasRenameOutput {
old_name: old.clone(),
new_name: new.clone(),
updated_default,
};
robot::robot_success(output, "aliases", start.elapsed());
} else {
println!("Renamed '{}' -> '{}'", old, new);
if updated_default {
println!(" (default alias updated)");
}
}
Ok(())
}
// ---------------------------------------------------------------------------
// Delete
// ---------------------------------------------------------------------------
fn cmd_delete(alias: &str, robot: bool, start: Instant) -> Result<(), SwaggerCliError> {
let cm = CacheManager::new(cache_dir());
cm.delete_alias(alias)?;
// Clear default if it was this alias
let cfg_path = config_path(None);
let mut cfg = Config::load(&cfg_path)?;
let cleared_default = cfg.default_alias.as_deref() == Some(alias);
if cleared_default {
cfg.default_alias = None;
cfg.save(&cfg_path)?;
}
if robot {
let output = AliasDeleteOutput {
name: alias.to_string(),
cleared_default,
};
robot::robot_success(output, "aliases", start.elapsed());
} else {
println!("Deleted alias '{alias}'");
if cleared_default {
println!(" (cleared default alias)");
}
}
Ok(())
}
// ---------------------------------------------------------------------------
// Set default
// ---------------------------------------------------------------------------
fn cmd_set_default(alias: &str, robot: bool, start: Instant) -> Result<(), SwaggerCliError> {
let cm = CacheManager::new(cache_dir());
if !cm.alias_exists(alias) {
return Err(SwaggerCliError::AliasNotFound(alias.to_string()));
}
let cfg_path = config_path(None);
let mut cfg = Config::load(&cfg_path)?;
let previous_default = cfg.default_alias.clone();
cfg.default_alias = Some(alias.to_string());
cfg.save(&cfg_path)?;
if robot {
let output = AliasSetDefaultOutput {
name: alias.to_string(),
previous_default,
};
robot::robot_success(output, "aliases", start.elapsed());
} else {
println!("Default alias set to '{alias}'");
}
Ok(())
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::core::cache::CacheManager;
use crate::core::spec::{IndexInfo, SpecIndex};
fn make_test_index() -> SpecIndex {
SpecIndex {
index_version: 1,
generation: 1,
content_hash: "sha256:test".into(),
openapi: "3.0.3".into(),
info: IndexInfo {
title: "Test".into(),
version: "1.0.0".into(),
},
endpoints: vec![],
schemas: vec![],
tags: vec![],
}
}
fn write_test_alias(cm: &CacheManager, alias: &str) -> CacheMetadata {
let index = make_test_index();
cm.write_cache(
alias,
b"openapi: 3.0.3",
b"{\"openapi\":\"3.0.3\"}",
&index,
Some("https://example.com/api.json".into()),
"1.0.0",
"Test API",
"yaml",
None,
None,
None,
)
.unwrap()
}
#[test]
fn test_list_aliases_output_format() {
let tmp = tempfile::tempdir().unwrap();
let cm = CacheManager::new(tmp.path().to_path_buf());
write_test_alias(&cm, "petstore");
write_test_alias(&cm, "users-api");
let metas = cm.list_aliases().unwrap();
let default_alias = Some("petstore".to_string());
let entries: Vec<AliasListEntry> = metas
.iter()
.map(|m| meta_to_list_entry(m, &default_alias))
.collect();
assert_eq!(entries.len(), 2);
let pet = entries.iter().find(|e| e.name == "petstore").unwrap();
assert!(pet.is_default);
assert_eq!(pet.version, "1.0.0");
assert_eq!(pet.source_url, Some("https://example.com/api.json".into()));
let users = entries.iter().find(|e| e.name == "users-api").unwrap();
assert!(!users.is_default);
// Verify JSON round-trip
let output = AliasListOutput {
count: entries.len(),
aliases: entries,
default_alias,
};
let json = serde_json::to_string(&output).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["count"], 2);
assert!(parsed["aliases"].is_array());
assert_eq!(parsed["default_alias"], "petstore");
}
#[test]
fn test_rename_validation() {
let tmp = tempfile::tempdir().unwrap();
let cm = CacheManager::new(tmp.path().to_path_buf());
write_test_alias(&cm, "original");
// Renaming to an invalid name should fail at validation
let result = validate_alias("../evil");
assert!(result.is_err());
// Renaming to an existing alias should fail
write_test_alias(&cm, "taken");
assert!(cm.alias_exists("taken"));
// Renaming a non-existent alias should fail
assert!(!cm.alias_exists("ghost"));
// A valid rename should succeed
let old_dir = cm.alias_dir("original");
let new_dir = cm.alias_dir("renamed");
assert!(old_dir.exists());
assert!(!new_dir.exists());
std::fs::rename(&old_dir, &new_dir).unwrap();
assert!(!old_dir.exists());
assert!(new_dir.exists());
}
#[test]
fn test_show_output_fields() {
let tmp = tempfile::tempdir().unwrap();
let cm = CacheManager::new(tmp.path().to_path_buf());
write_test_alias(&cm, "showtest");
let (_index, meta) = cm.load_index("showtest").unwrap();
let default_alias = Some("showtest".to_string());
let output = meta_to_show_output(&meta, &default_alias);
assert_eq!(output.name, "showtest");
assert_eq!(output.title, "Test API");
assert_eq!(output.version, "1.0.0");
assert!(output.is_default);
assert_eq!(output.source_format, "yaml");
assert_eq!(output.generation, 1);
assert!(output.content_hash.starts_with("sha256:"));
// JSON round-trip
let json = serde_json::to_string(&output).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["name"], "showtest");
assert_eq!(parsed["is_default"], true);
}
#[test]
fn test_delete_clears_default() {
let tmp = tempfile::tempdir().unwrap();
let cm = CacheManager::new(tmp.path().to_path_buf());
let cfg_path = tmp.path().join("config.toml");
write_test_alias(&cm, "doomed");
let cfg = Config {
default_alias: Some("doomed".to_string()),
..Config::default()
};
cfg.save(&cfg_path).unwrap();
// Delete the alias
cm.delete_alias("doomed").unwrap();
assert!(!cm.alias_exists("doomed"));
// Simulate clearing default
let mut cfg = Config::load(&cfg_path).unwrap();
let cleared = cfg.default_alias.as_deref() == Some("doomed");
assert!(cleared);
cfg.default_alias = None;
cfg.save(&cfg_path).unwrap();
let cfg = Config::load(&cfg_path).unwrap();
assert!(cfg.default_alias.is_none());
}
#[test]
fn test_set_default_requires_existing_alias() {
let tmp = tempfile::tempdir().unwrap();
let cm = CacheManager::new(tmp.path().to_path_buf());
assert!(!cm.alias_exists("nonexistent"));
write_test_alias(&cm, "real");
assert!(cm.alias_exists("real"));
}
#[test]
fn test_rename_updates_meta_alias_field() {
let tmp = tempfile::tempdir().unwrap();
let cm = CacheManager::new(tmp.path().to_path_buf());
write_test_alias(&cm, "old-name");
let old_dir = cm.alias_dir("old-name");
let new_dir = cm.alias_dir("new-name");
std::fs::rename(&old_dir, &new_dir).unwrap();
// Simulate the meta.json update that cmd_rename does
let meta_path = new_dir.join("meta.json");
let bytes = std::fs::read(&meta_path).unwrap();
let mut meta: CacheMetadata = serde_json::from_slice(&bytes).unwrap();
assert_eq!(meta.alias, "old-name");
meta.alias = "new-name".to_string();
let updated = serde_json::to_vec_pretty(&meta).unwrap();
std::fs::write(&meta_path, updated).unwrap();
let bytes = std::fs::read(&meta_path).unwrap();
let meta: CacheMetadata = serde_json::from_slice(&bytes).unwrap();
assert_eq!(meta.alias, "new-name");
}
} }

View File

@@ -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::errors::SwaggerCliError;
use crate::output::robot::robot_success;
use crate::output::table::render_table_or_empty;
// ---------------------------------------------------------------------------
// CLI args
// ---------------------------------------------------------------------------
/// Manage the spec cache /// Manage the spec cache
#[derive(Debug, ClapArgs)] #[derive(Debug, ClapArgs)]
pub struct Args { 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)] #[arg(long)]
pub path: bool, pub path: bool,
/// Clear the entire cache /// Remove aliases whose fetched_at exceeds the stale threshold
#[arg(long)] #[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)] #[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"));
}
} }

View File

@@ -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::errors::SwaggerCliError;
use crate::output::robot;
// ---------------------------------------------------------------------------
// CLI arguments
// ---------------------------------------------------------------------------
/// Check cache health and diagnose issues /// Check cache health and diagnose issues
#[derive(Debug, ClapArgs)] #[derive(Debug, ClapArgs)]
@@ -8,8 +23,747 @@ pub struct Args {
/// Attempt to fix issues automatically /// Attempt to fix issues automatically
#[arg(long)] #[arg(long)]
pub fix: bool, 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);
}
} }

View File

@@ -1,26 +1,865 @@
use clap::Args as ClapArgs; use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use chrono::{DateTime, Utc};
use clap::Args as ClapArgs;
use serde::Serialize;
use tokio::io::AsyncReadExt;
use crate::core::cache::{CacheManager, compute_hash, validate_alias};
use crate::core::config::{AuthType, Config, CredentialSource, cache_dir, config_path};
use crate::core::http::AsyncHttpClient;
use crate::core::indexer::{Format, build_index, detect_format, normalize_to_json};
use crate::errors::SwaggerCliError; use crate::errors::SwaggerCliError;
use crate::output::robot;
// ---------------------------------------------------------------------------
// CLI arguments
// ---------------------------------------------------------------------------
/// Fetch and cache an OpenAPI spec /// Fetch and cache an OpenAPI spec
#[derive(Debug, ClapArgs)] #[derive(Debug, ClapArgs)]
pub struct Args { pub struct Args {
/// URL of the OpenAPI spec /// URL, file path, or "-" for stdin
pub url: String, pub url: String,
/// Alias for the cached spec /// Alias for the cached spec
#[arg(long)] #[arg(long)]
pub alias: Option<String>, pub alias: String,
/// Additional HTTP header (repeatable, format: "Name: Value")
#[arg(long = "header", short = 'H')]
pub header: Vec<String>,
/// Bearer token for Authorization header
#[arg(long)]
pub bearer: Option<String>,
/// Auth profile name from config
#[arg(long)]
pub auth_profile: Option<String>,
/// Overwrite existing alias /// Overwrite existing alias
#[arg(long)] #[arg(long)]
pub force: bool, pub force: bool,
/// Auth profile name from config /// HTTP request timeout in milliseconds
#[arg(long, default_value = "10000")]
pub timeout_ms: u64,
/// Maximum response size in bytes
#[arg(long, default_value = "26214400")]
pub max_bytes: u64,
/// Number of retries on transient errors
#[arg(long, default_value = "2")]
pub retries: u32,
/// Allow private/internal host (repeatable)
#[arg(long = "allow-private-host")]
pub allow_private_host: Vec<String>,
/// Allow plain HTTP (insecure)
#[arg(long)] #[arg(long)]
pub auth: Option<String>, pub allow_insecure_http: bool,
} }
pub async fn execute(_args: &Args, _robot: bool) -> Result<(), SwaggerCliError> { // ---------------------------------------------------------------------------
Err(SwaggerCliError::Usage("fetch not yet implemented".into())) // Robot output data struct
// ---------------------------------------------------------------------------
#[derive(Debug, Serialize)]
struct FetchOutput {
alias: String,
url: String,
title: String,
version: String,
endpoint_count: usize,
schema_count: usize,
cached_at: DateTime<Utc>,
source_format: String,
content_hash: String,
}
// ---------------------------------------------------------------------------
// Source classification
// ---------------------------------------------------------------------------
#[derive(Debug, PartialEq)]
enum SourceKind {
Stdin,
LocalFile(String),
Url(String),
}
fn classify_source(url: &str) -> SourceKind {
if url == "-" {
return SourceKind::Stdin;
}
// Strip file:// prefix for local file access
if let Some(path) = url.strip_prefix("file://") {
return SourceKind::LocalFile(path.to_string());
}
// If it looks like a URL (has scheme), treat as URL
if url.contains("://") {
return SourceKind::Url(url.to_string());
}
// If the path exists on disk, treat as local file
if Path::new(url).exists() {
return SourceKind::LocalFile(url.to_string());
}
// Default: assume it's a URL (will fail with a helpful error in the HTTP client)
SourceKind::Url(url.to_string())
}
// ---------------------------------------------------------------------------
// Auth header resolution
// ---------------------------------------------------------------------------
/// Resolve a credential source to its string value.
fn resolve_credential(source: &CredentialSource) -> Result<String, SwaggerCliError> {
match source {
CredentialSource::Literal { value } => Ok(value.clone()),
CredentialSource::EnvVar { name } => std::env::var(name).map_err(|_| {
SwaggerCliError::Auth(format!(
"environment variable '{name}' not set (required by auth profile)"
))
}),
CredentialSource::Keyring { service, account } => Err(SwaggerCliError::Auth(format!(
"keyring credential lookup not yet implemented (service={service}, account={account})"
))),
}
}
/// Build the list of auth headers from CLI flags and config auth profile.
///
/// Precedence: --bearer and --header flags override auth profile values.
/// Auth header values are never logged or included in output.
fn resolve_auth_headers(
args: &Args,
config: &Config,
) -> Result<Vec<(String, String)>, SwaggerCliError> {
let mut headers: Vec<(String, String)> = Vec::new();
// 1. Auth profile from config (lowest precedence)
if let Some(profile_name) = &args.auth_profile {
let profile = config.auth_profiles.get(profile_name).ok_or_else(|| {
SwaggerCliError::Auth(format!("auth profile '{profile_name}' not found in config"))
})?;
let credential = resolve_credential(&profile.credential)?;
match &profile.auth_type {
AuthType::Bearer => {
headers.push(("Authorization".to_string(), format!("Bearer {credential}")));
}
AuthType::ApiKey { header } => {
headers.push((header.clone(), credential));
}
}
}
// 2. --bearer flag (overrides profile Authorization header)
if let Some(token) = &args.bearer {
headers.retain(|(name, _)| !name.eq_ignore_ascii_case("authorization"));
headers.push(("Authorization".to_string(), format!("Bearer {token}")));
}
// 3. --header flags (highest precedence, override matching names)
for raw in &args.header {
let (name, value) = parse_header(raw)?;
headers.retain(|(n, _)| !n.eq_ignore_ascii_case(&name));
headers.push((name, value));
}
Ok(headers)
}
/// Parse a "Name: Value" header string.
fn parse_header(raw: &str) -> Result<(String, String), SwaggerCliError> {
let Some((name, value)) = raw.split_once(':') else {
return Err(SwaggerCliError::Usage(format!(
"invalid header format: '{raw}'. Expected 'Name: Value'"
)));
};
let name = name.trim().to_string();
let value = value.trim().to_string();
if name.is_empty() {
return Err(SwaggerCliError::Usage(
"header name cannot be empty".to_string(),
));
}
Ok((name, value))
}
// ---------------------------------------------------------------------------
// Core fetch pipeline (testable without env var mutation)
// ---------------------------------------------------------------------------
/// Run the full fetch pipeline with an explicit cache path.
///
/// Separated from `execute()` so integration tests can provide a temp cache
/// directory without mutating environment variables (which is unsafe in
/// Rust edition 2024 with `#![forbid(unsafe_code)]`).
async fn fetch_inner(
args: &Args,
cache_path: PathBuf,
robot_mode: bool,
) -> Result<(), SwaggerCliError> {
let start = Instant::now();
// 1. Build cache manager and validate alias
let cm = CacheManager::new(cache_path);
validate_alias(&args.alias)?;
// 2. Check alias exists (unless --force)
if cm.alias_exists(&args.alias) && !args.force {
return Err(SwaggerCliError::AliasExists(args.alias.clone()));
}
// 3. Load config and resolve auth headers
let cfg = Config::load(&config_path(None))?;
let auth_headers = resolve_auth_headers(args, &cfg)?;
// 4. Fetch raw bytes based on source kind
let source = classify_source(&args.url);
let (raw_bytes, content_type_hint, source_url, filename_hint): (
Vec<u8>,
Option<String>,
Option<String>,
Option<String>,
) = match &source {
SourceKind::Stdin => {
let mut buf = Vec::new();
tokio::io::stdin().read_to_end(&mut buf).await?;
(buf, None, None, None)
}
SourceKind::LocalFile(path) => {
let bytes = std::fs::read(path).map_err(|e| {
SwaggerCliError::Io(std::io::Error::new(
e.kind(),
format!("failed to read file '{path}': {e}"),
))
})?;
let filename = Path::new(path)
.file_name()
.map(|f| f.to_string_lossy().to_string());
(bytes, None, Some(format!("file://{path}")), filename)
}
SourceKind::Url(url) => {
let mut builder = AsyncHttpClient::builder()
.overall_timeout(Duration::from_millis(args.timeout_ms))
.max_bytes(args.max_bytes)
.max_retries(args.retries)
.allow_insecure_http(args.allow_insecure_http)
.allowed_private_hosts(args.allow_private_host.clone());
for (name, value) in &auth_headers {
builder = builder.auth_header(name.clone(), value.clone());
}
let client = builder.build();
let result = client.fetch_spec(url).await?;
(
result.bytes,
result.content_type,
Some(url.clone()),
Some(url.clone()),
)
}
};
// 5. Detect format, normalize to JSON, parse, build index
let format = detect_format(
&raw_bytes,
filename_hint.as_deref(),
content_type_hint.as_deref(),
);
let format_str = match format {
Format::Json => "json",
Format::Yaml => "yaml",
};
let json_bytes = normalize_to_json(&raw_bytes, format)?;
let value: serde_json::Value = serde_json::from_slice(&json_bytes)?;
// Compute content hash for indexing
let content_hash = compute_hash(&raw_bytes);
// Determine generation: if overwriting, increment previous generation
let previous_generation = if args.force && cm.alias_exists(&args.alias) {
cm.load_index(&args.alias)
.ok()
.map(|(_, meta)| meta.generation)
} else {
None
};
let index = build_index(&value, &content_hash, previous_generation.unwrap_or(0) + 1)?;
let spec_title = index.info.title.clone();
let spec_version = index.info.version.clone();
// 6. Write to cache
let meta = cm.write_cache(
&args.alias,
&raw_bytes,
&json_bytes,
&index,
source_url.clone(),
&spec_version,
&spec_title,
format_str,
None, // etag
None, // last_modified
previous_generation,
)?;
// 7. Output
let duration = start.elapsed();
if robot_mode {
let output = FetchOutput {
alias: args.alias.clone(),
url: source_url.unwrap_or_else(|| "stdin".to_string()),
title: spec_title,
version: spec_version,
endpoint_count: meta.endpoint_count,
schema_count: meta.schema_count,
cached_at: meta.fetched_at,
source_format: format_str.to_string(),
content_hash: meta.content_hash,
};
robot::robot_success(output, "fetch", duration);
} else {
println!("Fetched '{}' as alias '{}'", args.url, args.alias);
println!(
" {} v{} -- {} endpoints, {} schemas ({})",
meta.spec_title, meta.spec_version, meta.endpoint_count, meta.schema_count, format_str,
);
}
Ok(())
}
// ---------------------------------------------------------------------------
// Public entry point
// ---------------------------------------------------------------------------
pub async fn execute(args: &Args, robot_mode: bool) -> Result<(), SwaggerCliError> {
let cache = cache_dir();
fetch_inner(args, cache, robot_mode).await
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
// -- Source classification -----------------------------------------------
#[test]
fn test_classify_source_stdin() {
assert_eq!(classify_source("-"), SourceKind::Stdin);
}
#[test]
fn test_classify_source_file_prefix() {
assert_eq!(
classify_source("file:///tmp/spec.json"),
SourceKind::LocalFile("/tmp/spec.json".to_string()),
);
}
#[test]
fn test_classify_source_url() {
assert_eq!(
classify_source("https://example.com/api.json"),
SourceKind::Url("https://example.com/api.json".to_string()),
);
assert_eq!(
classify_source("http://localhost:8080/spec.yaml"),
SourceKind::Url("http://localhost:8080/spec.yaml".to_string()),
);
}
#[test]
fn test_classify_source_ambiguous_defaults_to_url() {
let result = classify_source("not-a-real-file-on-disk-xyz.json");
assert_eq!(
result,
SourceKind::Url("not-a-real-file-on-disk-xyz.json".to_string()),
);
}
// -- Header parsing ------------------------------------------------------
#[test]
fn test_parse_header_valid() {
let (name, value) = parse_header("X-Custom: my-value").unwrap();
assert_eq!(name, "X-Custom");
assert_eq!(value, "my-value");
}
#[test]
fn test_parse_header_with_colons_in_value() {
let (name, value) = parse_header("Authorization: Bearer abc:def:ghi").unwrap();
assert_eq!(name, "Authorization");
assert_eq!(value, "Bearer abc:def:ghi");
}
#[test]
fn test_parse_header_invalid_no_colon() {
let result = parse_header("NoColonHere");
assert!(result.is_err());
}
#[test]
fn test_parse_header_empty_name() {
let result = parse_header(": value");
assert!(result.is_err());
}
// -- Auth header resolution ----------------------------------------------
fn make_base_args() -> Args {
Args {
url: "https://example.com".to_string(),
alias: "test".to_string(),
header: vec![],
bearer: None,
auth_profile: None,
force: false,
timeout_ms: 10000,
max_bytes: 26214400,
retries: 2,
allow_private_host: vec![],
allow_insecure_http: false,
}
}
#[test]
fn test_resolve_auth_headers_bearer_flag() {
let mut args = make_base_args();
args.bearer = Some("my-token".to_string());
let config = Config::default();
let headers = resolve_auth_headers(&args, &config).unwrap();
assert_eq!(headers.len(), 1);
assert_eq!(headers[0].0, "Authorization");
assert_eq!(headers[0].1, "Bearer my-token");
}
#[test]
fn test_resolve_auth_headers_custom_header() {
let mut args = make_base_args();
args.header = vec!["X-Api-Key: secret123".to_string()];
let config = Config::default();
let headers = resolve_auth_headers(&args, &config).unwrap();
assert_eq!(headers.len(), 1);
assert_eq!(headers[0].0, "X-Api-Key");
assert_eq!(headers[0].1, "secret123");
}
#[test]
fn test_resolve_auth_headers_bearer_overrides_profile() {
use crate::core::config::AuthConfig;
use std::collections::BTreeMap;
let mut profiles = BTreeMap::new();
profiles.insert(
"myprofile".to_string(),
AuthConfig {
auth_type: AuthType::Bearer,
credential: CredentialSource::Literal {
value: "profile-token".to_string(),
},
},
);
let config = Config {
auth_profiles: profiles,
..Config::default()
};
let mut args = make_base_args();
args.bearer = Some("override-token".to_string());
args.auth_profile = Some("myprofile".to_string());
let headers = resolve_auth_headers(&args, &config).unwrap();
assert_eq!(headers.len(), 1);
assert_eq!(headers[0].0, "Authorization");
assert_eq!(headers[0].1, "Bearer override-token");
}
#[test]
fn test_resolve_auth_headers_missing_profile() {
let mut args = make_base_args();
args.auth_profile = Some("nonexistent".to_string());
let config = Config::default();
let result = resolve_auth_headers(&args, &config);
assert!(result.is_err());
match result.unwrap_err() {
SwaggerCliError::Auth(msg) => {
assert!(msg.contains("nonexistent"));
}
other => panic!("expected Auth error, got: {other:?}"),
}
}
#[test]
fn test_resolve_auth_headers_literal_credential_profile() {
use crate::core::config::AuthConfig;
use std::collections::BTreeMap;
let mut profiles = BTreeMap::new();
profiles.insert(
"literal-profile".to_string(),
AuthConfig {
auth_type: AuthType::ApiKey {
header: "X-Api-Key".to_string(),
},
credential: CredentialSource::Literal {
value: "my-api-key".to_string(),
},
},
);
let config = Config {
auth_profiles: profiles,
..Config::default()
};
let mut args = make_base_args();
args.auth_profile = Some("literal-profile".to_string());
let headers = resolve_auth_headers(&args, &config).unwrap();
assert_eq!(headers.len(), 1);
assert_eq!(headers[0].0, "X-Api-Key");
assert_eq!(headers[0].1, "my-api-key");
}
#[test]
fn test_resolve_auth_headers_header_overrides_profile() {
use crate::core::config::AuthConfig;
use std::collections::BTreeMap;
let mut profiles = BTreeMap::new();
profiles.insert(
"apikey-profile".to_string(),
AuthConfig {
auth_type: AuthType::ApiKey {
header: "X-Api-Key".to_string(),
},
credential: CredentialSource::Literal {
value: "profile-key".to_string(),
},
},
);
let config = Config {
auth_profiles: profiles,
..Config::default()
};
let mut args = make_base_args();
args.header = vec!["X-Api-Key: override-key".to_string()];
args.auth_profile = Some("apikey-profile".to_string());
let headers = resolve_auth_headers(&args, &config).unwrap();
assert_eq!(headers.len(), 1);
assert_eq!(headers[0].0, "X-Api-Key");
assert_eq!(headers[0].1, "override-key");
}
#[test]
fn test_resolve_auth_headers_no_auth() {
let args = make_base_args();
let config = Config::default();
let headers = resolve_auth_headers(&args, &config).unwrap();
assert!(headers.is_empty());
}
// -- Alias validation (integration) --------------------------------------
#[test]
fn test_alias_validation_integration() {
assert!(validate_alias("petstore").is_ok());
assert!(validate_alias("my-api").is_ok());
assert!(validate_alias("v1.0").is_ok());
assert!(validate_alias("API_2").is_ok());
assert!(validate_alias("").is_err());
assert!(validate_alias("../etc").is_err());
assert!(validate_alias(".hidden").is_err());
assert!(validate_alias("CON").is_err());
}
// -- Full pipeline integration tests (using fetch_inner) -----------------
fn make_test_args(url: &str, alias: &str) -> Args {
Args {
url: url.to_string(),
alias: alias.to_string(),
header: vec![],
bearer: None,
auth_profile: None,
force: false,
timeout_ms: 10000,
max_bytes: 26214400,
retries: 2,
allow_private_host: vec![],
allow_insecure_http: false,
}
}
#[tokio::test]
async fn test_execute_local_file() {
let tmp = tempfile::tempdir().unwrap();
let spec_path = tmp.path().join("petstore.json");
let spec = 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" } }
}
}
}
});
std::fs::write(&spec_path, serde_json::to_vec_pretty(&spec).unwrap()).unwrap();
let cache_path = tmp.path().join("cache");
std::fs::create_dir_all(&cache_path).unwrap();
let args = make_test_args(spec_path.to_str().unwrap(), "localtest");
let result = fetch_inner(&args, cache_path.clone(), false).await;
assert!(result.is_ok(), "execute failed: {result:?}");
let cm = CacheManager::new(cache_path);
assert!(cm.alias_exists("localtest"));
let (index, meta) = cm.load_index("localtest").unwrap();
assert_eq!(meta.spec_title, "Test API");
assert_eq!(meta.spec_version, "1.0.0");
assert_eq!(index.endpoints.len(), 1);
assert_eq!(meta.source_format, "json");
}
#[tokio::test]
async fn test_execute_yaml_local_file() {
let tmp = tempfile::tempdir().unwrap();
let spec_path = tmp.path().join("api.yaml");
let yaml = r#"
openapi: "3.0.3"
info:
title: YAML API
version: "2.0.0"
paths:
/items:
get:
summary: List items
responses:
"200":
description: OK
post:
summary: Create item
responses:
"201":
description: Created
"#;
std::fs::write(&spec_path, yaml).unwrap();
let cache_path = tmp.path().join("cache");
std::fs::create_dir_all(&cache_path).unwrap();
let args = make_test_args(spec_path.to_str().unwrap(), "yamltest");
let result = fetch_inner(&args, cache_path.clone(), false).await;
assert!(result.is_ok(), "execute failed: {result:?}");
let cm = CacheManager::new(cache_path);
let (index, meta) = cm.load_index("yamltest").unwrap();
assert_eq!(meta.spec_title, "YAML API");
assert_eq!(meta.spec_version, "2.0.0");
assert_eq!(meta.source_format, "yaml");
assert_eq!(index.endpoints.len(), 2);
}
#[tokio::test]
async fn test_execute_alias_exists_without_force() {
let tmp = tempfile::tempdir().unwrap();
let spec_path = tmp.path().join("spec.json");
let spec = serde_json::json!({
"openapi": "3.0.3",
"info": { "title": "Test", "version": "1.0.0" },
"paths": {}
});
std::fs::write(&spec_path, serde_json::to_vec(&spec).unwrap()).unwrap();
let cache_path = tmp.path().join("cache");
std::fs::create_dir_all(&cache_path).unwrap();
let args = make_test_args(spec_path.to_str().unwrap(), "dupetest");
assert!(fetch_inner(&args, cache_path.clone(), false).await.is_ok());
let result = fetch_inner(&args, cache_path, false).await;
assert!(result.is_err());
match result.unwrap_err() {
SwaggerCliError::AliasExists(alias) => assert_eq!(alias, "dupetest"),
other => panic!("expected AliasExists, got: {other:?}"),
}
}
#[tokio::test]
async fn test_execute_force_overwrites() {
let tmp = tempfile::tempdir().unwrap();
let spec_path = tmp.path().join("spec.json");
let spec = serde_json::json!({
"openapi": "3.0.3",
"info": { "title": "Version 1", "version": "1.0.0" },
"paths": {}
});
std::fs::write(&spec_path, serde_json::to_vec(&spec).unwrap()).unwrap();
let cache_path = tmp.path().join("cache");
std::fs::create_dir_all(&cache_path).unwrap();
let args_v1 = make_test_args(spec_path.to_str().unwrap(), "forcetest");
assert!(
fetch_inner(&args_v1, cache_path.clone(), false)
.await
.is_ok()
);
let spec_v2 = serde_json::json!({
"openapi": "3.0.3",
"info": { "title": "Version 2", "version": "2.0.0" },
"paths": {}
});
std::fs::write(&spec_path, serde_json::to_vec(&spec_v2).unwrap()).unwrap();
let mut args_v2 = make_test_args(spec_path.to_str().unwrap(), "forcetest");
args_v2.force = true;
assert!(
fetch_inner(&args_v2, cache_path.clone(), false)
.await
.is_ok()
);
let cm = CacheManager::new(cache_path);
let (_, meta) = cm.load_index("forcetest").unwrap();
assert_eq!(meta.spec_title, "Version 2");
assert_eq!(meta.generation, 2);
}
#[tokio::test]
async fn test_execute_robot_output() {
let tmp = tempfile::tempdir().unwrap();
let spec_path = tmp.path().join("robot.json");
let spec = serde_json::json!({
"openapi": "3.0.3",
"info": { "title": "Robot Test", "version": "0.1.0" },
"paths": {}
});
std::fs::write(&spec_path, serde_json::to_vec(&spec).unwrap()).unwrap();
let cache_path = tmp.path().join("cache");
std::fs::create_dir_all(&cache_path).unwrap();
let args = make_test_args(spec_path.to_str().unwrap(), "robottest");
let result = fetch_inner(&args, cache_path.clone(), true).await;
assert!(result.is_ok(), "robot mode execute failed: {result:?}");
let cm = CacheManager::new(cache_path);
assert!(cm.alias_exists("robottest"));
}
#[tokio::test]
async fn test_execute_invalid_alias() {
let tmp = tempfile::tempdir().unwrap();
let spec_path = tmp.path().join("spec.json");
let spec = serde_json::json!({
"openapi": "3.0.3",
"info": { "title": "Test", "version": "1.0.0" },
"paths": {}
});
std::fs::write(&spec_path, serde_json::to_vec(&spec).unwrap()).unwrap();
let cache_path = tmp.path().join("cache");
std::fs::create_dir_all(&cache_path).unwrap();
let args = make_test_args(spec_path.to_str().unwrap(), "../bad-alias");
let result = fetch_inner(&args, cache_path, false).await;
assert!(result.is_err());
match result.unwrap_err() {
SwaggerCliError::Usage(msg) => {
assert!(msg.contains("Invalid alias"));
}
other => panic!("expected Usage error, got: {other:?}"),
}
}
#[tokio::test]
async fn test_execute_file_prefix() {
let tmp = tempfile::tempdir().unwrap();
let spec_path = tmp.path().join("fileprefix.json");
let spec = serde_json::json!({
"openapi": "3.0.3",
"info": { "title": "File Prefix Test", "version": "1.0.0" },
"paths": {}
});
std::fs::write(&spec_path, serde_json::to_vec(&spec).unwrap()).unwrap();
let cache_path = tmp.path().join("cache");
std::fs::create_dir_all(&cache_path).unwrap();
let url = format!("file://{}", spec_path.to_str().unwrap());
let args = make_test_args(&url, "fileprefixtest");
let result = fetch_inner(&args, cache_path.clone(), false).await;
assert!(result.is_ok(), "file:// prefix failed: {result:?}");
let cm = CacheManager::new(cache_path);
let (_, meta) = cm.load_index("fileprefixtest").unwrap();
assert_eq!(meta.spec_title, "File Prefix Test");
}
#[tokio::test]
async fn test_execute_nonexistent_file() {
let tmp = tempfile::tempdir().unwrap();
let cache_path = tmp.path().join("cache");
std::fs::create_dir_all(&cache_path).unwrap();
let args = make_test_args("file:///nonexistent/path/spec.json", "nofile");
let result = fetch_inner(&args, cache_path, false).await;
assert!(result.is_err());
assert!(
matches!(result.unwrap_err(), SwaggerCliError::Io(_)),
"expected Io error for missing file",
);
}
} }

View File

@@ -1,6 +1,16 @@
use clap::Args as ClapArgs; use std::collections::BTreeMap;
use std::time::Instant;
use clap::Args as ClapArgs;
use regex::Regex;
use serde::Serialize;
use tabled::Tabled;
use crate::core::cache::CacheManager;
use crate::core::config::cache_dir;
use crate::errors::SwaggerCliError; use crate::errors::SwaggerCliError;
use crate::output::robot;
use crate::output::table::render_table_or_empty;
/// List endpoints from a cached spec /// List endpoints from a cached spec
#[derive(Debug, ClapArgs)] #[derive(Debug, ClapArgs)]
@@ -8,19 +18,657 @@ pub struct Args {
/// Alias of the cached spec /// Alias of the cached spec
pub alias: String, pub alias: String,
/// Filter by HTTP method /// Filter by HTTP method (case-insensitive)
#[arg(long)] #[arg(long, short = 'm')]
pub method: Option<String>, pub method: Option<String>,
/// Filter by tag /// Filter by tag (endpoints containing this tag)
#[arg(long)] #[arg(long, short = 't')]
pub tag: Option<String>, pub tag: Option<String>,
/// Filter by path pattern /// Filter by path pattern (regex)
#[arg(long)] #[arg(long, short = 'p')]
pub path: Option<String>, pub path: Option<String>,
/// Sort order: path, method, or tag
#[arg(long, default_value = "path", value_parser = ["path", "method", "tag"])]
pub sort: String,
/// Maximum number of endpoints to show
#[arg(long, short = 'n', default_value = "50")]
pub limit: usize,
/// Show all endpoints (no limit)
#[arg(long, short = 'a')]
pub all: bool,
} }
pub async fn execute(_args: &Args, _robot: bool) -> Result<(), SwaggerCliError> { // ---------------------------------------------------------------------------
Err(SwaggerCliError::Usage("list not yet implemented".into())) // Robot output structs
// ---------------------------------------------------------------------------
#[derive(Debug, Serialize)]
struct ListOutput {
endpoints: Vec<EndpointEntry>,
total: usize,
filtered: usize,
applied_filters: BTreeMap<String, String>,
meta: ListMeta,
}
#[derive(Debug, Serialize)]
struct EndpointEntry {
path: String,
method: String,
summary: Option<String>,
operation_id: Option<String>,
tags: Vec<String>,
deprecated: bool,
}
#[derive(Debug, Serialize)]
struct ListMeta {
alias: String,
spec_version: String,
cached_at: String,
duration_ms: u64,
}
// ---------------------------------------------------------------------------
// Human output row
// ---------------------------------------------------------------------------
#[derive(Tabled)]
struct EndpointRow {
#[tabled(rename = "METHOD")]
method: String,
#[tabled(rename = "PATH")]
path: String,
#[tabled(rename = "SUMMARY")]
summary: String,
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Map an HTTP method string to a sort rank.
/// GET=0, POST=1, PUT=2, PATCH=3, DELETE=4, everything else=5.
fn method_rank(method: &str) -> u8 {
match method.to_uppercase().as_str() {
"GET" => 0,
"POST" => 1,
"PUT" => 2,
"PATCH" => 3,
"DELETE" => 4,
_ => 5,
}
}
// ---------------------------------------------------------------------------
// Execute
// ---------------------------------------------------------------------------
pub async fn execute(args: &Args, robot_mode: bool) -> Result<(), SwaggerCliError> {
let start = Instant::now();
// Compile path regex early so we fail fast on invalid patterns
let path_regex = match &args.path {
Some(pattern) => {
let re = Regex::new(pattern).map_err(|e| {
SwaggerCliError::Usage(format!("Invalid path regex '{pattern}': {e}"))
})?;
Some(re)
}
None => None,
};
let cm = CacheManager::new(cache_dir());
let (index, meta) = cm.load_index(&args.alias)?;
let total = index.endpoints.len();
// ---- Filter ----
let method_upper = args.method.as_ref().map(|m| m.to_uppercase());
let tag_lower = args.tag.as_ref().map(|t| t.to_lowercase());
let mut filtered: Vec<_> = index
.endpoints
.into_iter()
.filter(|ep| {
if let Some(ref m) = method_upper
&& ep.method.to_uppercase() != *m
{
return false;
}
if let Some(ref t) = tag_lower
&& !ep
.tags
.iter()
.any(|tag| tag.to_lowercase().contains(t.as_str()))
{
return false;
}
if let Some(ref re) = path_regex
&& !re.is_match(&ep.path)
{
return false;
}
true
})
.collect();
let filtered_count = filtered.len();
// ---- Sort ----
match args.sort.as_str() {
"method" => {
filtered.sort_by(|a, b| {
method_rank(&a.method)
.cmp(&method_rank(&b.method))
.then_with(|| a.path.cmp(&b.path))
});
}
"tag" => {
filtered.sort_by(|a, b| {
let tag_a = a.tags.first().map(String::as_str).unwrap_or("");
let tag_b = b.tags.first().map(String::as_str).unwrap_or("");
tag_a
.cmp(tag_b)
.then_with(|| a.path.cmp(&b.path))
.then_with(|| method_rank(&a.method).cmp(&method_rank(&b.method)))
});
}
// "path" or anything else: default sort
_ => {
filtered.sort_by(|a, b| {
a.path
.cmp(&b.path)
.then_with(|| method_rank(&a.method).cmp(&method_rank(&b.method)))
});
}
}
// ---- Limit ----
if !args.all {
filtered.truncate(args.limit);
}
let duration = start.elapsed();
// ---- Output ----
if robot_mode {
let mut applied_filters = BTreeMap::new();
if let Some(ref m) = args.method {
applied_filters.insert("method".into(), m.clone());
}
if let Some(ref t) = args.tag {
applied_filters.insert("tag".into(), t.clone());
}
if let Some(ref p) = args.path {
applied_filters.insert("path".into(), p.clone());
}
let entries: Vec<EndpointEntry> = filtered
.iter()
.map(|ep| EndpointEntry {
path: ep.path.clone(),
method: ep.method.clone(),
summary: ep.summary.clone(),
operation_id: ep.operation_id.clone(),
tags: ep.tags.clone(),
deprecated: ep.deprecated,
})
.collect();
let output = ListOutput {
endpoints: entries,
total,
filtered: filtered_count,
applied_filters,
meta: ListMeta {
alias: args.alias.clone(),
spec_version: meta.spec_version.clone(),
cached_at: meta.fetched_at.to_rfc3339(),
duration_ms: duration.as_millis().min(u64::MAX as u128) as u64,
},
};
robot::robot_success(output, "list", duration);
} else {
println!("API: {} ({} endpoints)", index.info.title, total);
println!();
let rows: Vec<EndpointRow> = filtered
.iter()
.map(|ep| EndpointRow {
method: ep.method.clone(),
path: ep.path.clone(),
summary: ep.summary.clone().unwrap_or_default(),
})
.collect();
let table = render_table_or_empty(&rows, "No endpoints match the given filters.");
println!("{table}");
if !rows.is_empty() {
println!();
if filtered_count > rows.len() {
println!(
"Showing {} of {} (filtered from {}). Use --all to show everything.",
rows.len(),
filtered_count,
total
);
} else {
println!("Showing {} of {}", rows.len(), total);
}
}
}
Ok(())
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::core::spec::{
IndexInfo, IndexedEndpoint, IndexedParam, IndexedSchema, IndexedTag, SpecIndex,
};
fn make_test_endpoint(
path: &str,
method: &str,
summary: Option<&str>,
tags: &[&str],
deprecated: bool,
) -> IndexedEndpoint {
IndexedEndpoint {
path: path.to_string(),
method: method.to_string(),
summary: summary.map(|s| s.to_string()),
description: None,
operation_id: Some(format!(
"{}{}",
method.to_lowercase(),
path.replace('/', "_")
)),
tags: tags.iter().map(|t| t.to_string()).collect(),
deprecated,
parameters: vec![IndexedParam {
name: "id".into(),
location: "path".into(),
required: true,
description: None,
}],
request_body_required: method != "GET" && method != "DELETE",
request_body_content_types: if method != "GET" && method != "DELETE" {
vec!["application/json".into()]
} else {
vec![]
},
security_schemes: vec!["bearerAuth".into()],
security_required: true,
operation_ptr: format!(
"#/paths/~1{}/{}",
path.trim_start_matches('/'),
method.to_lowercase()
),
}
}
fn make_test_index() -> SpecIndex {
SpecIndex {
index_version: 1,
generation: 1,
content_hash: "sha256:test".into(),
openapi: "3.0.3".into(),
info: IndexInfo {
title: "Petstore API".into(),
version: "1.0.0".into(),
},
endpoints: vec![
make_test_endpoint("/pets", "GET", Some("List all pets"), &["pets"], false),
make_test_endpoint("/pets", "POST", Some("Create a pet"), &["pets"], false),
make_test_endpoint(
"/pets/{petId}",
"GET",
Some("Get a pet by ID"),
&["pets"],
false,
),
make_test_endpoint(
"/pets/{petId}",
"DELETE",
Some("Delete a pet"),
&["pets"],
true,
),
make_test_endpoint(
"/store/inventory",
"GET",
Some("Get store inventory"),
&["store"],
false,
),
],
schemas: vec![IndexedSchema {
name: "Pet".into(),
schema_ptr: "#/components/schemas/Pet".into(),
}],
tags: vec![
IndexedTag {
name: "pets".into(),
description: Some("Pet operations".into()),
endpoint_count: 4,
},
IndexedTag {
name: "store".into(),
description: Some("Store operations".into()),
endpoint_count: 1,
},
],
}
}
/// Apply the same filtering logic used in execute() to the test index.
fn filter_endpoints<'a>(
index: &'a SpecIndex,
method: Option<&str>,
tag: Option<&str>,
path_pattern: Option<&str>,
) -> Result<Vec<&'a IndexedEndpoint>, SwaggerCliError> {
let path_regex = match path_pattern {
Some(p) => Some(
Regex::new(p).map_err(|e| SwaggerCliError::Usage(format!("Invalid regex: {e}")))?,
),
None => None,
};
let method_upper = method.map(|m| m.to_uppercase());
let tag_lower = tag.map(|t| t.to_lowercase());
let results: Vec<&IndexedEndpoint> = index
.endpoints
.iter()
.filter(|ep| {
if let Some(ref m) = method_upper
&& ep.method.to_uppercase() != *m
{
return false;
}
if let Some(ref t) = tag_lower
&& !ep
.tags
.iter()
.any(|tag| tag.to_lowercase().contains(t.as_str()))
{
return false;
}
if let Some(ref re) = path_regex
&& !re.is_match(&ep.path)
{
return false;
}
true
})
.collect();
Ok(results)
}
#[test]
fn test_filter_by_method() {
let index = make_test_index();
let results = filter_endpoints(&index, Some("GET"), None, None).unwrap();
assert_eq!(results.len(), 3);
for ep in &results {
assert_eq!(ep.method, "GET");
}
}
#[test]
fn test_filter_by_method_case_insensitive() {
let index = make_test_index();
let results = filter_endpoints(&index, Some("get"), None, None).unwrap();
assert_eq!(results.len(), 3);
let results = filter_endpoints(&index, Some("Post"), None, None).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].method, "POST");
}
#[test]
fn test_filter_by_tag() {
let index = make_test_index();
let results = filter_endpoints(&index, None, Some("store"), None).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].path, "/store/inventory");
}
#[test]
fn test_filter_by_tag_case_insensitive() {
let index = make_test_index();
let results = filter_endpoints(&index, None, Some("PETS"), None).unwrap();
assert_eq!(results.len(), 4);
}
#[test]
fn test_filter_by_path_regex() {
let index = make_test_index();
let results = filter_endpoints(&index, None, None, Some(r"\{petId\}")).unwrap();
assert_eq!(results.len(), 2);
for ep in &results {
assert!(ep.path.contains("{petId}"));
}
}
#[test]
fn test_filter_by_path_regex_prefix() {
let index = make_test_index();
let results = filter_endpoints(&index, None, None, Some("^/pets")).unwrap();
assert_eq!(results.len(), 4);
}
#[test]
fn test_invalid_regex_error() {
let index = make_test_index();
let result = filter_endpoints(&index, None, None, Some("[invalid"));
assert!(result.is_err());
let err = result.unwrap_err();
match err {
SwaggerCliError::Usage(msg) => {
assert!(msg.contains("Invalid regex"), "unexpected message: {msg}");
}
other => panic!("expected Usage error, got: {other:?}"),
}
}
#[test]
fn test_combined_filters() {
let index = make_test_index();
let results = filter_endpoints(&index, Some("GET"), Some("pets"), None).unwrap();
assert_eq!(results.len(), 2);
for ep in &results {
assert_eq!(ep.method, "GET");
assert!(ep.tags.contains(&"pets".to_string()));
}
}
#[test]
fn test_no_matches() {
let index = make_test_index();
let results = filter_endpoints(&index, Some("PATCH"), None, None).unwrap();
assert!(results.is_empty());
}
#[test]
fn test_sort_by_method() {
let index = make_test_index();
let mut endpoints: Vec<IndexedEndpoint> = index.endpoints.clone();
endpoints.sort_by(|a, b| {
method_rank(&a.method)
.cmp(&method_rank(&b.method))
.then_with(|| a.path.cmp(&b.path))
});
// All GETs come first, then POST, then DELETE
assert_eq!(endpoints[0].method, "GET");
assert_eq!(endpoints[1].method, "GET");
assert_eq!(endpoints[2].method, "GET");
assert_eq!(endpoints[3].method, "POST");
assert_eq!(endpoints[4].method, "DELETE");
}
#[test]
fn test_sort_by_path_default() {
let index = make_test_index();
let mut endpoints: Vec<IndexedEndpoint> = index.endpoints.clone();
endpoints.sort_by(|a, b| {
a.path
.cmp(&b.path)
.then_with(|| method_rank(&a.method).cmp(&method_rank(&b.method)))
});
assert_eq!(endpoints[0].path, "/pets");
assert_eq!(endpoints[0].method, "GET");
assert_eq!(endpoints[1].path, "/pets");
assert_eq!(endpoints[1].method, "POST");
assert_eq!(endpoints[2].path, "/pets/{petId}");
assert_eq!(endpoints[2].method, "GET");
assert_eq!(endpoints[3].path, "/pets/{petId}");
assert_eq!(endpoints[3].method, "DELETE");
assert_eq!(endpoints[4].path, "/store/inventory");
}
#[test]
fn test_sort_by_tag() {
let index = make_test_index();
let mut endpoints: Vec<IndexedEndpoint> = index.endpoints.clone();
endpoints.sort_by(|a, b| {
let tag_a = a.tags.first().map(String::as_str).unwrap_or("");
let tag_b = b.tags.first().map(String::as_str).unwrap_or("");
tag_a
.cmp(tag_b)
.then_with(|| a.path.cmp(&b.path))
.then_with(|| method_rank(&a.method).cmp(&method_rank(&b.method)))
});
// "pets" < "store" alphabetically
assert!(endpoints[0].tags.contains(&"pets".to_string()));
assert!(endpoints[1].tags.contains(&"pets".to_string()));
assert!(endpoints[2].tags.contains(&"pets".to_string()));
assert!(endpoints[3].tags.contains(&"pets".to_string()));
assert!(endpoints[4].tags.contains(&"store".to_string()));
}
#[test]
fn test_limit_applied() {
let index = make_test_index();
let mut endpoints = index.endpoints.clone();
let limit: usize = 2;
endpoints.truncate(limit);
assert_eq!(endpoints.len(), 2);
}
#[test]
fn test_limit_larger_than_count() {
let index = make_test_index();
let mut endpoints = index.endpoints.clone();
endpoints.truncate(100);
assert_eq!(endpoints.len(), 5);
}
#[test]
fn test_method_rank_ordering() {
assert_eq!(method_rank("GET"), 0);
assert_eq!(method_rank("POST"), 1);
assert_eq!(method_rank("PUT"), 2);
assert_eq!(method_rank("PATCH"), 3);
assert_eq!(method_rank("DELETE"), 4);
assert_eq!(method_rank("OPTIONS"), 5);
assert_eq!(method_rank("HEAD"), 5);
}
#[test]
fn test_method_rank_case_insensitive() {
assert_eq!(method_rank("get"), 0);
assert_eq!(method_rank("Post"), 1);
assert_eq!(method_rank("delete"), 4);
}
#[test]
fn test_endpoint_entry_serialization() {
let entry = EndpointEntry {
path: "/pets".into(),
method: "GET".into(),
summary: Some("List pets".into()),
operation_id: Some("listPets".into()),
tags: vec!["pets".into()],
deprecated: false,
};
let json = serde_json::to_string(&entry).unwrap();
assert!(json.contains("\"path\":\"/pets\""));
assert!(json.contains("\"method\":\"GET\""));
assert!(json.contains("\"deprecated\":false"));
}
#[test]
fn test_list_output_serialization() {
let output = ListOutput {
endpoints: vec![],
total: 5,
filtered: 0,
applied_filters: BTreeMap::new(),
meta: ListMeta {
alias: "petstore".into(),
spec_version: "1.0.0".into(),
cached_at: "2025-01-01T00:00:00+00:00".into(),
duration_ms: 42,
},
};
let json = serde_json::to_string(&output).unwrap();
assert!(json.contains("\"total\":5"));
assert!(json.contains("\"filtered\":0"));
assert!(json.contains("\"alias\":\"petstore\""));
}
#[test]
fn test_applied_filters_populated() {
let mut filters: BTreeMap<String, String> = BTreeMap::new();
filters.insert("method".into(), "GET".into());
filters.insert("tag".into(), "pets".into());
filters.insert("path".into(), "^/pets".into());
assert_eq!(filters.len(), 3);
assert_eq!(filters.get("method").unwrap(), "GET");
}
#[test]
fn test_make_test_index_structure() {
let index = make_test_index();
assert_eq!(index.info.title, "Petstore API");
assert_eq!(index.info.version, "1.0.0");
assert_eq!(index.endpoints.len(), 5);
assert_eq!(index.schemas.len(), 1);
assert_eq!(index.tags.len(), 2);
}
} }

View File

@@ -1,8 +1,15 @@
use std::time::Instant;
use clap::Args as ClapArgs; use clap::Args as ClapArgs;
use serde::Serialize;
use crate::core::cache::CacheManager;
use crate::core::config::cache_dir;
use crate::core::search::{SearchEngine, SearchOptions, SearchResult, SearchResultType};
use crate::errors::SwaggerCliError; use crate::errors::SwaggerCliError;
use crate::output::robot;
/// Search endpoints by keyword /// Search endpoints and schemas by keyword
#[derive(Debug, ClapArgs)] #[derive(Debug, ClapArgs)]
pub struct Args { pub struct Args {
/// Alias of the cached spec /// Alias of the cached spec
@@ -11,11 +18,173 @@ pub struct Args {
/// Search query /// Search query
pub query: String, pub query: String,
/// Case-sensitive matching
#[arg(long)]
pub case_sensitive: bool,
/// Match query as exact phrase
#[arg(long)]
pub exact: bool,
/// Fields to search (comma-separated: all, paths, descriptions, schemas)
#[arg(long = "in")]
pub in_fields: Option<String>,
/// Maximum number of results /// Maximum number of results
#[arg(long, default_value = "20")] #[arg(long, default_value = "20")]
pub limit: usize, pub limit: usize,
} }
pub async fn execute(_args: &Args, _robot: bool) -> Result<(), SwaggerCliError> { // ---------------------------------------------------------------------------
Err(SwaggerCliError::Usage("search not yet implemented".into())) // Robot-mode output types
// ---------------------------------------------------------------------------
#[derive(Debug, Serialize)]
struct RobotOutput {
results: Vec<RobotResult>,
total: usize,
}
#[derive(Debug, Serialize)]
struct RobotResult {
#[serde(rename = "type")]
result_type: &'static str,
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
method: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
summary: Option<String>,
rank: usize,
score: u32,
matches: Vec<RobotMatch>,
}
#[derive(Debug, Serialize)]
struct RobotMatch {
field: String,
snippet: String,
}
impl From<&SearchResult> for RobotResult {
fn from(r: &SearchResult) -> Self {
Self {
result_type: match r.result_type {
SearchResultType::Endpoint => "endpoint",
SearchResultType::Schema => "schema",
},
name: r.name.clone(),
method: r.method.clone(),
summary: r.summary.clone(),
rank: r.rank,
score: r.score,
matches: r
.matches
.iter()
.map(|m| RobotMatch {
field: m.field.clone(),
snippet: m.snippet.clone(),
})
.collect(),
}
}
}
// ---------------------------------------------------------------------------
// Field parsing
// ---------------------------------------------------------------------------
fn parse_in_fields(raw: &str) -> Result<(bool, bool, bool), SwaggerCliError> {
let mut search_paths = false;
let mut search_descriptions = false;
let mut search_schemas = false;
for field in raw.split(',').map(str::trim) {
match field.to_lowercase().as_str() {
"all" => {
search_paths = true;
search_descriptions = true;
search_schemas = true;
}
"paths" => search_paths = true,
"descriptions" => search_descriptions = true,
"schemas" => search_schemas = true,
other => {
return Err(SwaggerCliError::Usage(format!(
"Unknown --in field '{other}'. Valid values: all, paths, descriptions, schemas"
)));
}
}
}
Ok((search_paths, search_descriptions, search_schemas))
}
// ---------------------------------------------------------------------------
// Execute
// ---------------------------------------------------------------------------
pub async fn execute(args: &Args, robot_mode: bool) -> Result<(), SwaggerCliError> {
let start = Instant::now();
let (search_paths, search_descriptions, search_schemas) = match &args.in_fields {
Some(fields) => parse_in_fields(fields)?,
None => (true, true, true),
};
let cm = CacheManager::new(cache_dir());
let (index, _meta) = cm.load_index(&args.alias)?;
let opts = SearchOptions {
search_paths,
search_descriptions,
search_schemas,
case_sensitive: args.case_sensitive,
exact: args.exact,
limit: args.limit,
};
let engine = SearchEngine::new(&index);
let results = engine.search(&args.query, &opts);
if robot_mode {
let output = RobotOutput {
total: results.len(),
results: results.iter().map(RobotResult::from).collect(),
};
robot::robot_success(output, "search", start.elapsed());
} else if results.is_empty() {
println!("No results found for '{}'", args.query);
} else {
println!(
"Found {} result{} for '{}':\n",
results.len(),
if results.len() == 1 { "" } else { "s" },
args.query,
);
for r in &results {
let type_label = match r.result_type {
SearchResultType::Endpoint => "endpoint",
SearchResultType::Schema => "schema",
};
let method_str = r
.method
.as_deref()
.map(|m| format!("{m} "))
.unwrap_or_default();
let summary_str = r
.summary
.as_deref()
.map(|s| format!(" - {s}"))
.unwrap_or_default();
println!(
" {rank}. [{type_label}] {method_str}{name}{summary_str} (score: {score})",
rank = r.rank,
name = r.name,
score = r.score,
);
}
}
Ok(())
} }

View File

@@ -1,6 +1,14 @@
use clap::Args as ClapArgs; use std::time::Instant;
use clap::Args as ClapArgs;
use serde::Serialize;
use serde_json::Value;
use crate::core::cache::CacheManager;
use crate::core::config::cache_dir;
use crate::core::refs::expand_refs;
use crate::errors::SwaggerCliError; use crate::errors::SwaggerCliError;
use crate::output::robot::robot_success;
/// Show details of a specific endpoint /// Show details of a specific endpoint
#[derive(Debug, ClapArgs)] #[derive(Debug, ClapArgs)]
@@ -8,10 +16,417 @@ pub struct Args {
/// Alias of the cached spec /// Alias of the cached spec
pub alias: String, pub alias: String,
/// Operation ID or path to show /// Endpoint path (e.g., "/pets/{petId}")
pub endpoint: String, pub path: String,
/// HTTP method to show (GET, POST, etc.). Required when path has multiple methods.
#[arg(long, short)]
pub method: Option<String>,
/// Expand $ref entries inline
#[arg(long)]
pub expand_refs: bool,
/// Maximum depth for ref expansion
#[arg(long, default_value = "3")]
pub max_depth: u32,
} }
pub async fn execute(_args: &Args, _robot: bool) -> Result<(), SwaggerCliError> { #[derive(Debug, Serialize)]
Err(SwaggerCliError::Usage("show not yet implemented".into())) pub struct ShowOutput {
pub path: String,
pub method: String,
pub summary: Option<String>,
pub description: Option<String>,
pub operation_id: Option<String>,
pub tags: Vec<String>,
pub deprecated: bool,
pub parameters: Value,
pub request_body: Option<Value>,
pub responses: Value,
pub security: Value,
}
/// Navigate a JSON value using a JSON Pointer (RFC 6901).
///
/// Unescapes `~1` -> `/` and `~0` -> `~` (decode ~1 first per spec).
fn navigate_pointer(root: &Value, pointer: &str) -> Option<Value> {
if pointer.is_empty() {
return None;
}
let stripped = pointer.strip_prefix('/')?;
let mut current = root;
for token in stripped.split('/') {
let unescaped = token.replace("~1", "/").replace("~0", "~");
match current {
Value::Object(map) => {
current = map.get(&unescaped)?;
}
Value::Array(arr) => {
let idx: usize = unescaped.parse().ok()?;
current = arr.get(idx)?;
}
_ => return None,
}
}
Some(current.clone())
}
pub async fn execute(args: &Args, robot: bool) -> Result<(), SwaggerCliError> {
let start = Instant::now();
let cm = CacheManager::new(cache_dir());
let (index, meta) = cm.load_index(&args.alias)?;
// Find endpoints matching the requested path
let matching: Vec<_> = index
.endpoints
.iter()
.filter(|ep| ep.path == args.path)
.collect();
if matching.is_empty() {
return Err(SwaggerCliError::Usage(format!(
"No endpoint found for path '{}' in alias '{}'",
args.path, args.alias
)));
}
// If a method is specified, filter to that method
let endpoint = if let Some(method) = &args.method {
let method_upper = method.to_uppercase();
matching
.iter()
.find(|ep| ep.method.to_uppercase() == method_upper)
.ok_or_else(|| {
let available: Vec<&str> = matching.iter().map(|ep| ep.method.as_str()).collect();
SwaggerCliError::Usage(format!(
"Method '{}' not found for path '{}'. Available methods: {}",
method,
args.path,
available.join(", ")
))
})?
} else if matching.len() == 1 {
&matching[0]
} else {
let available: Vec<&str> = matching.iter().map(|ep| ep.method.as_str()).collect();
return Err(SwaggerCliError::Usage(format!(
"Multiple methods available for path '{}': {}. Use --method to specify one.",
args.path,
available.join(", ")
)));
};
// Load raw spec
let raw = cm.load_raw(&args.alias, &meta)?;
// Navigate to operation subtree
let operation = navigate_pointer(&raw, &endpoint.operation_ptr).ok_or_else(|| {
SwaggerCliError::Cache(format!(
"Failed to navigate to operation at pointer '{}' in raw spec for alias '{}'",
endpoint.operation_ptr, args.alias
))
})?;
let mut operation = operation;
if args.expand_refs {
expand_refs(&mut operation, &raw, args.max_depth);
}
let parameters = operation
.get("parameters")
.cloned()
.unwrap_or(Value::Array(vec![]));
let request_body = operation.get("requestBody").cloned();
let responses = operation
.get("responses")
.cloned()
.unwrap_or(Value::Object(serde_json::Map::new()));
let security = operation
.get("security")
.cloned()
.unwrap_or(Value::Array(vec![]));
let output = ShowOutput {
path: endpoint.path.clone(),
method: endpoint.method.clone(),
summary: endpoint.summary.clone(),
description: endpoint.description.clone(),
operation_id: endpoint.operation_id.clone(),
tags: endpoint.tags.clone(),
deprecated: endpoint.deprecated,
parameters,
request_body,
responses,
security,
};
if robot {
robot_success(&output, "show", start.elapsed());
} else {
print_human(&output);
}
Ok(())
}
fn print_human(output: &ShowOutput) {
println!("{} {}", output.method.to_uppercase(), output.path);
if let Some(summary) = &output.summary {
println!(" Summary: {summary}");
}
if let Some(description) = &output.description {
println!(" Description: {description}");
}
if let Some(op_id) = &output.operation_id {
println!(" Operation ID: {op_id}");
}
if !output.tags.is_empty() {
println!(" Tags: {}", output.tags.join(", "));
}
if output.deprecated {
println!(" DEPRECATED");
}
if let Value::Array(params) = &output.parameters
&& !params.is_empty()
{
println!(" Parameters:");
for param in params {
let name = param.get("name").and_then(|v| v.as_str()).unwrap_or("?");
let location = param.get("in").and_then(|v| v.as_str()).unwrap_or("?");
let required = param
.get("required")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let req_marker = if required { " (required)" } else { "" };
println!(" - {name} [{location}]{req_marker}");
}
}
if let Some(body) = &output.request_body {
println!(" Request Body:");
if let Ok(pretty) = serde_json::to_string_pretty(body) {
for line in pretty.lines() {
println!(" {line}");
}
}
}
if let Value::Object(responses) = &output.responses
&& !responses.is_empty()
{
println!(" Responses:");
for (status, _) in responses {
println!(" - {status}");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::spec::{IndexedEndpoint, IndexedParam};
use serde_json::json;
fn make_test_endpoints() -> Vec<IndexedEndpoint> {
vec![
IndexedEndpoint {
path: "/pets/{petId}".into(),
method: "GET".into(),
summary: Some("Get a pet".into()),
description: Some("Returns a single pet".into()),
operation_id: Some("getPet".into()),
tags: vec!["pets".into()],
deprecated: false,
parameters: vec![IndexedParam {
name: "petId".into(),
location: "path".into(),
required: true,
description: Some("ID of pet".into()),
}],
request_body_required: false,
request_body_content_types: vec![],
security_schemes: vec!["api_key".into()],
security_required: true,
operation_ptr: "/paths/~1pets~1{petId}/get".into(),
},
IndexedEndpoint {
path: "/pets/{petId}".into(),
method: "DELETE".into(),
summary: Some("Delete a pet".into()),
description: None,
operation_id: Some("deletePet".into()),
tags: vec!["pets".into()],
deprecated: false,
parameters: vec![IndexedParam {
name: "petId".into(),
location: "path".into(),
required: true,
description: Some("ID of pet".into()),
}],
request_body_required: false,
request_body_content_types: vec![],
security_schemes: vec!["api_key".into()],
security_required: true,
operation_ptr: "/paths/~1pets~1{petId}/delete".into(),
},
IndexedEndpoint {
path: "/pets".into(),
method: "GET".into(),
summary: Some("List pets".into()),
description: None,
operation_id: Some("listPets".into()),
tags: vec!["pets".into()],
deprecated: false,
parameters: vec![],
request_body_required: false,
request_body_content_types: vec![],
security_schemes: vec![],
security_required: false,
operation_ptr: "/paths/~1pets/get".into(),
},
]
}
#[test]
fn test_find_endpoint_by_path() {
let endpoints = make_test_endpoints();
let path = "/pets";
let matching: Vec<_> = endpoints.iter().filter(|ep| ep.path == path).collect();
assert_eq!(matching.len(), 1);
assert_eq!(matching[0].method, "GET");
assert_eq!(matching[0].operation_id, Some("listPets".into()));
}
#[test]
fn test_multiple_methods_error() {
let endpoints = make_test_endpoints();
let path = "/pets/{petId}";
let matching: Vec<_> = endpoints.iter().filter(|ep| ep.path == path).collect();
assert_eq!(matching.len(), 2);
let available: Vec<&str> = matching.iter().map(|ep| ep.method.as_str()).collect();
assert!(available.contains(&"GET"));
assert!(available.contains(&"DELETE"));
// With method filter, should find exactly one
let method = "GET";
let method_upper = method.to_uppercase();
let found: Vec<_> = matching
.iter()
.filter(|ep| ep.method.to_uppercase() == method_upper)
.collect();
assert_eq!(found.len(), 1);
assert_eq!(found[0].operation_id, Some("getPet".into()));
}
#[test]
fn test_pointer_navigation() {
let raw = json!({
"paths": {
"/pets/{petId}": {
"get": {
"summary": "Get a pet",
"parameters": [
{
"name": "petId",
"in": "path",
"required": true
}
],
"responses": {
"200": { "description": "A pet" },
"404": { "description": "Not found" }
}
},
"delete": {
"summary": "Delete a pet",
"responses": {
"204": { "description": "Deleted" }
}
}
},
"/pets": {
"get": {
"summary": "List pets",
"responses": {
"200": { "description": "A list of pets" }
}
}
}
}
});
// Navigate to GET /pets/{petId}
let result = navigate_pointer(&raw, "/paths/~1pets~1{petId}/get");
assert!(result.is_some());
let op = result.unwrap();
assert_eq!(op["summary"], "Get a pet");
assert!(op["parameters"].is_array());
assert_eq!(op["parameters"][0]["name"], "petId");
// Navigate to DELETE /pets/{petId}
let result = navigate_pointer(&raw, "/paths/~1pets~1{petId}/delete");
assert!(result.is_some());
let op = result.unwrap();
assert_eq!(op["summary"], "Delete a pet");
// Navigate to GET /pets
let result = navigate_pointer(&raw, "/paths/~1pets/get");
assert!(result.is_some());
let op = result.unwrap();
assert_eq!(op["summary"], "List pets");
// Invalid pointer
let result = navigate_pointer(&raw, "/paths/~1nonexistent/get");
assert!(result.is_none());
// Empty pointer
let result = navigate_pointer(&raw, "");
assert!(result.is_none());
}
#[test]
fn test_show_output_serialization() {
let output = ShowOutput {
path: "/pets/{petId}".into(),
method: "GET".into(),
summary: Some("Get a pet".into()),
description: Some("Returns a single pet".into()),
operation_id: Some("getPet".into()),
tags: vec!["pets".into()],
deprecated: false,
parameters: json!([
{ "name": "petId", "in": "path", "required": true }
]),
request_body: None,
responses: json!({
"200": { "description": "A pet" }
}),
security: json!([]),
};
let serialized = serde_json::to_value(&output).unwrap();
assert_eq!(serialized["path"], "/pets/{petId}");
assert_eq!(serialized["method"], "GET");
assert_eq!(serialized["deprecated"], false);
assert!(serialized["request_body"].is_null());
assert!(serialized["tags"].is_array());
}
} }

View File

@@ -1,6 +1,15 @@
use clap::Args as ClapArgs; use std::time::Instant;
use clap::Args as ClapArgs;
use serde::Serialize;
use tabled::Tabled;
use crate::core::cache::CacheManager;
use crate::core::config::cache_dir;
use crate::core::spec::SpecIndex;
use crate::errors::SwaggerCliError; use crate::errors::SwaggerCliError;
use crate::output::robot;
use crate::output::table::render_table_or_empty;
/// List tags from a cached spec /// List tags from a cached spec
#[derive(Debug, ClapArgs)] #[derive(Debug, ClapArgs)]
@@ -9,6 +18,151 @@ pub struct Args {
pub alias: String, pub alias: String,
} }
pub async fn execute(_args: &Args, _robot: bool) -> Result<(), SwaggerCliError> { #[derive(Debug, Serialize)]
Err(SwaggerCliError::Usage("tags not yet implemented".into())) struct TagsOutput {
tags: Vec<TagEntry>,
total: usize,
}
#[derive(Debug, Serialize)]
struct TagEntry {
name: String,
description: Option<String>,
endpoint_count: usize,
}
#[derive(Tabled)]
struct TagRow {
#[tabled(rename = "NAME")]
name: String,
#[tabled(rename = "ENDPOINTS")]
endpoints: usize,
#[tabled(rename = "DESCRIPTION")]
description: String,
}
fn build_output(index: &SpecIndex) -> TagsOutput {
let tags: Vec<TagEntry> = index
.tags
.iter()
.map(|t| TagEntry {
name: t.name.clone(),
description: t.description.clone(),
endpoint_count: t.endpoint_count,
})
.collect();
let total = tags.len();
TagsOutput { tags, total }
}
pub async fn execute(args: &Args, robot_mode: bool) -> Result<(), SwaggerCliError> {
let start = Instant::now();
let cm = CacheManager::new(cache_dir());
let (index, meta) = cm.load_index(&args.alias)?;
let output = build_output(&index);
if robot_mode {
robot::robot_success(output, "tags", start.elapsed());
} else {
let rows: Vec<TagRow> = output
.tags
.iter()
.map(|t| TagRow {
name: t.name.clone(),
endpoints: t.endpoint_count,
description: t.description.clone().unwrap_or_default(),
})
.collect();
println!(
"{} {} -- {} tags",
meta.spec_title, meta.spec_version, output.total
);
println!(
"{}",
render_table_or_empty(&rows, "No tags defined in this spec.")
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::spec::{IndexInfo, IndexedTag, SpecIndex};
fn make_test_index() -> SpecIndex {
SpecIndex {
index_version: 1,
generation: 1,
content_hash: "sha256:test".into(),
openapi: "3.0.3".into(),
info: IndexInfo {
title: "Test API".into(),
version: "2.0.0".into(),
},
endpoints: vec![],
schemas: vec![],
tags: vec![
IndexedTag {
name: "auth".into(),
description: Some("Authentication endpoints".into()),
endpoint_count: 3,
},
IndexedTag {
name: "users".into(),
description: None,
endpoint_count: 5,
},
],
}
}
#[test]
fn test_tags_output() {
let index = make_test_index();
let output = build_output(&index);
assert_eq!(output.total, 2);
assert_eq!(output.tags.len(), 2);
assert_eq!(output.tags[0].name, "auth");
assert_eq!(
output.tags[0].description.as_deref(),
Some("Authentication endpoints")
);
assert_eq!(output.tags[0].endpoint_count, 3);
assert_eq!(output.tags[1].name, "users");
assert!(output.tags[1].description.is_none());
assert_eq!(output.tags[1].endpoint_count, 5);
// Verify serialization roundtrip
let json = serde_json::to_string(&output).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["total"], 2);
assert_eq!(parsed["tags"][0]["name"], "auth");
}
#[test]
fn test_tags_empty() {
let index = SpecIndex {
index_version: 1,
generation: 1,
content_hash: "sha256:empty".into(),
openapi: "3.1.0".into(),
info: IndexInfo {
title: "Empty".into(),
version: "0.1.0".into(),
},
endpoints: vec![],
schemas: vec![],
tags: vec![],
};
let output = build_output(&index);
assert_eq!(output.total, 0);
assert!(output.tags.is_empty());
}
} }

View File

@@ -236,10 +236,7 @@ impl CacheManager {
/// Validates that index_version, generation, and index_hash all match /// Validates that index_version, generation, and index_hash all match
/// between meta and the on-disk index. Returns `AliasNotFound` if /// between meta and the on-disk index. Returns `AliasNotFound` if
/// meta.json is missing, `CacheIntegrity` on any mismatch. /// meta.json is missing, `CacheIntegrity` on any mismatch.
pub fn load_index( pub fn load_index(&self, alias: &str) -> Result<(SpecIndex, CacheMetadata), SwaggerCliError> {
&self,
alias: &str,
) -> Result<(SpecIndex, CacheMetadata), SwaggerCliError> {
validate_alias(alias)?; validate_alias(alias)?;
let dir = self.alias_dir(alias); let dir = self.alias_dir(alias);
@@ -248,24 +245,16 @@ impl CacheManager {
if e.kind() == std::io::ErrorKind::NotFound { if e.kind() == std::io::ErrorKind::NotFound {
SwaggerCliError::AliasNotFound(alias.to_string()) SwaggerCliError::AliasNotFound(alias.to_string())
} else { } else {
SwaggerCliError::Cache(format!( SwaggerCliError::Cache(format!("Failed to read {}: {e}", meta_path.display()))
"Failed to read {}: {e}",
meta_path.display()
))
} }
})?; })?;
let meta: CacheMetadata = serde_json::from_slice(&meta_bytes).map_err(|e| { let meta: CacheMetadata = serde_json::from_slice(&meta_bytes).map_err(|e| {
SwaggerCliError::CacheIntegrity(format!( SwaggerCliError::CacheIntegrity(format!("Corrupt meta.json for alias '{alias}': {e}"))
"Corrupt meta.json for alias '{alias}': {e}"
))
})?; })?;
let index_path = dir.join("index.json"); let index_path = dir.join("index.json");
let index_bytes = fs::read(&index_path).map_err(|e| { let index_bytes = fs::read(&index_path).map_err(|e| {
SwaggerCliError::Cache(format!( SwaggerCliError::Cache(format!("Failed to read {}: {e}", index_path.display()))
"Failed to read {}: {e}",
index_path.display()
))
})?; })?;
let actual_hash = compute_hash(&index_bytes); let actual_hash = compute_hash(&index_bytes);
@@ -277,9 +266,7 @@ impl CacheManager {
} }
let index: SpecIndex = serde_json::from_slice(&index_bytes).map_err(|e| { let index: SpecIndex = serde_json::from_slice(&index_bytes).map_err(|e| {
SwaggerCliError::CacheIntegrity(format!( SwaggerCliError::CacheIntegrity(format!("Corrupt index.json for alias '{alias}': {e}"))
"Corrupt index.json for alias '{alias}': {e}"
))
})?; })?;
if meta.index_version != index.index_version { if meta.index_version != index.index_version {
@@ -317,10 +304,7 @@ impl CacheManager {
) -> Result<serde_json::Value, SwaggerCliError> { ) -> Result<serde_json::Value, SwaggerCliError> {
let raw_path = self.alias_dir(alias).join("raw.json"); let raw_path = self.alias_dir(alias).join("raw.json");
let raw_bytes = fs::read(&raw_path).map_err(|e| { let raw_bytes = fs::read(&raw_path).map_err(|e| {
SwaggerCliError::Cache(format!( SwaggerCliError::Cache(format!("Failed to read {}: {e}", raw_path.display()))
"Failed to read {}: {e}",
raw_path.display()
))
})?; })?;
let actual_hash = compute_hash(&raw_bytes); let actual_hash = compute_hash(&raw_bytes);
@@ -331,13 +315,9 @@ impl CacheManager {
))); )));
} }
let value: serde_json::Value = let value: serde_json::Value = serde_json::from_slice(&raw_bytes).map_err(|e| {
serde_json::from_slice(&raw_bytes).map_err(|e| { SwaggerCliError::Cache(format!("Failed to parse raw.json for '{}': {e}", alias))
SwaggerCliError::Cache(format!( })?;
"Failed to parse raw.json for '{}': {e}",
alias
))
})?;
Ok(value) Ok(value)
} }
@@ -697,14 +677,12 @@ mod tests {
manager manager
.write_cache( .write_cache(
"api1", b"src1", b"{}", &index, None, "1.0", "API 1", "json", "api1", b"src1", b"{}", &index, None, "1.0", "API 1", "json", None, None, None,
None, None, None,
) )
.unwrap(); .unwrap();
manager manager
.write_cache( .write_cache(
"api2", b"src2", b"{}", &index, None, "2.0", "API 2", "yaml", "api2", b"src2", b"{}", &index, None, "2.0", "API 2", "yaml", None, None, None,
None, None, None,
) )
.unwrap(); .unwrap();

View File

@@ -72,19 +72,18 @@ fn is_blocked_mapped_v4(v6: &std::net::Ipv6Addr) -> bool {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
fn validate_url(url: &str, allow_insecure_http: bool) -> Result<Url, SwaggerCliError> { fn validate_url(url: &str, allow_insecure_http: bool) -> Result<Url, SwaggerCliError> {
let parsed = Url::parse(url).map_err(|e| { let parsed = Url::parse(url)
SwaggerCliError::InvalidSpec(format!("invalid URL '{url}': {e}")) .map_err(|e| SwaggerCliError::InvalidSpec(format!("invalid URL '{url}': {e}")))?;
})?;
match parsed.scheme() { match parsed.scheme() {
"https" => Ok(parsed), "https" => Ok(parsed),
"http" if allow_insecure_http => Ok(parsed), "http" if allow_insecure_http => Ok(parsed),
"http" => Err(SwaggerCliError::PolicyBlocked( "http" => Err(SwaggerCliError::PolicyBlocked(format!(
format!("HTTP is not allowed for '{url}'. Use --allow-insecure-http to override."), "HTTP is not allowed for '{url}'. Use --allow-insecure-http to override."
)), ))),
other => Err(SwaggerCliError::InvalidSpec( other => Err(SwaggerCliError::InvalidSpec(format!(
format!("unsupported scheme '{other}' in URL '{url}'"), "unsupported scheme '{other}' in URL '{url}'"
)), ))),
} }
} }
@@ -105,16 +104,16 @@ async fn resolve_and_check(
let addrs: Vec<_> = match lookup_host(&addr).await { let addrs: Vec<_> = match lookup_host(&addr).await {
Ok(iter) => iter.collect(), Ok(iter) => iter.collect(),
Err(e) => { Err(e) => {
return Err(SwaggerCliError::InvalidSpec( return Err(SwaggerCliError::InvalidSpec(format!(
format!("DNS resolution failed for '{host}': {e}"), "DNS resolution failed for '{host}': {e}"
)); )));
} }
}; };
if addrs.is_empty() { if addrs.is_empty() {
return Err(SwaggerCliError::InvalidSpec( return Err(SwaggerCliError::InvalidSpec(format!(
format!("DNS resolution returned no addresses for '{host}'"), "DNS resolution returned no addresses for '{host}'"
)); )));
} }
for socket_addr in &addrs { for socket_addr in &addrs {
@@ -178,9 +177,9 @@ impl AsyncHttpClient {
pub async fn fetch_spec(&self, url: &str) -> Result<FetchResult, SwaggerCliError> { pub async fn fetch_spec(&self, url: &str) -> Result<FetchResult, SwaggerCliError> {
let parsed = validate_url(url, self.allow_insecure_http)?; let parsed = validate_url(url, self.allow_insecure_http)?;
let host = parsed.host_str().ok_or_else(|| { let host = parsed
SwaggerCliError::InvalidSpec(format!("URL '{url}' has no host")) .host_str()
})?; .ok_or_else(|| SwaggerCliError::InvalidSpec(format!("URL '{url}' has no host")))?;
let port = parsed.port_or_known_default().unwrap_or(443); let port = parsed.port_or_known_default().unwrap_or(443);
resolve_and_check(host, port, &self.allowed_private_hosts).await?; resolve_and_check(host, port, &self.allowed_private_hosts).await?;
@@ -215,11 +214,7 @@ impl AsyncHttpClient {
attempts += 1; attempts += 1;
if attempts > self.max_retries { if attempts > self.max_retries {
return Err(SwaggerCliError::Network( return Err(SwaggerCliError::Network(
client client.get(url).send().await.unwrap_err(),
.get(url)
.send()
.await
.unwrap_err(),
)); ));
} }
let delay = self.retry_delay(&response, attempts); let delay = self.retry_delay(&response, attempts);
@@ -370,7 +365,9 @@ mod tests {
#[test] #[test]
fn test_ssrf_blocks_loopback() { fn test_ssrf_blocks_loopback() {
assert!(is_ip_blocked(&IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)))); assert!(is_ip_blocked(&IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))));
assert!(is_ip_blocked(&IpAddr::V4(Ipv4Addr::new(127, 255, 255, 254)))); assert!(is_ip_blocked(&IpAddr::V4(Ipv4Addr::new(
127, 255, 255, 254
))));
assert!(is_ip_blocked(&IpAddr::V6(Ipv6Addr::LOCALHOST))); assert!(is_ip_blocked(&IpAddr::V6(Ipv6Addr::LOCALHOST)));
} }
@@ -392,7 +389,9 @@ mod tests {
#[test] #[test]
fn test_ssrf_blocks_link_local() { fn test_ssrf_blocks_link_local() {
// IPv4 link-local (169.254.x.x) -- includes the AWS metadata endpoint // IPv4 link-local (169.254.x.x) -- includes the AWS metadata endpoint
assert!(is_ip_blocked(&IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254)))); assert!(is_ip_blocked(&IpAddr::V4(Ipv4Addr::new(
169, 254, 169, 254
))));
assert!(is_ip_blocked(&IpAddr::V4(Ipv4Addr::new(169, 254, 0, 1)))); assert!(is_ip_blocked(&IpAddr::V4(Ipv4Addr::new(169, 254, 0, 1))));
// IPv6 link-local (fe80::/10) // IPv6 link-local (fe80::/10)
@@ -441,10 +440,7 @@ mod tests {
fn test_url_allows_https() { fn test_url_allows_https() {
let result = validate_url("https://example.com/spec.json", false); let result = validate_url("https://example.com/spec.json", false);
assert!(result.is_ok()); assert!(result.is_ok());
assert_eq!( assert_eq!(result.unwrap().as_str(), "https://example.com/spec.json");
result.unwrap().as_str(),
"https://example.com/spec.json"
);
} }
#[test] #[test]
@@ -457,7 +453,10 @@ mod tests {
fn test_url_rejects_unsupported_scheme() { fn test_url_rejects_unsupported_scheme() {
let result = validate_url("ftp://example.com/spec.json", false); let result = validate_url("ftp://example.com/spec.json", false);
assert!(result.is_err()); assert!(result.is_err());
assert!(matches!(result.unwrap_err(), SwaggerCliError::InvalidSpec(_))); assert!(matches!(
result.unwrap_err(),
SwaggerCliError::InvalidSpec(_)
));
} }
#[test] #[test]
@@ -505,8 +504,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_resolve_and_check_skips_allowed_host() { async fn test_resolve_and_check_skips_allowed_host() {
let result = let result = resolve_and_check("localhost", 80, &["localhost".into()]).await;
resolve_and_check("localhost", 80, &["localhost".into()]).await;
assert!(result.is_ok()); assert!(result.is_ok());
} }
} }

View File

@@ -2,4 +2,6 @@ pub mod cache;
pub mod config; pub mod config;
pub mod http; pub mod http;
pub mod indexer; pub mod indexer;
pub mod refs;
pub mod search;
pub mod spec; pub mod spec;

295
src/core/refs.rs Normal file
View File

@@ -0,0 +1,295 @@
use std::collections::HashSet;
use serde_json::Value;
/// Resolve a JSON Pointer (RFC 6901) against a root value.
///
/// Unescapes `~1` -> `/` and `~0` -> `~` (in that order per spec).
/// Returns `None` if the pointer is empty, malformed, or the path does not exist.
pub fn resolve_json_pointer<'a>(root: &'a Value, pointer: &str) -> Option<&'a Value> {
if pointer.is_empty() {
return None;
}
let stripped = pointer.strip_prefix('/')?;
let mut current = root;
for token in stripped.split('/') {
let unescaped = token.replace("~1", "/").replace("~0", "~");
match current {
Value::Object(map) => {
current = map.get(&unescaped)?;
}
Value::Array(arr) => {
let idx: usize = unescaped.parse().ok()?;
current = arr.get(idx)?;
}
_ => return None,
}
}
Some(current)
}
/// Expand all `$ref` entries in `value` by inlining the referenced content from `root`.
///
/// - Internal refs (starting with `#/`) are resolved via JSON pointer navigation.
/// - External refs (not starting with `#/`) are replaced with `{"$external_ref": "..."}`.
/// - Circular refs (already visited in the current path) are replaced with `{"$circular_ref": "..."}`.
/// - Expansion stops at `max_depth` to prevent unbounded recursion.
pub fn expand_refs(value: &mut Value, root: &Value, max_depth: u32) {
let mut visited = HashSet::new();
expand_recursive(value, root, max_depth, 0, &mut visited);
}
fn expand_recursive(
value: &mut Value,
root: &Value,
max_depth: u32,
depth: u32,
visited: &mut HashSet<String>,
) {
if let Some(ref_str) = extract_ref_if_present(value) {
if !ref_str.starts_with("#/") {
*value = serde_json::json!({ "$external_ref": ref_str });
return;
}
if depth >= max_depth || visited.contains(&ref_str) {
*value = serde_json::json!({ "$circular_ref": ref_str });
return;
}
let pointer = &ref_str[1..]; // strip leading '#'
if let Some(resolved) = resolve_json_pointer(root, pointer) {
let mut expanded = resolved.clone();
visited.insert(ref_str);
expand_recursive(&mut expanded, root, max_depth, depth + 1, visited);
// Do not remove from visited: keep it for sibling detection within the same
// subtree path. The caller manages the visited set across siblings.
*value = expanded;
}
// If pointer doesn't resolve, leave the $ref as-is (broken ref)
return;
}
match value {
Value::Object(map) => {
for val in map.values_mut() {
expand_recursive(val, root, max_depth, depth, visited);
}
}
Value::Array(arr) => {
for item in arr.iter_mut() {
expand_recursive(item, root, max_depth, depth, visited);
}
}
_ => {}
}
}
fn extract_ref_if_present(value: &Value) -> Option<String> {
let map = value.as_object()?;
let ref_val = map.get("$ref")?;
Some(ref_val.as_str()?.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_resolve_json_pointer() {
let root = json!({
"components": {
"schemas": {
"Pet": {
"type": "object",
"properties": {
"name": { "type": "string" }
}
}
}
}
});
let result = resolve_json_pointer(&root, "/components/schemas/Pet");
assert!(result.is_some());
let pet = result.unwrap();
assert_eq!(pet["type"], "object");
assert_eq!(pet["properties"]["name"]["type"], "string");
// Empty pointer
assert!(resolve_json_pointer(&root, "").is_none());
// Missing path
assert!(resolve_json_pointer(&root, "/components/schemas/Dog").is_none());
// No leading slash
assert!(resolve_json_pointer(&root, "components").is_none());
// Escaped path segments: ~1 -> /
let root_with_slash = json!({
"paths": {
"/pets/{petId}": {
"get": { "summary": "Get pet" }
}
}
});
let result = resolve_json_pointer(&root_with_slash, "/paths/~1pets~1{petId}/get");
assert!(result.is_some());
assert_eq!(result.unwrap()["summary"], "Get pet");
// Escaped: ~0 -> ~
let root_with_tilde = json!({
"x~y": "found"
});
let result = resolve_json_pointer(&root_with_tilde, "/x~0y");
assert!(result.is_some());
assert_eq!(result.unwrap(), "found");
// Array indexing
let root_with_array = json!({
"items": ["a", "b", "c"]
});
let result = resolve_json_pointer(&root_with_array, "/items/1");
assert!(result.is_some());
assert_eq!(result.unwrap(), "b");
}
#[test]
fn test_expand_basic_ref() {
let root = json!({
"components": {
"schemas": {
"Pet": {
"type": "object",
"properties": {
"name": { "type": "string" }
}
}
}
}
});
let mut value = json!({
"schema": { "$ref": "#/components/schemas/Pet" }
});
expand_refs(&mut value, &root, 10);
assert_eq!(value["schema"]["type"], "object");
assert_eq!(value["schema"]["properties"]["name"]["type"], "string");
// $ref key should be gone (replaced with inlined content)
assert!(value["schema"]["$ref"].is_null());
}
#[test]
fn test_expand_circular_ref() {
let root = json!({
"components": {
"schemas": {
"Node": {
"type": "object",
"properties": {
"child": { "$ref": "#/components/schemas/Node" }
}
}
}
}
});
let mut value = json!({
"schema": { "$ref": "#/components/schemas/Node" }
});
expand_refs(&mut value, &root, 5);
// The first expansion should succeed
assert_eq!(value["schema"]["type"], "object");
// The recursive child ref should be replaced with $circular_ref
let child = &value["schema"]["properties"]["child"];
assert_eq!(child["$circular_ref"], "#/components/schemas/Node");
}
#[test]
fn test_expand_external_ref() {
let root = json!({});
let mut value = json!({
"schema": { "$ref": "https://example.com/schemas/Pet.json" }
});
expand_refs(&mut value, &root, 5);
assert_eq!(
value["schema"]["$external_ref"],
"https://example.com/schemas/Pet.json"
);
}
#[test]
fn test_expand_max_depth() {
let root = json!({
"components": {
"schemas": {
"A": {
"nested": { "$ref": "#/components/schemas/B" }
},
"B": {
"value": "deep"
}
}
}
});
// With max_depth=1, the first ref resolves but nested ref hits depth limit
let mut value = json!({ "$ref": "#/components/schemas/A" });
expand_refs(&mut value, &root, 1);
// A should be expanded
assert!(value.get("nested").is_some());
// B ref should be left as $circular_ref due to max_depth
assert_eq!(value["nested"]["$circular_ref"], "#/components/schemas/B");
}
#[test]
fn test_expand_array_refs() {
let root = json!({
"components": {
"schemas": {
"Tag": { "type": "string" }
}
}
});
let mut value = json!({
"items": [
{ "$ref": "#/components/schemas/Tag" },
{ "type": "integer" }
]
});
expand_refs(&mut value, &root, 5);
assert_eq!(value["items"][0]["type"], "string");
assert_eq!(value["items"][1]["type"], "integer");
}
#[test]
fn test_expand_broken_ref_left_as_is() {
let root = json!({});
let mut value = json!({
"schema": { "$ref": "#/components/schemas/Missing" }
});
let original = value.clone();
expand_refs(&mut value, &root, 5);
// Broken internal ref left untouched
assert_eq!(value, original);
}
}

634
src/core/search.rs Normal file
View File

@@ -0,0 +1,634 @@
use serde::Serialize;
use super::indexer::method_rank;
use super::spec::SpecIndex;
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize)]
pub struct SearchResult {
pub result_type: SearchResultType,
pub name: String,
pub method: Option<String>,
pub summary: Option<String>,
pub rank: usize,
pub score: u32,
pub matches: Vec<Match>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SearchResultType {
Endpoint,
Schema,
}
impl SearchResultType {
fn ordinal(self) -> u8 {
match self {
Self::Endpoint => 0,
Self::Schema => 1,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Match {
pub field: String,
pub snippet: String,
}
#[derive(Debug, Clone)]
pub struct SearchOptions {
pub search_paths: bool,
pub search_descriptions: bool,
pub search_schemas: bool,
pub case_sensitive: bool,
pub exact: bool,
pub limit: usize,
}
impl Default for SearchOptions {
fn default() -> Self {
Self {
search_paths: true,
search_descriptions: true,
search_schemas: true,
case_sensitive: false,
exact: false,
limit: 20,
}
}
}
// ---------------------------------------------------------------------------
// Field weights
// ---------------------------------------------------------------------------
const WEIGHT_PATH: f64 = 10.0;
const WEIGHT_SUMMARY: f64 = 5.0;
const WEIGHT_DESCRIPTION: f64 = 2.0;
const WEIGHT_SCHEMA_NAME: f64 = 8.0;
// ---------------------------------------------------------------------------
// Search engine
// ---------------------------------------------------------------------------
pub struct SearchEngine<'a> {
index: &'a SpecIndex,
}
impl<'a> SearchEngine<'a> {
pub fn new(index: &'a SpecIndex) -> Self {
Self { index }
}
pub fn search(&self, query: &str, opts: &SearchOptions) -> Vec<SearchResult> {
let query = query.trim();
if query.is_empty() {
return Vec::new();
}
let terms = tokenize(query, opts.exact);
let total_terms = terms.len();
let mut results: Vec<SearchResult> = Vec::new();
// Search endpoints
if opts.search_paths || opts.search_descriptions {
for ep in &self.index.endpoints {
let mut raw_score: f64 = 0.0;
let mut matched_terms: usize = 0;
let mut matches: Vec<Match> = Vec::new();
for term in &terms {
let mut term_matched = false;
if opts.search_paths && contains_term(&ep.path, term, opts.case_sensitive) {
raw_score += WEIGHT_PATH;
matches.push(Match {
field: "path".into(),
snippet: safe_snippet(&ep.path, term, opts.case_sensitive),
});
term_matched = true;
}
if (opts.search_descriptions || opts.search_paths)
&& let Some(ref summary) = ep.summary
&& contains_term(summary, term, opts.case_sensitive)
{
raw_score += WEIGHT_SUMMARY;
matches.push(Match {
field: "summary".into(),
snippet: safe_snippet(summary, term, opts.case_sensitive),
});
term_matched = true;
}
if opts.search_descriptions
&& let Some(ref desc) = ep.description
&& contains_term(desc, term, opts.case_sensitive)
{
raw_score += WEIGHT_DESCRIPTION;
matches.push(Match {
field: "description".into(),
snippet: safe_snippet(desc, term, opts.case_sensitive),
});
term_matched = true;
}
if term_matched {
matched_terms += 1;
}
}
if raw_score > 0.0 {
let coverage_boost = 1.0 + (matched_terms as f64 / total_terms.max(1) as f64);
let final_score = raw_score * coverage_boost;
let quantized = (final_score * 100.0).round() as u32;
results.push(SearchResult {
result_type: SearchResultType::Endpoint,
name: ep.path.clone(),
method: Some(ep.method.clone()),
summary: ep.summary.clone(),
rank: 0, // assigned after sort
score: quantized,
matches,
});
}
}
}
// Search schemas
if opts.search_schemas {
for schema in &self.index.schemas {
let mut raw_score: f64 = 0.0;
let mut matched_terms: usize = 0;
let mut matches: Vec<Match> = Vec::new();
for term in &terms {
if contains_term(&schema.name, term, opts.case_sensitive) {
raw_score += WEIGHT_SCHEMA_NAME;
matches.push(Match {
field: "schema_name".into(),
snippet: safe_snippet(&schema.name, term, opts.case_sensitive),
});
matched_terms += 1;
}
}
if raw_score > 0.0 {
let coverage_boost = 1.0 + (matched_terms as f64 / total_terms.max(1) as f64);
let final_score = raw_score * coverage_boost;
let quantized = (final_score * 100.0).round() as u32;
results.push(SearchResult {
result_type: SearchResultType::Schema,
name: schema.name.clone(),
method: None,
summary: None,
rank: 0,
score: quantized,
matches,
});
}
}
}
// Deterministic sort: score DESC, type ordinal ASC, name ASC, method_rank ASC
results.sort_by(|a, b| {
b.score
.cmp(&a.score)
.then_with(|| a.result_type.ordinal().cmp(&b.result_type.ordinal()))
.then_with(|| a.name.cmp(&b.name))
.then_with(|| {
let a_rank = a.method.as_deref().map(method_rank).unwrap_or(u8::MAX);
let b_rank = b.method.as_deref().map(method_rank).unwrap_or(u8::MAX);
a_rank.cmp(&b_rank)
})
});
// Assign 1-based ranks and apply limit
results.truncate(opts.limit);
for (i, result) in results.iter_mut().enumerate() {
result.rank = i + 1;
}
results
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn tokenize(query: &str, exact: bool) -> Vec<String> {
if exact {
vec![query.to_string()]
} else {
query.split_whitespace().map(String::from).collect()
}
}
fn contains_term(haystack: &str, needle: &str, case_sensitive: bool) -> bool {
if case_sensitive {
haystack.contains(needle)
} else {
let h = haystack.to_lowercase();
let n = needle.to_lowercase();
h.contains(&n)
}
}
/// Build a Unicode-safe snippet around the first occurrence of `needle` in
/// `haystack`. The context window is 50 characters. Ellipses are added when
/// the snippet is truncated.
fn safe_snippet(haystack: &str, needle: &str, case_sensitive: bool) -> String {
let (h_search, n_search) = if case_sensitive {
(haystack.to_string(), needle.to_string())
} else {
(haystack.to_lowercase(), needle.to_lowercase())
};
let byte_pos = match h_search.find(&n_search) {
Some(pos) => pos,
None => return haystack.chars().take(50).collect(),
};
// Convert byte position to char index.
let char_start = haystack[..byte_pos].chars().count();
let needle_char_len = needle.chars().count();
let haystack_chars: Vec<char> = haystack.chars().collect();
let total_chars = haystack_chars.len();
const WINDOW: usize = 50;
// Centre the window around the match.
let context_budget = WINDOW.saturating_sub(needle_char_len);
let left_context = context_budget / 2;
let snippet_start = char_start.saturating_sub(left_context);
let snippet_end = (snippet_start + WINDOW).min(total_chars);
let prefix = if snippet_start > 0 { "..." } else { "" };
let suffix = if snippet_end < total_chars { "..." } else { "" };
let snippet_body: String = haystack_chars[snippet_start..snippet_end].iter().collect();
format!("{prefix}{snippet_body}{suffix}")
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::core::spec::{
IndexInfo, IndexedEndpoint, IndexedParam, IndexedSchema, IndexedTag, SpecIndex,
};
fn petstore_index() -> SpecIndex {
SpecIndex {
index_version: 1,
generation: 1,
content_hash: "sha256:test".into(),
openapi: "3.0.3".into(),
info: IndexInfo {
title: "Petstore".into(),
version: "1.0.0".into(),
},
endpoints: vec![
IndexedEndpoint {
path: "/pets".into(),
method: "GET".into(),
summary: Some("List all pets".into()),
description: Some("Returns a list of pets from the store".into()),
operation_id: Some("listPets".into()),
tags: vec!["pets".into()],
deprecated: false,
parameters: vec![IndexedParam {
name: "limit".into(),
location: "query".into(),
required: false,
description: Some("Max items".into()),
}],
request_body_required: false,
request_body_content_types: vec![],
security_schemes: vec![],
security_required: false,
operation_ptr: "/paths/~1pets/get".into(),
},
IndexedEndpoint {
path: "/pets".into(),
method: "POST".into(),
summary: Some("Create a pet".into()),
description: None,
operation_id: Some("createPet".into()),
tags: vec!["pets".into()],
deprecated: false,
parameters: vec![],
request_body_required: true,
request_body_content_types: vec!["application/json".into()],
security_schemes: vec![],
security_required: false,
operation_ptr: "/paths/~1pets/post".into(),
},
IndexedEndpoint {
path: "/pets/{petId}".into(),
method: "GET".into(),
summary: Some("Info for a specific pet".into()),
description: Some("Detailed information about a single pet".into()),
operation_id: Some("showPetById".into()),
tags: vec!["pets".into()],
deprecated: false,
parameters: vec![IndexedParam {
name: "petId".into(),
location: "path".into(),
required: true,
description: Some("The id of the pet".into()),
}],
request_body_required: false,
request_body_content_types: vec![],
security_schemes: vec![],
security_required: false,
operation_ptr: "/paths/~1pets~1{petId}/get".into(),
},
IndexedEndpoint {
path: "/store/inventory".into(),
method: "GET".into(),
summary: Some("Returns store inventory".into()),
description: None,
operation_id: Some("getInventory".into()),
tags: vec!["store".into()],
deprecated: false,
parameters: vec![],
request_body_required: false,
request_body_content_types: vec![],
security_schemes: vec![],
security_required: false,
operation_ptr: "/paths/~1store~1inventory/get".into(),
},
],
schemas: vec![
IndexedSchema {
name: "Pet".into(),
schema_ptr: "/components/schemas/Pet".into(),
},
IndexedSchema {
name: "Error".into(),
schema_ptr: "/components/schemas/Error".into(),
},
IndexedSchema {
name: "PetList".into(),
schema_ptr: "/components/schemas/PetList".into(),
},
],
tags: vec![
IndexedTag {
name: "pets".into(),
description: Some("Pet operations".into()),
endpoint_count: 3,
},
IndexedTag {
name: "store".into(),
description: Some("Store operations".into()),
endpoint_count: 1,
},
],
}
}
#[test]
fn test_search_basic() {
let index = petstore_index();
let engine = SearchEngine::new(&index);
let opts = SearchOptions::default();
let results = engine.search("pet", &opts);
assert!(
!results.is_empty(),
"should find 'pet' in petstore endpoints"
);
// All results should mention pet somewhere
for r in &results {
let has_pet = r
.matches
.iter()
.any(|m| m.snippet.to_lowercase().contains("pet"));
assert!(has_pet, "result {:?} should match 'pet'", r.name);
}
// Ranks should be sequential 1-based
for (i, r) in results.iter().enumerate() {
assert_eq!(r.rank, i + 1, "rank should be 1-based sequential");
}
}
#[test]
fn test_search_scores_deterministic() {
let index = petstore_index();
let engine = SearchEngine::new(&index);
let opts = SearchOptions::default();
let run1 = engine.search("pet", &opts);
let run2 = engine.search("pet", &opts);
assert_eq!(run1.len(), run2.len());
for (a, b) in run1.iter().zip(run2.iter()) {
assert_eq!(a.score, b.score, "scores should be identical across runs");
assert_eq!(a.rank, b.rank, "ranks should be identical across runs");
assert_eq!(a.name, b.name, "names should be identical across runs");
assert_eq!(
a.method, b.method,
"methods should be identical across runs"
);
}
}
#[test]
fn test_search_exact_mode() {
let index = petstore_index();
let engine = SearchEngine::new(&index);
// "list all" as two tokens: should match broadly
let loose_opts = SearchOptions {
exact: false,
..SearchOptions::default()
};
let loose = engine.search("list all", &loose_opts);
// "list all" as exact phrase: only matches if that exact phrase appears
let exact_opts = SearchOptions {
exact: true,
..SearchOptions::default()
};
let exact = engine.search("list all", &exact_opts);
// Exact should be a subset of (or equal to) loose results
assert!(
exact.len() <= loose.len(),
"exact mode should return fewer or equal results"
);
// The exact match should find "List all pets" summary
assert!(
!exact.is_empty(),
"exact 'list all' should match 'List all pets'"
);
}
#[test]
fn test_search_case_sensitive() {
let index = petstore_index();
let engine = SearchEngine::new(&index);
// Case-insensitive (default): "PET" matches "pet", "/pets", etc.
let insensitive = SearchOptions {
case_sensitive: false,
..SearchOptions::default()
};
let results_insensitive = engine.search("PET", &insensitive);
// Case-sensitive: "PET" should NOT match lowercase "pet" or "/pets"
let sensitive = SearchOptions {
case_sensitive: true,
..SearchOptions::default()
};
let results_sensitive = engine.search("PET", &sensitive);
assert!(
results_sensitive.len() < results_insensitive.len(),
"case-sensitive 'PET' should match fewer results than case-insensitive"
);
}
#[test]
fn test_safe_snippet_unicode() {
// Emoji and multi-byte characters
let haystack = "Hello \u{1F600} world of pets and \u{1F431} cats everywhere";
let snippet = safe_snippet(haystack, "pets", false);
assert!(
snippet.contains("pets"),
"snippet should contain the search term"
);
// Must not panic on multi-byte boundaries
}
#[test]
fn test_safe_snippet_truncation() {
let long = "a".repeat(200);
let haystack = format!("{long}needle{long}");
let snippet = safe_snippet(&haystack, "needle", false);
assert!(snippet.contains("needle"));
assert!(
snippet.contains("..."),
"should have ellipsis for truncation"
);
// Snippet should be around 50 chars + ellipsis markers
let body_len = snippet.replace("...", "").chars().count();
assert!(body_len <= 50, "snippet body should be at most 50 chars");
}
#[test]
fn test_empty_query_returns_empty() {
let index = petstore_index();
let engine = SearchEngine::new(&index);
let opts = SearchOptions::default();
assert!(engine.search("", &opts).is_empty());
assert!(engine.search(" ", &opts).is_empty());
}
#[test]
fn test_search_limit() {
let index = petstore_index();
let engine = SearchEngine::new(&index);
let opts = SearchOptions {
limit: 2,
..SearchOptions::default()
};
let results = engine.search("pet", &opts);
assert!(results.len() <= 2, "should respect limit");
}
#[test]
fn test_search_schemas_only() {
let index = petstore_index();
let engine = SearchEngine::new(&index);
let opts = SearchOptions {
search_paths: false,
search_descriptions: false,
search_schemas: true,
..SearchOptions::default()
};
let results = engine.search("Pet", &opts);
assert!(!results.is_empty());
for r in &results {
assert_eq!(
r.result_type,
SearchResultType::Schema,
"should only return schemas"
);
}
}
#[test]
fn test_search_paths_only() {
let index = petstore_index();
let engine = SearchEngine::new(&index);
let opts = SearchOptions {
search_paths: true,
search_descriptions: false,
search_schemas: false,
..SearchOptions::default()
};
let results = engine.search("store", &opts);
assert!(!results.is_empty());
for r in &results {
assert_eq!(
r.result_type,
SearchResultType::Endpoint,
"should only return endpoints"
);
}
}
#[test]
fn test_multi_term_coverage_boost() {
let index = petstore_index();
let engine = SearchEngine::new(&index);
let opts = SearchOptions::default();
// "pets store" has two terms; an endpoint matching both gets higher coverage
let results = engine.search("pets list", &opts);
if results.len() >= 2 {
// The first result should have a higher score due to more term matches
assert!(
results[0].score >= results[1].score,
"results should be sorted by score descending"
);
}
}
#[test]
fn test_no_match_returns_empty() {
let index = petstore_index();
let engine = SearchEngine::new(&index);
let opts = SearchOptions::default();
let results = engine.search("zzzznotfound", &opts);
assert!(
results.is_empty(),
"gibberish query should return no results"
);
}
}