forked from Crockan/MercuryToolbox
chore(release): prepare public source release
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "asmapi"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
readme.workspace = true
|
||||
publish.workspace = true
|
||||
description = "Diff managed assembly API surfaces with AI-friendly output."
|
||||
keywords.workspace = true
|
||||
categories.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../common", default-features = false }
|
||||
lexopt.workspace = true
|
||||
managed = { path = "../managed" }
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd.workspace = true
|
||||
predicates.workspace = true
|
||||
serde_json.workspace = true
|
||||
@@ -0,0 +1,511 @@
|
||||
//! The `asmapi` command diffs managed assembly API surfaces.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::fmt::Write as _;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use common::{
|
||||
CliError, CommonArgs, ExitCode, RenderMode, parse_color_choice, parse_format_choice,
|
||||
print_quick_help_error, print_structured, print_text,
|
||||
};
|
||||
use lexopt::prelude::{Long, Short, Value as ArgValue};
|
||||
use managed::{ApiDiffQuery, ApiDiffReport, ApiVisibilityScope, diff_assembly_api};
|
||||
|
||||
const HELP: &str = "\
|
||||
Diff managed assembly API surfaces.
|
||||
|
||||
Usage:
|
||||
asmapi [OPTIONS] <SUBCOMMAND> [ARGS...]
|
||||
|
||||
Subcommands:
|
||||
diff Compare old and new managed assembly APIs
|
||||
|
||||
Shared Options:
|
||||
--format <FORMAT> Structured output format: text, json, toon
|
||||
--json Shortcut for --format json
|
||||
--toon Shortcut for --format toon
|
||||
--color <WHEN> Control ANSI color output: auto, never
|
||||
--quiet Suppress non-essential status output
|
||||
-h, --help Show this help text
|
||||
-V, --version Show the command version
|
||||
|
||||
Examples:
|
||||
asmapi diff old\\0Harmony.dll new\\0Harmony.dll
|
||||
asmapi diff Rocket.API.old.dll Rocket.API.new.dll --json
|
||||
asmapi diff old.dll new.dll --visibility all
|
||||
";
|
||||
|
||||
const DIFF_HELP: &str = "\
|
||||
Compare old and new managed assembly APIs.
|
||||
|
||||
Usage:
|
||||
asmapi diff [OPTIONS] <OLD_ASSEMBLY> <NEW_ASSEMBLY>
|
||||
|
||||
Options:
|
||||
--format <FORMAT> Structured output format: text, json, toon
|
||||
--json Shortcut for --format json
|
||||
--toon Shortcut for --format toon
|
||||
--color <WHEN> Control ANSI color output: auto, never
|
||||
--quiet Suppress non-essential status output
|
||||
--visibility <SCOPE> API scope: public, internal, all (default: public)
|
||||
--include-special Include special-name methods such as property accessors
|
||||
--no-missing-method-risks Suppress MissingMethodException risk rows
|
||||
-h, --help Show this help text
|
||||
-V, --version Show the command version
|
||||
|
||||
Examples:
|
||||
asmapi diff old\\0Harmony.dll new\\0Harmony.dll
|
||||
asmapi diff Rocket.API.old.dll Rocket.API.new.dll --json
|
||||
asmapi diff old.dll new.dll --visibility all
|
||||
";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Cli {
|
||||
common: CommonArgs,
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum Command {
|
||||
Diff(DiffArgs),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct DiffArgs {
|
||||
old_assembly: PathBuf,
|
||||
new_assembly: PathBuf,
|
||||
query: ApiDiffQuery,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ParseOutcome {
|
||||
Help(&'static str),
|
||||
Version,
|
||||
Run,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum ParsedCommand {
|
||||
Outcome(ParseOutcome),
|
||||
Command(Command),
|
||||
}
|
||||
|
||||
/// Parses CLI arguments and returns a process exit code.
|
||||
#[must_use]
|
||||
pub fn main_entry() -> i32 {
|
||||
match parse_cli_from(std::env::args_os()) {
|
||||
Ok((ParseOutcome::Help(help), _)) => match print_text(help) {
|
||||
Ok(()) => ExitCode::Success.as_i32(),
|
||||
Err(error) => {
|
||||
eprintln!("error: {error}");
|
||||
error.exit_code().as_i32()
|
||||
}
|
||||
},
|
||||
Ok((ParseOutcome::Version, _)) => {
|
||||
println!("asmapi {}", env!("CARGO_PKG_VERSION"));
|
||||
ExitCode::Success.as_i32()
|
||||
}
|
||||
Ok((ParseOutcome::Run, cli)) => match run(&cli) {
|
||||
Ok(code) => code.as_i32(),
|
||||
Err(error) => {
|
||||
print_quick_help_error(&error, HELP);
|
||||
error.exit_code().as_i32()
|
||||
}
|
||||
},
|
||||
Err(error) => {
|
||||
print_quick_help_error(&error, HELP);
|
||||
error.exit_code().as_i32()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_cli_from<I, T>(args: I) -> Result<(ParseOutcome, Cli), CliError>
|
||||
where
|
||||
I: IntoIterator<Item = T>,
|
||||
T: Into<OsString>,
|
||||
{
|
||||
let mut parser = lexopt::Parser::from_iter(args);
|
||||
let mut common = CommonArgs::default();
|
||||
let subcommand = loop {
|
||||
let Some(argument) = parser
|
||||
.next()
|
||||
.map_err(|error| CliError::usage(error.to_string()))?
|
||||
else {
|
||||
return Err(CliError::usage("missing subcommand; expected diff"));
|
||||
};
|
||||
match argument {
|
||||
Long("help") | Short('h') => {
|
||||
return Ok((ParseOutcome::Help(HELP), placeholder(common)));
|
||||
}
|
||||
Long("version") | Short('V') => {
|
||||
return Ok((ParseOutcome::Version, placeholder(common)));
|
||||
}
|
||||
Long("json") => common.set_render_mode(RenderMode::Json),
|
||||
Long("toon") => common.set_render_mode(RenderMode::Toon),
|
||||
Long("format") => {
|
||||
let value = parser_value_string(&mut parser, "--format")?;
|
||||
common.set_render_mode(parse_format_choice(&value)?);
|
||||
}
|
||||
Long("color") => {
|
||||
common.color = parse_color_choice(&parser_value_string(&mut parser, "--color")?)?;
|
||||
}
|
||||
Long("quiet") => common.quiet = true,
|
||||
ArgValue(value) => {
|
||||
break value.into_string().map_err(|invalid| {
|
||||
CliError::usage(format!(
|
||||
"subcommand expects UTF-8 text, got '{}'",
|
||||
invalid.to_string_lossy()
|
||||
))
|
||||
})?;
|
||||
}
|
||||
_ => {
|
||||
return Err(CliError::usage(
|
||||
"unsupported argument; use --help to see available options",
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let parsed = match subcommand.as_str() {
|
||||
"diff" => parse_diff_command(&mut parser, &mut common)?,
|
||||
_ => return Err(CliError::usage("unsupported subcommand; expected diff")),
|
||||
};
|
||||
let command = match parsed {
|
||||
ParsedCommand::Outcome(outcome) => return Ok((outcome, placeholder(common))),
|
||||
ParsedCommand::Command(command) => command,
|
||||
};
|
||||
|
||||
Ok((ParseOutcome::Run, Cli { common, command }))
|
||||
}
|
||||
|
||||
fn placeholder(common: CommonArgs) -> Cli {
|
||||
Cli {
|
||||
common,
|
||||
command: Command::Diff(DiffArgs {
|
||||
old_assembly: PathBuf::new(),
|
||||
new_assembly: PathBuf::new(),
|
||||
query: ApiDiffQuery::default(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_diff_command(
|
||||
parser: &mut lexopt::Parser,
|
||||
common: &mut CommonArgs,
|
||||
) -> Result<ParsedCommand, CliError> {
|
||||
let mut query = ApiDiffQuery::default();
|
||||
let mut paths = Vec::new();
|
||||
while let Some(argument) = parser
|
||||
.next()
|
||||
.map_err(|error| CliError::usage(error.to_string()))?
|
||||
{
|
||||
match argument {
|
||||
Long("help") | Short('h') => {
|
||||
return Ok(ParsedCommand::Outcome(ParseOutcome::Help(DIFF_HELP)));
|
||||
}
|
||||
Long("version") | Short('V') => {
|
||||
return Ok(ParsedCommand::Outcome(ParseOutcome::Version));
|
||||
}
|
||||
Long("json") => common.set_render_mode(RenderMode::Json),
|
||||
Long("toon") => common.set_render_mode(RenderMode::Toon),
|
||||
Long("format") => {
|
||||
let value = parser_value_string(parser, "--format")?;
|
||||
common.set_render_mode(parse_format_choice(&value)?);
|
||||
}
|
||||
Long("color") => {
|
||||
common.color = parse_color_choice(&parser_value_string(parser, "--color")?)?;
|
||||
}
|
||||
Long("quiet") => common.quiet = true,
|
||||
Long("visibility") => {
|
||||
query.visibility = parse_visibility(&parser_value_string(parser, "--visibility")?)?;
|
||||
}
|
||||
Long("include-special") => query.include_special = true,
|
||||
Long("no-missing-method-risks") => query.include_missing_method_risks = false,
|
||||
ArgValue(value) => {
|
||||
if paths.len() == 2 {
|
||||
return Err(CliError::usage(
|
||||
"diff requires exactly two assembly paths: <OLD_ASSEMBLY> <NEW_ASSEMBLY>",
|
||||
));
|
||||
}
|
||||
paths.push(PathBuf::from(value));
|
||||
}
|
||||
_ => {
|
||||
return Err(CliError::usage(
|
||||
"unsupported argument; use --help to see available options",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if paths.len() != 2 {
|
||||
return Err(CliError::usage(
|
||||
"diff requires exactly two assembly paths: <OLD_ASSEMBLY> <NEW_ASSEMBLY>",
|
||||
));
|
||||
}
|
||||
Ok(ParsedCommand::Command(Command::Diff(DiffArgs {
|
||||
old_assembly: paths.remove(0),
|
||||
new_assembly: paths.remove(0),
|
||||
query,
|
||||
})))
|
||||
}
|
||||
|
||||
fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result<String, CliError> {
|
||||
let value = parser
|
||||
.value()
|
||||
.map_err(|error| CliError::usage(error.to_string()))?;
|
||||
value.into_string().map_err(|invalid| {
|
||||
CliError::usage(format!(
|
||||
"{flag} expects UTF-8 text, got '{}'",
|
||||
invalid.to_string_lossy()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_visibility(value: &str) -> Result<ApiVisibilityScope, CliError> {
|
||||
match value {
|
||||
"public" => Ok(ApiVisibilityScope::Public),
|
||||
"internal" => Ok(ApiVisibilityScope::Internal),
|
||||
"all" => Ok(ApiVisibilityScope::All),
|
||||
other => Err(CliError::usage(format!(
|
||||
"invalid --visibility value '{other}'; expected public, internal, or all"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn run(cli: &Cli) -> Result<ExitCode, CliError> {
|
||||
match &cli.command {
|
||||
Command::Diff(args) => {
|
||||
let report = diff_assembly_api(&args.old_assembly, &args.new_assembly, &args.query)
|
||||
.map_err(|error| CliError::runtime(error.to_string()))?;
|
||||
render_report(&report, cli.common.render_mode())?;
|
||||
Ok(ExitCode::Success)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_report(report: &ApiDiffReport, mode: RenderMode) -> Result<(), CliError> {
|
||||
match mode {
|
||||
RenderMode::Json | RenderMode::Toon => print_structured(report, mode),
|
||||
RenderMode::Text => print_text(render_text_report(report)),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_text_report(report: &ApiDiffReport) -> String {
|
||||
let mut output = String::new();
|
||||
let _ = writeln!(
|
||||
output,
|
||||
"asmapi diff old={} new={} visibility={:?} removed_types={} added_types={} removed_methods={} added_methods={} signature_changed={} missing_method_risks={}",
|
||||
report.old_assembly.assembly_name,
|
||||
report.new_assembly.assembly_name,
|
||||
report.visibility,
|
||||
report.summary.removed_types,
|
||||
report.summary.added_types,
|
||||
report.summary.removed_methods,
|
||||
report.summary.added_methods,
|
||||
report.summary.signature_changed_methods,
|
||||
report.summary.missing_method_risks
|
||||
);
|
||||
render_type_section(&mut output, "removed_types", &report.removed_types);
|
||||
render_type_section(&mut output, "added_types", &report.added_types);
|
||||
render_method_section(&mut output, "removed_methods", &report.removed_methods);
|
||||
render_method_section(&mut output, "added_methods", &report.added_methods);
|
||||
render_method_section(
|
||||
&mut output,
|
||||
"signature_changed",
|
||||
&report.signature_changed_methods,
|
||||
);
|
||||
if !report.missing_method_risks.is_empty() {
|
||||
let _ = writeln!(output, "missing_method_risks:");
|
||||
for row in &report.missing_method_risks {
|
||||
let _ = writeln!(
|
||||
output,
|
||||
" - {}::{} old_signature=\"{}\" old_assembly={} reason={}",
|
||||
row.type_name, row.method_name, row.old_signature, row.old_assembly, row.reason
|
||||
);
|
||||
}
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
fn render_type_section(output: &mut String, title: &str, rows: &[managed::ApiTypeChange]) {
|
||||
if rows.is_empty() {
|
||||
return;
|
||||
}
|
||||
let _ = writeln!(output, "{title}:");
|
||||
for row in rows {
|
||||
let _ = writeln!(
|
||||
output,
|
||||
" - {} kind={} visibility={}",
|
||||
row.type_name, row.kind, row.visibility
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_method_section(output: &mut String, title: &str, rows: &[managed::ApiMethodChange]) {
|
||||
if rows.is_empty() {
|
||||
return;
|
||||
}
|
||||
let _ = writeln!(output, "{title}:");
|
||||
for row in rows {
|
||||
let old = if row.old_signatures.is_empty() {
|
||||
"-"
|
||||
} else {
|
||||
&row.old_signatures[0]
|
||||
};
|
||||
let new = if row.new_signatures.is_empty() {
|
||||
"-"
|
||||
} else {
|
||||
&row.new_signatures[0]
|
||||
};
|
||||
let _ = writeln!(
|
||||
output,
|
||||
" - {}::{} old=\"{}\" new=\"{}\"",
|
||||
row.type_name, row.method_name, old, new
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use managed::{
|
||||
ApiDiffSummary, ApiMethodChange, ApiTypeChange, AssemblyDescriptor, MissingMethodRisk,
|
||||
};
|
||||
|
||||
fn assembly(name: &str) -> AssemblyDescriptor {
|
||||
AssemblyDescriptor {
|
||||
path: PathBuf::from(format!("{name}.dll")),
|
||||
assembly_name: name.to_string(),
|
||||
assembly_version: Some("1.2.3.4".to_string()),
|
||||
runtime_version: "v4.0.30319".to_string(),
|
||||
is_il_only: true,
|
||||
is_library: true,
|
||||
is_strong_name_signed: false,
|
||||
public_key_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cli_covers_top_level_and_diff_outcomes() {
|
||||
let (outcome, _) = parse_cli_from(["asmapi", "--help"]).expect("top help");
|
||||
assert_eq!(outcome, ParseOutcome::Help(HELP));
|
||||
let (outcome, _) = parse_cli_from(["asmapi", "--version"]).expect("top version");
|
||||
assert_eq!(outcome, ParseOutcome::Version);
|
||||
let (outcome, _) = parse_cli_from(["asmapi", "diff", "--help"]).expect("diff help");
|
||||
assert_eq!(outcome, ParseOutcome::Help(DIFF_HELP));
|
||||
let error = parse_cli_from(["asmapi"]).expect_err("missing subcommand");
|
||||
assert!(error.to_string().contains("missing subcommand"));
|
||||
|
||||
let (outcome, cli) = parse_cli_from([
|
||||
"asmapi",
|
||||
"--json",
|
||||
"--color",
|
||||
"never",
|
||||
"diff",
|
||||
"--visibility",
|
||||
"all",
|
||||
"--include-special",
|
||||
"--no-missing-method-risks",
|
||||
"old.dll",
|
||||
"new.dll",
|
||||
])
|
||||
.expect("diff args");
|
||||
assert_eq!(outcome, ParseOutcome::Run);
|
||||
assert_eq!(cli.common.render_mode(), RenderMode::Json);
|
||||
let Command::Diff(args) = cli.command;
|
||||
assert_eq!(args.old_assembly, PathBuf::from("old.dll"));
|
||||
assert_eq!(args.new_assembly, PathBuf::from("new.dll"));
|
||||
assert_eq!(args.query.visibility, ApiVisibilityScope::All);
|
||||
assert!(args.query.include_special);
|
||||
assert!(!args.query.include_missing_method_risks);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_diff_rejects_invalid_visibility_and_wrong_path_count() {
|
||||
let error = parse_cli_from(["asmapi", "diff", "--visibility", "private", "old", "new"])
|
||||
.expect_err("invalid visibility");
|
||||
assert!(error.to_string().contains("invalid --visibility"));
|
||||
let error = parse_cli_from(["asmapi", "diff", "only-one"]).expect_err("path count");
|
||||
assert!(error.to_string().contains("exactly two assembly paths"));
|
||||
let error =
|
||||
parse_cli_from(["asmapi", "diff", "old", "new", "extra"]).expect_err("path count");
|
||||
assert!(error.to_string().contains("exactly two assembly paths"));
|
||||
let error = parse_cli_from(["asmapi", "--format", "xml", "diff", "old", "new"])
|
||||
.expect_err("format");
|
||||
assert!(error.to_string().contains("invalid --format value 'xml'"));
|
||||
|
||||
assert_eq!(
|
||||
parse_visibility("public").expect("public"),
|
||||
ApiVisibilityScope::Public
|
||||
);
|
||||
assert_eq!(
|
||||
parse_visibility("internal").expect("internal"),
|
||||
ApiVisibilityScope::Internal
|
||||
);
|
||||
assert_eq!(
|
||||
parse_visibility("all").expect("all"),
|
||||
ApiVisibilityScope::All
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_text_report_includes_all_change_sections_and_risk_rows() {
|
||||
let report = ApiDiffReport {
|
||||
old_assembly: assembly("OldGame"),
|
||||
new_assembly: assembly("NewGame"),
|
||||
visibility: ApiVisibilityScope::Public,
|
||||
summary: ApiDiffSummary {
|
||||
removed_types: 1,
|
||||
added_types: 1,
|
||||
removed_methods: 1,
|
||||
added_methods: 1,
|
||||
signature_changed_methods: 1,
|
||||
missing_method_risks: 1,
|
||||
},
|
||||
removed_types: vec![ApiTypeChange {
|
||||
type_name: "Game.Legacy".to_string(),
|
||||
kind: "class".to_string(),
|
||||
visibility: "public".to_string(),
|
||||
}],
|
||||
added_types: vec![ApiTypeChange {
|
||||
type_name: "Game.Modern".to_string(),
|
||||
kind: "class".to_string(),
|
||||
visibility: "public".to_string(),
|
||||
}],
|
||||
removed_methods: vec![ApiMethodChange {
|
||||
type_name: "Game.Legacy".to_string(),
|
||||
method_name: "Run".to_string(),
|
||||
old_signatures: vec!["void Run()".to_string()],
|
||||
new_signatures: Vec::new(),
|
||||
}],
|
||||
added_methods: vec![ApiMethodChange {
|
||||
type_name: "Game.Modern".to_string(),
|
||||
method_name: "Run".to_string(),
|
||||
old_signatures: Vec::new(),
|
||||
new_signatures: vec!["void Run(int count)".to_string()],
|
||||
}],
|
||||
signature_changed_methods: vec![ApiMethodChange {
|
||||
type_name: "Game.Player".to_string(),
|
||||
method_name: "Move".to_string(),
|
||||
old_signatures: vec!["void Move(float x)".to_string()],
|
||||
new_signatures: vec!["void Move(float x, float y)".to_string()],
|
||||
}],
|
||||
missing_method_risks: vec![MissingMethodRisk {
|
||||
old_assembly: "OldGame".to_string(),
|
||||
type_name: "Game.Legacy".to_string(),
|
||||
method_name: "Run".to_string(),
|
||||
old_signature: "void Run()".to_string(),
|
||||
reason: "removed public method".to_string(),
|
||||
}],
|
||||
};
|
||||
|
||||
let text = render_text_report(&report);
|
||||
assert!(text.contains("asmapi diff old=OldGame new=NewGame"));
|
||||
assert!(text.contains("removed_types:"));
|
||||
assert!(text.contains("added_types:"));
|
||||
assert!(text.contains("removed_methods:"));
|
||||
assert!(text.contains("added_methods:"));
|
||||
assert!(text.contains("signature_changed:"));
|
||||
assert!(text.contains("missing_method_risks:"));
|
||||
assert!(text.contains("Game.Legacy::Run old_signature=\"void Run()\""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Binary entry point for the `asmapi` command.
|
||||
|
||||
fn main() {
|
||||
std::process::exit(asmapi::main_entry());
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
//! Integration tests for the `asmapi` command.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use assert_cmd::Command;
|
||||
use predicates::prelude::*;
|
||||
use serde_json::Value;
|
||||
|
||||
fn cargo_command() -> Command {
|
||||
Command::cargo_bin("asmapi").expect("binary")
|
||||
}
|
||||
|
||||
fn workspace_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("..")
|
||||
.canonicalize()
|
||||
.expect("workspace root")
|
||||
}
|
||||
|
||||
fn managed_fixture_dir() -> PathBuf {
|
||||
workspace_root()
|
||||
.join("fixtures")
|
||||
.join("managed")
|
||||
.join("bin")
|
||||
}
|
||||
|
||||
fn game_assembly() -> PathBuf {
|
||||
managed_fixture_dir().join("GameAssembly.dll")
|
||||
}
|
||||
|
||||
fn fixture_support() -> PathBuf {
|
||||
managed_fixture_dir().join("FixtureSupport.dll")
|
||||
}
|
||||
|
||||
fn diff_json(extra_args: &[&str]) -> Value {
|
||||
let mut command = cargo_command();
|
||||
let output = command
|
||||
.arg("diff")
|
||||
.arg(game_assembly())
|
||||
.arg(fixture_support())
|
||||
.args(extra_args)
|
||||
.assert()
|
||||
.success()
|
||||
.get_output()
|
||||
.stdout
|
||||
.clone();
|
||||
serde_json::from_slice::<Value>(&output).expect("json payload")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_args_prints_quick_help_card() {
|
||||
let mut command = cargo_command();
|
||||
command
|
||||
.assert()
|
||||
.code(2)
|
||||
.stdout(predicate::str::is_empty())
|
||||
.stderr(predicate::str::contains(
|
||||
"missing subcommand; expected diff",
|
||||
))
|
||||
.stderr(predicate::str::contains("asmapi"))
|
||||
.stderr(predicate::str::contains("Type 'asmapi --help'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_mentions_diff_and_shared_output_flags() {
|
||||
let mut command = cargo_command();
|
||||
command
|
||||
.arg("--help")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("asmapi diff"))
|
||||
.stdout(predicate::str::contains("--format <FORMAT>"))
|
||||
.stdout(predicate::str::contains("--json"))
|
||||
.stdout(predicate::str::contains("--toon"));
|
||||
|
||||
let mut command = cargo_command();
|
||||
command
|
||||
.args(["diff", "--help"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("--visibility <SCOPE>"))
|
||||
.stdout(predicate::str::contains("--include-special"))
|
||||
.stdout(predicate::str::contains("--no-missing-method-risks"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_outputs_compact_text_sections() {
|
||||
let mut command = cargo_command();
|
||||
command
|
||||
.arg(game_assembly())
|
||||
.arg(fixture_support())
|
||||
.arg("diff")
|
||||
.assert()
|
||||
.code(2);
|
||||
|
||||
let mut command = cargo_command();
|
||||
command
|
||||
.arg("diff")
|
||||
.arg(game_assembly())
|
||||
.arg(fixture_support())
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("removed_types="))
|
||||
.stdout(predicate::str::contains("added_types:"))
|
||||
.stdout(predicate::str::contains("removed_methods:"))
|
||||
.stdout(predicate::str::contains("missing_method_risks:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_json_exposes_stable_top_level_fields() {
|
||||
let payload = diff_json(&["--json"]);
|
||||
assert_eq!(payload["visibility"], "public");
|
||||
assert!(payload["old_assembly"].is_object());
|
||||
assert!(payload["new_assembly"].is_object());
|
||||
assert!(payload["summary"]["removed_types"].as_u64().is_some());
|
||||
assert!(payload["missing_method_risks"].as_array().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visibility_all_includes_more_or_equal_removed_methods() {
|
||||
let public_payload = diff_json(&["--json"]);
|
||||
let all_payload = diff_json(&["--visibility", "all", "--json"]);
|
||||
|
||||
assert_eq!(all_payload["visibility"], "all");
|
||||
assert!(
|
||||
all_payload["summary"]["removed_methods"]
|
||||
.as_u64()
|
||||
.expect("all visibility removed method count")
|
||||
>= public_payload["summary"]["removed_methods"]
|
||||
.as_u64()
|
||||
.expect("public visibility removed method count")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_visibility_is_usage_error() {
|
||||
let mut command = cargo_command();
|
||||
command
|
||||
.arg("diff")
|
||||
.arg(game_assembly())
|
||||
.arg(fixture_support())
|
||||
.arg("--visibility")
|
||||
.arg("private")
|
||||
.assert()
|
||||
.code(2)
|
||||
.stderr(predicate::str::contains("invalid --visibility value"));
|
||||
}
|
||||
Reference in New Issue
Block a user