use crate::{
FAILURE_EXIT_CODE, NEEDS_UPDATE_EXIT_CODE,
apis::{ManagedApi, ManagedApis},
compatibility::{
ApiCompatIssue, CompatIssueLocation, CompatRenderStatus,
FinalizedCompatDedupMap,
},
environment::{ErrorAccumulator, ResolvedEnv},
resolved::{
Fix, NonVersionProblem, Resolution, ResolutionKind, Resolved,
VersionProblem,
},
validation::CheckStale,
};
use anyhow::bail;
use camino::Utf8Path;
use clap::{Args, ColorChoice};
use headers::*;
use indent_write::fmt::IndentWriter;
use owo_colors::{OwoColorize, Style};
use similar::{ChangeTag, DiffableStr, TextDiff};
use std::{
fmt::{self, Write},
io,
process::ExitCode,
};
#[derive(Debug, Args)]
#[clap(next_help_heading = "Global options")]
pub struct OutputOpts {
#[clap(long, value_enum, global = true, default_value_t)]
pub(crate) color: ColorChoice,
}
impl OutputOpts {
pub(crate) fn use_color(&self, stream: supports_color::Stream) -> bool {
match self.color {
ColorChoice::Auto => supports_color::on_cached(stream).is_some(),
ColorChoice::Always => true,
ColorChoice::Never => false,
}
}
pub(crate) fn styles(&self, stream: supports_color::Stream) -> Styles {
let mut styles = Styles::default();
if self.use_color(stream) {
styles.colorize();
}
styles
}
}
#[derive(Clone, Debug, Default)]
pub(crate) struct Styles {
pub(crate) bold: Style,
pub(crate) dimmed: Style,
pub(crate) header: Style,
pub(crate) success_header: Style,
pub(crate) failure: Style,
pub(crate) failure_header: Style,
pub(crate) warning: Style,
pub(crate) warning_header: Style,
pub(crate) unchanged_header: Style,
pub(crate) filename: Style,
pub(crate) operation_id: Style,
pub(crate) diff_before: Style,
pub(crate) diff_after: Style,
}
impl Styles {
pub(crate) fn colorize(&mut self) {
self.bold = Style::new().bold();
self.dimmed = Style::new().dimmed();
self.header = Style::new().purple();
self.success_header = Style::new().green().bold();
self.failure = Style::new().red();
self.failure_header = Style::new().red().bold();
self.warning = Style::new().yellow();
self.warning_header = Style::new().yellow().bold();
self.unchanged_header = Style::new().blue().bold();
self.filename = Style::new().cyan();
self.operation_id = Style::new().purple();
self.diff_before = Style::new().red();
self.diff_after = Style::new().green();
}
}
pub(crate) fn write_diff<'diff, 'old, 'new, 'bufs, T>(
diff: &'diff TextDiff<'old, 'new, 'bufs, T>,
path1: &Utf8Path,
path2: &Utf8Path,
styles: &Styles,
context_radius: usize,
missing_newline_hint: bool,
out: &mut dyn io::Write,
) -> io::Result<()>
where
'diff: 'old + 'new + 'bufs,
T: DiffableStr + ?Sized,
{
let a = format_diff_path("a", path1);
writeln!(out, "{}", format!("--- {a}").style(styles.diff_before))?;
let b = format_diff_path("b", path2);
writeln!(out, "{}", format!("+++ {b}").style(styles.diff_after))?;
let mut udiff = diff.unified_diff();
udiff
.context_radius(context_radius)
.missing_newline_hint(missing_newline_hint);
for hunk in udiff.iter_hunks() {
for (idx, change) in hunk.iter_changes().enumerate() {
if idx == 0 {
writeln!(out, "{}", hunk.header())?;
}
let style = match change.tag() {
ChangeTag::Delete => styles.diff_before,
ChangeTag::Insert => styles.diff_after,
ChangeTag::Equal => Style::new(),
};
write!(out, "{}", change.tag().style(style))?;
write!(out, "{}", change.value().to_string_lossy().style(style))?;
if !diff.newline_terminated() {
writeln!(out)?;
}
if diff.newline_terminated() && change.missing_newline() {
writeln!(
out,
"{}",
MissingNewlineHint(hunk.missing_newline_hint())
)?;
}
}
}
Ok(())
}
fn format_diff_path(prefix: &str, path: &Utf8Path) -> String {
if std::path::MAIN_SEPARATOR == '/' {
format!("{prefix}/{path}")
} else {
format!("{prefix}/{}", path.as_str().replace('\\', "/"))
}
}
pub(crate) fn display_api_spec(api: &ManagedApi, styles: &Styles) -> String {
let mut versions = api.iter_versions_semver();
let count = versions.len();
let latest_version =
versions.next_back().expect("must be at least one version");
if api.is_versioned() {
format!(
"{} ({}, versioned ({} supported), latest = {})",
api.ident().style(styles.filename),
api.title(),
count,
latest_version,
)
} else {
format!(
"{} ({}, lockstep, v{})",
api.ident().style(styles.filename),
api.title(),
latest_version,
)
}
}
pub(crate) fn display_api_spec_version(
api: &ManagedApi,
version: &semver::Version,
styles: &Styles,
resolution: &Resolution<'_>,
) -> String {
if api.is_lockstep() {
assert_eq!(resolution.kind(), ResolutionKind::Lockstep);
format!(
"{} (lockstep v{}): {}",
api.ident().style(styles.filename),
version,
api.title(),
)
} else {
format!(
"{} (versioned v{} ({})): {}",
api.ident().style(styles.filename),
version,
resolution.kind(),
api.title(),
)
}
}
pub(crate) fn display_error(
error: &anyhow::Error,
failure_style: Style,
) -> impl fmt::Display + '_ {
struct DisplayError<'a> {
error: &'a anyhow::Error,
failure_style: Style,
}
impl fmt::Display for DisplayError<'_> {
fn fmt(&self, mut f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "{}", self.error.style(self.failure_style))?;
let mut source = self.error.source();
while let Some(curr) = source {
write!(f, "-> ")?;
writeln!(
IndentWriter::new_skip_initial(" ", &mut f),
"{}",
curr.style(self.failure_style),
)?;
source = curr.source();
}
Ok(())
}
}
DisplayError { error, failure_style }
}
struct MissingNewlineHint(bool);
impl fmt::Display for MissingNewlineHint {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.0 {
write!(f, "\n\\ No newline at end of file")?;
}
Ok(())
}
}
pub fn display_load_problems(
writer: &mut dyn io::Write,
error_accumulator: &ErrorAccumulator,
styles: &Styles,
) -> anyhow::Result<()> {
for w in error_accumulator.iter_warnings() {
writeln!(
writer,
"{:>HEADER_WIDTH$} {:#}",
WARNING.style(styles.warning_header),
w
)?;
}
let mut nerrors = 0;
for e in error_accumulator.iter_errors() {
nerrors += 1;
writeln!(
writer,
"{:>HEADER_WIDTH$} {:#}",
FAILURE.style(styles.failure_header),
e
)?;
}
if nerrors > 0 {
bail!(
"bailing out after {} {} above",
nerrors,
plural::errors(nerrors)
);
}
Ok(())
}
pub fn display_resolution(
writer: &mut dyn io::Write,
env: &ResolvedEnv,
apis: &ManagedApis,
resolved: &Resolved,
styles: &Styles,
) -> anyhow::Result<CheckResult> {
let total = resolved.nexpected_documents();
writeln!(
writer,
"{:>HEADER_WIDTH$} {} OpenAPI {}...",
CHECKING.style(styles.success_header),
total.style(styles.bold),
plural::documents(total),
)?;
let mut num_fresh = 0;
let mut num_stale = 0;
let mut num_failed = 0;
let mut num_non_version_problems = 0;
let dedup = resolved.build_compat_dedup_map();
for api in apis.iter_apis() {
let ident = api.ident();
for version in api.iter_versions_semver() {
let resolution = resolved
.resolution_for_api_version(ident, version)
.expect("resolution for all supported API versions");
if resolution.has_errors() {
num_failed += 1;
} else if resolution.has_problems() {
num_stale += 1;
} else {
num_fresh += 1;
}
summarize_one(
writer, env, api, version, resolution, styles, &dedup,
)?;
}
if !api.is_versioned() {
continue;
}
if let Some(symlink_problem) = resolved.symlink_problem(ident) {
if symlink_problem.is_fixable() {
num_non_version_problems += 1;
writeln!(
writer,
"{:>HEADER_WIDTH$} {} \"latest\" symlink",
STALE.style(styles.warning_header),
ident.style(styles.filename),
)?;
display_non_version_problems(
writer,
std::iter::once(symlink_problem),
styles,
)?;
} else {
num_failed += 1;
writeln!(
writer,
"{:>HEADER_WIDTH$} {} \"latest\" symlink",
FAILURE.style(styles.failure_header),
ident.style(styles.filename),
)?;
display_non_version_problems(
writer,
std::iter::once(symlink_problem),
styles,
)?;
}
} else {
num_fresh += 1;
writeln!(
writer,
"{:>HEADER_WIDTH$} {} \"latest\" symlink",
FRESH.style(styles.success_header),
ident.style(styles.filename),
)?;
}
}
let orphaned_and_unparseable: Vec<_> =
resolved.orphaned_and_unparseable().collect();
num_non_version_problems += if !orphaned_and_unparseable.is_empty() {
writeln!(
writer,
"\n{:>HEADER_WIDTH$} problems not associated with a specific \
supported API version:",
"Other".style(styles.warning_header),
)?;
let (fixable, unfixable): (
Vec<&NonVersionProblem>,
Vec<&NonVersionProblem>,
) = orphaned_and_unparseable.iter().partition(|p| p.is_fixable());
num_failed += unfixable.len();
display_non_version_problems(writer, orphaned_and_unparseable, styles)?;
fixable.len()
} else {
0
};
for n in resolved.notes() {
let initial_indent =
format!("{:>HEADER_WIDTH$} ", "Note".style(styles.warning_header));
let more_indent = " ".repeat(HEADER_WIDTH + " ".len());
writeln!(
writer,
"\n{}\n",
textwrap::fill(
&n.to_string(),
textwrap::Options::new(term_width())
.initial_indent(&initial_indent)
.subsequent_indent(&more_indent)
)
)?;
}
let status_header = if num_failed > 0 {
FAILURE.style(styles.failure_header)
} else if num_stale > 0 || num_non_version_problems > 0 {
STALE.style(styles.warning_header)
} else {
SUCCESS.style(styles.success_header)
};
writeln!(writer, "{:>HEADER_WIDTH$}", SEPARATOR)?;
writeln!(
writer,
"{:>HEADER_WIDTH$} {} {} checked: {} fresh, {} stale, {} failed, \
{} other {}",
status_header,
total.style(styles.bold),
plural::documents(total),
num_fresh.style(styles.bold),
num_stale.style(styles.bold),
num_failed.style(styles.bold),
num_non_version_problems.style(styles.bold),
plural::problems(num_non_version_problems),
)?;
if num_failed > 0 {
writeln!(
writer,
"{:>HEADER_WIDTH$} (fix failures, then run {} to update)",
"",
format!("{} generate", env.command).style(styles.bold)
)?;
Ok(CheckResult::Failures)
} else if num_stale > 0 || num_non_version_problems > 0 {
writeln!(
writer,
"{:>HEADER_WIDTH$} (run {} to update)",
"",
format!("{} generate", env.command).style(styles.bold)
)?;
Ok(CheckResult::NeedsUpdate)
} else {
Ok(CheckResult::Success)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CheckResult {
Success,
NeedsUpdate,
Failures,
}
impl CheckResult {
pub fn to_exit_code(self) -> ExitCode {
match self {
CheckResult::Success => ExitCode::SUCCESS,
CheckResult::NeedsUpdate => NEEDS_UPDATE_EXIT_CODE.into(),
CheckResult::Failures => FAILURE_EXIT_CODE.into(),
}
}
}
fn summarize_one(
writer: &mut dyn io::Write,
env: &ResolvedEnv,
api: &ManagedApi,
version: &semver::Version,
resolution: &Resolution<'_>,
styles: &Styles,
dedup: &FinalizedCompatDedupMap<'_>,
) -> io::Result<()> {
let problems: Vec<_> = resolution.problems().collect();
if problems.is_empty() {
writeln!(
writer,
"{:>HEADER_WIDTH$} {}",
FRESH.style(styles.success_header),
display_api_spec_version(api, version, styles, resolution),
)?;
} else {
writeln!(
writer,
"{:>HEADER_WIDTH$} {}",
if resolution.has_errors() {
FAILURE.style(styles.failure_header)
} else {
assert!(resolution.has_problems());
STALE.style(styles.warning_header)
},
display_api_spec_version(api, version, styles, resolution),
)?;
let compat_ctx = CompatDisplayContext {
dedup,
current: CompatIssueLocation { api: api.ident(), version },
};
display_version_problems(writer, env, problems, styles, compat_ctx)?;
}
Ok(())
}
pub(crate) struct CompatDisplayContext<'a> {
pub(crate) dedup: &'a FinalizedCompatDedupMap<'a>,
pub(crate) current: CompatIssueLocation<'a>,
}
pub(crate) fn display_version_problems<'a, T>(
writer: &mut dyn io::Write,
env: &ResolvedEnv,
problems: T,
styles: &Styles,
compat_ctx: CompatDisplayContext<'_>,
) -> io::Result<()>
where
T: IntoIterator<Item = &'a VersionProblem<'a>>,
{
for p in problems.into_iter() {
write_problem_header(writer, p, p.is_fixable(), styles)?;
let issue_indent = " ".repeat(HEADER_WIDTH - "error".len());
let issues = p.compatibility_issues();
for issue in issues {
let status = compat_ctx.dedup.status_for(issue, compat_ctx.current);
display_compat_issue(
&mut *writer,
issue,
&issue_indent,
styles,
status,
)?;
}
if !issues.is_empty() {
writeln!(writer)?;
}
if let VersionProblem::BlessedLatestVersionBytewiseMismatch {
blessed,
generated,
} = p
{
let diff =
TextDiff::from_lines(blessed.contents(), generated.contents());
let path1 =
env.openapi_abs_dir().join(blessed.spec_file_name().path());
let path2 =
env.openapi_abs_dir().join(generated.spec_file_name().path());
let indent = " ".repeat(HEADER_WIDTH + 1);
write_diff(
&diff,
&path1,
&path2,
styles,
3,
true,
&mut indent_write::io::IndentWriter::new(&indent, &mut *writer),
)?;
}
let Some(fix) = p.fix() else {
continue;
};
write_fix_summary(writer, &fix, styles)?;
let do_diff = match p {
VersionProblem::LockstepStale { found, generated } => {
let diff = TextDiff::from_lines(
found.contents(),
generated.contents(),
);
let path1 =
env.openapi_abs_dir().join(found.spec_file_name().path());
let path2 = env
.openapi_abs_dir()
.join(generated.spec_file_name().path());
Some((diff, path1, path2))
}
VersionProblem::ExtraFileStale {
check_stale:
CheckStale::Modified { full_path, actual, expected },
..
} => {
let diff = TextDiff::from_lines(actual, expected);
Some((diff, full_path.clone(), full_path.clone()))
}
VersionProblem::LocalVersionStale { spec_files, generated }
if spec_files.len() == 1 =>
{
let diff = TextDiff::from_lines(
spec_files[0].contents(),
generated.contents(),
);
let path1 = env
.openapi_abs_dir()
.join(spec_files[0].spec_file_name().path());
let path2 = env
.openapi_abs_dir()
.join(generated.spec_file_name().path());
Some((diff, path1, path2))
}
_ => None,
};
if let Some((diff, path1, path2)) = do_diff {
let indent = " ".repeat(HEADER_WIDTH + 1);
write_diff(
&diff,
&path1,
&path2,
styles,
3,
true,
&mut indent_write::io::IndentWriter::new(&indent, &mut *writer),
)?;
writeln!(writer)?;
}
}
Ok(())
}
pub fn display_non_version_problems<'a, T>(
writer: &mut dyn io::Write,
problems: T,
styles: &Styles,
) -> io::Result<()>
where
T: IntoIterator<Item = &'a NonVersionProblem<'a>>,
{
for p in problems.into_iter() {
write_problem_header(writer, p, p.is_fixable(), styles)?;
if let Some(fix) = p.fix() {
write_fix_summary(writer, &fix, styles)?;
}
}
Ok(())
}
fn write_problem_header(
writer: &mut dyn io::Write,
error: &dyn std::error::Error,
is_fixable: bool,
styles: &Styles,
) -> io::Result<()> {
let first_indent = format!(
"{:>HEADER_WIDTH$}: ",
if is_fixable {
"problem".style(styles.warning_header)
} else {
"error".style(styles.failure_header)
}
);
let more_indent = " ".repeat(HEADER_WIDTH + 2);
writeln!(
writer,
"{}",
textwrap::fill(
&InlineErrorChain::new(error).to_string(),
textwrap::Options::new(term_width())
.initial_indent(&first_indent)
.subsequent_indent(&more_indent)
)
)
}
fn write_fix_summary(
writer: &mut dyn io::Write,
fix: &Fix<'_>,
styles: &Styles,
) -> io::Result<()> {
let first_indent =
format!("{:>HEADER_WIDTH$}: ", "fix".style(styles.warning_header));
let more_indent = " ".repeat(HEADER_WIDTH + 2);
let fix_str = fix.to_string();
for s in fix_str.trim_end().split("\n") {
writeln!(
writer,
"{}",
textwrap::fill(
&format!("will {}", s),
textwrap::Options::new(term_width())
.initial_indent(&first_indent)
.subsequent_indent(&more_indent)
)
)?;
}
Ok(())
}
fn display_compat_issue(
writer: &mut dyn io::Write,
issue: &ApiCompatIssue,
body_indent: &str,
styles: &Styles,
status: CompatRenderStatus,
) -> io::Result<()> {
writeln!(writer)?;
let wrap_width =
term_width().saturating_sub(textwrap::core::display_width(body_indent));
let mut buf = String::new();
write!(
IndentWriter::new(body_indent, &mut buf),
"{}",
issue.display(styles, status).with_wrap_width(wrap_width),
)
.expect("writing to a String never fails");
writeln!(writer, "{buf}")?;
match status {
CompatRenderStatus::FirstOccurrence { .. } => {
let blessed_json = issue.blessed_json();
let generated_json = issue.generated_json();
let diff = TextDiff::from_lines(&blessed_json, &generated_json);
write_diff(
&diff,
"blessed".as_ref(),
"generated".as_ref(),
styles,
8,
false,
&mut indent_write::io::IndentWriter::new(body_indent, writer),
)
}
CompatRenderStatus::Duplicate { .. } => {
Ok(())
}
}
}
pub struct InlineErrorChain<'a>(&'a dyn std::error::Error);
impl<'a> InlineErrorChain<'a> {
pub fn new(error: &'a dyn std::error::Error) -> Self {
Self(error)
}
}
impl fmt::Display for InlineErrorChain<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)?;
let mut cause = self.0.source();
while let Some(source) = cause {
write!(f, ": {source}")?;
cause = source.source();
}
Ok(())
}
}
pub(crate) fn term_width() -> usize {
match std::env::var("OPENAPI_MGR_TERM_WIDTH") {
Ok(s) => s.parse().unwrap_or_else(|err| {
panic!("OPENAPI_MGR_TERM_WIDTH={s:?} is not a valid width: {err}")
}),
Err(_) => textwrap::termwidth(),
}
}
pub(crate) mod headers {
pub(crate) const HEADER_WIDTH: usize = 12;
pub(crate) static SEPARATOR: &str = "-------";
pub(crate) static CHECKING: &str = "Checking";
pub(crate) static GENERATING: &str = "Generating";
pub(crate) static FRESH: &str = "Fresh";
pub(crate) static STALE: &str = "Stale";
pub(crate) static UNCHANGED: &str = "Unchanged";
pub(crate) static SUCCESS: &str = "Success";
pub(crate) static FAILURE: &str = "Failure";
pub(crate) static WARNING: &str = "Warning";
}
pub(crate) mod plural {
pub(crate) fn files(count: usize) -> &'static str {
if count == 1 { "file" } else { "files" }
}
pub(crate) fn changes(count: usize) -> &'static str {
if count == 1 { "change" } else { "changes" }
}
pub(crate) fn documents(count: usize) -> &'static str {
if count == 1 { "document" } else { "documents" }
}
pub(crate) fn errors(count: usize) -> &'static str {
if count == 1 { "error" } else { "errors" }
}
pub(crate) fn paths(count: usize) -> &'static str {
if count == 1 { "path" } else { "paths" }
}
pub(crate) fn problems(count: usize) -> &'static str {
if count == 1 { "problem" } else { "problems" }
}
pub(crate) fn schemas(count: usize) -> &'static str {
if count == 1 { "schema" } else { "schemas" }
}
}