1use 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 #[clap(long, value_enum, global = true, default_value_t)]
28 pub(crate) color: ColorChoice,
29}
30
31impl OutputOpts {
32 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 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
81pub(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 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
258pub 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 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 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 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 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
418pub enum CheckResult {
419 Success,
421 NeedsUpdate,
423 Failures,
425}
426
427impl CheckResult {
428 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
438fn 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 eprintln!(
450 "{:>HEADER_WIDTH$} {}",
451 FRESH.style(styles.success_header),
452 display_api_spec_version(api, version, styles, resolution),
453 );
454 } else {
455 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
471pub 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 if let Problem::BlessedVersionBroken { compatibility_issues } = &p {
503 for issue in compatibility_issues {
504 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 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 let _ = write_diff(
527 &diff,
528 "blessed".as_ref(),
529 "generated".as_ref(),
530 styles,
531 8,
534 false,
535 &mut indent_write::io::IndentWriter::new(
537 &nested_more_indent,
538 std::io::stderr(),
539 ),
540 );
541 }
542 }
543
544 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 3,
565 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 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 let _ = write_diff(
640 &diff,
641 &path1,
642 &path2,
643 styles,
644 3,
647 true,
648 &mut indent_write::io::IndentWriter::new(
650 &indent,
651 std::io::stderr(),
652 ),
653 );
654 eprintln!();
655 }
656 }
657}
658
659pub 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
681pub(crate) mod headers {
683 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}