chore(release): prepare public source release

This commit is contained in:
MercuryToolbox Release
2026-07-18 15:33:01 +08:00
commit 34d6a57f38
510 changed files with 163501 additions and 0 deletions
+25
View File
@@ -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
+511
View File
@@ -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()\""));
}
}
+5
View File
@@ -0,0 +1,5 @@
//! Binary entry point for the `asmapi` command.
fn main() {
std::process::exit(asmapi::main_entry());
}
+148
View File
@@ -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"));
}