oapi-codegen 1.0.0

Generate client and server boilerplate from OpenAPI 3 specifications
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
//! Terminal console output for the CLI: guided, colorized error reporting.
//!
//! Everything here writes to stderr through [`anstream`], which automatically
//! strips ANSI styling when stderr is not a terminal or `NO_COLOR` is set, so
//! the output degrades gracefully in pipes and logs. Styling is applied with
//! [`owo_colors`]. The goal is a "guided" experience: every failure states
//! what went wrong and, where possible, a concrete next step.

use std::io::ErrorKind;
use std::path::Path;

use anstream::eprint;
use anstream::eprintln;
use oapi_codegen::Error;
use oapi_codegen::config::Generate;
use oapi_codegen::deps::Dependency;
use owo_colors::OwoColorize;

/// Example `generate:` block shown when a config enables no artifacts. Each
/// line is printed dimmed and indented under the hint.
const GENERATE_EXAMPLE: &str = "\
generate:
  models: true            # structs/enums from components.schemas
  std-http-server: true   # an axum server trait from the paths
  client: true            # a blocking reqwest client from the paths
  server-urls: true       # constants/builders from the top-level `servers:` block";

/// A lightweight summary of a spec, used to explain empty output.
#[derive(Debug, Clone, Copy)]
pub struct SpecStats {
    /// Number of `components.schemas` entries (after filtering).
    pub schemas: usize,
    /// Number of paths declared in the document (after filtering).
    pub paths: usize,
    /// Number of top-level `servers:` entries declared in the document.
    pub servers: usize,
}

/// Return `true` when generated `code` contains no items, only the header and
/// blank lines. Such a file is confusing to write to disk, so the CLI treats it
/// as a failure and explains why instead.
///
/// The header is stripped as the one string the emitter writes, and not matched
/// by shape. It holds an inner attribute as well as the do-not-edit comment, and
/// a shape test that accepted any `#!` line would accept a real attribute too.
pub fn is_effectively_empty(code: &str) -> bool {
    let body = code.strip_prefix(oapi_codegen::emit::HEADER).unwrap_or(code);
    return body.lines().all(|line| {
        let trimmed = line.trim();
        return trimmed.is_empty() || trimmed.starts_with("//");
    });
}

/// Print a guided, styled error report for a generator `err` to stderr.
///
/// An [`Error::Validation`] holds several independent problems. Each one gets its
/// own numbered heading and its own hints, because one message with every hint
/// after it does not show which hint corrects which problem.
pub fn report_error(err: &Error) {
    if let Error::Validation { problems } = err {
        eprintln!(
            "{} found {} problems in the spec.",
            "error:".red().bold(),
            problems.len().bold()
        );
        for (index, problem) in problems.iter().enumerate() {
            // 1-based, to match how the count above reads to a person.
            let position = index.saturating_add(1);
            eprintln!("  {} {}", format!("{position}.").red().bold(), problem.bold());
            for hint in hints_for(problem) {
                eprintln!("     {} {hint}", "hint:".cyan().bold());
            }
        }
        return;
    }
    eprintln!("{} {}", "error:".red().bold(), err.bold());
    for hint in hints_for(err) {
        eprintln!("  {} {hint}", "hint:".cyan().bold());
    }
}

/// Report that no output destination was given, and show how to fix it.
pub fn report_no_output() {
    eprintln!("{} no output destination was given.", "error:".red().bold());
    eprintln!(
        "  {} pass `--output-file <file>` on the command line, or set `output:` in your config.",
        "hint:".cyan().bold()
    );
}

/// Report that a loaded config enables no artifacts, and show how to fix it.
pub fn report_no_artifacts(config: &Path) {
    eprintln!(
        "{} the config `{}` enables nothing to generate.",
        "error:".red().bold(),
        config.display()
    );
    eprintln!(
        "  {} enable at least one artifact under `generate:`",
        "hint:".cyan().bold()
    );
    print_snippet(GENERATE_EXAMPLE);
}

/// Print a multi-line code snippet dimmed and indented under a hint.
fn print_snippet(snippet: &str) {
    for line in snippet.lines() {
        eprintln!("      {}", line.dimmed());
    }
}

