Skip to main content

dropshot_api_manager/
output.rs

1// Copyright 2026 Oxide Computer Company
2
3use crate::{
4    FAILURE_EXIT_CODE, NEEDS_UPDATE_EXIT_CODE,
5    apis::{ManagedApi, ManagedApis},
6    compatibility::{
7        ApiCompatIssue, CompatIssueLocation, CompatRenderStatus,
8        FinalizedCompatDedupMap,
9    },
10    environment::{ErrorAccumulator, ResolvedEnv},
11    resolved::{
12        Fix, NonVersionProblem, Resolution, ResolutionKind, Resolved,
13        VersionProblem,
14    },
15    validation::CheckStale,
16};
17use anyhow::bail;
18use camino::Utf8Path;
19use clap::{Args, ColorChoice};
20use headers::*;
21use indent_write::fmt::IndentWriter;
22use owo_colors::{OwoColorize, Style};
23use similar::{ChangeTag, DiffableStr, TextDiff};
24use std::{
25    fmt::{self, Write},
26    io,
27    process::ExitCode,
28};
29
30#[derive(Debug, Args)]
31#[clap(next_help_heading = "Global options")]
32pub struct OutputOpts {
33    /// Color output
34    #[clap(long, value_enum, global = true, default_value_t)]
35    pub(crate) color: ColorChoice,
36}
37
38impl OutputOpts {
39    /// Returns true if color should be used for the stream.
40    pub(crate) fn use_color(&self, stream: supports_color::Stream) -> bool {
41        match self.color {
42            ColorChoice::Auto => supports_color::on_cached(stream).is_some(),
43            ColorChoice::Always => true,
44            ColorChoice::Never => false,
45        }
46    }
47
48    /// Creates a `Styles` instance, colorized if color is enabled for the
49    /// given stream.
50    pub(crate) fn styles(&self, stream: supports_color::Stream) -> Styles {
51        let mut styles = Styles::default();
52        if self.use_color(stream) {
53            styles.colorize();
54        }
55        styles
56    }
57}
58
59#[derive(Clone, Debug, Default)]
60pub(crate) struct Styles {
61    pub(crate) bold: Style,
62    pub(crate) dimmed: Style,
63    pub(crate) header: Style,
64    pub(crate) success_header: Style,
65    pub(crate) failure: Style,
66    pub(crate) failure_header: Style,
67    pub(crate) warning: Style,
68    pub(crate) warning_header: Style,
69    pub(crate) unchanged_header: Style,
70    pub(crate) filename: Style,
71    pub(crate) operation_id: Style,
72    pub(crate) diff_before: Style,
73    pub(crate) diff_after: Style,
74}
75
76impl Styles {
77    pub(crate) fn colorize(&mut self) {
78        self.bold = Style::new().bold();
79        self.dimmed = Style::new().dimmed();
80        self.header = Style::new().purple();
81        self.success_header = Style::new().green().bold();
82        self.failure = Style::new().red();
83        self.failure_header = Style::new().red().bold();
84        self.warning = Style::new().yellow();
85        self.warning_header = Style::new().yellow().bold();
86        self.unchanged_header = Style::new().blue().bold();
87        self.filename = Style::new().cyan();
88        self.operation_id = Style::new().purple();
89        self.diff_before = Style::new().red();
90        self.diff_after = Style::new().green();
91    }
92}
93
94// This is copied from similar's UnifiedDiff::to_writer, except with colorized
95// output.
96pub(crate) fn write_diff<'diff, 'old, 'new, 'bufs, T>(
97    diff: &'diff TextDiff<'old, 'new, 'bufs, T>,
98    path1: &Utf8Path,
99    path2: &Utf8Path,
100    styles: &Styles,
101    context_radius: usize,
102    missing_newline_hint: bool,
103    out: &mut dyn io::Write,
104) -> io::Result<()>
105where
106    'diff: 'old + 'new + 'bufs,
107    T: DiffableStr + ?Sized,
108{
109    // The "a/" and "b/" prefixes make this feel more like a git diff. We
110    // assemble the header by hand (and normalize any backslashes in the
111    // path) so the output is forward-slashed regardless of host OS, which
112    // is the convention every diff/patch tool expects.
113    let a = format_diff_path("a", path1);
114    writeln!(out, "{}", format!("--- {a}").style(styles.diff_before))?;
115    let b = format_diff_path("b", path2);
116    writeln!(out, "{}", format!("+++ {b}").style(styles.diff_after))?;
117
118    let mut udiff = diff.unified_diff();
119    udiff
120        .context_radius(context_radius)
121        .missing_newline_hint(missing_newline_hint);
122    for hunk in udiff.iter_hunks() {
123        for (idx, change) in hunk.iter_changes().enumerate() {
124            if idx == 0 {
125                writeln!(out, "{}", hunk.header())?;
126            }
127            let style = match change.tag() {
128                ChangeTag::Delete => styles.diff_before,
129                ChangeTag::Insert => styles.diff_after,
130                ChangeTag::Equal => Style::new(),
131            };
132
133            write!(out, "{}", change.tag().style(style))?;
134            write!(out, "{}", change.value().to_string_lossy().style(style))?;
135            if !diff.newline_terminated() {
136                writeln!(out)?;
137            }
138            if diff.newline_terminated() && change.missing_newline() {
139                writeln!(
140                    out,
141                    "{}",
142                    MissingNewlineHint(hunk.missing_newline_hint())
143                )?;
144            }
145        }
146    }
147
148    Ok(())
149}
150
151/// Format a `prefix/path` header for unified diff output, normalizing
152/// path separators to `/` so the rendered output is identical on Windows
153/// and Unix (and matches the diff/patch convention).
154fn format_diff_path(prefix: &str, path: &Utf8Path) -> String {
155    if std::path::MAIN_SEPARATOR == '/' {
156        format!("{prefix}/{path}")
157    } else {
158        format!("{prefix}/{}", path.as_str().replace('\\', "/"))
159    }
160}
161
162pub(crate) fn display_api_spec(api: &ManagedApi, styles: &Styles) -> String {
163    let mut versions = api.iter_versions_semver();
164    let count = versions.len();
165    let latest_version =
166        versions.next_back().expect("must be at least one version");
167    if api.is_versioned() {
168        format!(
169            "{} ({}, versioned ({} supported), latest = {})",
170            api.ident().style(styles.filename),
171            api.title(),
172            count,
173            latest_version,
174        )
175    } else {
176        format!(
177            "{} ({}, lockstep, v{})",
178            api.ident().style(styles.filename),
179            api.title(),
180            latest_version,
181        )
182    }
183}
184
185pub(crate) fn display_api_spec_version(
186    api: &ManagedApi,
187    version: &semver::Version,
188    styles: &Styles,
189    resolution: &Resolution<'_>,
190) -> String {
191    if api.is_lockstep() {
192        assert_eq!(resolution.kind(), ResolutionKind::Lockstep);
193        format!(
194            "{} (lockstep v{}): {}",
195            api.ident().style(styles.filename),
196            version,
197            api.title(),
198        )
199    } else {
200        format!(
201            "{} (versioned v{} ({})): {}",
202            api.ident().style(styles.filename),
203            version,
204            resolution.kind(),
205            api.title(),
206        )
207    }
208}
209
210pub(crate) fn display_error(
211    error: &anyhow::Error,
212    failure_style: Style,
213) -> impl fmt::Display + '_ {
214    struct DisplayError<'a> {
215        error: &'a anyhow::Error,
216        failure_style: Style,
217    }
218
219    impl fmt::Display for DisplayError<'_> {
220        fn fmt(&self, mut f: &mut fmt::Formatter<'_>) -> fmt::Result {
221            writeln!(f, "{}", self.error.style(self.failure_style))?;
222
223            let mut source = self.error.source();
224            while let Some(curr) = source {
225                write!(f, "-> ")?;
226                writeln!(
227                    IndentWriter::new_skip_initial("   ", &mut f),
228                    "{}",
229                    curr.style(self.failure_style),
230                )?;
231                source = curr.source();
232            }
233
234            Ok(())
235        }
236    }
237
238    DisplayError { error, failure_style }
239}
240
241struct MissingNewlineHint(bool);
242
243impl fmt::Display for MissingNewlineHint {
244    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
245        if self.0 {
246            write!(f, "\n\\ No newline at end of file")?;
247        }
248        Ok(())
249    }
250}
251
252pub fn display_load_problems(
253    writer: &mut dyn io::Write,
254    error_accumulator: &ErrorAccumulator,
255    styles: &Styles,
256) -> anyhow::Result<()> {
257    for w in error_accumulator.iter_warnings() {
258        writeln!(
259            writer,
260            "{:>HEADER_WIDTH$} {:#}",
261            WARNING.style(styles.warning_header),
262            w
263        )?;
264    }
265
266    let mut nerrors = 0;
267    for e in error_accumulator.iter_errors() {
268        nerrors += 1;
269        writeln!(
270            writer,
271            "{:>HEADER_WIDTH$} {:#}",
272            FAILURE.style(styles.failure_header),
273            e
274        )?;
275    }
276
277    if nerrors > 0 {
278        bail!(
279            "bailing out after {} {} above",
280            nerrors,
281            plural::errors(nerrors)
282        );
283    }
284
285    Ok(())
286}
287
288/// Summarize the results of checking all supported API versions, plus other
289/// problems found during resolution
290pub fn display_resolution(
291    writer: &mut dyn io::Write,
292    env: &ResolvedEnv,
293    apis: &ManagedApis,
294    resolved: &Resolved,
295    styles: &Styles,
296) -> anyhow::Result<CheckResult> {
297    let total = resolved.nexpected_documents();
298
299    writeln!(
300        writer,
301        "{:>HEADER_WIDTH$} {} OpenAPI {}...",
302        CHECKING.style(styles.success_header),
303        total.style(styles.bold),
304        plural::documents(total),
305    )?;
306
307    let mut num_fresh = 0;
308    let mut num_stale = 0;
309    let mut num_failed = 0;
310    let mut num_non_version_problems = 0;
311
312    let dedup = resolved.build_compat_dedup_map();
313
314    // Print problems associated with a supported API version
315    // (i.e., one of the expected OpenAPI documents).
316    for api in apis.iter_apis() {
317        let ident = api.ident();
318
319        for version in api.iter_versions_semver() {
320            let resolution = resolved
321                .resolution_for_api_version(ident, version)
322                .expect("resolution for all supported API versions");
323            if resolution.has_errors() {
324                num_failed += 1;
325            } else if resolution.has_problems() {
326                num_stale += 1;
327            } else {
328                num_fresh += 1;
329            }
330            summarize_one(
331                writer, env, api, version, resolution, styles, &dedup,
332            )?;
333        }
334
335        if !api.is_versioned() {
336            continue;
337        }
338
339        if let Some(symlink_problem) = resolved.symlink_problem(ident) {
340            if symlink_problem.is_fixable() {
341                num_non_version_problems += 1;
342                writeln!(
343                    writer,
344                    "{:>HEADER_WIDTH$} {} \"latest\" symlink",
345                    STALE.style(styles.warning_header),
346                    ident.style(styles.filename),
347                )?;
348                display_non_version_problems(
349                    writer,
350                    std::iter::once(symlink_problem),
351                    styles,
352                )?;
353            } else {
354                num_failed += 1;
355                writeln!(
356                    writer,
357                    "{:>HEADER_WIDTH$} {} \"latest\" symlink",
358                    FAILURE.style(styles.failure_header),
359                    ident.style(styles.filename),
360                )?;
361                display_non_version_problems(
362                    writer,
363                    std::iter::once(symlink_problem),
364                    styles,
365                )?;
366            }
367        } else {
368            num_fresh += 1;
369            writeln!(
370                writer,
371                "{:>HEADER_WIDTH$} {} \"latest\" symlink",
372                FRESH.style(styles.success_header),
373                ident.style(styles.filename),
374            )?;
375        }
376    }
377
378    // Print problems not associated with any supported version, if any.
379    let orphaned_and_unparseable: Vec<_> =
380        resolved.orphaned_and_unparseable().collect();
381    num_non_version_problems += if !orphaned_and_unparseable.is_empty() {
382        writeln!(
383            writer,
384            "\n{:>HEADER_WIDTH$} problems not associated with a specific \
385             supported API version:",
386            "Other".style(styles.warning_header),
387        )?;
388
389        let (fixable, unfixable): (
390            Vec<&NonVersionProblem>,
391            Vec<&NonVersionProblem>,
392        ) = orphaned_and_unparseable.iter().partition(|p| p.is_fixable());
393        num_failed += unfixable.len();
394        display_non_version_problems(writer, orphaned_and_unparseable, styles)?;
395        fixable.len()
396    } else {
397        0
398    };
399
400    // Print informational notes, if any.
401    for n in resolved.notes() {
402        let initial_indent =
403            format!("{:>HEADER_WIDTH$} ", "Note".style(styles.warning_header));
404        let more_indent = " ".repeat(HEADER_WIDTH + " ".len());
405        writeln!(
406            writer,
407            "\n{}\n",
408            textwrap::fill(
409                &n.to_string(),
410                textwrap::Options::new(term_width())
411                    .initial_indent(&initial_indent)
412                    .subsequent_indent(&more_indent)
413            )
414        )?;
415    }
416
417    // Print a summary line.
418    let status_header = if num_failed > 0 {
419        FAILURE.style(styles.failure_header)
420    } else if num_stale > 0 || num_non_version_problems > 0 {
421        STALE.style(styles.warning_header)
422    } else {
423        SUCCESS.style(styles.success_header)
424    };
425
426    writeln!(writer, "{:>HEADER_WIDTH$}", SEPARATOR)?;
427    writeln!(
428        writer,
429        "{:>HEADER_WIDTH$} {} {} checked: {} fresh, {} stale, {} failed, \
430         {} other {}",
431        status_header,
432        total.style(styles.bold),
433        plural::documents(total),
434        num_fresh.style(styles.bold),
435        num_stale.style(styles.bold),
436        num_failed.style(styles.bold),
437        num_non_version_problems.style(styles.bold),
438        plural::problems(num_non_version_problems),
439    )?;
440    if num_failed > 0 {
441        writeln!(
442            writer,
443            "{:>HEADER_WIDTH$} (fix failures, then run {} to update)",
444            "",
445            format!("{} generate", env.command).style(styles.bold)
446        )?;
447        Ok(CheckResult::Failures)
448    } else if num_stale > 0 || num_non_version_problems > 0 {
449        writeln!(
450            writer,
451            "{:>HEADER_WIDTH$} (run {} to update)",
452            "",
453            format!("{} generate", env.command).style(styles.bold)
454        )?;
455        Ok(CheckResult::NeedsUpdate)
456    } else {
457        Ok(CheckResult::Success)
458    }
459}
460
461/// The result of a check operation.
462///
463/// Returned by the `check_apis_up_to_date` function.
464#[derive(Clone, Copy, Debug, Eq, PartialEq)]
465pub enum CheckResult {
466    /// The APIs are up-to-date.
467    Success,
468    /// The APIs need to be updated.
469    NeedsUpdate,
470    /// There were validation errors or other problems.
471    Failures,
472}
473
474impl CheckResult {
475    /// Returns the exit code corresponding to the check result.
476    pub fn to_exit_code(self) -> ExitCode {
477        match self {
478            CheckResult::Success => ExitCode::SUCCESS,
479            CheckResult::NeedsUpdate => NEEDS_UPDATE_EXIT_CODE.into(),
480            CheckResult::Failures => FAILURE_EXIT_CODE.into(),
481        }
482    }
483}
484
485/// Summarize the "check" status of one supported API version
486fn summarize_one(
487    writer: &mut dyn io::Write,
488    env: &ResolvedEnv,
489    api: &ManagedApi,
490    version: &semver::Version,
491    resolution: &Resolution<'_>,
492    styles: &Styles,
493    dedup: &FinalizedCompatDedupMap<'_>,
494) -> io::Result<()> {
495    let problems: Vec<_> = resolution.problems().collect();
496    if problems.is_empty() {
497        // Success case: file is up-to-date.
498        writeln!(
499            writer,
500            "{:>HEADER_WIDTH$} {}",
501            FRESH.style(styles.success_header),
502            display_api_spec_version(api, version, styles, resolution),
503        )?;
504    } else {
505        // There were one or more problems, some of which may be unfixable.
506        writeln!(
507            writer,
508            "{:>HEADER_WIDTH$} {}",
509            if resolution.has_errors() {
510                FAILURE.style(styles.failure_header)
511            } else {
512                assert!(resolution.has_problems());
513                STALE.style(styles.warning_header)
514            },
515            display_api_spec_version(api, version, styles, resolution),
516        )?;
517
518        let compat_ctx = CompatDisplayContext {
519            dedup,
520            current: CompatIssueLocation { api: api.ident(), version },
521        };
522        display_version_problems(writer, env, problems, styles, compat_ctx)?;
523    }
524    Ok(())
525}
526
527pub(crate) struct CompatDisplayContext<'a> {
528    pub(crate) dedup: &'a FinalizedCompatDedupMap<'a>,
529    pub(crate) current: CompatIssueLocation<'a>,
530}
531
532/// Print a formatted list of per-(api, version) [`VersionProblem`]s to
533/// `writer`, including any compatibility-issue diffs and fix descriptions.
534///
535/// `compat_ctx` enables [`VersionProblem::BlessedVersionBroken`] rendering to
536/// abbreviate compatibility issues that have already been shown elsewhere.
537pub(crate) fn display_version_problems<'a, T>(
538    writer: &mut dyn io::Write,
539    env: &ResolvedEnv,
540    problems: T,
541    styles: &Styles,
542    compat_ctx: CompatDisplayContext<'_>,
543) -> io::Result<()>
544where
545    T: IntoIterator<Item = &'a VersionProblem<'a>>,
546{
547    for p in problems.into_iter() {
548        write_problem_header(writer, p, p.is_fixable(), styles)?;
549
550        // Indent for compat-issue bodies. The issue's longest label sits
551        // at this column so its leftmost edge lines up with where `error`
552        // begins (HEADER_WIDTH minus the 5 chars of "error"), preserving
553        // the verb column the eye is already tracking down from the
554        // cargo headers.
555        let issue_indent = " ".repeat(HEADER_WIDTH - "error".len());
556
557        // Each issue gets a leading blank (emitted by `display_compat_issue`,
558        // separating it from the problem header or the previous diff) and a
559        // single trailing blank here (separating the last issue from the next
560        // problem). An issue already reported elsewhere is rendered in
561        // abbreviated form, pointing at the canonical occurrence.
562        let issues = p.compatibility_issues();
563        for issue in issues {
564            let status = compat_ctx.dedup.status_for(issue, compat_ctx.current);
565            display_compat_issue(
566                &mut *writer,
567                issue,
568                &issue_indent,
569                styles,
570                status,
571            )?;
572        }
573        if !issues.is_empty() {
574            writeln!(writer)?;
575        }
576
577        // For BlessedLatestVersionBytewiseMismatch, show a diff between blessed
578        // and generated versions even though there's no fix.
579        if let VersionProblem::BlessedLatestVersionBytewiseMismatch {
580            blessed,
581            generated,
582        } = p
583        {
584            let diff =
585                TextDiff::from_lines(blessed.contents(), generated.contents());
586            let path1 =
587                env.openapi_abs_dir().join(blessed.spec_file_name().path());
588            let path2 =
589                env.openapi_abs_dir().join(generated.spec_file_name().path());
590            let indent = " ".repeat(HEADER_WIDTH + 1);
591            write_diff(
592                &diff,
593                &path1,
594                &path2,
595                styles,
596                // context_radius: show enough context to understand the changes.
597                3,
598                /* missing_newline_hint */ true,
599                &mut indent_write::io::IndentWriter::new(&indent, &mut *writer),
600            )?;
601        }
602
603        let Some(fix) = p.fix() else {
604            continue;
605        };
606
607        write_fix_summary(writer, &fix, styles)?;
608
609        // When possible, print a useful diff of changes.
610        let do_diff = match p {
611            VersionProblem::LockstepStale { found, generated } => {
612                let diff = TextDiff::from_lines(
613                    found.contents(),
614                    generated.contents(),
615                );
616                let path1 =
617                    env.openapi_abs_dir().join(found.spec_file_name().path());
618                let path2 = env
619                    .openapi_abs_dir()
620                    .join(generated.spec_file_name().path());
621                Some((diff, path1, path2))
622            }
623            VersionProblem::ExtraFileStale {
624                check_stale:
625                    CheckStale::Modified { full_path, actual, expected },
626                ..
627            } => {
628                let diff = TextDiff::from_lines(actual, expected);
629                Some((diff, full_path.clone(), full_path.clone()))
630            }
631            VersionProblem::LocalVersionStale { spec_files, generated }
632                if spec_files.len() == 1 =>
633            {
634                let diff = TextDiff::from_lines(
635                    spec_files[0].contents(),
636                    generated.contents(),
637                );
638                let path1 = env
639                    .openapi_abs_dir()
640                    .join(spec_files[0].spec_file_name().path());
641                let path2 = env
642                    .openapi_abs_dir()
643                    .join(generated.spec_file_name().path());
644                Some((diff, path1, path2))
645            }
646            _ => None,
647        };
648
649        if let Some((diff, path1, path2)) = do_diff {
650            let indent = " ".repeat(HEADER_WIDTH + 1);
651            write_diff(
652                &diff,
653                &path1,
654                &path2,
655                styles,
656                // context_radius: here, a small radius is sufficient to show
657                // differences.
658                3,
659                /* missing_newline_hint */ true,
660                // Add an indent to align diff with the status message.
661                &mut indent_write::io::IndentWriter::new(&indent, &mut *writer),
662            )?;
663            writeln!(writer)?;
664        }
665    }
666    Ok(())
667}
668
669/// Print a formatted list of [`NonVersionProblem`]s to `writer`.
670///
671/// None of these variants have associated diffs, so this is just a header +
672/// fix.
673pub fn display_non_version_problems<'a, T>(
674    writer: &mut dyn io::Write,
675    problems: T,
676    styles: &Styles,
677) -> io::Result<()>
678where
679    T: IntoIterator<Item = &'a NonVersionProblem<'a>>,
680{
681    for p in problems.into_iter() {
682        write_problem_header(writer, p, p.is_fixable(), styles)?;
683        if let Some(fix) = p.fix() {
684            write_fix_summary(writer, &fix, styles)?;
685        }
686    }
687    Ok(())
688}
689
690/// Write the `problem:` / `error:` header line for a single problem, wrapping
691/// the error chain to the terminal width.
692///
693/// The keyword is the second-tier verb: it continues the cargo-style verb
694/// column from the surrounding `Fresh` / `Failure` headers. It's right-aligned
695/// to the same width so the colons stack vertically as the eye scans down.
696fn write_problem_header(
697    writer: &mut dyn io::Write,
698    error: &dyn std::error::Error,
699    is_fixable: bool,
700    styles: &Styles,
701) -> io::Result<()> {
702    let first_indent = format!(
703        "{:>HEADER_WIDTH$}: ",
704        if is_fixable {
705            "problem".style(styles.warning_header)
706        } else {
707            "error".style(styles.failure_header)
708        }
709    );
710    // Continuation indent for wrapped error text. Aligns with the
711    // post-keyword content (HEADER_WIDTH + ": ".len()).
712    let more_indent = " ".repeat(HEADER_WIDTH + 2);
713    writeln!(
714        writer,
715        "{}",
716        textwrap::fill(
717            &InlineErrorChain::new(error).to_string(),
718            textwrap::Options::new(term_width())
719                .initial_indent(&first_indent)
720                .subsequent_indent(&more_indent)
721        )
722    )
723}
724
725/// Write the `fix:` line(s) for a single fix, splitting multi-step fixes into
726/// separate `will ...` lines that share the column structure of
727/// [`write_problem_header`].
728fn write_fix_summary(
729    writer: &mut dyn io::Write,
730    fix: &Fix<'_>,
731    styles: &Styles,
732) -> io::Result<()> {
733    let first_indent =
734        format!("{:>HEADER_WIDTH$}: ", "fix".style(styles.warning_header));
735    let more_indent = " ".repeat(HEADER_WIDTH + 2);
736    let fix_str = fix.to_string();
737    for s in fix_str.trim_end().split("\n") {
738        writeln!(
739            writer,
740            "{}",
741            textwrap::fill(
742                &format!("will {}", s),
743                textwrap::Options::new(term_width())
744                    .initial_indent(&first_indent)
745                    .subsequent_indent(&more_indent)
746            )
747        )?;
748    }
749    Ok(())
750}
751
752/// Render one compatibility issue under a problem to `writer`.
753///
754/// `body_indent` is the column the issue body starts at (each rendered
755/// line gets this prefix). The labels right-align within an issue's own
756/// colon column, so a single `body_indent` is sufficient — no separate
757/// initial/continuation indents are needed.
758fn display_compat_issue(
759    writer: &mut dyn io::Write,
760    issue: &ApiCompatIssue,
761    body_indent: &str,
762    styles: &Styles,
763    status: CompatRenderStatus,
764) -> io::Result<()> {
765    // A blank line separates this issue from the previous problem header
766    // (or, in the full form, from the previous issue's JSON diff which
767    // already ends in a newline).
768    writeln!(writer)?;
769
770    // Wrap at terminal width minus the body indent. (`display_width` matches
771    // what `wrap.rs` uses for its own indent.)
772    let wrap_width =
773        term_width().saturating_sub(textwrap::core::display_width(body_indent));
774
775    // Indent every line of the rendered block. `IndentWriter` prefixes the
776    // first line as well, so we don't need a separate initial-indent string.
777    let mut buf = String::new();
778    write!(
779        IndentWriter::new(body_indent, &mut buf),
780        "{}",
781        issue.display(styles, status).with_wrap_width(wrap_width),
782    )
783    .expect("writing to a String never fails");
784    writeln!(writer, "{buf}")?;
785
786    match status {
787        CompatRenderStatus::FirstOccurrence { .. } => {
788            // Full form: print the textual diff between the blessed and
789            // generated values for this base.
790            let blessed_json = issue.blessed_json();
791            let generated_json = issue.generated_json();
792
793            let diff = TextDiff::from_lines(&blessed_json, &generated_json);
794            write_diff(
795                &diff,
796                "blessed".as_ref(),
797                "generated".as_ref(),
798                styles,
799                // context_radius: use a large radius to ensure that most
800                // of the schema is printed out.
801                8,
802                /* missing_newline_hint */ false,
803                // Align diff with the issue body.
804                &mut indent_write::io::IndentWriter::new(body_indent, writer),
805            )
806        }
807        CompatRenderStatus::Duplicate { .. } => {
808            // Abbreviated form: no JSON diff, since it was already shown
809            // at the first occurrence.
810            Ok(())
811        }
812    }
813}
814/// Adapter for [`Error`]s that provides a [`std::fmt::Display`] implementation
815/// that print the full chain of error sources, separated by `: `.
816pub struct InlineErrorChain<'a>(&'a dyn std::error::Error);
817
818impl<'a> InlineErrorChain<'a> {
819    pub fn new(error: &'a dyn std::error::Error) -> Self {
820        Self(error)
821    }
822}
823
824impl fmt::Display for InlineErrorChain<'_> {
825    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
826        write!(f, "{}", self.0)?;
827        let mut cause = self.0.source();
828        while let Some(source) = cause {
829            write!(f, ": {source}")?;
830            cause = source.source();
831        }
832        Ok(())
833    }
834}
835
836/// Returns the wrap width to use for terminal output.
837///
838/// Honors the `OPENAPI_MGR_TERM_WIDTH` environment variable as an override.
839/// Otherwise falls back to [`textwrap::termwidth`], which queries the terminal
840/// connected to stdout, or returns 80 when stdout isn't a tty.
841///
842/// The override exists for snapshot determinism. Under `cargo nextest run` by
843/// default, stdout is captured, so `termwidth` returns 80 and snapshots are
844/// deterministic. Under `cargo nextest run --no-capture` (or `cargo test`),
845/// however, stdout may be the developer's tty, and width is wherever the window
846/// happens to be sized. Setting `OPENAPI_MGR_TERM_WIDTH=80` explicitly, as we
847/// do in our tests, ensures that snapshots are deterministic in this scenario
848/// as well.
849pub(crate) fn term_width() -> usize {
850    match std::env::var("OPENAPI_MGR_TERM_WIDTH") {
851        Ok(s) => s.parse().unwrap_or_else(|err| {
852            panic!("OPENAPI_MGR_TERM_WIDTH={s:?} is not a valid width: {err}")
853        }),
854        Err(_) => textwrap::termwidth(),
855    }
856}
857
858/// Output headers.
859pub(crate) mod headers {
860    // Same width as Cargo's output.
861    pub(crate) const HEADER_WIDTH: usize = 12;
862
863    pub(crate) static SEPARATOR: &str = "-------";
864
865    pub(crate) static CHECKING: &str = "Checking";
866    pub(crate) static GENERATING: &str = "Generating";
867
868    pub(crate) static FRESH: &str = "Fresh";
869    pub(crate) static STALE: &str = "Stale";
870
871    pub(crate) static UNCHANGED: &str = "Unchanged";
872
873    pub(crate) static SUCCESS: &str = "Success";
874    pub(crate) static FAILURE: &str = "Failure";
875    pub(crate) static WARNING: &str = "Warning";
876}
877
878pub(crate) mod plural {
879    pub(crate) fn files(count: usize) -> &'static str {
880        if count == 1 { "file" } else { "files" }
881    }
882
883    pub(crate) fn changes(count: usize) -> &'static str {
884        if count == 1 { "change" } else { "changes" }
885    }
886
887    pub(crate) fn documents(count: usize) -> &'static str {
888        if count == 1 { "document" } else { "documents" }
889    }
890
891    pub(crate) fn errors(count: usize) -> &'static str {
892        if count == 1 { "error" } else { "errors" }
893    }
894
895    pub(crate) fn paths(count: usize) -> &'static str {
896        if count == 1 { "path" } else { "paths" }
897    }
898
899    pub(crate) fn problems(count: usize) -> &'static str {
900        if count == 1 { "problem" } else { "problems" }
901    }
902
903    pub(crate) fn schemas(count: usize) -> &'static str {
904        if count == 1 { "schema" } else { "schemas" }
905    }
906}