1use 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 #[clap(long, value_enum, global = true, default_value_t)]
23 pub(crate) color: ColorChoice,
24}
25
26impl OutputOpts {
27 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
66pub(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 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
235pub 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 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 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 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 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
398fn 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 eprintln!(
410 "{:>HEADER_WIDTH$} {}",
411 FRESH.style(styles.success_header),
412 display_api_spec_version(api, version, styles, resolution),
413 );
414 } else {
415 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
431pub 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 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 let _ = write_diff(
527 &diff,
528 &path1,
529 &path2,
530 styles,
531 &mut indent_write::io::IndentWriter::new(
533 &indent,
534 std::io::stderr(),
535 ),
536 );
537 eprintln!();
538 }
539 }
540}
541
542pub 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
564pub(crate) mod headers {
566 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}