/// Report that generation succeeded but produced no code, explaining the
/// mismatch between what the config asked for and what the spec contains.
pub fn report_empty_output(spec: &Path, stats: &SpecStats, generate: &Generate) {
    eprintln!(
        "{} generation of `{}` produced no code.",
        "error:".red().bold(),
        spec.display()
    );
    eprintln!(
        "  the spec declares {} and {}.",
        count("schema", stats.schemas).bold(),
        count("path", stats.paths).bold()
    );
    for hint in empty_output_hints(stats, generate) {
        eprintln!("  {} {hint}", "hint:".cyan().bold());
    }
}

/// Report a successful write to `path`.
pub fn report_wrote(path: &Path) {
    eprintln!("{} wrote {}", "✓".green().bold(), path.display());
}

/// Report that `--check` found `path` up to date.
pub fn report_check_passed(path: &Path) {
    eprintln!("{} {} is up to date", "✓".green().bold(), path.display());
}

/// Report that `--check` found drift, and name the command that resolves it.
///
/// The message states which of the two cases holds, because an absent file and a
/// stale file need the reader to look at different things. Both have one remedy,
/// which is a run with no `--check`.
pub fn report_drift(path: &Path, absent: bool) {
    if absent {
        eprintln!(
            "{} {} does not exist.",
            "error:".red().bold(),
            path.display().to_string().bold()
        );
    } else {
        eprintln!(
            "{} {} is out of date with the spec.",
            "error:".red().bold(),
            path.display().to_string().bold()
        );
    }
    eprintln!(
        "  {} run the same command without `--check` to update it, and commit the result.",
        "hint:".cyan().bold()
    );
}

/// After a successful write, list the external crates the generated code
/// references so the consumer can add them to `Cargo.toml` — Cargo does not
/// infer them from `use` paths the way `go mod tidy` does. Prints nothing when
/// the output references no external crates (e.g. `server-urls` only).
pub fn report_dependencies(deps: &[Dependency]) {
    if deps.is_empty() {
        return;
    }
    eprintln!(
        "  {} add the crates the generated code references to Cargo.toml:",
        "note:".cyan().bold()
    );
    for dep in deps {
        eprintln!("      {}", dep.toml().dimmed());
    }
    eprintln!("      {}", "# or:".dimmed());
    for dep in deps {
        eprintln!("      {}", dep.cargo_add().dimmed());
    }
}

/// Ask whether to run the `cargo add` commands now. Returns `false` on EOF or a
/// non-affirmative answer. Only meaningful on an interactive terminal.
pub fn prompt_install_dependencies() -> bool {
    use std::io::Write;
    eprint!("  {} run these `cargo add` commands now? [y/N] ", "?".cyan().bold());
    let _ = std::io::stderr().flush();
    let mut answer = String::new();
    if std::io::stdin().read_line(&mut answer).is_err() {
        return false;
    }
    let answer = answer.trim().to_ascii_lowercase();
    return answer == "y" || answer == "yes";
}

/// Report that a dependency is being added via `cargo add`.
pub fn report_installing(dep: &Dependency) {
    eprintln!("  {} {}", "+".green().bold(), dep.cargo_add().dimmed());
}

/// Report that a `cargo add` invocation failed, without aborting — the output
/// file is already written, so a failed convenience step is a warning, not a
/// fatal error.
pub fn report_install_failed(dep: &Dependency, detail: &str) {
    eprintln!(
        "  {} `{}` failed: {detail}",
        "warning:".yellow().bold(),
        dep.cargo_add()
    );
}

