chore(release): prepare public source release

This commit is contained in:
MercuryToolbox Release
2026-07-18 15:41:59 +08:00
commit e365e5df4d
508 changed files with 163373 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "asmref"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "Inspect managed assembly references with AI-friendly output."
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[dependencies]
common = { path = "../common", default-features = false }
managed = { path = "../managed" }
lexopt.workspace = true
serde.workspace = true
[dev-dependencies]
assert_cmd.workspace = true
predicates.workspace = true
serde_json.workspace = true
+598
View File
@@ -0,0 +1,598 @@
//! The `asmref` command inspects managed assembly references.
use std::ffi::OsString;
use std::fmt::Write as _;
use std::io::{self, Read};
use std::path::PathBuf;
use common::{
CliError, CommonArgs, ExitCode, InputFormat, RenderMode, map_result_count, parse_color_choice,
parse_format_choice, parse_input_format, print_json, print_quick_help_error, print_structured,
read_existing_stdin_path_records, should_read_stdin, write_stdout,
};
use lexopt::prelude::{Long, Short, Value as ArgValue};
use managed::{
DependencyDiagnosisReport, DependencyReferenceDiagnostic, DiagnoseQuery, ReferenceQuery,
ResolutionStatus, diagnose_dependencies, inspect_references,
};
const HELP: &str = "\
Inspect managed assembly references and simple resolution status.
Usage:
asmref [OPTIONS] [ASSEMBLY...]
asmref diagnose [OPTIONS] [ASSEMBLY...]
Options:
--format <FORMAT> Structured output format: text, json, toon
--json Shortcut for --format json
--toon Shortcut for --format toon
--input-format <FORMAT> Override stdin parsing mode: auto, lines, jsonl
--color <WHEN> Control ANSI color output: auto, never
--quiet Suppress non-essential status output
--resolve-dir <PATH> Additional directory to search for references
-h, --help Show this help text
-V, --version Show the command version
Examples:
asmref .\\fixtures\\managed\\bin\\GameAssembly.dll --resolve-dir .\\fixtures\\managed\\bin
asmref diagnose .\\Plugins\\Example.Plugin.dll --resolve-dir .\\Libraries --resolve-dir .\\Managed --format toon
'C:\\game\\Managed\\Assembly-CSharp.dll' | asmref --input-format lines --resolve-dir C:\\game\\Managed
asmref .\\fixtures\\managed\\bin\\GameAssembly.dll --json | ConvertFrom-Json
";
const DIAGNOSE_HELP: &str = "\
Diagnose managed assembly dependency closure resolution.
Usage:
asmref diagnose [OPTIONS] [ASSEMBLY...]
Options:
--format <FORMAT> Structured output format: text, json, toon
--json Shortcut for --format json
--toon Shortcut for --format toon
--input-format <FORMAT> Override stdin parsing mode: auto, lines, jsonl
--color <WHEN> Control ANSI color output: auto, never
--quiet Suppress non-essential status output
--resolve-dir <PATH> Additional directory to search for dependencies
--test-only-pattern <REGEX> Extra case-insensitive test-only name/path pattern
--no-default-test-patterns Disable built-in test-only markers
-h, --help Show this help text
-V, --version Show the command version
Examples:
asmref diagnose .\\Plugins\\Example.Plugin.dll --resolve-dir .\\Libraries --resolve-dir .\\Managed --format toon
asmref diagnose .\\RootPlugin.dll --test-only-pattern Project.Tests --json
asmref diagnose .\\RootPlugin.dll --no-default-test-patterns --toon
";
#[derive(Debug, Clone)]
struct Cli {
mode: CommandMode,
common: CommonArgs,
assembly_paths: Vec<PathBuf>,
query: ReferenceQuery,
diagnose_query: DiagnoseQuery,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CommandMode {
Inspect,
Diagnose,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ParseOutcome {
Help(CommandMode),
Version,
Run,
}
/// 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(CommandMode::Inspect), _)) => {
print!("{HELP}");
ExitCode::Success.as_i32()
}
Ok((ParseOutcome::Help(CommandMode::Diagnose), _)) => {
print!("{DIAGNOSE_HELP}");
ExitCode::Success.as_i32()
}
Ok((ParseOutcome::Version, _)) => {
println!("asmref {}", 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_for_mode(cli.mode));
error.exit_code().as_i32()
}
},
Err(error) => {
print_quick_help_error(&error, HELP);
error.exit_code().as_i32()
}
}
}
const fn help_for_mode(mode: CommandMode) -> &'static str {
match mode {
CommandMode::Inspect => HELP,
CommandMode::Diagnose => DIAGNOSE_HELP,
}
}
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 cli = Cli {
mode: CommandMode::Inspect,
common: CommonArgs::default(),
assembly_paths: Vec::new(),
query: ReferenceQuery::default(),
diagnose_query: DiagnoseQuery::default(),
};
while let Some(argument) = parser
.next()
.map_err(|error| CliError::usage(error.to_string()))?
{
match argument {
Long("help") | Short('h') => return Ok((ParseOutcome::Help(cli.mode), cli)),
Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)),
Long("json") => cli.common.set_render_mode(RenderMode::Json),
Long("toon") => cli.common.set_render_mode(RenderMode::Toon),
Long("format") => {
let value = parser_value_string(&mut parser, "--format")?;
cli.common.set_render_mode(parse_format_choice(&value)?);
}
Long("input-format") => {
cli.common.input_format =
parse_input_format(&parser_value_string(&mut parser, "--input-format")?)?;
}
Long("color") => {
cli.common.color =
parse_color_choice(&parser_value_string(&mut parser, "--color")?)?;
}
Long("quiet") => cli.common.quiet = true,
Long("resolve-dir") => cli.push_resolve_dir(PathBuf::from(parser_value_string(
&mut parser,
"--resolve-dir",
)?)),
Long("test-only-pattern") if cli.mode == CommandMode::Diagnose => cli
.diagnose_query
.test_only_patterns
.push(parser_value_string(&mut parser, "--test-only-pattern")?),
Long("no-default-test-patterns") if cli.mode == CommandMode::Diagnose => {
cli.diagnose_query.use_default_test_patterns = false;
}
ArgValue(value)
if cli.mode == CommandMode::Inspect
&& cli.assembly_paths.is_empty()
&& value.to_string_lossy() == "diagnose" =>
{
cli.mode = CommandMode::Diagnose;
}
ArgValue(value) => cli.assembly_paths.push(PathBuf::from(value)),
_ => {
return Err(CliError::usage(
"unsupported argument; use --help to see available options",
));
}
}
}
Ok((ParseOutcome::Run, cli))
}
impl Cli {
fn push_resolve_dir(&mut self, path: PathBuf) {
self.query.resolve_dirs.push(path.clone());
self.diagnose_query.resolve_dirs.push(path);
}
}
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 run(cli: &Cli) -> Result<ExitCode, CliError> {
let paths = collect_paths(cli)?;
if paths.is_empty() {
return Err(CliError::usage(
"provide at least one assembly path or pipe assembly paths into stdin",
));
}
match cli.mode {
CommandMode::Inspect => run_inspect(cli, &paths),
CommandMode::Diagnose => run_diagnose(cli, &paths),
}
}
fn run_inspect(cli: &Cli, paths: &[PathBuf]) -> Result<ExitCode, CliError> {
let mut reports = inspect_references(paths, &cli.query)
.map_err(|error| CliError::runtime(error.to_string()))?;
let exit_code = map_result_count(reports.len());
match cli.common.render_mode() {
RenderMode::Json => {
if reports.len() == 1 {
print_json(&reports.remove(0))?;
} else {
print_json(&reports)?;
}
}
RenderMode::Toon => {
if reports.len() == 1 {
print_structured(&reports.remove(0), RenderMode::Toon)?;
} else {
print_structured(&reports, RenderMode::Toon)?;
}
}
RenderMode::Text => {
for report in &reports {
if report.references.is_empty() {
println!(
"{} references=0 path={}",
report.assembly.assembly_name,
report.assembly.path.display()
);
continue;
}
for reference in &report.references {
println!(
"{} name={} resolved={} path={}",
report.assembly.assembly_name,
reference.name,
reference.resolved,
reference
.resolved_path
.as_deref()
.map_or_else(|| "-".to_string(), |value| value.display().to_string())
);
}
}
}
}
Ok(exit_code)
}
fn run_diagnose(cli: &Cli, paths: &[PathBuf]) -> Result<ExitCode, CliError> {
let report = diagnose_dependencies(paths, &cli.diagnose_query)
.map_err(|error| CliError::runtime(error.to_string()))?;
let exit_code = if report.summary.error_count == 0 {
ExitCode::Success
} else {
ExitCode::NoResults
};
match cli.common.render_mode() {
RenderMode::Json => print_json(&report)?,
RenderMode::Toon => print_structured(&report, RenderMode::Toon)?,
RenderMode::Text => write_stdout(&render_diagnose_text(&report))?,
}
Ok(exit_code)
}
fn render_diagnose_text(report: &DependencyDiagnosisReport) -> String {
let mut output = String::new();
let summary = &report.summary;
let _ = writeln!(
&mut output,
"summary roots={} assemblies={} references={} resolved={} missing={} conflicts={} test_only={} errors={} warnings={} infos={}",
summary.root_count,
summary.assembly_count,
summary.reference_count,
summary.resolved_count,
summary.missing_count,
summary.conflict_count,
summary.test_only_count,
summary.error_count,
summary.warning_count,
summary.info_count
);
output.push_str("missing\n");
for reference in report
.references
.iter()
.filter(|item| item.resolution_status == ResolutionStatus::Missing)
{
let _ = writeln!(
&mut output,
" {} -> {} requested={}",
reference.source_assembly, reference.reference_name, reference.requested_version
);
}
output.push_str("conflicts\n");
for conflict in &report.conflicts {
let _ = writeln!(
&mut output,
" {} reason={} candidates={}",
conflict.reference_name,
conflict.reason,
conflict.candidates.len()
);
}
output.push_str("winners\n");
for winner in &report.winners {
let _ = writeln!(
&mut output,
" {} requested={} winner={} version={} reason={:?}",
winner.reference_name,
winner.requested_version,
winner.winner.assembly.path.display(),
winner
.winner
.assembly
.assembly_version
.as_deref()
.unwrap_or("-"),
winner.reason
);
}
output.push_str("test_only\n");
for candidate in &report.test_only {
let _ = writeln!(
&mut output,
" {} version={} path={}",
candidate.assembly.assembly_name,
candidate
.assembly
.assembly_version
.as_deref()
.unwrap_or("-"),
candidate.assembly.path.display()
);
}
output.push_str("risks\n");
for risk in &report.risks {
let _ = writeln!(
&mut output,
" {} {} source={} reference={} path={} message={}",
risk.severity,
risk.kind,
risk.source_assembly.as_deref().unwrap_or("-"),
risk.reference_name.as_deref().unwrap_or("-"),
risk.path
.as_deref()
.map_or_else(|| "-".to_string(), |path| path.display().to_string()),
risk.message
);
}
output.push_str("notable_refs\n");
for reference in &report.notable_refs {
output.push_str(&render_notable_reference(reference));
}
if !report.scan_warnings.is_empty() {
output.push_str("scan_warnings\n");
for warning in &report.scan_warnings {
let _ = writeln!(&mut output, " {warning}");
}
}
output
}
fn render_notable_reference(reference: &DependencyReferenceDiagnostic) -> String {
format!(
" {} -> {} requested={} status={:?} winner={}\n",
reference.source_assembly,
reference.reference_name,
reference.requested_version,
reference.resolution_status,
reference.winner.as_ref().map_or_else(
|| "-".to_string(),
|candidate| candidate.assembly.path.display().to_string()
)
)
}
fn collect_paths(cli: &Cli) -> Result<Vec<PathBuf>, CliError> {
if should_read_stdin(
!cli.assembly_paths.is_empty(),
cli.common.stdin_is_terminal(),
) {
let mut buffer = String::new();
io::stdin()
.read_to_string(&mut buffer)
.map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?;
if let Some(parsed) =
read_existing_stdin_path_records(&buffer, cli.common.input_format, "asmref")?
{
if !parsed.is_empty() {
return Ok(parsed);
}
}
}
common::expand_input_patterns(&cli.assembly_paths, "asmref")
}
#[allow(dead_code)]
fn parse_paths_from_string(
buffer: &str,
input_format: InputFormat,
) -> Result<Vec<PathBuf>, CliError> {
read_existing_stdin_path_records(buffer, input_format, "asmref")?
.map_or_else(|| Ok(Vec::new()), Ok)
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use common::{ColorChoice, InputFormat};
use super::*;
fn cli() -> Cli {
Cli {
mode: CommandMode::Inspect,
common: CommonArgs {
json: false,
format: None,
input_format: InputFormat::Auto,
color: ColorChoice::Never,
quiet: false,
},
assembly_paths: Vec::new(),
query: ReferenceQuery::default(),
diagnose_query: DiagnoseQuery::default(),
}
}
fn common_args(json: bool, input_format: InputFormat) -> CommonArgs {
CommonArgs {
json,
format: None,
input_format,
color: ColorChoice::Never,
quiet: false,
}
}
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.canonicalize()
.expect("workspace root")
}
fn fixture_assembly() -> PathBuf {
workspace_root()
.join("fixtures")
.join("managed")
.join("bin")
.join("GameAssembly.dll")
}
fn fixture_dir() -> PathBuf {
workspace_root()
.join("fixtures")
.join("managed")
.join("bin")
}
#[test]
fn parse_paths_reads_lines_and_jsonl_paths() {
let first = fixture_assembly();
let second = workspace_root().join("Cargo.toml");
assert_eq!(
parse_paths_from_string(
&format!("{}\n{}\n", first.display(), second.display()),
InputFormat::Lines
)
.expect("line paths"),
vec![second, first.clone()]
);
assert_eq!(
parse_paths_from_string(
&format!(
"{{\"path\":{}}}\n",
serde_json::to_string(&first.display().to_string()).expect("json path")
),
InputFormat::Jsonl
)
.expect("jsonl paths"),
vec![first]
);
}
#[test]
fn run_requires_at_least_one_path() {
let error = run(&cli()).expect_err("missing paths should fail");
assert!(matches!(
error,
CliError::Usage(message) if message.contains("provide at least one assembly path")
));
}
#[test]
fn parse_cli_collects_paths_and_resolve_dirs() {
let (_, parsed) = parse_cli_from([
"asmref",
"--resolve-dir",
"managed",
"--json",
"fixture.dll",
])
.expect("cli parse");
assert!(parsed.common.json);
assert_eq!(parsed.mode, CommandMode::Inspect);
assert_eq!(parsed.assembly_paths, vec![PathBuf::from("fixture.dll")]);
assert_eq!(parsed.query.resolve_dirs, vec![PathBuf::from("managed")]);
assert_eq!(
parsed.diagnose_query.resolve_dirs,
vec![PathBuf::from("managed")]
);
}
#[test]
fn parse_cli_collects_diagnose_options() {
let (_, parsed) = parse_cli_from([
"asmref",
"diagnose",
"--resolve-dir",
"managed",
"--test-only-pattern",
"Project.Tests",
"--no-default-test-patterns",
"fixture.dll",
])
.expect("cli parse");
assert_eq!(parsed.mode, CommandMode::Diagnose);
assert_eq!(parsed.assembly_paths, vec![PathBuf::from("fixture.dll")]);
assert_eq!(
parsed.diagnose_query.resolve_dirs,
vec![PathBuf::from("managed")]
);
assert_eq!(
parsed.diagnose_query.test_only_patterns,
vec!["Project.Tests"]
);
assert!(!parsed.diagnose_query.use_default_test_patterns);
}
#[test]
fn run_emits_success_for_fixture_reference_report() {
let exit_code = run(&Cli {
common: common_args(false, InputFormat::Auto),
assembly_paths: vec![fixture_assembly()],
mode: CommandMode::Inspect,
query: ReferenceQuery {
resolve_dirs: vec![fixture_dir()],
},
diagnose_query: DiagnoseQuery::default(),
})
.expect("fixture reference run");
assert_eq!(exit_code, ExitCode::Success);
}
}
+5
View File
@@ -0,0 +1,5 @@
//! Binary entry point for `asmref`.
fn main() {
std::process::exit(asmref::main_entry());
}
+227
View File
@@ -0,0 +1,227 @@
//! Integration tests for the `asmref` command.
use assert_cmd::Command;
use predicates::prelude::*;
use serde_json::Value;
use std::path::PathBuf;
fn cargo_command() -> Command {
Command::cargo_bin("asmref").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 fixture_assembly() -> PathBuf {
managed_fixture_dir().join("GameAssembly.dll")
}
fn diagnose_fixture_root() -> PathBuf {
workspace_root()
.join("fixtures")
.join("managed")
.join("diagnose-bin")
.join("root")
.join("RootPlugin.dll")
}
fn diagnose_server_a_dir() -> PathBuf {
workspace_root()
.join("fixtures")
.join("managed")
.join("diagnose-bin")
.join("server-a")
}
fn diagnose_server_b_dir() -> PathBuf {
workspace_root()
.join("fixtures")
.join("managed")
.join("diagnose-bin")
.join("server-b")
}
fn framework_reference_free_assembly() -> PathBuf {
PathBuf::from(r"C:\Windows\Microsoft.NET\Framework64\v4.0.30319\mscorlib.dll")
}
#[test]
fn help_includes_examples_and_resolve_dir_usage() {
let mut command = cargo_command();
command
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("--resolve-dir"))
.stdout(predicate::str::contains("ConvertFrom-Json"));
}
#[test]
fn emits_reference_resolution_as_json() {
let fixture_dir = managed_fixture_dir();
let mut command = cargo_command();
let output = command
.arg(fixture_assembly())
.arg("--resolve-dir")
.arg(&fixture_dir)
.arg("--json")
.assert()
.success()
.get_output()
.stdout
.clone();
let payload = serde_json::from_slice::<Value>(&output).expect("json payload");
let refs = payload["references"].as_array().expect("references array");
assert!(
refs.iter().any(|entry| {
entry["name"] == "FixtureSupport"
&& entry["resolved"] == true
&& entry["resolved_path"]
.as_str()
.is_some_and(|value: &str| value.ends_with("FixtureSupport.dll"))
}),
"expected FixtureSupport reference to resolve"
);
}
#[test]
fn text_mode_reports_zero_reference_empty_state() {
let mut command = cargo_command();
command
.arg(framework_reference_free_assembly())
.assert()
.success()
.stdout(predicate::str::contains("references=0"));
}
#[test]
fn supports_path_input_from_powershell_pipeline() {
let binary = assert_cmd::cargo::cargo_bin("asmref");
let input = fixture_assembly();
let fixture_dir = managed_fixture_dir();
let script = format!(
"'{}' | & '{}' --input-format lines --resolve-dir '{}' --json",
input.display(),
binary.display(),
fixture_dir.display()
);
let mut command = Command::new("pwsh");
command
.arg("-NoProfile")
.arg("-Command")
.arg(script)
.assert()
.success()
.stdout(predicate::str::contains("\"name\":\"FixtureSupport\""));
}
#[test]
fn diagnose_help_mentions_closure_options() {
let mut command = cargo_command();
command
.args(["diagnose", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("asmref diagnose"))
.stdout(predicate::str::contains("--resolve-dir"))
.stdout(predicate::str::contains("--test-only-pattern"))
.stdout(predicate::str::contains("--no-default-test-patterns"))
.stdout(predicate::str::contains("--format <FORMAT>"))
.stdout(predicate::str::contains("--toon"));
}
#[test]
fn diagnose_errors_show_diagnose_usage() {
let mut command = cargo_command();
command
.arg("diagnose")
.assert()
.code(2)
.stderr(predicate::str::contains(
"asmref diagnose [OPTIONS] [ASSEMBLY...]",
));
}
#[test]
fn diagnose_reports_missing_conflicts_winners_and_risks() {
let mut command = cargo_command();
let output = command
.arg("diagnose")
.arg(diagnose_fixture_root())
.arg("--resolve-dir")
.arg(diagnose_server_a_dir())
.arg("--resolve-dir")
.arg(diagnose_server_b_dir())
.arg("--json")
.assert()
.code(1)
.get_output()
.stdout
.clone();
let payload = serde_json::from_slice::<Value>(&output).expect("json payload");
assert_eq!(payload["summary"]["root_count"], 1);
assert!(
payload["summary"]["error_count"]
.as_u64()
.is_some_and(|count| count > 0)
);
let references = payload["references"].as_array().expect("references array");
assert!(references.iter().any(|entry| {
entry["reference_name"] == "MissingOnly" && entry["resolution_status"] == "missing"
}));
let candidates = payload["candidates"].as_array().expect("candidates array");
assert!(candidates.iter().any(|entry| {
entry["assembly"]["assembly_name"] == "RuntimeDependency"
&& entry["assembly"]["assembly_version"] == "2.0.0.0"
}));
let winners = payload["winners"].as_array().expect("winners array");
assert!(winners.iter().any(|entry| {
entry["reference_name"] == "0Harmony"
&& entry["winner"]["assembly"]["assembly_version"] == "2.2.2.0"
}));
let conflicts = payload["conflicts"].as_array().expect("conflicts array");
assert!(
conflicts
.iter()
.any(|entry| entry["reference_name"] == "RuntimeDependency")
);
let test_only = payload["test_only"].as_array().expect("test_only array");
assert!(
test_only
.iter()
.any(|entry| entry["assembly"]["assembly_name"] == "TestOnlySupport")
);
let risks = payload["risks"].as_array().expect("risks array");
for expected in [
"missing_reference",
"version_mismatch",
"test_only_dependency",
"missing_method",
"missing_type",
] {
assert!(
risks.iter().any(|entry| entry["kind"] == expected),
"expected risk kind {expected} in {risks:#?}"
);
}
}