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 = "argv"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "Inspect argv and optional stdin line payloads for shell quoting/debug workflows."
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[dependencies]
common = { path = "../common", default-features = false }
lexopt.workspace = true
runtimekit = { path = "../runtimekit" }
serde.workspace = true
serde_json.workspace = true
[dev-dependencies]
assert_cmd.workspace = true
predicates.workspace = true
+922
View File
@@ -0,0 +1,922 @@
//! The `argv` command renders shell-safe argument text and verifies real argv round-trips.
use std::ffi::OsString;
use std::fs::{self, OpenOptions};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
use common::{
CliError, CommonArgs, ExitCode, RenderMode, parse_color_choice, parse_format_choice,
print_json, print_quick_help_error, print_structured, print_text,
};
use lexopt::prelude::{Long, Short, Value as ArgValue};
use runtimekit::{ShellMode, collect_command_values, parse_shell_mode, parser_value_string};
use serde::{Deserialize, Serialize};
const HELP: &str = "\
Quote argument vectors for Windows shells and inspect what a shell really passed to a native process.
Usage:
argv quote --shell <pwsh|cmd|raw> [VALUE...]
argv inspect [--json] --shell <pwsh|cmd|raw> [VALUE...]
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
Notes:
Windows-first: quote and inspect focus on native invocation behavior for pwsh, cmd, and raw process launches.
cmd inspect is Windows-only.
quote accepts positional args or a JSON array of strings from stdin when no positional args are provided.
raw bypasses any shell and shows the direct native-process argv path.
Examples:
argv quote --shell pwsh -- git commit -m \"two words\"
'[\"tool.exe\",\"two words\"]' | argv quote --shell cmd
argv inspect --json --shell pwsh -- alpha \"two words\"
";
const QUOTE_HELP: &str = "\
Render a shell-safe invocation string for pwsh, cmd, or raw native argv.
Usage:
argv quote --shell <pwsh|cmd|raw> [VALUE...]
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
--shell <SHELL> Target shell: pwsh, cmd, raw
-h, --help Show this help text
Examples:
argv quote --shell pwsh -- git commit -m \"two words\"
'[\"tool.exe\",\"two words\"]' | argv quote --shell cmd
";
const INSPECT_HELP: &str = "\
Launch a native helper and show what argv actually arrived after shell parsing.
Usage:
argv inspect [--json] --shell <pwsh|cmd|raw> [VALUE...]
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
--shell <SHELL> Target shell: pwsh, cmd, raw
-h, --help Show this help text
Examples:
argv inspect --shell pwsh -- alpha \"two words\"
argv inspect --json --shell cmd -- alpha \"--filter=x|y\"
";
const HELPER_SUBCOMMAND: &str = "__native_helper";
#[derive(Debug, Clone)]
struct Cli {
common: CommonArgs,
command: CommandMode,
}
#[derive(Debug, Clone)]
enum CommandMode {
Help(&'static str),
Quote {
shell: ShellMode,
values: Vec<OsString>,
},
Inspect {
shell: ShellMode,
values: Vec<OsString>,
},
NativeHelper {
values: Vec<String>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ParseOutcome {
Help,
Version,
Run,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct QuoteReport {
shell: String,
text: String,
argv: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct InspectReport {
shell: String,
invocation: String,
probe_invocation: Option<String>,
count: usize,
argv: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct HelperReport {
count: usize,
argv: Vec<String>,
}
/// 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, _)) => {
print!("{HELP}");
ExitCode::Success.as_i32()
}
Ok((ParseOutcome::Version, _)) => {
println!("argv {}", 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 cli = Cli {
common: CommonArgs::default(),
command: CommandMode::Quote {
shell: ShellMode::Raw,
values: Vec::new(),
},
};
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)),
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("quiet") => cli.common.quiet = true,
Long("color") => {
cli.common.color =
parse_color_choice(&parser_value_string(&mut parser, "--color")?)?;
}
ArgValue(value) => {
let token = runtimekit::os_to_utf8(value, "subcommand")?;
cli.command = match token.as_str() {
"quote" => parse_quote_command(&mut parser, &mut cli.common)?,
"inspect" => parse_inspect_command(&mut parser, &mut cli.common)?,
HELPER_SUBCOMMAND => parse_native_helper_command(&mut parser)?,
_ => {
return Err(CliError::usage(
"unknown subcommand; expected quote or inspect",
));
}
};
return Ok((ParseOutcome::Run, cli));
}
_ => {
return Err(CliError::usage(
"unsupported argument; use --help to see available options",
));
}
}
}
Err(CliError::usage("provide a subcommand: quote or inspect"))
}
fn parse_quote_command(
parser: &mut lexopt::Parser,
common: &mut CommonArgs,
) -> Result<CommandMode, CliError> {
let mut shell = None::<ShellMode>;
let mut values = Vec::new();
while let Some(argument) = parser
.next()
.map_err(|error| CliError::usage(error.to_string()))?
{
match argument {
Long("help") | Short('h') => return Ok(CommandMode::Help(QUOTE_HELP)),
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("quiet") => common.quiet = true,
Long("color") => {
common.color = parse_color_choice(&parser_value_string(parser, "--color")?)?;
}
Long("shell") => {
shell = Some(parse_shell_mode(
"--shell",
&parser_value_string(parser, "--shell")?,
)?);
}
ArgValue(value) => {
values = collect_command_values(parser, value)?;
break;
}
_ => {
return Err(CliError::usage(
"unsupported quote argument; use --help to see available options",
));
}
}
}
Ok(CommandMode::Quote {
shell: shell.ok_or_else(|| CliError::usage("quote requires --shell <pwsh|cmd|raw>"))?,
values,
})
}
fn parse_inspect_command(
parser: &mut lexopt::Parser,
common: &mut CommonArgs,
) -> Result<CommandMode, CliError> {
let mut shell = None::<ShellMode>;
let mut values = Vec::new();
while let Some(argument) = parser
.next()
.map_err(|error| CliError::usage(error.to_string()))?
{
match argument {
Long("help") | Short('h') => return Ok(CommandMode::Help(INSPECT_HELP)),
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("quiet") => common.quiet = true,
Long("color") => {
common.color = parse_color_choice(&parser_value_string(parser, "--color")?)?;
}
Long("shell") => {
shell = Some(parse_shell_mode(
"--shell",
&parser_value_string(parser, "--shell")?,
)?);
}
ArgValue(value) => {
values = collect_command_values(parser, value)?;
break;
}
_ => {
return Err(CliError::usage(
"unsupported inspect argument; use --help to see available options",
));
}
}
}
Ok(CommandMode::Inspect {
shell: shell.ok_or_else(|| CliError::usage("inspect requires --shell <pwsh|cmd|raw>"))?,
values,
})
}
fn parse_native_helper_command(parser: &mut lexopt::Parser) -> Result<CommandMode, CliError> {
let mut values = Vec::new();
while let Some(argument) = parser
.next()
.map_err(|error| CliError::usage(error.to_string()))?
{
match argument {
ArgValue(value) => values.push(runtimekit::os_to_utf8(value, "helper arg")?),
_ => {
return Err(CliError::usage(
"internal helper expects only positional values",
));
}
}
}
Ok(CommandMode::NativeHelper { values })
}
fn run(cli: &Cli) -> Result<ExitCode, CliError> {
match &cli.command {
CommandMode::Help(text) => {
print_text(text)?;
Ok(ExitCode::Success)
}
CommandMode::Quote { shell, values } => run_quote(cli, *shell, values),
CommandMode::Inspect { shell, values } => run_inspect(cli, *shell, values),
CommandMode::NativeHelper { values } => run_native_helper(values),
}
}
fn run_quote(cli: &Cli, shell: ShellMode, values: &[OsString]) -> Result<ExitCode, CliError> {
let argv = resolve_values(cli, values)?;
let report = QuoteReport {
shell: shell_label(shell).to_string(),
text: render_shell_invocation(shell, &argv),
argv,
};
match cli.common.render_mode() {
RenderMode::Json => print_json(&report)?,
RenderMode::Toon => print_structured(&report, RenderMode::Toon)?,
RenderMode::Text => print_text(&report.text)?,
}
Ok(ExitCode::Success)
}
fn run_inspect(cli: &Cli, shell: ShellMode, values: &[OsString]) -> Result<ExitCode, CliError> {
let argv = resolve_values(cli, values)?;
let inspection = inspect_arguments(shell, &argv)?;
let report = InspectReport {
shell: shell_label(shell).to_string(),
invocation: inspection.invocation,
probe_invocation: Some(inspection.probe_invocation),
count: inspection.helper.argv.len(),
argv: inspection.helper.argv,
};
match cli.common.render_mode() {
RenderMode::Json => print_json(&report)?,
RenderMode::Toon => print_structured(&report, RenderMode::Toon)?,
RenderMode::Text => print_text(render_inspect_text(&report))?,
}
Ok(ExitCode::Success)
}
fn run_native_helper(values: &[String]) -> Result<ExitCode, CliError> {
let report = HelperReport {
count: values.len(),
argv: values.to_vec(),
};
print_json(&report)?;
Ok(ExitCode::Success)
}
fn resolve_values(cli: &Cli, values: &[OsString]) -> Result<Vec<String>, CliError> {
if !values.is_empty() {
return values
.iter()
.cloned()
.map(|value| runtimekit::os_to_utf8(value, "argv value"))
.collect();
}
if cli.common.stdin_is_terminal() {
return Err(CliError::usage(
"provide positional values or pipe a JSON array of strings into stdin",
));
}
let mut buffer = String::new();
std::io::stdin()
.read_to_string(&mut buffer)
.map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?;
let trimmed = buffer.trim();
if trimmed.is_empty() {
return Err(CliError::usage(
"stdin was empty; expected a JSON array of strings",
));
}
serde_json::from_str::<Vec<String>>(trimmed).map_err(|error| {
CliError::usage(format!(
"expected a JSON array of strings on stdin, got parse error: {error}"
))
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct InspectionResult {
invocation: String,
probe_invocation: String,
helper: HelperReport,
}
fn inspect_arguments(shell: ShellMode, values: &[String]) -> Result<InspectionResult, CliError> {
let current_exe = std::env::current_exe().map_err(|error| {
CliError::runtime(format!("failed to resolve current executable: {error}"))
})?;
let helper_exe = current_exe.display().to_string();
let mut helper_argv = vec![helper_exe, HELPER_SUBCOMMAND.to_string(), "--".to_string()];
helper_argv.extend(values.iter().cloned());
let invocation = render_shell_invocation(shell, values);
let probe_invocation = render_shell_invocation(shell, &helper_argv);
let output = match shell {
ShellMode::Raw => Command::new(&current_exe)
.arg(HELPER_SUBCOMMAND)
.arg("--")
.args(values)
.output()
.map_err(|error| CliError::runtime(format!("failed to launch raw helper: {error}")))?,
ShellMode::Pwsh => Command::new("pwsh")
.arg("-NoProfile")
.arg("-Command")
.arg(&probe_invocation)
.output()
.map_err(|error| CliError::runtime(format!("failed to launch pwsh helper: {error}")))?,
ShellMode::Cmd => {
if !cfg!(windows) {
return Err(CliError::usage("cmd inspect is Windows-only"));
}
let wrapper = unique_cmd_wrapper_path();
let wrapper_body = format!(
"@echo off\r\nsetlocal DisableDelayedExpansion\r\n{}\r\n",
probe_invocation.replace('%', "%%")
);
write_cmd_wrapper_file(&wrapper, &wrapper_body)?;
let output = Command::new("cmd")
.arg("/d")
.arg("/v:off")
.arg("/c")
.arg(&wrapper)
.output()
.map_err(|error| {
CliError::runtime(format!("failed to launch cmd helper: {error}"))
});
let _ = fs::remove_file(&wrapper);
output?
}
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(CliError::runtime(format!(
"native helper failed with status {}: {}",
output.status,
stderr.trim()
)));
}
let helper = serde_json::from_slice::<HelperReport>(&output.stdout)
.map_err(|error| CliError::runtime(format!("failed to parse helper output: {error}")))?;
Ok(InspectionResult {
invocation,
probe_invocation,
helper,
})
}
fn render_inspect_text(report: &InspectReport) -> String {
let mut lines = vec![format!(
"shell={} count={} invocation={}",
report.shell, report.count, report.invocation
)];
lines.extend(
report
.argv
.iter()
.enumerate()
.map(|(index, value)| format!("argv[{index}]={}", quote_text(value))),
);
lines.join("\n")
}
fn render_shell_invocation(shell: ShellMode, values: &[String]) -> String {
match shell {
ShellMode::Pwsh => {
let mut parts = vec!["&".to_string()];
parts.extend(values.iter().map(|value| quote_pwsh(value)));
parts.join(" ")
}
ShellMode::Cmd => values
.iter()
.map(|value| quote_cmd(value))
.collect::<Vec<_>>()
.join(" "),
ShellMode::Raw => values
.iter()
.map(|value| quote_raw(value))
.collect::<Vec<_>>()
.join(" "),
}
}
fn quote_pwsh(value: &str) -> String {
format!("'{}'", value.replace('\'', "''"))
}
fn quote_cmd(value: &str) -> String {
if value.is_empty()
|| value
.chars()
.any(|ch| ch.is_whitespace() || "\"^&|<>()%!".contains(ch))
{
quote_windows_arg_inner(value, true)
} else {
value.to_string()
}
}
fn quote_raw(value: &str) -> String {
quote_windows_arg(value)
}
fn quote_windows_arg(value: &str) -> String {
quote_windows_arg_inner(value, false)
}
fn quote_windows_arg_inner(value: &str, force_quote: bool) -> String {
if force_quote || value.is_empty() || value.chars().any(|ch| ch.is_whitespace() || ch == '"') {
let mut rendered = String::from("\"");
let mut backslashes = 0_usize;
for ch in value.chars() {
match ch {
'\\' => backslashes += 1,
'"' => {
rendered.push_str(&"\\".repeat(backslashes * 2 + 1));
rendered.push('"');
backslashes = 0;
}
_ => {
rendered.push_str(&"\\".repeat(backslashes));
backslashes = 0;
rendered.push(ch);
}
}
}
rendered.push_str(&"\\".repeat(backslashes * 2));
rendered.push('"');
rendered
} else {
value.to_string()
}
}
fn quote_text(value: &str) -> String {
if value.is_empty() || value.chars().any(char::is_whitespace) {
format!("\"{}\"", value.replace('"', "\\\""))
} else {
value.to_string()
}
}
const fn shell_label(shell: ShellMode) -> &'static str {
match shell {
ShellMode::Raw => "raw",
ShellMode::Pwsh => "pwsh",
ShellMode::Cmd => "cmd",
}
}
fn unique_cmd_wrapper_path() -> PathBuf {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |value| value.as_nanos());
std::env::temp_dir().join(format!(
"mercury-argv-helper-{}-{unique}.cmd",
std::process::id()
))
}
fn write_cmd_wrapper_file(path: &Path, body: &str) -> Result<(), CliError> {
let mut file = OpenOptions::new()
.create_new(true)
.write(true)
.open(path)
.map_err(|error| {
CliError::runtime(format!(
"refusing to replace existing cmd inspect wrapper {}: {error}",
path.display()
))
})?;
file.write_all(body.as_bytes()).map_err(|error| {
CliError::runtime(format!("failed to write {}: {error}", path.display()))
})?;
file.flush()
.map_err(|error| CliError::runtime(format!("failed to flush {}: {error}", path.display())))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parser_accepts_quote_and_inspect_commands() {
let (_, quote_cli) = parse_cli_from([
"argv",
"quote",
"--shell",
"pwsh",
"--",
"tool",
"two words",
])
.expect("quote cli");
assert!(matches!(
quote_cli.command,
CommandMode::Quote {
shell: ShellMode::Pwsh,
..
}
));
let (_, inspect_cli) =
parse_cli_from(["argv", "--json", "inspect", "--shell", "raw", "--", "alpha"])
.expect("inspect cli");
assert!(inspect_cli.common.json);
assert!(matches!(
inspect_cli.command,
CommandMode::Inspect {
shell: ShellMode::Raw,
..
}
));
let (_, inspect_help) =
parse_cli_from(["argv", "inspect", "--help"]).expect("inspect help");
assert!(matches!(
inspect_help.command,
CommandMode::Help(text) if text.contains("argv inspect")
));
}
#[test]
fn parser_covers_global_and_subcommand_common_flags() {
let (_, quote_cli) = parse_cli_from([
"argv", "--quiet", "--color", "never", "quote", "--json", "--quiet", "--color",
"never", "--shell", "cmd", "--", "tool.exe",
])
.expect("quote cli");
assert!(quote_cli.common.json);
assert!(quote_cli.common.quiet);
assert_eq!(quote_cli.common.color, common::ColorChoice::Never);
assert!(matches!(
quote_cli.command,
CommandMode::Quote {
shell: ShellMode::Cmd,
..
}
));
let (_, inspect_cli) = parse_cli_from([
"argv", "inspect", "--quiet", "--color", "never", "--shell", "pwsh", "--", "alpha",
])
.expect("inspect cli");
assert!(inspect_cli.common.quiet);
assert_eq!(inspect_cli.common.color, common::ColorChoice::Never);
assert!(matches!(
inspect_cli.command,
CommandMode::Inspect {
shell: ShellMode::Pwsh,
..
}
));
}
#[test]
fn quote_rendering_is_shell_aware() {
assert_eq!(
render_shell_invocation(
ShellMode::Pwsh,
&["tool".to_string(), "two words".to_string()]
),
"& 'tool' 'two words'"
);
assert_eq!(
render_shell_invocation(
ShellMode::Cmd,
&["tool.exe".to_string(), "two words".to_string()]
),
"tool.exe \"two words\""
);
assert_eq!(quote_pwsh("a'b"), "'a''b'");
}
#[test]
fn helper_text_output_is_compact() {
let report = InspectReport {
shell: "raw".to_string(),
invocation: "alpha".to_string(),
probe_invocation: Some("tool.exe __native_helper -- alpha".to_string()),
count: 2,
argv: vec!["alpha".to_string(), "two words".to_string()],
};
let text = render_inspect_text(&report);
assert!(text.contains("shell=raw count=2"));
assert!(text.contains("argv[1]=\"two words\""));
}
#[test]
fn native_helper_parser_rejects_flags_and_quote_rendering_covers_raw() {
assert!(matches!(
parse_cli_from(["argv", HELPER_SUBCOMMAND, "--json"]),
Err(CliError::Usage(message)) if message.contains("only positional values")
));
assert_eq!(
render_shell_invocation(
ShellMode::Raw,
&["tool.exe".to_string(), "two words".to_string()]
),
"tool.exe \"two words\""
);
assert_eq!(quote_raw(""), "\"\"");
assert_eq!(quote_text("needs quotes"), "\"needs quotes\"");
}
#[test]
fn parser_and_helper_paths_reject_invalid_arguments() {
assert!(matches!(
parse_cli_from(["argv"]),
Err(CliError::Usage(message)) if message.contains("provide a subcommand")
));
assert!(matches!(
parse_cli_from(["argv", "--bogus"]),
Err(CliError::Usage(message)) if message.contains("unsupported argument")
));
assert!(matches!(
parse_cli_from(["argv", "quote", "--shell", "pwsh", "--bogus"]),
Err(CliError::Usage(message)) if message.contains("unsupported quote argument")
));
assert!(matches!(
parse_cli_from(["argv", "inspect", "--shell", "raw", "--bogus"]),
Err(CliError::Usage(message)) if message.contains("unsupported inspect argument")
));
let (_, helper_cli) =
parse_cli_from(["argv", HELPER_SUBCOMMAND, "alpha", "two words"]).expect("helper cli");
assert!(matches!(
helper_cli.command,
CommandMode::NativeHelper { values } if values == vec!["alpha".to_string(), "two words".to_string()]
));
}
#[test]
fn parser_rejects_unknown_subcommands_and_missing_shell_flags() {
assert!(matches!(
parse_cli_from(["argv", "wat"]),
Err(CliError::Usage(message)) if message.contains("expected quote or inspect")
));
assert!(matches!(
parse_cli_from(["argv", "quote", "--", "tool.exe"]),
Err(CliError::Usage(message)) if message.contains("quote requires --shell")
));
assert!(matches!(
parse_cli_from(["argv", "inspect", "--", "tool.exe"]),
Err(CliError::Usage(message)) if message.contains("inspect requires --shell")
));
}
#[test]
fn quote_helpers_cover_metacharacters_backslashes_and_empty_values() {
assert_eq!(quote_cmd("a&b"), "\"a&b\"");
assert_eq!(quote_cmd("two words"), "\"two words\"");
assert_eq!(quote_windows_arg("two words\\"), "\"two words\\\\\"");
assert_eq!(quote_windows_arg("say \"hi\""), "\"say \\\"hi\\\"\"");
assert_eq!(quote_windows_arg("plain"), "plain");
assert_eq!(quote_text(""), "\"\"");
assert_eq!(shell_label(ShellMode::Cmd), "cmd");
}
#[test]
fn run_and_resolve_values_cover_quote_and_helper_dispatch() {
let quote_cli = Cli {
common: CommonArgs::default(),
command: CommandMode::Quote {
shell: ShellMode::Raw,
values: vec![OsString::from("tool.exe"), OsString::from("two words")],
},
};
assert_eq!(
resolve_values(&quote_cli, &quote_cli_values(&quote_cli)).expect("values"),
vec!["tool.exe".to_string(), "two words".to_string()]
);
assert_eq!(run(&quote_cli).expect("quote"), ExitCode::Success);
let helper_cli = Cli {
common: CommonArgs {
json: true,
format: None,
..CommonArgs::default()
},
command: CommandMode::NativeHelper {
values: vec!["alpha".to_string()],
},
};
assert_eq!(run(&helper_cli).expect("helper"), ExitCode::Success);
}
#[test]
fn inspect_arguments_attempt_pwsh_and_cmd_launch_paths() {
let pwsh_error =
inspect_arguments(ShellMode::Pwsh, &["alpha".to_string()]).expect_err("pwsh helper");
assert!(matches!(
pwsh_error,
CliError::Runtime(message)
if message.contains("failed to parse helper output")
|| message.contains("native helper failed with status")
|| message.contains("failed to launch pwsh helper")
));
#[cfg(windows)]
{
let cmd_error =
inspect_arguments(ShellMode::Cmd, &["alpha".to_string()]).expect_err("cmd helper");
assert!(matches!(
cmd_error,
CliError::Runtime(message)
if message.contains("failed to parse helper output")
|| message.contains("native helper failed with status")
|| message.contains("failed to write")
|| message.contains("failed to launch cmd helper")
));
let wrapper_one = unique_cmd_wrapper_path();
let wrapper_two = unique_cmd_wrapper_path();
assert_ne!(wrapper_one, wrapper_two);
assert_eq!(
wrapper_one.extension().and_then(|value| value.to_str()),
Some("cmd")
);
}
}
#[test]
fn resolve_values_rejects_missing_terminal_input() {
let cli = Cli {
common: CommonArgs::default(),
command: CommandMode::Quote {
shell: ShellMode::Raw,
values: Vec::new(),
},
};
let error = resolve_values(&cli, &[]).expect_err("missing values");
assert!(matches!(
error,
CliError::Usage(message)
if message.contains("provide positional values or pipe a JSON array")
|| message.contains("stdin was empty; expected a JSON array of strings")
));
}
#[test]
fn inspect_arguments_in_unit_tests_surface_helper_failures() {
let error = inspect_arguments(ShellMode::Raw, &["alpha".to_string()])
.expect_err("unit test binary is not the helper entrypoint");
assert!(matches!(
error,
CliError::Runtime(message)
if message.contains("failed to parse helper output")
|| message.contains("native helper failed with status")
));
}
#[test]
fn cmd_wrapper_writer_refuses_preexisting_paths() {
let path = unique_cmd_wrapper_path();
fs::write(&path, "original").expect("preexisting wrapper");
let error =
write_cmd_wrapper_file(&path, "replacement").expect_err("preexisting path refused");
assert!(matches!(
error,
CliError::Runtime(message)
if message.contains("refusing to replace existing cmd inspect wrapper")
));
assert_eq!(
fs::read_to_string(&path).expect("preserved content"),
"original"
);
let _ = fs::remove_file(path);
}
#[test]
fn run_native_helper_returns_success_exit_code() {
let code =
run_native_helper(&["alpha".to_string(), "two words".to_string()]).expect("helper");
assert_eq!(code, ExitCode::Success);
}
fn quote_cli_values(cli: &Cli) -> Vec<OsString> {
match &cli.command {
CommandMode::Quote { values, .. } => values.clone(),
_ => Vec::new(),
}
}
}
+5
View File
@@ -0,0 +1,5 @@
//! Binary entry point for `argv`.
fn main() {
std::process::exit(argv::main_entry());
}
+233
View File
@@ -0,0 +1,233 @@
//! Integration tests for the `argv` command.
use assert_cmd::Command;
use predicates::prelude::*;
fn cargo_command() -> Command {
Command::cargo_bin("argv").expect("binary")
}
#[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(
"error: provide a subcommand: quote or inspect",
))
.stderr(predicate::str::contains("argv - Mercury Toolbox"))
.stderr(predicate::str::contains("Usage:"))
.stderr(predicate::str::contains("argv quote"))
.stderr(predicate::str::contains("argv inspect"))
.stderr(predicate::str::contains(
"Type 'argv --help' for the full command reference.",
));
}
#[test]
fn help_mentions_quote_inspect_and_platform_boundaries() {
let mut command = cargo_command();
command
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("argv quote"))
.stdout(predicate::str::contains("argv inspect"))
.stdout(predicate::str::contains("Windows-first"))
.stdout(predicate::str::contains("cmd inspect is Windows-only"));
}
#[test]
fn subcommand_help_mentions_common_output_flags() {
for subcommand in ["quote", "inspect"] {
let mut command = cargo_command();
command
.args([subcommand, "--help"])
.assert()
.success()
.stdout(predicate::str::contains("--format <FORMAT>"))
.stdout(predicate::str::contains("--json"))
.stdout(predicate::str::contains("--toon"))
.stdout(predicate::str::contains("--color <WHEN>"))
.stdout(predicate::str::contains("--quiet"))
.stdout(predicate::str::contains("--shell <SHELL>"));
}
}
#[test]
fn quote_accepts_json_array_from_stdin() {
let mut command = cargo_command();
command
.arg("quote")
.arg("--shell")
.arg("pwsh")
.write_stdin("[\"tool\",\"two words\"]")
.assert()
.success()
.stdout(predicate::str::contains("& 'tool' 'two words'"));
}
#[cfg(windows)]
#[test]
fn quote_renders_cmd_safe_text_for_positional_args() {
let mut command = cargo_command();
command
.arg("quote")
.arg("--shell")
.arg("cmd")
.arg("--")
.arg("tool.exe")
.arg("two words")
.assert()
.success()
.stdout(predicate::str::contains("tool.exe"))
.stdout(predicate::str::contains("\"two words\""));
}
#[cfg(windows)]
#[test]
fn quote_cmd_wraps_metacharacters_for_cmd_shell() {
let mut command = cargo_command();
command
.arg("quote")
.arg("--shell")
.arg("cmd")
.arg("--")
.arg("tool.exe")
.arg("a&b")
.assert()
.success()
.stdout(predicate::str::contains("tool.exe"))
.stdout(predicate::str::contains("\"a&b\""));
}
#[cfg(windows)]
#[test]
fn inspect_round_trips_through_powershell_helper() {
let mut command = cargo_command();
command
.arg("inspect")
.arg("--json")
.arg("--shell")
.arg("pwsh")
.arg("--")
.arg("alpha")
.arg("two words")
.assert()
.success()
.stdout(predicate::str::contains("\"shell\":\"pwsh\""))
.stdout(predicate::str::contains(
"\"argv\":[\"alpha\",\"two words\"]",
))
.stdout(predicate::str::contains("\"count\":2"));
}
#[cfg(windows)]
#[test]
fn inspect_round_trips_positional_values_that_look_like_options() {
let mut command = cargo_command();
command
.arg("inspect")
.arg("--json")
.arg("--shell")
.arg("pwsh")
.arg("--")
.arg("alpha")
.arg("--filter=x|y")
.assert()
.success()
.stdout(predicate::str::contains(
"\"argv\":[\"alpha\",\"--filter=x|y\"]",
))
.stdout(predicate::str::contains("\"count\":2"));
}
#[cfg(windows)]
#[test]
fn inspect_round_trips_through_cmd_helper() {
let mut command = cargo_command();
command
.arg("inspect")
.arg("--json")
.arg("--shell")
.arg("cmd")
.arg("--")
.arg("alpha")
.arg("two words")
.assert()
.success()
.stdout(predicate::str::contains("\"shell\":\"cmd\""))
.stdout(predicate::str::contains(
"\"argv\":[\"alpha\",\"two words\"]",
))
.stdout(predicate::str::contains("\"count\":2"));
}
#[cfg(windows)]
#[test]
fn inspect_round_trips_cmd_caret_literals() {
let mut command = cargo_command();
command
.arg("inspect")
.arg("--json")
.arg("--shell")
.arg("cmd")
.arg("--")
.arg("--flag")
.arg("literal^caret")
.assert()
.success()
.stdout(predicate::str::contains(
"\"argv\":[\"--flag\",\"literal^caret\"]",
));
}
#[test]
fn inspect_raw_uses_helper_without_shell() {
let mut command = cargo_command();
command
.arg("inspect")
.arg("--json")
.arg("--shell")
.arg("raw")
.arg("--")
.arg("alpha")
.arg("two words")
.assert()
.success()
.stdout(predicate::str::contains("\"shell\":\"raw\""))
.stdout(predicate::str::contains(
"\"argv\":[\"alpha\",\"two words\"]",
));
}
#[test]
fn inspect_text_accepts_json_array_from_stdin() {
let mut command = cargo_command();
command
.arg("inspect")
.arg("--shell")
.arg("raw")
.write_stdin("[\"alpha\",\"two words\"]")
.assert()
.success()
.stdout(predicate::str::contains("shell=raw count=2"))
.stdout(predicate::str::contains("argv[1]=\"two words\""));
}
#[test]
fn quote_rejects_invalid_stdin_json() {
let mut command = cargo_command();
command
.arg("quote")
.arg("--shell")
.arg("raw")
.write_stdin("{\"argv\":[\"alpha\"]}")
.assert()
.code(2)
.stderr(predicate::str::contains(
"expected a JSON array of strings on stdin",
));
}
+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"));
}
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "asmflow"
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 IL, calls, fields, and string literals 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" }
regex-lite.workspace = true
serde.workspace = true
serde_json.workspace = true
[dev-dependencies]
assert_cmd.workspace = true
predicates.workspace = true
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
//! Public entry point for the `asmflow` command crate.
mod cli;
pub use cli::main_entry;
+5
View File
@@ -0,0 +1,5 @@
//! Binary entry point for `asmflow`.
fn main() {
std::process::exit(asmflow::main_entry());
}
+27
View File
@@ -0,0 +1,27 @@
//! Integration tests for the `asmflow` command.
use assert_cmd::Command;
use predicates::prelude::*;
#[test]
fn no_args_prints_quick_help_card() {
let mut command = Command::cargo_bin("asmflow").expect("binary");
command
.assert()
.code(2)
.stdout(predicate::str::is_empty())
.stderr(predicate::str::contains(
"error: missing subcommand; expected find, body, or xref",
))
.stderr(predicate::str::contains("asmflow - Mercury Toolbox"))
.stderr(predicate::str::contains("Usage:"))
.stderr(predicate::str::contains(
"asmflow [OPTIONS] <SUBCOMMAND> [ARGS...]",
))
.stderr(predicate::str::contains("find"))
.stderr(predicate::str::contains("body"))
.stderr(predicate::str::contains("xref"))
.stderr(predicate::str::contains(
"Type 'asmflow --help' for the full command reference.",
));
}
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "asmmember"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "List managed assembly members 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
regex-lite.workspace = true
serde.workspace = true
serde_json.workspace = true
[dev-dependencies]
assert_cmd.workspace = true
predicates.workspace = true
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
//! Public entry point for the `asmmember` command crate.
mod cli;
pub use cli::main_entry;
+5
View File
@@ -0,0 +1,5 @@
//! Binary entry point for `asmmember`.
fn main() {
std::process::exit(asmmember::main_entry());
}
+105
View File
@@ -0,0 +1,105 @@
//! Integration tests for the `asmmember` command.
use assert_cmd::Command;
use predicates::prelude::*;
use serde_json::Value;
use std::path::PathBuf;
fn cargo_command() -> Command {
Command::cargo_bin("asmmember").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")
}
#[test]
fn help_includes_examples_and_binding_usage() {
let mut command = cargo_command();
command
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("--assembly"))
.stdout(predicate::str::contains("--binding"))
.stdout(predicate::str::contains("--user-code-only"))
.stdout(predicate::str::contains("ConvertFrom-Json"));
}
#[test]
fn emits_methods_fields_and_properties_as_json() {
let mut command = cargo_command();
let output = command
.arg("--assembly")
.arg(fixture_assembly())
.arg("Game.UI.Windows.Windows.SpaceCraftConstructionWindow")
.arg("--match")
.arg("Build|Project|Launch|Queue|Complete")
.arg("--binding")
.arg("public,nonpublic,instance,static")
.arg("--user-code-only")
.arg("--json")
.assert()
.success()
.get_output()
.stdout
.clone();
let payload = serde_json::from_slice::<Value>(&output).expect("json payload");
let entries = payload.as_array().expect("array payload");
assert!(
entries
.iter()
.any(|entry| entry["kind"] == "method" && entry["name"] == "StartProject")
);
assert!(
entries
.iter()
.any(|entry| entry["kind"] == "field" && entry["name"] == "_buildTicks")
);
assert!(
entries
.iter()
.any(|entry| entry["kind"] == "property" && entry["name"] == "ProjectName")
);
assert!(!entries.iter().any(|entry| {
entry["kind"] == "field"
&& entry["name"]
.as_str()
.is_some_and(|name| name.contains("BackingField"))
}));
}
#[test]
fn supports_type_names_from_powershell_pipeline() {
let binary = assert_cmd::cargo::cargo_bin("asmmember");
let assembly = fixture_assembly();
let script = format!(
"'Game.UI.Windows.Windows.SpaceCraftConstructionWindow' | & '{}' --assembly '{}' --input-format lines --match Project --json",
binary.display(),
assembly.display()
);
let mut command = Command::new("pwsh");
command
.arg("-NoProfile")
.arg("-Command")
.arg(script)
.assert()
.success()
.stdout(predicate::str::contains("\"name\":\"StartProject\""));
}
+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:#?}"
);
}
}
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "asmtype"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "List managed assembly types 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
regex-lite.workspace = true
serde.workspace = true
serde_json.workspace = true
[dev-dependencies]
assert_cmd.workspace = true
predicates.workspace = true
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
//! Binary entry point for `asmtype`.
fn main() {
std::process::exit(asmtype::main_entry());
}
+137
View File
@@ -0,0 +1,137 @@
//! Integration tests for the `asmtype` command.
use assert_cmd::Command;
use predicates::prelude::*;
use serde_json::Value;
use std::path::PathBuf;
fn cargo_command() -> Command {
Command::cargo_bin("asmtype").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")
}
#[test]
fn help_includes_examples_and_pipeline_usage() {
let mut command = cargo_command();
command
.arg("--help")
.assert()
.success()
.stdout(
predicate::str::contains(
"asmtype .\\fixtures\\managed\\GameAssembly\\GameAssembly.csproj",
)
.not(),
)
.stdout(predicate::str::contains("asmtype .\\target\\"))
.stdout(predicate::str::contains("ConvertFrom-Json"))
.stdout(predicate::str::contains("--match"))
.stdout(predicate::str::contains("--user-code-only"));
}
#[test]
fn filters_types_as_json() {
let mut command = cargo_command();
let output = command
.arg(fixture_assembly())
.arg("--match")
.arg("SpaceCraft|Spacecraft")
.arg("--json")
.assert()
.success()
.get_output()
.stdout
.clone();
let payload = serde_json::from_slice::<Value>(&output).expect("json payload");
let entries = payload.as_array().expect("array payload");
assert!(entries.len() >= 4, "expected at least 4 matching types");
assert!(
entries
.iter()
.any(|entry| entry["full_name"]
== "Game.UI.Windows.Windows.SpaceCraftConstructionWindow")
);
assert!(
entries.iter().any(|entry| entry["kind"] == "struct"
&& entry["full_name"] == "Data.SpacecraftConstructData")
);
}
#[test]
fn json_no_match_is_successful_empty_array_for_pipelines() {
let mut command = cargo_command();
command
.arg(fixture_assembly())
.arg("--match")
.arg("^DefinitelyMissingType$")
.arg("--json")
.assert()
.success()
.stdout(predicate::eq("[]\n"));
}
#[test]
fn supports_powershell_pipeline_input() {
let binary = assert_cmd::cargo::cargo_bin("asmtype");
let input = fixture_assembly();
let script = format!(
"'{}' | & '{}' --input-format lines --match SpaceCraft --json",
input.display(),
binary.display()
);
let mut command = Command::new("pwsh");
command
.arg("-NoProfile")
.arg("-Command")
.arg(script)
.assert()
.success()
.stdout(predicate::str::contains(
"\"full_name\":\"Game.UI.Windows.Windows.SpaceCraftConstructionWindow\"",
));
}
#[test]
fn show_matched_members_hides_backing_fields_when_user_facing_hits_exist() {
let mut command = cargo_command();
let output = command
.arg(fixture_assembly())
.arg("--with-member-match")
.arg("StartProject|QueueVehicle|k__BackingField")
.arg("--show-matched-members")
.arg("--json")
.assert()
.success()
.get_output()
.stdout
.clone();
let payload = serde_json::from_slice::<Value>(&output).expect("json payload");
let entries = payload.as_array().expect("array payload");
let construction = entries
.iter()
.find(|entry| entry["full_name"] == "Game.UI.Windows.Windows.SpaceCraftConstructionWindow")
.expect("construction row");
assert_eq!(
construction["matched_members"],
Value::Array(vec![Value::String("StartProject".to_string())])
);
}
+31
View File
@@ -0,0 +1,31 @@
[package]
name = "await"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "Wait for paths, ports, HTTP endpoints, or repeated commands with bounded polling."
keywords.workspace = true
categories.workspace = true
[lib]
name = "awaitcmd"
[lints]
workspace = true
[dependencies]
common = { path = "../common", default-features = false }
lexopt.workspace = true
native-tls.workspace = true
runtimekit = { path = "../runtimekit" }
serde.workspace = true
serde_json.workspace = true
[dev-dependencies]
assert_cmd.workspace = true
predicates.workspace = true
tempfile.workspace = true
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
//! Binary entry point for `await`.
#![allow(
clippy::multiple_crate_versions,
reason = "native-tls is already used elsewhere in the workspace and cargo deny remains the dependency audit gate"
)]
fn main() {
std::process::exit(awaitcmd::main_entry());
}
+175
View File
@@ -0,0 +1,175 @@
//! Integration tests for the `await` command.
use std::fs;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::thread;
use assert_cmd::Command;
use predicates::prelude::*;
use tempfile::TempDir;
fn cargo_command() -> Command {
Command::cargo_bin("await").expect("binary")
}
#[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(
"error: provide a subcommand: path, port, http, or run",
))
.stderr(predicate::str::contains("await - Mercury Toolbox"))
.stderr(predicate::str::contains("Usage:"))
.stderr(predicate::str::contains("await [OPTIONS] path <PATH>"))
.stderr(predicate::str::contains("await [OPTIONS] port <TARGET>"))
.stderr(predicate::str::contains(
"Type 'await --help' for the full command reference.",
));
}
fn spawn_http_server(response: &'static [u8]) -> u16 {
let listener = TcpListener::bind(("127.0.0.1", 0)).expect("listener");
let port = listener.local_addr().expect("address").port();
thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
let mut buffer = [0_u8; 1024];
let _ = stream.read(&mut buffer);
let _ = stream.write_all(response);
}
});
port
}
#[test]
fn help_mentions_v3_modes() {
let mut command = cargo_command();
command
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("path <PATH>"))
.stdout(predicate::str::contains("port <TARGET>"))
.stdout(predicate::str::contains("http <URL>"))
.stdout(predicate::str::contains("--state exists|missing"));
}
#[test]
fn path_mode_supports_missing_state() {
let temp = TempDir::new().expect("temp dir");
let missing = temp.path().join("missing.flag");
let mut command = cargo_command();
command
.arg("path")
.arg(missing)
.arg("--json")
.arg("--state")
.arg("missing")
.assert()
.success()
.stdout(predicate::str::contains("\"ok\":true"))
.stdout(predicate::str::contains("\"kind\":\"path\""))
.stdout(predicate::str::contains("\"state\":\"missing\""));
}
#[test]
fn port_mode_uses_tcp_scheme_target() {
let listener = TcpListener::bind(("127.0.0.1", 0)).expect("listener");
let address = listener.local_addr().expect("address");
let mut command = cargo_command();
command
.arg("--json")
.arg("--timeout")
.arg("800ms")
.arg("--interval")
.arg("50ms")
.arg("port")
.arg(format!("tcp://127.0.0.1:{}", address.port()))
.assert()
.success()
.stdout(predicate::str::contains("\"ok\":true"))
.stdout(predicate::str::contains("\"kind\":\"port\""))
.stdout(predicate::str::contains("\"target\":\"tcp://127.0.0.1:"));
drop(listener);
}
#[test]
fn http_mode_supports_status_matching() {
let port = spawn_http_server(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n");
let mut command = cargo_command();
command
.arg("--json")
.arg("--timeout")
.arg("800ms")
.arg("--interval")
.arg("50ms")
.arg("http")
.arg(format!("http://127.0.0.1:{port}/health"))
.arg("--status")
.arg("204")
.assert()
.success()
.stdout(predicate::str::contains("\"ok\":true"))
.stdout(predicate::str::contains("\"kind\":\"http\""))
.stdout(predicate::str::contains("\"status_code\":204"));
}
#[cfg(windows)]
#[test]
fn run_mode_retries_until_exit_code_matches() {
let temp = TempDir::new().expect("temp dir");
let marker = temp.path().join("ready.flag");
let script = temp.path().join("flip.ps1");
fs::write(
&script,
format!(
"if (Test-Path '{}') {{ exit 0 }}\nNew-Item -ItemType File -Path '{}' | Out-Null\nexit 9\n",
marker.display(),
marker.display()
),
)
.expect("script");
let mut command = cargo_command();
command
.arg("--json")
.arg("--timeout")
.arg("20s")
.arg("--interval")
.arg("100ms")
.arg("run")
.arg("--shell")
.arg("pwsh")
.arg("--exit-code")
.arg("0")
.arg("--")
.arg(script)
.assert()
.success()
.stdout(predicate::str::contains("\"ok\":true"))
.stdout(predicate::str::contains("\"attempts\":2"))
.stdout(predicate::str::contains("\"kind\":\"run\""))
.stdout(predicate::str::contains("\"exit_code\":0"));
}
#[test]
fn path_mode_times_out_when_requested_state_never_arrives() {
let temp = TempDir::new().expect("temp dir");
let existing = temp.path().join("ready.flag");
fs::write(&existing, "ready").expect("flag");
let mut command = cargo_command();
command
.args(["--timeout", "150ms", "--interval", "50ms", "path"])
.arg(&existing)
.arg("--state")
.arg("missing")
.assert()
.code(1)
.stdout(predicate::str::contains("timeout condition=path:missing:"))
.stdout(predicate::str::contains("last=path:ok=false:exists=true"));
}
+39
View File
@@ -0,0 +1,39 @@
[package]
name = "binmeta"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "Inspect Windows binary metadata with AI-friendly output."
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[dependencies]
common = { path = "../common", default-features = false }
goblin.workspace = true
humantime.workspace = true
lexopt.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
[target.'cfg(windows)'.dependencies]
windows-sys = { workspace = true, features = [
"Win32_Foundation",
"Win32_Security_Cryptography",
"Win32_Security_Cryptography_Catalog",
"Win32_Security_Cryptography_Sip",
"Win32_Security_WinTrust",
"Win32_Storage_FileSystem",
] }
[dev-dependencies]
assert_cmd.workspace = true
predicates.workspace = true
tempfile.workspace = true
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
//! Binary entry point for `binmeta`.
fn main() {
std::process::exit(binmeta::main_entry());
}
+182
View File
@@ -0,0 +1,182 @@
//! Integration tests for the `binmeta` command.
use std::path::{Path, PathBuf};
use assert_cmd::Command;
use predicates::prelude::*;
use serde_json::Value;
fn cargo_command() -> Command {
Command::cargo_bin("binmeta").expect("binary")
}
fn fixture(path: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("fixtures")
.join(path)
}
fn inspect_json(path: &Path) -> Value {
let mut command = cargo_command();
let output = command
.arg(path)
.arg("--json")
.assert()
.success()
.get_output()
.stdout
.clone();
serde_json::from_slice::<Value>(&output).expect("json payload")
}
#[test]
fn help_includes_examples_and_json_pipeline_usage() {
let mut command = cargo_command();
command
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains(
"binmeta .\\fixtures\\binmeta\\plain.txt",
))
.stdout(predicate::str::contains("ConvertFrom-Json"))
.stdout(predicate::str::contains("--input-format"));
}
#[test]
fn inspects_plain_text_fixture_as_not_pe() {
let input = fixture("binmeta/plain.txt");
let payload = inspect_json(&input);
let entries = payload.as_array().expect("array payload");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0]["kind"], "not_pe");
assert_eq!(entries[0]["extension"], "txt");
assert_eq!(entries[0]["sha256"].as_str().map(str::len), Some(64));
assert!(
entries[0]["size_bytes"]
.as_u64()
.is_some_and(|value| value > 0)
);
assert!(entries[0]["pe"].is_null());
assert!(entries[0]["version"].is_null());
assert!(entries[0]["signature"].is_null());
}
#[test]
fn supports_powershell_pipeline_input() {
let binary = assert_cmd::cargo::cargo_bin("binmeta");
let input = fixture("binmeta/plain.txt");
let script = format!("'{}' | & '{}' --json", input.display(), binary.display());
let mut command = Command::new("pwsh");
command
.arg("-NoProfile")
.arg("-Command")
.arg(script)
.assert()
.success()
.stdout(predicate::str::contains("\"kind\":\"not_pe\""));
}
#[cfg(windows)]
#[test]
fn inspects_current_test_binary_as_pe() {
let current_exe = std::env::current_exe().expect("current exe");
let payload = inspect_json(&current_exe);
let entry = &payload.as_array().expect("array payload")[0];
assert_eq!(entry["kind"], "pe");
assert_eq!(entry["path"], current_exe.display().to_string());
assert_eq!(entry["sha256"].as_str().map(str::len), Some(64));
assert!(
entry["pe"]["machine"]
.as_str()
.is_some_and(|value| !value.is_empty())
);
assert!(
entry["pe"]["architecture"]
.as_str()
.is_some_and(|value| !value.is_empty())
);
assert!(
entry["pe"]["section_count"]
.as_u64()
.is_some_and(|value| value > 0)
);
assert!(
entry["pe"]["sections"]
.as_array()
.is_some_and(|value| !value.is_empty())
);
assert!(entry["pe"]["library_count"].as_u64().is_some());
assert!(entry.get("version").is_some());
assert!(entry.get("signature").is_some());
assert_eq!(entry["signature"]["status"], "not_signed");
assert_eq!(entry["signature"]["signature_type"], "none");
}
#[cfg(windows)]
#[test]
fn inspects_windows_binary_version_and_catalog_signature() {
let notepad = PathBuf::from(r"C:\Windows\System32\notepad.exe");
if !notepad.is_file() {
eprintln!(
"skipping notepad signature smoke: {} missing",
notepad.display()
);
return;
}
let payload = inspect_json(&notepad);
let entry = &payload.as_array().expect("array payload")[0];
assert_eq!(entry["kind"], "pe");
assert!(
entry["version"]["company_name"]
.as_str()
.is_some_and(|value| !value.is_empty())
);
assert!(
entry["version"]["file_version"]
.as_str()
.is_some_and(|value| !value.is_empty())
);
assert_eq!(entry["signature"]["status"], "valid");
assert_eq!(entry["signature"]["catalog_signed"], true);
assert_eq!(entry["signature"]["signature_type"], "catalog");
assert!(
entry["signature"]["signer"]["subject"]
.as_str()
.is_some_and(|value| value.contains("Microsoft"))
);
}
#[cfg(windows)]
#[test]
fn malformed_version_resource_still_reports_pe_identity_when_reference_exists() {
let nuitka_exe = PathBuf::from(r"C:\Users\example\Desktop\SampleTool.exe");
if !nuitka_exe.is_file() {
eprintln!(
"skipping Nuitka resource compatibility smoke: {} missing",
nuitka_exe.display()
);
return;
}
let payload = inspect_json(&nuitka_exe);
let entry = &payload.as_array().expect("array payload")[0];
assert_eq!(entry["kind"], "pe");
assert_eq!(entry["version"]["company_name"], "Example Corp");
assert_eq!(
entry["version"]["file_description"],
"Codex Thread Importer"
);
assert_eq!(entry["version"]["product_name"], "Codex Thread Importer");
assert!(
entry["parse_error"]
.as_str()
.is_some_and(|message| message.contains("ResourceString value_len"))
);
}
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "chunkcat"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "List and read deterministic chunks from large text files."
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[dependencies]
common = { path = "../common", default-features = false }
lexopt.workspace = true
serde.workspace = true
[dev-dependencies]
assert_cmd.workspace = true
predicates.workspace = true
serde_json.workspace = true
tempfile.workspace = true
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
//! Binary entry point for `chunkcat`.
fn main() {
std::process::exit(chunkcat::main_entry());
}
+196
View File
@@ -0,0 +1,196 @@
//! Integration tests for the `chunkcat` command.
use std::fs;
use std::path::{Path, PathBuf};
use assert_cmd::Command;
use predicates::prelude::*;
use serde_json::Value;
use tempfile::{TempDir, tempdir};
const SAMPLE_RS: &str = "reading/sample.rs";
fn cargo_command() -> Command {
Command::cargo_bin("chunkcat").expect("binary")
}
fn fixture(path: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("fixtures")
.join(path)
}
fn temp_file(name: &str, contents: impl AsRef<[u8]>) -> (TempDir, PathBuf) {
let dir = tempdir().expect("tempdir");
let path = dir.path().join(name);
fs::write(&path, contents).expect("fixture");
(dir, path)
}
fn pwsh_command(script: impl AsRef<str>) -> Command {
let mut command = Command::new("pwsh");
command
.arg("-NoProfile")
.arg("-Command")
.arg(script.as_ref());
command
}
fn ps_quote(value: impl std::fmt::Display) -> String {
format!("'{}'", value.to_string().replace('\'', "''"))
}
fn display_path(path: &Path) -> String {
path.display().to_string()
}
#[test]
fn lists_chunk_inventory_in_text_mode() {
let mut command = cargo_command();
command
.arg(fixture(SAMPLE_RS))
.arg("--max-lines")
.arg("8")
.arg("--overlap")
.arg("2")
.arg("--inventory")
.assert()
.success()
.stdout(predicate::str::contains("chunks=6"))
.stdout(predicate::str::contains("0 lines=1:8"))
.stdout(predicate::str::contains("5 lines=31:35"));
}
#[test]
fn renders_selected_chunk_with_line_numbers() {
let mut command = cargo_command();
command
.arg(fixture(SAMPLE_RS))
.arg("--max-lines")
.arg("8")
.arg("--overlap")
.arg("2")
.arg("--chunk")
.arg("2")
.assert()
.success()
.stdout(predicate::str::contains("chunk=2 lines=13:20"))
.stdout(predicate::str::contains("13: pub enum Mode {"))
.stdout(predicate::str::contains("20: Mode::Safe"));
}
#[test]
fn emits_json_inventory_and_selected_chunk_payloads() {
let mut inventory = cargo_command();
let inventory_output = inventory
.arg("--json")
.arg(fixture(SAMPLE_RS))
.arg("--max-lines")
.arg("8")
.arg("--overlap")
.arg("2")
.assert()
.success()
.get_output()
.stdout
.clone();
let inventory_json = serde_json::from_slice::<Value>(&inventory_output).expect("inventory");
assert_eq!(inventory_json["path"], display_path(&fixture(SAMPLE_RS)));
assert_eq!(inventory_json["total_lines"], 35);
assert_eq!(inventory_json["chunks"][0]["start_line"], 1);
assert_eq!(inventory_json["chunks"][5]["end_line"], 35);
assert!(inventory_json["selected_chunk"].is_null());
let mut selected = cargo_command();
let selected_output = selected
.arg("--json")
.arg(fixture(SAMPLE_RS))
.arg("--max-lines")
.arg("8")
.arg("--overlap")
.arg("2")
.arg("--chunk")
.arg("4")
.assert()
.success()
.get_output()
.stdout
.clone();
let selected_json = serde_json::from_slice::<Value>(&selected_output).expect("selected");
assert_eq!(selected_json["selected_chunk"]["index"], 4);
assert_eq!(selected_json["selected_chunk"]["start_line"], 25);
assert_eq!(selected_json["selected_chunk"]["lines"][0]["number"], 25);
assert_eq!(
selected_json["selected_chunk"]["lines"][0]["text"],
" match mode {"
);
}
#[test]
fn supports_powershell_pipeline_for_path_input() {
let binary = assert_cmd::cargo::cargo_bin("chunkcat");
let input = fixture(SAMPLE_RS);
let script = format!(
"{} | & {} --json --max-lines 10 --chunk 1",
ps_quote(input.display()),
ps_quote(binary.display())
);
let mut command = pwsh_command(script);
command
.assert()
.success()
.stdout(predicate::str::contains("\"selected_chunk\""))
.stdout(predicate::str::contains("\"index\":1"));
}
#[test]
fn utf8_bom_file_does_not_pollute_first_chunk_line() {
let (_dir, path) = temp_file("bom.txt", "\u{feff}alpha\nbeta\n");
let mut command = cargo_command();
command
.arg(&path)
.arg("--max-lines")
.arg("2")
.arg("--chunk")
.arg("0")
.assert()
.success()
.stdout(predicate::str::contains("1: alpha"))
.stdout(predicate::str::contains("\u{feff}alpha").not());
}
#[test]
fn invalid_utf8_file_reports_text_read_error() {
let (_dir, path) = temp_file("invalid-utf8.txt", [0x61, 0x80, 0x0A]);
let mut command = cargo_command();
command
.arg(&path)
.arg("--max-lines")
.arg("2")
.arg("--chunk")
.arg("0")
.assert()
.failure()
.stderr(predicate::str::contains("looks like a binary file"))
.stderr(predicate::str::contains("invalid-utf8.txt"));
}
#[test]
fn help_includes_examples_and_pipeline_usage() {
let mut command = cargo_command();
command
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains(
"chunkcat .\\fixtures\\reading\\sample.rs --max-lines 8",
))
.stdout(predicate::str::contains("--inventory"))
.stdout(predicate::str::contains("ConvertFrom-Json"))
.stdout(predicate::str::contains("--chunk"));
}
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "cjson"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "Compact JSON and JSONL into stable single-line output."
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[dependencies]
common = { path = "../common", default-features = false }
lexopt.workspace = true
serde.workspace = true
serde_json = { workspace = true, features = ["preserve_order"] }
[dev-dependencies]
assert_cmd.workspace = true
predicates.workspace = true
+829
View File
@@ -0,0 +1,829 @@
//! The `cjson` command compacts JSON and JSONL.
use std::ffi::OsString;
use std::fs;
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use common::{
CliError, CommonArgs, ExitCode, InputFormat, RenderMode, parse_color_choice,
parse_format_choice, parse_input_format, print_json, print_quick_help_error, print_structured,
read_existing_stdin_paths, should_read_stdin,
};
use lexopt::prelude::{Long, Short, Value as ArgValue};
use serde::Serialize;
use serde_json::Value;
const MAX_JSON_INPUT_BYTES: u64 = 64 * 1024 * 1024;
const MAX_SORT_DEPTH: usize = 512;
const HELP: &str = "\
Compact JSON and JSONL into single-line output with optional recursive key sorting.
Usage:
cjson [OPTIONS] [PATH]
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, json, lines, jsonl
--color <WHEN> Control ANSI color output: auto, never
--quiet Suppress non-essential status output
--sort-keys Sort object keys recursively before rendering
-h, --help Show this help text
-V, --version Show the command version
Examples:
cjson .\\fixtures\\cjson\\sample.json
bat --style=plain --paging=never .\\fixtures\\cjson\\records.jsonl | cjson --input-format jsonl --sort-keys
bat --style=plain --paging=never .\\fixtures\\cjson\\sample.json | cjson --input-format json
'.\\fixtures\\cjson\\sample.json' | cjson --input-format lines
cjson --sort-keys --json .\\fixtures\\cjson\\sample.json | ConvertFrom-Json
";
#[derive(Debug, Clone)]
struct Cli {
common: CommonArgs,
sort_keys: bool,
path: Option<PathBuf>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ParseOutcome {
Help,
Version,
Run,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CompactFormat {
Json,
Jsonl,
}
#[derive(Debug, Clone, PartialEq)]
struct ParsedDocuments {
format: CompactFormat,
documents: Vec<Value>,
skipped_empty: usize,
skipped_empty_paths: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct CompactJsonPayload {
format: &'static str,
documents: usize,
skipped_empty: usize,
skipped_empty_paths: Vec<String>,
text: String,
}
#[derive(Debug, Clone, PartialEq)]
enum LoadedInput {
Content(String),
Paths(Vec<PathBuf>),
}
/// 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, _)) => {
print!("{HELP}");
ExitCode::Success.as_i32()
}
Ok((ParseOutcome::Version, _)) => {
println!("cjson {}", 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 cli = Cli {
common: CommonArgs::default(),
sort_keys: false,
path: None,
};
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)),
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") => {
let value = parser_value_string(&mut parser, "--input-format")?;
cli.common.input_format = parse_cjson_input_format(&value)?;
}
Long("color") => {
let value = parser_value_string(&mut parser, "--color")?;
cli.common.color = parse_color_choice(&value)?;
}
Long("quiet") => cli.common.quiet = true,
Long("sort-keys") => cli.sort_keys = true,
ArgValue(path) => {
if cli.path.replace(PathBuf::from(path)).is_some() {
return Err(CliError::usage("cjson accepts at most one explicit path"));
}
}
_ => {
return Err(CliError::usage(
"unsupported argument; use --help to see available options",
));
}
}
}
Ok((ParseOutcome::Run, cli))
}
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 input = load_input(cli)?;
let parsed = match input {
LoadedInput::Content(content) => {
let input_format =
if cli.path.is_some() && cli.common.input_format == InputFormat::Lines {
InputFormat::Auto
} else {
cli.common.input_format
};
parse_documents(&content, input_format, None)?
}
LoadedInput::Paths(paths) => parse_documents_from_paths(&paths)?,
};
let document_count = parsed.documents.len();
let text = compact_documents(parsed.documents, parsed.format, cli.sort_keys)?;
match cli.common.render_mode() {
RenderMode::Json => print_json(&CompactJsonPayload {
format: parsed.format.as_str(),
documents: document_count,
skipped_empty: parsed.skipped_empty,
skipped_empty_paths: parsed.skipped_empty_paths,
text,
})?,
RenderMode::Toon => print_structured(
&CompactJsonPayload {
format: parsed.format.as_str(),
documents: document_count,
skipped_empty: parsed.skipped_empty,
skipped_empty_paths: parsed.skipped_empty_paths,
text,
},
RenderMode::Toon,
)?,
RenderMode::Text => {
write_text_output(&text)?;
emit_skipped_empty_note(
parsed.skipped_empty,
&parsed.skipped_empty_paths,
cli.common.quiet,
);
}
}
Ok(ExitCode::Success)
}
fn load_input(cli: &Cli) -> Result<LoadedInput, CliError> {
if should_read_stdin(cli.path.is_some(), cli.common.stdin_is_terminal()) {
let buffer = read_to_string_limited(io::stdin(), MAX_JSON_INPUT_BYTES, "stdin")?;
if !buffer.is_empty() {
if cli.common.input_format != InputFormat::Jsonl
&& let Some(paths) =
read_existing_stdin_paths(&buffer, cli.common.input_format, "cjson")?
{
return Ok(LoadedInput::Paths(paths));
}
return Ok(LoadedInput::Content(buffer));
}
}
let Some(path) = &cli.path else {
return Err(CliError::usage(
"provide one JSON path or pipe JSON/JSONL into stdin",
));
};
Ok(LoadedInput::Paths(common::expand_input_patterns(
std::slice::from_ref(path),
"cjson",
)?))
}
fn parse_documents_from_paths(paths: &[PathBuf]) -> Result<ParsedDocuments, CliError> {
let mut documents = Vec::new();
let mut skipped_empty = 0_usize;
let mut skipped_empty_paths = Vec::new();
let mut format = if paths.len() > 1 {
CompactFormat::Jsonl
} else {
CompactFormat::Json
};
for path in paths {
let content = read_path_to_string_limited(path, MAX_JSON_INPUT_BYTES)?;
if paths.len() > 1 && content.trim().is_empty() {
skipped_empty += 1;
skipped_empty_paths.push(path.display().to_string());
continue;
}
let parsed = parse_documents(&content, InputFormat::Auto, Some(path))?;
if paths.len() == 1 {
format = parsed.format;
}
documents.extend(parsed.documents);
skipped_empty += parsed.skipped_empty;
skipped_empty_paths.extend(parsed.skipped_empty_paths);
}
if documents.is_empty() {
if skipped_empty > 0 {
return Err(CliError::runtime(format!(
"all {skipped_empty} JSON input path(s) were empty"
)));
}
return Err(CliError::runtime("JSON input is empty"));
}
Ok(ParsedDocuments {
format,
documents,
skipped_empty,
skipped_empty_paths,
})
}
fn read_path_to_string_limited(path: &Path, max_bytes: u64) -> Result<String, CliError> {
let metadata = fs::metadata(path).map_err(|error| {
CliError::runtime(format!(
"failed to read metadata for {}: {error}",
path.display()
))
})?;
if metadata.len() > max_bytes {
return Err(CliError::runtime(format!(
"{} is {} byte(s), above the cjson input limit of {max_bytes} byte(s)",
path.display(),
metadata.len()
)));
}
fs::read_to_string(path)
.map_err(|error| CliError::runtime(format!("failed to read {}: {error}", path.display())))
}
fn read_to_string_limited<R: Read>(
reader: R,
max_bytes: u64,
label: &str,
) -> Result<String, CliError> {
let mut limited = reader.take(max_bytes.saturating_add(1));
let mut buffer = String::new();
limited
.read_to_string(&mut buffer)
.map_err(|error| CliError::runtime(format!("failed to read {label}: {error}")))?;
if buffer.len() as u64 > max_bytes {
return Err(CliError::runtime(format!(
"{label} exceeds the cjson input limit of {max_bytes} byte(s)"
)));
}
Ok(buffer)
}
fn parse_documents(
input: &str,
input_format: InputFormat,
source_path: Option<&std::path::Path>,
) -> Result<ParsedDocuments, CliError> {
if input.trim().is_empty() {
return Err(empty_input_error("JSON", source_path));
}
match input_format {
InputFormat::Auto => parse_auto_documents(input, source_path),
InputFormat::Jsonl => parse_jsonl_documents(input, source_path),
InputFormat::Lines => Err(CliError::usage(
"cjson does not support --input-format lines; use auto or jsonl",
)),
}
}
fn parse_cjson_input_format(value: &str) -> Result<InputFormat, CliError> {
if value.eq_ignore_ascii_case("json") {
Ok(InputFormat::Auto)
} else {
parse_input_format(value)
}
}
fn parse_auto_documents(
input: &str,
source_path: Option<&Path>,
) -> Result<ParsedDocuments, CliError> {
if source_path.is_some_and(has_jsonl_extension) {
return parse_jsonl_documents(input, source_path);
}
let trimmed = input.trim();
match serde_json::from_str::<Value>(trimmed) {
Ok(document) => Ok(ParsedDocuments {
format: CompactFormat::Json,
documents: vec![document],
skipped_empty: 0,
skipped_empty_paths: Vec::new(),
}),
Err(json_error) => {
if source_path.is_some_and(has_json_extension) {
return Err(invalid_json_input_error(&json_error));
}
let mut non_empty_lines = input.lines().map(str::trim).filter(|line| !line.is_empty());
if non_empty_lines.next().is_some() && non_empty_lines.next().is_some() {
match parse_jsonl_documents(input, source_path) {
Ok(parsed) => return Ok(parsed),
Err(line_stream_error) => {
if looks_like_json_document(trimmed) {
return Err(invalid_json_input_error(&json_error));
}
return Err(line_stream_error);
}
}
}
if looks_like_json_document(trimmed) {
return Err(invalid_json_input_error(&json_error));
}
parse_jsonl_documents(input, source_path)
}
}
}
fn parse_jsonl_documents(
input: &str,
source_path: Option<&Path>,
) -> Result<ParsedDocuments, CliError> {
let mut documents = Vec::new();
for (index, line) in input.lines().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let document = serde_json::from_str::<Value>(trimmed).map_err(|error| {
CliError::runtime(format!("invalid JSONL at line {}: {error}", index + 1))
})?;
documents.push(document);
}
if documents.is_empty() {
return Err(empty_input_error("JSONL", source_path));
}
Ok(ParsedDocuments {
format: CompactFormat::Jsonl,
documents,
skipped_empty: 0,
skipped_empty_paths: Vec::new(),
})
}
fn empty_input_error(kind: &str, source_path: Option<&Path>) -> CliError {
source_path.map_or_else(
|| CliError::runtime(format!("{kind} input is empty")),
|path| CliError::runtime(format!("{kind} input is empty: {}", path.display())),
)
}
fn compact_documents(
documents: Vec<Value>,
format: CompactFormat,
sort_keys: bool,
) -> Result<String, CliError> {
match format {
CompactFormat::Json => compact_single_document(documents, sort_keys),
CompactFormat::Jsonl => compact_jsonl_documents(documents, sort_keys),
}
}
fn compact_single_document(documents: Vec<Value>, sort_keys: bool) -> Result<String, CliError> {
let mut iter = documents.into_iter();
let Some(document) = iter.next() else {
return Err(CliError::runtime(
"internal error: missing JSON document for compaction",
));
};
if iter.next().is_some() {
return Err(CliError::runtime(
"internal error: JSON compaction received multiple documents",
));
}
serialize_document(document, sort_keys)
}
fn compact_jsonl_documents(documents: Vec<Value>, sort_keys: bool) -> Result<String, CliError> {
let mut rendered = String::with_capacity(documents.len().saturating_mul(96));
for (index, document) in documents.into_iter().enumerate() {
if index > 0 {
rendered.push('\n');
}
rendered.push_str(&serialize_document(document, sort_keys)?);
}
Ok(rendered)
}
fn serialize_document(mut document: Value, sort_keys: bool) -> Result<String, CliError> {
if sort_keys {
sort_value(&mut document, 0)?;
}
serde_json::to_string(&document)
.map_err(|error| CliError::runtime(format!("failed to render JSON: {error}")))
}
fn sort_value(value: &mut Value, depth: usize) -> Result<(), CliError> {
if depth > MAX_SORT_DEPTH {
return Err(CliError::runtime(format!(
"JSON nesting exceeds cjson --sort-keys limit of {MAX_SORT_DEPTH}"
)));
}
match value {
Value::Object(map) => {
let mut entries = std::mem::take(map).into_iter().collect::<Vec<_>>();
entries.sort_unstable_by(|left, right| left.0.cmp(&right.0));
for (key, mut child) in entries {
sort_value(&mut child, depth + 1)?;
let _ = map.insert(key, child);
}
}
Value::Array(items) => {
for item in items {
sort_value(item, depth + 1)?;
}
}
Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
}
Ok(())
}
fn write_text_output(text: &str) -> Result<(), CliError> {
let mut stdout = io::stdout().lock();
stdout
.write_all(text.as_bytes())
.map_err(|error| CliError::runtime(format!("failed to write stdout: {error}")))?;
stdout
.write_all(b"\n")
.map_err(|error| CliError::runtime(format!("failed to write stdout: {error}")))
}
fn emit_skipped_empty_note(skipped_empty: usize, skipped_empty_paths: &[String], quiet: bool) {
if quiet || skipped_empty == 0 {
return;
}
let preview = skipped_empty_paths
.iter()
.take(3)
.cloned()
.collect::<Vec<_>>()
.join(", ");
let suffix = if skipped_empty_paths.len() > 3 {
format!(" (+{} more)", skipped_empty_paths.len() - 3)
} else {
String::new()
};
eprintln!("note: skipped {skipped_empty} empty JSON input path(s): {preview}{suffix}");
}
impl CompactFormat {
const fn as_str(self) -> &'static str {
match self {
Self::Json => "json",
Self::Jsonl => "jsonl",
}
}
}
fn has_json_extension(path: &Path) -> bool {
has_extension(path, "json")
}
fn has_jsonl_extension(path: &Path) -> bool {
has_extension(path, "jsonl") || has_extension(path, "ndjson")
}
fn has_extension(path: &Path, expected: &str) -> bool {
path.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case(expected))
}
fn looks_like_json_document(input: &str) -> bool {
input.starts_with('{')
|| input.starts_with('[')
|| input.starts_with('"')
|| matches!(input.as_bytes().first(), Some(b'-' | b'0'..=b'9'))
|| input == "true"
|| input == "false"
|| input == "null"
}
fn invalid_json_input_error(error: &serde_json::Error) -> CliError {
CliError::runtime(format!("invalid JSON input: {error}"))
}
#[cfg(test)]
mod tests {
use common::{ColorChoice, InputFormat};
use serde_json::json;
use super::*;
fn common_args(json: bool, input_format: InputFormat) -> CommonArgs {
CommonArgs {
json,
format: None,
input_format,
color: ColorChoice::Never,
quiet: false,
}
}
#[test]
fn parse_documents_supports_auto_json_and_jsonl() {
let single = parse_documents(
"{\n \"z\": 3,\n \"a\": {\"y\": 2, \"x\": 1}\n}\n",
InputFormat::Auto,
None,
)
.expect("single JSON document");
assert_eq!(
single,
ParsedDocuments {
format: CompactFormat::Json,
documents: vec![json!({"z": 3, "a": {"y": 2, "x": 1}})],
skipped_empty: 0,
skipped_empty_paths: Vec::new(),
}
);
let stream = parse_documents(
"{\"ok\":true,\"event\":\"login\"}\n{\"ok\":false,\"event\":\"logout\"}\n",
InputFormat::Jsonl,
None,
)
.expect("jsonl documents");
assert_eq!(stream.format, CompactFormat::Jsonl);
assert_eq!(
stream.documents,
vec![
json!({"ok": true, "event": "login"}),
json!({"ok": false, "event": "logout"}),
]
);
}
#[test]
fn parse_auto_jsonl_preserves_invalid_json_precedence() {
let stream = parse_documents(
"{\"ok\":true,\"event\":\"login\"}\n\n{\"ok\":false,\"event\":\"logout\"}\n",
InputFormat::Auto,
None,
)
.expect("auto jsonl documents");
assert_eq!(stream.format, CompactFormat::Jsonl);
assert_eq!(
stream.documents,
vec![
json!({"ok": true, "event": "login"}),
json!({"ok": false, "event": "logout"}),
]
);
let invalid_json = parse_documents("{\"ok\": true}\nnot-json\n", InputFormat::Auto, None)
.expect_err("json-looking input should prefer JSON error");
assert!(matches!(
invalid_json,
CliError::Runtime(message)
if message.contains("invalid JSON input")
&& !message.contains("JSONL")
));
let invalid_jsonl = parse_documents("not-json\n{\"ok\":true}\n", InputFormat::Auto, None)
.expect_err("non-json-looking input should report JSONL line error");
assert!(matches!(
invalid_jsonl,
CliError::Runtime(message)
if message.contains("invalid JSONL at line 1")
));
}
#[test]
fn parse_documents_rejects_lines_mode_and_empty_input() {
let lines_error =
parse_documents("{\"ok\":true}\n", InputFormat::Lines, None).expect_err("lines mode");
assert!(matches!(
lines_error,
CliError::Usage(message)
if message.contains("does not support --input-format lines")
));
let empty_error =
parse_documents(" \n\t", InputFormat::Auto, None).expect_err("empty input");
assert!(matches!(
empty_error,
CliError::Runtime(message)
if message.contains("JSON input is empty")
));
let invalid_json = parse_documents("{\"ok\": true,,}\n", InputFormat::Auto, None)
.expect_err("invalid json should fail");
assert!(matches!(
invalid_json,
CliError::Runtime(message)
if message.contains("invalid JSON input")
&& !message.contains("JSONL")
));
}
#[test]
fn cjson_input_format_accepts_json_alias() {
assert_eq!(
parse_cjson_input_format("json").expect("json alias"),
InputFormat::Auto
);
assert_eq!(
parse_cjson_input_format("jsonl").expect("jsonl"),
InputFormat::Jsonl
);
}
#[test]
fn input_readers_reject_payloads_above_size_limit() {
let error = read_to_string_limited(std::io::Cursor::new("abcd"), 3, "stdin")
.expect_err("oversize stdin rejected");
assert!(error.to_string().contains("cjson input limit"));
let temp = std::env::temp_dir().join(format!(
"cjson-large-input-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_nanos()
));
std::fs::write(&temp, "abcd").expect("fixture");
let error = read_path_to_string_limited(&temp, 3).expect_err("oversize file rejected");
assert!(error.to_string().contains("cjson input limit"));
std::fs::remove_file(temp).expect("cleanup");
}
#[test]
fn sort_keys_rejects_extreme_json_nesting() {
let mut value = json!(true);
for _ in 0..(MAX_SORT_DEPTH + 2) {
value = json!({ "child": value });
}
let error = serialize_document(value, true).expect_err("deep sort rejected");
assert!(error.to_string().contains("sort-keys limit"));
}
#[test]
fn compaction_can_sort_keys_recursively() {
let rendered = compact_documents(
vec![json!({
"z": 3,
"a": {"y": 2, "x": 1},
"items": [{"b": 2, "a": 1}],
"name": "Ada",
})],
CompactFormat::Json,
true,
)
.expect("sorted compaction");
assert_eq!(
rendered,
"{\"a\":{\"x\":1,\"y\":2},\"items\":[{\"a\":1,\"b\":2}],\"name\":\"Ada\",\"z\":3}"
);
}
#[test]
fn run_supports_text_and_json_wrapper_modes() {
let text_exit = run(&Cli {
common: common_args(false, InputFormat::Auto),
sort_keys: false,
path: Some(
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("fixtures")
.join("cjson")
.join("sample.json"),
),
})
.expect("text run");
assert_eq!(text_exit, ExitCode::Success);
let json_exit = run(&Cli {
common: common_args(true, InputFormat::Jsonl),
sort_keys: true,
path: Some(
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("fixtures")
.join("cjson")
.join("records.jsonl"),
),
})
.expect("json run");
assert_eq!(json_exit, ExitCode::Success);
}
#[test]
fn load_input_accepts_multiple_stdin_paths() {
let temp = std::env::temp_dir().join(format!(
"cjson-stdin-paths-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_nanos()
));
std::fs::create_dir_all(&temp).expect("tempdir");
let sample = temp.join("sample.json");
let second = temp.join("second.json");
std::fs::write(&sample, "{\"ok\":true}\n").expect("sample");
std::fs::write(&second, "{\"ok\":false}\n").expect("second");
let loaded_input = load_input_from_buffer(
&Cli {
common: common_args(false, InputFormat::Lines),
sort_keys: false,
path: None,
},
&format!("{}\n{}\n", sample.display(), second.display()),
)
.expect("stdin paths");
let LoadedInput::Paths(paths) = loaded_input else {
panic!("expected path stream input");
};
let loaded = parse_documents_from_paths(&paths).expect("parsed");
assert_eq!(loaded.format, CompactFormat::Jsonl);
assert_eq!(loaded.documents.len(), 2);
std::fs::remove_dir_all(temp).expect("cleanup");
}
fn load_input_from_buffer(cli: &Cli, buffer: &str) -> Result<LoadedInput, CliError> {
if cli.common.input_format != InputFormat::Jsonl
&& let Some(paths) =
read_existing_stdin_paths(buffer, cli.common.input_format, "cjson")?
{
return Ok(LoadedInput::Paths(paths));
}
Ok(LoadedInput::Content(buffer.to_string()))
}
}
+5
View File
@@ -0,0 +1,5 @@
//! Binary entry point for `cjson`.
fn main() {
std::process::exit(cjson::main_entry());
}
+247
View File
@@ -0,0 +1,247 @@
//! Integration tests for the `cjson` command.
use std::{
fs,
path::PathBuf,
sync::atomic::{AtomicU64, Ordering},
};
use assert_cmd::Command;
use predicates::prelude::*;
use serde_json::Value;
fn cargo_command() -> Command {
Command::cargo_bin("cjson").expect("binary")
}
fn cargo_binary() -> PathBuf {
assert_cmd::cargo::cargo_bin("cjson")
}
fn powershell_command(script: String) -> Command {
let mut command = Command::new("pwsh");
command.args(["-NoProfile", "-Command"]).arg(script);
command
}
fn ps_quote(value: impl std::fmt::Display) -> String {
format!("'{}'", value.to_string().replace('\'', "''"))
}
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
}
fn fixture(path: &str) -> PathBuf {
let fixture = workspace_root().join("fixtures").join(path);
assert!(
fixture.exists(),
"missing fixture `{path}` at {}",
fixture.display()
);
fixture
}
fn json_stdout(output: &[u8]) -> Value {
serde_json::from_slice(output).unwrap_or_else(|error| {
panic!(
"stdout should be valid JSON: {error}\n{}",
String::from_utf8_lossy(output)
)
})
}
struct TempTestDir {
path: PathBuf,
}
impl TempTestDir {
fn path(&self) -> &std::path::Path {
&self.path
}
}
impl Drop for TempTestDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
fn temp_test_dir(name: &str) -> TempTestDir {
static NEXT_ID: AtomicU64 = AtomicU64::new(0);
loop {
let unique = NEXT_ID.fetch_add(1, Ordering::Relaxed);
let path =
std::env::temp_dir().join(format!("cjson-{}-{name}-{unique}", std::process::id()));
match fs::create_dir(&path) {
Ok(()) => return TempTestDir { path },
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(error) => panic!("temp test dir: {error}"),
}
}
}
#[test]
fn compacts_json_file_in_text_mode() {
let output = cargo_command()
.arg(fixture("cjson/sample.json"))
.assert()
.success()
.get_output()
.stdout
.clone();
let compacted: Value = json_stdout(&output);
assert_eq!(
compacted["name"],
"Ada",
"stdout={}",
String::from_utf8_lossy(&output)
);
assert_eq!(
compacted["a"]["x"],
1,
"stdout={}",
String::from_utf8_lossy(&output)
);
}
#[test]
fn sort_keys_reorders_objects_recursively() {
cargo_command()
.arg("--sort-keys")
.write_stdin(
"{\"z\":3,\"a\":{\"y\":2,\"x\":1},\"items\":[{\"b\":2,\"a\":1}],\"name\":\"Ada\"}",
)
.assert()
.success()
.stdout(
"{\"a\":{\"x\":1,\"y\":2},\"items\":[{\"a\":1,\"b\":2}],\"name\":\"Ada\",\"z\":3}\n",
);
}
#[test]
fn explicit_path_wins_over_piped_stdin_noise() {
cargo_command()
.arg(fixture("cjson/sample.json"))
.write_stdin("not json from upstream pipeline")
.assert()
.success()
.stdout(predicate::str::contains("\"name\":\"Ada\""));
}
#[test]
fn json_wrapper_reports_jsonl_documents() {
let output = cargo_command()
.arg("--input-format")
.arg("jsonl")
.arg("--sort-keys")
.arg("--json")
.arg(fixture("cjson/records.jsonl"))
.assert()
.success()
.get_output()
.stdout
.clone();
let payload = json_stdout(&output);
assert_eq!(
payload["format"],
"jsonl",
"stdout={}",
String::from_utf8_lossy(&output)
);
assert_eq!(
payload["documents"],
2,
"stdout={}",
String::from_utf8_lossy(&output)
);
assert!(
payload["text"]
.as_str()
.is_some_and(|text| text.contains("\"event\":\"login\",\"ok\":true")),
"stdout={}",
String::from_utf8_lossy(&output)
);
}
#[test]
fn auto_mode_treats_explicit_jsonl_and_ndjson_paths_as_line_streams() {
let temp = temp_test_dir("auto-jsonl-paths");
let jsonl = temp.path().join("single.jsonl");
let ndjson = temp.path().join("single.ndjson");
fs::write(&jsonl, "{\"ok\":true}\n").expect("jsonl fixture");
fs::write(&ndjson, "{\"ok\":true}\n").expect("ndjson fixture");
for path in [&jsonl, &ndjson] {
let mut command = cargo_command();
command
.arg("--json")
.arg(path)
.assert()
.success()
.stdout(predicate::str::contains("\"format\":\"jsonl\""))
.stdout(predicate::str::contains("\"documents\":1"));
}
}
#[test]
fn supports_powershell_pipeline() {
let binary = cargo_binary();
let input = fixture("cjson/records.jsonl");
let script = format!(
"[System.IO.File]::ReadLines({}) | & {} --input-format jsonl --sort-keys",
ps_quote(input.display()),
ps_quote(binary.display())
);
powershell_command(script)
.assert()
.success()
.stdout(predicate::str::contains(
"{\"event\":\"login\",\"ok\":true}",
))
.stdout(predicate::str::contains(
"{\"event\":\"logout\",\"ok\":false}",
));
}
#[test]
fn accepts_single_stdin_path_stream_in_lines_mode() {
cargo_command()
.args(["--input-format", "lines"])
.write_stdin(format!("{}\n", fixture("cjson/sample.json").display()))
.assert()
.success()
.stdout(predicate::str::contains("\"name\":\"Ada\""));
}
#[test]
fn lines_mode_accepts_windows_paths_with_quotes_and_spaces() {
let temp = temp_test_dir("quoted path");
let path = temp.path().join("Ada's sample.json");
fs::write(&path, "{\"name\":\"Ada\",\"ok\":true}\n").expect("quoted path fixture");
cargo_command()
.args(["--input-format", "lines"])
.write_stdin(format!("{}\n", path.display()))
.assert()
.success()
.stdout(predicate::str::contains("\"name\":\"Ada\""));
}
#[test]
fn help_includes_examples_and_sort_keys_flag() {
cargo_command()
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("--sort-keys"))
.stdout(predicate::str::contains(
"bat --style=plain --paging=never .\\fixtures\\cjson\\records.jsonl",
))
.stdout(predicate::str::contains("ConvertFrom-Json"));
}
+49
View File
@@ -0,0 +1,49 @@
[package]
name = "codeindex"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "Shared tree-sitter-based code indexing for Mercury Toolbox."
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[features]
default = ["all-languages"]
all-languages = [
"lang-rust",
"lang-csharp",
"lang-powershell",
"lang-python",
"lang-go",
"lang-java",
"lang-javascript",
"lang-typescript",
]
lang-rust = ["dep:tree-sitter-rust"]
lang-csharp = ["dep:tree-sitter-c-sharp"]
lang-powershell = ["dep:tree-sitter-powershell"]
lang-python = ["dep:tree-sitter-python"]
lang-go = ["dep:tree-sitter-go"]
lang-java = ["dep:tree-sitter-java"]
lang-javascript = ["dep:tree-sitter-javascript"]
lang-typescript = ["dep:tree-sitter-typescript"]
[dependencies]
common = { path = "../common", default-features = false }
serde.workspace = true
tree-sitter.workspace = true
tree-sitter-c-sharp = { workspace = true, optional = true }
tree-sitter-go = { workspace = true, optional = true }
tree-sitter-javascript = { workspace = true, optional = true }
tree-sitter-java = { workspace = true, optional = true }
tree-sitter-powershell = { workspace = true, optional = true }
tree-sitter-python = { workspace = true, optional = true }
tree-sitter-rust = { workspace = true, optional = true }
tree-sitter-typescript = { workspace = true, optional = true }
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "codeshape"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "Emit recursive AST-backed codebase maps with compact signatures."
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[dependencies]
codeindex = { path = "../codeindex" }
common = { path = "../common", default-features = false }
lexopt.workspace = true
serde.workspace = true
[dev-dependencies]
assert_cmd.workspace = true
predicates.workspace = true
+700
View File
@@ -0,0 +1,700 @@
//! The `codeshape` command emits recursive AST-backed project maps.
use codeindex::{
CodeIndexer, CodeLanguage, ENGINE_NAME, IndexedSymbol, SUPPORTED_LANGUAGE_LIST, SymbolKind,
detect_language, parse_language_label,
};
use common::{
CliError, CommonArgs, ExitCode, InputFormat, RenderMode, collect_matching_files,
parse_color_choice, parse_format_choice, parse_input_format, print_json,
print_quick_help_error, print_structured, should_read_stdin,
};
use lexopt::prelude::{Long, Short, Value as ArgValue};
use serde::Serialize;
use std::collections::BTreeSet;
use std::ffi::OsString;
use std::fmt::Write as _;
use std::fs;
use std::io::{self, Read};
use std::path::{Path, PathBuf};
const MAX_SOURCE_BYTES: u64 = 8 * 1024 * 1024;
const HELP: &str = "\
Emit recursive AST-backed codebase maps with compact signatures via the shared codeindex engine.
Usage:
codeshape [OPTIONS] [PATH...]
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
--lang <LIST> Restrict languages: rust,csharp,powershell,python,go,java,javascript,typescript
--max-files <COUNT> Maximum number of files to include
--max-depth <COUNT> Maximum symbol depth to include
--limit-per-file <COUNT> Maximum symbols to include per file after depth filtering
-h, --help Show this help text
-V, --version Show the command version
Examples:
codeshape .\\fixtures\\polyglot\\repo
codeshape --max-depth 1 --limit-per-file 8 . --json | ConvertFrom-Json
'.\\fixtures\\polyglot\\repo' | codeshape --json | ConvertFrom-Json
JSON fields:
engine, roots, files[].path, files[].language, files[].items[], totals
";
#[derive(Debug, Clone)]
struct Cli {
common: CommonArgs,
paths: Vec<PathBuf>,
languages: Option<BTreeSet<CodeLanguage>>,
max_files: usize,
max_depth: Option<usize>,
limit_per_file: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ParseOutcome {
Help,
Version,
Run,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct CodeShapeItem {
kind: SymbolKind,
name: String,
qualified_name: String,
signature: String,
depth: usize,
start_line: usize,
end_line: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct FileReport {
path: String,
language: CodeLanguage,
items: Vec<CodeShapeItem>,
omitted_items: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct Totals {
files_seen: usize,
files_indexed: usize,
files_omitted_by_limit: usize,
symbols_emitted: usize,
symbols_omitted: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct CodeShapeReport {
engine: &'static str,
roots: Vec<String>,
files: Vec<FileReport>,
totals: Totals,
}
/// 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, _)) => {
print!("{HELP}");
ExitCode::Success.as_i32()
}
Ok((ParseOutcome::Version, _)) => {
println!("codeshape {}", 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()
}
}
}
#[allow(
clippy::too_many_lines,
reason = "single-pass CLI parsing keeps global and shared output flags auditable"
)]
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 mut paths = Vec::new();
let mut languages = None::<BTreeSet<CodeLanguage>>;
let mut max_files = 200_usize;
let mut max_depth = None::<usize>;
let mut limit_per_file = 32_usize;
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 {
common,
paths,
languages,
max_files,
max_depth,
limit_per_file,
},
));
}
Long("version") | Short('V') => {
return Ok((
ParseOutcome::Version,
Cli {
common,
paths,
languages,
max_files,
max_depth,
limit_per_file,
},
));
}
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("input-format") => {
common.input_format =
parse_input_format(&parser_value_string(&mut parser, "--input-format")?)?;
}
Long("color") => {
common.color = parse_color_choice(&parser_value_string(&mut parser, "--color")?)?;
}
Long("quiet") => common.quiet = true,
Long("lang") => {
languages = Some(parse_language_list(&parser_value_string(
&mut parser,
"--lang",
)?)?);
}
Long("max-files") => {
max_files = parse_usize_flag(
"--max-files",
&parser_value_string(&mut parser, "--max-files")?,
)?;
}
Long("max-depth") => {
max_depth = Some(parse_usize_flag(
"--max-depth",
&parser_value_string(&mut parser, "--max-depth")?,
)?);
}
Long("limit-per-file") => {
limit_per_file = parse_usize_flag(
"--limit-per-file",
&parser_value_string(&mut parser, "--limit-per-file")?,
)?;
}
ArgValue(value) => paths.push(PathBuf::from(value)),
_ => {
return Err(CliError::usage(
"unsupported argument; use --help to see available options",
));
}
}
}
if max_files == 0 {
return Err(CliError::usage("--max-files must be greater than 0"));
}
if limit_per_file == 0 {
return Err(CliError::usage("--limit-per-file must be greater than 0"));
}
Ok((
ParseOutcome::Run,
Cli {
common,
paths,
languages,
max_files,
max_depth,
limit_per_file,
},
))
}
fn run(cli: &Cli) -> Result<ExitCode, CliError> {
let report = build_report(cli)?;
match cli.common.render_mode() {
RenderMode::Json => print_json(&report)?,
RenderMode::Toon => print_structured(&report, RenderMode::Toon)?,
RenderMode::Text => print!("{}", render_text(&report)),
}
Ok(if report.files.is_empty() {
ExitCode::NoResults
} else {
ExitCode::Success
})
}
fn build_report(cli: &Cli) -> Result<CodeShapeReport, CliError> {
let roots = collect_roots(cli)?;
let discovered = discover_supported_files(&roots)?;
let mut indexer = CodeIndexer::new();
let mut files = Vec::new();
let mut totals = Totals {
files_seen: discovered.len(),
files_indexed: 0,
files_omitted_by_limit: 0,
symbols_emitted: 0,
symbols_omitted: 0,
};
for path in discovered {
if files.len() >= cli.max_files {
totals.files_omitted_by_limit += 1;
continue;
}
let Some(language) = detect_language(&path) else {
continue;
};
if cli
.languages
.as_ref()
.is_some_and(|languages| !languages.contains(&language))
{
continue;
}
if is_source_too_large(&path, MAX_SOURCE_BYTES)? {
totals.files_omitted_by_limit += 1;
continue;
}
let source = fs::read_to_string(&path).map_err(|error| {
CliError::runtime(format!("failed to read {}: {error}", path.display()))
})?;
let mut items = indexer
.index_source_summary(&path, &source)?
.into_iter()
.filter(|item| cli.max_depth.is_none_or(|depth| item.depth <= depth))
.map(into_codeshape_item)
.collect::<Vec<_>>();
items.sort_unstable_by(|left, right| {
(left.depth, left.start_line, left.name.as_str()).cmp(&(
right.depth,
right.start_line,
right.name.as_str(),
))
});
if items.is_empty() {
continue;
}
let omitted_items = items.len().saturating_sub(cli.limit_per_file);
items.truncate(cli.limit_per_file);
totals.files_indexed += 1;
totals.symbols_emitted += items.len();
totals.symbols_omitted += omitted_items;
files.push(FileReport {
path: path.display().to_string(),
language,
items,
omitted_items,
});
}
Ok(CodeShapeReport {
engine: ENGINE_NAME,
roots: roots
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>(),
files,
totals,
})
}
fn is_source_too_large(path: &Path, max_bytes: u64) -> Result<bool, CliError> {
let metadata = fs::metadata(path).map_err(|error| {
CliError::runtime(format!(
"failed to read metadata for {}: {error}",
path.display()
))
})?;
Ok(metadata.len() > max_bytes)
}
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_usize_flag(flag: &str, value: &str) -> Result<usize, CliError> {
value
.parse::<usize>()
.map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}")))
}
fn parse_language_list(value: &str) -> Result<BTreeSet<CodeLanguage>, CliError> {
let mut languages = BTreeSet::new();
for raw in value
.split(',')
.map(str::trim)
.filter(|part| !part.is_empty())
{
let language = parse_language_label(raw).ok_or_else(|| {
CliError::usage(format!(
"invalid --lang entry '{raw}'; expected {SUPPORTED_LANGUAGE_LIST}"
))
})?;
let _ = languages.insert(language);
}
Ok(languages)
}
fn collect_roots(cli: &Cli) -> Result<Vec<PathBuf>, CliError> {
if should_read_stdin(!cli.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}")))?;
let roots = parse_paths_from_string(&buffer, cli.common.input_format)?;
if !roots.is_empty() {
return Ok(roots);
}
}
if cli.paths.is_empty() {
Ok(vec![PathBuf::from(".")])
} else {
common::expand_input_patterns(&cli.paths, "codeshape")
}
}
fn parse_paths_from_string(
buffer: &str,
input_format: InputFormat,
) -> Result<Vec<PathBuf>, CliError> {
common::read_existing_stdin_path_records(buffer, input_format, "codeshape")?
.map_or_else(|| Ok(Vec::new()), Ok)
}
fn discover_supported_files(roots: &[PathBuf]) -> Result<Vec<PathBuf>, CliError> {
collect_matching_files(roots, &|path| detect_language(path).is_some())
}
fn into_codeshape_item(symbol: IndexedSymbol) -> CodeShapeItem {
CodeShapeItem {
kind: symbol.kind,
name: symbol.name,
qualified_name: symbol.qualified_name,
signature: symbol.signature,
depth: symbol.depth,
start_line: symbol.start_line,
end_line: symbol.end_line,
}
}
fn render_text(report: &CodeShapeReport) -> String {
let mut output = String::new();
for file in &report.files {
let _ = writeln!(output, "{} [{}]", file.path, language_label(file.language));
for item in &file.items {
let indent = " ".repeat(item.depth + 1);
let _ = writeln!(
output,
"{}{} {} :: {}",
indent,
kind_label(item.kind),
item.qualified_name,
item.signature,
);
}
if file.omitted_items > 0 {
let _ = writeln!(output, " ... {} more item(s)", file.omitted_items);
}
}
let _ = writeln!(
output,
"totals: files_indexed={} symbols_emitted={} symbols_omitted={} files_omitted_by_limit={}",
report.totals.files_indexed,
report.totals.symbols_emitted,
report.totals.symbols_omitted,
report.totals.files_omitted_by_limit
);
output
}
const fn language_label(language: CodeLanguage) -> &'static str {
match language {
CodeLanguage::Rust
| CodeLanguage::Csharp
| CodeLanguage::Powershell
| CodeLanguage::Python
| CodeLanguage::Go
| CodeLanguage::Java
| CodeLanguage::Javascript
| CodeLanguage::Typescript => language.label(),
}
}
const fn kind_label(kind: SymbolKind) -> &'static str {
match kind {
SymbolKind::Module => "module",
SymbolKind::Namespace => "namespace",
SymbolKind::Class => "class",
SymbolKind::Struct => "struct",
SymbolKind::Enum => "enum",
SymbolKind::Interface => "interface",
SymbolKind::Record => "record",
SymbolKind::Trait => "trait",
SymbolKind::Impl => "impl",
SymbolKind::TypeAlias => "type_alias",
SymbolKind::Function => "function",
SymbolKind::Method => "method",
SymbolKind::Constructor => "constructor",
SymbolKind::Const => "const",
SymbolKind::Static => "static",
}
}
#[cfg(test)]
mod tests {
use super::*;
use common::ColorChoice;
fn fixture_repo() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("fixtures")
.join("polyglot")
.join("repo")
}
fn cli_for(root: PathBuf) -> Cli {
Cli {
common: CommonArgs {
json: false,
format: None,
input_format: InputFormat::Auto,
color: ColorChoice::Never,
quiet: false,
},
paths: vec![root],
languages: None,
max_files: 200,
max_depth: None,
limit_per_file: 32,
}
}
fn sample_symbol() -> IndexedSymbol {
IndexedSymbol {
engine: ENGINE_NAME,
path: "fixture.ts".to_string(),
language: CodeLanguage::Typescript,
kind: SymbolKind::Function,
name: "helper".to_string(),
qualified_name: "web::helper".to_string(),
signature: "export const helper = (name: string) => name.trim();".to_string(),
parents: Vec::new(),
depth: 0,
start_line: 1,
end_line: 1,
text: "export const helper = (name: string) => name.trim();".to_string(),
}
}
#[test]
fn parse_cli_and_lists_cover_help_version_and_validation() {
assert_eq!(
parse_cli_from(["codeshape", "--help"]).expect("help").0,
ParseOutcome::Help
);
assert_eq!(
parse_cli_from(["codeshape", "--version"])
.expect("version")
.0,
ParseOutcome::Version
);
let (_, cli) = parse_cli_from([
"codeshape",
"--json",
"--input-format",
"jsonl",
"--color",
"never",
"--lang",
"rust,java,typescript",
"--max-files",
"4",
"--max-depth",
"1",
"--limit-per-file",
"6",
"fixtures/polyglot/repo",
])
.expect("parsed cli");
assert!(cli.common.json);
assert_eq!(cli.common.input_format, InputFormat::Jsonl);
assert_eq!(cli.common.color, ColorChoice::Never);
assert_eq!(cli.max_files, 4);
assert_eq!(cli.max_depth, Some(1));
assert_eq!(cli.limit_per_file, 6);
assert!(
cli.languages
.as_ref()
.expect("languages")
.contains(&CodeLanguage::Typescript)
&& cli
.languages
.as_ref()
.expect("languages")
.contains(&CodeLanguage::Java)
);
assert!(parse_cli_from(["codeshape", "--max-files", "0"]).is_err());
assert!(parse_cli_from(["codeshape", "--limit-per-file", "0"]).is_err());
assert!(parse_language_list("lua").is_err());
assert!(parse_usize_flag("--max-files", "nope").is_err());
}
#[test]
fn path_parsing_and_discovery_cover_supported_inputs_and_failures() {
let source = PathBuf::from("src/lib.rs");
let manifest = PathBuf::from("Cargo.toml");
let line_paths = parse_paths_from_string("src/lib.rs\nCargo.toml\n", InputFormat::Lines)
.expect("line paths");
assert_eq!(line_paths, vec![manifest.clone(), source.clone()]);
let json_paths = parse_paths_from_string("{\"path\":\"src/lib.rs\"}\n", InputFormat::Jsonl)
.expect("json paths");
assert_eq!(json_paths, vec![source.clone()]);
let auto_paths = parse_paths_from_string("src/lib.rs\nCargo.toml\n", InputFormat::Auto)
.expect("auto paths");
assert_eq!(auto_paths, vec![manifest, source]);
assert!(
discover_supported_files(&[fixture_repo()])
.expect("discover")
.len()
>= 8
);
assert!(discover_supported_files(&[fixture_repo().join("missing")]).is_err());
}
#[test]
fn build_report_skips_supported_sources_above_size_limit() {
let root = std::env::temp_dir().join(format!(
"codeshape-large-source-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_nanos()
));
fs::create_dir_all(&root).expect("temp root");
let large_len = usize::try_from(MAX_SOURCE_BYTES + 1).expect("test size fits usize");
fs::write(root.join("large.rs"), vec![b' '; large_len]).expect("large source");
let report = build_report(&cli_for(root.clone())).expect("report");
assert!(report.files.is_empty());
assert_eq!(report.totals.files_omitted_by_limit, 1);
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn build_report_render_and_run_cover_truncation_and_empty_results() {
let mut cli = cli_for(fixture_repo());
let report = build_report(&cli).expect("report");
assert_eq!(report.engine, ENGINE_NAME);
assert!(report.files.len() >= 6);
assert!(report.totals.files_seen >= report.totals.files_indexed);
assert!(
report
.files
.iter()
.any(|file| file.path.ends_with("web\\app.ts"))
);
cli.languages = Some(BTreeSet::from([CodeLanguage::Rust]));
cli.max_files = 1;
cli.max_depth = Some(1);
cli.limit_per_file = 2;
let limited = build_report(&cli).expect("limited report");
assert_eq!(limited.files.len(), 1);
assert!(limited.files[0].omitted_items <= limited.totals.symbols_omitted);
let item = into_codeshape_item(sample_symbol());
assert_eq!(item.name, "helper");
let text = render_text(&CodeShapeReport {
engine: ENGINE_NAME,
roots: vec!["repo".to_string()],
files: vec![FileReport {
path: "repo/web/app.ts".to_string(),
language: CodeLanguage::Typescript,
items: vec![item],
omitted_items: 2,
}],
totals: Totals {
files_seen: 1,
files_indexed: 1,
files_omitted_by_limit: 0,
symbols_emitted: 1,
symbols_omitted: 2,
},
});
assert!(text.contains("repo/web/app.ts [typescript]"));
assert!(text.contains("function web::helper"));
assert!(text.contains("... 2 more item(s)"));
assert!(text.contains("totals: files_indexed=1"));
assert_eq!(language_label(CodeLanguage::Javascript), "javascript");
assert_eq!(language_label(CodeLanguage::Java), "java");
assert_eq!(kind_label(SymbolKind::Static), "static");
let success_cli = cli_for(fixture_repo());
assert_eq!(run(&success_cli).expect("run success"), ExitCode::Success);
let empty_cli = cli_for(
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("README.md"),
);
assert_eq!(run(&empty_cli).expect("run empty"), ExitCode::NoResults);
}
}
+5
View File
@@ -0,0 +1,5 @@
//! Binary entry point for `codeshape`.
fn main() {
std::process::exit(codeshape::main_entry());
}
+92
View File
@@ -0,0 +1,92 @@
//! Integration tests for the `codeshape` command.
use assert_cmd::Command;
use predicates::prelude::*;
use std::path::PathBuf;
fn cargo_command() -> Command {
Command::cargo_bin("codeshape").expect("binary")
}
fn fixture_path(relative: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("fixtures")
.join("polyglot")
.join("repo")
.join(relative)
}
fn pwsh_command(script: impl AsRef<str>) -> Command {
let mut command = Command::new("pwsh");
command
.arg("-NoProfile")
.arg("-Command")
.arg(script.as_ref());
command
}
#[test]
fn summarizes_polyglot_repo_as_json() {
let mut command = cargo_command();
command
.arg("--json")
.arg(fixture_path(""))
.assert()
.success()
.stdout(predicate::str::contains("\"roots\""))
.stdout(predicate::str::contains("\"language\":\"rust\""))
.stdout(predicate::str::contains("\"language\":\"go\""))
.stdout(predicate::str::contains("\"language\":\"java\""))
.stdout(predicate::str::contains(
"\"qualified_name\":\"Service::execute\"",
))
.stdout(predicate::str::contains(
"\"signature\":\"pub fn helper(name: &str) -> String\"",
))
.stdout(predicate::str::contains(
"\"signature\":\"function Invoke-Helper\"",
));
}
#[test]
fn renders_compact_tree_text() {
let mut command = cargo_command();
command
.args(["--limit-per-file", "4"])
.arg(fixture_path(""))
.assert()
.success()
.stdout(predicate::str::contains("src\\lib.rs"))
.stdout(predicate::str::contains("function helper"))
.stdout(predicate::str::contains("namespace Mercury.Game"));
}
#[test]
fn supports_powershell_pipeline_roots() {
let binary = assert_cmd::cargo::cargo_bin("codeshape");
let root = fixture_path("");
let script = format!(
"'{}' | & '{}' --json | ConvertFrom-Json | Select-Object -ExpandProperty totals | Select-Object -ExpandProperty files_indexed",
root.display(),
binary.display()
);
let mut command = pwsh_command(script);
command
.assert()
.success()
.stdout(predicate::str::contains("10"));
}
#[test]
fn help_includes_repo_map_examples() {
let mut command = cargo_command();
command
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("--max-files"))
.stdout(predicate::str::contains("ConvertFrom-Json"))
.stdout(predicate::str::contains("codeshape"));
}
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "common"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "Shared CLI runtime helpers for the AI-friendly CLI toolbox."
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
walkdir.workspace = true
[dev-dependencies]
tempfile.workspace = true
+4
View File
@@ -0,0 +1,4 @@
pub mod ison;
pub mod tonl;
pub mod toon;
pub mod zon;
+365
View File
@@ -0,0 +1,365 @@
//! Shared ISON/ISONL v1 helpers for compact JSON-family records.
use std::fmt::Write as _;
use serde_json::{Map, Number, Value};
use crate::CliError;
const RECORD_SEGMENT_ERROR: &str = "ISONL record expects 3 pipe-delimited segments";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FieldType {
Int,
Float,
Bool,
Str,
Null,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Field {
name: String,
kind: FieldType,
}
/// Encodes JSON object records into newline-delimited ISONL text.
///
/// # Errors
///
/// Returns [`CliError::Usage`] when any record is not an object or has no fields.
pub fn encode_records(records: &[Value], record_name: &str) -> Result<String, CliError> {
let mut output = String::with_capacity(records.len().saturating_mul(96));
for record in records {
output.push_str(&encode_record(record, record_name)?);
output.push('\n');
}
Ok(output)
}
/// Encodes one JSON object into an ISONL record line.
///
/// # Errors
///
/// Returns [`CliError::Usage`] when `record` is not an object or has no fields.
pub fn encode_record(record: &Value, record_name: &str) -> Result<String, CliError> {
let object = record
.as_object()
.ok_or_else(|| CliError::usage("ISONL v1 expects each record to be a JSON object"))?;
if object.is_empty() {
return Err(CliError::usage(
"ISONL v1 cannot infer fields from an empty object",
));
}
let fields = infer_fields(object);
let mut output =
String::with_capacity(record_name.len() + fields.len().saturating_mul(24) + 16);
output.push_str("object.");
output.push_str(record_name);
output.push('|');
push_fields(&mut output, &fields);
output.push('|');
push_row(&mut output, object, &fields);
Ok(output)
}
/// Decodes one ISONL record line into a JSON object.
///
/// # Errors
///
/// Returns [`CliError::Usage`] when the line is malformed.
pub fn decode_record_line(line: &str, line_number: usize) -> Result<Value, CliError> {
let (header, fields_text, row_text) = split_record_line(line, line_number)?;
parse_header(header.trim(), line_number)?;
let fields = parse_fields(fields_text.trim(), line_number)?;
let row = parse_row(row_text.trim(), line_number, &fields)?;
Ok(Value::Object(row))
}
/// Decodes newline-delimited ISONL records into JSON objects.
///
/// Reuses the previous field schema when consecutive records share the same
/// header and field definition, which is the common JSONL-style stream shape.
///
/// # Errors
///
/// Returns [`CliError::Usage`] when any line is malformed.
pub fn decode_records(input: &str) -> Result<Vec<Value>, CliError> {
let mut records = Vec::new();
let mut cached_header = "";
let mut cached_fields_text = "";
let mut cached_fields = Vec::new();
for (index, raw_line) in input.lines().enumerate() {
let line_number = index + 1;
let line = raw_line.trim();
if line.is_empty() {
continue;
}
let (header, fields_text, row_text) = split_record_line(line, line_number)?;
if header != cached_header || fields_text != cached_fields_text {
parse_header(header, line_number)?;
cached_fields = parse_fields(fields_text, line_number)?;
cached_header = header;
cached_fields_text = fields_text;
}
records.push(Value::Object(parse_row(
row_text,
line_number,
&cached_fields,
)?));
}
Ok(records)
}
fn split_record_line(line: &str, line_number: usize) -> Result<(&str, &str, &str), CliError> {
let mut parts = line.splitn(3, '|');
let Some(header) = parts.next() else {
return Err(record_segment_error(line_number));
};
let Some(fields_text) = parts.next() else {
return Err(record_segment_error(line_number));
};
let Some(row_text) = parts.next() else {
return Err(record_segment_error(line_number));
};
Ok((header.trim(), fields_text.trim(), row_text.trim()))
}
fn record_segment_error(line_number: usize) -> CliError {
CliError::usage(format!("line {line_number}: {RECORD_SEGMENT_ERROR}"))
}
fn infer_fields(object: &Map<String, Value>) -> Vec<Field> {
object
.iter()
.map(|(name, value)| Field {
name: name.clone(),
kind: infer_type(value),
})
.collect()
}
fn infer_type(value: &Value) -> FieldType {
match value {
Value::Bool(_) => FieldType::Bool,
Value::Number(number) if number.is_i64() || number.is_u64() => FieldType::Int,
Value::Number(_) => FieldType::Float,
Value::String(_) | Value::Array(_) | Value::Object(_) => FieldType::Str,
Value::Null => FieldType::Null,
}
}
fn push_fields(output: &mut String, fields: &[Field]) {
for (index, field) in fields.iter().enumerate() {
if index > 0 {
output.push(' ');
}
output.push_str(&field.name);
output.push(':');
output.push_str(field.kind.as_str());
}
}
fn push_row(output: &mut String, object: &Map<String, Value>, fields: &[Field]) {
for (index, field) in fields.iter().enumerate() {
if index > 0 {
output.push(' ');
}
push_value(output, object.get(&field.name).unwrap_or(&Value::Null));
}
}
fn push_value(output: &mut String, value: &Value) {
match value {
Value::Null => output.push_str("null"),
Value::Bool(value) => output.push_str(if *value { "true" } else { "false" }),
Value::Number(value) => {
let _ = write!(output, "{value}");
}
Value::String(value) => push_string(output, value),
Value::Array(_) | Value::Object(_) => push_string(output, &value.to_string()),
}
}
fn push_string(output: &mut String, value: &str) {
if value.is_empty()
|| value
.chars()
.any(|character| character.is_whitespace() || matches!(character, '"' | '\\' | '|'))
{
output.push_str(&serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string()));
} else {
output.push_str(value);
}
}
fn parse_header(line: &str, line_number: usize) -> Result<(), CliError> {
let Some((kind, name)) = line.split_once('.') else {
return Err(CliError::usage(format!(
"line {line_number}: expected block header kind.name"
)));
};
if kind != "object" || name.is_empty() {
return Err(CliError::usage(format!(
"line {line_number}: expected object.<name> header"
)));
}
Ok(())
}
fn parse_fields(line: &str, line_number: usize) -> Result<Vec<Field>, CliError> {
let fields = line
.split_whitespace()
.map(|token| parse_field(token, line_number))
.collect::<Result<Vec<_>, _>>()?;
if fields.is_empty() {
return Err(CliError::usage(format!(
"line {line_number}: field definition is empty"
)));
}
Ok(fields)
}
fn parse_field(token: &str, line_number: usize) -> Result<Field, CliError> {
let Some((name, kind)) = token.split_once(':') else {
return Err(CliError::usage(format!(
"line {line_number}: field '{token}' must be name:type"
)));
};
if name.is_empty() {
return Err(CliError::usage(format!(
"line {line_number}: field name must not be empty"
)));
}
Ok(Field {
name: name.to_string(),
kind: parse_field_type(kind, line_number)?,
})
}
fn parse_field_type(kind: &str, line_number: usize) -> Result<FieldType, CliError> {
match kind {
"int" => Ok(FieldType::Int),
"float" => Ok(FieldType::Float),
"bool" => Ok(FieldType::Bool),
"str" => Ok(FieldType::Str),
"null" => Ok(FieldType::Null),
other => Err(CliError::usage(format!(
"line {line_number}: unknown field type '{other}'"
))),
}
}
fn parse_row(
line: &str,
line_number: usize,
fields: &[Field],
) -> Result<Map<String, Value>, CliError> {
let values = split_row(line)
.map_err(|message| CliError::usage(format!("line {line_number}: {message}")))?;
if values.len() != fields.len() {
return Err(CliError::usage(format!(
"line {line_number}: expected {} values, got {}",
fields.len(),
values.len()
)));
}
fields
.iter()
.zip(values)
.map(|(field, raw)| {
parse_value(raw, field.kind, line_number).map(|value| (field.name.clone(), value))
})
.collect()
}
fn split_row(line: &str) -> Result<Vec<&str>, String> {
let mut values = Vec::new();
let mut token_start = None;
let mut in_string = false;
let mut escaped = false;
for (index, character) in line.char_indices() {
if in_string {
if escaped {
escaped = false;
} else if character == '\\' {
escaped = true;
} else if character == '"' {
in_string = false;
}
continue;
}
if character == '"' {
in_string = true;
token_start.get_or_insert(index);
} else if character.is_whitespace() {
if let Some(start) = token_start.take() {
values.push(&line[start..index]);
}
} else {
token_start.get_or_insert(index);
}
}
if in_string {
return Err("unterminated quoted string".to_string());
}
if let Some(start) = token_start {
values.push(&line[start..]);
}
Ok(values)
}
fn parse_value(raw: &str, kind: FieldType, line_number: usize) -> Result<Value, CliError> {
match kind {
FieldType::Int => parse_number_value(raw, line_number),
FieldType::Float => raw
.parse::<f64>()
.ok()
.and_then(serde_json::Number::from_f64)
.map(Value::Number)
.ok_or_else(|| CliError::usage(format!("line {line_number}: invalid float '{raw}'"))),
FieldType::Bool => raw.parse::<bool>().map(Value::Bool).map_err(|error| {
CliError::usage(format!("line {line_number}: invalid bool '{raw}': {error}"))
}),
FieldType::Str => {
if raw.starts_with('"') {
serde_json::from_str::<String>(raw)
.map(Value::String)
.map_err(|error| {
CliError::usage(format!("line {line_number}: invalid string: {error}"))
})
} else {
Ok(Value::String(raw.to_string()))
}
}
FieldType::Null => Ok(Value::Null),
}
}
fn parse_number_value(raw: &str, line_number: usize) -> Result<Value, CliError> {
if let Ok(value) = raw.parse::<i64>() {
return Ok(Value::Number(Number::from(value)));
}
raw.parse::<u64>()
.map(Number::from)
.map(Value::Number)
.map_err(|error| {
CliError::usage(format!("line {line_number}: invalid int '{raw}': {error}"))
})
}
impl FieldType {
const fn as_str(self) -> &'static str {
match self {
Self::Int => "int",
Self::Float => "float",
Self::Bool => "bool",
Self::Str => "str",
Self::Null => "null",
}
}
}
+144
View File
@@ -0,0 +1,144 @@
//! Shared TONL v1 helpers for JSON-backed key/value documents.
use serde_json::{Map, Value};
use crate::CliError;
/// Encodes JSON records into TONL documents.
///
/// # Errors
///
/// Returns [`CliError::Runtime`] when a JSON value cannot be rendered.
pub fn encode_documents(records: &[Value]) -> Result<String, CliError> {
let mut output = String::with_capacity(records.len().saturating_mul(96));
for (index, record) in records.iter().enumerate() {
if records.len() > 1 {
output.push_str("---\n");
} else if index > 0 {
output.push('\n');
}
push_document(&mut output, record)?;
}
Ok(output)
}
/// Decodes TONL documents into JSON records.
///
/// # Errors
///
/// Returns [`CliError::Usage`] when a line is malformed or a value is not JSON.
pub fn decode_documents(content: &str) -> Result<Vec<Value>, CliError> {
let mut records = Vec::new();
let mut current = Map::new();
let mut root_value = None;
for (index, line) in content.lines().enumerate() {
let line_number = index + 1;
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if trimmed == "---" {
finish_record(&mut records, &mut current, &mut root_value);
continue;
}
let (key, raw_value) = trimmed.split_once('=').ok_or_else(|| {
CliError::usage(format!(
"line {line_number}: expected 'key = <valid JSON value>'"
))
})?;
let key = key.trim();
if key.is_empty() {
return Err(CliError::usage(format!(
"line {line_number}: key cannot be empty"
)));
}
let value = serde_json::from_str::<Value>(raw_value.trim()).map_err(|error| {
CliError::usage(format!(
"line {line_number}: value must be a valid JSON value: {error}"
))
})?;
if key == "$" {
if !current.is_empty() {
return Err(CliError::usage(format!(
"line {line_number}: '$' root value cannot be mixed with object fields"
)));
}
root_value = Some(value);
} else {
if root_value.is_some() {
return Err(CliError::usage(format!(
"line {line_number}: object fields cannot be mixed with '$' root value"
)));
}
current.insert(key.to_string(), value);
}
}
finish_record(&mut records, &mut current, &mut root_value);
if records.is_empty() {
return Err(CliError::usage("no TONL records found"));
}
Ok(records)
}
fn push_document(output: &mut String, record: &Value) -> Result<(), CliError> {
match record {
Value::Object(object) => {
for (key, value) in object {
output.push_str(key);
output.push_str(" = ");
output.push_str(&compact_json(value)?);
output.push('\n');
}
}
value => {
output.push_str("$ = ");
output.push_str(&compact_json(value)?);
output.push('\n');
}
}
Ok(())
}
fn finish_record(
records: &mut Vec<Value>,
current: &mut Map<String, Value>,
root_value: &mut Option<Value>,
) {
if let Some(value) = root_value.take() {
records.push(value);
} else if !current.is_empty() {
records.push(Value::Object(std::mem::take(current)));
}
}
fn compact_json(value: &Value) -> Result<String, CliError> {
serde_json::to_string(value)
.map_err(|error| CliError::runtime(format!("failed to render json: {error}")))
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{decode_documents, encode_documents};
#[test]
fn shared_tonl_roundtrips_objects_and_root_values() {
let records = vec![json!({"id": "a", "ok": true}), json!([1, 2])];
let encoded = encode_documents(&records).expect("encode tonl");
assert!(encoded.contains("---\nid = \"a\"\nok = true\n"));
assert!(encoded.contains("---\n$ = [1,2]\n"));
assert_eq!(decode_documents(&encoded).expect("decode tonl"), records);
}
#[test]
fn shared_tonl_reports_malformed_inputs() {
assert!(decode_documents("\n# comment\n").is_err());
assert!(decode_documents("missing separator\n").is_err());
assert!(decode_documents(" = 1\n").is_err());
assert!(decode_documents("id = not-json\n").is_err());
assert!(decode_documents("id = 1\n$ = 2\n").is_err());
assert!(decode_documents("$ = 1\nid = 2\n").is_err());
}
}
File diff suppressed because it is too large Load Diff
+168
View File
@@ -0,0 +1,168 @@
//! Shared Zero Overhead Notation (ZON) v1 helpers.
use serde_json::{Map, Value};
use crate::CliError;
/// Encodes a JSON value into the Mercury ZON v1 subset.
///
/// # Errors
///
/// Returns [`CliError::Runtime`] when a JSON value cannot be rendered.
pub fn encode_value(value: &Value) -> Result<String, CliError> {
let mut output = String::with_capacity(estimate_capacity(value));
match value {
Value::Object(object) => encode_object(object, &mut output)?,
primitive => {
output.push_str(&render_inline_value(primitive)?);
output.push('\n');
}
}
Ok(output)
}
fn estimate_capacity(value: &Value) -> usize {
match value {
Value::Object(object) => object.len().saturating_mul(64),
Value::Array(items) => items.len().saturating_mul(48),
Value::String(text) => text.len() + 8,
Value::Null | Value::Bool(_) | Value::Number(_) => 16,
}
}
/// Decodes the Mercury ZON v1 subset into JSON.
///
/// # Errors
///
/// Returns [`CliError::Usage`] when a line is malformed.
pub fn decode_str(input: &str) -> Result<Value, CliError> {
let mut object = Map::new();
let mut scalar = None;
for (index, line) in input.lines().enumerate() {
let line_number = index + 1;
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if let Some((key, raw_value)) = trimmed.split_once(':') {
if key.is_empty() {
return Err(CliError::usage(format!(
"line {line_number}: key cannot be empty"
)));
}
object.insert(
key.to_string(),
parse_inline_value(raw_value.trim(), line_number)?,
);
} else if scalar
.replace(parse_inline_value(trimmed, line_number)?)
.is_some()
{
return Err(CliError::usage(format!(
"line {line_number}: multiple scalar ZON values are not supported"
)));
}
}
if object.is_empty() {
scalar.ok_or_else(|| CliError::usage("ZON input is empty"))
} else {
Ok(Value::Object(object))
}
}
fn encode_object(object: &Map<String, Value>, output: &mut String) -> Result<(), CliError> {
for (key, value) in object {
output.push_str(key);
output.push(':');
output.push_str(&render_inline_value(value)?);
output.push('\n');
}
Ok(())
}
fn render_inline_value(value: &Value) -> Result<String, CliError> {
serde_json::to_string(value)
.map_err(|error| CliError::runtime(format!("failed to render ZON inline value: {error}")))
}
fn parse_inline_value(raw: &str, line_number: usize) -> Result<Value, CliError> {
if raw.is_empty() {
return Ok(Value::String(String::new()));
}
if let Some(value) = parse_keyword_value(raw) {
return Ok(value);
}
if raw.starts_with(['"', '[', '{']) || looks_like_number(raw) {
return serde_json::from_str::<Value>(raw).map_err(|error| {
CliError::usage(format!(
"line {line_number}: invalid inline JSON value: {error}"
))
});
}
Ok(Value::String(raw.to_string()))
}
fn parse_keyword_value(raw: &str) -> Option<Value> {
match raw {
"true" => Some(Value::Bool(true)),
"false" => Some(Value::Bool(false)),
"null" => Some(Value::Null),
_ => None,
}
}
fn looks_like_number(value: &str) -> bool {
value
.as_bytes()
.first()
.is_some_and(|byte| matches!(byte, b'-' | b'0'..=b'9'))
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn encodes_and_decodes_object_and_scalar_values() {
let object = json!({
"active": true,
"count": 3,
"name": "mercury",
"notes": "",
"tags": ["fast", "portable"],
});
let encoded = encode_value(&object).expect("encoded object");
let decoded = decode_str(&encoded).expect("decoded object");
assert_eq!(decoded, object);
assert_eq!(decode_str("null\n").expect("null scalar"), Value::Null);
assert_eq!(
decode_str("plain-text\n").expect("bare string"),
json!("plain-text")
);
}
#[test]
fn reports_malformed_zon_inputs() {
let empty_key = decode_str(":true\n").expect_err("empty key");
assert!(matches!(
empty_key,
CliError::Usage(message) if message.contains("key cannot be empty")
));
let multiple_scalars = decode_str("1\n2\n").expect_err("multiple scalars");
assert!(matches!(
multiple_scalars,
CliError::Usage(message) if message.contains("multiple scalar ZON values")
));
let invalid_inline = decode_str("value:[1,,2]\n").expect_err("invalid inline JSON");
assert!(matches!(
invalid_inline,
CliError::Usage(message) if message.contains("invalid inline JSON value")
));
}
}
File diff suppressed because it is too large Load Diff
+276
View File
@@ -0,0 +1,276 @@
//! Tests for the generated Mercury Toolbox AI assets.
use std::fs;
use std::path::{Path, PathBuf};
use serde_json::Value;
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.canonicalize()
.expect("workspace root")
}
fn workspace_path(root: &Path, relative_path: &[&str]) -> PathBuf {
relative_path
.iter()
.fold(root.to_path_buf(), |path, component| path.join(component))
}
fn read_workspace_text(root: &Path, relative_path: &[&str], label: &str) -> String {
fs::read_to_string(workspace_path(root, relative_path))
.unwrap_or_else(|error| panic!("failed to read {label}: {error}"))
}
fn toolbox_commands(root: &Path) -> Vec<String> {
let commands = read_workspace_text(
root,
&["scripts", "toolbox-commands.ps1"],
"scripts/toolbox-commands.ps1",
);
let mut in_command_list = false;
let mut consumed_command_list = false;
let mut names = commands
.lines()
.filter_map(|line| {
let trimmed = line.trim();
if trimmed == "return @(" && !in_command_list && !consumed_command_list {
in_command_list = true;
return None;
}
if in_command_list && trimmed == ")" {
in_command_list = false;
consumed_command_list = true;
return None;
}
if !in_command_list {
return None;
}
trimmed
.strip_prefix('\'')
.and_then(|rest| rest.split_once('\''))
.map(|(name, _)| name.to_string())
})
.collect::<Vec<_>>();
names.sort();
names.dedup();
names
}
fn assert_lf_only(root: &Path, relative_path: &[&str]) {
let path = workspace_path(root, relative_path);
let bytes = fs::read(&path).unwrap_or_else(|error| panic!("read {}: {error}", path.display()));
assert!(
!bytes.windows(2).any(|window| window == b"\r\n"),
"{} should use LF line endings",
path.display()
);
}
#[test]
fn ai_prompt_assets_exist_and_cover_every_tool() {
let root = workspace_root();
let prompt = read_workspace_text(
&root,
&["docs", "ai", "mercury-toolbox-ai-prompt.md"],
"generated AI prompt",
);
let notes = read_workspace_text(
&root,
&["docs", "ai", "toolbox-ai-prompt-notes.json"],
"AI prompt notes",
);
let commands = toolbox_commands(&root);
assert!(prompt.contains("Mercury Toolbox"));
assert!(prompt.contains("PowerShell"));
assert!(prompt.contains("`--json`"));
assert!(prompt.contains("`--toon`"));
assert!(prompt.contains("Available tools:"));
assert!(prompt.contains("Rules:"));
assert!(prompt.contains("Pipe external JSON into `toon`"));
assert!(prompt.contains("Every tool has guided triage metadata"));
assert!(prompt.contains("Guided: answer="));
assert!(prompt.contains("`report_quality`"));
assert!(prompt.contains("`next_actions`"));
assert!(!prompt.contains("toon --from json --to toon"));
assert!(prompt.contains("Usage: `"));
assert!(prompt.contains("Example:"));
assert!(prompt.contains("msudo:"));
assert!(prompt.contains("Top-level high-risk command"));
assert!(prompt.contains(
"msudo status --json | ConvertFrom-Json | Select-Object ok,host,supports_runas,is_elevated"
));
assert!(!prompt.contains("## Tool Catalog"));
assert!(!prompt.contains("### `"));
assert!(!prompt.contains("$Fence"));
assert!(
prompt.lines().count() <= 120,
"prompt should stay compact for AI consumption"
);
assert_eq!(commands.len(), 59, "toolbox command inventory changed");
let notes_json = serde_json::from_str::<Value>(&notes).expect("AI prompt notes JSON");
let tools = notes_json["tools"].as_object().expect("notes tools object");
for command in commands {
assert!(
prompt.contains(&format!("{command}:")),
"prompt should contain {command}"
);
assert!(
notes.contains(&format!("\"{command}\"")),
"notes should contain {command}"
);
let guided = tools
.get(&command)
.and_then(|tool| tool.get("guided_triage"))
.unwrap_or_else(|| panic!("notes should contain guided_triage for {command}"));
assert!(
guided["answer"]
.as_str()
.is_some_and(|value| !value.is_empty()),
"guided_triage.answer should be non-empty for {command}"
);
assert!(
guided["trust"]
.as_str()
.is_some_and(|value| !value.is_empty()),
"guided_triage.trust should be non-empty for {command}"
);
assert!(
guided["next_actions"]
.as_array()
.is_some_and(|items| items.len() >= 2
&& items
.iter()
.all(|item| item.as_str().is_some_and(|value| !value.is_empty()))),
"guided_triage.next_actions should list at least two actions for {command}"
);
}
assert!(
notes.contains("msudo status --json | ConvertFrom-Json | Select-Object ok,host,supports_runas,is_elevated"),
"notes should document the stable msudo status discovery fields"
);
}
#[test]
fn ai_skill_assets_exist_and_cover_every_tool() {
let root = workspace_root();
let skill = read_workspace_text(
&root,
&["skills", "mercury-toolbox", "SKILL.md"],
"generated skill",
);
let catalog = read_workspace_text(
&root,
&[
"skills",
"mercury-toolbox",
"references",
"command-catalog.md",
],
"generated skill catalog",
);
let openai_yaml = read_workspace_text(
&root,
&["skills", "mercury-toolbox", "agents", "openai.yaml"],
"generated openai.yaml",
);
assert!(skill.contains("Mercury Toolbox"));
assert!(skill.contains("Prefer Mercury readers over `Get-Content`"));
assert!(skill.contains("## Modern Pairings"));
assert!(skill.contains("## Job Routing"));
assert!(skill.contains("Windows driver: start with `drvshape <SYS>`"));
assert!(skill.contains("`report_quality` and `next_actions`"));
assert!(skill.contains("Use `rg` over recursive `grep`"));
assert!(skill.contains("Treat `msudo` as the top-level high-risk toolbox command"));
assert!(skill.contains("`msudo status --json`"));
assert!(skill.contains("references/command-catalog.md"));
assert!(skill.contains("switch to `--toon` or `--format toon`"));
assert!(!skill.contains("toon --from json --to toon"));
assert!(skill.lines().count() <= 95, "skill should stay concise");
assert!(catalog.contains("# Mercury Toolbox Command Catalog"));
assert!(catalog.contains("Keep output compact"));
assert!(catalog.contains("TOON example:"));
assert!(catalog.contains("Guided answer:"));
assert!(catalog.contains("Trust basis:"));
assert!(catalog.contains("Next actions:"));
assert!(catalog.contains("--toon"));
assert!(!catalog.contains("toon --from json --to toon"));
assert!(catalog.contains("### `msudo`"));
assert!(catalog.contains("Top-level high-risk command"));
assert!(catalog.contains(
"msudo status --json | ConvertFrom-Json | Select-Object ok,host,supports_runas,is_elevated"
));
assert!(
catalog.lines().count() <= 520,
"catalog should stay compact"
);
assert!(openai_yaml.contains("display_name: \"Mercury Toolbox\""));
assert!(openai_yaml.contains("icon_small: \"./assets/logo.png\""));
assert!(openai_yaml.contains("icon_large: \"./assets/logo.png\""));
assert!(openai_yaml.contains("brand_color: \"#35C2FF\""));
assert!(openai_yaml.contains("default_prompt: \"Use $mercury-toolbox first"));
assert!(
root.join("skills")
.join("mercury-toolbox")
.join("assets")
.join("logo.png")
.is_file(),
"generated skill should include its logo asset"
);
for command in toolbox_commands(&root) {
assert!(
catalog.contains(&format!("### `{command}`")),
"catalog should contain {command}"
);
}
}
#[test]
fn generated_ai_markdown_assets_use_lf_line_endings() {
let root = workspace_root();
assert_lf_only(&root, &["docs", "ai", "mercury-toolbox-ai-prompt.md"]);
assert_lf_only(&root, &["skills", "mercury-toolbox", "SKILL.md"]);
assert_lf_only(
&root,
&[
"skills",
"mercury-toolbox",
"references",
"command-catalog.md",
],
);
}
#[test]
fn readme_tool_map_covers_every_toolbox_command() {
let root = workspace_root();
let readme = read_workspace_text(&root, &["README.md"], "README.md");
assert!(readme.contains("### Tool Map"));
assert!(readme.contains("## Which Tool First"));
assert!(readme.contains("Safe starter commands"));
assert!(readme.contains("Every command has guided triage notes"));
assert!(readme.contains("`report_quality` and `next_actions`"));
assert!(readme.contains("Every command supports `--help`"));
for command in toolbox_commands(&root) {
assert!(
readme.contains(&format!("| `{command}` |")),
"README tool map should contain {command}"
);
}
assert!(readme.contains("### `asmflow`"));
assert!(readme.contains("### `unityasset`"));
assert!(readme.contains("### `unityprobe`"));
assert!(readme.contains("### `unitydiag`"));
}
+150
View File
@@ -0,0 +1,150 @@
//! Contract tests for shared CLI helpers.
use common::{
CliError, ColorChoice, CommonArgs, ExitCode, InputFormat, RenderMode, emit_json,
emit_structured, formats, map_result_count, parse_color_choice, parse_format_choice,
parse_input_format, should_read_stdin,
};
use serde::Serialize;
use serde_json::{Map, Value, json};
#[test]
fn parses_shared_choice_values() {
let parsed = CommonArgs {
json: true,
format: None,
input_format: parse_input_format("jsonl").expect("input format"),
color: parse_color_choice("never").expect("color choice"),
quiet: false,
};
assert!(parsed.json);
assert_eq!(parsed.input_format, InputFormat::Jsonl);
assert_eq!(parsed.color, ColorChoice::Never);
assert_eq!(parsed.render_mode(), RenderMode::Json);
assert_eq!(
parse_format_choice("toon").expect("format choice"),
RenderMode::Toon
);
let input_error = parse_input_format("yaml").expect_err("invalid input format");
let color_error = parse_color_choice("always").expect_err("invalid color choice");
let format_error = parse_format_choice("yaml").expect_err("invalid format choice");
assert!(matches!(input_error, CliError::Usage(_)));
assert!(matches!(color_error, CliError::Usage(_)));
assert!(matches!(format_error, CliError::Usage(_)));
}
#[test]
fn auto_stdin_reads_only_without_explicit_input_and_when_not_terminal() {
assert!(should_read_stdin(false, false));
assert!(!should_read_stdin(false, true));
assert!(!should_read_stdin(true, false));
}
#[test]
fn maps_result_count_to_exit_code() {
assert_eq!(map_result_count(1), ExitCode::Success);
assert_eq!(map_result_count(0), ExitCode::NoResults);
}
#[test]
fn emits_single_json_document() {
#[derive(Serialize)]
struct Demo<'a> {
name: &'a str,
}
let rendered = emit_json(&Demo { name: "jsonlgrep" }).expect("json output");
assert_eq!(rendered, "{\"name\":\"jsonlgrep\"}\n");
}
#[test]
fn emits_structured_toon_document() {
#[derive(Serialize)]
struct Demo<'a> {
name: &'a str,
count: u8,
}
let rendered = emit_structured(
&Demo {
name: "jsonlgrep",
count: 2,
},
RenderMode::Toon,
)
.expect("toon output");
let lines = rendered.lines().collect::<Vec<_>>();
assert_eq!(lines.len(), 2);
assert!(lines.contains(&"name: jsonlgrep"));
assert!(lines.contains(&"count: 2"));
assert!(rendered.ends_with('\n'));
}
#[test]
fn structured_toon_render_rejects_values_past_explicit_depth_limit() {
let error = emit_structured(&deeply_nested_value(260), RenderMode::Toon)
.expect_err("deep TOON encode should fail");
assert!(matches!(
error,
CliError::Runtime(message) if message.contains("maximum TOON encoding depth")
));
}
#[test]
fn exposes_json_family_format_modules() {
let records = vec![json!({"id": "user-1", "active": true})];
let ison = formats::ison::encode_records(&records, "record").expect("ison records");
assert!(ison.contains("object.record|"));
assert!(ison.contains("id:str"));
assert!(ison.contains("active:bool"));
assert_eq!(
formats::ison::decode_record_line(&ison, 1).expect("ison decode"),
records[0]
);
let zon = formats::zon::encode_value(&records[0]).expect("zon");
assert!(zon.contains("id:\"user-1\""));
assert_eq!(
formats::zon::decode_str(&zon).expect("zon decode"),
records[0]
);
let tonl = formats::tonl::encode_documents(&records).expect("tonl");
assert!(tonl.contains("id = \"user-1\""));
assert_eq!(
formats::tonl::decode_documents(&tonl).expect("tonl decode"),
records
);
}
fn deeply_nested_value(depth: usize) -> Value {
let mut value = json!(1);
for index in 0..depth {
let mut object = Map::new();
object.insert(format!("k{index}"), value);
value = Value::Object(object);
}
value
}
#[test]
fn isonl_roundtrips_pipe_inside_quoted_string() {
let records = vec![json!({"message": "left|right", "ok": true})];
let ison = formats::ison::encode_records(&records, "record").expect("ison records");
assert_eq!(
formats::ison::decode_record_line(ison.trim_end(), 1).expect("ison decode"),
records[0]
);
assert_eq!(
formats::ison::decode_records(&ison).expect("ison batch decode"),
records
);
}
+484
View File
@@ -0,0 +1,484 @@
//! Workspace policy tests for Jade Discipline.
use std::fs;
use std::path::PathBuf;
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.canonicalize()
.expect("workspace root")
}
fn workspace_path(components: &[&str]) -> PathBuf {
components
.iter()
.fold(workspace_root(), |path, component| path.join(component))
}
fn script_path(script_name: &str) -> PathBuf {
workspace_path(&["scripts", script_name])
}
fn read_workspace_text(components: &[&str], label: &str) -> String {
fs::read_to_string(workspace_path(components))
.unwrap_or_else(|error| panic!("failed to read {label}: {error}"))
}
#[test]
fn workspace_cargo_toml_publishes_jade_lints_and_profiles() {
let cargo_toml = read_workspace_text(&["Cargo.toml"], "Cargo.toml");
for required_line in [
"rust-version = \"1.86\"",
"missing_docs = \"deny\"",
"pedantic = { level = \"deny\", priority = -3 }",
"nursery = { level = \"deny\", priority = -2 }",
"[profile.release-fast]",
"lto = \"fat\"",
"[profile.release-size]",
"opt-level = \"z\"",
] {
assert!(
cargo_toml.contains(required_line),
"Cargo.toml should contain {required_line:?}"
);
}
for forbidden_line in [
"missing_copy_implementations",
"implicit_return",
"missing_const_for_fn",
"module_name_repetitions",
"multiple_crate_versions",
"must_use_candidate",
"needless_pass_by_value",
] {
assert!(
!cargo_toml.contains(forbidden_line),
"Cargo.toml should not contain {forbidden_line:?}"
);
}
}
#[test]
fn cargo_config_and_jade_docs_exist() {
let config = read_workspace_text(&[".cargo", "config.toml"], ".cargo/config.toml");
assert!(config.contains("git-fetch-with-cli = true"));
assert!(config.contains("rustflags = [\"-Dwarnings\"]"));
assert!(config.contains("frequency = \"always\""));
assert!(workspace_path(&["justfile"]).is_file(), "missing justfile");
assert!(
workspace_path(&["bacon.toml"]).is_file(),
"missing bacon.toml"
);
let docs = read_workspace_text(&["docs", "jade-discipline.md"], "jade discipline docs");
let maintainer_notes =
read_workspace_text(&["docs", "maintainer-notes.md"], "maintainer notes");
assert!(docs.contains("Jade Discipline"));
assert!(docs.contains("cargo clippy --all-targets --all-features -- -D warnings -W clippy::pedantic -W clippy::nursery"));
assert!(docs.contains("Miri, fuzzing, sanitizer, no-panic, and Loom checks are Jade gates"));
assert!(docs.contains(
"Missing tools, missing harnesses, or platform discomfort are failures by default"
));
assert!(docs.contains("Global `allow` is reserved for two cases only"));
assert!(docs.contains("smallest code-local scope"));
assert!(docs.contains("Install"));
assert!(docs.contains("just"));
assert!(docs.contains("bacon"));
assert!(maintainer_notes.contains("Jade has no optional safety tier"));
assert!(maintainer_notes.contains("Every JSON-capable Mercury tool"));
assert!(maintainer_notes.contains("is the shared AST/indexing engine"));
}
#[test]
fn release_packaging_scripts_and_docs_exist() {
for script_name in [
"package-toolbox.ps1",
"install-package-toolbox.ps1",
"uninstall-package-toolbox.ps1",
"generate-ai-skill.ps1",
"check-ai-skill.ps1",
] {
assert!(
script_path(script_name).is_file(),
"missing packaging script {script_name}"
);
}
let readme = read_workspace_text(&["README.md"], "README.md");
assert!(readme.contains("just"));
assert!(readme.contains("bacon"));
assert!(readme.contains("## Portable Package"));
assert!(readme.contains(r".\scripts\package-toolbox.ps1"));
assert!(readme.contains("install-package-toolbox.ps1"));
assert!(readme.contains("mercury-toolbox-package.json"));
assert!(readme.contains("SHA256SUMS.txt"));
assert!(readme.contains("generate-ai-skill.ps1"));
assert!(readme.contains(r".\skills\mercury-toolbox\"));
assert!(readme.contains("### `msudo`"));
assert!(readme.contains("HIGH RISK"));
assert!(readme.contains("top-level high-risk toolbox command"));
assert!(readme.contains("msudo status --json"));
assert!(readme.contains("Select-Object ok,host,supports_runas,is_elevated"));
assert!(readme.contains("msudo --help"));
assert!(readme.contains("msudo run --help"));
}
#[test]
#[allow(clippy::too_many_lines)]
fn powershell_gate_assets_and_docs_exist() {
let root = workspace_root();
assert!(
script_path("check-powershell.ps1").is_file(),
"missing PowerShell gate script"
);
assert!(
script_path("cargo-flamegraph-windows.ps1").is_file(),
"missing Windows flamegraph wrapper script"
);
assert!(
workspace_path(&["PSScriptAnalyzerSettings.psd1"]).is_file(),
"missing PowerShell analyzer settings"
);
let check_jade = read_workspace_text(&["scripts", "check-jade.ps1"], "scripts/check-jade.ps1");
assert!(check_jade.contains("check-powershell.ps1"));
assert!(check_jade.contains("check-ai-skill.ps1"));
assert!(check_jade.contains("check-jade-hardening.ps1"));
assert!(
check_jade.contains("Invoke-TimedNativeWithEnvironment"),
"Jade coverage gate should be able to isolate cargo-llvm-cov environment"
);
assert!(
check_jade.contains("CARGO_INCREMENTAL") && check_jade.contains("RUSTC_WRAPPER"),
"Jade coverage gate should disable incremental and rustc-wrapper for cargo-llvm-cov"
);
assert!(
check_jade.contains("CARGO_TARGET_DIR")
&& check_jade.contains("mercury-jade-llvm-cov")
&& check_jade.contains("cargo llvm-cov clean")
&& check_jade
.contains("Invoke-TimedNativeWithEnvironment -Name 'cargo llvm-cov clean'"),
"Jade coverage gate should use a per-run isolated cargo target dir for clean and nextest"
);
assert!(
check_jade.contains("MERCURY_JADE_COVERAGE_ROOT")
&& check_jade.contains("C:\\tmp")
&& check_jade.contains("'mtcov'")
&& check_jade.contains(".Substring(0, 8)"),
"Jade coverage target dir should stay short enough for Windows llvm-cov object argv"
);
assert!(
check_jade.contains(
"'llvm-cov',\n '--jobs',\n '1',\n 'nextest'"
),
"Jade coverage gate should limit cargo-llvm-cov build jobs before the nextest subcommand"
);
assert_justfile_test_recipes(&root);
let ecosystem = read_workspace_text(
&["scripts", "check-ecosystem.ps1"],
"ecosystem check script",
);
assert!(
ecosystem.contains("toolbox:all-binaries-report-version")
&& ecosystem.contains("Test-ToolboxBinaryVersions"),
"ecosystem gate should prove every toolbox binary reports --version"
);
assert!(
ecosystem.contains("toolbox:all-binaries-no-args-contract")
&& ecosystem.contains("Test-ToolboxNoArgsContracts"),
"ecosystem gate should prove every toolbox binary has bounded no-args behavior"
);
assert!(
ecosystem.contains("toolbox:all-binaries-invalid-flag-contract")
&& ecosystem.contains("Test-ToolboxInvalidFlagContracts"),
"ecosystem gate should prove every toolbox binary has bounded invalid-flag diagnostics"
);
assert!(
ecosystem.contains("toolbox:all-binaries-structured-output-help")
&& ecosystem.contains("Test-ToolboxStructuredOutputHelpContracts"),
"ecosystem gate should prove every toolbox binary exposes structured output help"
);
assert!(
ecosystem.contains("toolbox:malformed-jsonl-stdin-contract")
&& ecosystem.contains("Test-ToolboxMalformedJsonlStdinContracts"),
"ecosystem gate should prove malformed JSONL stdin is bounded for input-format commands"
);
assert!(
ecosystem.contains("toolbox:valid-jsonl-path-stream-smokes"),
"ecosystem gate should prove representative positive JSONL path-stream behavior"
);
assert!(
ecosystem.contains("toolbox:functional-toon-smokes"),
"ecosystem gate should prove representative functional TOON output smokes"
);
let check_hardening = read_workspace_text(
&["scripts", "check-jade-hardening.ps1"],
"scripts/check-jade-hardening.ps1",
);
for required_gate in [
"cargo miri setup",
"NightlyToolchain",
"'fuzz'",
"'run'",
"json_family_decode",
"-Zsanitizer=address",
"check-no-panic.ps1",
"loom_capture",
"Mode 'hardening'",
"ValidateRange(1, 3600)",
"Only = 'All'",
] {
assert!(
check_hardening.contains(required_gate),
"hardening script should contain {required_gate:?}"
);
}
assert!(check_hardening.contains("exemption requires a non-empty reason"));
let jade_install = read_workspace_text(
&["scripts", "install-jade-tooling.ps1"],
"scripts/install-jade-tooling.ps1",
);
assert!(jade_install.contains("PSScriptAnalyzer"));
assert!(jade_install.contains("cargo-binstall"));
assert!(jade_install.contains("\"just\", \"bacon\""));
assert!(jade_install.contains("\"component\", \"add\", \"miri\""));
assert!(jade_install.contains("\"cargo-udeps\", \"cargo-llvm-cov\""));
assert!(jade_install.contains("\"install\", \"cargo-fuzz\""));
let docs = read_workspace_text(&["docs", "jade-discipline.md"], "jade discipline docs");
assert!(docs.contains("PowerShell Gate"));
assert!(docs.contains("check-powershell.ps1"));
assert!(docs.contains("check-ai-skill.ps1"));
assert!(docs.contains("PSScriptAnalyzer"));
assert!(docs.contains("cargo-flamegraph-windows.ps1"));
let readme = read_workspace_text(&["README.md"], "README.md");
assert!(readme.contains("cargo-flamegraph-windows.ps1"));
}
fn assert_justfile_test_recipes(root: &std::path::Path) {
let justfile = fs::read_to_string(root.join("justfile")).expect("justfile");
assert!(
justfile.contains("coverage:\n cargo llvm-cov nextest --all-features --summary-only"),
"just coverage should keep a fast local coverage path without forcing Jade's serial coverage gate"
);
assert!(
!justfile.contains("coverage:\n cargo llvm-cov clean --workspace"),
"just coverage should not pre-clean coverage artifacts on every local iteration"
);
assert!(
justfile.contains("test:\n cargo nextest run --all-features"),
"just test should keep the fast incremental nextest path for local iteration"
);
assert!(
justfile.contains("stable-test:")
&& justfile.contains(
"CARGO_INCREMENTAL = '0'; cargo nextest run --all-features --run-ignored all",
),
"stable-test should keep the non-incremental Windows cleanup-race path and slow integration coverage"
);
}
#[test]
#[allow(clippy::too_many_lines)]
fn repo_temp_paths_are_audited_and_use_exclusive_writes() {
let root = workspace_root();
let production_temp_dir_hits = production_source_hits(
&root,
&[
"std::env::temp_dir()",
"env::temp_dir()",
"tempfile::",
"NamedTempFile",
"TempDir::new",
"tempdir()",
],
);
assert_eq!(
production_temp_dir_hits,
[
"crates\\argv\\src\\lib.rs:std::env::temp_dir().join(format!(",
"crates\\envdiff\\src\\lib.rs:std::env::temp_dir().join(format!(",
"crates\\msudo\\src\\lib.rs:let temp_dir = std::env::temp_dir();",
"crates\\runtimekit\\src\\lib.rs:std::env::temp_dir().join(format!(\"{prefix}-{unique}.{extension}\"))",
],
"production temp root use must stay explicitly audited"
);
for (path, required) in [
(
"crates/runtimekit/src/lib.rs",
&[
"fn write_shell_wrapper_file",
".create_new(true)",
"refusing to replace existing shell wrapper",
][..],
),
(
"crates/argv/src/lib.rs",
&[
"fn write_cmd_wrapper_file",
".create_new(true)",
"refusing to replace existing cmd inspect wrapper",
][..],
),
(
"crates/envdiff/src/lib.rs",
&[
"fn write_temp_file_exclusive",
".create_new(true)",
"fn create_temp_dir_exclusive",
"fs::create_dir(path)",
][..],
),
(
"crates/msudo/src/lib.rs",
&[
"fn write_relay_exit_status",
".create_new(true)",
"failed to create relay exit status",
][..],
),
(
"crates/runprobe/src/lib.rs",
&[
"fn write_log_file_exclusive",
".create_new(true)",
"refusing to replace existing runprobe log",
][..],
),
(
"crates/windowsupport/src/sudo.rs",
&[
"fn create_relay_output_file",
".create_new(true)",
"FILE_FLAG_OPEN_REPARSE_POINT",
][..],
),
] {
let body = fs::read_to_string(root.join(path)).unwrap_or_else(|error| {
panic!("failed to read {path}: {error}");
});
for needle in required {
assert!(
body.contains(needle),
"{path} should keep temp/log output guard {needle:?}"
);
}
}
}
fn production_source_hits(root: &std::path::Path, needles: &[&str]) -> Vec<String> {
let mut hits = Vec::new();
collect_production_source_hits(&root.join("crates"), root, needles, &mut hits);
hits.sort();
hits
}
fn collect_production_source_hits(
directory: &std::path::Path,
root: &std::path::Path,
needles: &[&str],
hits: &mut Vec<String>,
) {
for entry in fs::read_dir(directory).unwrap_or_else(|error| {
panic!("failed to read {}: {error}", directory.display());
}) {
let path = entry.expect("directory entry").path();
if path.is_dir() {
if path.file_name().and_then(|name| name.to_str()) != Some("tests") {
collect_production_source_hits(&path, root, needles, hits);
}
continue;
}
if path.extension().and_then(|extension| extension.to_str()) != Some("rs") {
continue;
}
collect_file_hits(&path, root, needles, hits);
}
}
fn collect_file_hits(
path: &std::path::Path,
root: &std::path::Path,
needles: &[&str],
hits: &mut Vec<String>,
) {
let body = fs::read_to_string(path).unwrap_or_else(|error| {
panic!("failed to read {}: {error}", path.display());
});
let mut in_test_region = path.components().any(|component| {
component
.as_os_str()
.to_string_lossy()
.eq_ignore_ascii_case("tests")
});
for line in body.lines() {
let trimmed = line.trim();
if trimmed == "#[cfg(test)]" || trimmed.starts_with("#[test]") {
in_test_region = true;
}
if !in_test_region && needles.iter().any(|needle| trimmed.contains(needle)) {
let relative = path.strip_prefix(root).unwrap_or(path);
hits.push(format!("{}:{}", relative.display(), trimmed));
}
}
}
#[test]
fn deny_advisory_ignores_carry_review_evidence() {
let deny = read_workspace_text(&["deny.toml"], "deny.toml");
let advisory = "RUSTSEC-2024-0436";
let offset = deny
.find(advisory)
.unwrap_or_else(|| panic!("deny.toml should mention {advisory}"));
let context_start = deny[..offset]
.rfind("[advisories]")
.expect("advisories section");
let context = &deny[context_start..offset];
for required in [
"Package:",
"Reachability:",
"Reviewed:",
"Upgrade/follow-up:",
] {
assert!(
context.contains(required),
"advisory ignore {advisory} should document {required}"
);
}
}
#[test]
fn ai_asset_checks_self_heal_generated_drift_before_failing() {
for script_name in ["check-ai-prompt.ps1", "check-ai-skill.ps1"] {
let script = read_workspace_text(&["scripts", script_name], script_name);
assert!(
script.contains("Invoke-GeneratorCheck"),
"{script_name} should use the shared check/regenerate/recheck helper"
);
assert!(
script.contains("Invoke-GeneratorWrite"),
"{script_name} should regenerate stale generated assets automatically"
);
assert!(
script.contains("still out of date after regeneration"),
"{script_name} should only ask for manual intervention after regeneration fails"
);
}
let docs = read_workspace_text(&["docs", "jade-discipline.md"], "jade docs");
assert!(
docs.contains("self-heal generated asset drift"),
"Jade docs should describe the AI asset gate's self-healing behavior"
);
}
+63
View File
@@ -0,0 +1,63 @@
//! Miri regression coverage for compact JSON-family parsers.
use common::formats::toon::{DecodeOptions, EncodeOptions};
use common::formats::{ison, tonl, toon, zon};
use serde_json::json;
#[test]
fn toon_roundtrip_exercises_nested_and_tabular_paths() {
for value in [
json!({
"meta": {
"ok": true,
"count": 2
},
"items": [
{"id": 1, "name": "Ada"},
{"id": 2, "name": "Bob"}
],
"literal.path": "quoted when encoded"
}),
json!([1, 2, 3]),
json!([{"id": 1}, {"id": 2}]),
json!("plain"),
] {
let encoded = toon::encode_value(&value, EncodeOptions::default()).expect("TOON encode");
let decoded = toon::decode_str(&encoded, DecodeOptions::default());
assert_eq!(decoded.expect("TOON decode"), value);
}
}
#[test]
fn toon_decoder_rejects_malformed_counts_and_path_conflicts() {
let bad_count = toon::decode_str("[3]: a,b\n", DecodeOptions::default());
assert!(bad_count.is_err());
let path_conflict = toon::decode_str(
"a: 1\na.b: 2\n",
DecodeOptions {
expand_paths: common::formats::toon::SafeMode::Safe,
..DecodeOptions::default()
},
);
assert!(path_conflict.is_err());
}
#[test]
fn record_formats_decode_without_panicking() {
let records = vec![json!({"id": 1, "name": "Ada", "active": true})];
let isonl = ison::encode_records(&records, "user").expect("ISONL encode");
assert_eq!(ison::decode_records(&isonl).expect("ISONL decode"), records);
assert_eq!(
zon::decode_str("id:1\nname:\"Ada\"\nactive:true\n").expect("ZON decode"),
json!({"id": 1, "name": "Ada", "active": true})
);
let tonl = tonl::encode_documents(&[json!({"id": 1, "name": "Ada"})]).expect("TONL encode");
assert_eq!(
tonl::decode_documents(&tonl).expect("TONL decode"),
vec![json!({"id": 1, "name": "Ada"})]
);
}
+29
View File
@@ -0,0 +1,29 @@
[package]
name = "config"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "Summarize common config file formats into compact AI-friendly key paths."
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[dependencies]
configsupport = { path = "../configsupport" }
common = { path = "../common" }
lexopt.workspace = true
serde.workspace = true
serde_json = { workspace = true, features = ["preserve_order"] }
serde_yaml = "0.9.34"
toml = "0.8.23"
[dev-dependencies]
assert_cmd.workspace = true
predicates.workspace = true
tempfile.workspace = true
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
//! Binary entry point for `config`.
fn main() {
std::process::exit(config::main_entry());
}
+247
View File
@@ -0,0 +1,247 @@
//! Integration tests for the `config` command.
use std::fs;
use assert_cmd::Command;
use predicates::prelude::*;
use serde_json::json;
use tempfile::tempdir;
fn cargo_command() -> Command {
Command::cargo_bin("config").expect("binary")
}
#[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(
"error: provide a subcommand: get, set, delete, or ls",
))
.stderr(predicate::str::contains("config - Mercury Toolbox"))
.stderr(predicate::str::contains("Usage:"))
.stderr(predicate::str::contains("get [PATH] [POINTER]"))
.stderr(predicate::str::contains(
"config [OPTIONS] inspect [PATH] [POINTER]",
))
.stderr(predicate::str::contains(
"Type 'config --help' for the full command reference.",
));
}
#[test]
fn get_reads_toml_pointer_as_json() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("sample.toml");
fs::write(
&path,
"[package]\nname = \"toolbox\"\nversion = \"3.0.0\"\n",
)
.expect("fixture");
let mut command = cargo_command();
command
.arg("--json")
.arg("get")
.arg(&path)
.arg("/package/version")
.assert()
.success()
.stdout(predicate::str::contains("\"format\":\"toml\""))
.stdout(predicate::str::contains("\"pointer\":\"/package/version\""))
.stdout(predicate::str::contains("\"value\":\"3.0.0\""));
}
#[test]
fn set_then_get_updates_json_number_value() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("config.json");
fs::write(&path, "{\"retries\":1}\n").expect("fixture");
let mut set = cargo_command();
set.arg("set")
.arg(&path)
.arg("/retries")
.arg("5")
.arg("--json")
.arg("--value-type")
.arg("number")
.assert()
.success()
.stdout(predicate::str::contains("\"changed\":true"));
let mut get = cargo_command();
get.arg("--json")
.arg("get")
.arg(&path)
.arg("/retries")
.assert()
.success()
.stdout(predicate::str::contains("\"value\":5"));
}
#[test]
fn delete_removes_env_key() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join(".env");
fs::write(&path, "KEEP=yes\nDROP=no\n").expect("fixture");
let mut command = cargo_command();
command
.arg("delete")
.arg(&path)
.arg("/DROP")
.assert()
.success()
.stdout(predicate::str::contains("op=delete"));
let content = fs::read_to_string(&path).expect("updated");
assert!(content.contains("KEEP=yes"));
assert!(!content.contains("DROP=no"));
}
#[test]
fn ls_lists_ini_section_entries() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("settings.ini");
fs::write(&path, "mode=dev\n[server]\nport=8080\nhost=127.0.0.1\n").expect("fixture");
let mut command = cargo_command();
command
.arg("--json")
.arg("ls")
.arg(&path)
.arg("/server")
.assert()
.success()
.stdout(predicate::str::contains("\"key\":\"host\""))
.stdout(predicate::str::contains("\"key\":\"port\""));
}
#[test]
fn ls_accepts_single_stdin_path_stream() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("config.json");
fs::write(&path, "{\"mode\":\"dev\",\"nested\":{\"ok\":true}}\n").expect("fixture");
let mut command = cargo_command();
command
.arg("--json")
.arg("ls")
.write_stdin(format!("{}\n", path.display()))
.assert()
.success()
.stdout(predicate::str::contains("\"pointer\":\"/\""))
.stdout(predicate::str::contains("\"key\":\"mode\""))
.stdout(predicate::str::contains("\"key\":\"nested\""));
}
#[test]
fn help_includes_new_subcommands_and_flags() {
let mut command = cargo_command();
command
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("get [PATH] [POINTER]"))
.stdout(predicate::str::contains("set <PATH> <POINTER> <VALUE>"))
.stdout(predicate::str::contains("--value-type"))
.stdout(predicate::str::contains("--format"))
.stdout(predicate::str::contains("config"));
}
#[test]
fn ls_on_scalar_pointer_returns_no_results_exit_code() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("config.json");
fs::write(&path, "{\"mode\":\"dev\"}\n").expect("fixture");
let mut command = cargo_command();
command
.arg("ls")
.arg(&path)
.arg("/mode")
.assert()
.code(1)
.stdout(predicate::str::contains("entries=0"));
}
#[test]
fn delete_missing_json_key_returns_no_results_and_preserves_file() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("config.json");
fs::write(&path, "{\"present\":true}\n").expect("fixture");
let mut command = cargo_command();
command
.arg("--json")
.arg("delete")
.arg(&path)
.arg("/missing")
.assert()
.code(1)
.stdout(predicate::str::contains("\"changed\":false"))
.stdout(predicate::str::contains("\"pointer\":\"/missing\""));
let content = fs::read_to_string(&path).expect("updated");
let parsed: serde_json::Value = serde_json::from_str(&content).expect("valid json");
assert_eq!(parsed, json!({"present": true}));
}
#[test]
fn set_creates_nested_json_arrays_and_objects() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("config.json");
fs::write(&path, "{}\n").expect("fixture");
let mut command = cargo_command();
command
.arg("--json")
.arg("set")
.arg(&path)
.arg("/pkg/deps/0/name")
.arg("serde")
.assert()
.success()
.stdout(predicate::str::contains("\"changed\":true"));
let content = fs::read_to_string(&path).expect("updated");
let parsed: serde_json::Value = serde_json::from_str(&content).expect("valid json");
assert_eq!(parsed, json!({"pkg": {"deps": [{"name": "serde"}]}}));
}
#[test]
fn env_set_rejects_nested_pointer_and_non_scalar_json_values() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join(".env");
fs::write(&path, "KEEP=yes\n").expect("fixture");
let mut nested_pointer = cargo_command();
nested_pointer
.arg("set")
.arg(&path)
.arg("/A/B")
.arg("x")
.assert()
.code(3)
.stderr(predicate::str::contains(
"env write operations only support root keys like /NAME",
));
let mut object_value = cargo_command();
object_value
.arg("set")
.arg(&path)
.arg("/OBJ")
.arg("{\"nested\":true}")
.arg("--value-type")
.arg("json")
.assert()
.code(3)
.stderr(predicate::str::contains(
"env/ini writes require scalar string|number|bool values",
));
}
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "configsupport"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "Shared CLI helpers for standalone Mercury Toolbox config and repo commands."
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
+614
View File
@@ -0,0 +1,614 @@
//! Shared CLI helpers for standalone Mercury Toolbox commands.
use std::fmt::Display;
use std::io::{self, IsTerminal, Write};
use serde::Serialize;
use thiserror::Error;
/// Controls ANSI color behavior for command output.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ColorChoice {
/// Enable color only when output looks interactive.
#[default]
Auto,
/// Never emit ANSI color sequences.
Never,
}
/// Selects the output surface for a command invocation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RenderMode {
/// Emit compact text.
Text,
/// Emit compact JSON.
Json,
/// Emit compact TOON.
Toon,
}
/// Shared flags that every standalone command accepts.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct CommonArgs {
/// Emit JSON instead of text.
pub json: bool,
/// Explicit structured output format selected by `--format`, `--json`, or `--toon`.
pub format: Option<RenderMode>,
/// Suppress non-essential status output.
pub quiet: bool,
/// Control ANSI color output.
pub color: ColorChoice,
}
impl CommonArgs {
/// Records an explicit output format selection.
pub fn set_render_mode(&mut self, render_mode: RenderMode) {
self.json = render_mode == RenderMode::Json;
self.format = Some(render_mode);
}
/// Returns the output mode implied by the current settings.
#[must_use]
pub const fn render_mode(self) -> RenderMode {
if let Some(format) = self.format {
return format;
}
if self.json {
RenderMode::Json
} else {
RenderMode::Text
}
}
/// Reports whether stdin is attached to an interactive terminal.
#[must_use]
pub fn stdin_is_terminal(&self) -> bool {
io::stdin().is_terminal()
}
}
/// Stable process exit codes shared by the standalone commands.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExitCode {
/// The command succeeded and produced at least one result.
Success = 0,
/// The command succeeded but produced no matching results.
NoResults = 1,
/// The invocation was rejected because the input was invalid.
UsageError = 2,
/// The command hit an operational failure at runtime.
RuntimeError = 3,
}
impl ExitCode {
/// Converts the enum into a process exit code.
#[must_use]
pub const fn as_i32(self) -> i32 {
self as i32
}
}
/// Represents user-facing command failures.
#[derive(Debug, Error)]
pub enum CliError {
/// The user supplied invalid arguments or malformed input.
#[error("{0}")]
Usage(String),
/// The command failed while reading, probing, or rendering data.
#[error("{0}")]
Runtime(String),
}
impl CliError {
/// Builds a usage error.
#[must_use]
pub fn usage(message: impl Into<String>) -> Self {
Self::Usage(message.into())
}
/// Builds a runtime error.
#[must_use]
pub fn runtime(message: impl Into<String>) -> Self {
Self::Runtime(message.into())
}
/// Returns the exit code associated with the error category.
#[must_use]
pub const fn exit_code(&self) -> ExitCode {
match self {
Self::Usage(_) => ExitCode::UsageError,
Self::Runtime(_) => ExitCode::RuntimeError,
}
}
}
/// Parses the shared `--color` argument.
///
/// # Errors
///
/// Returns [`CliError::Usage`] when the value is not supported.
pub fn parse_color_choice(value: &str) -> Result<ColorChoice, CliError> {
match value {
"auto" => Ok(ColorChoice::Auto),
"never" => Ok(ColorChoice::Never),
other => Err(CliError::usage(format!(
"invalid --color value '{other}'; expected auto or never"
))),
}
}
/// Parses the shared `--format` output argument.
///
/// # Errors
///
/// Returns [`CliError::Usage`] when the value is not supported.
pub fn parse_format_choice(value: &str) -> Result<RenderMode, CliError> {
match value {
"text" => Ok(RenderMode::Text),
"json" => Ok(RenderMode::Json),
"toon" => Ok(RenderMode::Toon),
other => Err(CliError::usage(format!(
"invalid --format value '{other}'; expected text, json, or toon"
))),
}
}
/// Writes a JSON value to stdout.
///
/// # Errors
///
/// Returns [`CliError::Runtime`] when serialization or stdout writes fail.
pub fn print_json<T>(value: &T) -> Result<(), CliError>
where
T: Serialize,
{
let mut stdout = io::stdout().lock();
if let Err(error) = serde_json::to_writer(&mut stdout, value) {
return match error.io_error_kind() {
Some(io::ErrorKind::BrokenPipe) => Ok(()),
_ => Err(CliError::runtime(format!(
"failed to write stdout: {error}"
))),
};
}
stdout_write_result(stdout.write_all(b"\n"))
}
/// Writes a structured value to stdout as JSON or TOON.
///
/// # Errors
///
/// Returns [`CliError::Runtime`] when serialization or stdout writes fail.
pub fn print_structured<T>(value: &T, render_mode: RenderMode) -> Result<(), CliError>
where
T: Serialize,
{
match render_mode {
RenderMode::Json => print_json(value),
RenderMode::Toon => {
let value = serde_json::to_value(value).map_err(|error| {
CliError::runtime(format!("failed to serialize structured output: {error}"))
})?;
let mut stdout = io::stdout().lock();
write_toon_value(&mut stdout, &value, 0)?;
stdout_write_result(stdout.write_all(b"\n"))
}
RenderMode::Text => Err(CliError::runtime(
"structured text rendering requires command-specific text output",
)),
}
}
fn write_toon_value(
writer: &mut impl Write,
value: &serde_json::Value,
depth: usize,
) -> Result<(), CliError> {
match value {
serde_json::Value::Object(object) => {
for (key, value) in object {
match value {
serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
stdout_write_result(writeln!(writer, "{}{}:", " ".repeat(depth), key))?;
write_toon_value(writer, value, depth + 1)?;
}
primitive => {
stdout_write_result(writeln!(
writer,
"{}{}: {}",
" ".repeat(depth),
key,
toon_primitive(primitive)
))?;
}
}
}
}
serde_json::Value::Array(array) => {
for value in array {
stdout_write_result(writeln!(
writer,
"{}- {}",
" ".repeat(depth),
toon_primitive(value)
))?;
}
}
primitive => {
stdout_write_result(write!(writer, "{}", toon_primitive(primitive)))?;
}
}
Ok(())
}
fn stdout_write_result(result: io::Result<()>) -> Result<(), CliError> {
match result {
Ok(()) => Ok(()),
Err(error) if error.kind() == io::ErrorKind::BrokenPipe => Ok(()),
Err(error) => Err(CliError::runtime(format!(
"failed to write stdout: {error}"
))),
}
}
fn toon_primitive(value: &serde_json::Value) -> String {
match value {
serde_json::Value::Null => "null".to_string(),
serde_json::Value::Bool(value) => value.to_string(),
serde_json::Value::Number(value) => value.to_string(),
serde_json::Value::String(value) => value.clone(),
serde_json::Value::Array(_) | serde_json::Value::Object(_) => "{}".to_string(),
}
}
/// Writes a single line of text to stdout.
///
/// # Errors
///
/// Returns [`CliError::Runtime`] when stdout cannot be written.
pub fn print_text(text: impl Display) -> Result<(), CliError> {
let mut stdout = io::stdout().lock();
stdout_write_result(writeln!(stdout, "{text}"))
}
/// Writes a formatted error message to stderr.
pub fn print_error(error: &CliError) {
let _ = writeln!(io::stderr().lock(), "{error}");
}
/// Writes a compact first-page help card for usage failures.
pub fn print_quick_help_error(error: &CliError, help: &str) {
let use_color = stderr_supports_color();
let mut stderr = io::stderr().lock();
if use_color {
let _ = writeln!(stderr, "\x1b[31;1merror:\x1b[0m {error}");
} else {
let _ = writeln!(stderr, "error: {error}");
}
let _ = writeln!(stderr);
let _ = write_quick_help(&mut stderr, help, use_color);
}
fn stderr_supports_color() -> bool {
io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none()
}
fn write_quick_help(mut writer: impl Write, help: &str, use_color: bool) -> io::Result<()> {
let summary = first_help_line(help).unwrap_or("Mercury Toolbox command");
let command = quick_help_command(help).unwrap_or("mercury");
let title = format!("{command} - Mercury Toolbox");
write_quick_heading(&mut writer, &title, use_color)?;
writeln!(writer, " {summary}")?;
writeln!(writer)?;
write_named_section(
&mut writer,
help,
"Usage:",
&[
"Commands:",
"Subcommands:",
"Options:",
"Shared Options:",
"Examples:",
],
"Usage:",
5,
use_color,
)?;
write_named_section(
&mut writer,
help,
"Commands:",
&["Options:", "Shared Options:", "Examples:"],
"Commands:",
8,
use_color,
)?;
write_named_section(
&mut writer,
help,
"Subcommands:",
&["Options:", "Shared Options:", "Examples:"],
"Commands:",
8,
use_color,
)?;
write_named_section(
&mut writer,
help,
"Options:",
&["Commands:", "Subcommands:", "Examples:"],
"Common options:",
8,
use_color,
)?;
write_named_section(
&mut writer,
help,
"Shared Options:",
&[
"Commands:",
"Subcommands:",
"Find Options:",
"Body Options:",
"Examples:",
],
"Common options:",
8,
use_color,
)?;
write_named_section(
&mut writer,
help,
"Examples:",
&[],
"Examples:",
3,
use_color,
)?;
if use_color {
writeln!(
writer,
"Type \x1b[1m{command} --help\x1b[0m for the full command reference."
)
} else {
writeln!(
writer,
"Type '{command} --help' for the full command reference."
)
}
}
fn first_help_line(help: &str) -> Option<&str> {
help.lines().map(str::trim).find(|line| !line.is_empty())
}
fn quick_help_command(help: &str) -> Option<&str> {
help_section(
help,
"Usage:",
&[
"Commands:",
"Subcommands:",
"Options:",
"Shared Options:",
"Examples:",
],
)
.and_then(|lines| lines.into_iter().find_map(first_usage_token))
}
fn first_usage_token(line: &str) -> Option<&str> {
line.split_whitespace()
.next()
.filter(|token| token.chars().any(char::is_alphanumeric))
}
fn write_quick_heading(writer: &mut impl Write, heading: &str, use_color: bool) -> io::Result<()> {
if use_color {
writeln!(writer, "\x1b[1;36m{heading}\x1b[0m")
} else {
writeln!(writer, "{heading}")
}
}
fn write_named_section(
writer: &mut impl Write,
help: &str,
source_heading: &str,
stop_headings: &[&str],
display_heading: &str,
limit: usize,
use_color: bool,
) -> io::Result<()> {
if let Some(lines) = help_section(help, source_heading, stop_headings) {
write_quick_heading(writer, display_heading, use_color)?;
write_limited_section(writer, lines, limit)?;
writeln!(writer)?;
}
Ok(())
}
fn help_section<'a>(help: &'a str, heading: &str, stop_headings: &[&str]) -> Option<Vec<&'a str>> {
let mut lines = help.lines();
for line in lines.by_ref() {
if line.trim() == heading {
let mut section = Vec::new();
for candidate in lines {
let trimmed = candidate.trim();
if stop_headings.contains(&trimmed) || is_top_level_help_heading(candidate, trimmed)
{
break;
}
if !trimmed.is_empty() {
section.push(candidate);
}
}
return Some(section);
}
}
None
}
fn is_top_level_help_heading(raw: &str, trimmed: &str) -> bool {
!trimmed.is_empty() && raw == trimmed && trimmed.ends_with(':')
}
fn write_limited_section(
writer: &mut impl Write,
lines: Vec<&str>,
limit: usize,
) -> io::Result<()> {
for line in lines.into_iter().take(limit) {
writeln!(writer, "{line}")?;
}
Ok(())
}
/// Maps a result count to the shared exit code contract.
#[must_use]
pub const fn map_result_count(count: usize) -> ExitCode {
if count == 0 {
ExitCode::NoResults
} else {
ExitCode::Success
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn cli_error_exit_codes_match_categories() {
assert_eq!(CliError::usage("bad").exit_code(), ExitCode::UsageError);
assert_eq!(CliError::runtime("bad").exit_code(), ExitCode::RuntimeError);
}
#[test]
fn print_helpers_succeed() {
assert!(print_json(&json!({"ok": true})).is_ok());
assert!(print_text("ok").is_ok());
}
#[test]
fn stdout_write_errors_ignore_broken_pipe_only() {
assert!(
stdout_write_result(Err(io::Error::new(io::ErrorKind::BrokenPipe, "closed"))).is_ok()
);
assert!(matches!(
stdout_write_result(Err(io::Error::other("disk"))),
Err(CliError::Runtime(message)) if message.contains("failed to write stdout: disk")
));
}
#[test]
fn common_args_render_mode_and_result_mapping_are_stable() {
assert_eq!(CommonArgs::default().render_mode(), RenderMode::Text);
assert_eq!(
CommonArgs {
json: true,
format: None,
quiet: false,
color: ColorChoice::Auto,
}
.render_mode(),
RenderMode::Json
);
assert_eq!(map_result_count(0), ExitCode::NoResults);
assert_eq!(map_result_count(3), ExitCode::Success);
}
#[test]
fn parse_color_choice_accepts_known_values_and_rejects_unknowns() {
assert_eq!(parse_color_choice("auto").expect("auto"), ColorChoice::Auto);
assert_eq!(
parse_color_choice("never").expect("never"),
ColorChoice::Never
);
assert!(matches!(
parse_color_choice("always"),
Err(CliError::Usage(message))
if message.contains("invalid --color value 'always'")
));
}
#[test]
fn format_choice_and_toon_writer_cover_nested_values() {
assert_eq!(parse_format_choice("text").expect("text"), RenderMode::Text);
assert_eq!(parse_format_choice("json").expect("json"), RenderMode::Json);
assert_eq!(parse_format_choice("toon").expect("toon"), RenderMode::Toon);
assert!(parse_format_choice("yaml").is_err());
let mut output = Vec::new();
write_toon_value(
&mut output,
&json!({
"meta": {"ok": true, "count": 2},
"items": ["a", {"nested": true}],
"none": null
}),
0,
)
.expect("toon writer");
let rendered = String::from_utf8(output).expect("utf8");
assert!(rendered.contains("meta:\n"));
assert!(rendered.contains(" ok: true\n"));
assert!(rendered.contains(" count: 2\n"));
assert!(rendered.contains("items:\n - a\n - {}\n"));
assert!(rendered.contains("none: null\n"));
assert_eq!(toon_primitive(&json!("plain")), "plain");
assert_eq!(toon_primitive(&json!({ "nested": true })), "{}");
}
#[test]
fn quick_help_extracts_sections_and_limits_output() {
let help = "\
Example command.
Usage:
example [OPTIONS] <PATH>
Commands:
run
inspect
Options:
--json
--toon
--verbose
Examples:
example README.md
example --json config.json
";
assert_eq!(first_help_line(help), Some("Example command."));
assert_eq!(quick_help_command(help), Some("example"));
assert_eq!(
help_section(help, "Commands:", &["Options:"]).expect("commands"),
vec![" run", " inspect"]
);
assert!(is_top_level_help_heading("Options:", "Options:"));
assert!(!is_top_level_help_heading(" --json", "--json"));
let mut output = Vec::new();
write_quick_help(&mut output, help, false).expect("plain quick help");
let rendered = String::from_utf8(output).expect("utf8");
assert!(rendered.contains("example - Mercury Toolbox"));
assert!(rendered.contains("Common options:"));
assert!(rendered.contains("Type 'example --help'"));
let mut colored = Vec::new();
write_quick_heading(&mut colored, "Title", true).expect("colored heading");
assert!(
String::from_utf8(colored)
.expect("utf8")
.contains("\x1b[1;36m")
);
}
}
+31
View File
@@ -0,0 +1,31 @@
[package]
name = "context"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "Pack files, hits, snippets, and definitions into compact context blocks."
keywords.workspace = true
categories.workspace = true
autobins = false
[lints]
workspace = true
[dependencies]
codeindex = { path = "../codeindex" }
common = { path = "../common", default-features = false }
lexopt.workspace = true
serde.workspace = true
serde_json.workspace = true
[dev-dependencies]
assert_cmd.workspace = true
predicates.workspace = true
[[bin]]
name = "ctxpack"
path = "src/main.rs"
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
//! Binary entry point for `ctxpack`.
fn main() {
std::process::exit(context::main_entry());
}
+129
View File
@@ -0,0 +1,129 @@
//! Integration tests for the `ctxpack` command.
use assert_cmd::Command;
use predicates::prelude::*;
use std::path::PathBuf;
fn cargo_command() -> Command {
Command::cargo_bin("ctxpack").expect("binary")
}
fn fixture_path(relative: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("fixtures")
.join("polyglot")
.join("repo")
.join(relative)
}
fn pwsh_command(script: impl AsRef<str>) -> Command {
let mut command = Command::new("pwsh");
command
.arg("-NoProfile")
.arg("-Command")
.arg(script.as_ref());
command
}
#[test]
fn emits_definition_blocks_for_line_hits() {
let mut command = cargo_command();
command
.arg(format!("{}:26", fixture_path("src/lib.rs").display()))
.assert()
.success()
.stdout(predicate::str::contains("kind=definition"))
.stdout(predicate::str::contains("call_helper"));
}
#[test]
fn emits_json_blocks_from_definition_records() {
let mut command = cargo_command();
command
.arg("--json")
.arg("{\"path\":\"demo.rs\",\"qualified_name\":\"helper\",\"start_line\":1,\"end_line\":2,\"text\":\"fn helper() {}\"}")
.assert()
.failure();
}
#[test]
fn supports_powershell_pipeline_json_input() {
let binary = assert_cmd::cargo::cargo_bin("ctxpack");
let input = "[{\"path\":\"demo.rs\",\"qualified_name\":\"helper\",\"start_line\":1,\"end_line\":2,\"text\":\"fn helper() {}\"}]";
let script = format!(
"'{}' | & '{}' --input-format auto --json | ConvertFrom-Json | Select-Object -First 1 -ExpandProperty header",
input,
binary.display()
);
let mut command = pwsh_command(script);
command
.assert()
.success()
.stdout(predicate::str::contains("helper"));
}
#[test]
fn rejects_scalar_json_from_stdin_auto_mode() {
let mut command = cargo_command();
command
.args(["--json", "--input-format", "auto"])
.write_stdin("42\n")
.assert()
.failure()
.stderr(predicate::str::contains(
"JSON input must be an object or array of objects",
));
}
#[test]
fn rejects_non_array_wrapper_fields_from_jsonl_stdin() {
let mut command = cargo_command();
command
.args(["--json", "--input-format", "jsonl"])
.write_stdin("{\"hits\":{\"path\":\"demo.rs\",\"line\":7}}\n")
.assert()
.failure()
.stderr(predicate::str::contains(
"context JSON field 'hits' must be an array when present",
));
}
#[test]
fn expands_jsonl_wrappers_and_applies_dedupe_limits() {
let input = concat!(
"{\"items\":[{\"path\":\"demo.rs\",\"name\":\"first\",\"text\":\"fn first() {}\"}]}\n",
"{\"path\":\"demo.rs\",\"name\":\"first\",\"text\":\"fn first() {}\"}\n",
"{\"path\":\"demo.rs\",\"name\":\"second\",\"text\":\"fn second() {}\"}\n"
);
let mut command = cargo_command();
command
.args([
"--json",
"--input-format",
"jsonl",
"--dedupe",
"--sort",
"path",
"--max-blocks",
"1",
])
.write_stdin(input)
.assert()
.success()
.stdout(predicate::str::contains("\"header\":\"first\""))
.stdout(predicate::str::contains("\"header\":\"second\"").not());
}
#[test]
fn help_includes_examples_and_pipeline_usage() {
let mut command = cargo_command();
command
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("--max-blocks"))
.stdout(predicate::str::contains("ConvertFrom-Json"))
.stdout(predicate::str::contains("ctxpack"));
}
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "csvshape"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "Summarize large CSV and TSV files with bounded, AI-friendly output."
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[dependencies]
common = { path = "../common", default-features = false }
csv.workspace = true
lexopt.workspace = true
serde.workspace = true
[dev-dependencies]
assert_cmd.workspace = true
predicates.workspace = true
tempfile.workspace = true
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
//! Binary entry point for `csvshape`.
fn main() {
std::process::exit(csvshape::main_entry());
}
+45
View File
@@ -0,0 +1,45 @@
//! Integration tests for the `csvshape` command.
use std::fs;
use assert_cmd::Command;
use predicates::prelude::*;
use tempfile::tempdir;
fn cargo_command() -> Command {
Command::cargo_bin("csvshape").expect("binary")
}
#[test]
fn summarizes_csv_as_json() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("sample.csv");
fs::write(
&path,
"name,age,city\nAda,34,London\nBob,,Paris\nCara,29,Paris\n",
)
.expect("fixture");
cargo_command()
.arg("--json")
.arg(&path)
.assert()
.success()
.stdout(predicate::str::contains("\"row_count\":3"))
.stdout(predicate::str::contains("\"column_count\":3"))
.stdout(predicate::str::contains("\"name\":\"age\""));
}
#[test]
fn help_includes_csv_examples() {
cargo_command()
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains(
"csvshape [OPTIONS] diff <BEFORE> <AFTER>",
))
.stdout(predicate::str::contains("--sample-rows"))
.stdout(predicate::str::contains("ConvertFrom-Json"))
.stdout(predicate::str::contains("csvshape"));
}
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "defsnip"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "Extract full code definitions by exact symbol name with AST-backed matching."
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[dependencies]
codeindex = { path = "../codeindex" }
common = { path = "../common", default-features = false }
lexopt.workspace = true
[dev-dependencies]
assert_cmd.workspace = true
predicates.workspace = true
serde_json.workspace = true
tempfile.workspace = true
+672
View File
@@ -0,0 +1,672 @@
//! The `defsnip` command extracts full AST-backed definitions by exact symbol name.
use codeindex::{
CodeIndexer, CodeLanguage, IndexedSymbol, SUPPORTED_LANGUAGE_LIST, SymbolKind, detect_language,
parse_language_label,
};
use common::{
CliError, CommonArgs, ExitCode, InputFormat, RenderMode, collect_matching_files,
parse_color_choice, parse_format_choice, parse_input_format, print_json,
print_quick_help_error, print_structured, should_read_stdin,
};
use lexopt::prelude::{Long, Short, Value as ArgValue};
use std::collections::BTreeSet;
use std::ffi::OsString;
use std::fmt::Write as _;
use std::fs;
use std::io::{self, Read};
use std::path::PathBuf;
const HELP: &str = "\
Extract full code definitions by exact symbol name via the shared codeindex engine.
Usage:
defsnip [OPTIONS] <SYMBOL> [PATH...]
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
--lang <LIST> Restrict languages: rust,csharp,powershell,python,go,java,javascript,typescript
--kind <LIST> Restrict kinds: module,namespace,class,struct,enum,interface,record,trait,impl,type_alias,function,method,constructor,const,static
--limit <COUNT> Maximum number of matching definitions to emit
--allow-empty Exit 0 when no matching definitions are found
--parents Include parent-chain metadata in text output
-h, --help Show this help text
-V, --version Show the command version
Examples:
defsnip helper .\\fixtures\\polyglot\\repo
defsnip build_report . --json | ConvertFrom-Json
defsnip --kind method --parents Build .\\fixtures\\polyglot\\repo
'.\\fixtures\\polyglot\\repo\\web\\app.ts' | defsnip helper --json | ConvertFrom-Json
Notes:
exact-name lookups can return multiple definitions; narrow with PATH, --kind, or --lang
JSON fields:
engine, path, language, kind, name, qualified_name, signature, start_line, end_line, text
";
#[derive(Debug, Clone)]
struct Cli {
common: CommonArgs,
symbol: String,
paths: Vec<PathBuf>,
languages: Option<BTreeSet<CodeLanguage>>,
kinds: Option<BTreeSet<SymbolKind>>,
limit: usize,
allow_empty: bool,
parents: bool,
}
#[derive(Debug, Clone)]
struct CliDraft {
common: CommonArgs,
paths: Vec<PathBuf>,
languages: Option<BTreeSet<CodeLanguage>>,
kinds: Option<BTreeSet<SymbolKind>>,
limit: usize,
allow_empty: bool,
parents: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ParseOutcome {
Help,
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, _)) => {
print!("{HELP}");
ExitCode::Success.as_i32()
}
Ok((ParseOutcome::Version, _)) => {
println!("defsnip {}", 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 draft = CliDraft {
common: CommonArgs::default(),
paths: Vec::new(),
languages: None,
kinds: None,
limit: 20,
allow_empty: false,
parents: false,
};
let mut symbol = None::<String>;
while let Some(argument) = parser
.next()
.map_err(|error| CliError::usage(error.to_string()))?
{
match argument {
Long("help") | Short('h') => {
return Ok((ParseOutcome::Help, draft.clone().into_cli(String::new())));
}
Long("version") | Short('V') => {
return Ok((ParseOutcome::Version, draft.clone().into_cli(String::new())));
}
Long("json") => draft.common.set_render_mode(RenderMode::Json),
Long("toon") => draft.common.set_render_mode(RenderMode::Toon),
Long("format") => {
let value = parser_value_string(&mut parser, "--format")?;
draft.common.set_render_mode(parse_format_choice(&value)?);
}
Long("input-format") => {
draft.common.input_format =
parse_input_format(&parser_value_string(&mut parser, "--input-format")?)?;
}
Long("color") => {
draft.common.color =
parse_color_choice(&parser_value_string(&mut parser, "--color")?)?;
}
Long("quiet") => draft.common.quiet = true,
Long("lang") => {
draft.languages = Some(parse_language_list(&parser_value_string(
&mut parser,
"--lang",
)?)?);
}
Long("kind") => {
draft.kinds = Some(parse_kind_list(&parser_value_string(
&mut parser,
"--kind",
)?)?);
}
Long("limit") => {
draft.limit = parse_positive_usize_flag(
"--limit",
&parser_value_string(&mut parser, "--limit")?,
)?;
}
Long("allow-empty") => draft.allow_empty = true,
Long("parents") => draft.parents = true,
ArgValue(value) => {
if symbol.is_none() {
symbol = Some(os_value_string(value, "symbol")?);
} else {
draft.paths.push(PathBuf::from(value));
}
}
_ => {
return Err(CliError::usage(
"unsupported argument; use --help to see available options",
));
}
}
}
let symbol =
symbol.ok_or_else(|| CliError::usage("provide an exact symbol name to extract"))?;
Ok((ParseOutcome::Run, draft.into_cli(symbol)))
}
impl CliDraft {
fn into_cli(self, symbol: String) -> Cli {
Cli {
common: self.common,
symbol,
paths: self.paths,
languages: self.languages,
kinds: self.kinds,
limit: self.limit,
allow_empty: self.allow_empty,
parents: self.parents,
}
}
}
fn run(cli: &Cli) -> Result<ExitCode, CliError> {
let matches = collect_matches(cli)?;
match cli.common.render_mode() {
RenderMode::Json => print_json(&matches)?,
RenderMode::Toon => print_structured(&matches, RenderMode::Toon)?,
RenderMode::Text => {
if matches.is_empty() {
if !cli.common.quiet {
if cli.allow_empty {
println!("0 matches");
} else {
println!("0 matches (use --allow-empty to exit 0)");
}
}
} else {
print!("{}", render_text(&matches, cli.parents, !cli.common.quiet));
}
}
}
Ok(if matches.is_empty() {
if cli.allow_empty {
ExitCode::Success
} else {
ExitCode::NoResults
}
} else {
ExitCode::Success
})
}
fn collect_matches(cli: &Cli) -> Result<Vec<IndexedSymbol>, CliError> {
let roots = collect_roots(cli)?;
let files = discover_supported_files(&roots)?;
let mut indexer = CodeIndexer::new();
let mut matches = Vec::new();
for path in files {
let source = fs::read_to_string(&path).map_err(|error| {
CliError::runtime(format!("failed to read {}: {error}", path.display()))
})?;
let symbols = indexer.index_source(&path, &source)?;
for symbol in symbols {
if !matches_symbol(&symbol, cli) {
continue;
}
matches.push(symbol);
if matches.len() >= cli.limit {
break;
}
}
if matches.len() >= cli.limit {
break;
}
}
Ok(matches)
}
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()))?;
os_value_string(value, flag)
}
fn os_value_string(value: OsString, flag: &str) -> Result<String, CliError> {
value.into_string().map_err(|invalid| {
CliError::usage(format!(
"{flag} expects UTF-8 text, got '{}'",
invalid.to_string_lossy()
))
})
}
fn parse_usize_flag(flag: &str, value: &str) -> Result<usize, CliError> {
value
.parse::<usize>()
.map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}")))
}
fn parse_positive_usize_flag(flag: &str, value: &str) -> Result<usize, CliError> {
let parsed = parse_usize_flag(flag, value)?;
if parsed == 0 {
return Err(CliError::usage(format!("{flag} must be greater than 0")));
}
Ok(parsed)
}
fn parse_language_list(value: &str) -> Result<BTreeSet<CodeLanguage>, CliError> {
let mut languages = BTreeSet::new();
for raw in split_csv_values(value) {
let language = parse_language_label(raw).ok_or_else(|| {
CliError::usage(format!(
"invalid --lang entry '{raw}'; expected {SUPPORTED_LANGUAGE_LIST}"
))
})?;
let _ = languages.insert(language);
}
Ok(languages)
}
fn parse_kind_list(value: &str) -> Result<BTreeSet<SymbolKind>, CliError> {
let mut kinds = BTreeSet::new();
for raw in split_csv_values(value) {
let kind = match raw {
"module" => SymbolKind::Module,
"namespace" => SymbolKind::Namespace,
"class" => SymbolKind::Class,
"struct" => SymbolKind::Struct,
"enum" => SymbolKind::Enum,
"interface" => SymbolKind::Interface,
"record" => SymbolKind::Record,
"trait" => SymbolKind::Trait,
"impl" => SymbolKind::Impl,
"type_alias" => SymbolKind::TypeAlias,
"function" => SymbolKind::Function,
"method" => SymbolKind::Method,
"constructor" => SymbolKind::Constructor,
"const" => SymbolKind::Const,
"static" => SymbolKind::Static,
other => {
return Err(CliError::usage(format!(
"invalid --kind entry '{other}'; expected module,namespace,class,struct,enum,interface,record,trait,impl,type_alias,function,method,constructor,const,static"
)));
}
};
let _ = kinds.insert(kind);
}
Ok(kinds)
}
fn split_csv_values(value: &str) -> impl Iterator<Item = &str> {
value
.split(',')
.map(str::trim)
.filter(|part| !part.is_empty())
}
fn collect_roots(cli: &Cli) -> Result<Vec<PathBuf>, CliError> {
if should_read_stdin(!cli.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}")))?;
let roots = parse_paths_from_string(&buffer, cli.common.input_format)?;
if !roots.is_empty() {
return Ok(roots);
}
}
if cli.paths.is_empty() {
Ok(vec![PathBuf::from(".")])
} else {
common::expand_input_patterns(&cli.paths, "defsnip")
}
}
fn parse_paths_from_string(
buffer: &str,
input_format: InputFormat,
) -> Result<Vec<PathBuf>, CliError> {
common::read_existing_stdin_path_records(buffer, input_format, "defsnip")?
.map_or_else(|| Ok(Vec::new()), Ok)
}
fn discover_supported_files(roots: &[PathBuf]) -> Result<Vec<PathBuf>, CliError> {
collect_matching_files(roots, &|path| detect_language(path).is_some())
}
fn matches_symbol(symbol: &IndexedSymbol, cli: &Cli) -> bool {
if symbol.name != cli.symbol {
return false;
}
if let Some(languages) = &cli.languages {
if !languages.contains(&symbol.language) {
return false;
}
}
if let Some(kinds) = &cli.kinds {
if !kinds.contains(&symbol.kind) {
return false;
}
}
true
}
fn render_text(matches: &[IndexedSymbol], include_parents: bool, include_guidance: bool) -> String {
let mut output = String::new();
if include_guidance && matches.len() > 1 {
let _ = writeln!(
output,
"matches={} narrow_with=path|--kind|--lang",
matches.len()
);
let _ = writeln!(output);
}
for (index, symbol) in matches.iter().enumerate() {
if index > 0 {
let _ = writeln!(output);
}
let _ = writeln!(
output,
"== {}:{}-{} | {} {}",
symbol.path,
symbol.start_line,
symbol.end_line,
language_label(symbol.language),
kind_label(symbol.kind),
);
let _ = writeln!(output, "qualified: {}", symbol.qualified_name);
if include_parents && !symbol.parents.is_empty() {
let _ = writeln!(output, "parents: {}", symbol.parents.join("::"));
}
let _ = writeln!(output, "{}", symbol.text);
}
output
}
const fn language_label(language: CodeLanguage) -> &'static str {
match language {
CodeLanguage::Rust
| CodeLanguage::Csharp
| CodeLanguage::Powershell
| CodeLanguage::Python
| CodeLanguage::Go
| CodeLanguage::Java
| CodeLanguage::Javascript
| CodeLanguage::Typescript => language.label(),
}
}
const fn kind_label(kind: SymbolKind) -> &'static str {
match kind {
SymbolKind::Module => "module",
SymbolKind::Namespace => "namespace",
SymbolKind::Class => "class",
SymbolKind::Struct => "struct",
SymbolKind::Enum => "enum",
SymbolKind::Interface => "interface",
SymbolKind::Record => "record",
SymbolKind::Trait => "trait",
SymbolKind::Impl => "impl",
SymbolKind::TypeAlias => "type_alias",
SymbolKind::Function => "function",
SymbolKind::Method => "method",
SymbolKind::Constructor => "constructor",
SymbolKind::Const => "const",
SymbolKind::Static => "static",
}
}
#[cfg(test)]
mod tests {
use super::*;
use common::ColorChoice;
use std::fs;
use tempfile::tempdir;
fn fixture_repo() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("fixtures")
.join("polyglot")
.join("repo")
}
fn sample_symbol() -> IndexedSymbol {
IndexedSymbol {
engine: codeindex::ENGINE_NAME,
path: "fixture.rs".to_string(),
language: CodeLanguage::Rust,
kind: SymbolKind::Function,
name: "helper".to_string(),
qualified_name: "nested::Widget::helper".to_string(),
signature: "pub fn helper()".to_string(),
parents: vec!["nested".to_string(), "Widget".to_string()],
depth: 2,
start_line: 3,
end_line: 6,
text: "pub fn helper() {\n 42\n}".to_string(),
}
}
fn cli_for(symbol: &str) -> Cli {
Cli {
common: CommonArgs {
json: false,
format: None,
input_format: InputFormat::Auto,
color: ColorChoice::Never,
quiet: false,
},
symbol: symbol.to_string(),
paths: vec![fixture_repo()],
languages: None,
kinds: None,
limit: 20,
allow_empty: false,
parents: false,
}
}
#[test]
fn parse_cli_and_filters_cover_help_version_and_validation() {
assert_eq!(
parse_cli_from(["defsnip", "--help"]).expect("help").0,
ParseOutcome::Help
);
assert_eq!(
parse_cli_from(["defsnip", "--version"]).expect("version").0,
ParseOutcome::Version
);
let (_, cli) = parse_cli_from([
"defsnip",
"--json",
"--input-format",
"jsonl",
"--color",
"never",
"--lang",
"rust,typescript",
"--kind",
"function,method",
"--limit",
"5",
"--parents",
"helper",
"fixtures/polyglot/repo",
])
.expect("parsed cli");
assert!(cli.common.json);
assert_eq!(cli.common.input_format, InputFormat::Jsonl);
assert_eq!(cli.common.color, ColorChoice::Never);
assert_eq!(cli.limit, 5);
assert!(cli.parents);
assert!(
cli.languages
.as_ref()
.expect("languages")
.contains(&CodeLanguage::Rust)
);
assert!(
cli.kinds
.as_ref()
.expect("kinds")
.contains(&SymbolKind::Method)
);
assert!(parse_cli_from(["defsnip"]).is_err());
assert!(parse_language_list("lua").is_err());
assert!(parse_kind_list("macro").is_err());
assert!(parse_usize_flag("--limit", "nope").is_err());
assert!(parse_positive_usize_flag("--limit", "0").is_err());
assert!(os_value_string(OsString::from("helper"), "symbol").is_ok());
}
#[test]
fn path_parsing_and_discovery_cover_auto_jsonl_and_missing_paths() {
let temp = tempdir().expect("tempdir");
let first = temp.path().join("alpha.rs");
let second = temp.path().join("beta.ts");
let third = temp.path().join("plain.rs");
let fourth = temp.path().join("code.py");
fs::write(&first, "a").expect("first");
fs::write(&second, "b").expect("second");
fs::write(&third, "c").expect("third");
fs::write(&fourth, "d").expect("fourth");
let line_paths = parse_paths_from_string(
&format!("{}\n{}\n", first.display(), second.display()),
InputFormat::Lines,
)
.expect("line paths");
assert_eq!(line_paths, vec![first, second]);
let json_paths = parse_paths_from_string(
&format!(
"{{\"path\":{}}}\n",
serde_json::to_string(&third.display().to_string()).expect("json path")
),
InputFormat::Jsonl,
)
.expect("json paths");
assert_eq!(json_paths, vec![third.clone()]);
let auto_paths = parse_paths_from_string(
&format!("{}\n{}\n", third.display(), fourth.display()),
InputFormat::Auto,
)
.expect("auto paths");
assert_eq!(auto_paths, vec![fourth, third]);
assert!(
discover_supported_files(&[fixture_repo()])
.expect("discover")
.len()
>= 8
);
assert!(discover_supported_files(&[fixture_repo().join("missing")]).is_err());
}
#[test]
fn collect_matches_render_and_run_cover_success_and_no_results() {
let mut cli = cli_for("helper");
let matches = collect_matches(&cli).expect("matches");
assert!(matches.len() >= 4);
assert!(matches.iter().all(|symbol| symbol.name == "helper"));
assert!(
matches
.iter()
.all(|symbol| symbol.engine == codeindex::ENGINE_NAME)
);
cli.languages = Some(BTreeSet::from([CodeLanguage::Typescript]));
cli.kinds = Some(BTreeSet::from([SymbolKind::Function]));
cli.limit = 2;
let filtered = collect_matches(&cli).expect("filtered");
assert!(!filtered.is_empty());
assert!(filtered.len() <= 2);
assert!(
filtered
.iter()
.all(|symbol| symbol.language == CodeLanguage::Typescript)
);
let text = render_text(&[sample_symbol()], true, true);
assert!(text.contains("qualified: nested::Widget::helper"));
assert!(text.contains("parents: nested::Widget"));
assert!(text.contains("pub fn helper()"));
assert_eq!(language_label(CodeLanguage::Powershell), "powershell");
assert_eq!(language_label(CodeLanguage::Java), "java");
assert_eq!(kind_label(SymbolKind::Constructor), "constructor");
let ambiguous_text = render_text(&[sample_symbol(), sample_symbol()], false, true);
assert!(ambiguous_text.contains("matches=2"));
assert!(ambiguous_text.contains("narrow_with=path|--kind|--lang"));
let match_cli = cli_for("helper");
assert_eq!(run(&match_cli).expect("run success"), ExitCode::Success);
let missing_cli = cli_for("does_not_exist");
assert_eq!(run(&missing_cli).expect("run empty"), ExitCode::NoResults);
}
#[test]
fn symbol_matching_honors_language_and_kind_filters() {
let symbol = sample_symbol();
let mut cli = cli_for("helper");
assert!(matches_symbol(&symbol, &cli));
cli.languages = Some(BTreeSet::from([CodeLanguage::Rust]));
assert!(matches_symbol(&symbol, &cli));
cli.languages = Some(BTreeSet::from([CodeLanguage::Python]));
assert!(!matches_symbol(&symbol, &cli));
cli.languages = None;
cli.kinds = Some(BTreeSet::from([SymbolKind::Method]));
assert!(!matches_symbol(&symbol, &cli));
}
}
+5
View File
@@ -0,0 +1,5 @@
//! Binary entry point for `defsnip`.
fn main() {
std::process::exit(defsnip::main_entry());
}
+85
View File
@@ -0,0 +1,85 @@
//! Integration tests for the `defsnip` command.
use assert_cmd::Command;
use predicates::prelude::*;
use std::path::PathBuf;
fn cargo_command() -> Command {
Command::cargo_bin("defsnip").expect("binary")
}
fn fixture_path(relative: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("fixtures")
.join("polyglot")
.join("repo")
.join(relative)
}
fn pwsh_command(script: impl AsRef<str>) -> Command {
let mut command = Command::new("pwsh");
command
.arg("-NoProfile")
.arg("-Command")
.arg(script.as_ref());
command
}
#[test]
fn extracts_exact_symbol_matches_as_json() {
let mut command = cargo_command();
command
.arg("--json")
.arg("helper")
.arg(fixture_path(""))
.assert()
.success()
.stdout(predicate::str::contains("\"qualified_name\":\"helper\""))
.stdout(predicate::str::contains(
"\"qualified_name\":\"nested::Widget::helper\"",
))
.stdout(predicate::str::contains("\"qualified_name\":\"Worker::build\"").not());
}
#[test]
fn filters_method_matches_and_renders_text() {
let mut command = cargo_command();
command
.args(["--kind", "method", "--parents", "Build"])
.arg(fixture_path(""))
.assert()
.success()
.stdout(predicate::str::contains("RocketBuilder::Build"))
.stdout(predicate::str::contains("NestedThing::Build"))
.stdout(predicate::str::contains("public void Build(string name)"));
}
#[test]
fn supports_powershell_pipeline_paths() {
let binary = assert_cmd::cargo::cargo_bin("defsnip");
let path = fixture_path("web/app.ts");
let script = format!(
"'{}' | & '{}' helper --json | ConvertFrom-Json | Select-Object -ExpandProperty qualified_name",
path.display(),
binary.display()
);
let mut command = pwsh_command(script);
command
.assert()
.success()
.stdout(predicate::str::contains("helper"));
}
#[test]
fn help_includes_examples_and_pipeline_usage() {
let mut command = cargo_command();
command
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("--kind"))
.stdout(predicate::str::contains("ConvertFrom-Json"))
.stdout(predicate::str::contains("defsnip helper"));
}
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "diagpick"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "Extract actionable diagnostics from noisy logs."
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[dependencies]
codeindex = { path = "../codeindex" }
common = { path = "../common", default-features = false }
lexopt.workspace = true
serde.workspace = true
serde_json.workspace = true
[dev-dependencies]
assert_cmd.workspace = true
predicates.workspace = true
tempfile.workspace = true
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
//! Binary entry point for `diagpick`.
fn main() {
std::process::exit(diagpick::main_entry());
}
+178
View File
@@ -0,0 +1,178 @@
//! Integration tests for the `diagpick` command.
use std::fs;
use std::path::PathBuf;
use tempfile::tempdir;
use assert_cmd::Command;
use predicates::prelude::*;
fn cargo_command() -> Command {
Command::cargo_bin("diagpick").expect("binary")
}
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.canonicalize()
.expect("workspace root")
}
fn fixture_relative(path: &str) -> PathBuf {
PathBuf::from("fixtures").join(path)
}
fn fixture(path: &str) -> PathBuf {
workspace_root().join(fixture_relative(path))
}
fn relative_to_workspace(path: &str) -> String {
fixture_relative(path).display().to_string()
}
fn pwsh_command(script: impl AsRef<str>) -> Command {
let mut command = Command::new("pwsh");
command
.arg("-NoProfile")
.arg("-Command")
.arg(script.as_ref());
command
}
#[test]
fn extracts_rust_diagnostics_in_text_mode() {
let mut command = cargo_command();
command
.current_dir(workspace_root())
.arg(fixture("diag/rust-errors.txt"))
.assert()
.success()
.stdout(predicate::str::contains("severity=error code=E0425"))
.stdout(predicate::str::contains("sample.rs line=25 column=11"))
.stdout(predicate::str::contains("severity=warning code=-"))
.stdout(predicate::str::contains("sample.rs line=33 column=4"));
}
#[test]
fn filters_unity_diagnostics_and_attaches_source_as_json() {
let mut command = cargo_command();
command
.current_dir(workspace_root())
.arg("--json")
.arg("--severity")
.arg("error")
.arg("--with-source")
.arg("--context")
.arg("1")
.arg(fixture("diag/unity-errors.txt"))
.assert()
.success()
.stdout(predicate::str::contains("\"severity\":\"error\""))
.stdout(predicate::str::contains("\"code\":\"CS0103\""))
.stdout(predicate::str::contains("\"path\":\""))
.stdout(predicate::str::contains("sample.cs"))
.stdout(predicate::str::contains("\"start_line\":8"))
.stdout(predicate::str::contains("ComputeScore"))
.stdout(predicate::str::contains("CS0168").not());
}
#[test]
fn supports_powershell_pipeline_input() {
let binary = assert_cmd::cargo::cargo_bin("diagpick");
let input = relative_to_workspace("diag/unity-errors.txt");
let script = format!(
"[System.IO.File]::ReadLines('{}') | & '{}' --json",
input,
binary.display()
);
let mut command = pwsh_command(script);
command
.current_dir(workspace_root())
.assert()
.success()
.stdout(predicate::str::contains("\"code\":\"CS0103\""))
.stdout(predicate::str::contains("\"severity\":\"warning\""));
}
#[test]
fn utf8_bom_stdin_still_extracts_first_diagnostic() {
let mut command = cargo_command();
command
.write_stdin(
"\u{feff}error[E0425]: cannot find value `missing` in this scope\n --> sample.rs:25:11\n",
)
.assert()
.success()
.stdout(predicate::str::contains("severity=error code=E0425"))
.stdout(predicate::str::contains("sample.rs line=25 column=11"));
}
#[test]
fn utf8_bom_log_file_still_extracts_diagnostics() {
let dir = tempdir().expect("tempdir");
let log_path = dir.path().join("bom-rust-errors.txt");
let mut bytes = vec![0xEF, 0xBB, 0xBF];
bytes.extend(fs::read(fixture("diag/rust-errors.txt")).expect("fixture"));
fs::write(&log_path, bytes).expect("log fixture");
let mut command = cargo_command();
command
.current_dir(workspace_root())
.arg(&log_path)
.assert()
.success()
.stdout(predicate::str::contains("severity=error code=E0425"))
.stdout(predicate::str::contains("sample.rs line=25 column=11"));
}
#[test]
fn invalid_utf8_log_file_reports_read_error() {
let dir = tempdir().expect("tempdir");
let log_path = dir.path().join("invalid-utf8.log");
fs::write(&log_path, [0x66, 0x6F, 0x80, 0x6F]).expect("log fixture");
let mut command = cargo_command();
command
.current_dir(workspace_root())
.arg(&log_path)
.assert()
.failure()
.stderr(predicate::str::contains("failed to read"))
.stderr(predicate::str::contains("invalid-utf8.log"));
}
#[test]
fn help_includes_diagnostic_examples() {
let mut command = cargo_command();
command
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("--with-source"))
.stdout(predicate::str::contains("ConvertFrom-Json"))
.stdout(predicate::str::contains(
"diagpick .\\fixtures\\diag\\rust-errors.txt",
));
}
#[test]
fn emits_tool_fallback_for_actionable_cargo_errors() {
let temp = tempdir().expect("tempdir");
let log = temp.path().join("cargo.log");
fs::write(
&log,
"error: package ID specification `missing-crate` did not match any packages\n",
)
.expect("fixture");
let mut command = cargo_command();
command
.arg("--json")
.arg(&log)
.assert()
.success()
.stdout(predicate::str::contains("\"tool_hint\":\"cargo\""))
.stdout(predicate::str::contains("\"path\":\"<cargo>\""));
}
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "dotnetshape"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "Inspect .NET project graphs, package references, and MSBuild configuration shape."
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[dependencies]
common = { path = "../common", default-features = false }
ignore.workspace = true
lexopt.workspace = true
quick-xml.workspace = true
serde.workspace = true
[dev-dependencies]
assert_cmd.workspace = true
predicates.workspace = true
serde_json.workspace = true
tempfile.workspace = true
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
//! Binary entry point for `dotnetshape`.
fn main() {
std::process::exit(dotnetshape::main_entry());
}
+386
View File
@@ -0,0 +1,386 @@
//! Integration tests for the `dotnetshape` command.
use std::fs;
use std::path::Path;
use assert_cmd::Command;
use predicates::prelude::*;
use serde_json::Value;
use tempfile::TempDir;
fn cargo_command() -> Command {
Command::cargo_bin("dotnetshape").expect("binary")
}
fn write_file(path: &Path, contents: &str) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("parent directory");
}
fs::write(path, contents).expect("write fixture");
}
fn json_report(root: &Path) -> Value {
let output = Command::cargo_bin("dotnetshape")
.expect("binary")
.arg("--json")
.arg(root)
.output()
.expect("run dotnetshape");
assert!(
output.status.success(),
"dotnetshape failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
serde_json::from_slice(&output.stdout).expect("json output")
}
fn fixture_repo() -> TempDir {
let temp = tempfile::tempdir().expect("tempdir");
write_file(
&temp.path().join("Directory.Build.props"),
r"<Project>
<PropertyGroup>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AnalysisMode>AllEnabledByDefault</AnalysisMode>
<BaseOutputPath>artifacts/bin/</BaseOutputPath>
<AssemblyName>InheritedName</AssemblyName>
</PropertyGroup>
</Project>",
);
write_file(
&temp.path().join("Directory.Build.targets"),
r"<Project>
<PropertyGroup>
<OutputType>Exe</OutputType>
</PropertyGroup>
</Project>",
);
write_file(
&temp.path().join("Directory.Packages.props"),
r#"<Project>
<ItemGroup>
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
<PackageVersion Update="xunit" Version="2.9.2" />
</ItemGroup>
</Project>"#,
);
write_file(
&temp.path().join("repo.sln"),
"Microsoft Visual Studio Solution File\n",
);
write_file(
&temp.path().join("src").join("App").join("App.csproj"),
r#"<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<AssemblyName>SharedAssembly</AssemblyName>
<OutputType>Library</OutputType>
<OutputPath>artifacts/shared/</OutputPath>
<Nullable Condition="'$(Configuration)' == 'Release'">disable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../Lib/Lib.csproj" />
<ProjectReference Include="../Missing/Missing.csproj" />
<Reference Include="Legacy">
<HintPath>..\lib\Legacy.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Implicit">
<HintPath>..\lib\Implicit.dll</HintPath>
</Reference>
<PackageReference Include="Newtonsoft.Json" />
<PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="Missing.Version" />
</ItemGroup>
<Target Name="ManualCompile">
<Csc Sources="Program.cs" />
</Target>
</Project>"#,
);
write_file(
&temp.path().join("src").join("Lib").join("Lib.csproj"),
r#"<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>netstandard2.0;net8.0</TargetFrameworks>
<AssemblyName>SharedAssembly</AssemblyName>
<OutputPath>artifacts/shared/</OutputPath>
</PropertyGroup>
<ItemGroup>
<Reference Include="PrivateLegacy">
<HintPath>..\lib\PrivateLegacy.dll</HintPath>
<Private>true</Private>
</Reference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageReference Include="xunit" />
</ItemGroup>
</Project>"#,
);
write_file(
&temp
.path()
.join("tests")
.join("Unit.Tests")
.join("Unit.Tests.csproj"),
r#"<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
</Project>"#,
);
write_file(
&temp.path().join("build.ps1"),
"Write-Host build\ncsc.exe Program.cs\ndotnet build src/App/App.csproj\n",
);
temp
}
#[test]
fn help_lists_shared_and_repo_options() {
let mut command = cargo_command();
command
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("--format <FORMAT>"))
.stdout(predicate::str::contains("--json"))
.stdout(predicate::str::contains("--toon"))
.stdout(predicate::str::contains("--max-depth <COUNT>"))
.stdout(predicate::str::contains("--hidden"))
.stdout(predicate::str::contains("dotnetshape . --json"));
}
#[test]
#[allow(clippy::too_many_lines)]
fn json_reports_static_project_shape_and_diagnostics() {
let temp = fixture_repo();
let report = json_report(temp.path());
assert_eq!(report["evaluation_mode"], "static_ancestor_merge");
let projects = report["projects"].as_array().expect("projects");
assert_eq!(projects.len(), 3);
let app = projects
.iter()
.find(|project| project["path"] == "src/App/App.csproj")
.expect("app project");
assert_eq!(app["sdk"], "Microsoft.NET.Sdk");
assert_eq!(app["target_frameworks"], serde_json::json!(["net8.0"]));
assert_eq!(app["assembly_name"], "SharedAssembly");
assert_eq!(app["output_type"], "Library");
assert_eq!(app["nullable"], "enable");
assert_eq!(app["treat_warnings_as_errors"], "true");
assert_eq!(app["analysis_mode"], "AllEnabledByDefault");
assert_eq!(app["kind"], "production");
assert!(
app["conditioned_properties"]
.as_array()
.expect("conditioned properties")
.iter()
.any(|property| property["name"] == "Nullable"
&& property["value"] == "disable"
&& property["condition"]
.as_str()
.is_some_and(|condition| condition.contains("Release")))
);
let references = report["project_references"]
.as_array()
.expect("project refs");
assert!(
references
.iter()
.any(|edge| edge["from"] == "src/App/App.csproj"
&& edge["to"] == "src/Lib/Lib.csproj"
&& edge["resolved"] == true)
);
assert!(
references
.iter()
.any(|edge| edge["from"] == "src/App/App.csproj"
&& edge["to"] == "src/Missing/Missing.csproj"
&& edge["resolved"] == false)
);
let hint_refs = report["reference_hints"].as_array().expect("hint refs");
assert!(hint_refs.iter().any(|edge| edge["include"] == "Legacy"
&& edge["hint_path"] == "../lib/Legacy.dll"
&& edge["private"] == false));
assert!(
hint_refs
.iter()
.any(|edge| edge["include"] == "Implicit" && edge["private"].is_null())
);
assert!(
hint_refs
.iter()
.any(|edge| edge["include"] == "PrivateLegacy" && edge["private"] == true)
);
let packages = report["package_references"].as_array().expect("packages");
assert!(
packages
.iter()
.any(|package| package["include"] == "Newtonsoft.Json"
&& package["version"] == "13.0.3"
&& package["version_source"] == "central")
);
assert!(packages.iter().any(|package| package["include"] == "Dapper"
&& package["version"] == "2.1.66"
&& package["version_source"] == "inline"));
assert!(
packages
.iter()
.any(|package| package["include"] == "Missing.Version"
&& package["version"].is_null()
&& package["version_source"] == "missing")
);
assert!(packages.iter().any(|package| package["include"] == "xunit"
&& package["version"] == "2.9.2"
&& package["version_source"] == "central"));
assert!(projects.iter().any(|project| {
project["path"] == "src/Lib/Lib.csproj"
&& project["kind"] == "test"
&& project["kind_reasons"]
.as_array()
.expect("kind reasons")
.iter()
.any(|reason| {
reason
.as_str()
.is_some_and(|value| value.contains("Microsoft.NET.Test.Sdk"))
})
}));
assert!(projects.iter().any(|project| {
project["path"] == "tests/Unit.Tests/Unit.Tests.csproj"
&& project["kind"] == "test"
&& project["kind_reasons"]
.as_array()
.expect("kind reasons")
.iter()
.any(|reason| {
reason
.as_str()
.is_some_and(|value| value.contains("IsTestProject"))
})
}));
let diagnostics = report["diagnostics"].as_array().expect("diagnostics");
assert!(
diagnostics
.iter()
.any(|diagnostic| diagnostic["kind"] == "duplicate_assembly_name")
);
assert!(
diagnostics
.iter()
.any(|diagnostic| diagnostic["kind"] == "shared_output_path")
);
assert!(
diagnostics
.iter()
.any(|diagnostic| diagnostic["kind"] == "unresolved_project_reference")
);
assert!(
diagnostics
.iter()
.any(|diagnostic| diagnostic["kind"] == "missing_package_version")
);
let bypass_hints = report["build_bypass_hints"]
.as_array()
.expect("bypass hints");
assert!(
bypass_hints
.iter()
.any(|hint| hint["kind"] == "csc_task" && hint["path"] == "src/App/App.csproj")
);
assert!(
bypass_hints.iter().any(|hint| hint["kind"] == "csc_exe"
&& hint["path"] == "build.ps1"
&& hint["line"] == 2)
);
assert!(
bypass_hints
.iter()
.any(|hint| hint["kind"] == "direct_project_build"
&& hint["path"] == "build.ps1"
&& hint["line"] == 3)
);
}
#[test]
fn max_depth_and_hidden_control_project_discovery() {
let temp = tempfile::tempdir().expect("tempdir");
write_file(
&temp.path().join("Root.csproj"),
r"<Project><PropertyGroup><TargetFramework>net8.0</TargetFramework></PropertyGroup></Project>",
);
write_file(
&temp.path().join("deep").join("Nested.csproj"),
r"<Project><PropertyGroup><TargetFramework>net8.0</TargetFramework></PropertyGroup></Project>",
);
write_file(
&temp.path().join(".hidden").join("Hidden.csproj"),
r"<Project><PropertyGroup><TargetFramework>net8.0</TargetFramework></PropertyGroup></Project>",
);
let shallow = json_report_with_args(temp.path(), &["--max-depth", "1"]);
assert_eq!(shallow["projects"].as_array().expect("projects").len(), 1);
assert!(
shallow["projects"]
.as_array()
.expect("projects")
.iter()
.all(|project| project["path"] != "deep/Nested.csproj"
&& project["path"] != ".hidden/Hidden.csproj")
);
let hidden = json_report_with_args(temp.path(), &["--hidden"]);
assert!(
hidden["projects"]
.as_array()
.expect("projects")
.iter()
.any(|project| project["path"] == ".hidden/Hidden.csproj")
);
}
#[test]
fn text_and_toon_outputs_are_structured() {
let temp = fixture_repo();
let mut text = cargo_command();
text.arg(temp.path())
.assert()
.success()
.stdout(predicate::str::contains("dotnetshape"))
.stdout(predicate::str::contains("projects:"))
.stdout(predicate::str::contains("diagnostics:"));
let mut toon = cargo_command();
toon.arg("--toon")
.arg(temp.path())
.assert()
.success()
.stdout(predicate::str::contains("evaluation_mode"))
.stdout(predicate::str::contains("projects"));
}
fn json_report_with_args(root: &Path, args: &[&str]) -> Value {
let mut command = cargo_command();
command.arg("--json");
for arg in args {
command.arg(arg);
}
let output = command.arg(root).output().expect("run dotnetshape");
assert!(
output.status.success(),
"dotnetshape failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
serde_json::from_slice(&output.stdout).expect("json output")
}
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "envdiff"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "Capture and compare environment variable state for AI-friendly debugging."
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[dependencies]
common = { path = "../common", default-features = false }
serde.workspace = true
serde_json.workspace = true
windowsupport = { path = "../windowsupport" }
[dev-dependencies]
assert_cmd.workspace = true
predicates.workspace = true
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
//! Binary entry point for `envdiff`.
#![allow(clippy::multiple_crate_versions)]
fn main() {
std::process::exit(envdiff::main_entry());
}
+80
View File
@@ -0,0 +1,80 @@
//! Integration tests for the `envdiff` command.
use std::fs;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use assert_cmd::Command;
use predicates::prelude::*;
fn cargo_command() -> Command {
Command::cargo_bin("envdiff").expect("binary")
}
struct TempTestDir {
path: PathBuf,
}
impl TempTestDir {
fn path(&self) -> &std::path::Path {
&self.path
}
}
impl Drop for TempTestDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
#[test]
fn runs_cmd_and_reports_environment_diff_as_json() {
let temp = unique_temp_dir();
let script = temp.path().join("mutate.cmd");
fs::write(
&script,
"@echo off\r\nset TEST_FLAG=enabled\r\nset PATH=%PATH%;C:\\Mercury\\Bin\r\n",
)
.expect("script");
let mut command = cargo_command();
command
.arg("run")
.arg("--json")
.arg("--shell")
.arg("cmd")
.arg("--")
.arg(&script)
.assert()
.success()
.stdout(predicate::str::contains("\"added\""))
.stdout(predicate::str::contains("\"name\":\"TEST_FLAG\""))
.stdout(predicate::str::contains("\"path_like_changes\""));
}
#[test]
fn help_includes_envdiff_examples() {
let mut command = cargo_command();
command
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("snapshot"))
.stdout(predicate::str::contains("run --shell cmd"))
.stdout(predicate::str::contains("ConvertFrom-Json"));
}
fn unique_temp_dir() -> TempTestDir {
static NEXT_ID: AtomicU64 = AtomicU64::new(0);
loop {
let unique = NEXT_ID.fetch_add(1, Ordering::Relaxed);
let path =
std::env::temp_dir().join(format!("envdiff-test-{}-{unique}", std::process::id()));
match fs::create_dir(&path) {
Ok(()) => return TempTestDir { path },
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(error) => panic!("tempdir: {error}"),
}
}
}
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "fileprobe"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "Probe file type and usefulness heuristics for AI-friendly workflows."
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[dependencies]
common = { path = "../common", default-features = false }
humantime.workspace = true
lexopt.workspace = true
serde.workspace = true
[dev-dependencies]
assert_cmd.workspace = true
predicates.workspace = true
serde_json.workspace = true
tempfile.workspace = true
+921
View File
@@ -0,0 +1,921 @@
//! The `fileprobe` command classifies files with lightweight heuristics.
use std::ffi::OsString;
use std::fmt::Write as _;
use std::fs;
use std::io::{self, Read};
use std::path::{Component, 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,
should_read_stdin,
};
use lexopt::prelude::{Long, Short, Value as ArgValue};
use serde::Serialize;
const HELP: &str = "\
Probe file type and usefulness heuristics for AI-friendly workflows.
Usage:
fileprobe [OPTIONS] [PATH...]
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
-h, --help Show this help text
-V, --version Show the command version
Examples:
fileprobe .\\src\\main.rs
fileprobe .\\dist\\bundle.min.js --json | ConvertFrom-Json
fileprobe .\\samples\\*.json --toon
fd -t f . .\\src | fileprobe --input-format lines --json | ConvertFrom-Json
fd -t f . .\\samples | fileprobe --input-format lines --toon
";
/// CLI arguments for the `fileprobe` binary.
#[derive(Debug, Clone)]
struct Cli {
/// Shared output and stdin policy flags.
common: CommonArgs,
/// Explicit files to inspect when stdin is empty.
paths: Vec<PathBuf>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ParseOutcome {
Help,
Version,
Run,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
enum FileFamily {
Directory,
Source,
Config,
Data,
Text,
Binary,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct TextStats {
line_count: usize,
blank_lines: usize,
longest_line: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct LineEndingCounts {
lf: usize,
crlf: usize,
cr: usize,
}
#[allow(
clippy::struct_excessive_bools,
reason = "the JSON contract intentionally exposes fixed heuristic toggles as stable booleans"
)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct FileReport {
path: String,
extension: Option<String>,
size_bytes: u64,
modified_rfc3339: String,
family: FileFamily,
is_directory: bool,
language_hint: Option<String>,
container_hint: Option<String>,
is_binary: bool,
encoding_hint: Option<String>,
bom: Option<String>,
newline_style: Option<String>,
mixed_newlines: Option<bool>,
line_ending_counts: Option<LineEndingCounts>,
line_count: Option<usize>,
blank_lines: Option<usize>,
longest_line: Option<usize>,
likely_generated: bool,
likely_minified: bool,
likely_lockfile: bool,
likely_test: bool,
likely_vendor: bool,
}
/// 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, _)) => {
print!("{HELP}");
ExitCode::Success.as_i32()
}
Ok((ParseOutcome::Version, _)) => {
println!("fileprobe {}", 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 cli = Cli {
common: CommonArgs::default(),
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((ParseOutcome::Help, 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") => {
let value = parser_value_string(&mut parser, "--input-format")?;
cli.common.input_format = parse_input_format(&value)?;
}
Long("color") => {
let value = parser_value_string(&mut parser, "--color")?;
cli.common.color = parse_color_choice(&value)?;
}
Long("quiet") => cli.common.quiet = true,
ArgValue(path) => cli.paths.push(PathBuf::from(path)),
_ => {
return Err(CliError::usage(
"unsupported argument; use --help to see available options",
));
}
}
}
Ok((ParseOutcome::Run, cli))
}
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 path or pipe paths into stdin",
));
}
let mut reports = Vec::new();
for path in &paths {
reports.push(inspect_path(path)?);
}
match cli.common.render_mode() {
RenderMode::Json => print_json(&reports)?,
RenderMode::Toon => print_structured(&reports, RenderMode::Toon)?,
RenderMode::Text => {
for report in &reports {
println!("{}", render_text_report(report));
}
}
}
Ok(map_result_count(reports.len()))
}
fn collect_paths(cli: &Cli) -> Result<Vec<PathBuf>, CliError> {
if should_read_stdin(!cli.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}")))?;
let paths = parse_paths_from_string(&buffer, cli.common.input_format)?;
if !paths.is_empty() {
return Ok(paths);
}
}
common::expand_input_patterns(&cli.paths, "fileprobe")
}
fn parse_paths_from_string(
buffer: &str,
input_format: InputFormat,
) -> Result<Vec<PathBuf>, CliError> {
common::read_existing_stdin_path_records(buffer, input_format, "fileprobe")?
.map_or_else(|| Ok(Vec::new()), Ok)
}
fn inspect_path(path: &Path) -> Result<FileReport, CliError> {
let path_text = path.display().to_string();
let metadata = fs::metadata(path).map_err(|error| {
CliError::runtime(format!("failed to read metadata for {path_text}: {error}"))
})?;
let modified = metadata.modified().map_err(|error| {
CliError::runtime(format!(
"failed to read modified time for {path_text}: {error}"
))
})?;
if metadata.is_dir() {
return Ok(FileReport {
path: path_text,
extension: None,
size_bytes: 0,
modified_rfc3339: humantime::format_rfc3339_seconds(modified).to_string(),
family: FileFamily::Directory,
is_directory: true,
language_hint: None,
container_hint: Some("directory".to_string()),
is_binary: false,
encoding_hint: None,
bom: None,
newline_style: None,
mixed_newlines: None,
line_ending_counts: None,
line_count: None,
blank_lines: None,
longest_line: None,
likely_generated: false,
likely_minified: false,
likely_lockfile: false,
likely_test: detect_test_path(path),
likely_vendor: detect_vendor_path(path),
});
}
let bytes = fs::read(path)
.map_err(|error| CliError::runtime(format!("failed to read {path_text}: {error}")))?;
let extension = extension_label(path);
let container_hint = detect_container_hint(&bytes);
let bom = detect_bom(&bytes);
let is_binary = is_binary_blob(&bytes, container_hint.as_deref(), bom.as_deref());
let language_hint = detect_language_hint(path);
let family = classify_family(is_binary, language_hint.as_deref());
let decoded_text = (!is_binary)
.then(|| decode_text(&bytes, bom.as_deref()))
.flatten();
let text_stats = decoded_text.as_deref().map(summarize_text);
let encoding_hint = decoded_text
.as_ref()
.and_then(|_| detect_encoding_hint(&bytes, bom.as_deref()));
let line_ending_counts = decoded_text.as_deref().map(count_line_endings);
let newline_style = line_ending_counts.as_ref().and_then(detect_newline_style);
let mixed_newlines = line_ending_counts
.as_ref()
.map(|counts| distinct_line_endings(counts) > 1);
let likely_lockfile = detect_lockfile(path);
let likely_generated = detect_generated(path, &bytes, likely_lockfile);
let likely_minified = text_stats
.as_ref()
.zip(decoded_text.as_deref())
.is_some_and(|(stats, text)| detect_minified(text, stats));
Ok(FileReport {
path: path_text,
extension,
size_bytes: metadata.len(),
modified_rfc3339: humantime::format_rfc3339_seconds(modified).to_string(),
family,
is_directory: false,
language_hint,
container_hint,
is_binary,
encoding_hint,
bom,
newline_style,
mixed_newlines,
line_ending_counts,
line_count: text_stats.as_ref().map(|stats| stats.line_count),
blank_lines: text_stats.as_ref().map(|stats| stats.blank_lines),
longest_line: text_stats.as_ref().map(|stats| stats.longest_line),
likely_generated,
likely_minified,
likely_lockfile,
likely_test: detect_test_path(path),
likely_vendor: detect_vendor_path(path),
})
}
fn extension_label(path: &Path) -> Option<String> {
path.extension()
.and_then(|value| value.to_str())
.map(str::to_ascii_lowercase)
}
fn detect_container_hint(bytes: &[u8]) -> Option<String> {
magic_container_hint(bytes).map(str::to_owned)
}
fn magic_container_hint(bytes: &[u8]) -> Option<&'static str> {
if bytes.starts_with(b"MZ") || bytes.windows(2).take(8).any(|window| window == b"MZ") {
Some("pe")
} else if bytes.starts_with(&[0x7f, b'E', b'L', b'F']) {
Some("elf")
} else if bytes.starts_with(&[0xfe, 0xed, 0xfa, 0xce])
|| bytes.starts_with(&[0xfe, 0xed, 0xfa, 0xcf])
|| bytes.starts_with(&[0xce, 0xfa, 0xed, 0xfe])
|| bytes.starts_with(&[0xcf, 0xfa, 0xed, 0xfe])
|| bytes.starts_with(&[0xca, 0xfe, 0xba, 0xbe])
{
Some("mach")
} else if bytes.starts_with(b"!<arch>\n") {
Some("archive")
} else if bytes.starts_with(b"PK\x03\x04") {
Some("zip")
} else if bytes.starts_with(b"SQLite format 3\0") {
Some("sqlite")
} else if bytes.starts_with(b"%PDF-") {
Some("pdf")
} else {
None
}
}
fn is_binary_blob(bytes: &[u8], container_hint: Option<&str>, bom: Option<&str>) -> bool {
if container_hint.is_some() {
return true;
}
if bom.is_some() {
return false;
}
if bytes.contains(&0) {
return true;
}
std::str::from_utf8(bytes).is_err()
}
fn detect_bom(bytes: &[u8]) -> Option<String> {
if bytes.starts_with(&[0xef, 0xbb, 0xbf]) {
Some("utf-8".to_owned())
} else if bytes.starts_with(&[0xff, 0xfe]) {
Some("utf-16le".to_owned())
} else if bytes.starts_with(&[0xfe, 0xff]) {
Some("utf-16be".to_owned())
} else {
None
}
}
fn detect_encoding_hint(bytes: &[u8], bom: Option<&str>) -> Option<String> {
if let Some(bom) = bom {
return Some(bom.to_string());
}
std::str::from_utf8(bytes).ok().map(|_| "utf-8".to_string())
}
fn detect_language_hint(path: &Path) -> Option<String> {
match path.extension().and_then(|value| value.to_str()) {
Some("rs") => Some("rust".to_string()),
Some("cs") => Some("csharp".to_string()),
Some("js" | "mjs" | "cjs") => Some("javascript".to_string()),
Some("ts" | "tsx") => Some("typescript".to_string()),
Some("json") => Some("json".to_string()),
Some("jsonl") => Some("jsonl".to_string()),
Some("csv") => Some("csv".to_string()),
Some("tsv") => Some("tsv".to_string()),
Some("toml") => Some("toml".to_string()),
Some("yaml" | "yml") => Some("yaml".to_string()),
Some("ps1") => Some("powershell".to_string()),
Some("py") => Some("python".to_string()),
Some("md") => Some("markdown".to_string()),
Some("xml") => Some("xml".to_string()),
Some("html" | "htm") => Some("html".to_string()),
Some("css") => Some("css".to_string()),
Some("lock") => Some("lockfile".to_string()),
_ => None,
}
}
fn classify_family(is_binary: bool, language_hint: Option<&str>) -> FileFamily {
if is_binary {
return FileFamily::Binary;
}
match language_hint {
Some(
"rust" | "csharp" | "javascript" | "typescript" | "powershell" | "python" | "xml"
| "html" | "css",
) => FileFamily::Source,
Some("json" | "toml" | "yaml" | "lockfile") => FileFamily::Config,
Some("jsonl" | "csv" | "tsv") => FileFamily::Data,
Some(_) | None => FileFamily::Text,
}
}
fn decode_text(bytes: &[u8], bom: Option<&str>) -> Option<String> {
match bom {
Some("utf-8") => Some(String::from_utf8_lossy(&bytes[3..]).to_string()),
Some("utf-16le") => Some(decode_utf16(&bytes[2..], true)),
Some("utf-16be") => Some(decode_utf16(&bytes[2..], false)),
Some(_) => None,
None => Some(String::from_utf8_lossy(bytes).to_string()),
}
}
fn decode_utf16(bytes: &[u8], little_endian: bool) -> String {
let units = bytes
.chunks_exact(2)
.map(|chunk| {
if little_endian {
u16::from_le_bytes([chunk[0], chunk[1]])
} else {
u16::from_be_bytes([chunk[0], chunk[1]])
}
})
.collect::<Vec<_>>();
String::from_utf16_lossy(&units)
}
fn summarize_text(text: &str) -> TextStats {
let mut line_count = 0_usize;
let mut blank_lines = 0_usize;
let mut longest_line = 0_usize;
for line in text.lines() {
line_count += 1;
if line.trim().is_empty() {
blank_lines += 1;
}
longest_line = longest_line.max(line.len());
}
TextStats {
line_count,
blank_lines,
longest_line,
}
}
fn detect_generated(path: &Path, bytes: &[u8], likely_lockfile: bool) -> bool {
if likely_lockfile {
return true;
}
let file_name = file_name_lower(path);
if is_known_generated_artifact(path, &file_name) {
return true;
}
if file_name.contains(".designer.") || file_name.contains(".generated.") {
return true;
}
contains_generated_marker(&bytes[..bytes.len().min(512)])
}
fn is_known_generated_artifact(path: &Path, file_name: &str) -> bool {
if matches!(
file_name,
"project.assets.json"
| "project.nuget.cache"
| ".netcoreapp,version=v1.0.assemblyattributes.cs"
| ".netframework,version=v4.8.assemblyattributes.cs"
) {
return true;
}
let in_obj_dir = path.components().any(|component| match component {
Component::Normal(value) => value.to_string_lossy().eq_ignore_ascii_case("obj"),
_ => false,
});
in_obj_dir
&& (file_name.ends_with(".assemblyinfo.cs")
|| file_name.ends_with(".assemblyattributes.cs"))
}
fn contains_generated_marker(preview: &[u8]) -> bool {
const MARKERS: [&[u8]; 4] = [
b"@generated",
b"generated by",
b"auto-generated",
b"automatically generated",
];
MARKERS
.iter()
.any(|marker| contains_ascii_case_insensitive(preview, marker))
}
fn contains_ascii_case_insensitive(haystack: &[u8], needle: &[u8]) -> bool {
haystack
.windows(needle.len())
.any(|window| window.eq_ignore_ascii_case(needle))
}
fn count_line_endings(text: &str) -> LineEndingCounts {
let mut counts = LineEndingCounts {
lf: 0,
crlf: 0,
cr: 0,
};
let bytes = text.as_bytes();
let mut index = 0_usize;
while index < bytes.len() {
match bytes[index] {
b'\r' if bytes.get(index + 1) == Some(&b'\n') => {
counts.crlf += 1;
index += 2;
}
b'\r' => {
counts.cr += 1;
index += 1;
}
b'\n' => {
counts.lf += 1;
index += 1;
}
_ => index += 1,
}
}
counts
}
fn detect_newline_style(counts: &LineEndingCounts) -> Option<String> {
if counts.crlf > 0 && counts.lf == 0 && counts.cr == 0 {
Some("crlf".to_owned())
} else if counts.lf > 0 && counts.crlf == 0 && counts.cr == 0 {
Some("lf".to_owned())
} else if counts.cr > 0 && counts.lf == 0 && counts.crlf == 0 {
Some("cr".to_owned())
} else if distinct_line_endings(counts) > 1 {
Some("mixed".to_owned())
} else {
None
}
}
fn distinct_line_endings(counts: &LineEndingCounts) -> usize {
usize::from(counts.lf > 0) + usize::from(counts.crlf > 0) + usize::from(counts.cr > 0)
}
fn detect_minified(text: &str, stats: &TextStats) -> bool {
if stats.line_count == 0 || stats.line_count > 3 || stats.longest_line < 80 {
return false;
}
let mut non_newline_len = 0usize;
let mut whitespace_chars = 0usize;
let mut has_open_brace = false;
let mut has_semicolon = false;
for ch in text.chars() {
if matches!(ch, '\n' | '\r') {
continue;
}
non_newline_len += 1;
if ch.is_whitespace() {
whitespace_chars += 1;
}
has_open_brace |= ch == '{';
has_semicolon |= ch == ';';
}
if non_newline_len == 0 {
return false;
}
whitespace_chars.saturating_mul(100) < non_newline_len.saturating_mul(12)
&& has_open_brace
&& has_semicolon
}
fn detect_lockfile(path: &Path) -> bool {
let file_name = file_name_lower(path);
Path::new(&file_name)
.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case("lock"))
|| matches!(
file_name.as_str(),
"package-lock.json"
| "pnpm-lock.yaml"
| "yarn.lock"
| "bun.lockb"
| "composer.lock"
| "poetry.lock"
| "uv.lock"
)
}
fn detect_test_path(path: &Path) -> bool {
let lower_path = path.display().to_string().to_ascii_lowercase();
lower_path.contains("\\tests\\")
|| lower_path.contains("/tests/")
|| lower_path.contains(".test.")
|| lower_path.contains("_test.")
|| lower_path.contains(".spec.")
|| lower_path.contains("_spec.")
}
fn detect_vendor_path(path: &Path) -> bool {
path.components().any(|component| {
let Component::Normal(segment) = component else {
return false;
};
let lower = segment.to_string_lossy().to_ascii_lowercase();
matches!(
lower.as_str(),
"vendor" | "node_modules" | "third_party" | "packages"
)
})
}
fn file_name_lower(path: &Path) -> String {
path.file_name()
.and_then(|value| value.to_str())
.map(str::to_ascii_lowercase)
.unwrap_or_default()
}
fn render_text_report(report: &FileReport) -> String {
let mut line = format!(
"path={} family={} language={} binary={} size={} mtime={}",
report.path,
family_label(report.family),
report.language_hint.as_deref().unwrap_or("-"),
report.is_binary,
report.size_bytes,
report.modified_rfc3339
);
if let Some(extension) = &report.extension {
write!(line, " ext={extension}").expect("writing to a String cannot fail");
}
write!(line, " directory={}", report.is_directory).expect("writing to a String cannot fail");
if let Some(container_hint) = &report.container_hint {
write!(line, " container={container_hint}").expect("writing to a String cannot fail");
}
if let Some(encoding_hint) = &report.encoding_hint {
write!(line, " encoding={encoding_hint}").expect("writing to a String cannot fail");
}
if let Some(bom) = &report.bom {
write!(line, " bom={bom}").expect("writing to a String cannot fail");
}
if let Some(newline_style) = &report.newline_style {
write!(line, " newline={newline_style}").expect("writing to a String cannot fail");
}
if let Some(mixed_newlines) = report.mixed_newlines {
write!(line, " mixed_newlines={mixed_newlines}").expect("writing to a String cannot fail");
}
if let Some(line_count) = report.line_count {
write!(line, " lines={line_count}").expect("writing to a String cannot fail");
}
if let Some(blank_lines) = report.blank_lines {
write!(line, " blank={blank_lines}").expect("writing to a String cannot fail");
}
if let Some(longest_line) = report.longest_line {
write!(line, " longest={longest_line}").expect("writing to a String cannot fail");
}
write!(
line,
" generated={} minified={} lockfile={} test={} vendor={}",
report.likely_generated,
report.likely_minified,
report.likely_lockfile,
report.likely_test,
report.likely_vendor
)
.expect("writing to a String cannot fail");
line
}
const fn family_label(family: FileFamily) -> &'static str {
match family {
FileFamily::Directory => "directory",
FileFamily::Source => "source",
FileFamily::Config => "config",
FileFamily::Data => "data",
FileFamily::Text => "text",
FileFamily::Binary => "binary",
}
}
#[cfg(test)]
mod tests {
use std::fs;
use common::ColorChoice;
use tempfile::tempdir;
use super::*;
fn common_args(json: bool, input_format: InputFormat) -> CommonArgs {
CommonArgs {
json,
format: None,
input_format,
color: ColorChoice::Never,
quiet: false,
}
}
#[test]
fn parse_paths_supports_line_and_json_inputs() {
let temp = tempdir().expect("tempdir");
let line_path = temp.path().join("a.rs");
let jsonl_one = temp.path().join("b.cs");
let jsonl_two = temp.path().join("c.js");
let auto_path = temp.path().join("d.toml");
fs::write(&line_path, "a").expect("line file");
fs::write(&jsonl_one, "b").expect("jsonl one");
fs::write(&jsonl_two, "c").expect("jsonl two");
fs::write(&auto_path, "d").expect("auto file");
assert_eq!(
parse_paths_from_string(&format!("{}\n", line_path.display()), InputFormat::Lines)
.expect("lines"),
vec![line_path]
);
assert_eq!(
parse_paths_from_string(
&format!(
"{}\n{{\"path\":{}}}\n",
serde_json::to_string(&jsonl_one.display().to_string()).expect("jsonl one"),
serde_json::to_string(&jsonl_two.display().to_string()).expect("jsonl two"),
),
InputFormat::Jsonl,
)
.expect("jsonl"),
vec![jsonl_one, jsonl_two]
);
assert_eq!(
parse_paths_from_string(&format!("{}\n", auto_path.display()), InputFormat::Auto)
.expect("auto"),
vec![auto_path]
);
}
#[test]
fn parse_paths_reports_invalid_jsonl() {
let error =
parse_paths_from_string("nope\n", InputFormat::Jsonl).expect_err("invalid jsonl");
assert!(matches!(
error,
CliError::Usage(message)
if message.contains("stdin JSONL path line 1 is not valid JSON")
));
}
#[test]
fn container_language_and_path_heuristics_are_stable() {
assert_eq!(
detect_container_hint(b"MZ\x00\x01payload"),
Some("pe".to_string())
);
assert_eq!(detect_container_hint(b"%PDF-1.7"), Some("pdf".to_string()));
assert!(is_binary_blob(b"\x00\x01\xff", None, None));
assert!(!is_binary_blob(b"plain text", None, None));
assert_eq!(
detect_language_hint(Path::new("demo.rs")),
Some("rust".to_string())
);
assert_eq!(
detect_language_hint(Path::new("events.jsonl")),
Some("jsonl".to_string())
);
assert_eq!(
classify_family(false, Some("javascript")),
FileFamily::Source
);
assert_eq!(family_label(FileFamily::Directory), "directory");
assert_eq!(classify_family(false, Some("toml")), FileFamily::Config);
assert_eq!(classify_family(false, Some("jsonl")), FileFamily::Data);
assert_eq!(classify_family(true, Some("rust")), FileFamily::Binary);
assert!(detect_lockfile(Path::new("Cargo.lock")));
assert!(detect_test_path(Path::new("C:\\repo\\tests\\probe.rs")));
assert!(detect_vendor_path(Path::new("C:\\repo\\vendor\\lib.rs")));
}
#[test]
fn generated_and_minified_heuristics_use_content_markers() {
let stats = summarize_text(
"function boot(){const state={ready:true,mode:\"fast\"};if(state.ready){console.log(state.mode);}}\n",
);
assert!(detect_minified(
"function boot(){const state={ready:true,mode:\"fast\"};if(state.ready){console.log(state.mode);}}\n",
&stats,
));
assert!(detect_generated(
Path::new("generated.lock"),
b"# This file is automatically @generated by the build system.\n",
true,
));
assert!(detect_generated(
Path::new("demo.rs"),
b"// auto-generated by tool\nfn run() {}\n",
false,
));
assert!(detect_generated(
Path::new("C:\\repo\\obj\\project.assets.json"),
b"{\"version\":3}\n",
false,
));
}
#[test]
fn inspect_path_and_render_text_cover_text_and_binary_reports() {
let temp = tempdir().expect("tempdir");
let source_path = temp.path().join("sample.rs");
let binary_path = temp.path().join("sample.bin");
fs::write(&source_path, b"pub fn run() {}\n").expect("source fixture");
fs::write(&binary_path, b"MZ\x00\x01\xff").expect("binary fixture");
let source_report = inspect_path(&source_path).expect("source report");
assert_eq!(source_report.family, FileFamily::Source);
assert_eq!(source_report.language_hint.as_deref(), Some("rust"));
assert_eq!(source_report.line_count, Some(1));
assert_eq!(source_report.encoding_hint.as_deref(), Some("utf-8"));
assert!(render_text_report(&source_report).contains("family=source"));
let binary_report = inspect_path(&binary_path).expect("binary report");
assert_eq!(binary_report.family, FileFamily::Binary);
assert_eq!(binary_report.container_hint.as_deref(), Some("pe"));
assert!(binary_report.is_binary);
assert!(render_text_report(&binary_report).contains("container=pe"));
}
#[test]
fn inspect_path_reports_directories_without_raw_os_errors() {
let temp = tempdir().expect("tempdir");
let report = inspect_path(temp.path()).expect("directory report");
assert_eq!(report.family, FileFamily::Directory);
assert!(report.is_directory);
assert_eq!(report.container_hint.as_deref(), Some("directory"));
}
#[test]
fn detects_bom_and_mixed_newlines() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("bom.txt");
fs::write(&path, [0xef, 0xbb, 0xbf, b'a', b'\r', b'\n', b'b', b'\n']).expect("fixture");
let report = inspect_path(&path).expect("report");
assert_eq!(report.bom.as_deref(), Some("utf-8"));
assert_eq!(report.newline_style.as_deref(), Some("mixed"));
assert_eq!(report.mixed_newlines, Some(true));
assert_eq!(
report.line_ending_counts,
Some(LineEndingCounts {
lf: 1,
crlf: 1,
cr: 0,
})
);
}
#[test]
fn run_accepts_explicit_paths_for_text_and_json_output() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("sample.cs");
fs::write(&path, b"class Demo {}\n").expect("fixture");
let text_exit = run(&Cli {
common: common_args(false, InputFormat::Auto),
paths: vec![path.clone()],
})
.expect("text run");
assert_eq!(text_exit, ExitCode::Success);
let json_exit = run(&Cli {
common: common_args(true, InputFormat::Lines),
paths: vec![path],
})
.expect("json run");
assert_eq!(json_exit, ExitCode::Success);
}
}
+5
View File
@@ -0,0 +1,5 @@
//! Binary entry point for `fileprobe`.
fn main() {
std::process::exit(fileprobe::main_entry());
}
+163
View File
@@ -0,0 +1,163 @@
//! Integration tests for the `fileprobe` command.
use std::path::PathBuf;
use assert_cmd::Command;
use predicates::prelude::*;
fn cargo_command() -> Command {
Command::cargo_bin("fileprobe").expect("binary")
}
fn fixture(path: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("fixtures")
.join(path)
}
fn pwsh_command(script: impl AsRef<str>) -> Command {
let mut command = Command::new("pwsh");
command
.arg("-NoProfile")
.arg("-Command")
.arg(script.as_ref());
command
}
#[test]
fn probes_source_files_in_text_mode() {
let mut command = cargo_command();
command
.arg(fixture("reading/sample.rs"))
.assert()
.success()
.stdout(predicate::str::contains("family=source"))
.stdout(predicate::str::contains("language=rust"))
.stdout(predicate::str::contains("binary=false"));
}
#[test]
fn probes_binary_files_as_json() {
let mut command = cargo_command();
command
.arg("--json")
.arg(fixture("reading/binary.bin"))
.assert()
.success()
.stdout(predicate::str::contains("\"is_binary\":true"))
.stdout(predicate::str::contains("\"family\":\"binary\""))
.stdout(predicate::str::contains("\"container_hint\":\"pe\""));
}
#[test]
fn flags_generated_minified_and_locklike_files() {
let mut minified = cargo_command();
minified
.arg("--json")
.arg(fixture("reading/minified.js"))
.assert()
.success()
.stdout(predicate::str::contains("\"likely_minified\":true"))
.stdout(predicate::str::contains("\"language_hint\":\"javascript\""));
let mut generated = cargo_command();
generated
.arg("--json")
.arg(fixture("reading/generated.lock"))
.assert()
.success()
.stdout(predicate::str::contains("\"likely_generated\":true"))
.stdout(predicate::str::contains("\"likely_lockfile\":true"));
}
#[test]
fn recognizes_jsonl_as_data_for_pipeline_handoffs() {
let mut command = cargo_command();
command
.arg("--json")
.arg(fixture("jsonl/events.jsonl"))
.assert()
.success()
.stdout(predicate::str::contains("\"family\":\"data\""))
.stdout(predicate::str::contains("\"language_hint\":\"jsonl\""));
}
#[test]
fn supports_powershell_path_pipeline() {
let binary = assert_cmd::cargo::cargo_bin("fileprobe");
let input = fixture("reading/sample.cs");
let script = format!("'{}' | & '{}' --json", input.display(), binary.display());
let mut command = pwsh_command(script);
command
.assert()
.success()
.stdout(predicate::str::contains("\"language_hint\":\"csharp\""))
.stdout(predicate::str::contains("\"family\":\"source\""));
}
#[test]
fn expands_literal_globs_passed_by_powershell() {
let temp = tempfile::tempdir().expect("tempdir");
std::fs::write(temp.path().join("one.json"), "{}\n").expect("one");
std::fs::write(temp.path().join("two.json"), "{}\n").expect("two");
std::fs::write(temp.path().join("skip.txt"), "notes\n").expect("skip");
let mut command = cargo_command();
command
.arg("--json")
.arg(temp.path().join("*.json"))
.assert()
.success()
.stdout(predicate::str::contains("one.json"))
.stdout(predicate::str::contains("two.json"))
.stdout(predicate::str::contains("skip.txt").not());
}
#[test]
fn unmatched_glob_suggests_fd_pipeline_for_powershell() {
let temp = tempfile::tempdir().expect("tempdir");
let mut command = cargo_command();
command
.arg("--json")
.arg(temp.path().join("*.missing"))
.assert()
.failure()
.stderr(predicate::str::contains("glob pattern"))
.stderr(predicate::str::contains("PowerShell"))
.stderr(predicate::str::contains("fd -t f ."))
.stderr(predicate::str::contains("fileprobe --input-format lines"));
}
#[test]
fn reports_directories_as_directories_instead_of_raw_access_errors() {
let mut command = cargo_command();
command
.arg("--json")
.arg(
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("crates"),
)
.assert()
.success()
.stdout(predicate::str::contains("\"family\":\"directory\""))
.stdout(predicate::str::contains("\"is_directory\":true"));
}
#[test]
fn help_includes_probe_examples() {
let mut command = cargo_command();
command
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("fileprobe .\\src\\main.rs"))
.stdout(predicate::str::contains("ConvertFrom-Json"))
.stdout(predicate::str::contains("--toon"))
.stdout(predicate::str::contains("--format <FORMAT>"));
}
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "gitshape"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "Summarize Git repository state into compact branch and change counters."
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[dependencies]
configsupport = { path = "../configsupport" }
lexopt.workspace = true
serde.workspace = true
[dev-dependencies]
assert_cmd.workspace = true
predicates.workspace = true
tempfile.workspace = true
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
//! Binary entry point for `gitshape`.
fn main() {
std::process::exit(gitshape::main_entry());
}
+288
View File
@@ -0,0 +1,288 @@
//! Integration tests for the `gitshape` command.
use std::fs;
use std::process::Command as ProcessCommand;
use assert_cmd::Command;
use predicates::prelude::*;
use tempfile::tempdir;
fn cargo_command() -> Command {
Command::cargo_bin("gitshape").expect("binary")
}
#[test]
fn status_subcommand_summarizes_dirty_repository_as_json() {
let temp = tempdir().expect("tempdir");
run_git(temp.path(), ["-c", "init.defaultBranch=main", "init"]);
run_git(temp.path(), ["config", "user.name", "Codex"]);
run_git(temp.path(), ["config", "user.email", "codex@example.com"]);
fs::write(temp.path().join("tracked.txt"), "first\n").expect("tracked");
run_git(temp.path(), ["add", "tracked.txt"]);
run_git(temp.path(), ["commit", "-m", "init"]);
fs::write(temp.path().join("tracked.txt"), "second\n").expect("modify");
fs::write(temp.path().join("new.txt"), "new\n").expect("untracked");
let mut command = cargo_command();
command
.arg("--json")
.arg("status")
.arg("--repo")
.arg(temp.path())
.assert()
.success()
.stdout(predicate::str::contains("\"modified\":1"))
.stdout(predicate::str::contains("\"untracked\":1"))
.stdout(predicate::str::contains("\"clean\":false"));
}
#[test]
fn diff_subcommand_reports_staged_change_summary() {
let temp = tempdir().expect("tempdir");
run_git(temp.path(), ["init"]);
run_git(temp.path(), ["config", "user.name", "Codex"]);
run_git(temp.path(), ["config", "user.email", "codex@example.com"]);
fs::write(
temp.path().join("src.rs"),
"fn build() {\n println!(\"old\");\n}\n",
)
.expect("src");
run_git(temp.path(), ["add", "src.rs"]);
run_git(temp.path(), ["commit", "-m", "init"]);
fs::write(
temp.path().join("src.rs"),
"fn build() {\n println!(\"new\");\n}\n",
)
.expect("modify");
run_git(temp.path(), ["add", "src.rs"]);
let mut command = cargo_command();
command
.arg("--json")
.arg("diff")
.arg("--repo")
.arg(temp.path())
.arg("--staged")
.assert()
.success()
.stdout(predicate::str::contains("\"mode\":\"staged\""))
.stdout(predicate::str::contains("\"files_changed\":1"))
.stdout(predicate::str::contains("\"status\":\"modified\""));
}
#[test]
fn status_subcommand_handles_unborn_repository() {
let temp = tempdir().expect("tempdir");
run_git(temp.path(), ["-c", "init.defaultBranch=main", "init"]);
let mut command = cargo_command();
command
.arg("--json")
.arg("status")
.arg("--repo")
.arg(temp.path())
.assert()
.success()
.stdout(predicate::str::contains("\"branch\":\"main\""))
.stdout(predicate::str::contains("\"head\":\"-\""))
.stdout(predicate::str::contains("\"clean\":true"));
}
#[test]
fn diff_subcommand_accepts_explicit_revisions() {
let temp = tempdir().expect("tempdir");
run_git(temp.path(), ["init"]);
run_git(temp.path(), ["config", "user.name", "Codex"]);
run_git(temp.path(), ["config", "user.email", "codex@example.com"]);
fs::write(temp.path().join("demo.txt"), "one\n").expect("demo");
run_git(temp.path(), ["add", "demo.txt"]);
run_git(temp.path(), ["commit", "-m", "first"]);
let first = run_git_capture(temp.path(), ["rev-parse", "--short", "HEAD"]);
fs::write(temp.path().join("demo.txt"), "one\ntwo\n").expect("update");
run_git(temp.path(), ["add", "demo.txt"]);
run_git(temp.path(), ["commit", "-m", "second"]);
let second = run_git_capture(temp.path(), ["rev-parse", "--short", "HEAD"]);
let mut command = cargo_command();
command
.arg("--json")
.arg("diff")
.arg("--repo")
.arg(temp.path())
.arg(first)
.arg(second)
.assert()
.success()
.stdout(predicate::str::contains("\"mode\":\"revisions\""))
.stdout(predicate::str::contains("\"revisions\":["))
.stdout(predicate::str::contains("\"files_changed\":1"));
}
#[test]
fn diff_subcommand_returns_success_for_empty_revision_diff() {
let temp = tempdir().expect("tempdir");
run_git(temp.path(), ["init"]);
run_git(temp.path(), ["config", "user.name", "Codex"]);
run_git(temp.path(), ["config", "user.email", "codex@example.com"]);
fs::write(temp.path().join("demo.txt"), "one\n").expect("demo");
run_git(temp.path(), ["add", "demo.txt"]);
run_git(temp.path(), ["commit", "-m", "first"]);
let mut command = cargo_command();
command
.arg("diff")
.arg("--repo")
.arg(temp.path())
.arg("HEAD")
.arg("HEAD")
.assert()
.success()
.stdout(predicate::str::contains("mode=revisions"))
.stdout(predicate::str::contains("files=0"))
.stdout(predicate::str::contains("line_additions=0"))
.stdout(predicate::str::contains("line_deletions=0"));
}
#[test]
fn help_includes_new_subcommands_and_flags() {
let mut command = cargo_command();
command
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("gitshape [OPTIONS] status"))
.stdout(predicate::str::contains("gitshape [OPTIONS] diff"))
.stdout(predicate::str::contains("--repo"))
.stdout(predicate::str::contains("--staged"))
.stdout(predicate::str::contains("--ignored"))
.stdout(predicate::str::contains("gitshape"));
}
#[test]
fn bare_path_defaults_to_status_mode() {
let temp = tempdir().expect("tempdir");
run_git(temp.path(), ["-c", "init.defaultBranch=main", "init"]);
let mut command = cargo_command();
command
.arg("--json")
.arg(temp.path())
.assert()
.success()
.stdout(predicate::str::contains("\"repository_present\":true"))
.stdout(predicate::str::contains("\"branch\":\"main\""));
}
#[test]
fn status_reports_missing_repository_without_failing() {
let temp = tempdir().expect("tempdir");
let mut command = cargo_command();
command
.arg("--json")
.arg("status")
.arg("--repo")
.arg(temp.path())
.assert()
.success()
.stdout(predicate::str::contains("\"repository_present\":false"))
.stdout(predicate::str::contains(
"\"error\":\"fatal: not a git repository",
));
}
#[test]
fn diff_subcommand_rejects_staged_with_explicit_revisions() {
let mut command = cargo_command();
command
.arg("diff")
.arg("--staged")
.arg("HEAD")
.assert()
.failure()
.stderr(predicate::str::contains(
"diff --staged does not accept explicit revisions",
));
}
#[test]
fn status_subcommand_rejects_repo_and_positional_path_together() {
let temp = tempdir().expect("tempdir");
let mut command = cargo_command();
command
.arg("status")
.arg("--repo")
.arg(temp.path())
.arg(temp.path())
.assert()
.failure()
.stderr(predicate::str::contains(
"status accepts either --repo <PATH> or positional [PATH], not both",
));
}
#[test]
fn diff_subcommand_text_output_renders_rename_details() {
let temp = tempdir().expect("tempdir");
run_git(temp.path(), ["init"]);
run_git(temp.path(), ["config", "user.name", "Codex"]);
run_git(temp.path(), ["config", "user.email", "codex@example.com"]);
fs::write(
temp.path().join("old_name.rs"),
"fn rename_me() {\n println!(\"old\");\n}\n",
)
.expect("old file");
run_git(temp.path(), ["add", "old_name.rs"]);
run_git(temp.path(), ["commit", "-m", "before rename"]);
let first = run_git_capture(temp.path(), ["rev-parse", "--short", "HEAD"]);
run_git(temp.path(), ["mv", "old_name.rs", "new_name.rs"]);
fs::write(
temp.path().join("new_name.rs"),
"fn rename_me() {\n println!(\"old\");\n}\nfn helper() {}\n",
)
.expect("new file");
run_git(temp.path(), ["add", "new_name.rs"]);
run_git(temp.path(), ["commit", "-m", "after rename"]);
let second = run_git_capture(temp.path(), ["rev-parse", "--short", "HEAD"]);
let mut command = cargo_command();
command
.arg("diff")
.arg("--repo")
.arg(temp.path())
.arg(first)
.arg(second)
.assert()
.success()
.stdout(predicate::str::contains("mode=revisions"))
.stdout(predicate::str::contains("status=renamed"))
.stdout(predicate::str::contains("path=new_name.rs"))
.stdout(predicate::str::contains("previous=old_name.rs"));
}
fn run_git<const N: usize>(cwd: &std::path::Path, args: [&str; N]) {
let status = ProcessCommand::new("git")
.current_dir(cwd)
.args(args)
.status()
.expect("git command");
assert!(status.success(), "git command failed");
}
fn run_git_capture<const N: usize>(cwd: &std::path::Path, args: [&str; N]) -> String {
let output = ProcessCommand::new("git")
.current_dir(cwd)
.args(args)
.output()
.expect("git command output");
assert!(output.status.success(), "git command failed");
String::from_utf8(output.stdout)
.expect("utf8")
.trim()
.to_string()
}
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "hitsnip"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "Merge search hits into compact, AI-friendly snippets."
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[dependencies]
codeindex = { path = "../codeindex" }
common = { path = "../common", default-features = false }
lexopt.workspace = true
serde.workspace = true
serde_json.workspace = true
[dev-dependencies]
assert_cmd.workspace = true
predicates.workspace = true
tempfile.workspace = true
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
//! Binary entry point for `hitsnip`.
fn main() {
std::process::exit(hitsnip::main_entry());
}

Some files were not shown because too many files have changed in this diff Show More