/// Build the context-specific hints shown after an error message.
fn hints_for(err: &Error) -> Vec<String> {
    match err {
        Error::ReadSpec { path, source } => {
            return io_read_hints("spec file", path, source.kind());
        }
        Error::ReadConfig { path, source } => {
            return io_read_hints("config file", path, source.kind());
        }
        Error::ReadRefFile { file, source } => {
            return io_read_hints("referenced file", file, source.kind());
        }
        Error::ParseSpec { source, .. } => {
            return parse_spec_hints(&source.to_string());
        }
        Error::ParseRefFile { .. } => {
            return vec!["The referenced file must be a valid OpenAPI 3 fragment (YAML or JSON).".to_owned()];
        }
        Error::ParseConfig { .. } => {
            return config_hints();
        }
        Error::WriteOutput { path, .. } => {
            return vec![format!("Check that the directory for `{path}` is writable.")];
        }
        // An absent file is drift and not this error, so the reader has a path
        // that exists and that the process cannot read.
        Error::ReadOutput { path, .. } => {
            return vec![format!("Check that `{path}` is a readable file and not a directory.")];
        }
        Error::Unimplemented(_) => {
            return vec!["This generation mode is not supported yet.".to_owned()];
        }
        Error::UnresolvedRef(_) => {
            return vec![
                "The document has no component at that pointer.".to_owned(),
                "Check the spelling, and check that the component exists.".to_owned(),
                "For a cross-file ref, the component must exist in the other file.".to_owned(),
            ];
        }
        Error::UnsupportedRef { reason, .. } if reason.contains("the one Rust name") => {
            return vec![
                "The run that writes the models separates the two names with `output-options.type-name-suffix`, and this run cannot see that config. Give one of the two schemas an `x-rust-name` in the file that holds them. Both runs read that key, so both reach the same name."
                    .to_owned(),
            ];
        }
        Error::UnsupportedRef { .. } => {
            return vec![
                "A same-document `#/components/...` pointer is the supported form at any site.".to_owned(),
                "A cross-file `<file>#/components/...` ref resolves at a response, a parameter, or a request body. Give the file an `import-mapping` entry. The path is relative to the spec.".to_owned(),
                "Inside a schema, only a same-document ref resolves. This covers a property, `items`, `additionalProperties`, `allOf`, and a union member.".to_owned(),
            ];
        }
        Error::InvalidExtensionValue { key, expected, .. } => {
            return vec![format!(
                "An `x-` key changes what the generator emits, so a value it cannot read is a fault and not a default. Write `{key}` as {expected}."
            )];
        }
        Error::UnsupportedSchema { reason, .. } if reason.contains("does not reach the type") => {
            if reason.contains("Properties") {
                return vec![
                    "`minProperties` and `maxProperties` read a free-form map. A schema that names its properties fixes the count already.".to_owned(),
                ];
            }
            if reason.contains("uniqueItems") {
                return vec![
                    "`uniqueItems` reads a list of numbers, strings, or booleans. A list of models cannot always compare.".to_owned(),
                ];
            }
            return vec![
                "A `format` can name a type that is no longer a string, such as a date or a UUID. Drop the `format` to keep the string and the rule.".to_owned(),
                "An `x-rust-type` does the same. The rules of that type are its own.".to_owned(),
            ];
        }
        Error::UnsupportedSchema { reason, .. } if reason.contains("is not above zero") => {
            return vec!["JSON Schema asks for a `multipleOf` above zero. A step of zero divides by zero.".to_owned()];
        }
        Error::UnsupportedSchema { reason, .. } if reason.contains("accept no value") => {
            return vec![
                "No value passes these bounds, so the generated code would refuse every request. An `exclusiveMinimum` or an `exclusiveMaximum` moves the bound by one, which can carry it past the end of the type."
                    .to_owned(),
            ];
        }
        Error::UnsupportedSchema { reason, .. } if reason.contains("does not fit `i32`") => {
            return vec![
                "A bound must fit the type the `format` chooses. `int32` holds -2147483648 to 2147483647. Drop the `format` to get `i64`."
                    .to_owned(),
            ];
        }
        Error::UnsupportedSchema { reason, .. }
            if reason.contains("does not fit `u32`") || reason.contains("does not fit `u64`") =>
        {
            // A `minimum` of zero or more makes the type unsigned, so a bound
            // fails here for one of two reasons: it is negative, or it is above
            // the width. Each one takes a different fix.
            if reason.contains("value `-") {
                return vec![
                    "A `minimum` of zero or more gives an unsigned type, which holds no negative bound. Remove the `minimum` to keep a signed type, or correct the negative bound."
                        .to_owned(),
                ];
            }
            return vec![
                "A bound must fit the type the `format` and the `minimum` choose. `u32` holds 0 to 4294967295. Drop the `format` to get `u64`."
                    .to_owned(),
            ];
        }
        Error::UnsupportedSchema { reason, .. } if reason.contains("gives the variant no name") => {
            return vec![
                "A member of a `oneOf` becomes a variant, and that variant needs a name. Give the member an `x-rust-name`, or move it into a component schema and point at it with `$ref`."
                    .to_owned(),
            ];
        }
        Error::UnsupportedSchema { reason, .. } if reason.contains("the union holds") => {
            return vec![
                "A `oneOf` becomes an untagged enum. Serde reads the variants in order and takes the first that fits, so a repeated type is unreachable. Remove the repeated member."
                    .to_owned(),
            ];
        }
        Error::UnsupportedSchema { reason, .. }
            if reason.contains("cannot hold") && (reason.contains("`u32`") || reason.contains("`u64`")) =>
        {
            // The reason reads "the `enum` gives `-1`, which `u32` cannot
            // hold", so a minus sign after the backtick marks a negative value.
            // A negative value and a value above the width are separate faults,
            // and each one takes a different fix.
            if reason.contains("gives `-") {
                return vec![
                    "A `minimum` of zero or more gives an unsigned type, which holds no negative value. Remove the `minimum`, or drop the negative `enum` value."
                        .to_owned(),
                ];
            }
            return vec![
                "An unsigned `enum` value must fit the width the `format` chooses. `u32` holds 0 to 4294967295. Drop the `format` to get `u64`."
                    .to_owned(),
            ];
        }
        Error::UnsupportedSchema { reason, .. } if reason.contains("`enum`") => {
            return vec![
                "An `enum` names each value once. Remove the repeat.".to_owned(),
                "An integer `enum` value must fit the type the `format` chooses. `int32` holds -2147483648 to 2147483647. Drop the `format` to get `i64`.".to_owned(),
            ];
        }
        Error::UnsupportedSchema { .. } | Error::UnsupportedOperation { .. } => {
            return vec![
                "This spec uses a feature the generator cannot represent yet; simplify the schema/operation or open an issue."
                    .to_owned(),
            ];
        }
        Error::SchemaDepthExceeded { .. } => {
            return vec![
                "A schema nests too deeply; flatten it or split the nested shape into a named component referenced by `$ref`."
                    .to_owned(),
            ];
        }
        Error::InvalidPathParameter { name, .. } => {
            return vec![format!(
                "Add `{{{name}}}` to the path template, or change the parameter's `in:` to `query`, `header`, or `cookie`."
            )];
        }
        Error::UndeclaredPathParameter { name, .. } => {
            return vec![format!(
                "Declare a parameter with `name: {name}`, `in: path`, `required: true`, or remove `{{{name}}}` from the path."
            )];
        }
        Error::UnsupportedSpecVersion { hint, .. }
        | Error::UnsupportedSpecKey { hint, .. }
        | Error::UnsupportedContentType { hint, .. }
        | Error::TypeNameCollision { hint, .. }
        | Error::DuplicateTypeName { hint, .. }
        | Error::PreludeShadowing { hint, .. }
        | Error::OperationTypeCollision { hint, .. }
        | Error::SchemaNameCollision { hint, .. }
        | Error::RecursiveAlias { hint, .. }
        | Error::UnsupportedDefault { hint, .. }
        | Error::OperationNameCollision { hint, .. }
        | Error::InvalidTypeNameSuffix { hint, .. } => {
            return vec![hint.clone()];
        }
        Error::InvalidGeneratedCode { .. } => {
            return vec!["This is an internal bug in oapi-codegen. Please report it with your spec.".to_owned()];
        }
        // `report_error` renders each collected problem on its own, with that
        // problem's own hints, so the aggregate itself adds no hint.
        Error::Validation { .. } => {
            return Vec::new();
        }
        // `Error` is `non_exhaustive`, so a later version can add a variant this
        // build has never seen. Such an error shows its message with no hint.
        _ => {
            return Vec::new();
        }
    }
}

