1use crate::{
4 FAILURE_EXIT_CODE, NEEDS_UPDATE_EXIT_CODE,
5 apis::{ManagedApi, ManagedApis},
6 compatibility::{
7 ApiCompatIssue, CompatIssueLocation, CompatRenderStatus,
8 FinalizedCompatDedupMap,
9 },
10 environment::{ErrorAccumulator, ResolvedEnv},
11 resolved::{
12 Fix, NonVersionProblem, Resolution, ResolutionKind, Resolved,
13 VersionProblem,
14 },
15 validation::CheckStale,
16};
17use anyhow::bail;
18use camino::Utf8Path;
19use clap::{Args, ColorChoice};
20use headers::*;
21use indent_write::fmt::IndentWriter;
22use owo_colors::{OwoColorize, Style};
23use similar::{ChangeTag, DiffableStr, TextDiff};
24use std::{
25 fmt::{self, Write},
26 io,
27 process::ExitCode,
28};
29
30#[derive(Debug, Args)]
31#[clap(next_help_heading = "Global options")]
32pub struct OutputOpts {
33 #[clap(long, value_enum, global = true, default_value_t)]
35 pub(crate) color: ColorChoice,
36}
37
38impl OutputOpts {
39 pub(crate) fn use_color(&self, stream: supports_color::Stream) -> bool {
41 match self.color {
42 ColorChoice::Auto => supports_color::on_cached(stream).is_some(),
43 ColorChoice::Always => true,
44 ColorChoice::Never => false,
45 }
46 }
47
48 pub(crate) fn styles(&self, stream: supports_color::Stream) -> Styles {
51 let mut styles = Styles::default();
52 if self.use_color(stream) {
53 styles.colorize();
54 }
55 styles
56 }
57}
58
59#[derive(Clone, Debug, Default)]
60pub(crate) struct Styles {
61 pub(crate) bold: Style,
62 pub(crate) dimmed: Style,
63 pub(crate) header: Style,
64 pub(crate) success_header: Style,
65 pub(crate) failure: Style,
66 pub(crate) failure_header: Style,
67 pub(crate) warning: Style,
68 pub(crate) warning_header: Style,
69 pub(crate) unchanged_header: Style,
70 pub(crate) filename: Style,
71 pub(crate) operation_id: Style,
72 pub(crate) diff_before: Style,
73 pub(crate) diff_after: Style,
74}
75
76impl Styles {
77 pub(crate) fn colorize(&mut self) {
78 self.bold = Style::new().bold();
79 self.dimmed = Style::new().dimmed();
80 self.header = Style::new().purple();
81 self.success_header = Style::new().green().bold();
82 self.failure = Style::new().red();
83 self.failure_header = Style::new().red().bold();
84 self.warning = Style::new().yellow();
85 self.warning_header = Style::new().yellow().bold();
86 self.unchanged_header = Style::new().blue().bold();
87 self.filename = Style::new().cyan();
88 self.operation_id = Style::new().purple();
89 self.diff_before = Style::new().red();
90 self.diff_after = Style::new().green();
91 }
92}
93
94pub(crate) fn write_diff<'diff, 'old, 'new, 'bufs, T>(
97 diff: &'diff TextDiff<'old, 'new, 'bufs, T>,
98 path1: &Utf8Path,
99 path2: &Utf8Path,
100 styles: &Styles,
101 context_radius: usize,
102 missing_newline_hint: bool,
103 out: &mut dyn io::Write,
104) -> io::Result<()>
105where
106 'diff: 'old + 'new + 'bufs,
107 T: DiffableStr + ?Sized,
108{
109 let a = format_diff_path("a", path1);
114 writeln!(out, "{}", format!("--- {a}").style(styles.diff_before))?;
115 let b = format_diff_path("b", path2);
116 writeln!(out, "{}", format!("+++ {b}").style(styles.diff_after))?;
117
118 let mut udiff = diff.unified_diff();
119 udiff
120 .context_radius(context_radius)
121 .missing_newline_hint(missing_newline_hint);
122 for hunk in udiff.iter_hunks() {
123 for (idx, change) in hunk.iter_changes().enumerate() {
124 if idx == 0 {
125 writeln!(out, "{}", hunk.header())?;
126 }
127 let style = match change.tag() {
128 ChangeTag::Delete => styles.diff_before,
129 ChangeTag::Insert => styles.diff_after,
130 ChangeTag::Equal => Style::new(),
131 };
132
133 write!(out, "{}", change.tag().style(style))?;
134 write!(out, "{}", change.value().to_string_lossy().style(style))?;
135 if !diff.newline_terminated() {
136 writeln!(out)?;
137 }
138 if diff.newline_terminated() && change.missing_newline() {
139 writeln!(
140 out,
141 "{}",
142 MissingNewlineHint(hunk.missing_newline_hint())
143 )?;
144 }
145 }
146 }
147
148 Ok(())
149}
150
151fn format_diff_path(prefix: &str, path: &Utf8Path) -> String {
155 if std::path::MAIN_SEPARATOR == '/' {
156 format!("{prefix}/{path}")
157 } else {
158 format!("{prefix}/{}", path.as_str().replace('\\', "/"))
159 }
160}
161
162pub(crate) fn display_api_spec(api: &ManagedApi, styles: &Styles) -> String {
163 let mut versions = api.iter_versions_semver();
164 let count = versions.len();
165 let latest_version =
166 versions.next_back().expect("must be at least one version");
167 if api.is_versioned() {
168 format!(
169 "{} ({}, versioned ({} supported), latest = {})",
170 api.ident().style(styles.filename),
171 api.title(),
172 count,
173 latest_version,
174 )
175 } else {
176 format!(
177 "{} ({}, lockstep, v{})",
178 api.ident().style(styles.filename),
179 api.title(),
180 latest_version,
181 )
182 }
183}
184
185pub(crate) fn display_api_spec_version(
186 api: &ManagedApi,
187 version: &semver::Version,
188 styles: &Styles,
189 resolution: &Resolution<'_>,
190) -> String {
191 if api.is_lockstep() {
192 assert_eq!(resolution.kind(), ResolutionKind::Lockstep);
193 format!(
194 "{} (lockstep v{}): {}",
195 api.ident().style(styles.filename),
196 version,
197 api.title(),
198 )
199 } else {
200 format!(
201 "{} (versioned v{} ({})): {}",
202 api.ident().style(styles.filename),
203 version,
204 resolution.kind(),
205 api.title(),
206 )
207 }
208}
209
210pub(crate) fn display_error(
211 error: &anyhow::Error,
212 failure_style: Style,
213) -> impl fmt::Display + '_ {
214 struct DisplayError<'a> {
215 error: &'a anyhow::Error,
216 failure_style: Style,
217 }
218
219 impl fmt::Display for DisplayError<'_> {
220 fn fmt(&self, mut f: &mut fmt::Formatter<'_>) -> fmt::Result {
221 writeln!(f, "{}", self.error.style(self.failure_style))?;
222
223 let mut source = self.error.source();
224 while let Some(curr) = source {
225 write!(f, "-> ")?;
226 writeln!(
227 IndentWriter::new_skip_initial(" ", &mut f),
228 "{}",
229 curr.style(self.failure_style),
230 )?;
231 source = curr.source();
232 }
233
234 Ok(())
235 }
236 }
237
238 DisplayError { error, failure_style }
239}
240
241struct MissingNewlineHint(bool);
242
243impl fmt::Display for MissingNewlineHint {
244 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
245 if self.0 {
246 write!(f, "\n\\ No newline at end of file")?;
247 }
248 Ok(())
249 }
250}
251
252pub fn display_load_problems(
253 writer: &mut dyn io::Write,
254 error_accumulator: &ErrorAccumulator,
255 styles: &Styles,
256) -> anyhow::Result<()> {
257 for w in error_accumulator.iter_warnings() {
258 writeln!(
259 writer,
260 "{:>HEADER_WIDTH$} {:#}",
261 WARNING.style(styles.warning_header),
262 w
263 )?;
264 }
265
266 let mut nerrors = 0;
267 for e in error_accumulator.iter_errors() {
268 nerrors += 1;
269 writeln!(
270 writer,
271 "{:>HEADER_WIDTH$} {:#}",
272 FAILURE.style(styles.failure_header),
273 e
274 )?;
275 }
276
277 if nerrors > 0 {
278 bail!(
279 "bailing out after {} {} above",
280 nerrors,
281 plural::errors(nerrors)
282 );
283 }
284
285 Ok(())
286}
287
288pub fn display_resolution(
291 writer: &mut dyn io::Write,
292 env: &ResolvedEnv,
293 apis: &ManagedApis,
294 resolved: &Resolved,
295 styles: &Styles,
296) -> anyhow::Result<CheckResult> {
297 let total = resolved.nexpected_documents();
298
299 writeln!(
300 writer,
301 "{:>HEADER_WIDTH$} {} OpenAPI {}...",
302 CHECKING.style(styles.success_header),
303 total.style(styles.bold),
304 plural::documents(total),
305 )?;
306
307 let mut num_fresh = 0;
308 let mut num_stale = 0;
309 let mut num_failed = 0;
310 let mut num_non_version_problems = 0;
311
312 let dedup = resolved.build_compat_dedup_map();
313
314 for api in apis.iter_apis() {
317 let ident = api.ident();
318
319 for version in api.iter_versions_semver() {
320 let resolution = resolved
321 .resolution_for_api_version(ident, version)
322 .expect("resolution for all supported API versions");
323 if resolution.has_errors() {
324 num_failed += 1;
325 } else if resolution.has_problems() {
326 num_stale += 1;
327 } else {
328 num_fresh += 1;
329 }
330 summarize_one(
331 writer, env, api, version, resolution, styles, &dedup,
332 )?;
333 }
334
335 if !api.is_versioned() {
336 continue;
337 }
338
339 if let Some(symlink_problem) = resolved.symlink_problem(ident) {
340 if symlink_problem.is_fixable() {
341 num_non_version_problems += 1;
342 writeln!(
343 writer,
344 "{:>HEADER_WIDTH$} {} \"latest\" symlink",
345 STALE.style(styles.warning_header),
346 ident.style(styles.filename),
347 )?;
348 display_non_version_problems(
349 writer,
350 std::iter::once(symlink_problem),
351 styles,
352 )?;
353 } else {
354 num_failed += 1;
355 writeln!(
356 writer,
357 "{:>HEADER_WIDTH$} {} \"latest\" symlink",
358 FAILURE.style(styles.failure_header),
359 ident.style(styles.filename),
360 )?;
361 display_non_version_problems(
362 writer,
363 std::iter::once(symlink_problem),
364 styles,
365 )?;
366 }
367 } else {
368 num_fresh += 1;
369 writeln!(
370 writer,
371 "{:>HEADER_WIDTH$} {} \"latest\" symlink",
372 FRESH.style(styles.success_header),
373 ident.style(styles.filename),
374 )?;
375 }
376 }
377
378 let orphaned_and_unparseable: Vec<_> =
380 resolved.orphaned_and_unparseable().collect();
381 num_non_version_problems += if !orphaned_and_unparseable.is_empty() {
382 writeln!(
383 writer,
384 "\n{:>HEADER_WIDTH$} problems not associated with a specific \
385 supported API version:",
386 "Other".style(styles.warning_header),
387 )?;
388
389 let (fixable, unfixable): (
390 Vec<&NonVersionProblem>,
391 Vec<&NonVersionProblem>,
392 ) = orphaned_and_unparseable.iter().partition(|p| p.is_fixable());
393 num_failed += unfixable.len();
394 display_non_version_problems(writer, orphaned_and_unparseable, styles)?;
395 fixable.len()
396 } else {
397 0
398 };
399
400 for n in resolved.notes() {
402 let initial_indent =
403 format!("{:>HEADER_WIDTH$} ", "Note".style(styles.warning_header));
404 let more_indent = " ".repeat(HEADER_WIDTH + " ".len());
405 writeln!(
406 writer,
407 "\n{}\n",
408 textwrap::fill(
409 &n.to_string(),
410 textwrap::Options::new(term_width())
411 .initial_indent(&initial_indent)
412 .subsequent_indent(&more_indent)
413 )
414 )?;
415 }
416
417 let status_header = if num_failed > 0 {
419 FAILURE.style(styles.failure_header)
420 } else if num_stale > 0 || num_non_version_problems > 0 {
421 STALE.style(styles.warning_header)
422 } else {
423 SUCCESS.style(styles.success_header)
424 };
425
426 writeln!(writer, "{:>HEADER_WIDTH$}", SEPARATOR)?;
427 writeln!(
428 writer,
429 "{:>HEADER_WIDTH$} {} {} checked: {} fresh, {} stale, {} failed, \
430 {} other {}",
431 status_header,
432 total.style(styles.bold),
433 plural::documents(total),
434 num_fresh.style(styles.bold),
435 num_stale.style(styles.bold),
436 num_failed.style(styles.bold),
437 num_non_version_problems.style(styles.bold),
438 plural::problems(num_non_version_problems),
439 )?;
440 if num_failed > 0 {
441 writeln!(
442 writer,
443 "{:>HEADER_WIDTH$} (fix failures, then run {} to update)",
444 "",
445 format!("{} generate", env.command).style(styles.bold)
446 )?;
447 Ok(CheckResult::Failures)
448 } else if num_stale > 0 || num_non_version_problems > 0 {
449 writeln!(
450 writer,
451 "{:>HEADER_WIDTH$} (run {} to update)",
452 "",
453 format!("{} generate", env.command).style(styles.bold)
454 )?;
455 Ok(CheckResult::NeedsUpdate)
456 } else {
457 Ok(CheckResult::Success)
458 }
459}
460
461#[derive(Clone, Copy, Debug, Eq, PartialEq)]
465pub enum CheckResult {
466 Success,
468 NeedsUpdate,
470 Failures,
472}
473
474impl CheckResult {
475 pub fn to_exit_code(self) -> ExitCode {
477 match self {
478 CheckResult::Success => ExitCode::SUCCESS,
479 CheckResult::NeedsUpdate => NEEDS_UPDATE_EXIT_CODE.into(),
480 CheckResult::Failures => FAILURE_EXIT_CODE.into(),
481 }
482 }
483}
484
485fn summarize_one(
487 writer: &mut dyn io::Write,
488 env: &ResolvedEnv,
489 api: &ManagedApi,
490 version: &semver::Version,
491 resolution: &Resolution<'_>,
492 styles: &Styles,
493 dedup: &FinalizedCompatDedupMap<'_>,
494) -> io::Result<()> {
495 let problems: Vec<_> = resolution.problems().collect();
496 if problems.is_empty() {
497 writeln!(
499 writer,
500 "{:>HEADER_WIDTH$} {}",
501 FRESH.style(styles.success_header),
502 display_api_spec_version(api, version, styles, resolution),
503 )?;
504 } else {
505 writeln!(
507 writer,
508 "{:>HEADER_WIDTH$} {}",
509 if resolution.has_errors() {
510 FAILURE.style(styles.failure_header)
511 } else {
512 assert!(resolution.has_problems());
513 STALE.style(styles.warning_header)
514 },
515 display_api_spec_version(api, version, styles, resolution),
516 )?;
517
518 let compat_ctx = CompatDisplayContext {
519 dedup,
520 current: CompatIssueLocation { api: api.ident(), version },
521 };
522 display_version_problems(writer, env, problems, styles, compat_ctx)?;
523 }
524 Ok(())
525}
526
527pub(crate) struct CompatDisplayContext<'a> {
528 pub(crate) dedup: &'a FinalizedCompatDedupMap<'a>,
529 pub(crate) current: CompatIssueLocation<'a>,
530}
531
532pub(crate) fn display_version_problems<'a, T>(
538 writer: &mut dyn io::Write,
539 env: &ResolvedEnv,
540 problems: T,
541 styles: &Styles,
542 compat_ctx: CompatDisplayContext<'_>,
543) -> io::Result<()>
544where
545 T: IntoIterator<Item = &'a VersionProblem<'a>>,
546{
547 for p in problems.into_iter() {
548 write_problem_header(writer, p, p.is_fixable(), styles)?;
549
550 let issue_indent = " ".repeat(HEADER_WIDTH - "error".len());
556
557 let issues = p.compatibility_issues();
563 for issue in issues {
564 let status = compat_ctx.dedup.status_for(issue, compat_ctx.current);
565 display_compat_issue(
566 &mut *writer,
567 issue,
568 &issue_indent,
569 styles,
570 status,
571 )?;
572 }
573 if !issues.is_empty() {
574 writeln!(writer)?;
575 }
576
577 if let VersionProblem::BlessedLatestVersionBytewiseMismatch {
580 blessed,
581 generated,
582 } = p
583 {
584 let diff =
585 TextDiff::from_lines(blessed.contents(), generated.contents());
586 let path1 =
587 env.openapi_abs_dir().join(blessed.spec_file_name().path());
588 let path2 =
589 env.openapi_abs_dir().join(generated.spec_file_name().path());
590 let indent = " ".repeat(HEADER_WIDTH + 1);
591 write_diff(
592 &diff,
593 &path1,
594 &path2,
595 styles,
596 3,
598 true,
599 &mut indent_write::io::IndentWriter::new(&indent, &mut *writer),
600 )?;
601 }
602
603 let Some(fix) = p.fix() else {
604 continue;
605 };
606
607 write_fix_summary(writer, &fix, styles)?;
608
609 let do_diff = match p {
611 VersionProblem::LockstepStale { found, generated } => {
612 let diff = TextDiff::from_lines(
613 found.contents(),
614 generated.contents(),
615 );
616 let path1 =
617 env.openapi_abs_dir().join(found.spec_file_name().path());
618 let path2 = env
619 .openapi_abs_dir()
620 .join(generated.spec_file_name().path());
621 Some((diff, path1, path2))
622 }
623 VersionProblem::ExtraFileStale {
624 check_stale:
625 CheckStale::Modified { full_path, actual, expected },
626 ..
627 } => {
628 let diff = TextDiff::from_lines(actual, expected);
629 Some((diff, full_path.clone(), full_path.clone()))
630 }
631 VersionProblem::LocalVersionStale { spec_files, generated }
632 if spec_files.len() == 1 =>
633 {
634 let diff = TextDiff::from_lines(
635 spec_files[0].contents(),
636 generated.contents(),
637 );
638 let path1 = env
639 .openapi_abs_dir()
640 .join(spec_files[0].spec_file_name().path());
641 let path2 = env
642 .openapi_abs_dir()
643 .join(generated.spec_file_name().path());
644 Some((diff, path1, path2))
645 }
646 _ => None,
647 };
648
649 if let Some((diff, path1, path2)) = do_diff {
650 let indent = " ".repeat(HEADER_WIDTH + 1);
651 write_diff(
652 &diff,
653 &path1,
654 &path2,
655 styles,
656 3,
659 true,
660 &mut indent_write::io::IndentWriter::new(&indent, &mut *writer),
662 )?;
663 writeln!(writer)?;
664 }
665 }
666 Ok(())
667}
668
669pub fn display_non_version_problems<'a, T>(
674 writer: &mut dyn io::Write,
675 problems: T,
676 styles: &Styles,
677) -> io::Result<()>
678where
679 T: IntoIterator<Item = &'a NonVersionProblem<'a>>,
680{
681 for p in problems.into_iter() {
682 write_problem_header(writer, p, p.is_fixable(), styles)?;
683 if let Some(fix) = p.fix() {
684 write_fix_summary(writer, &fix, styles)?;
685 }
686 }
687 Ok(())
688}
689
690fn write_problem_header(
697 writer: &mut dyn io::Write,
698 error: &dyn std::error::Error,
699 is_fixable: bool,
700 styles: &Styles,
701) -> io::Result<()> {
702 let first_indent = format!(
703 "{:>HEADER_WIDTH$}: ",
704 if is_fixable {
705 "problem".style(styles.warning_header)
706 } else {
707 "error".style(styles.failure_header)
708 }
709 );
710 let more_indent = " ".repeat(HEADER_WIDTH + 2);
713 writeln!(
714 writer,
715 "{}",
716 textwrap::fill(
717 &InlineErrorChain::new(error).to_string(),
718 textwrap::Options::new(term_width())
719 .initial_indent(&first_indent)
720 .subsequent_indent(&more_indent)
721 )
722 )
723}
724
725fn write_fix_summary(
729 writer: &mut dyn io::Write,
730 fix: &Fix<'_>,
731 styles: &Styles,
732) -> io::Result<()> {
733 let first_indent =
734 format!("{:>HEADER_WIDTH$}: ", "fix".style(styles.warning_header));
735 let more_indent = " ".repeat(HEADER_WIDTH + 2);
736 let fix_str = fix.to_string();
737 for s in fix_str.trim_end().split("\n") {
738 writeln!(
739 writer,
740 "{}",
741 textwrap::fill(
742 &format!("will {}", s),
743 textwrap::Options::new(term_width())
744 .initial_indent(&first_indent)
745 .subsequent_indent(&more_indent)
746 )
747 )?;
748 }
749 Ok(())
750}
751
752fn display_compat_issue(
759 writer: &mut dyn io::Write,
760 issue: &ApiCompatIssue,
761 body_indent: &str,
762 styles: &Styles,
763 status: CompatRenderStatus,
764) -> io::Result<()> {
765 writeln!(writer)?;
769
770 let wrap_width =
773 term_width().saturating_sub(textwrap::core::display_width(body_indent));
774
775 let mut buf = String::new();
778 write!(
779 IndentWriter::new(body_indent, &mut buf),
780 "{}",
781 issue.display(styles, status).with_wrap_width(wrap_width),
782 )
783 .expect("writing to a String never fails");
784 writeln!(writer, "{buf}")?;
785
786 match status {
787 CompatRenderStatus::FirstOccurrence { .. } => {
788 let blessed_json = issue.blessed_json();
791 let generated_json = issue.generated_json();
792
793 let diff = TextDiff::from_lines(&blessed_json, &generated_json);
794 write_diff(
795 &diff,
796 "blessed".as_ref(),
797 "generated".as_ref(),
798 styles,
799 8,
802 false,
803 &mut indent_write::io::IndentWriter::new(body_indent, writer),
805 )
806 }
807 CompatRenderStatus::Duplicate { .. } => {
808 Ok(())
811 }
812 }
813}
814pub struct InlineErrorChain<'a>(&'a dyn std::error::Error);
817
818impl<'a> InlineErrorChain<'a> {
819 pub fn new(error: &'a dyn std::error::Error) -> Self {
820 Self(error)
821 }
822}
823
824impl fmt::Display for InlineErrorChain<'_> {
825 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
826 write!(f, "{}", self.0)?;
827 let mut cause = self.0.source();
828 while let Some(source) = cause {
829 write!(f, ": {source}")?;
830 cause = source.source();
831 }
832 Ok(())
833 }
834}
835
836pub(crate) fn term_width() -> usize {
850 match std::env::var("OPENAPI_MGR_TERM_WIDTH") {
851 Ok(s) => s.parse().unwrap_or_else(|err| {
852 panic!("OPENAPI_MGR_TERM_WIDTH={s:?} is not a valid width: {err}")
853 }),
854 Err(_) => textwrap::termwidth(),
855 }
856}
857
858pub(crate) mod headers {
860 pub(crate) const HEADER_WIDTH: usize = 12;
862
863 pub(crate) static SEPARATOR: &str = "-------";
864
865 pub(crate) static CHECKING: &str = "Checking";
866 pub(crate) static GENERATING: &str = "Generating";
867
868 pub(crate) static FRESH: &str = "Fresh";
869 pub(crate) static STALE: &str = "Stale";
870
871 pub(crate) static UNCHANGED: &str = "Unchanged";
872
873 pub(crate) static SUCCESS: &str = "Success";
874 pub(crate) static FAILURE: &str = "Failure";
875 pub(crate) static WARNING: &str = "Warning";
876}
877
878pub(crate) mod plural {
879 pub(crate) fn files(count: usize) -> &'static str {
880 if count == 1 { "file" } else { "files" }
881 }
882
883 pub(crate) fn changes(count: usize) -> &'static str {
884 if count == 1 { "change" } else { "changes" }
885 }
886
887 pub(crate) fn documents(count: usize) -> &'static str {
888 if count == 1 { "document" } else { "documents" }
889 }
890
891 pub(crate) fn errors(count: usize) -> &'static str {
892 if count == 1 { "error" } else { "errors" }
893 }
894
895 pub(crate) fn paths(count: usize) -> &'static str {
896 if count == 1 { "path" } else { "paths" }
897 }
898
899 pub(crate) fn problems(count: usize) -> &'static str {
900 if count == 1 { "problem" } else { "problems" }
901 }
902
903 pub(crate) fn schemas(count: usize) -> &'static str {
904 if count == 1 { "schema" } else { "schemas" }
905 }
906}