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