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