mod config;
mod diff_inputs;
use crate::{costs, diff, markdown, parser, report, selection, stats};
pub use config::Cli;
use std::fs::File;
use std::io::{BufWriter, Write};
use std::path::Path;
use anyhow::{Context, Result, bail};
use clap::CommandFactory;
use log::info;
use self::config::{Commands, DiffArgs, DiffFormatArg, build_config};
use crate::markdown::generate_markdown_with_notices;
use crate::models::{ApiDocumentation, DocConfig};
use parser::{parse_openapi, parse_openapi_bytes};
fn write_output<W: Write>(
writer: &mut W,
api_doc: &ApiDocumentation,
config: &DocConfig,
notice: &mut dyn FnMut(&str),
) -> Result<()> {
if !config.include_report {
return generate_markdown_with_notices(writer, api_doc, config, notice)
.context("Failed to generate markdown");
}
let mut body = Vec::new();
generate_markdown_with_notices(&mut body, api_doc, config, notice)
.context("Failed to generate markdown")?;
while body.last() == Some(&b'\n') {
body.pop();
}
body.push(b'\n');
writer
.write_all(&body)
.context("Failed to write documentation")?;
let hygiene = report::analyze(api_doc, config);
report::write_report(writer, &hygiene).context("Failed to write spec hygiene report")?;
Ok(())
}
fn run_diff<W: Write>(args: &DiffArgs, output: &mut W) -> Result<bool> {
let (old_input, new_input) = diff_inputs::read(args)?;
let old_sha = diff::json::sha256_hex(&old_input.bytes);
let new_sha = diff::json::sha256_hex(&new_input.bytes);
if old_input.is_protobuf() || new_input.is_protobuf() {
anyhow::ensure!(
old_input.is_protobuf() && new_input.is_protobuf(),
"Cannot compare different contract formats"
);
anyhow::ensure!(
args.contract == 2,
"Protobuf requires --contract 2; --contract 1 is OpenAPI-only"
);
let old = old_input.protobuf(&args.proto_paths)?;
let new = new_input.protobuf(&args.proto_paths)?;
let changes = crate::contract::protobuf::diff(&old, &new)?;
let breaking = changes.iter().any(|c| {
c.severity == crate::contract::Severity::Breaking
&& match args.breaking {
config::BreakingArg::All => true,
config::BreakingArg::Wire => c.impacts.iter().any(|i| i == "wire"),
config::BreakingArg::Source => {
c.impacts.iter().any(|i| i == "source" || i == "wire_json")
}
}
});
let document = crate::contract::json::to_json(
crate::contract::protobuf::adapt(&old),
crate::contract::protobuf::adapt(&new),
changes,
&old_sha,
&new_sha,
None,
);
let mut writer: Box<dyn Write + '_> = if let Some(path) = &args.output {
Box::new(BufWriter::new(create_output_file(path)?))
} else {
Box::new(output)
};
match args.format {
DiffFormatArg::Json => write_json_diff(&mut writer, &document)?,
DiffFormatArg::Markdown => crate::contract::protobuf::write_diff(
&mut writer,
&document.changes,
args.report,
&old,
&new,
)?,
};
return Ok(args.fail_on_breaking && breaking);
}
let old = old_input.parse()?;
let new = new_input.parse()?;
let spec_diff = diff::diff(&old, &new);
let deltas = if args.report {
Some(diff::compute_deltas(&old, &new).context("Failed to compute deltas")?)
} else {
None
};
let neutral = if args.format == DiffFormatArg::Json && args.contract == 2 {
Some(crate::contract::json::openapi(
&old,
&new,
deltas.as_ref(),
&old_sha,
&new_sha,
)?)
} else {
None
};
match (&args.output, args.format) {
(Some(output_path), DiffFormatArg::Markdown) => {
let mut writer = BufWriter::new(create_output_file(output_path)?);
diff::write_diff(&mut writer, &spec_diff, deltas.as_ref())
.context("Failed to write diff")?;
writer.flush().context("Failed to write diff")?;
info!("Diff written to: {:?}", output_path);
}
(Some(output_path), DiffFormatArg::Json) => {
let document = diff::json::to_json(&spec_diff, deltas.as_ref(), &old_sha, &new_sha);
let mut writer = BufWriter::new(create_output_file(output_path)?);
if let Some(neutral) = &neutral {
write_json_diff(&mut writer, neutral)?;
} else {
write_json_diff(&mut writer, &document)?;
}
info!("Diff written to: {:?}", output_path);
}
(None, DiffFormatArg::Markdown) => {
diff::write_diff(output, &spec_diff, deltas.as_ref())
.context("Failed to write diff")?;
output.flush().context("Failed to write diff")?;
}
(None, DiffFormatArg::Json) => {
let document = diff::json::to_json(&spec_diff, deltas.as_ref(), &old_sha, &new_sha);
if let Some(neutral) = &neutral {
write_json_diff(output, neutral)?;
} else {
write_json_diff(output, &document)?;
}
}
}
if args.fail_on_breaking
&& neutral
.as_ref()
.map_or_else(|| spec_diff.has_breaking(), |d| d.has_breaking())
{
return Ok(true);
}
Ok(false)
}
fn file_extension(path: &Path) -> String {
path.extension()
.and_then(|ext| ext.to_str())
.unwrap_or_default()
.to_string()
}
fn write_json_diff<W: Write, T: serde::Serialize>(writer: &mut W, document: &T) -> Result<()> {
serde_json::to_writer_pretty(&mut *writer, document).context("Failed to write diff")?;
writer.write_all(b"\n").context("Failed to write diff")?;
writer.flush().context("Failed to write diff")
}
fn warn_filtered_out_selectors(
api_doc: &ApiDocumentation,
config: &DocConfig,
notice: &mut dyn FnMut(&str),
) {
for warning in selection::filtered_out_warnings(api_doc, config) {
notice(&warning);
}
}
fn create_output_file(path: &Path) -> Result<File> {
File::create(path).with_context(|| format!("Failed to create output file: {:?}", path))
}
pub fn run<W: Write>(cli: &Cli, output: &mut W, notice: &mut dyn FnMut(&str)) -> Result<bool> {
match &cli.command {
Some(Commands::Completions { shell }) => {
let mut completions = Vec::new();
clap_complete::generate(*shell, &mut Cli::command(), "vimanam", &mut completions);
output
.write_all(&completions)
.context("Failed to write shell completions")?;
output
.flush()
.context("Failed to write shell completions")?;
return Ok(false);
}
Some(Commands::Diff(args)) => return run_diff(args, output),
None => {}
}
let Some(input) = &cli.input else {
bail!("missing input file; run `vimanam --help` for usage");
};
let config = build_config(cli, notice);
let api_doc = if crate::contract::protobuf::is_input(input) {
crate::contract::protobuf::documentation(&crate::contract::protobuf::Document::read(
input,
&cli.proto_paths,
)?)
} else {
parse_openapi(input).with_context(|| format!("Failed to parse OpenAPI file: {input:?}"))?
};
selection::validate(&api_doc, &config)?;
if cli.stats {
let stats = stats::compute(&api_doc, &config).context("Failed to compute stats")?;
stats::write_stats(output, &stats).context("Failed to write stats")?;
warn_filtered_out_selectors(&api_doc, &config, notice);
return Ok(false);
}
if cli.costs {
let costs = costs::compute(&api_doc, &config).context("Failed to compute token costs")?;
costs::write_costs(output, &costs).context("Failed to write token costs")?;
warn_filtered_out_selectors(&api_doc, &config, notice);
return Ok(false);
}
let tree_layout = cli
.split
.map(|split| markdown::split::TreeLayout::Split(split.into()))
.or_else(|| cli.output_mode.map(|_| markdown::split::TreeLayout::Skill));
if let Some(layout) = tree_layout {
markdown::split::write_tree(
cli.output.as_ref().expect("clap requires --output"),
input,
&api_doc,
&config,
layout,
cli.overview_max_tokens,
notice,
)?;
warn_filtered_out_selectors(&api_doc, &config, notice);
return Ok(false);
}
if let Some(output_path) = &cli.output {
let mut writer = BufWriter::new(create_output_file(output_path)?);
write_output(&mut writer, &api_doc, &config, notice)?;
info!("Documentation written to: {:?}", output_path);
} else {
write_output(output, &api_doc, &config, notice)?;
}
warn_filtered_out_selectors(&api_doc, &config, notice);
Ok(false)
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
use std::io;
struct FailingWriter {
bytes: Vec<u8>,
fail_write: bool,
fail_flush: bool,
}
impl Write for FailingWriter {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
if self.fail_write {
return Err(io::Error::new(io::ErrorKind::BrokenPipe, "write failed"));
}
self.bytes.extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> io::Result<()> {
if self.fail_flush {
return Err(io::Error::new(io::ErrorKind::BrokenPipe, "flush failed"));
}
Ok(())
}
}
fn completions_cli() -> Cli {
Cli::try_parse_from(["vimanam", "completions", "bash"]).unwrap()
}
#[test]
fn completions_return_write_errors() {
let mut writer = FailingWriter {
bytes: Vec::new(),
fail_write: true,
fail_flush: false,
};
let error = run(&completions_cli(), &mut writer, &mut |_| {}).unwrap_err();
assert_eq!(error.to_string(), "Failed to write shell completions");
assert_eq!(
error
.source()
.unwrap()
.downcast_ref::<io::Error>()
.unwrap()
.kind(),
io::ErrorKind::BrokenPipe
);
}
#[test]
fn completions_return_flush_errors() {
let mut writer = FailingWriter {
bytes: Vec::new(),
fail_write: false,
fail_flush: true,
};
let error = run(&completions_cli(), &mut writer, &mut |_| {}).unwrap_err();
assert_eq!(error.to_string(), "Failed to write shell completions");
assert_eq!(
error
.source()
.unwrap()
.downcast_ref::<io::Error>()
.unwrap()
.kind(),
io::ErrorKind::BrokenPipe
);
assert!(!writer.bytes.is_empty());
}
#[test]
fn completions_preserve_generated_bytes() {
let cli = completions_cli();
let mut expected = Vec::new();
let Some(Commands::Completions { shell }) = &cli.command else {
unreachable!("the parsed command is completions");
};
clap_complete::generate(*shell, &mut Cli::command(), "vimanam", &mut expected);
let mut actual = Vec::new();
assert!(!run(&cli, &mut actual, &mut |_| {}).unwrap());
assert_eq!(actual, expected);
}
}