1use super::{OperationIndex, OperationSummary};
7
8#[derive(Debug, Default, Clone)]
10pub struct ListFilter {
11 pub tag: Option<String>,
12 pub method: Option<String>,
13 pub grep: Option<String>,
14}
15
16impl ListFilter {
17 fn matches(&self, op: &OperationSummary) -> bool {
18 if let Some(tag) = &self.tag {
19 let needle = tag.to_ascii_lowercase();
20 if !op
21 .tags
22 .iter()
23 .any(|t| t.to_ascii_lowercase().contains(&needle))
24 {
25 return false;
26 }
27 }
28 if let Some(method) = &self.method
29 && !op.method.eq_ignore_ascii_case(method)
30 {
31 return false;
32 }
33 if let Some(grep) = &self.grep {
34 let needle = grep.to_ascii_lowercase();
35 let hay_id = op.operation_id.to_ascii_lowercase();
36 let hay_path = op.path.to_ascii_lowercase();
37 if !hay_id.contains(&needle) && !hay_path.contains(&needle) {
38 return false;
39 }
40 }
41 true
42 }
43}
44
45#[derive(Debug, Clone, Copy)]
46pub enum ListOutput {
47 Table,
48 Json,
49}
50
51pub fn select<'a>(index: &'a OperationIndex, filter: &ListFilter) -> Vec<&'a OperationSummary> {
53 index
54 .operations()
55 .iter()
56 .filter(|op| filter.matches(op))
57 .collect()
58}
59
60pub fn render(index: &OperationIndex, filter: &ListFilter, output: ListOutput) -> (String, usize) {
63 let matched = select(index, filter);
64 let count = matched.len();
65 let body = match output {
66 ListOutput::Table => render_table(&matched, index.tag_count()),
67 ListOutput::Json => render_json(&matched),
68 };
69 (body, count)
70}
71
72fn render_json(matched: &[&OperationSummary]) -> String {
73 serde_json::to_string_pretty(matched).unwrap_or_else(|_| "[]".to_string())
74}
75
76fn render_table(matched: &[&OperationSummary], total_tags: usize) -> String {
77 if matched.is_empty() {
78 return "No operations match the given filters.\n".to_string();
79 }
80
81 let tag_w = matched
82 .iter()
83 .map(|op| display_tag(op).len())
84 .max()
85 .unwrap_or(3)
86 .max("TAG".len());
87 let id_w = matched
88 .iter()
89 .map(|op| display_op_id(op).len())
90 .max()
91 .unwrap_or(5)
92 .max("OP_ID".len());
93 let method_w = matched
94 .iter()
95 .map(|op| op.method.len())
96 .max()
97 .unwrap_or(6)
98 .max("METHOD".len());
99
100 let mut out = String::new();
101 use std::fmt::Write;
102 let _ = writeln!(
103 out,
104 "{:<tag_w$} {:<id_w$} {:<method_w$} PATH",
105 "TAG",
106 "OP_ID",
107 "METHOD",
108 tag_w = tag_w,
109 id_w = id_w,
110 method_w = method_w,
111 );
112 for op in matched {
113 let id = display_op_id(op);
114 let tag = display_tag(op);
115 let stream_marker = if op.supports_streaming { " [SSE]" } else { "" };
116 let _ = writeln!(
117 out,
118 "{:<tag_w$} {:<id_w$} {:<method_w$} {}{}",
119 tag,
120 id,
121 op.method,
122 op.path,
123 stream_marker,
124 tag_w = tag_w,
125 id_w = id_w,
126 method_w = method_w,
127 );
128 }
129 let _ = writeln!(
130 out,
131 "\n{} operation{} across {} tag{}.",
132 matched.len(),
133 if matched.len() == 1 { "" } else { "s" },
134 total_tags,
135 if total_tags == 1 { "" } else { "s" },
136 );
137 out
138}
139
140fn display_tag(op: &OperationSummary) -> String {
141 if op.tags.is_empty() {
142 "<untagged>".to_string()
143 } else {
144 op.tags.join(",")
145 }
146}
147
148fn display_op_id(op: &OperationSummary) -> String {
149 if op.operation_id.is_empty() {
150 "-".to_string()
151 } else {
152 op.operation_id.clone()
153 }
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 fn op(id: &str, method: &str, path: &str, tags: &[&str], sse: bool) -> OperationSummary {
161 OperationSummary {
162 operation_id: id.to_string(),
163 method: method.to_string(),
164 path: path.to_string(),
165 tags: tags.iter().map(|s| s.to_string()).collect(),
166 supports_streaming: sse,
167 }
168 }
169
170 fn index_from(ops: Vec<OperationSummary>) -> OperationIndex {
171 OperationIndex::from_summaries(ops)
172 }
173
174 #[test]
175 fn empty_filter_returns_all() {
176 let idx = index_from(vec![
177 op("a", "GET", "/a", &["X"], false),
178 op("b", "POST", "/b", &["Y"], true),
179 ]);
180 assert_eq!(select(&idx, &ListFilter::default()).len(), 2);
181 }
182
183 #[test]
184 fn tag_filter_is_case_insensitive_substring() {
185 let idx = index_from(vec![
186 op("a", "GET", "/a", &["Chat"], false),
187 op("b", "POST", "/b", &["Embeddings"], false),
188 ]);
189 let f = ListFilter {
190 tag: Some("chat".to_string()),
191 ..Default::default()
192 };
193 let got = select(&idx, &f);
194 assert_eq!(got.len(), 1);
195 assert_eq!(got[0].operation_id, "a");
196 }
197
198 #[test]
199 fn method_filter_exact() {
200 let idx = index_from(vec![
201 op("a", "GET", "/a", &[], false),
202 op("b", "POST", "/b", &[], false),
203 ]);
204 let f = ListFilter {
205 method: Some("post".to_string()),
206 ..Default::default()
207 };
208 let got = select(&idx, &f);
209 assert_eq!(got.len(), 1);
210 assert_eq!(got[0].operation_id, "b");
211 }
212
213 #[test]
214 fn grep_matches_op_id_or_path() {
215 let idx = index_from(vec![
216 op("createResponse", "POST", "/v1/responses", &[], true),
217 op("listFiles", "GET", "/v1/files", &[], false),
218 ]);
219 let f = ListFilter {
220 grep: Some("response".to_string()),
221 ..Default::default()
222 };
223 assert_eq!(select(&idx, &f).len(), 1);
224 let f = ListFilter {
225 grep: Some("/v1/files".to_string()),
226 ..Default::default()
227 };
228 assert_eq!(select(&idx, &f).len(), 1);
229 }
230
231 #[test]
232 fn filters_compose() {
233 let idx = index_from(vec![
234 op("a", "POST", "/x", &["T"], false),
235 op("b", "GET", "/x", &["T"], false),
236 op("c", "POST", "/y", &["U"], false),
237 ]);
238 let f = ListFilter {
239 tag: Some("T".to_string()),
240 method: Some("POST".to_string()),
241 ..Default::default()
242 };
243 let got = select(&idx, &f);
244 assert_eq!(got.len(), 1);
245 assert_eq!(got[0].operation_id, "a");
246 }
247
248 #[test]
249 fn table_renders_header_and_count() {
250 let idx = index_from(vec![op("a", "GET", "/a", &["X"], false)]);
251 let (body, n) = render(&idx, &ListFilter::default(), ListOutput::Table);
252 assert_eq!(n, 1);
253 assert!(body.contains("OP_ID"));
254 assert!(body.contains("/a"));
255 assert!(body.contains("1 operation"));
256 }
257
258 #[test]
259 fn table_marks_sse() {
260 let idx = index_from(vec![op("a", "POST", "/a", &["X"], true)]);
261 let (body, _) = render(&idx, &ListFilter::default(), ListOutput::Table);
262 assert!(body.contains("[SSE]"));
263 }
264
265 #[test]
266 fn json_output_parses() {
267 let idx = index_from(vec![op("a", "GET", "/a", &["X"], false)]);
268 let (body, _) = render(&idx, &ListFilter::default(), ListOutput::Json);
269 let v: serde_json::Value = serde_json::from_str(&body).expect("valid json");
270 assert!(v.is_array());
271 assert_eq!(v[0]["operation_id"], "a");
272 }
273
274 #[test]
275 fn empty_match_message() {
276 let idx = index_from(vec![op("a", "GET", "/a", &[], false)]);
277 let f = ListFilter {
278 tag: Some("nope".to_string()),
279 ..Default::default()
280 };
281 let (body, n) = render(&idx, &f, ListOutput::Table);
282 assert_eq!(n, 0);
283 assert!(body.contains("No operations match"));
284 }
285}