Wave 7: Phase 2 features - sync --all, external refs, cross-alias discovery, CI/CD, reliability tests (bd-1ky, bd-1bp, bd-1rk, bd-1lj, bd-gvr, bd-1x5)
- Sync --all with async concurrency, per-host throttling, failure budgets, resumable execution - External ref bundling at fetch time with origin tracking - Cross-alias discovery (--all-aliases) for list and search commands - CI/CD pipeline (.gitlab-ci.yml), cargo-deny config, Dockerfile, install script - Reliability test suite: crash consistency (8 tests), lock contention (3 tests), property-based (4 tests) - Criterion performance benchmarks (5 benchmarks) - Bug fix: doctor --fix now repairs missing index.json when raw.json exists - Bug fix: shared $ref references no longer incorrectly flagged as circular (refs.rs) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
222
src/cli/list.rs
222
src/cli/list.rs
@@ -16,8 +16,12 @@ use crate::output::table::render_table_or_empty;
|
||||
/// List endpoints from a cached spec
|
||||
#[derive(Debug, ClapArgs)]
|
||||
pub struct Args {
|
||||
/// Alias of the cached spec
|
||||
pub alias: String,
|
||||
/// Alias of the cached spec (omit when using --all-aliases)
|
||||
pub alias: Option<String>,
|
||||
|
||||
/// Query across every cached alias
|
||||
#[arg(long)]
|
||||
pub all_aliases: bool,
|
||||
|
||||
/// Filter by HTTP method (case-insensitive)
|
||||
#[arg(long, short = 'm')]
|
||||
@@ -57,6 +61,18 @@ struct ListOutput {
|
||||
meta: ListMeta,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct AllAliasesListOutput {
|
||||
endpoints: Vec<AliasEndpointEntry>,
|
||||
aliases_searched: Vec<String>,
|
||||
total: usize,
|
||||
filtered: usize,
|
||||
applied_filters: BTreeMap<String, String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
warnings: Vec<String>,
|
||||
duration_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct EndpointEntry {
|
||||
path: String,
|
||||
@@ -67,6 +83,17 @@ struct EndpointEntry {
|
||||
deprecated: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct AliasEndpointEntry {
|
||||
alias: String,
|
||||
path: String,
|
||||
method: String,
|
||||
summary: Option<String>,
|
||||
operation_id: Option<String>,
|
||||
tags: Vec<String>,
|
||||
deprecated: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ListMeta {
|
||||
alias: String,
|
||||
@@ -89,11 +116,31 @@ struct EndpointRow {
|
||||
summary: String,
|
||||
}
|
||||
|
||||
#[derive(Tabled)]
|
||||
struct AliasEndpointRow {
|
||||
#[tabled(rename = "ALIAS")]
|
||||
alias: String,
|
||||
#[tabled(rename = "METHOD")]
|
||||
method: String,
|
||||
#[tabled(rename = "PATH")]
|
||||
path: String,
|
||||
#[tabled(rename = "SUMMARY")]
|
||||
summary: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Execute
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn execute(args: &Args, robot_mode: bool) -> Result<(), SwaggerCliError> {
|
||||
if args.all_aliases {
|
||||
return execute_all_aliases(args, robot_mode).await;
|
||||
}
|
||||
|
||||
let alias = args.alias.as_deref().ok_or_else(|| {
|
||||
SwaggerCliError::Usage("An alias is required unless --all-aliases is specified".to_string())
|
||||
})?;
|
||||
|
||||
let start = Instant::now();
|
||||
|
||||
// Compile path regex early so we fail fast on invalid patterns
|
||||
@@ -108,7 +155,7 @@ pub async fn execute(args: &Args, robot_mode: bool) -> Result<(), SwaggerCliErro
|
||||
};
|
||||
|
||||
let cm = CacheManager::new(cache_dir());
|
||||
let (index, meta) = cm.load_index(&args.alias)?;
|
||||
let (index, meta) = cm.load_index(alias)?;
|
||||
|
||||
let total = index.endpoints.len();
|
||||
|
||||
@@ -211,7 +258,7 @@ pub async fn execute(args: &Args, robot_mode: bool) -> Result<(), SwaggerCliErro
|
||||
filtered: filtered_count,
|
||||
applied_filters,
|
||||
meta: ListMeta {
|
||||
alias: args.alias.clone(),
|
||||
alias: alias.to_string(),
|
||||
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,
|
||||
@@ -253,6 +300,173 @@ pub async fn execute(args: &Args, robot_mode: bool) -> Result<(), SwaggerCliErro
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// All-aliases execution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn execute_all_aliases(args: &Args, robot_mode: bool) -> Result<(), SwaggerCliError> {
|
||||
let start = Instant::now();
|
||||
|
||||
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 alias_metas = cm.list_aliases()?;
|
||||
|
||||
if alias_metas.is_empty() {
|
||||
return Err(SwaggerCliError::Usage(
|
||||
"No cached aliases found. Fetch a spec first with 'swagger-cli fetch'.".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
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 all_entries: Vec<AliasEndpointEntry> = Vec::new();
|
||||
let mut aliases_searched: Vec<String> = Vec::new();
|
||||
let mut warnings: Vec<String> = Vec::new();
|
||||
let mut total_endpoints: usize = 0;
|
||||
|
||||
let mut sorted_aliases: Vec<_> = alias_metas.iter().map(|m| m.alias.as_str()).collect();
|
||||
sorted_aliases.sort();
|
||||
|
||||
for alias_name in &sorted_aliases {
|
||||
match cm.load_index(alias_name) {
|
||||
Ok((index, _meta)) => {
|
||||
aliases_searched.push((*alias_name).to_string());
|
||||
total_endpoints += index.endpoints.len();
|
||||
|
||||
for ep in &index.endpoints {
|
||||
if let Some(ref m) = method_upper
|
||||
&& ep.method.to_uppercase() != *m
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Some(ref t) = tag_lower
|
||||
&& !ep
|
||||
.tags
|
||||
.iter()
|
||||
.any(|tag| tag.to_lowercase().contains(t.as_str()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Some(ref re) = path_regex
|
||||
&& !re.is_match(&ep.path)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
all_entries.push(AliasEndpointEntry {
|
||||
alias: (*alias_name).to_string(),
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warnings.push(format!("Failed to load alias '{alias_name}': {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if aliases_searched.is_empty() {
|
||||
return Err(SwaggerCliError::Cache(
|
||||
"All aliases failed to load".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let filtered_count = all_entries.len();
|
||||
|
||||
// Sort: alias ASC, path ASC, method_rank ASC
|
||||
all_entries.sort_by(|a, b| {
|
||||
a.alias
|
||||
.cmp(&b.alias)
|
||||
.then_with(|| a.path.cmp(&b.path))
|
||||
.then_with(|| method_rank(&a.method).cmp(&method_rank(&b.method)))
|
||||
});
|
||||
|
||||
// ---- Limit ----
|
||||
if !args.all {
|
||||
all_entries.truncate(args.limit);
|
||||
}
|
||||
|
||||
let duration = start.elapsed();
|
||||
|
||||
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 output = AllAliasesListOutput {
|
||||
endpoints: all_entries,
|
||||
aliases_searched,
|
||||
total: total_endpoints,
|
||||
filtered: filtered_count,
|
||||
applied_filters,
|
||||
warnings,
|
||||
duration_ms: duration.as_millis().min(u64::MAX as u128) as u64,
|
||||
};
|
||||
|
||||
robot::robot_success(output, "list", duration);
|
||||
} else {
|
||||
println!("All aliases ({} searched)\n", aliases_searched.len());
|
||||
|
||||
let rows: Vec<AliasEndpointRow> = all_entries
|
||||
.iter()
|
||||
.map(|ep| AliasEndpointRow {
|
||||
alias: ep.alias.clone(),
|
||||
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_endpoints
|
||||
);
|
||||
} else {
|
||||
println!("Showing {} of {}", rows.len(), total_endpoints);
|
||||
}
|
||||
}
|
||||
|
||||
if !warnings.is_empty() {
|
||||
println!();
|
||||
for w in &warnings {
|
||||
eprintln!("Warning: {w}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user