/// Hints for a failed read, keyed on the underlying IO error kind.
///
/// `what` names the kind of file, and the caller supplies the whole noun phrase
/// (for example `spec file`). Every message below reads it as one noun, so no
/// message adds a word of its own to it.
fn io_read_hints(what: &str, path: &str, kind: ErrorKind) -> Vec<String> {
    match kind {
        ErrorKind::NotFound => {
            return vec![format!(
                "No {what} exists at `{path}`; check the path and your working directory."
            )];
        }
        ErrorKind::PermissionDenied => {
            return vec![format!(
                "Permission denied reading `{path}`; check the file's permissions."
            )];
        }
        _ => {
            return vec![format!("Could not read the {what} at `{path}`.")];
        }
    }
}

/// Hints for a spec that failed to parse, with a special case for documents
/// that are valid YAML/JSON but not OpenAPI 3.
fn parse_spec_hints(message: &str) -> Vec<String> {
    if message.contains("missing field `openapi`") {
        return vec![
            "This file does not look like an OpenAPI 3 document (no top-level `openapi:` field).".to_owned(),
            "oapi-codegen expects an OpenAPI 3.x spec in YAML or JSON, with `openapi`, `info`, and `paths`.".to_owned(),
        ];
    }
    return vec![
        "The spec must be a valid OpenAPI 3 document; the parser message above points at the problem.".to_owned(),
    ];
}

