Skip to main content

openapi_to_rust/server/
list.rs

1//! `openapi-to-rust server list` — read-only operation discovery.
2//!
3//! Filters compose: each non-empty filter narrows the result set.
4//! Output is either an aligned table (default) or a JSON array.
5
6use super::{OperationIndex, OperationSummary};
7
8/// Filter criteria. Empty filters match everything.
9#[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
51/// Apply filters and return the matching operations in index order.
52pub 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
60/// Render selected operations as either an aligned table or JSON.
61/// Returns the rendered string and the count of matched operations.
62pub 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}