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)] pub struct Args { /// Alias of the cached spec pub alias: String, /// Filter by HTTP method (case-insensitive) #[arg(long, short = 'm')] pub method: Option, /// Filter by tag (endpoints containing this tag) #[arg(long, short = 't')] pub tag: Option, /// Filter by path pattern (regex) #[arg(long, short = 'p')] pub path: Option, /// 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, } // --------------------------------------------------------------------------- // Robot output structs // --------------------------------------------------------------------------- #[derive(Debug, Serialize)] struct ListOutput { endpoints: Vec, total: usize, filtered: usize, applied_filters: BTreeMap, meta: ListMeta, } #[derive(Debug, Serialize)] struct EndpointEntry { path: String, method: String, summary: Option, operation_id: Option, tags: Vec, 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 = 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 = 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, 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 = 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 = 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 = 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 = 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); } }