Skip to main content

dropshot_api_manager/
output.rs

1// Copyright 2025 Oxide Computer Company
2
3use crate::{
4    FAILURE_EXIT_CODE, NEEDS_UPDATE_EXIT_CODE,
5    apis::{ManagedApi, ManagedApis},
6    environment::{ErrorAccumulator, ResolvedEnv},
7    resolved::{Problem, Resolution, ResolutionKind, Resolved},
8    validation::CheckStale,
9};
10use anyhow::bail;
11use camino::Utf8Path;
12use clap::{Args, ColorChoice};
13use headers::*;
14use indent_write::fmt::IndentWriter;
15use owo_colors::{OwoColorize, Style};
16use similar::{ChangeTag, DiffableStr, TextDiff};
17use std::{
18    fmt::{self, Write},
19    io,
20    process::ExitCode,
21};
22
23#[derive(Debug, Args)]
24#[clap(next_help_heading = "Global options")]
25pub struct OutputOpts {
26    /// Color output
27    #[clap(long, value_enum, global = true, default_value_t)]
28    pub(crate) color: ColorChoice,
29}
30
31impl OutputOpts {
32    /// Returns true if color should be used for the stream.
33    pub(crate) fn use_color(&self, stream: supports_color::Stream) -> bool {
34        match self.color {
35            ColorChoice::Auto => supports_color::on_cached(stream).is_some(),
36            ColorChoice::Always => true,
37            ColorChoice::Never => false,
38        }
39    }
40
41    /// Creates a `Styles` instance, colorized if color is enabled for the
42    /// given stream.
43    pub(crate) fn styles(&self, stream: supports_color::Stream) -> Styles {
44        let mut styles = Styles::default();
45        if self.use_color(stream) {
46            styles.colorize();
47        }
48        styles
49    }
50}
51
52#[derive(Clone, Debug, Default)]
53pub(crate) struct Styles {
54    pub(crate) bold: Style,
55    pub(crate) header: Style,
56    pub(crate) success_header: Style,
57    pub(crate) failure: Style,
58    pub(crate) failure_header: Style,
59    pub(crate) warning_header: Style,
60    pub(crate) unchanged_header: Style,
61    pub(crate) filename: Style,
62    pub(crate) diff_before: Style,
63    pub(crate) diff_after: Style,
64}
65
66impl Styles {
67    pub(crate) fn colorize(&mut self) {
68        self.bold = Style::new().bold();
69        self.header = Style::new().purple();
70        self.success_header = Style::new().green().bold();
71        self.failure = Style::new().red();
72        self.failure_header = Style::new().red().bold();
73        self.unchanged_header = Style::new().blue().bold();
74        self.warning_header = Style::new().yellow().bold();
75        self.filename = Style::new().cyan();
76        self.diff_before = Style::new().red();
77        self.diff_after = Style::new().green();
78    }
79}
80
81// This is copied from similar's UnifiedDiff::to_writer, except with colorized
82// output.
83pub(crate) fn write_diff<'diff, 'old, 'new, 'bufs, T>(
84    diff: &'diff TextDiff<'old, 'new, 'bufs, T>,
85    path1: &Utf8Path,
86    path2: &Utf8Path,
87    styles: &Styles,
88    context_radius: usize,
89    missing_newline_hint: bool,
90    out: &mut dyn io::Write,
91) -> io::Result<()>
92where
93    'diff: 'old + 'new + 'bufs,
94    T: DiffableStr + ?Sized,
95{
96    // The "a/" (/ courtesy full_path) and "b/" make it feel more like git diff.
97    let a = Utf8Path::new("a").join(path1);
98    writeln!(out, "{}", format!("--- {a}").style(styles.diff_before))?;
99    let b = Utf8Path::new("b").join(path2);
100    writeln!(out, "{}", format!("+++ {b}").style(styles.diff_after))?;
101
102    let mut udiff = diff.unified_diff();
103    udiff
104        .context_radius(context_radius)
105        .missing_newline_hint(missing_newline_hint);
106    for hunk in udiff.iter_hunks() {
107        for (idx, change) in hunk.iter_changes().enumerate() {
108            if idx == 0 {
109                writeln!(out, "{}", hunk.header())?;
110            }
111            let style = match change.tag() {
112                ChangeTag::Delete => styles.diff_before,
113                ChangeTag::Insert => styles.diff_after,
114                ChangeTag::Equal => Style::new(),
115            };
116
117            write!(out, "{}", change.tag().style(style))?;
118            write!(out, "{}", change.value().to_string_lossy().style(style))?;
119            if !diff.newline_terminated() {
120                writeln!(out)?;
121            }
122            if diff.newline_terminated() && change.missing_newline() {
123                writeln!(
124                    out,
125                    "{}",
126                    MissingNewlineHint(hunk.missing_newline_hint())
127                )?;
128            }
129        }
130    }
131
132    Ok(())
133}
134
135pub(crate) fn display_api_spec(api: &ManagedApi, styles: &Styles) -> String {
136    let mut versions = api.iter_versions_semver();
137    let count = versions.len();
138    let latest_version =
139        versions.next_back().expect("must be at least one version");
140    if api.is_versioned() {
141        format!(
142            "{} ({}, versioned ({} supported), latest = {})",
143            api.ident().style(styles.filename),
144            api.title(),
145            count,
146            latest_version,
147        )
148    } else {
149        format!(
150            "{} ({}, lockstep, v{})",
151            api.ident().style(styles.filename),
152            api.title(),
153            latest_version,
154        )
155    }
156}
157
158pub(crate) fn display_api_spec_version(
159    api: &ManagedApi,
160    version: &semver::Version,
161    styles: &Styles,
162    resolution: &Resolution<'_>,
163) -> String {
164    if api.is_lockstep() {
165        assert_eq!(resolution.kind(), ResolutionKind::Lockstep);
166        format!(
167            "{} (lockstep v{}): {}",
168            api.ident().style(styles.filename),
169            version,
170            api.title(),
171        )
172    } else {
173        format!(
174            "{} (versioned v{} ({})): {}",
175            api.ident().style(styles.filename),
176            version,
177            resolution.kind(),
178            api.title(),
179        )
180    }
181}
182
183pub(crate) fn display_error(
184    error: &anyhow::Error,
185    failure_style: Style,
186) -> impl fmt::Display + '_ {
187    struct DisplayError<'a> {
188        error: &'a anyhow::Error,
189        failure_style: Style,
190    }
191
192    impl fmt::Display for DisplayError<'_> {
193        fn fmt(&self, mut f: &mut fmt::Formatter<'_>) -> fmt::Result {
194            writeln!(f, "{}", self.error.style(self.failure_style))?;
195
196            let mut source = self.error.source();
197            while let Some(curr) = source {
198                write!(f, "-> ")?;
199                writeln!(
200                    IndentWriter::new_skip_initial("   ", &mut f),
201                    "{}",
202                    curr.style(self.failure_style),
203                )?;
204                source = curr.source();
205            }
206
207            Ok(())
208        }
209    }
210
211    DisplayError { error, failure_style }
212}
213
214struct MissingNewlineHint(bool);
215
216impl fmt::Display for MissingNewlineHint {
217    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
218        if self.0 {
219            write!(f, "\n\\ No newline at end of file")?;
220        }
221        Ok(())
222    }
223}
224
225pub fn display_load_problems(
226    error_accumulator: &ErrorAccumulator,
227    styles: &Styles,
228) -> anyhow::Result<()> {
229    for w in error_accumulator.iter_warnings() {
230        eprintln!(
231            "{:>HEADER_WIDTH$} {:#}",
232            WARNING.style(styles.warning_header),
233            w
234        );
235    }
236
237    let mut nerrors = 0;
238    for e in error_accumulator.iter_errors() {
239        nerrors += 1;
240        eprintln!(
241            "{:>HEADER_WIDTH$} {:#}",
242            FAILURE.style(styles.failure_header),
243            e
244        );
245    }
246
247    if nerrors > 0 {
248        bail!(
249            "bailing out after {} {} above",
250            nerrors,
251            plural::errors(nerrors)
252        );
253    }
254
255    Ok(())
256}
257
258/// Summarize the results of checking all supported API versions, plus other
259/// problems found during resolution
260pub fn display_resolution(
261    env: &ResolvedEnv,
262    apis: &ManagedApis,
263    resolved: &Resolved,
264    styles: &Styles,
265) -> anyhow::Result<CheckResult> {
266    let total = resolved.nexpected_documents();
267
268    eprintln!(
269        "{:>HEADER_WIDTH$} {} OpenAPI {}...",
270        CHECKING.style(styles.success_header),
271        total.style(styles.bold),
272        plural::documents(total),
273    );
274
275    let mut num_fresh = 0;
276    let mut num_stale = 0;
277    let mut num_failed = 0;
278    let mut num_general_problems = 0;
279
280    // Print problems associated with a supported API version
281    // (i.e., one of the expected OpenAPI documents).
282    for api in apis.iter_apis() {
283        let ident = api.ident();
284
285        for version in api.iter_versions_semver() {
286            let resolution = resolved
287                .resolution_for_api_version(ident, version)
288                .expect("resolution for all supported API versions");
289            if resolution.has_errors() {
290                num_failed += 1;
291            } else if resolution.has_problems() {
292                num_stale += 1;
293            } else {
294                num_fresh += 1;
295            }
296            summarize_one(env, api, version, resolution, styles);
297        }
298
299        if !api.is_versioned() {
300            continue;
301        }
302
303        if let Some(symlink_problem) = resolved.symlink_problem(ident) {
304            if symlink_problem.is_fixable() {
305                num_general_problems += 1;
306                eprintln!(
307                    "{:>HEADER_WIDTH$} {} \"latest\" symlink",
308                    STALE.style(styles.warning_header),
309                    ident.style(styles.filename),
310                );
311                display_resolution_problems(
312                    env,
313                    std::iter::once(symlink_problem),
314                    styles,
315                );
316            } else {
317                num_failed += 1;
318                eprintln!(
319                    "{:>HEADER_WIDTH$} {} \"latest\" symlink",
320                    FAILURE.style(styles.failure_header),
321                    ident.style(styles.filename),
322                );
323                display_resolution_problems(
324                    env,
325                    std::iter::once(symlink_problem),
326                    styles,
327                );
328            }
329        } else {
330            num_fresh += 1;
331            eprintln!(
332                "{:>HEADER_WIDTH$} {} \"latest\" symlink",
333                FRESH.style(styles.success_header),
334                ident.style(styles.filename),
335            );
336        }
337    }
338
339    // Print problems not associated with any supported version, if any.
340    let general_problems: Vec<_> = resolved.general_problems().collect();
341    num_general_problems += if !general_problems.is_empty() {
342        eprintln!(
343            "\n{:>HEADER_WIDTH$} problems not associated with a specific \
344             supported API version:",
345            "Other".style(styles.warning_header),
346        );
347
348        let (fixable, unfixable): (Vec<&Problem>, Vec<&Problem>) =
349            general_problems.iter().partition(|p| p.is_fixable());
350        num_failed += unfixable.len();
351        display_resolution_problems(env, general_problems, styles);
352        fixable.len()
353    } else {
354        0
355    };
356
357    // Print informational notes, if any.
358    for n in resolved.notes() {
359        let initial_indent =
360            format!("{:>HEADER_WIDTH$} ", "Note".style(styles.warning_header));
361        let more_indent = " ".repeat(HEADER_WIDTH + " ".len());
362        eprintln!(
363            "\n{}\n",
364            textwrap::fill(
365                &n.to_string(),
366                textwrap::Options::with_termwidth()
367                    .initial_indent(&initial_indent)
368                    .subsequent_indent(&more_indent)
369            )
370        );
371    }
372
373    // Print a summary line.
374    let status_header = if num_failed > 0 {
375        FAILURE.style(styles.failure_header)
376    } else if num_stale > 0 || num_general_problems > 0 {
377        STALE.style(styles.warning_header)
378    } else {
379        SUCCESS.style(styles.success_header)
380    };
381
382    eprintln!("{:>HEADER_WIDTH$}", SEPARATOR);
383    eprintln!(
384        "{:>HEADER_WIDTH$} {} {} checked: {} fresh, {} stale, {} failed, \
385         {} other {}",
386        status_header,
387        total.style(styles.bold),
388        plural::documents(total),
389        num_fresh.style(styles.bold),
390        num_stale.style(styles.bold),
391        num_failed.style(styles.bold),
392        num_general_problems.style(styles.bold),
393        plural::problems(num_general_problems),
394    );
395    if num_failed > 0 {
396        eprintln!(
397            "{:>HEADER_WIDTH$} (fix failures, then run {} to update)",
398            "",
399            format!("{} generate", env.command).style(styles.bold)
400        );
401        Ok(CheckResult::Failures)
402    } else if num_stale > 0 || num_general_problems > 0 {
403        eprintln!(
404            "{:>HEADER_WIDTH$} (run {} to update)",
405            "",
406            format!("{} generate", env.command).style(styles.bold)
407        );
408        Ok(CheckResult::NeedsUpdate)
409    } else {
410        Ok(CheckResult::Success)
411    }
412}
413
414/// The result of a check operation.
415///
416/// Returned by the `check_apis_up_to_date` function.
417#[derive(Clone, Copy, Debug, Eq, PartialEq)]
418pub enum CheckResult {
419    /// The APIs are up-to-date.
420    Success,
421    /// The APIs need to be updated.
422    NeedsUpdate,
423    /// There were validation errors or other problems.
424    Failures,
425}
426
427impl CheckResult {
428    /// Returns the exit code corresponding to the check result.
429    pub fn to_exit_code(self) -> ExitCode {
430        match self {
431            CheckResult::Success => ExitCode::SUCCESS,
432            CheckResult::NeedsUpdate => NEEDS_UPDATE_EXIT_CODE.into(),
433            CheckResult::Failures => FAILURE_EXIT_CODE.into(),
434        }
435    }
436}
437
438/// Summarize the "check" status of one supported API version
439fn summarize_one(
440    env: &ResolvedEnv,
441    api: &ManagedApi,
442    version: &semver::Version,
443    resolution: &Resolution<'_>,
444    styles: &Styles,
445) {
446    let problems: Vec<_> = resolution.problems().collect();
447    if problems.is_empty() {
448        // Success case: file is up-to-date.
449        eprintln!(
450            "{:>HEADER_WIDTH$} {}",
451            FRESH.style(styles.success_header),
452            display_api_spec_version(api, version, styles, resolution),
453        );
454    } else {
455        // There were one or more problems, some of which may be unfixable.
456        eprintln!(
457            "{:>HEADER_WIDTH$} {}",
458            if resolution.has_errors() {
459                FAILURE.style(styles.failure_header)
460            } else {
461                assert!(resolution.has_problems());
462                STALE.style(styles.warning_header)
463            },
464            display_api_spec_version(api, version, styles, resolution),
465        );
466
467        display_resolution_problems(env, problems, styles);
468    }
469}
470
471/// Print a formatted list of Problems
472pub fn display_resolution_problems<'a, T>(
473    env: &ResolvedEnv,
474    problems: T,
475    styles: &Styles,
476) where
477    T: IntoIterator<Item = &'a Problem<'a>>,
478{
479    for p in problems.into_iter() {
480        let subheader_width = HEADER_WIDTH + 4;
481        let first_indent = format!(
482            "{:>subheader_width$}: ",
483            if p.is_fixable() {
484                "problem".style(styles.warning_header)
485            } else {
486                "error".style(styles.failure_header)
487            }
488        );
489        let more_indent = " ".repeat(subheader_width + 2);
490        eprintln!(
491            "{}",
492            textwrap::fill(
493                &InlineErrorChain::new(&p).to_string(),
494                textwrap::Options::with_termwidth()
495                    .initial_indent(&first_indent)
496                    .subsequent_indent(&more_indent)
497            )
498        );
499
500        // For BlessedVersionBroken, print each item separately, along with a
501        // diff between blessed and generated versions.
502        if let Problem::BlessedVersionBroken { compatibility_issues } = &p {
503            for issue in compatibility_issues {
504                // Print each compatibility issue on a new line, prefixed with
505                // "- ".
506                let nested_first_indent = format!("{}- ", more_indent);
507                let nested_more_indent = format!("{}  ", more_indent);
508                eprintln!(
509                    "{}",
510                    textwrap::fill(
511                        &issue.to_string(),
512                        textwrap::Options::with_termwidth()
513                            .initial_indent(&nested_first_indent)
514                            .subsequent_indent(&nested_more_indent)
515                    )
516                );
517
518                // Now print a textual diff between the blessed and generated
519                // versions.
520                let blessed_json = issue.blessed_json();
521                let generated_json = issue.generated_json();
522
523                let diff = TextDiff::from_lines(&blessed_json, &generated_json);
524                // We don't care about I/O errors here (just as we don't when
525                // using eprintln! above).
526                let _ = write_diff(
527                    &diff,
528                    "blessed".as_ref(),
529                    "generated".as_ref(),
530                    styles,
531                    // context_radius: use a large radius to ensure that most of
532                    // the schema is printed out.
533                    8,
534                    /* missing_newline_hint */ false,
535                    // Add an indent to align diff with the status message.
536                    &mut indent_write::io::IndentWriter::new(
537                        &nested_more_indent,
538                        std::io::stderr(),
539                    ),
540                );
541            }
542        }
543
544        // For BlessedLatestVersionBytewiseMismatch, show a diff between blessed
545        // and generated versions even though there's no fix.
546        if let Problem::BlessedLatestVersionBytewiseMismatch {
547            blessed,
548            generated,
549        } = p
550        {
551            let diff =
552                TextDiff::from_lines(blessed.contents(), generated.contents());
553            let path1 =
554                env.openapi_abs_dir().join(blessed.spec_file_name().path());
555            let path2 =
556                env.openapi_abs_dir().join(generated.spec_file_name().path());
557            let indent = " ".repeat(HEADER_WIDTH + 1);
558            let _ = write_diff(
559                &diff,
560                &path1,
561                &path2,
562                styles,
563                // context_radius: show enough context to understand the changes.
564                3,
565                /* missing_newline_hint */ true,
566                &mut indent_write::io::IndentWriter::new(
567                    &indent,
568                    std::io::stderr(),
569                ),
570            );
571        }
572
573        let Some(fix) = p.fix() else {
574            continue;
575        };
576
577        let first_indent = format!(
578            "{:>subheader_width$}: ",
579            "fix".style(styles.warning_header)
580        );
581        let fix_str = fix.to_string();
582        let steps = fix_str.trim_end().split("\n");
583        for s in steps {
584            eprintln!(
585                "{}",
586                textwrap::fill(
587                    &format!("will {}", s),
588                    textwrap::Options::with_termwidth()
589                        .initial_indent(&first_indent)
590                        .subsequent_indent(&more_indent)
591                )
592            );
593        }
594
595        // When possible, print a useful diff of changes.
596        let do_diff = match p {
597            Problem::LockstepStale { found, generated } => {
598                let diff = TextDiff::from_lines(
599                    found.contents(),
600                    generated.contents(),
601                );
602                let path1 =
603                    env.openapi_abs_dir().join(found.spec_file_name().path());
604                let path2 = env
605                    .openapi_abs_dir()
606                    .join(generated.spec_file_name().path());
607                Some((diff, path1, path2))
608            }
609            Problem::ExtraFileStale {
610                check_stale:
611                    CheckStale::Modified { full_path, actual, expected },
612                ..
613            } => {
614                let diff = TextDiff::from_lines(actual, expected);
615                Some((diff, full_path.clone(), full_path.clone()))
616            }
617            Problem::LocalVersionStale { spec_files, generated }
618                if spec_files.len() == 1 =>
619            {
620                let diff = TextDiff::from_lines(
621                    spec_files[0].contents(),
622                    generated.contents(),
623                );
624                let path1 = env
625                    .openapi_abs_dir()
626                    .join(spec_files[0].spec_file_name().path());
627                let path2 = env
628                    .openapi_abs_dir()
629                    .join(generated.spec_file_name().path());
630                Some((diff, path1, path2))
631            }
632            _ => None,
633        };
634
635        if let Some((diff, path1, path2)) = do_diff {
636            let indent = " ".repeat(HEADER_WIDTH + 1);
637            // We don't care about I/O errors here (just as we don't when using
638            // eprintln! above).
639            let _ = write_diff(
640                &diff,
641                &path1,
642                &path2,
643                styles,
644                // context_radius: here, a small radius is sufficient to show
645                // differences.
646                3,
647                /* missing_newline_hint */ true,
648                // Add an indent to align diff with the status message.
649                &mut indent_write::io::IndentWriter::new(
650                    &indent,
651                    std::io::stderr(),
652                ),
653            );
654            eprintln!();
655        }
656    }
657}
658
659/// Adapter for [`Error`]s that provides a [`std::fmt::Display`] implementation
660/// that print the full chain of error sources, separated by `: `.
661pub struct InlineErrorChain<'a>(&'a dyn std::error::Error);
662
663impl<'a> InlineErrorChain<'a> {
664    pub fn new(error: &'a dyn std::error::Error) -> Self {
665        Self(error)
666    }
667}
668
669impl fmt::Display for InlineErrorChain<'_> {
670    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
671        write!(f, "{}", self.0)?;
672        let mut cause = self.0.source();
673        while let Some(source) = cause {
674            write!(f, ": {source}")?;
675            cause = source.source();
676        }
677        Ok(())
678    }
679}
680
681/// Output headers.
682pub(crate) mod headers {
683    // Same width as Cargo's output.
684    pub(crate) const HEADER_WIDTH: usize = 12;
685
686    pub(crate) static SEPARATOR: &str = "-------";
687
688    pub(crate) static CHECKING: &str = "Checking";
689    pub(crate) static GENERATING: &str = "Generating";
690
691    pub(crate) static FRESH: &str = "Fresh";
692    pub(crate) static STALE: &str = "Stale";
693
694    pub(crate) static UNCHANGED: &str = "Unchanged";
695
696    pub(crate) static SUCCESS: &str = "Success";
697    pub(crate) static FAILURE: &str = "Failure";
698    pub(crate) static WARNING: &str = "Warning";
699}
700
701pub(crate) mod plural {
702    pub(crate) fn files(count: usize) -> &'static str {
703        if count == 1 { "file" } else { "files" }
704    }
705
706    pub(crate) fn changes(count: usize) -> &'static str {
707        if count == 1 { "change" } else { "changes" }
708    }
709
710    pub(crate) fn documents(count: usize) -> &'static str {
711        if count == 1 { "document" } else { "documents" }
712    }
713
714    pub(crate) fn errors(count: usize) -> &'static str {
715        if count == 1 { "error" } else { "errors" }
716    }
717
718    pub(crate) fn paths(count: usize) -> &'static str {
719        if count == 1 { "path" } else { "paths" }
720    }
721
722    pub(crate) fn problems(count: usize) -> &'static str {
723        if count == 1 { "problem" } else { "problems" }
724    }
725
726    pub(crate) fn schemas(count: usize) -> &'static str {
727        if count == 1 { "schema" } else { "schemas" }
728    }
729}