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,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::output::robot;
use crate::output::table::render_table_or_empty;
/// List endpoints from a cached spec
#[derive(Debug, ClapArgs)]
@@ -8,19 +18,657 @@ pub struct Args {
/// Alias of the cached spec
pub alias: String,
/// Filter by HTTP method
#[arg(long)]
/// Filter by HTTP method (case-insensitive)
#[arg(long, short = 'm')]
pub method: Option<String>,
/// Filter by tag
#[arg(long)]
/// Filter by tag (endpoints containing this tag)
#[arg(long, short = 't')]
pub tag: Option<String>,
/// Filter by path pattern
#[arg(long)]
/// Filter by path pattern (regex)
#[arg(long, short = 'p')]
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);
}
}