dropshot_api_manager/
output.rs

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