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
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
71pub(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 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
246pub 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 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 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 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 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
406pub enum CheckResult {
407 Success,
409 NeedsUpdate,
411 Failures,
413}
414
415impl CheckResult {
416 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
426fn 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 eprintln!(
438 "{:>HEADER_WIDTH$} {}",
439 FRESH.style(styles.success_header),
440 display_api_spec_version(api, version, styles, resolution),
441 );
442 } else {
443 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
459pub 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 if let Problem::BlessedVersionBroken { compatibility_issues } = &p {
491 for issue in compatibility_issues {
492 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 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 let _ = write_diff(
515 &diff,
516 "blessed".as_ref(),
517 "generated".as_ref(),
518 styles,
519 8,
522 false,
523 &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 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 let _ = write_diff(
599 &diff,
600 &path1,
601 &path2,
602 styles,
603 3,
606 true,
607 &mut indent_write::io::IndentWriter::new(
609 &indent,
610 std::io::stderr(),
611 ),
612 );
613 eprintln!();
614 }
615 }
616}
617
618pub 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
640pub(crate) mod headers {
642 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}