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