Skip to main content

tapes_client/cassettes/
spec.rs

1//! Reducing a cassette's OpenAPI document to the surface a CLI needs.
2//!
3//! This is deliberately *not* an OpenAPI implementation. A command line needs
4//! five things from an operation — what to call it, which verb, which path,
5//! which inputs, and whether it takes a body — and everything else in the
6//! document (response schemas, examples, security, servers) describes what comes
7//! *back*, which a consumer prints verbatim without modelling. So
8//! the reducer reads the handful of keys it acts on and ignores the rest, which
9//! also means a document using a feature this build predates still yields a
10//! working command instead of an error.
11//!
12//! # Naming
13//!
14//! A method's name is its `operationId`, kebab-cased: `getHello` becomes
15//! `tapesctl hello-world get-hello`. The id is the one name in the document the
16//! cassette author chose *for the operation itself* rather than for its
17//! transport, it is unique within a document by the OpenAPI spec, and core
18//! leaves it bare in the per-cassette document (only the merged aggregate
19//! namespaces ids). An operation with no id gets one synthesized from its verb
20//! and path, which is what core would have done anyway.
21
22use std::collections::BTreeSet;
23
24use serde_json::Value;
25
26/// The HTTP verbs OpenAPI defines as operations on a path item.
27///
28/// A path item also holds `summary`, `description`, `servers`, `parameters` and
29/// `$ref`, so "a key under a path" and "a method" are not the same thing — a
30/// reader that assumed they were would publish the shared parameter list as a
31/// command.
32const HTTP_METHODS: [&str; 8] = [
33    "get", "put", "post", "delete", "patch", "head", "options", "trace",
34];
35
36/// How a consumer parameterizes the reduction.
37///
38/// Extracted from tapesctl, where the reserved list was a compile-time
39/// constant; each consumer's subcommands define their own flags, so the list
40/// is theirs to declare.
41#[derive(Debug, Clone, Copy, Default)]
42pub struct ReducerConfig<'a> {
43    /// Flag names the generated surface cannot hand to a cassette parameter,
44    /// because the consumer's subcommand defines them itself. A colliding
45    /// parameter is presented as `param-<flag>`, prefixed again as many times
46    /// as it takes to clear the reserved set, and suffixed with a counter if
47    /// it then collides with a sibling; its wire name is untouched.
48    pub reserved_flags: &'a [&'a str],
49}
50
51/// Where a parameter travels.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum Location {
54    /// Substituted into the path template; becomes a positional argument.
55    Path,
56    /// Appended to the query string; becomes a `--flag`.
57    Query,
58    /// Sent as a request header; becomes a `--flag`.
59    Header,
60}
61
62/// One input to a generated method.
63#[derive(Debug, Clone)]
64pub struct Param {
65    /// The name on the wire, used verbatim in the query string or header.
66    pub wire: String,
67    /// The long-flag or positional name presented to the user.
68    pub flag: String,
69    /// Where it travels.
70    pub location: Location,
71    /// Whether the operation requires it.
72    pub required: bool,
73    /// Help text, when the document carries any.
74    pub description: Option<String>,
75}
76
77/// One generated method.
78#[derive(Debug, Clone)]
79pub struct Method {
80    /// The subcommand name.
81    pub name: String,
82    /// The document's own `operationId`, verbatim, when it carried one.
83    ///
84    /// Kept beside the kebab-cased `name` because it is the identity other
85    /// artifacts use: the vendored core contract is addressed by operation id
86    /// (see tapesctl's `api::contract`), and the coverage gate that keeps the
87    /// CLI honest about what it exposes is written in the document's own terms.
88    pub operation_id: Option<String>,
89    /// One line of help.
90    pub summary: Option<String>,
91    /// The HTTP verb, uppercased.
92    pub http_method: String,
93    /// The public path template, used verbatim — core already republished the
94    /// document onto the paths a client can call.
95    pub path: String,
96    /// Path, query and header inputs.
97    pub params: Vec<Param>,
98    /// `Some(true)` when a request body is required, `Some(false)` when it is
99    /// accepted but optional, `None` when the operation takes none.
100    pub body: Option<bool>,
101}
102
103impl Method {
104    /// The path parameters, in the order they appear in the path template.
105    #[must_use]
106    pub fn path_params(&self) -> Vec<&Param> {
107        self.params
108            .iter()
109            .filter(|param| param.location == Location::Path)
110            .collect()
111    }
112}
113
114/// One cassette's generated surface.
115#[derive(Debug, Clone)]
116pub struct Cassette {
117    /// The noun on the command line.
118    pub name: String,
119    /// One line of help.
120    pub description: Option<String>,
121    /// Its methods, ordered by name.
122    pub methods: Vec<Method>,
123}
124
125/// Every cassette a server serves.
126#[derive(Debug, Clone, Default)]
127pub struct Surface {
128    /// The cassettes, ordered by name.
129    pub cassettes: Vec<Cassette>,
130}
131
132impl Surface {
133    /// Whether there is anything to generate.
134    #[must_use]
135    pub fn is_empty(&self) -> bool {
136        self.cassettes.is_empty()
137    }
138
139    /// Find a cassette by its noun.
140    #[must_use]
141    pub fn cassette(&self, name: &str) -> Option<&Cassette> {
142        self.cassettes.iter().find(|c| c.name == name)
143    }
144}
145
146/// Reduce one cassette's OpenAPI document to its methods.
147///
148/// Never fails: an operation the reducer cannot make sense of is dropped, and a
149/// document with no usable operations yields a cassette with no methods. A hard
150/// error here would take out the whole CLI over one malformed cassette.
151#[must_use]
152pub fn reduce(
153    entry_name: &str,
154    description: Option<String>,
155    document: &Value,
156    reducer: &ReducerConfig<'_>,
157) -> Cassette {
158    Cassette {
159        name: entry_name.to_owned(),
160        description,
161        methods: reduce_methods(document, reducer),
162    }
163}
164
165/// Reduce any OpenAPI document to its operations.
166///
167/// This is the document-source-agnostic half of [`reduce`]: the cassette
168/// surface feeds it a runtime-discovered document, and tapesctl's core surface
169/// feeds it a vendored contract. One reducer, two document sources — so the
170/// two surfaces cannot read OpenAPI differently.
171#[must_use]
172pub fn reduce_methods(document: &Value, reducer: &ReducerConfig<'_>) -> Vec<Method> {
173    let mut methods: Vec<Method> = Vec::new();
174    let mut taken: BTreeSet<String> = BTreeSet::new();
175
176    if let Some(paths) = document.get("paths").and_then(Value::as_object) {
177        // serde_json orders object keys, so the generated surface is stable
178        // between invocations against an unchanged document.
179        for (path, item) in paths {
180            methods.extend(methods_of(path, item, document, &mut taken, reducer));
181        }
182    }
183
184    methods.sort_by(|a, b| a.name.cmp(&b.name));
185    methods
186}
187
188/// Every operation on one path item.
189///
190/// `taken` is threaded through rather than scoped per path because method names
191/// have to be unique across the whole cassette, not just within one path.
192fn methods_of(
193    path: &str,
194    item: &Value,
195    document: &Value,
196    taken: &mut BTreeSet<String>,
197    reducer: &ReducerConfig<'_>,
198) -> Vec<Method> {
199    let Some(item) = item.as_object() else {
200        return Vec::new();
201    };
202    let shared = parameters_of(item.get("parameters"), document);
203
204    HTTP_METHODS
205        .iter()
206        .filter_map(|verb| {
207            let operation = item.get(*verb)?.as_object()?;
208
209            let mut params = shared.clone();
210            params.extend(parameters_of(operation.get("parameters"), document));
211
212            let operation_id = operation
213                .get("operationId")
214                .and_then(Value::as_str)
215                .map(str::trim)
216                .filter(|id| !id.is_empty())
217                .map(ToOwned::to_owned);
218            let raw_name = operation_id
219                .as_deref()
220                .map_or_else(|| synthesize_id(verb, path), kebab_case);
221
222            Some(Method {
223                name: unique(raw_name, verb, taken),
224                operation_id,
225                summary: text_of(operation.get("summary"))
226                    .or_else(|| text_of(operation.get("description"))),
227                http_method: verb.to_ascii_uppercase(),
228                path: path.to_owned(),
229                params: finish_params(path, params, reducer),
230                body: operation.get("requestBody").map(body_required),
231            })
232        })
233        .collect()
234}
235
236/// Whether a request body object marks itself required. Absent means optional,
237/// which is what OpenAPI's own default says.
238fn body_required(body: &Value) -> bool {
239    body.get("required")
240        .and_then(Value::as_bool)
241        .unwrap_or(false)
242}
243
244/// Read a parameter list, resolving local `$ref`s into
245/// `#/components/parameters`.
246///
247/// A reference that does not resolve is dropped rather than guessed at: a
248/// parameter whose name is unknown cannot be sent under the right name, and
249/// inventing one would produce a request the server rejects for a reason the
250/// user cannot see.
251fn parameters_of(value: Option<&Value>, document: &Value) -> Vec<Param> {
252    let Some(list) = value.and_then(Value::as_array) else {
253        return Vec::new();
254    };
255
256    list.iter()
257        .filter_map(|entry| {
258            let resolved = match entry.get("$ref").and_then(Value::as_str) {
259                Some(reference) => resolve(reference, document)?,
260                None => entry,
261            };
262            parameter(resolved)
263        })
264        .collect()
265}
266
267/// Resolve a local JSON pointer of the `#/a/b/c` form.
268fn resolve<'a>(reference: &str, document: &'a Value) -> Option<&'a Value> {
269    let pointer = reference.strip_prefix('#')?;
270    document.pointer(pointer)
271}
272
273/// Read one parameter object.
274fn parameter(value: &Value) -> Option<Param> {
275    let wire = value.get("name").and_then(Value::as_str)?.trim();
276    if wire.is_empty() {
277        return None;
278    }
279    let location = match value.get("in").and_then(Value::as_str) {
280        Some("path") => Location::Path,
281        Some("query") => Location::Query,
282        Some("header") => Location::Header,
283        // `cookie` is the only other location OpenAPI defines, and a CLI has no
284        // sensible way to offer it. Anything else is not a parameter at all.
285        _ => return None,
286    };
287
288    Some(Param {
289        wire: wire.to_owned(),
290        flag: kebab_case(wire),
291        location,
292        // A path parameter is required by definition; OpenAPI says so and a
293        // document that claims otherwise still cannot produce a callable URL.
294        required: location == Location::Path
295            || value
296                .get("required")
297                .and_then(Value::as_bool)
298                .unwrap_or(false),
299        description: text_of(value.get("description")),
300    })
301}
302
303/// Put a parameter list into its final shape: path parameters in template
304/// order, then the rest, with every flag name unique and none of them colliding
305/// with a flag the subcommand defines itself.
306fn finish_params(path: &str, params: Vec<Param>, reducer: &ReducerConfig<'_>) -> Vec<Param> {
307    let templated = template_params(path);
308
309    let mut ordered: Vec<Param> = Vec::new();
310    // Template order wins for positionals: the URL is built by substitution, so
311    // the order the user types them in has to be the order they appear.
312    for name in &templated {
313        if let Some(found) = params
314            .iter()
315            .find(|p| p.location == Location::Path && &p.wire == name)
316        {
317            ordered.push(found.clone());
318        } else {
319            // Declared in the path but not in `parameters`. The URL cannot be
320            // built without a value for it, so synthesize the input rather than
321            // generate a command that can only produce a broken request.
322            ordered.push(Param {
323                wire: name.clone(),
324                flag: kebab_case(name),
325                location: Location::Path,
326                required: true,
327                description: None,
328            });
329        }
330    }
331    for param in params {
332        // A declared path parameter the template never mentions has nowhere to
333        // go; keeping it would offer an argument that changes nothing.
334        if param.location != Location::Path {
335            ordered.push(param);
336        }
337    }
338
339    let mut seen: BTreeSet<String> = BTreeSet::new();
340    for param in &mut ordered {
341        // Keep prefixing until the name is clear of the reserved set: a
342        // consumer is free to reserve a rewrite's spelling too (`body` AND
343        // `param-body`), and a single pass would emit a still-reserved flag —
344        // which clap punishes with a duplicate-id panic the moment the
345        // command is constructed. The set is finite, so this terminates.
346        let mut base = param.flag.clone();
347        while reducer.reserved_flags.contains(&base.as_str()) {
348            base = format!("param-{base}");
349        }
350        // Then make it unique among its siblings — and never let the counter
351        // land back on a reserved name either. The order of the checks
352        // matters: `insert` records the candidate as taken, so a reserved
353        // candidate must be rejected before it is offered to the set.
354        let mut candidate = base.clone();
355        let mut suffix = 2;
356        while reducer.reserved_flags.contains(&candidate.as_str())
357            || !seen.insert(candidate.clone())
358        {
359            candidate = format!("{base}-{suffix}");
360            suffix += 1;
361        }
362        param.flag = candidate;
363    }
364
365    ordered
366}
367
368/// The `{name}` placeholders in a path template, in order.
369fn template_params(path: &str) -> Vec<String> {
370    let mut found = Vec::new();
371    let mut rest = path;
372    while let Some(open) = rest.find('{') {
373        let Some(close) = rest[open..].find('}') else {
374            break;
375        };
376        let name = &rest[open + 1..open + close];
377        if !name.is_empty() {
378            found.push(name.to_owned());
379        }
380        rest = &rest[open + close + 1..];
381    }
382    found
383}
384
385/// Derive a method name from a verb and a path, for an operation with no
386/// `operationId`.
387///
388/// The cassette's own prefix is not stripped: this runs on the republished
389/// document, where every path starts `/v1/cassettes/<name>/`, and those segments
390/// are dropped so the name describes the operation rather than repeating the
391/// noun it already sits under.
392fn synthesize_id(verb: &str, path: &str) -> String {
393    let mut parts = vec![verb.to_ascii_lowercase()];
394    let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
395    // `/v1/cassettes/<name>/rest...` — drop the three-segment mount point.
396    let tail = if segments.len() > 3 && segments[0] == "v1" && segments[1] == "cassettes" {
397        &segments[3..]
398    } else {
399        &segments[..]
400    };
401    for segment in tail {
402        let cleaned: String = segment
403            .chars()
404            .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
405            .collect();
406        if !cleaned.is_empty() {
407            parts.push(kebab_case(&cleaned));
408        }
409    }
410    if parts.len() == 1 {
411        // Nothing but the mount point; the verb alone is still a usable name.
412        return parts.remove(0);
413    }
414    parts.join("-")
415}
416
417/// Make a name unique within one cassette.
418///
419/// Operation ids are unique within an OpenAPI document by the specification, so
420/// this only fires when two ids kebab to the same thing (`getHello` and
421/// `get_hello`). The verb disambiguates first because it is meaningful; a
422/// counter is the last resort.
423fn unique(name: String, verb: &str, taken: &mut BTreeSet<String>) -> String {
424    if taken.insert(name.clone()) {
425        return name;
426    }
427    let with_verb = format!("{name}-{verb}");
428    if taken.insert(with_verb.clone()) {
429        return with_verb;
430    }
431    let mut suffix = 2;
432    loop {
433        let candidate = format!("{name}-{suffix}");
434        if taken.insert(candidate.clone()) {
435            return candidate;
436        }
437        suffix += 1;
438    }
439}
440
441/// A trimmed, non-empty string, or nothing.
442fn text_of(value: Option<&Value>) -> Option<String> {
443    value
444        .and_then(Value::as_str)
445        .map(str::trim)
446        .filter(|s| !s.is_empty())
447        .map(ToOwned::to_owned)
448}
449
450/// Convert an identifier to kebab-case.
451///
452/// `getHello` → `get-hello`, `since_id` → `since-id`, `getHTTPStatus` →
453/// `get-http-status`: a run of capitals stays together, and only the last one
454/// starts the next word, so an acronym does not explode into single letters.
455fn kebab_case(raw: &str) -> String {
456    let chars: Vec<char> = raw.trim().chars().collect();
457    let mut out = String::with_capacity(chars.len() + 4);
458
459    for (index, &current) in chars.iter().enumerate() {
460        if current == '_' || current == ' ' || current == '.' {
461            if !out.ends_with('-') && !out.is_empty() {
462                out.push('-');
463            }
464            continue;
465        }
466        if current == '-' {
467            if !out.ends_with('-') && !out.is_empty() {
468                out.push('-');
469            }
470            continue;
471        }
472        if current.is_ascii_uppercase() && index > 0 {
473            let previous = chars[index - 1];
474            let starts_word = previous.is_ascii_lowercase()
475                || previous.is_ascii_digit()
476                || (previous.is_ascii_uppercase()
477                    && chars.get(index + 1).is_some_and(char::is_ascii_lowercase));
478            if starts_word && !out.ends_with('-') && !out.is_empty() {
479                out.push('-');
480            }
481        }
482        out.extend(current.to_lowercase());
483    }
484
485    out.trim_matches('-').to_owned()
486}
487
488#[cfg(test)]
489#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
490mod tests {
491    use super::*;
492    use serde_json::json;
493
494    /// The list tapesctl reserves, which is what these moved tests were
495    /// written against.
496    const RESERVED: ReducerConfig<'static> = ReducerConfig {
497        reserved_flags: &["tapes-url", "body", "help", "verbose"],
498    };
499
500    /// Shadow of [`super::reduce`] pinning the tapesctl reserved list, so the
501    /// moved test bodies read exactly as they did before the extraction.
502    fn reduce(entry_name: &str, description: Option<String>, document: &Value) -> Cassette {
503        super::reduce(entry_name, description, document, &RESERVED)
504    }
505
506    fn hello_world() -> Value {
507        // The shape core republishes: paths already public, ids still bare.
508        json!({
509            "openapi": "3.1.0",
510            "paths": {
511                "/v1/cassettes/hello-world/hello": {
512                    "get": {
513                        "operationId": "getHello",
514                        "summary": "Greet, and read back every stored row"
515                    },
516                    "post": {
517                        "operationId": "createHello",
518                        "summary": "Write one row to the hello table",
519                        "requestBody": {"required": false}
520                    }
521                }
522            }
523        })
524    }
525
526    #[test]
527    fn an_operation_id_becomes_a_kebab_case_method() {
528        let cassette = reduce("hello-world", None, &hello_world());
529        let names: Vec<&str> = cassette.methods.iter().map(|m| m.name.as_str()).collect();
530        assert_eq!(names, vec!["create-hello", "get-hello"]);
531    }
532
533    #[test]
534    fn the_republished_path_is_used_verbatim() {
535        // Core already rewrote the cassette's own `/api/<name>/hello` onto the
536        // public surface; rewriting it again here would break the request.
537        let cassette = reduce("hello-world", None, &hello_world());
538        let method = cassette
539            .methods
540            .iter()
541            .find(|m| m.name == "get-hello")
542            .unwrap();
543        assert_eq!(method.path, "/v1/cassettes/hello-world/hello");
544        assert_eq!(method.http_method, "GET");
545    }
546
547    #[test]
548    fn an_optional_request_body_is_distinguished_from_a_required_one_and_from_none() {
549        let document = json!({"paths": {"/v1/cassettes/c/thing": {
550            "post": {"operationId": "a", "requestBody": {"required": true}},
551            "put": {"operationId": "b", "requestBody": {}},
552            "get": {"operationId": "c"}
553        }}});
554        let cassette = reduce("c", None, &document);
555        let body = |name: &str| {
556            cassette
557                .methods
558                .iter()
559                .find(|m| m.name == name)
560                .unwrap()
561                .body
562        };
563        assert_eq!(body("a"), Some(true));
564        assert_eq!(body("b"), Some(false));
565        assert_eq!(body("c"), None);
566    }
567
568    #[test]
569    fn path_parameters_are_ordered_by_the_template_not_by_the_declaration() {
570        // The URL is built by substitution, so positional order must follow the
571        // path; a document is free to declare them in any order.
572        let document = json!({"paths": {"/v1/cassettes/c/{owner}/reports/{id}": {
573            "parameters": [
574                {"name": "id", "in": "path", "required": true},
575                {"name": "owner", "in": "path", "required": true}
576            ],
577            "get": {"operationId": "getReport"}
578        }}});
579        let cassette = reduce("c", None, &document);
580        let method = &cassette.methods[0];
581        let names: Vec<&str> = method
582            .path_params()
583            .iter()
584            .map(|p| p.wire.as_str())
585            .collect();
586        assert_eq!(names, vec!["owner", "id"]);
587    }
588
589    #[test]
590    fn a_templated_segment_with_no_declaration_still_becomes_an_argument() {
591        // Without a value for it there is no callable URL, so generating the
592        // command without the argument would only produce broken requests.
593        let document = json!({"paths": {"/v1/cassettes/c/reports/{id}": {
594            "get": {"operationId": "getReport"}
595        }}});
596        let cassette = reduce("c", None, &document);
597        assert_eq!(cassette.methods[0].path_params()[0].wire, "id");
598        assert!(cassette.methods[0].path_params()[0].required);
599    }
600
601    #[test]
602    fn shared_path_item_parameters_reach_every_operation() {
603        let document = json!({"paths": {"/v1/cassettes/c/reports": {
604            "parameters": [{"name": "since", "in": "query"}],
605            "get": {"operationId": "listReports"},
606            "post": {"operationId": "createReport"}
607        }}});
608        let cassette = reduce("c", None, &document);
609        for method in &cassette.methods {
610            assert!(
611                method.params.iter().any(|p| p.wire == "since"),
612                "{} lost the shared parameter",
613                method.name,
614            );
615        }
616    }
617
618    #[test]
619    fn a_shared_parameter_list_is_not_mistaken_for_an_operation() {
620        // `parameters` is a path-item key but not a method; a reader that
621        // treated every key as an operation would publish it as a command.
622        let document = json!({"paths": {"/v1/cassettes/c/reports": {
623            "parameters": [{"name": "since", "in": "query"}],
624            "summary": "not an operation",
625            "get": {"operationId": "listReports"}
626        }}});
627        let cassette = reduce("c", None, &document);
628        assert_eq!(cassette.methods.len(), 1);
629        assert_eq!(cassette.methods[0].name, "list-reports");
630    }
631
632    #[test]
633    fn a_referenced_parameter_is_resolved_from_components() {
634        let document = json!({
635            "components": {"parameters": {"Since": {"name": "since", "in": "query", "required": true}}},
636            "paths": {"/v1/cassettes/c/reports": {
637                "get": {"operationId": "listReports", "parameters": [{"$ref": "#/components/parameters/Since"}]}
638            }}
639        });
640        let cassette = reduce("c", None, &document);
641        let param = &cassette.methods[0].params[0];
642        assert_eq!(param.wire, "since");
643        assert!(param.required);
644        assert_eq!(param.location, Location::Query);
645    }
646
647    #[test]
648    fn a_reference_that_does_not_resolve_is_dropped_rather_than_guessed_at() {
649        let document = json!({"paths": {"/v1/cassettes/c/reports": {
650            "get": {"operationId": "listReports", "parameters": [{"$ref": "#/components/parameters/Absent"}]}
651        }}});
652        let cassette = reduce("c", None, &document);
653        assert!(cassette.methods[0].params.is_empty());
654    }
655
656    #[test]
657    fn a_cookie_parameter_is_ignored_because_a_cli_cannot_offer_one() {
658        let document = json!({"paths": {"/v1/cassettes/c/reports": {
659            "get": {"operationId": "listReports", "parameters": [{"name": "sid", "in": "cookie"}]}
660        }}});
661        let cassette = reduce("c", None, &document);
662        assert!(cassette.methods[0].params.is_empty());
663    }
664
665    #[test]
666    fn a_parameter_cannot_take_a_flag_the_subcommand_defines_itself() {
667        // `--tapes-url` belongs to tapesctl. Handing it to a cassette parameter
668        // would make clap panic on a duplicate argument at startup — which the
669        // workspace lints forbid and a user could trigger with a custom spec.
670        let document = json!({"paths": {"/v1/cassettes/c/reports": {
671            "get": {"operationId": "listReports", "parameters": [
672                {"name": "tapes_url", "in": "query"},
673                {"name": "body", "in": "query"}
674            ]}
675        }}});
676        let cassette = reduce("c", None, &document);
677        let flags: Vec<&str> = cassette.methods[0]
678            .params
679            .iter()
680            .map(|p| p.flag.as_str())
681            .collect();
682        assert_eq!(flags, vec!["param-tapes-url", "param-body"]);
683        // The wire names are untouched — only the presentation moved.
684        assert_eq!(cassette.methods[0].params[0].wire, "tapes_url");
685    }
686
687    #[test]
688    fn a_reserved_rewrite_that_is_itself_reserved_is_rewritten_again() {
689        // A consumer is free to reserve both a name and its rewrite (`body`
690        // AND `param-body`). One rewrite pass would emit the still-reserved
691        // `param-body`, and clap panics on the duplicate argument id the
692        // moment the command is constructed — a crash a server's spec could
693        // trigger on a user's machine.
694        let adversarial = ReducerConfig {
695            reserved_flags: &["body", "param-body", "help"],
696        };
697        let document = json!({"paths": {"/v1/cassettes/c/reports": {
698            "get": {"operationId": "listReports", "parameters": [
699                {"name": "body", "in": "query"}
700            ]}
701        }}});
702        let cassette = super::reduce("c", None, &document, &adversarial);
703        let param = &cassette.methods[0].params[0];
704        assert_eq!(param.flag, "param-param-body");
705        // The wire name is untouched — only the presentation moved.
706        assert_eq!(param.wire, "body");
707    }
708
709    #[test]
710    fn sibling_rewrites_that_collide_come_out_unique_and_unreserved() {
711        // `body` is reserved twice over, and `param_body` kebabs onto the
712        // same rewrite chain — the pair must come out distinct from each
713        // other AND clear of the reserved set.
714        let adversarial = ReducerConfig {
715            reserved_flags: &["body", "param-body"],
716        };
717        let document = json!({"paths": {"/v1/cassettes/c/reports": {
718            "get": {"operationId": "listReports", "parameters": [
719                {"name": "body", "in": "query"},
720                {"name": "param_body", "in": "query"}
721            ]}
722        }}});
723        let cassette = super::reduce("c", None, &document, &adversarial);
724        let flags: Vec<&str> = cassette.methods[0]
725            .params
726            .iter()
727            .map(|p| p.flag.as_str())
728            .collect();
729        assert_eq!(flags, vec!["param-param-body", "param-param-body-2"]);
730        for flag in flags {
731            assert!(
732                !adversarial.reserved_flags.contains(&flag),
733                "{flag:?} is still reserved",
734            );
735        }
736    }
737
738    #[test]
739    fn a_uniqueness_suffix_may_not_land_on_a_reserved_name() {
740        // The counter that separates colliding siblings can itself produce a
741        // reserved spelling; it has to keep counting past it.
742        let adversarial = ReducerConfig {
743            reserved_flags: &["since-id-2"],
744        };
745        let document = json!({"paths": {"/v1/cassettes/c/reports": {
746            "get": {"operationId": "listReports", "parameters": [
747                {"name": "since_id", "in": "query"},
748                {"name": "sinceId", "in": "header"}
749            ]}
750        }}});
751        let cassette = super::reduce("c", None, &document, &adversarial);
752        let flags: Vec<&str> = cassette.methods[0]
753            .params
754            .iter()
755            .map(|p| p.flag.as_str())
756            .collect();
757        assert_eq!(flags, vec!["since-id", "since-id-3"]);
758    }
759
760    #[test]
761    fn two_parameters_that_kebab_to_the_same_flag_stay_distinguishable() {
762        let document = json!({"paths": {"/v1/cassettes/c/reports": {
763            "get": {"operationId": "listReports", "parameters": [
764                {"name": "since_id", "in": "query"},
765                {"name": "sinceId", "in": "header"}
766            ]}
767        }}});
768        let cassette = reduce("c", None, &document);
769        let flags: Vec<&str> = cassette.methods[0]
770            .params
771            .iter()
772            .map(|p| p.flag.as_str())
773            .collect();
774        assert_eq!(flags, vec!["since-id", "since-id-2"]);
775    }
776
777    #[test]
778    fn an_operation_without_an_id_gets_one_from_its_verb_and_path() {
779        let document = json!({"paths": {"/v1/cassettes/summary/reports/{id}": {"get": {}}}});
780        let cassette = reduce("summary", None, &document);
781        // The `/v1/cassettes/summary` mount point is dropped: the command
782        // already sits under that noun.
783        assert_eq!(cassette.methods[0].name, "get-reports-id");
784    }
785
786    #[test]
787    fn colliding_method_names_are_disambiguated_by_verb() {
788        let document = json!({"paths": {"/v1/cassettes/c/thing": {
789            "get": {"operationId": "doThing"},
790            "post": {"operationId": "do_thing"}
791        }}});
792        let cassette = reduce("c", None, &document);
793        let names: Vec<&str> = cassette.methods.iter().map(|m| m.name.as_str()).collect();
794        assert_eq!(names.len(), 2);
795        assert!(names.contains(&"do-thing"), "got: {names:?}");
796        assert!(
797            names.iter().any(|n| n.starts_with("do-thing-")),
798            "got: {names:?}",
799        );
800    }
801
802    #[test]
803    fn a_document_with_nothing_usable_yields_a_cassette_with_no_methods() {
804        // Not an error: one malformed cassette must not take out the CLI.
805        for document in [
806            json!({}),
807            json!({"paths": {}}),
808            json!({"paths": "nonsense"}),
809        ] {
810            assert!(reduce("c", None, &document).methods.is_empty());
811        }
812    }
813
814    #[test]
815    fn the_generated_surface_is_stable_between_reductions() {
816        // A CLI whose subcommand order changed run to run would make `--help`
817        // diffs meaningless.
818        let first = reduce("hello-world", None, &hello_world());
819        let second = reduce("hello-world", None, &hello_world());
820        let names =
821            |c: &Cassette| -> Vec<String> { c.methods.iter().map(|m| m.name.clone()).collect() };
822        assert_eq!(names(&first), names(&second));
823    }
824
825    #[test]
826    fn kebab_casing_keeps_acronyms_whole() {
827        assert_eq!(kebab_case("getHello"), "get-hello");
828        assert_eq!(kebab_case("since_id"), "since-id");
829        assert_eq!(kebab_case("getHTTPStatus"), "get-http-status");
830        assert_eq!(kebab_case("already-kebab"), "already-kebab");
831        assert_eq!(kebab_case("X"), "x");
832    }
833}