/// Hints for a config that failed to parse.
fn config_hints() -> Vec<String> {
    return vec![
        "The config must be YAML using oapi-codegen's keys, e.g. `output:` and a `generate:` block.".to_owned(),
    ];
}

/// Build hints for empty output based on what the config requested versus what
/// the spec actually contains.
fn empty_output_hints(stats: &SpecStats, generate: &Generate) -> Vec<String> {
    let mut hints = Vec::new();
    if generate.models && stats.schemas == 0 {
        hints.push("`generate.models` is on, but the spec has no `components.schemas` to turn into models.".to_owned());
    }
    let wants_service = generate.std_http_server || generate.client;
    if wants_service && stats.paths == 0 {
        hints.push("A server/client was requested, but the spec declares no paths to turn into operations.".to_owned());
    }
    if generate.server_urls && stats.servers == 0 {
        hints.push(
            "`generate.server-urls` is on, but the spec declares no top-level `servers:` entries to emit.".to_owned(),
        );
    }
    if hints.is_empty() {
        hints.push(
            "Everything requested was filtered out; check your `output-options` include/exclude settings.".to_owned(),
        );
    } else {
        hints
            .push("Add the missing definitions to the spec, or enable a different artifact in your config.".to_owned());
    }
    return hints;
}

/// Format a count with a singular/plural noun, e.g. `1 schema`, `3 schemas`.
fn count(noun: &str, n: usize) -> String {
    if n == 1 {
        return format!("{n} {noun}");
    }
    return format!("{n} {noun}s");
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn empty_detects_header_and_comment_only_output() {
        assert!(is_effectively_empty(""));
        assert!(is_effectively_empty(oapi_codegen::emit::HEADER));
        assert!(is_effectively_empty("  // leading whitespace comment\n\n"));
    }

    #[test]
    fn non_empty_output_has_items() {
        assert!(!is_effectively_empty("// header\n\npub struct Foo;\n"));
    }

    /// The real header holds an inner attribute, so a check that only skipped
    /// comment lines would call an item-less file non-empty and write it out.
    #[test]
    fn empty_detects_the_real_header_followed_by_an_item() {
        let code = format!("{}pub struct Foo;\n", oapi_codegen::emit::HEADER);
        assert!(!is_effectively_empty(&code));
    }

    /// A negative value and a value above the width are separate faults, so the
    /// advice for one must not reach the other.
    #[test]
    fn an_unsigned_enum_fault_gets_the_hint_that_matches_it() {
        let fault = |reason: &str| {
            return hints_for(&Error::UnsupportedSchema {
                path: "A".to_owned(),
                reason: reason.to_owned(),
            });
        };
        let negative = fault("the `enum` gives `-1`, which `u32` cannot hold");
        assert_eq!(negative.len(), 1, "{negative:?}");
        assert!(negative[0].contains("no negative value"), "{negative:?}");

        let wide = fault("the `enum` gives `5000000000`, which `u32` cannot hold");
        assert_eq!(wide.len(), 1, "{wide:?}");
        assert!(wide[0].contains("fit the width"), "{wide:?}");
        assert!(!wide[0].contains("negative"), "{wide:?}");
    }

    /// A bound that is negative and a bound that is too wide are separate
    /// faults, and the advice for one must not reach the other. Neither one may
    /// fall back to the generic message, because an author can fix both.
    #[test]
    fn an_unsigned_bound_fault_gets_the_hint_that_matches_it() {
        let fault = |reason: &str| {
            return hints_for(&Error::UnsupportedSchema {
                path: "count".to_owned(),
                reason: reason.to_owned(),
            });
        };
        let wide = fault("the `maximum` value `5000000000` does not fit `u32`");
        assert_eq!(wide.len(), 1, "{wide:?}");
        assert!(wide[0].contains("0 to 4294967295"), "{wide:?}");

        let negative = fault("the `maximum` value `-5` does not fit `u32`");
        assert_eq!(negative.len(), 1, "{negative:?}");
        assert!(negative[0].contains("no negative bound"), "{negative:?}");

        // The signed bound keeps the hint it already had.
        let signed = fault("the `maximum` value `5000000000` does not fit `i32`");
        assert!(signed[0].contains("-2147483648"), "{signed:?}");
    }

    #[test]
    fn count_pluralizes_on_zero_and_many_but_not_one() {
        assert_eq!(count("schema", 0), "0 schemas");
        assert_eq!(count("schema", 1), "1 schema");
        assert_eq!(count("path", 3), "3 paths");
    }

    #[test]
    fn empty_hints_call_out_missing_models() {
        let stats = SpecStats {
            schemas: 0,
            paths: 0,
            servers: 0,
        };
        let generate = Generate {
            models: true,
            ..Default::default()
        };
        let hints = empty_output_hints(&stats, &generate);
        assert!(hints.iter().any(|h| return h.contains("components.schemas")));
    }

    #[test]
    fn empty_hints_call_out_missing_paths_for_service() {
        let stats = SpecStats {
            schemas: 0,
            paths: 0,
            servers: 0,
        };
        let generate = Generate {
            std_http_server: true,
            ..Default::default()
        };
        let hints = empty_output_hints(&stats, &generate);
        assert!(hints.iter().any(|h| return h.contains("no paths")));
    }

    #[test]
    fn empty_hints_call_out_missing_servers_for_server_urls() {
        let stats = SpecStats {
            schemas: 0,
            paths: 0,
            servers: 0,
        };
        let generate = Generate {
            server_urls: true,
            ..Default::default()
        };
        let hints = empty_output_hints(&stats, &generate);
        assert!(hints.iter().any(|h| return h.contains("servers:")));
    }

    #[test]
    fn empty_hints_fall_back_to_filtering_when_nothing_matches() {
        let stats = SpecStats {
            schemas: 5,
            paths: 5,
            servers: 0,
        };
        let generate = Generate {
            models: true,
            ..Default::default()
        };
        let hints = empty_output_hints(&stats, &generate);
        assert!(hints.iter().any(|h| return h.contains("filtered out")));
    }

    #[test]
    fn parse_spec_hint_recognizes_non_openapi_documents() {
        let hints = parse_spec_hints("missing field `openapi` at line 1 column 1");
        assert!(
            hints
                .iter()
                .any(|h| return h.contains("does not look like an OpenAPI 3 document"))
        );
    }

    /// Every caller of [`io_read_hints`] must pass a complete noun phrase. A
    /// caller that passes `referenced` instead of `referenced file` reads as
    /// "No referenced exists at", and one that also adds `file` inside the
    /// message reads as "No referenced file file exists at". This pins the three
    /// call sites so neither mistake returns.
    #[test]
    fn read_hints_name_the_file_kind_one_time() {
        for (what, expected) in [
            ("spec file", "No spec file exists at `x.yaml`"),
            ("config file", "No config file exists at `x.yaml`"),
            ("referenced file", "No referenced file exists at `x.yaml`"),
        ] {
            let hints = io_read_hints(what, "x.yaml", ErrorKind::NotFound);
            assert!(
                hints.iter().any(|hint| return hint.starts_with(expected)),
                "hint for `{what}` should start with `{expected}`, got: {hints:?}",
            );
        }
    }

    #[test]
    fn invalid_path_parameter_hint_guides_the_fix() {
        let err = Error::InvalidPathParameter {
            method: "get".to_owned(),
            path: "/dashboard".to_owned(),
            name: "tz".to_owned(),
        };
        let hints = hints_for(&err);
        assert!(
            hints.iter().any(|h| return h.contains("{tz}") && h.contains("query")),
            "hint should suggest adding the placeholder or changing `in:`, got: {hints:?}",
        );
    }

    /// Each constraint fault must reach its own hint. The arms read the reason
    /// text, so a broad one can take a message meant for a later arm and send the
    /// reader to the wrong fix.
    #[test]
    fn each_constraint_fault_reaches_its_own_hint() {
        let cases = [
            ("the `multipleOf` value `0` is not above zero", "divides by zero"),
            (
                "the bounds accept no value: they allow `10` to `5`",
                "refuse every request",
            ),
            ("the `multipleOf` value `5000000000` does not fit `i32`", "-2147483648"),
            ("the `minimum` value `-5000000000` does not fit `i32`", "-2147483648"),
            (
                "the `pattern` rule does not reach the type this field holds",
                "Drop the `format`",
            ),
            (
                "the `minProperties` rule does not reach the type this field holds",
                "free-form map",
            ),
            (
                "the `uniqueItems` rule does not reach the type this field holds",
                "numbers, strings, or booleans",
            ),
            ("the `enum` gives `1` more than once", "names each value once"),
            (
                "the union holds `Cat` twice, as `Cat` and as `Cat2`",
                "Remove the repeated member",
            ),
            (
                "member 0 of the union gives the variant no name",
                "move it into a component schema",
            ),
            // A schema named `enum` puts that word in the union message too.
            // The union arm must still win.
            (
                "the union holds `enum` twice, as `Enum` and as `Enum2`",
                "Remove the repeated member",
            ),
        ];
        for (reason, wanted) in cases {
            let err = Error::UnsupportedSchema {
                path: "field".to_owned(),
                reason: reason.to_owned(),
            };
            let hints = hints_for(&err);
            assert!(
                hints.iter().any(|hint| return hint.contains(wanted)),
                "`{reason}` should reach a hint holding `{wanted}`, got: {hints:?}",
            );
        }
    }

    /// The hint for a wrong extension value names both the key and the kind of
    /// value the key needs, so the author can correct it without the docs.
    #[test]
    fn a_wrong_extension_value_hint_names_the_key_and_the_kind() {
        let err = Error::InvalidExtensionValue {
            key: "x-order".to_owned(),
            at: "Widget.id".to_owned(),
            expected: "a whole number".to_owned(),
            found: "a string".to_owned(),
        };
        let hints = hints_for(&err);
        assert!(
            hints
                .iter()
                .any(|hint| return hint.contains("x-order") && hint.contains("a whole number")),
            "the hint should name the key and the kind it needs, got: {hints:?}",
        );
    }

    /// A cross-file name clash reaches its own hint, and not the general
    /// `$ref` advice, which names no remedy for it.
    #[test]
    fn a_cross_file_name_clash_hint_names_the_remedy() {
        let err = Error::UnsupportedRef {
            reference: "shared.yaml#/components/schemas/parcel".to_owned(),
            reason: "`shared.yaml` gives `parcel` and `Parcel` the one Rust name `Parcel`".to_owned(),
        };
        let hints = hints_for(&err);
        assert!(
            hints.iter().any(|hint| return hint.contains("x-rust-name")),
            "the hint should name `x-rust-name` as the remedy, got: {hints:?}",
        );
    }

    #[test]
    fn undeclared_path_parameter_hint_guides_the_fix() {
        let err = Error::UndeclaredPathParameter {
            method: "get".to_owned(),
            path: "/widgets/{id}".to_owned(),
            name: "id".to_owned(),
        };
        let hints = hints_for(&err);
        assert!(
            hints
                .iter()
                .any(|h| return h.contains("in: path") && h.contains("{id}")),
            "hint should suggest declaring the parameter or removing the placeholder, got: {hints:?}",
        );
    }
}