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>(
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 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
240pub 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 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 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 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 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#[derive(Clone, Copy, Debug)]
400pub enum CheckResult {
401 Success,
403 NeedsUpdate,
405 Failures,
407}
408
409impl CheckResult {
410 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
420fn 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 eprintln!(
432 "{:>HEADER_WIDTH$} {}",
433 FRESH.style(styles.success_header),
434 display_api_spec_version(api, version, styles, resolution),
435 );
436 } else {
437 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
453pub 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 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 let _ = write_diff(
549 &diff,
550 &path1,
551 &path2,
552 styles,
553 &mut indent_write::io::IndentWriter::new(
555 &indent,
556 std::io::stderr(),
557 ),
558 );
559 eprintln!();
560 }
561 }
562}
563
564pub 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
586pub(crate) mod headers {
588 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}