chore(release): prepare public source release

This commit is contained in:
MercuryToolbox Release
2026-07-18 15:33:01 +08:00
commit 34d6a57f38
510 changed files with 163501 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
[build]
target-dir = "target"
rustc-wrapper = "sccache"
[env]
DUCKDB_DOWNLOAD_LIB = "1"
[future-incompat-report]
frequency = "always"
[term]
verbose = false
[net]
git-fetch-with-cli = true
# Keep checked-in rustflags portable. For local-only CPU-specific builds, set
# RUSTFLAGS='-Ctarget-cpu=native' in the shell instead of committing it here.
[target.x86_64-pc-windows-msvc]
rustflags = ["-Dwarnings"]
[target.x86_64-unknown-linux-gnu]
rustflags = ["-Dwarnings"]
[target.aarch64-apple-darwin]
rustflags = ["-Dwarnings"]
+14
View File
@@ -0,0 +1,14 @@
* text=auto eol=lf
*.bin binary
*.db binary
*.dll binary
*.exe binary
*.gif binary
*.ico binary
*.jpg binary
*.jpeg binary
*.pdb binary
*.png binary
*.sqlite binary
*.zip binary
+67
View File
@@ -0,0 +1,67 @@
name: ci
on:
push:
branches:
- main
pull_request:
workflow_dispatch:
permissions: read-all
jobs:
verify:
runs-on: windows
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Commit messages
shell: pwsh
run: |
$event = Get-Content -Raw -LiteralPath $env:GITHUB_EVENT_PATH | ConvertFrom-Json
$range = switch ($env:GITHUB_EVENT_NAME) {
'pull_request' { "$($event.pull_request.base.sha)..$($event.pull_request.head.sha)" }
'push' {
if ($event.before -match '^0+$') {
$event.after
}
elseif (git merge-base --is-ancestor $event.before $event.after) {
"$($event.before)..$($event.after)"
}
else {
"$($event.after)^..$($event.after)"
}
}
default { 'HEAD' }
}
pwsh -NoProfile -File .\scripts\check-conventional-commits.ps1 -Range $range
- name: Toolchain snapshot
shell: pwsh
run: |
cargo --version
rustc --version
cargo nextest --version
cargo llvm-cov --version
just --version
git --version
pwsh --version
- name: Quick repo gate
shell: pwsh
run: just workspace-check
- name: Tests
shell: pwsh
run: cargo nextest run --all-features
- name: Clippy
shell: pwsh
run: cargo clippy --all-targets --all-features -- -D warnings -W clippy::pedantic -W clippy::nursery
- name: Packaging ecosystem
shell: pwsh
run: just ecosystem-fast
+61
View File
@@ -0,0 +1,61 @@
name: release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
tag:
description: Git tag to publish, for example v0.1.0
required: true
type: string
permissions: write-all
jobs:
publish:
runs-on: windows
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Resolve release tag
shell: pwsh
run: |
$tag = if ($env:GITHUB_REF_TYPE -eq 'tag' -and -not [string]::IsNullOrWhiteSpace($env:GITHUB_REF_NAME)) {
$env:GITHUB_REF_NAME
} else {
'${{ inputs.tag }}'
}
if ([string]::IsNullOrWhiteSpace($tag)) {
throw 'Release tag is required.'
}
$notesPath = Join-Path 'docs/releases' "$tag.md"
if (-not (Test-Path -LiteralPath $notesPath)) {
throw "Release notes not found: $notesPath"
}
"RELEASE_TAG=$tag" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
"RELEASE_NOTES_PATH=$notesPath" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
- name: Verify tag matches workspace version
shell: pwsh
run: pwsh -NoProfile -File .\scripts\check-release-version.ps1 -Tag $env:RELEASE_TAG
- name: Full release gate
shell: pwsh
run: just jade
- name: Build release package
shell: pwsh
run: just package
- name: Publish Gitea release
shell: pwsh
run: |
if ([string]::IsNullOrWhiteSpace($env:GITEA_TOKEN)) {
throw 'GITEA_TOKEN is not available in the release job environment.'
}
pwsh -NoProfile -File .\scripts\publish-gitea-release.ps1 -Tag $env:RELEASE_TAG -NotesPath $env:RELEASE_NOTES_PATH -ApiToken $env:GITEA_TOKEN
+19
View File
@@ -0,0 +1,19 @@
.worktrees/
.serena/
target/
.cargo-workspace/
.bacon-locations
# Generated build, packaging, profiling, and smoke-test artifacts.
dist/
tmp/
coverage/
flamegraph.svg
perf.data*
fixtures/managed/*/obj/
fixtures/managed/diagnose-src/**/bin/
fixtures/managed/diagnose-src/**/obj/
# Local reference clones used during privileged-launch research.
# Keep vendor/tree-sitter tracked: Cargo.toml patches tree-sitter to that path.
vendor/windows-privilege-refs/
+16
View File
@@ -0,0 +1,16 @@
# Changelog
All notable changes to this project are documented in this file.
## [Unreleased]
### Maintenance
- **license:** Reserve all rights
- **governance:** Add release standards
## [v0.1.0] - 2026-06-04
- First hosted release.
+49
View File
@@ -0,0 +1,49 @@
# Contributing
Mercury Toolbox is currently proprietary and distributed under the terms in
[LICENSE](LICENSE). Do not submit third-party code or assets unless their
licensing terms permit this repository's intended use.
## Commits
Use Conventional Commits for every non-merge commit:
```text
type(scope): imperative summary
```
Scopes are optional. Supported types are `build`, `chore`, `ci`, `docs`,
`feat`, `fix`, `perf`, `refactor`, `revert`, `style`, and `test`. Use `!`
before the colon for a breaking change, for example:
```text
feat(sqlshape)!: replace legacy output fields
fix(ci): preserve DuckDB runtime in portable packages
```
Check the current commit locally with:
```powershell
just commit-check
```
## Versions and Releases
Versions follow [Semantic Versioning 2.0.0](https://semver.org/):
- `MAJOR` for incompatible command-line or output-contract changes.
- `MINOR` for backward-compatible commands, options, and capabilities.
- `PATCH` for backward-compatible fixes and maintenance.
Set `[workspace.package].version` in `Cargo.toml`, create an annotated `vX.Y.Z`
tag with the exact same version, and add release notes under
`docs/releases/vX.Y.Z.md`. Validate a release candidate with:
```powershell
just release-version-check vX.Y.Z
just changelog
```
`git-cliff` generates `CHANGELOG.md` from Conventional Commits. The existing
`v0.1.0` release is retained as the baseline; new entries are generated from
the commit history after that release.
Generated
+5763
View File
File diff suppressed because it is too large Load Diff
+248
View File
@@ -0,0 +1,248 @@
[workspace]
members = [
"crates/common",
"crates/codeindex",
"crates/configsupport",
"crates/managed",
"crates/runtimekit",
"crates/unitysupport",
"crates/windowsupport",
"crates/msudo",
"crates/cjson",
"crates/ison",
"crates/isonl",
"crates/jsonlgrep",
"crates/jsonshape",
"crates/mhash",
"crates/recent",
"crates/pathshadow",
"crates/portping",
"crates/binmeta",
"crates/llvmtools",
"crates/petools",
"crates/snip",
"crates/outline",
"crates/fileprobe",
"crates/chunkcat",
"crates/hitsnip",
"crates/diagpick",
"crates/logshape",
"crates/stringscan",
"crates/toon",
"crates/tonl",
"crates/zon",
"crates/asmtype",
"crates/asmmember",
"crates/asmref",
"crates/asmapi",
"crates/csvshape",
"crates/sqliteshape",
"crates/sqlshape",
"crates/dotnetshape",
"crates/envdiff",
"crates/proctree",
"crates/unlock",
"crates/defsnip",
"crates/codeshape",
"crates/refs",
"crates/context",
"crates/config",
"crates/gitshape",
"crates/reposhape",
"crates/runprobe",
"crates/await",
"crates/argv",
"crates/portunlock",
"crates/sysshape",
"crates/asmflow",
"crates/unityasset",
"crates/unityprobe",
"crates/unitydiag",
]
exclude = ["fuzz"]
resolver = "2"
[workspace.package]
authors = ["MercuryToolbox contributors"]
edition = "2024"
version = "0.1.0"
rust-version = "1.86"
license = "LicenseRef-ARR"
license-file = "LICENSE"
readme = "README.md"
publish = false
description = "Mercury Toolbox crates for PowerShell-first, AI-friendly terminal workflows."
keywords = ["cli", "powershell", "toolbox", "mercury"]
categories = ["command-line-utilities"]
[workspace.dependencies]
anyhow = "1.0.100"
assert_cmd = "2.1.1"
criterion = "0.7.0"
clrmeta = "0.1.0"
filetime = "0.2.26"
goblin = { version = "0.10.5", default-features = false, features = ["std", "pe32", "pe64"] }
humantime = "2.1.0"
ignore = "0.4.23"
insta = "1.44.1"
lexopt = "0.3.2"
loom = "0.7.2"
miette = "7.6.0"
native-tls = "0.2.14"
duckdb = { version = "1.10504.0", default-features = false }
mysql = "28.0.0"
postgres = "0.19.13"
postgres-native-tls = "0.5.3"
predicates = "3.1.3"
proptest = "1.9.0"
quick-xml = "0.38.4"
regex-lite = "0.1.9"
rstest = "0.26.1"
rusqlite = { version = "0.37.0", features = ["bundled"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145"
sha2 = { version = "0.10.9", default-features = false }
blake2s_simd = "1.0.3"
blake3 = { version = "1.8.2", features = ["rayon"] }
crc = "3.4.0"
crc32fast = "1.5.0"
digest-traits = { package = "digest", version = "0.10.7" }
md-5 = "0.10.6"
md4 = "0.10.2"
memmap2 = "0.9.9"
rayon = "1.11.0"
ripemd = "0.1.3"
sha1 = "0.10.6"
sha3 = "0.10.8"
streebog = "0.10.2"
tiny-keccak = { version = "2.0.2", features = ["k12", "parallel_hash"] }
xxhash-rust = { version = "0.8.15", default-features = false, features = ["std", "xxh32", "xxh64", "xxh3"] }
sysinfo = { version = "0.37.2", default-features = false, features = ["system"] }
tempfile = "3.20.0"
thiserror = "2.0.17"
tokio = { version = "1.52.3", features = ["net", "rt-multi-thread", "time"] }
tokio-util = { version = "0.7.18", features = ["compat"] }
tracing = "0.1.41"
tracing-subscriber = { version = "0.3.20", features = ["env-filter", "fmt"] }
csv = "1.4.0"
netstat2 = "0.11.2"
walkdir = "2.5.0"
tree-sitter = { version = "0.25.10", default-features = false }
tree-sitter-c-sharp = "0.23.5"
tree-sitter-go = "0.25.0"
tree-sitter-javascript = "0.25.0"
tree-sitter-java = "0.23.5"
tree-sitter-powershell = "0.26.3"
tree-sitter-python = "0.25.0"
tree-sitter-rust = "0.24.2"
tree-sitter-typescript = "0.23.2"
unity-asset = "0.2.0"
unity-asset-binary = "0.2.0"
url = "2.5.8"
windows-sys = { version = "0.61.2", default-features = false }
tiberius = { version = "0.12.3", default-features = false, features = ["native-tls", "tds73", "tokio", "tokio-util"] }
[patch.crates-io]
tree-sitter = { path = "vendor/tree-sitter" }
[workspace.lints.rust]
unsafe_code = "deny"
dead_code = "deny"
unreachable_pub = "deny"
unused_imports = "deny"
unused_variables = "deny"
unused_mut = "deny"
unused_must_use = "deny"
missing_docs = "deny"
missing_debug_implementations = "deny"
[workspace.lints.clippy]
all = { level = "deny", priority = -4 }
pedantic = { level = "deny", priority = -3 }
nursery = { level = "deny", priority = -2 }
cargo = { level = "deny", priority = -1 }
cargo_common_metadata = "deny"
dbg_macro = "deny"
missing_errors_doc = "deny"
missing_panics_doc = "deny"
todo = "deny"
unwrap_used = "deny"
[profile.dev]
debug = 2
incremental = true
opt-level = 1
codegen-units = 256
[profile.release]
lto = "thin"
codegen-units = 1
strip = "symbols"
panic = "abort"
debug = 1
[profile.release-fast]
inherits = "release"
lto = "fat"
opt-level = 3
codegen-units = 1
panic = "abort"
strip = "symbols"
debug = 0
[profile.release-size]
inherits = "release"
lto = "fat"
opt-level = "z"
codegen-units = 1
panic = "abort"
strip = "symbols"
debug = 0
[profile.release-fast.package.aho-corasick]
opt-level = "s"
[profile.release-fast.package.codeindex]
opt-level = "s"
[profile.release-fast.package.globset]
opt-level = "s"
[profile.release-fast.package.ignore]
opt-level = "s"
[profile.release-fast.package.regex-automata]
opt-level = "s"
[profile.release-fast.package.regex-syntax]
opt-level = "s"
[profile.release-fast.package.tree-sitter]
opt-level = "s"
[profile.release-fast.package.tree-sitter-c-sharp]
opt-level = "s"
[profile.release-fast.package.tree-sitter-go]
opt-level = "s"
[profile.release-fast.package.tree-sitter-javascript]
opt-level = "s"
[profile.release-fast.package.tree-sitter-java]
opt-level = "s"
[profile.release-fast.package.tree-sitter-powershell]
opt-level = "s"
[profile.release-fast.package.tree-sitter-python]
opt-level = "s"
[profile.release-fast.package.tree-sitter-rust]
opt-level = "s"
[profile.release-fast.package.tree-sitter-typescript]
opt-level = "s"
[profile.release-fast.package.walkdir]
opt-level = "s"
+8
View File
@@ -0,0 +1,8 @@
Copyright (c) 2026 Crockan
All rights reserved.
This repository, including its source code, documentation, build scripts,
configuration, and generated assets, is proprietary. No permission is granted
to use, copy, modify, merge, publish, distribute, sublicense, or sell any part
of this repository without prior written permission from the copyright holder.
+6
View File
@@ -0,0 +1,6 @@
@{
IncludeRules = @(
'PSAvoidAssignmentToAutomaticVariable',
'PSAvoidUsingInvokeExpression'
)
}
+1028
View File
File diff suppressed because it is too large Load Diff
+210
View File
@@ -0,0 +1,210 @@
default_job = "check"
# Bacon already watches the Rust source tree and Cargo manifest; explicit
# watches here only cover lockfiles, fixtures, docs, scripts, skills, and
# workspace config that are outside the default scan.
[jobs.check]
command = ["just", "check"]
need_stdout = false
watch = ["Cargo.lock", ".cargo", "justfile", "bacon.toml"]
[jobs.clippy]
command = ["just", "clippy"]
need_stdout = false
watch = ["Cargo.lock", ".cargo", "justfile", "bacon.toml"]
[jobs.core]
command = ["just", "core-check"]
need_stdout = false
watch = ["Cargo.lock", ".cargo", "justfile", "bacon.toml"]
[jobs.core-clippy]
command = ["just", "core-clippy"]
need_stdout = true
watch = ["Cargo.lock", ".cargo", "justfile", "bacon.toml"]
[jobs.core-test]
command = ["just", "core-test"]
need_stdout = true
watch = ["Cargo.lock", ".cargo", "justfile", "bacon.toml"]
[jobs.code]
command = ["just", "code-check"]
need_stdout = false
watch = ["fixtures", "Cargo.lock", ".cargo", "justfile", "bacon.toml", "README.md"]
[jobs.code-clippy]
command = ["just", "code-clippy"]
need_stdout = true
watch = ["fixtures", "Cargo.lock", ".cargo", "justfile", "bacon.toml", "README.md"]
[jobs.code-test]
command = ["just", "code-test"]
need_stdout = true
watch = ["fixtures", "Cargo.lock", ".cargo", "justfile", "bacon.toml", "README.md"]
[jobs.data]
command = ["just", "data-check"]
need_stdout = false
watch = ["fixtures", "Cargo.lock", ".cargo", "justfile", "bacon.toml", "README.md"]
[jobs.data-clippy]
command = ["just", "data-clippy"]
need_stdout = true
watch = ["fixtures", "Cargo.lock", ".cargo", "justfile", "bacon.toml", "README.md"]
[jobs.data-test]
command = ["just", "data-test"]
need_stdout = true
watch = ["fixtures", "Cargo.lock", ".cargo", "justfile", "bacon.toml", "README.md"]
[jobs.mhash]
command = ["just", "mhash-check"]
need_stdout = false
watch = ["Cargo.lock", ".cargo", "justfile", "bacon.toml", "README.md"]
[jobs.mhash-clippy]
command = ["just", "mhash-clippy"]
need_stdout = true
watch = ["Cargo.lock", ".cargo", "justfile", "bacon.toml", "README.md"]
[jobs.mhash-test]
command = ["just", "mhash-test"]
need_stdout = true
watch = ["fixtures", "Cargo.lock", ".cargo", "justfile", "bacon.toml", "README.md"]
[jobs.runtime]
command = ["just", "runtime-check"]
need_stdout = false
watch = ["fixtures", "Cargo.lock", ".cargo", "justfile", "bacon.toml", "README.md"]
[jobs.runtime-clippy]
command = ["just", "runtime-clippy"]
need_stdout = true
watch = ["fixtures", "Cargo.lock", ".cargo", "justfile", "bacon.toml", "README.md"]
[jobs.runtime-test]
command = ["just", "runtime-test"]
need_stdout = true
watch = ["fixtures", "Cargo.lock", ".cargo", "justfile", "bacon.toml", "README.md"]
[jobs.windows]
command = ["just", "windows-check"]
need_stdout = false
watch = ["Cargo.lock", ".cargo", "justfile", "bacon.toml"]
[jobs.windows-clippy]
command = ["just", "windows-clippy"]
need_stdout = true
watch = ["Cargo.lock", ".cargo", "justfile", "bacon.toml"]
[jobs.windows-test]
command = ["just", "windows-test"]
need_stdout = true
watch = ["fixtures", "Cargo.lock", ".cargo", "justfile", "bacon.toml"]
[jobs.managed]
command = ["just", "managed-check"]
need_stdout = false
watch = ["fixtures/managed", "Cargo.lock", ".cargo", "justfile", "bacon.toml", "README.md"]
[jobs.managed-clippy]
command = ["just", "managed-clippy"]
need_stdout = true
watch = ["fixtures/managed", "Cargo.lock", ".cargo", "justfile", "bacon.toml", "README.md"]
[jobs.managed-test]
command = ["just", "managed-test"]
need_stdout = true
watch = ["fixtures/managed", "Cargo.lock", ".cargo", "justfile", "bacon.toml", "README.md"]
[jobs.unity]
command = ["just", "unity-check"]
need_stdout = false
watch = ["fixtures", "Cargo.lock", ".cargo", "justfile", "bacon.toml", "README.md"]
[jobs.unity-clippy]
command = ["just", "unity-clippy"]
need_stdout = true
watch = ["fixtures", "Cargo.lock", ".cargo", "justfile", "bacon.toml", "README.md"]
[jobs.unity-test]
command = ["just", "unity-test"]
need_stdout = true
watch = ["fixtures", "Cargo.lock", ".cargo", "justfile", "bacon.toml", "README.md"]
[jobs.msudo-check]
command = ["just", "msudo-check"]
need_stdout = false
watch = ["Cargo.lock", ".cargo", "justfile", "bacon.toml"]
[jobs.msudo-clippy]
command = ["just", "msudo-clippy"]
need_stdout = false
watch = ["Cargo.lock", ".cargo", "justfile", "bacon.toml"]
[jobs.msudo-test]
command = ["just", "msudo-test"]
need_stdout = true
watch = ["fixtures", "Cargo.lock", ".cargo", "justfile", "bacon.toml"]
[jobs.msudo-status]
command = ["just", "msudo-status"]
need_stdout = true
watch = ["Cargo.lock", ".cargo", "justfile", "bacon.toml"]
[jobs.msudo-verify]
command = ["just", "msudo-verify"]
need_stdout = true
watch = ["fixtures", "docs/ai", "skills/mercury-toolbox", "scripts", "Cargo.lock", ".cargo", "justfile", "bacon.toml", "README.md"]
[jobs.test]
command = ["just", "test"]
need_stdout = true
watch = ["fixtures", "Cargo.lock", ".cargo", "justfile", "bacon.toml"]
[jobs.ai]
command = ["just", "ai-check"]
need_stdout = true
watch = ["Cargo.lock", ".cargo", "docs/ai", "skills/mercury-toolbox", "scripts/toolbox-commands.ps1", "scripts/generate-ai-prompt.ps1", "scripts/check-ai-prompt.ps1", "scripts/generate-ai-skill.ps1", "scripts/check-ai-skill.ps1", "justfile", "bacon.toml", "README.md"]
[jobs.prompt]
command = ["just", "prompt-check"]
need_stdout = true
watch = ["Cargo.lock", ".cargo", "docs/ai", "scripts/toolbox-commands.ps1", "scripts/generate-ai-prompt.ps1", "scripts/check-ai-prompt.ps1", "justfile", "bacon.toml", "README.md"]
[jobs.skill]
command = ["just", "skill-check"]
need_stdout = true
watch = ["Cargo.lock", ".cargo", "skills/mercury-toolbox", "docs/ai", "scripts/toolbox-commands.ps1", "scripts/generate-ai-skill.ps1", "scripts/check-ai-skill.ps1", "justfile", "bacon.toml", "README.md"]
[jobs.powershell]
command = ["just", "powershell"]
need_stdout = true
watch = ["scripts", "PSScriptAnalyzerSettings.psd1", "justfile", "bacon.toml"]
[jobs.workspace]
command = ["just", "workspace-check"]
need_stdout = true
watch = ["scripts", "docs", "skills", "Cargo.lock", ".cargo", "PSScriptAnalyzerSettings.psd1", "justfile", "bacon.toml", "README.md"]
[jobs.ecosystem]
command = ["just", "ecosystem"]
need_stdout = true
watch = ["fixtures", "scripts", "docs", "skills", "Cargo.lock", ".cargo", "PSScriptAnalyzerSettings.psd1", "justfile", "bacon.toml", "README.md"]
[jobs.ecosystem-fast]
command = ["just", "ecosystem-fast"]
need_stdout = true
watch = ["fixtures", "scripts", "docs", "skills", "Cargo.lock", ".cargo", "PSScriptAnalyzerSettings.psd1", "justfile", "bacon.toml", "README.md"]
[jobs.jade]
command = ["just", "jade"]
need_stdout = true
watch = ["scripts", "docs", "skills", "Cargo.lock", ".cargo", "justfile", "bacon.toml", "README.md"]
[jobs.jade-skip-coverage]
command = ["just", "jade-skip-coverage"]
need_stdout = true
watch = ["scripts", "docs", "skills", "Cargo.lock", ".cargo", "justfile", "bacon.toml", "README.md"]
+44
View File
@@ -0,0 +1,44 @@
[changelog]
header = """
# Changelog
All notable changes to this project are documented in this file.
"""
body = """
{% if version %}
## [{{ version }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}
## [Unreleased]
{% endif %}
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group }}
{% for commit in commits %}
- {% if commit.scope %}**{{ commit.scope }}:** {% endif %}{{ commit.message | upper_first }}
{% endfor %}
{% endfor %}
"""
trim = true
footer = """
## [v0.1.0] - 2026-06-04
- First hosted release.
"""
[git]
conventional_commits = true
filter_unconventional = true
tag_pattern = "v[0-9]*"
skip_tags = "v0\\.1\\.0"
sort_commits = "oldest"
commit_parsers = [
{ message = "^chore\\(release\\)", skip = true },
{ message = "^feat", group = "Features" },
{ message = "^fix", group = "Fixes" },
{ message = "^perf", group = "Performance" },
{ message = "^refactor", group = "Refactoring" },
{ message = "^docs", group = "Documentation" },
{ message = "^test", group = "Tests" },
{ message = "^build", group = "Build" },
{ message = "^ci", group = "Continuous Integration" },
{ message = "^chore", group = "Maintenance" }
]
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "argv"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "Inspect argv and optional stdin line payloads for shell quoting/debug workflows."
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[dependencies]
common = { path = "../common", default-features = false }
lexopt.workspace = true
runtimekit = { path = "../runtimekit" }
serde.workspace = true
serde_json.workspace = true
[dev-dependencies]
assert_cmd.workspace = true
predicates.workspace = true
+922
View File
@@ -0,0 +1,922 @@
//! The `argv` command renders shell-safe argument text and verifies real argv round-trips.
use std::ffi::OsString;
use std::fs::{self, OpenOptions};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
use common::{
CliError, CommonArgs, ExitCode, RenderMode, parse_color_choice, parse_format_choice,
print_json, print_quick_help_error, print_structured, print_text,
};
use lexopt::prelude::{Long, Short, Value as ArgValue};
use runtimekit::{ShellMode, collect_command_values, parse_shell_mode, parser_value_string};
use serde::{Deserialize, Serialize};
const HELP: &str = "\
Quote argument vectors for Windows shells and inspect what a shell really passed to a native process.
Usage:
argv quote --shell <pwsh|cmd|raw> [VALUE...]
argv inspect [--json] --shell <pwsh|cmd|raw> [VALUE...]
Options:
--format <FORMAT> Structured output format: text, json, toon
--json Shortcut for --format json
--toon Shortcut for --format toon
--color <WHEN> Control ANSI color output: auto, never
--quiet Suppress non-essential status output
-h, --help Show this help text
-V, --version Show the command version
Notes:
Windows-first: quote and inspect focus on native invocation behavior for pwsh, cmd, and raw process launches.
cmd inspect is Windows-only.
quote accepts positional args or a JSON array of strings from stdin when no positional args are provided.
raw bypasses any shell and shows the direct native-process argv path.
Examples:
argv quote --shell pwsh -- git commit -m \"two words\"
'[\"tool.exe\",\"two words\"]' | argv quote --shell cmd
argv inspect --json --shell pwsh -- alpha \"two words\"
";
const QUOTE_HELP: &str = "\
Render a shell-safe invocation string for pwsh, cmd, or raw native argv.
Usage:
argv quote --shell <pwsh|cmd|raw> [VALUE...]
Options:
--format <FORMAT> Structured output format: text, json, toon
--json Shortcut for --format json
--toon Shortcut for --format toon
--color <WHEN> Control ANSI color output: auto, never
--quiet Suppress non-essential status output
--shell <SHELL> Target shell: pwsh, cmd, raw
-h, --help Show this help text
Examples:
argv quote --shell pwsh -- git commit -m \"two words\"
'[\"tool.exe\",\"two words\"]' | argv quote --shell cmd
";
const INSPECT_HELP: &str = "\
Launch a native helper and show what argv actually arrived after shell parsing.
Usage:
argv inspect [--json] --shell <pwsh|cmd|raw> [VALUE...]
Options:
--format <FORMAT> Structured output format: text, json, toon
--json Shortcut for --format json
--toon Shortcut for --format toon
--color <WHEN> Control ANSI color output: auto, never
--quiet Suppress non-essential status output
--shell <SHELL> Target shell: pwsh, cmd, raw
-h, --help Show this help text
Examples:
argv inspect --shell pwsh -- alpha \"two words\"
argv inspect --json --shell cmd -- alpha \"--filter=x|y\"
";
const HELPER_SUBCOMMAND: &str = "__native_helper";
#[derive(Debug, Clone)]
struct Cli {
common: CommonArgs,
command: CommandMode,
}
#[derive(Debug, Clone)]
enum CommandMode {
Help(&'static str),
Quote {
shell: ShellMode,
values: Vec<OsString>,
},
Inspect {
shell: ShellMode,
values: Vec<OsString>,
},
NativeHelper {
values: Vec<String>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ParseOutcome {
Help,
Version,
Run,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct QuoteReport {
shell: String,
text: String,
argv: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct InspectReport {
shell: String,
invocation: String,
probe_invocation: Option<String>,
count: usize,
argv: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct HelperReport {
count: usize,
argv: Vec<String>,
}
/// Parses CLI arguments and returns a process exit code.
#[must_use]
pub fn main_entry() -> i32 {
match parse_cli_from(std::env::args_os()) {
Ok((ParseOutcome::Help, _)) => {
print!("{HELP}");
ExitCode::Success.as_i32()
}
Ok((ParseOutcome::Version, _)) => {
println!("argv {}", env!("CARGO_PKG_VERSION"));
ExitCode::Success.as_i32()
}
Ok((ParseOutcome::Run, cli)) => match run(&cli) {
Ok(code) => code.as_i32(),
Err(error) => {
print_quick_help_error(&error, HELP);
error.exit_code().as_i32()
}
},
Err(error) => {
print_quick_help_error(&error, HELP);
error.exit_code().as_i32()
}
}
}
fn parse_cli_from<I, T>(args: I) -> Result<(ParseOutcome, Cli), CliError>
where
I: IntoIterator<Item = T>,
T: Into<OsString>,
{
let mut parser = lexopt::Parser::from_iter(args);
let mut cli = Cli {
common: CommonArgs::default(),
command: CommandMode::Quote {
shell: ShellMode::Raw,
values: Vec::new(),
},
};
while let Some(argument) = parser
.next()
.map_err(|error| CliError::usage(error.to_string()))?
{
match argument {
Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)),
Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)),
Long("json") => cli.common.set_render_mode(RenderMode::Json),
Long("toon") => cli.common.set_render_mode(RenderMode::Toon),
Long("format") => {
let value = parser_value_string(&mut parser, "--format")?;
cli.common.set_render_mode(parse_format_choice(&value)?);
}
Long("quiet") => cli.common.quiet = true,
Long("color") => {
cli.common.color =
parse_color_choice(&parser_value_string(&mut parser, "--color")?)?;
}
ArgValue(value) => {
let token = runtimekit::os_to_utf8(value, "subcommand")?;
cli.command = match token.as_str() {
"quote" => parse_quote_command(&mut parser, &mut cli.common)?,
"inspect" => parse_inspect_command(&mut parser, &mut cli.common)?,
HELPER_SUBCOMMAND => parse_native_helper_command(&mut parser)?,
_ => {
return Err(CliError::usage(
"unknown subcommand; expected quote or inspect",
));
}
};
return Ok((ParseOutcome::Run, cli));
}
_ => {
return Err(CliError::usage(
"unsupported argument; use --help to see available options",
));
}
}
}
Err(CliError::usage("provide a subcommand: quote or inspect"))
}
fn parse_quote_command(
parser: &mut lexopt::Parser,
common: &mut CommonArgs,
) -> Result<CommandMode, CliError> {
let mut shell = None::<ShellMode>;
let mut values = Vec::new();
while let Some(argument) = parser
.next()
.map_err(|error| CliError::usage(error.to_string()))?
{
match argument {
Long("help") | Short('h') => return Ok(CommandMode::Help(QUOTE_HELP)),
Long("json") => common.set_render_mode(RenderMode::Json),
Long("toon") => common.set_render_mode(RenderMode::Toon),
Long("format") => {
let value = parser_value_string(parser, "--format")?;
common.set_render_mode(parse_format_choice(&value)?);
}
Long("quiet") => common.quiet = true,
Long("color") => {
common.color = parse_color_choice(&parser_value_string(parser, "--color")?)?;
}
Long("shell") => {
shell = Some(parse_shell_mode(
"--shell",
&parser_value_string(parser, "--shell")?,
)?);
}
ArgValue(value) => {
values = collect_command_values(parser, value)?;
break;
}
_ => {
return Err(CliError::usage(
"unsupported quote argument; use --help to see available options",
));
}
}
}
Ok(CommandMode::Quote {
shell: shell.ok_or_else(|| CliError::usage("quote requires --shell <pwsh|cmd|raw>"))?,
values,
})
}
fn parse_inspect_command(
parser: &mut lexopt::Parser,
common: &mut CommonArgs,
) -> Result<CommandMode, CliError> {
let mut shell = None::<ShellMode>;
let mut values = Vec::new();
while let Some(argument) = parser
.next()
.map_err(|error| CliError::usage(error.to_string()))?
{
match argument {
Long("help") | Short('h') => return Ok(CommandMode::Help(INSPECT_HELP)),
Long("json") => common.set_render_mode(RenderMode::Json),
Long("toon") => common.set_render_mode(RenderMode::Toon),
Long("format") => {
let value = parser_value_string(parser, "--format")?;
common.set_render_mode(parse_format_choice(&value)?);
}
Long("quiet") => common.quiet = true,
Long("color") => {
common.color = parse_color_choice(&parser_value_string(parser, "--color")?)?;
}
Long("shell") => {
shell = Some(parse_shell_mode(
"--shell",
&parser_value_string(parser, "--shell")?,
)?);
}
ArgValue(value) => {
values = collect_command_values(parser, value)?;
break;
}
_ => {
return Err(CliError::usage(
"unsupported inspect argument; use --help to see available options",
));
}
}
}
Ok(CommandMode::Inspect {
shell: shell.ok_or_else(|| CliError::usage("inspect requires --shell <pwsh|cmd|raw>"))?,
values,
})
}
fn parse_native_helper_command(parser: &mut lexopt::Parser) -> Result<CommandMode, CliError> {
let mut values = Vec::new();
while let Some(argument) = parser
.next()
.map_err(|error| CliError::usage(error.to_string()))?
{
match argument {
ArgValue(value) => values.push(runtimekit::os_to_utf8(value, "helper arg")?),
_ => {
return Err(CliError::usage(
"internal helper expects only positional values",
));
}
}
}
Ok(CommandMode::NativeHelper { values })
}
fn run(cli: &Cli) -> Result<ExitCode, CliError> {
match &cli.command {
CommandMode::Help(text) => {
print_text(text)?;
Ok(ExitCode::Success)
}
CommandMode::Quote { shell, values } => run_quote(cli, *shell, values),
CommandMode::Inspect { shell, values } => run_inspect(cli, *shell, values),
CommandMode::NativeHelper { values } => run_native_helper(values),
}
}
fn run_quote(cli: &Cli, shell: ShellMode, values: &[OsString]) -> Result<ExitCode, CliError> {
let argv = resolve_values(cli, values)?;
let report = QuoteReport {
shell: shell_label(shell).to_string(),
text: render_shell_invocation(shell, &argv),
argv,
};
match cli.common.render_mode() {
RenderMode::Json => print_json(&report)?,
RenderMode::Toon => print_structured(&report, RenderMode::Toon)?,
RenderMode::Text => print_text(&report.text)?,
}
Ok(ExitCode::Success)
}
fn run_inspect(cli: &Cli, shell: ShellMode, values: &[OsString]) -> Result<ExitCode, CliError> {
let argv = resolve_values(cli, values)?;
let inspection = inspect_arguments(shell, &argv)?;
let report = InspectReport {
shell: shell_label(shell).to_string(),
invocation: inspection.invocation,
probe_invocation: Some(inspection.probe_invocation),
count: inspection.helper.argv.len(),
argv: inspection.helper.argv,
};
match cli.common.render_mode() {
RenderMode::Json => print_json(&report)?,
RenderMode::Toon => print_structured(&report, RenderMode::Toon)?,
RenderMode::Text => print_text(render_inspect_text(&report))?,
}
Ok(ExitCode::Success)
}
fn run_native_helper(values: &[String]) -> Result<ExitCode, CliError> {
let report = HelperReport {
count: values.len(),
argv: values.to_vec(),
};
print_json(&report)?;
Ok(ExitCode::Success)
}
fn resolve_values(cli: &Cli, values: &[OsString]) -> Result<Vec<String>, CliError> {
if !values.is_empty() {
return values
.iter()
.cloned()
.map(|value| runtimekit::os_to_utf8(value, "argv value"))
.collect();
}
if cli.common.stdin_is_terminal() {
return Err(CliError::usage(
"provide positional values or pipe a JSON array of strings into stdin",
));
}
let mut buffer = String::new();
std::io::stdin()
.read_to_string(&mut buffer)
.map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?;
let trimmed = buffer.trim();
if trimmed.is_empty() {
return Err(CliError::usage(
"stdin was empty; expected a JSON array of strings",
));
}
serde_json::from_str::<Vec<String>>(trimmed).map_err(|error| {
CliError::usage(format!(
"expected a JSON array of strings on stdin, got parse error: {error}"
))
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct InspectionResult {
invocation: String,
probe_invocation: String,
helper: HelperReport,
}
fn inspect_arguments(shell: ShellMode, values: &[String]) -> Result<InspectionResult, CliError> {
let current_exe = std::env::current_exe().map_err(|error| {
CliError::runtime(format!("failed to resolve current executable: {error}"))
})?;
let helper_exe = current_exe.display().to_string();
let mut helper_argv = vec![helper_exe, HELPER_SUBCOMMAND.to_string(), "--".to_string()];
helper_argv.extend(values.iter().cloned());
let invocation = render_shell_invocation(shell, values);
let probe_invocation = render_shell_invocation(shell, &helper_argv);
let output = match shell {
ShellMode::Raw => Command::new(&current_exe)
.arg(HELPER_SUBCOMMAND)
.arg("--")
.args(values)
.output()
.map_err(|error| CliError::runtime(format!("failed to launch raw helper: {error}")))?,
ShellMode::Pwsh => Command::new("pwsh")
.arg("-NoProfile")
.arg("-Command")
.arg(&probe_invocation)
.output()
.map_err(|error| CliError::runtime(format!("failed to launch pwsh helper: {error}")))?,
ShellMode::Cmd => {
if !cfg!(windows) {
return Err(CliError::usage("cmd inspect is Windows-only"));
}
let wrapper = unique_cmd_wrapper_path();
let wrapper_body = format!(
"@echo off\r\nsetlocal DisableDelayedExpansion\r\n{}\r\n",
probe_invocation.replace('%', "%%")
);
write_cmd_wrapper_file(&wrapper, &wrapper_body)?;
let output = Command::new("cmd")
.arg("/d")
.arg("/v:off")
.arg("/c")
.arg(&wrapper)
.output()
.map_err(|error| {
CliError::runtime(format!("failed to launch cmd helper: {error}"))
});
let _ = fs::remove_file(&wrapper);
output?
}
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(CliError::runtime(format!(
"native helper failed with status {}: {}",
output.status,
stderr.trim()
)));
}
let helper = serde_json::from_slice::<HelperReport>(&output.stdout)
.map_err(|error| CliError::runtime(format!("failed to parse helper output: {error}")))?;
Ok(InspectionResult {
invocation,
probe_invocation,
helper,
})
}
fn render_inspect_text(report: &InspectReport) -> String {
let mut lines = vec![format!(
"shell={} count={} invocation={}",
report.shell, report.count, report.invocation
)];
lines.extend(
report
.argv
.iter()
.enumerate()
.map(|(index, value)| format!("argv[{index}]={}", quote_text(value))),
);
lines.join("\n")
}
fn render_shell_invocation(shell: ShellMode, values: &[String]) -> String {
match shell {
ShellMode::Pwsh => {
let mut parts = vec!["&".to_string()];
parts.extend(values.iter().map(|value| quote_pwsh(value)));
parts.join(" ")
}
ShellMode::Cmd => values
.iter()
.map(|value| quote_cmd(value))
.collect::<Vec<_>>()
.join(" "),
ShellMode::Raw => values
.iter()
.map(|value| quote_raw(value))
.collect::<Vec<_>>()
.join(" "),
}
}
fn quote_pwsh(value: &str) -> String {
format!("'{}'", value.replace('\'', "''"))
}
fn quote_cmd(value: &str) -> String {
if value.is_empty()
|| value
.chars()
.any(|ch| ch.is_whitespace() || "\"^&|<>()%!".contains(ch))
{
quote_windows_arg_inner(value, true)
} else {
value.to_string()
}
}
fn quote_raw(value: &str) -> String {
quote_windows_arg(value)
}
fn quote_windows_arg(value: &str) -> String {
quote_windows_arg_inner(value, false)
}
fn quote_windows_arg_inner(value: &str, force_quote: bool) -> String {
if force_quote || value.is_empty() || value.chars().any(|ch| ch.is_whitespace() || ch == '"') {
let mut rendered = String::from("\"");
let mut backslashes = 0_usize;
for ch in value.chars() {
match ch {
'\\' => backslashes += 1,
'"' => {
rendered.push_str(&"\\".repeat(backslashes * 2 + 1));
rendered.push('"');
backslashes = 0;
}
_ => {
rendered.push_str(&"\\".repeat(backslashes));
backslashes = 0;
rendered.push(ch);
}
}
}
rendered.push_str(&"\\".repeat(backslashes * 2));
rendered.push('"');
rendered
} else {
value.to_string()
}
}
fn quote_text(value: &str) -> String {
if value.is_empty() || value.chars().any(char::is_whitespace) {
format!("\"{}\"", value.replace('"', "\\\""))
} else {
value.to_string()
}
}
const fn shell_label(shell: ShellMode) -> &'static str {
match shell {
ShellMode::Raw => "raw",
ShellMode::Pwsh => "pwsh",
ShellMode::Cmd => "cmd",
}
}
fn unique_cmd_wrapper_path() -> PathBuf {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |value| value.as_nanos());
std::env::temp_dir().join(format!(
"mercury-argv-helper-{}-{unique}.cmd",
std::process::id()
))
}
fn write_cmd_wrapper_file(path: &Path, body: &str) -> Result<(), CliError> {
let mut file = OpenOptions::new()
.create_new(true)
.write(true)
.open(path)
.map_err(|error| {
CliError::runtime(format!(
"refusing to replace existing cmd inspect wrapper {}: {error}",
path.display()
))
})?;
file.write_all(body.as_bytes()).map_err(|error| {
CliError::runtime(format!("failed to write {}: {error}", path.display()))
})?;
file.flush()
.map_err(|error| CliError::runtime(format!("failed to flush {}: {error}", path.display())))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parser_accepts_quote_and_inspect_commands() {
let (_, quote_cli) = parse_cli_from([
"argv",
"quote",
"--shell",
"pwsh",
"--",
"tool",
"two words",
])
.expect("quote cli");
assert!(matches!(
quote_cli.command,
CommandMode::Quote {
shell: ShellMode::Pwsh,
..
}
));
let (_, inspect_cli) =
parse_cli_from(["argv", "--json", "inspect", "--shell", "raw", "--", "alpha"])
.expect("inspect cli");
assert!(inspect_cli.common.json);
assert!(matches!(
inspect_cli.command,
CommandMode::Inspect {
shell: ShellMode::Raw,
..
}
));
let (_, inspect_help) =
parse_cli_from(["argv", "inspect", "--help"]).expect("inspect help");
assert!(matches!(
inspect_help.command,
CommandMode::Help(text) if text.contains("argv inspect")
));
}
#[test]
fn parser_covers_global_and_subcommand_common_flags() {
let (_, quote_cli) = parse_cli_from([
"argv", "--quiet", "--color", "never", "quote", "--json", "--quiet", "--color",
"never", "--shell", "cmd", "--", "tool.exe",
])
.expect("quote cli");
assert!(quote_cli.common.json);
assert!(quote_cli.common.quiet);
assert_eq!(quote_cli.common.color, common::ColorChoice::Never);
assert!(matches!(
quote_cli.command,
CommandMode::Quote {
shell: ShellMode::Cmd,
..
}
));
let (_, inspect_cli) = parse_cli_from([
"argv", "inspect", "--quiet", "--color", "never", "--shell", "pwsh", "--", "alpha",
])
.expect("inspect cli");
assert!(inspect_cli.common.quiet);
assert_eq!(inspect_cli.common.color, common::ColorChoice::Never);
assert!(matches!(
inspect_cli.command,
CommandMode::Inspect {
shell: ShellMode::Pwsh,
..
}
));
}
#[test]
fn quote_rendering_is_shell_aware() {
assert_eq!(
render_shell_invocation(
ShellMode::Pwsh,
&["tool".to_string(), "two words".to_string()]
),
"& 'tool' 'two words'"
);
assert_eq!(
render_shell_invocation(
ShellMode::Cmd,
&["tool.exe".to_string(), "two words".to_string()]
),
"tool.exe \"two words\""
);
assert_eq!(quote_pwsh("a'b"), "'a''b'");
}
#[test]
fn helper_text_output_is_compact() {
let report = InspectReport {
shell: "raw".to_string(),
invocation: "alpha".to_string(),
probe_invocation: Some("tool.exe __native_helper -- alpha".to_string()),
count: 2,
argv: vec!["alpha".to_string(), "two words".to_string()],
};
let text = render_inspect_text(&report);
assert!(text.contains("shell=raw count=2"));
assert!(text.contains("argv[1]=\"two words\""));
}
#[test]
fn native_helper_parser_rejects_flags_and_quote_rendering_covers_raw() {
assert!(matches!(
parse_cli_from(["argv", HELPER_SUBCOMMAND, "--json"]),
Err(CliError::Usage(message)) if message.contains("only positional values")
));
assert_eq!(
render_shell_invocation(
ShellMode::Raw,
&["tool.exe".to_string(), "two words".to_string()]
),
"tool.exe \"two words\""
);
assert_eq!(quote_raw(""), "\"\"");
assert_eq!(quote_text("needs quotes"), "\"needs quotes\"");
}
#[test]
fn parser_and_helper_paths_reject_invalid_arguments() {
assert!(matches!(
parse_cli_from(["argv"]),
Err(CliError::Usage(message)) if message.contains("provide a subcommand")
));
assert!(matches!(
parse_cli_from(["argv", "--bogus"]),
Err(CliError::Usage(message)) if message.contains("unsupported argument")
));
assert!(matches!(
parse_cli_from(["argv", "quote", "--shell", "pwsh", "--bogus"]),
Err(CliError::Usage(message)) if message.contains("unsupported quote argument")
));
assert!(matches!(
parse_cli_from(["argv", "inspect", "--shell", "raw", "--bogus"]),
Err(CliError::Usage(message)) if message.contains("unsupported inspect argument")
));
let (_, helper_cli) =
parse_cli_from(["argv", HELPER_SUBCOMMAND, "alpha", "two words"]).expect("helper cli");
assert!(matches!(
helper_cli.command,
CommandMode::NativeHelper { values } if values == vec!["alpha".to_string(), "two words".to_string()]
));
}
#[test]
fn parser_rejects_unknown_subcommands_and_missing_shell_flags() {
assert!(matches!(
parse_cli_from(["argv", "wat"]),
Err(CliError::Usage(message)) if message.contains("expected quote or inspect")
));
assert!(matches!(
parse_cli_from(["argv", "quote", "--", "tool.exe"]),
Err(CliError::Usage(message)) if message.contains("quote requires --shell")
));
assert!(matches!(
parse_cli_from(["argv", "inspect", "--", "tool.exe"]),
Err(CliError::Usage(message)) if message.contains("inspect requires --shell")
));
}
#[test]
fn quote_helpers_cover_metacharacters_backslashes_and_empty_values() {
assert_eq!(quote_cmd("a&b"), "\"a&b\"");
assert_eq!(quote_cmd("two words"), "\"two words\"");
assert_eq!(quote_windows_arg("two words\\"), "\"two words\\\\\"");
assert_eq!(quote_windows_arg("say \"hi\""), "\"say \\\"hi\\\"\"");
assert_eq!(quote_windows_arg("plain"), "plain");
assert_eq!(quote_text(""), "\"\"");
assert_eq!(shell_label(ShellMode::Cmd), "cmd");
}
#[test]
fn run_and_resolve_values_cover_quote_and_helper_dispatch() {
let quote_cli = Cli {
common: CommonArgs::default(),
command: CommandMode::Quote {
shell: ShellMode::Raw,
values: vec![OsString::from("tool.exe"), OsString::from("two words")],
},
};
assert_eq!(
resolve_values(&quote_cli, &quote_cli_values(&quote_cli)).expect("values"),
vec!["tool.exe".to_string(), "two words".to_string()]
);
assert_eq!(run(&quote_cli).expect("quote"), ExitCode::Success);
let helper_cli = Cli {
common: CommonArgs {
json: true,
format: None,
..CommonArgs::default()
},
command: CommandMode::NativeHelper {
values: vec!["alpha".to_string()],
},
};
assert_eq!(run(&helper_cli).expect("helper"), ExitCode::Success);
}
#[test]
fn inspect_arguments_attempt_pwsh_and_cmd_launch_paths() {
let pwsh_error =
inspect_arguments(ShellMode::Pwsh, &["alpha".to_string()]).expect_err("pwsh helper");
assert!(matches!(
pwsh_error,
CliError::Runtime(message)
if message.contains("failed to parse helper output")
|| message.contains("native helper failed with status")
|| message.contains("failed to launch pwsh helper")
));
#[cfg(windows)]
{
let cmd_error =
inspect_arguments(ShellMode::Cmd, &["alpha".to_string()]).expect_err("cmd helper");
assert!(matches!(
cmd_error,
CliError::Runtime(message)
if message.contains("failed to parse helper output")
|| message.contains("native helper failed with status")
|| message.contains("failed to write")
|| message.contains("failed to launch cmd helper")
));
let wrapper_one = unique_cmd_wrapper_path();
let wrapper_two = unique_cmd_wrapper_path();
assert_ne!(wrapper_one, wrapper_two);
assert_eq!(
wrapper_one.extension().and_then(|value| value.to_str()),
Some("cmd")
);
}
}
#[test]
fn resolve_values_rejects_missing_terminal_input() {
let cli = Cli {
common: CommonArgs::default(),
command: CommandMode::Quote {
shell: ShellMode::Raw,
values: Vec::new(),
},
};
let error = resolve_values(&cli, &[]).expect_err("missing values");
assert!(matches!(
error,
CliError::Usage(message)
if message.contains("provide positional values or pipe a JSON array")
|| message.contains("stdin was empty; expected a JSON array of strings")
));
}
#[test]
fn inspect_arguments_in_unit_tests_surface_helper_failures() {
let error = inspect_arguments(ShellMode::Raw, &["alpha".to_string()])
.expect_err("unit test binary is not the helper entrypoint");
assert!(matches!(
error,
CliError::Runtime(message)
if message.contains("failed to parse helper output")
|| message.contains("native helper failed with status")
));
}
#[test]
fn cmd_wrapper_writer_refuses_preexisting_paths() {
let path = unique_cmd_wrapper_path();
fs::write(&path, "original").expect("preexisting wrapper");
let error =
write_cmd_wrapper_file(&path, "replacement").expect_err("preexisting path refused");
assert!(matches!(
error,
CliError::Runtime(message)
if message.contains("refusing to replace existing cmd inspect wrapper")
));
assert_eq!(
fs::read_to_string(&path).expect("preserved content"),
"original"
);
let _ = fs::remove_file(path);
}
#[test]
fn run_native_helper_returns_success_exit_code() {
let code =
run_native_helper(&["alpha".to_string(), "two words".to_string()]).expect("helper");
assert_eq!(code, ExitCode::Success);
}
fn quote_cli_values(cli: &Cli) -> Vec<OsString> {
match &cli.command {
CommandMode::Quote { values, .. } => values.clone(),
_ => Vec::new(),
}
}
}
+5
View File
@@ -0,0 +1,5 @@
//! Binary entry point for `argv`.
fn main() {
std::process::exit(argv::main_entry());
}
+233
View File
@@ -0,0 +1,233 @@
//! Integration tests for the `argv` command.
use assert_cmd::Command;
use predicates::prelude::*;
fn cargo_command() -> Command {
Command::cargo_bin("argv").expect("binary")
}
#[test]
fn no_args_prints_quick_help_card() {
let mut command = cargo_command();
command
.assert()
.code(2)
.stdout(predicate::str::is_empty())
.stderr(predicate::str::contains(
"error: provide a subcommand: quote or inspect",
))
.stderr(predicate::str::contains("argv - Mercury Toolbox"))
.stderr(predicate::str::contains("Usage:"))
.stderr(predicate::str::contains("argv quote"))
.stderr(predicate::str::contains("argv inspect"))
.stderr(predicate::str::contains(
"Type 'argv --help' for the full command reference.",
));
}
#[test]
fn help_mentions_quote_inspect_and_platform_boundaries() {
let mut command = cargo_command();
command
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("argv quote"))
.stdout(predicate::str::contains("argv inspect"))
.stdout(predicate::str::contains("Windows-first"))
.stdout(predicate::str::contains("cmd inspect is Windows-only"));
}
#[test]
fn subcommand_help_mentions_common_output_flags() {
for subcommand in ["quote", "inspect"] {
let mut command = cargo_command();
command
.args([subcommand, "--help"])
.assert()
.success()
.stdout(predicate::str::contains("--format <FORMAT>"))
.stdout(predicate::str::contains("--json"))
.stdout(predicate::str::contains("--toon"))
.stdout(predicate::str::contains("--color <WHEN>"))
.stdout(predicate::str::contains("--quiet"))
.stdout(predicate::str::contains("--shell <SHELL>"));
}
}
#[test]
fn quote_accepts_json_array_from_stdin() {
let mut command = cargo_command();
command
.arg("quote")
.arg("--shell")
.arg("pwsh")
.write_stdin("[\"tool\",\"two words\"]")
.assert()
.success()
.stdout(predicate::str::contains("& 'tool' 'two words'"));
}
#[cfg(windows)]
#[test]
fn quote_renders_cmd_safe_text_for_positional_args() {
let mut command = cargo_command();
command
.arg("quote")
.arg("--shell")
.arg("cmd")
.arg("--")
.arg("tool.exe")
.arg("two words")
.assert()
.success()
.stdout(predicate::str::contains("tool.exe"))
.stdout(predicate::str::contains("\"two words\""));
}
#[cfg(windows)]
#[test]
fn quote_cmd_wraps_metacharacters_for_cmd_shell() {
let mut command = cargo_command();
command
.arg("quote")
.arg("--shell")
.arg("cmd")
.arg("--")
.arg("tool.exe")
.arg("a&b")
.assert()
.success()
.stdout(predicate::str::contains("tool.exe"))
.stdout(predicate::str::contains("\"a&b\""));
}
#[cfg(windows)]
#[test]
fn inspect_round_trips_through_powershell_helper() {
let mut command = cargo_command();
command
.arg("inspect")
.arg("--json")
.arg("--shell")
.arg("pwsh")
.arg("--")
.arg("alpha")
.arg("two words")
.assert()
.success()
.stdout(predicate::str::contains("\"shell\":\"pwsh\""))
.stdout(predicate::str::contains(
"\"argv\":[\"alpha\",\"two words\"]",
))
.stdout(predicate::str::contains("\"count\":2"));
}
#[cfg(windows)]
#[test]
fn inspect_round_trips_positional_values_that_look_like_options() {
let mut command = cargo_command();
command
.arg("inspect")
.arg("--json")
.arg("--shell")
.arg("pwsh")
.arg("--")
.arg("alpha")
.arg("--filter=x|y")
.assert()
.success()
.stdout(predicate::str::contains(
"\"argv\":[\"alpha\",\"--filter=x|y\"]",
))
.stdout(predicate::str::contains("\"count\":2"));
}
#[cfg(windows)]
#[test]
fn inspect_round_trips_through_cmd_helper() {
let mut command = cargo_command();
command
.arg("inspect")
.arg("--json")
.arg("--shell")
.arg("cmd")
.arg("--")
.arg("alpha")
.arg("two words")
.assert()
.success()
.stdout(predicate::str::contains("\"shell\":\"cmd\""))
.stdout(predicate::str::contains(
"\"argv\":[\"alpha\",\"two words\"]",
))
.stdout(predicate::str::contains("\"count\":2"));
}
#[cfg(windows)]
#[test]
fn inspect_round_trips_cmd_caret_literals() {
let mut command = cargo_command();
command
.arg("inspect")
.arg("--json")
.arg("--shell")
.arg("cmd")
.arg("--")
.arg("--flag")
.arg("literal^caret")
.assert()
.success()
.stdout(predicate::str::contains(
"\"argv\":[\"--flag\",\"literal^caret\"]",
));
}
#[test]
fn inspect_raw_uses_helper_without_shell() {
let mut command = cargo_command();
command
.arg("inspect")
.arg("--json")
.arg("--shell")
.arg("raw")
.arg("--")
.arg("alpha")
.arg("two words")
.assert()
.success()
.stdout(predicate::str::contains("\"shell\":\"raw\""))
.stdout(predicate::str::contains(
"\"argv\":[\"alpha\",\"two words\"]",
));
}
#[test]
fn inspect_text_accepts_json_array_from_stdin() {
let mut command = cargo_command();
command
.arg("inspect")
.arg("--shell")
.arg("raw")
.write_stdin("[\"alpha\",\"two words\"]")
.assert()
.success()
.stdout(predicate::str::contains("shell=raw count=2"))
.stdout(predicate::str::contains("argv[1]=\"two words\""));
}
#[test]
fn quote_rejects_invalid_stdin_json() {
let mut command = cargo_command();
command
.arg("quote")
.arg("--shell")
.arg("raw")
.write_stdin("{\"argv\":[\"alpha\"]}")
.assert()
.code(2)
.stderr(predicate::str::contains(
"expected a JSON array of strings on stdin",
));
}
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "asmapi"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "Diff managed assembly API surfaces with AI-friendly output."
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[dependencies]
common = { path = "../common", default-features = false }
lexopt.workspace = true
managed = { path = "../managed" }
[dev-dependencies]
assert_cmd.workspace = true
predicates.workspace = true
serde_json.workspace = true
+511
View File
@@ -0,0 +1,511 @@
//! The `asmapi` command diffs managed assembly API surfaces.
use std::ffi::OsString;
use std::fmt::Write as _;
use std::path::PathBuf;
use common::{
CliError, CommonArgs, ExitCode, RenderMode, parse_color_choice, parse_format_choice,
print_quick_help_error, print_structured, print_text,
};
use lexopt::prelude::{Long, Short, Value as ArgValue};
use managed::{ApiDiffQuery, ApiDiffReport, ApiVisibilityScope, diff_assembly_api};
const HELP: &str = "\
Diff managed assembly API surfaces.
Usage:
asmapi [OPTIONS] <SUBCOMMAND> [ARGS...]
Subcommands:
diff Compare old and new managed assembly APIs
Shared Options:
--format <FORMAT> Structured output format: text, json, toon
--json Shortcut for --format json
--toon Shortcut for --format toon
--color <WHEN> Control ANSI color output: auto, never
--quiet Suppress non-essential status output
-h, --help Show this help text
-V, --version Show the command version
Examples:
asmapi diff old\\0Harmony.dll new\\0Harmony.dll
asmapi diff Rocket.API.old.dll Rocket.API.new.dll --json
asmapi diff old.dll new.dll --visibility all
";
const DIFF_HELP: &str = "\
Compare old and new managed assembly APIs.
Usage:
asmapi diff [OPTIONS] <OLD_ASSEMBLY> <NEW_ASSEMBLY>
Options:
--format <FORMAT> Structured output format: text, json, toon
--json Shortcut for --format json
--toon Shortcut for --format toon
--color <WHEN> Control ANSI color output: auto, never
--quiet Suppress non-essential status output
--visibility <SCOPE> API scope: public, internal, all (default: public)
--include-special Include special-name methods such as property accessors
--no-missing-method-risks Suppress MissingMethodException risk rows
-h, --help Show this help text
-V, --version Show the command version
Examples:
asmapi diff old\\0Harmony.dll new\\0Harmony.dll
asmapi diff Rocket.API.old.dll Rocket.API.new.dll --json
asmapi diff old.dll new.dll --visibility all
";
#[derive(Debug, Clone)]
struct Cli {
common: CommonArgs,
command: Command,
}
#[derive(Debug, Clone)]
enum Command {
Diff(DiffArgs),
}
#[derive(Debug, Clone)]
struct DiffArgs {
old_assembly: PathBuf,
new_assembly: PathBuf,
query: ApiDiffQuery,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ParseOutcome {
Help(&'static str),
Version,
Run,
}
#[derive(Debug, Clone)]
enum ParsedCommand {
Outcome(ParseOutcome),
Command(Command),
}
/// Parses CLI arguments and returns a process exit code.
#[must_use]
pub fn main_entry() -> i32 {
match parse_cli_from(std::env::args_os()) {
Ok((ParseOutcome::Help(help), _)) => match print_text(help) {
Ok(()) => ExitCode::Success.as_i32(),
Err(error) => {
eprintln!("error: {error}");
error.exit_code().as_i32()
}
},
Ok((ParseOutcome::Version, _)) => {
println!("asmapi {}", env!("CARGO_PKG_VERSION"));
ExitCode::Success.as_i32()
}
Ok((ParseOutcome::Run, cli)) => match run(&cli) {
Ok(code) => code.as_i32(),
Err(error) => {
print_quick_help_error(&error, HELP);
error.exit_code().as_i32()
}
},
Err(error) => {
print_quick_help_error(&error, HELP);
error.exit_code().as_i32()
}
}
}
fn parse_cli_from<I, T>(args: I) -> Result<(ParseOutcome, Cli), CliError>
where
I: IntoIterator<Item = T>,
T: Into<OsString>,
{
let mut parser = lexopt::Parser::from_iter(args);
let mut common = CommonArgs::default();
let subcommand = loop {
let Some(argument) = parser
.next()
.map_err(|error| CliError::usage(error.to_string()))?
else {
return Err(CliError::usage("missing subcommand; expected diff"));
};
match argument {
Long("help") | Short('h') => {
return Ok((ParseOutcome::Help(HELP), placeholder(common)));
}
Long("version") | Short('V') => {
return Ok((ParseOutcome::Version, placeholder(common)));
}
Long("json") => common.set_render_mode(RenderMode::Json),
Long("toon") => common.set_render_mode(RenderMode::Toon),
Long("format") => {
let value = parser_value_string(&mut parser, "--format")?;
common.set_render_mode(parse_format_choice(&value)?);
}
Long("color") => {
common.color = parse_color_choice(&parser_value_string(&mut parser, "--color")?)?;
}
Long("quiet") => common.quiet = true,
ArgValue(value) => {
break value.into_string().map_err(|invalid| {
CliError::usage(format!(
"subcommand expects UTF-8 text, got '{}'",
invalid.to_string_lossy()
))
})?;
}
_ => {
return Err(CliError::usage(
"unsupported argument; use --help to see available options",
));
}
}
};
let parsed = match subcommand.as_str() {
"diff" => parse_diff_command(&mut parser, &mut common)?,
_ => return Err(CliError::usage("unsupported subcommand; expected diff")),
};
let command = match parsed {
ParsedCommand::Outcome(outcome) => return Ok((outcome, placeholder(common))),
ParsedCommand::Command(command) => command,
};
Ok((ParseOutcome::Run, Cli { common, command }))
}
fn placeholder(common: CommonArgs) -> Cli {
Cli {
common,
command: Command::Diff(DiffArgs {
old_assembly: PathBuf::new(),
new_assembly: PathBuf::new(),
query: ApiDiffQuery::default(),
}),
}
}
fn parse_diff_command(
parser: &mut lexopt::Parser,
common: &mut CommonArgs,
) -> Result<ParsedCommand, CliError> {
let mut query = ApiDiffQuery::default();
let mut paths = Vec::new();
while let Some(argument) = parser
.next()
.map_err(|error| CliError::usage(error.to_string()))?
{
match argument {
Long("help") | Short('h') => {
return Ok(ParsedCommand::Outcome(ParseOutcome::Help(DIFF_HELP)));
}
Long("version") | Short('V') => {
return Ok(ParsedCommand::Outcome(ParseOutcome::Version));
}
Long("json") => common.set_render_mode(RenderMode::Json),
Long("toon") => common.set_render_mode(RenderMode::Toon),
Long("format") => {
let value = parser_value_string(parser, "--format")?;
common.set_render_mode(parse_format_choice(&value)?);
}
Long("color") => {
common.color = parse_color_choice(&parser_value_string(parser, "--color")?)?;
}
Long("quiet") => common.quiet = true,
Long("visibility") => {
query.visibility = parse_visibility(&parser_value_string(parser, "--visibility")?)?;
}
Long("include-special") => query.include_special = true,
Long("no-missing-method-risks") => query.include_missing_method_risks = false,
ArgValue(value) => {
if paths.len() == 2 {
return Err(CliError::usage(
"diff requires exactly two assembly paths: <OLD_ASSEMBLY> <NEW_ASSEMBLY>",
));
}
paths.push(PathBuf::from(value));
}
_ => {
return Err(CliError::usage(
"unsupported argument; use --help to see available options",
));
}
}
}
if paths.len() != 2 {
return Err(CliError::usage(
"diff requires exactly two assembly paths: <OLD_ASSEMBLY> <NEW_ASSEMBLY>",
));
}
Ok(ParsedCommand::Command(Command::Diff(DiffArgs {
old_assembly: paths.remove(0),
new_assembly: paths.remove(0),
query,
})))
}
fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result<String, CliError> {
let value = parser
.value()
.map_err(|error| CliError::usage(error.to_string()))?;
value.into_string().map_err(|invalid| {
CliError::usage(format!(
"{flag} expects UTF-8 text, got '{}'",
invalid.to_string_lossy()
))
})
}
fn parse_visibility(value: &str) -> Result<ApiVisibilityScope, CliError> {
match value {
"public" => Ok(ApiVisibilityScope::Public),
"internal" => Ok(ApiVisibilityScope::Internal),
"all" => Ok(ApiVisibilityScope::All),
other => Err(CliError::usage(format!(
"invalid --visibility value '{other}'; expected public, internal, or all"
))),
}
}
fn run(cli: &Cli) -> Result<ExitCode, CliError> {
match &cli.command {
Command::Diff(args) => {
let report = diff_assembly_api(&args.old_assembly, &args.new_assembly, &args.query)
.map_err(|error| CliError::runtime(error.to_string()))?;
render_report(&report, cli.common.render_mode())?;
Ok(ExitCode::Success)
}
}
}
fn render_report(report: &ApiDiffReport, mode: RenderMode) -> Result<(), CliError> {
match mode {
RenderMode::Json | RenderMode::Toon => print_structured(report, mode),
RenderMode::Text => print_text(render_text_report(report)),
}
}
fn render_text_report(report: &ApiDiffReport) -> String {
let mut output = String::new();
let _ = writeln!(
output,
"asmapi diff old={} new={} visibility={:?} removed_types={} added_types={} removed_methods={} added_methods={} signature_changed={} missing_method_risks={}",
report.old_assembly.assembly_name,
report.new_assembly.assembly_name,
report.visibility,
report.summary.removed_types,
report.summary.added_types,
report.summary.removed_methods,
report.summary.added_methods,
report.summary.signature_changed_methods,
report.summary.missing_method_risks
);
render_type_section(&mut output, "removed_types", &report.removed_types);
render_type_section(&mut output, "added_types", &report.added_types);
render_method_section(&mut output, "removed_methods", &report.removed_methods);
render_method_section(&mut output, "added_methods", &report.added_methods);
render_method_section(
&mut output,
"signature_changed",
&report.signature_changed_methods,
);
if !report.missing_method_risks.is_empty() {
let _ = writeln!(output, "missing_method_risks:");
for row in &report.missing_method_risks {
let _ = writeln!(
output,
" - {}::{} old_signature=\"{}\" old_assembly={} reason={}",
row.type_name, row.method_name, row.old_signature, row.old_assembly, row.reason
);
}
}
output
}
fn render_type_section(output: &mut String, title: &str, rows: &[managed::ApiTypeChange]) {
if rows.is_empty() {
return;
}
let _ = writeln!(output, "{title}:");
for row in rows {
let _ = writeln!(
output,
" - {} kind={} visibility={}",
row.type_name, row.kind, row.visibility
);
}
}
fn render_method_section(output: &mut String, title: &str, rows: &[managed::ApiMethodChange]) {
if rows.is_empty() {
return;
}
let _ = writeln!(output, "{title}:");
for row in rows {
let old = if row.old_signatures.is_empty() {
"-"
} else {
&row.old_signatures[0]
};
let new = if row.new_signatures.is_empty() {
"-"
} else {
&row.new_signatures[0]
};
let _ = writeln!(
output,
" - {}::{} old=\"{}\" new=\"{}\"",
row.type_name, row.method_name, old, new
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use managed::{
ApiDiffSummary, ApiMethodChange, ApiTypeChange, AssemblyDescriptor, MissingMethodRisk,
};
fn assembly(name: &str) -> AssemblyDescriptor {
AssemblyDescriptor {
path: PathBuf::from(format!("{name}.dll")),
assembly_name: name.to_string(),
assembly_version: Some("1.2.3.4".to_string()),
runtime_version: "v4.0.30319".to_string(),
is_il_only: true,
is_library: true,
is_strong_name_signed: false,
public_key_token: None,
}
}
#[test]
fn parse_cli_covers_top_level_and_diff_outcomes() {
let (outcome, _) = parse_cli_from(["asmapi", "--help"]).expect("top help");
assert_eq!(outcome, ParseOutcome::Help(HELP));
let (outcome, _) = parse_cli_from(["asmapi", "--version"]).expect("top version");
assert_eq!(outcome, ParseOutcome::Version);
let (outcome, _) = parse_cli_from(["asmapi", "diff", "--help"]).expect("diff help");
assert_eq!(outcome, ParseOutcome::Help(DIFF_HELP));
let error = parse_cli_from(["asmapi"]).expect_err("missing subcommand");
assert!(error.to_string().contains("missing subcommand"));
let (outcome, cli) = parse_cli_from([
"asmapi",
"--json",
"--color",
"never",
"diff",
"--visibility",
"all",
"--include-special",
"--no-missing-method-risks",
"old.dll",
"new.dll",
])
.expect("diff args");
assert_eq!(outcome, ParseOutcome::Run);
assert_eq!(cli.common.render_mode(), RenderMode::Json);
let Command::Diff(args) = cli.command;
assert_eq!(args.old_assembly, PathBuf::from("old.dll"));
assert_eq!(args.new_assembly, PathBuf::from("new.dll"));
assert_eq!(args.query.visibility, ApiVisibilityScope::All);
assert!(args.query.include_special);
assert!(!args.query.include_missing_method_risks);
}
#[test]
fn parse_diff_rejects_invalid_visibility_and_wrong_path_count() {
let error = parse_cli_from(["asmapi", "diff", "--visibility", "private", "old", "new"])
.expect_err("invalid visibility");
assert!(error.to_string().contains("invalid --visibility"));
let error = parse_cli_from(["asmapi", "diff", "only-one"]).expect_err("path count");
assert!(error.to_string().contains("exactly two assembly paths"));
let error =
parse_cli_from(["asmapi", "diff", "old", "new", "extra"]).expect_err("path count");
assert!(error.to_string().contains("exactly two assembly paths"));
let error = parse_cli_from(["asmapi", "--format", "xml", "diff", "old", "new"])
.expect_err("format");
assert!(error.to_string().contains("invalid --format value 'xml'"));
assert_eq!(
parse_visibility("public").expect("public"),
ApiVisibilityScope::Public
);
assert_eq!(
parse_visibility("internal").expect("internal"),
ApiVisibilityScope::Internal
);
assert_eq!(
parse_visibility("all").expect("all"),
ApiVisibilityScope::All
);
}
#[test]
fn render_text_report_includes_all_change_sections_and_risk_rows() {
let report = ApiDiffReport {
old_assembly: assembly("OldGame"),
new_assembly: assembly("NewGame"),
visibility: ApiVisibilityScope::Public,
summary: ApiDiffSummary {
removed_types: 1,
added_types: 1,
removed_methods: 1,
added_methods: 1,
signature_changed_methods: 1,
missing_method_risks: 1,
},
removed_types: vec![ApiTypeChange {
type_name: "Game.Legacy".to_string(),
kind: "class".to_string(),
visibility: "public".to_string(),
}],
added_types: vec![ApiTypeChange {
type_name: "Game.Modern".to_string(),
kind: "class".to_string(),
visibility: "public".to_string(),
}],
removed_methods: vec![ApiMethodChange {
type_name: "Game.Legacy".to_string(),
method_name: "Run".to_string(),
old_signatures: vec!["void Run()".to_string()],
new_signatures: Vec::new(),
}],
added_methods: vec![ApiMethodChange {
type_name: "Game.Modern".to_string(),
method_name: "Run".to_string(),
old_signatures: Vec::new(),
new_signatures: vec!["void Run(int count)".to_string()],
}],
signature_changed_methods: vec![ApiMethodChange {
type_name: "Game.Player".to_string(),
method_name: "Move".to_string(),
old_signatures: vec!["void Move(float x)".to_string()],
new_signatures: vec!["void Move(float x, float y)".to_string()],
}],
missing_method_risks: vec![MissingMethodRisk {
old_assembly: "OldGame".to_string(),
type_name: "Game.Legacy".to_string(),
method_name: "Run".to_string(),
old_signature: "void Run()".to_string(),
reason: "removed public method".to_string(),
}],
};
let text = render_text_report(&report);
assert!(text.contains("asmapi diff old=OldGame new=NewGame"));
assert!(text.contains("removed_types:"));
assert!(text.contains("added_types:"));
assert!(text.contains("removed_methods:"));
assert!(text.contains("added_methods:"));
assert!(text.contains("signature_changed:"));
assert!(text.contains("missing_method_risks:"));
assert!(text.contains("Game.Legacy::Run old_signature=\"void Run()\""));
}
}
+5
View File
@@ -0,0 +1,5 @@
//! Binary entry point for the `asmapi` command.
fn main() {
std::process::exit(asmapi::main_entry());
}
+148
View File
@@ -0,0 +1,148 @@
//! Integration tests for the `asmapi` command.
use std::path::PathBuf;
use assert_cmd::Command;
use predicates::prelude::*;
use serde_json::Value;
fn cargo_command() -> Command {
Command::cargo_bin("asmapi").expect("binary")
}
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.canonicalize()
.expect("workspace root")
}
fn managed_fixture_dir() -> PathBuf {
workspace_root()
.join("fixtures")
.join("managed")
.join("bin")
}
fn game_assembly() -> PathBuf {
managed_fixture_dir().join("GameAssembly.dll")
}
fn fixture_support() -> PathBuf {
managed_fixture_dir().join("FixtureSupport.dll")
}
fn diff_json(extra_args: &[&str]) -> Value {
let mut command = cargo_command();
let output = command
.arg("diff")
.arg(game_assembly())
.arg(fixture_support())
.args(extra_args)
.assert()
.success()
.get_output()
.stdout
.clone();
serde_json::from_slice::<Value>(&output).expect("json payload")
}
#[test]
fn no_args_prints_quick_help_card() {
let mut command = cargo_command();
command
.assert()
.code(2)
.stdout(predicate::str::is_empty())
.stderr(predicate::str::contains(
"missing subcommand; expected diff",
))
.stderr(predicate::str::contains("asmapi"))
.stderr(predicate::str::contains("Type 'asmapi --help'"));
}
#[test]
fn help_mentions_diff_and_shared_output_flags() {
let mut command = cargo_command();
command
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("asmapi diff"))
.stdout(predicate::str::contains("--format <FORMAT>"))
.stdout(predicate::str::contains("--json"))
.stdout(predicate::str::contains("--toon"));
let mut command = cargo_command();
command
.args(["diff", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("--visibility <SCOPE>"))
.stdout(predicate::str::contains("--include-special"))
.stdout(predicate::str::contains("--no-missing-method-risks"));
}
#[test]
fn diff_outputs_compact_text_sections() {
let mut command = cargo_command();
command
.arg(game_assembly())
.arg(fixture_support())
.arg("diff")
.assert()
.code(2);
let mut command = cargo_command();
command
.arg("diff")
.arg(game_assembly())
.arg(fixture_support())
.assert()
.success()
.stdout(predicate::str::contains("removed_types="))
.stdout(predicate::str::contains("added_types:"))
.stdout(predicate::str::contains("removed_methods:"))
.stdout(predicate::str::contains("missing_method_risks:"));
}
#[test]
fn diff_json_exposes_stable_top_level_fields() {
let payload = diff_json(&["--json"]);
assert_eq!(payload["visibility"], "public");
assert!(payload["old_assembly"].is_object());
assert!(payload["new_assembly"].is_object());
assert!(payload["summary"]["removed_types"].as_u64().is_some());
assert!(payload["missing_method_risks"].as_array().is_some());
}
#[test]
fn visibility_all_includes_more_or_equal_removed_methods() {
let public_payload = diff_json(&["--json"]);
let all_payload = diff_json(&["--visibility", "all", "--json"]);
assert_eq!(all_payload["visibility"], "all");
assert!(
all_payload["summary"]["removed_methods"]
.as_u64()
.expect("all visibility removed method count")
>= public_payload["summary"]["removed_methods"]
.as_u64()
.expect("public visibility removed method count")
);
}
#[test]
fn invalid_visibility_is_usage_error() {
let mut command = cargo_command();
command
.arg("diff")
.arg(game_assembly())
.arg(fixture_support())
.arg("--visibility")
.arg("private")
.assert()
.code(2)
.stderr(predicate::str::contains("invalid --visibility value"));
}
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "asmflow"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "Inspect managed IL, calls, fields, and string literals with AI-friendly output."
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[dependencies]
common = { path = "../common", default-features = false }
lexopt.workspace = true
managed = { path = "../managed" }
regex-lite.workspace = true
serde.workspace = true
serde_json.workspace = true
[dev-dependencies]
assert_cmd.workspace = true
predicates.workspace = true
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
//! Public entry point for the `asmflow` command crate.
mod cli;
pub use cli::main_entry;
+5
View File
@@ -0,0 +1,5 @@
//! Binary entry point for `asmflow`.
fn main() {
std::process::exit(asmflow::main_entry());
}
+27
View File
@@ -0,0 +1,27 @@
//! Integration tests for the `asmflow` command.
use assert_cmd::Command;
use predicates::prelude::*;
#[test]
fn no_args_prints_quick_help_card() {
let mut command = Command::cargo_bin("asmflow").expect("binary");
command
.assert()
.code(2)
.stdout(predicate::str::is_empty())
.stderr(predicate::str::contains(
"error: missing subcommand; expected find, body, or xref",
))
.stderr(predicate::str::contains("asmflow - Mercury Toolbox"))
.stderr(predicate::str::contains("Usage:"))
.stderr(predicate::str::contains(
"asmflow [OPTIONS] <SUBCOMMAND> [ARGS...]",
))
.stderr(predicate::str::contains("find"))
.stderr(predicate::str::contains("body"))
.stderr(predicate::str::contains("xref"))
.stderr(predicate::str::contains(
"Type 'asmflow --help' for the full command reference.",
));
}
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "asmmember"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "List managed assembly members with AI-friendly output."
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[dependencies]
common = { path = "../common", default-features = false }
managed = { path = "../managed" }
lexopt.workspace = true
regex-lite.workspace = true
serde.workspace = true
serde_json.workspace = true
[dev-dependencies]
assert_cmd.workspace = true
predicates.workspace = true
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
//! Public entry point for the `asmmember` command crate.
mod cli;
pub use cli::main_entry;
+5
View File
@@ -0,0 +1,5 @@
//! Binary entry point for `asmmember`.
fn main() {
std::process::exit(asmmember::main_entry());
}
+105
View File
@@ -0,0 +1,105 @@
//! Integration tests for the `asmmember` command.
use assert_cmd::Command;
use predicates::prelude::*;
use serde_json::Value;
use std::path::PathBuf;
fn cargo_command() -> Command {
Command::cargo_bin("asmmember").expect("binary")
}
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.canonicalize()
.expect("workspace root")
}
fn managed_fixture_dir() -> PathBuf {
workspace_root()
.join("fixtures")
.join("managed")
.join("bin")
}
fn fixture_assembly() -> PathBuf {
managed_fixture_dir().join("GameAssembly.dll")
}
#[test]
fn help_includes_examples_and_binding_usage() {
let mut command = cargo_command();
command
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("--assembly"))
.stdout(predicate::str::contains("--binding"))
.stdout(predicate::str::contains("--user-code-only"))
.stdout(predicate::str::contains("ConvertFrom-Json"));
}
#[test]
fn emits_methods_fields_and_properties_as_json() {
let mut command = cargo_command();
let output = command
.arg("--assembly")
.arg(fixture_assembly())
.arg("Game.UI.Windows.Windows.SpaceCraftConstructionWindow")
.arg("--match")
.arg("Build|Project|Launch|Queue|Complete")
.arg("--binding")
.arg("public,nonpublic,instance,static")
.arg("--user-code-only")
.arg("--json")
.assert()
.success()
.get_output()
.stdout
.clone();
let payload = serde_json::from_slice::<Value>(&output).expect("json payload");
let entries = payload.as_array().expect("array payload");
assert!(
entries
.iter()
.any(|entry| entry["kind"] == "method" && entry["name"] == "StartProject")
);
assert!(
entries
.iter()
.any(|entry| entry["kind"] == "field" && entry["name"] == "_buildTicks")
);
assert!(
entries
.iter()
.any(|entry| entry["kind"] == "property" && entry["name"] == "ProjectName")
);
assert!(!entries.iter().any(|entry| {
entry["kind"] == "field"
&& entry["name"]
.as_str()
.is_some_and(|name| name.contains("BackingField"))
}));
}
#[test]
fn supports_type_names_from_powershell_pipeline() {
let binary = assert_cmd::cargo::cargo_bin("asmmember");
let assembly = fixture_assembly();
let script = format!(
"'Game.UI.Windows.Windows.SpaceCraftConstructionWindow' | & '{}' --assembly '{}' --input-format lines --match Project --json",
binary.display(),
assembly.display()
);
let mut command = Command::new("pwsh");
command
.arg("-NoProfile")
.arg("-Command")
.arg(script)
.assert()
.success()
.stdout(predicate::str::contains("\"name\":\"StartProject\""));
}
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "asmref"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "Inspect managed assembly references with AI-friendly output."
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[dependencies]
common = { path = "../common", default-features = false }
managed = { path = "../managed" }
lexopt.workspace = true
serde.workspace = true
[dev-dependencies]
assert_cmd.workspace = true
predicates.workspace = true
serde_json.workspace = true
+598
View File
@@ -0,0 +1,598 @@
//! The `asmref` command inspects managed assembly references.
use std::ffi::OsString;
use std::fmt::Write as _;
use std::io::{self, Read};
use std::path::PathBuf;
use common::{
CliError, CommonArgs, ExitCode, InputFormat, RenderMode, map_result_count, parse_color_choice,
parse_format_choice, parse_input_format, print_json, print_quick_help_error, print_structured,
read_existing_stdin_path_records, should_read_stdin, write_stdout,
};
use lexopt::prelude::{Long, Short, Value as ArgValue};
use managed::{
DependencyDiagnosisReport, DependencyReferenceDiagnostic, DiagnoseQuery, ReferenceQuery,
ResolutionStatus, diagnose_dependencies, inspect_references,
};
const HELP: &str = "\
Inspect managed assembly references and simple resolution status.
Usage:
asmref [OPTIONS] [ASSEMBLY...]
asmref diagnose [OPTIONS] [ASSEMBLY...]
Options:
--format <FORMAT> Structured output format: text, json, toon
--json Shortcut for --format json
--toon Shortcut for --format toon
--input-format <FORMAT> Override stdin parsing mode: auto, lines, jsonl
--color <WHEN> Control ANSI color output: auto, never
--quiet Suppress non-essential status output
--resolve-dir <PATH> Additional directory to search for references
-h, --help Show this help text
-V, --version Show the command version
Examples:
asmref .\\fixtures\\managed\\bin\\GameAssembly.dll --resolve-dir .\\fixtures\\managed\\bin
asmref diagnose .\\Plugins\\Example.Plugin.dll --resolve-dir .\\Libraries --resolve-dir .\\Managed --format toon
'C:\\game\\Managed\\Assembly-CSharp.dll' | asmref --input-format lines --resolve-dir C:\\game\\Managed
asmref .\\fixtures\\managed\\bin\\GameAssembly.dll --json | ConvertFrom-Json
";
const DIAGNOSE_HELP: &str = "\
Diagnose managed assembly dependency closure resolution.
Usage:
asmref diagnose [OPTIONS] [ASSEMBLY...]
Options:
--format <FORMAT> Structured output format: text, json, toon
--json Shortcut for --format json
--toon Shortcut for --format toon
--input-format <FORMAT> Override stdin parsing mode: auto, lines, jsonl
--color <WHEN> Control ANSI color output: auto, never
--quiet Suppress non-essential status output
--resolve-dir <PATH> Additional directory to search for dependencies
--test-only-pattern <REGEX> Extra case-insensitive test-only name/path pattern
--no-default-test-patterns Disable built-in test-only markers
-h, --help Show this help text
-V, --version Show the command version
Examples:
asmref diagnose .\\Plugins\\Example.Plugin.dll --resolve-dir .\\Libraries --resolve-dir .\\Managed --format toon
asmref diagnose .\\RootPlugin.dll --test-only-pattern Project.Tests --json
asmref diagnose .\\RootPlugin.dll --no-default-test-patterns --toon
";
#[derive(Debug, Clone)]
struct Cli {
mode: CommandMode,
common: CommonArgs,
assembly_paths: Vec<PathBuf>,
query: ReferenceQuery,
diagnose_query: DiagnoseQuery,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CommandMode {
Inspect,
Diagnose,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ParseOutcome {
Help(CommandMode),
Version,
Run,
}
/// Parses CLI arguments and returns a process exit code.
#[must_use]
pub fn main_entry() -> i32 {
match parse_cli_from(std::env::args_os()) {
Ok((ParseOutcome::Help(CommandMode::Inspect), _)) => {
print!("{HELP}");
ExitCode::Success.as_i32()
}
Ok((ParseOutcome::Help(CommandMode::Diagnose), _)) => {
print!("{DIAGNOSE_HELP}");
ExitCode::Success.as_i32()
}
Ok((ParseOutcome::Version, _)) => {
println!("asmref {}", env!("CARGO_PKG_VERSION"));
ExitCode::Success.as_i32()
}
Ok((ParseOutcome::Run, cli)) => match run(&cli) {
Ok(code) => code.as_i32(),
Err(error) => {
print_quick_help_error(&error, help_for_mode(cli.mode));
error.exit_code().as_i32()
}
},
Err(error) => {
print_quick_help_error(&error, HELP);
error.exit_code().as_i32()
}
}
}
const fn help_for_mode(mode: CommandMode) -> &'static str {
match mode {
CommandMode::Inspect => HELP,
CommandMode::Diagnose => DIAGNOSE_HELP,
}
}
fn parse_cli_from<I, T>(args: I) -> Result<(ParseOutcome, Cli), CliError>
where
I: IntoIterator<Item = T>,
T: Into<OsString>,
{
let mut parser = lexopt::Parser::from_iter(args);
let mut cli = Cli {
mode: CommandMode::Inspect,
common: CommonArgs::default(),
assembly_paths: Vec::new(),
query: ReferenceQuery::default(),
diagnose_query: DiagnoseQuery::default(),
};
while let Some(argument) = parser
.next()
.map_err(|error| CliError::usage(error.to_string()))?
{
match argument {
Long("help") | Short('h') => return Ok((ParseOutcome::Help(cli.mode), cli)),
Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)),
Long("json") => cli.common.set_render_mode(RenderMode::Json),
Long("toon") => cli.common.set_render_mode(RenderMode::Toon),
Long("format") => {
let value = parser_value_string(&mut parser, "--format")?;
cli.common.set_render_mode(parse_format_choice(&value)?);
}
Long("input-format") => {
cli.common.input_format =
parse_input_format(&parser_value_string(&mut parser, "--input-format")?)?;
}
Long("color") => {
cli.common.color =
parse_color_choice(&parser_value_string(&mut parser, "--color")?)?;
}
Long("quiet") => cli.common.quiet = true,
Long("resolve-dir") => cli.push_resolve_dir(PathBuf::from(parser_value_string(
&mut parser,
"--resolve-dir",
)?)),
Long("test-only-pattern") if cli.mode == CommandMode::Diagnose => cli
.diagnose_query
.test_only_patterns
.push(parser_value_string(&mut parser, "--test-only-pattern")?),
Long("no-default-test-patterns") if cli.mode == CommandMode::Diagnose => {
cli.diagnose_query.use_default_test_patterns = false;
}
ArgValue(value)
if cli.mode == CommandMode::Inspect
&& cli.assembly_paths.is_empty()
&& value.to_string_lossy() == "diagnose" =>
{
cli.mode = CommandMode::Diagnose;
}
ArgValue(value) => cli.assembly_paths.push(PathBuf::from(value)),
_ => {
return Err(CliError::usage(
"unsupported argument; use --help to see available options",
));
}
}
}
Ok((ParseOutcome::Run, cli))
}
impl Cli {
fn push_resolve_dir(&mut self, path: PathBuf) {
self.query.resolve_dirs.push(path.clone());
self.diagnose_query.resolve_dirs.push(path);
}
}
fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result<String, CliError> {
let value = parser
.value()
.map_err(|error| CliError::usage(error.to_string()))?;
value.into_string().map_err(|invalid| {
CliError::usage(format!(
"{flag} expects UTF-8 text, got '{}'",
invalid.to_string_lossy()
))
})
}
fn run(cli: &Cli) -> Result<ExitCode, CliError> {
let paths = collect_paths(cli)?;
if paths.is_empty() {
return Err(CliError::usage(
"provide at least one assembly path or pipe assembly paths into stdin",
));
}
match cli.mode {
CommandMode::Inspect => run_inspect(cli, &paths),
CommandMode::Diagnose => run_diagnose(cli, &paths),
}
}
fn run_inspect(cli: &Cli, paths: &[PathBuf]) -> Result<ExitCode, CliError> {
let mut reports = inspect_references(paths, &cli.query)
.map_err(|error| CliError::runtime(error.to_string()))?;
let exit_code = map_result_count(reports.len());
match cli.common.render_mode() {
RenderMode::Json => {
if reports.len() == 1 {
print_json(&reports.remove(0))?;
} else {
print_json(&reports)?;
}
}
RenderMode::Toon => {
if reports.len() == 1 {
print_structured(&reports.remove(0), RenderMode::Toon)?;
} else {
print_structured(&reports, RenderMode::Toon)?;
}
}
RenderMode::Text => {
for report in &reports {
if report.references.is_empty() {
println!(
"{} references=0 path={}",
report.assembly.assembly_name,
report.assembly.path.display()
);
continue;
}
for reference in &report.references {
println!(
"{} name={} resolved={} path={}",
report.assembly.assembly_name,
reference.name,
reference.resolved,
reference
.resolved_path
.as_deref()
.map_or_else(|| "-".to_string(), |value| value.display().to_string())
);
}
}
}
}
Ok(exit_code)
}
fn run_diagnose(cli: &Cli, paths: &[PathBuf]) -> Result<ExitCode, CliError> {
let report = diagnose_dependencies(paths, &cli.diagnose_query)
.map_err(|error| CliError::runtime(error.to_string()))?;
let exit_code = if report.summary.error_count == 0 {
ExitCode::Success
} else {
ExitCode::NoResults
};
match cli.common.render_mode() {
RenderMode::Json => print_json(&report)?,
RenderMode::Toon => print_structured(&report, RenderMode::Toon)?,
RenderMode::Text => write_stdout(&render_diagnose_text(&report))?,
}
Ok(exit_code)
}
fn render_diagnose_text(report: &DependencyDiagnosisReport) -> String {
let mut output = String::new();
let summary = &report.summary;
let _ = writeln!(
&mut output,
"summary roots={} assemblies={} references={} resolved={} missing={} conflicts={} test_only={} errors={} warnings={} infos={}",
summary.root_count,
summary.assembly_count,
summary.reference_count,
summary.resolved_count,
summary.missing_count,
summary.conflict_count,
summary.test_only_count,
summary.error_count,
summary.warning_count,
summary.info_count
);
output.push_str("missing\n");
for reference in report
.references
.iter()
.filter(|item| item.resolution_status == ResolutionStatus::Missing)
{
let _ = writeln!(
&mut output,
" {} -> {} requested={}",
reference.source_assembly, reference.reference_name, reference.requested_version
);
}
output.push_str("conflicts\n");
for conflict in &report.conflicts {
let _ = writeln!(
&mut output,
" {} reason={} candidates={}",
conflict.reference_name,
conflict.reason,
conflict.candidates.len()
);
}
output.push_str("winners\n");
for winner in &report.winners {
let _ = writeln!(
&mut output,
" {} requested={} winner={} version={} reason={:?}",
winner.reference_name,
winner.requested_version,
winner.winner.assembly.path.display(),
winner
.winner
.assembly
.assembly_version
.as_deref()
.unwrap_or("-"),
winner.reason
);
}
output.push_str("test_only\n");
for candidate in &report.test_only {
let _ = writeln!(
&mut output,
" {} version={} path={}",
candidate.assembly.assembly_name,
candidate
.assembly
.assembly_version
.as_deref()
.unwrap_or("-"),
candidate.assembly.path.display()
);
}
output.push_str("risks\n");
for risk in &report.risks {
let _ = writeln!(
&mut output,
" {} {} source={} reference={} path={} message={}",
risk.severity,
risk.kind,
risk.source_assembly.as_deref().unwrap_or("-"),
risk.reference_name.as_deref().unwrap_or("-"),
risk.path
.as_deref()
.map_or_else(|| "-".to_string(), |path| path.display().to_string()),
risk.message
);
}
output.push_str("notable_refs\n");
for reference in &report.notable_refs {
output.push_str(&render_notable_reference(reference));
}
if !report.scan_warnings.is_empty() {
output.push_str("scan_warnings\n");
for warning in &report.scan_warnings {
let _ = writeln!(&mut output, " {warning}");
}
}
output
}
fn render_notable_reference(reference: &DependencyReferenceDiagnostic) -> String {
format!(
" {} -> {} requested={} status={:?} winner={}\n",
reference.source_assembly,
reference.reference_name,
reference.requested_version,
reference.resolution_status,
reference.winner.as_ref().map_or_else(
|| "-".to_string(),
|candidate| candidate.assembly.path.display().to_string()
)
)
}
fn collect_paths(cli: &Cli) -> Result<Vec<PathBuf>, CliError> {
if should_read_stdin(
!cli.assembly_paths.is_empty(),
cli.common.stdin_is_terminal(),
) {
let mut buffer = String::new();
io::stdin()
.read_to_string(&mut buffer)
.map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?;
if let Some(parsed) =
read_existing_stdin_path_records(&buffer, cli.common.input_format, "asmref")?
{
if !parsed.is_empty() {
return Ok(parsed);
}
}
}
common::expand_input_patterns(&cli.assembly_paths, "asmref")
}
#[allow(dead_code)]
fn parse_paths_from_string(
buffer: &str,
input_format: InputFormat,
) -> Result<Vec<PathBuf>, CliError> {
read_existing_stdin_path_records(buffer, input_format, "asmref")?
.map_or_else(|| Ok(Vec::new()), Ok)
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use common::{ColorChoice, InputFormat};
use super::*;
fn cli() -> Cli {
Cli {
mode: CommandMode::Inspect,
common: CommonArgs {
json: false,
format: None,
input_format: InputFormat::Auto,
color: ColorChoice::Never,
quiet: false,
},
assembly_paths: Vec::new(),
query: ReferenceQuery::default(),
diagnose_query: DiagnoseQuery::default(),
}
}
fn common_args(json: bool, input_format: InputFormat) -> CommonArgs {
CommonArgs {
json,
format: None,
input_format,
color: ColorChoice::Never,
quiet: false,
}
}
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.canonicalize()
.expect("workspace root")
}
fn fixture_assembly() -> PathBuf {
workspace_root()
.join("fixtures")
.join("managed")
.join("bin")
.join("GameAssembly.dll")
}
fn fixture_dir() -> PathBuf {
workspace_root()
.join("fixtures")
.join("managed")
.join("bin")
}
#[test]
fn parse_paths_reads_lines_and_jsonl_paths() {
let first = fixture_assembly();
let second = workspace_root().join("Cargo.toml");
assert_eq!(
parse_paths_from_string(
&format!("{}\n{}\n", first.display(), second.display()),
InputFormat::Lines
)
.expect("line paths"),
vec![second, first.clone()]
);
assert_eq!(
parse_paths_from_string(
&format!(
"{{\"path\":{}}}\n",
serde_json::to_string(&first.display().to_string()).expect("json path")
),
InputFormat::Jsonl
)
.expect("jsonl paths"),
vec![first]
);
}
#[test]
fn run_requires_at_least_one_path() {
let error = run(&cli()).expect_err("missing paths should fail");
assert!(matches!(
error,
CliError::Usage(message) if message.contains("provide at least one assembly path")
));
}
#[test]
fn parse_cli_collects_paths_and_resolve_dirs() {
let (_, parsed) = parse_cli_from([
"asmref",
"--resolve-dir",
"managed",
"--json",
"fixture.dll",
])
.expect("cli parse");
assert!(parsed.common.json);
assert_eq!(parsed.mode, CommandMode::Inspect);
assert_eq!(parsed.assembly_paths, vec![PathBuf::from("fixture.dll")]);
assert_eq!(parsed.query.resolve_dirs, vec![PathBuf::from("managed")]);
assert_eq!(
parsed.diagnose_query.resolve_dirs,
vec![PathBuf::from("managed")]
);
}
#[test]
fn parse_cli_collects_diagnose_options() {
let (_, parsed) = parse_cli_from([
"asmref",
"diagnose",
"--resolve-dir",
"managed",
"--test-only-pattern",
"Project.Tests",
"--no-default-test-patterns",
"fixture.dll",
])
.expect("cli parse");
assert_eq!(parsed.mode, CommandMode::Diagnose);
assert_eq!(parsed.assembly_paths, vec![PathBuf::from("fixture.dll")]);
assert_eq!(
parsed.diagnose_query.resolve_dirs,
vec![PathBuf::from("managed")]
);
assert_eq!(
parsed.diagnose_query.test_only_patterns,
vec!["Project.Tests"]
);
assert!(!parsed.diagnose_query.use_default_test_patterns);
}
#[test]
fn run_emits_success_for_fixture_reference_report() {
let exit_code = run(&Cli {
common: common_args(false, InputFormat::Auto),
assembly_paths: vec![fixture_assembly()],
mode: CommandMode::Inspect,
query: ReferenceQuery {
resolve_dirs: vec![fixture_dir()],
},
diagnose_query: DiagnoseQuery::default(),
})
.expect("fixture reference run");
assert_eq!(exit_code, ExitCode::Success);
}
}
+5
View File
@@ -0,0 +1,5 @@
//! Binary entry point for `asmref`.
fn main() {
std::process::exit(asmref::main_entry());
}
+227
View File
@@ -0,0 +1,227 @@
//! Integration tests for the `asmref` command.
use assert_cmd::Command;
use predicates::prelude::*;
use serde_json::Value;
use std::path::PathBuf;
fn cargo_command() -> Command {
Command::cargo_bin("asmref").expect("binary")
}
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.canonicalize()
.expect("workspace root")
}
fn managed_fixture_dir() -> PathBuf {
workspace_root()
.join("fixtures")
.join("managed")
.join("bin")
}
fn fixture_assembly() -> PathBuf {
managed_fixture_dir().join("GameAssembly.dll")
}
fn diagnose_fixture_root() -> PathBuf {
workspace_root()
.join("fixtures")
.join("managed")
.join("diagnose-bin")
.join("root")
.join("RootPlugin.dll")
}
fn diagnose_server_a_dir() -> PathBuf {
workspace_root()
.join("fixtures")
.join("managed")
.join("diagnose-bin")
.join("server-a")
}
fn diagnose_server_b_dir() -> PathBuf {
workspace_root()
.join("fixtures")
.join("managed")
.join("diagnose-bin")
.join("server-b")
}
fn framework_reference_free_assembly() -> PathBuf {
PathBuf::from(r"C:\Windows\Microsoft.NET\Framework64\v4.0.30319\mscorlib.dll")
}
#[test]
fn help_includes_examples_and_resolve_dir_usage() {
let mut command = cargo_command();
command
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("--resolve-dir"))
.stdout(predicate::str::contains("ConvertFrom-Json"));
}
#[test]
fn emits_reference_resolution_as_json() {
let fixture_dir = managed_fixture_dir();
let mut command = cargo_command();
let output = command
.arg(fixture_assembly())
.arg("--resolve-dir")
.arg(&fixture_dir)
.arg("--json")
.assert()
.success()
.get_output()
.stdout
.clone();
let payload = serde_json::from_slice::<Value>(&output).expect("json payload");
let refs = payload["references"].as_array().expect("references array");
assert!(
refs.iter().any(|entry| {
entry["name"] == "FixtureSupport"
&& entry["resolved"] == true
&& entry["resolved_path"]
.as_str()
.is_some_and(|value: &str| value.ends_with("FixtureSupport.dll"))
}),
"expected FixtureSupport reference to resolve"
);
}
#[test]
fn text_mode_reports_zero_reference_empty_state() {
let mut command = cargo_command();
command
.arg(framework_reference_free_assembly())
.assert()
.success()
.stdout(predicate::str::contains("references=0"));
}
#[test]
fn supports_path_input_from_powershell_pipeline() {
let binary = assert_cmd::cargo::cargo_bin("asmref");
let input = fixture_assembly();
let fixture_dir = managed_fixture_dir();
let script = format!(
"'{}' | & '{}' --input-format lines --resolve-dir '{}' --json",
input.display(),
binary.display(),
fixture_dir.display()
);
let mut command = Command::new("pwsh");
command
.arg("-NoProfile")
.arg("-Command")
.arg(script)
.assert()
.success()
.stdout(predicate::str::contains("\"name\":\"FixtureSupport\""));
}
#[test]
fn diagnose_help_mentions_closure_options() {
let mut command = cargo_command();
command
.args(["diagnose", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("asmref diagnose"))
.stdout(predicate::str::contains("--resolve-dir"))
.stdout(predicate::str::contains("--test-only-pattern"))
.stdout(predicate::str::contains("--no-default-test-patterns"))
.stdout(predicate::str::contains("--format <FORMAT>"))
.stdout(predicate::str::contains("--toon"));
}
#[test]
fn diagnose_errors_show_diagnose_usage() {
let mut command = cargo_command();
command
.arg("diagnose")
.assert()
.code(2)
.stderr(predicate::str::contains(
"asmref diagnose [OPTIONS] [ASSEMBLY...]",
));
}
#[test]
fn diagnose_reports_missing_conflicts_winners_and_risks() {
let mut command = cargo_command();
let output = command
.arg("diagnose")
.arg(diagnose_fixture_root())
.arg("--resolve-dir")
.arg(diagnose_server_a_dir())
.arg("--resolve-dir")
.arg(diagnose_server_b_dir())
.arg("--json")
.assert()
.code(1)
.get_output()
.stdout
.clone();
let payload = serde_json::from_slice::<Value>(&output).expect("json payload");
assert_eq!(payload["summary"]["root_count"], 1);
assert!(
payload["summary"]["error_count"]
.as_u64()
.is_some_and(|count| count > 0)
);
let references = payload["references"].as_array().expect("references array");
assert!(references.iter().any(|entry| {
entry["reference_name"] == "MissingOnly" && entry["resolution_status"] == "missing"
}));
let candidates = payload["candidates"].as_array().expect("candidates array");
assert!(candidates.iter().any(|entry| {
entry["assembly"]["assembly_name"] == "RuntimeDependency"
&& entry["assembly"]["assembly_version"] == "2.0.0.0"
}));
let winners = payload["winners"].as_array().expect("winners array");
assert!(winners.iter().any(|entry| {
entry["reference_name"] == "0Harmony"
&& entry["winner"]["assembly"]["assembly_version"] == "2.2.2.0"
}));
let conflicts = payload["conflicts"].as_array().expect("conflicts array");
assert!(
conflicts
.iter()
.any(|entry| entry["reference_name"] == "RuntimeDependency")
);
let test_only = payload["test_only"].as_array().expect("test_only array");
assert!(
test_only
.iter()
.any(|entry| entry["assembly"]["assembly_name"] == "TestOnlySupport")
);
let risks = payload["risks"].as_array().expect("risks array");
for expected in [
"missing_reference",
"version_mismatch",
"test_only_dependency",
"missing_method",
"missing_type",
] {
assert!(
risks.iter().any(|entry| entry["kind"] == expected),
"expected risk kind {expected} in {risks:#?}"
);
}
}
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "asmtype"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "List managed assembly types with AI-friendly output."
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[dependencies]
common = { path = "../common", default-features = false }
managed = { path = "../managed" }
lexopt.workspace = true
regex-lite.workspace = true
serde.workspace = true
serde_json.workspace = true
[dev-dependencies]
assert_cmd.workspace = true
predicates.workspace = true
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
//! Binary entry point for `asmtype`.
fn main() {
std::process::exit(asmtype::main_entry());
}
+137
View File
@@ -0,0 +1,137 @@
//! Integration tests for the `asmtype` command.
use assert_cmd::Command;
use predicates::prelude::*;
use serde_json::Value;
use std::path::PathBuf;
fn cargo_command() -> Command {
Command::cargo_bin("asmtype").expect("binary")
}
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.canonicalize()
.expect("workspace root")
}
fn managed_fixture_dir() -> PathBuf {
workspace_root()
.join("fixtures")
.join("managed")
.join("bin")
}
fn fixture_assembly() -> PathBuf {
managed_fixture_dir().join("GameAssembly.dll")
}
#[test]
fn help_includes_examples_and_pipeline_usage() {
let mut command = cargo_command();
command
.arg("--help")
.assert()
.success()
.stdout(
predicate::str::contains(
"asmtype .\\fixtures\\managed\\GameAssembly\\GameAssembly.csproj",
)
.not(),
)
.stdout(predicate::str::contains("asmtype .\\target\\"))
.stdout(predicate::str::contains("ConvertFrom-Json"))
.stdout(predicate::str::contains("--match"))
.stdout(predicate::str::contains("--user-code-only"));
}
#[test]
fn filters_types_as_json() {
let mut command = cargo_command();
let output = command
.arg(fixture_assembly())
.arg("--match")
.arg("SpaceCraft|Spacecraft")
.arg("--json")
.assert()
.success()
.get_output()
.stdout
.clone();
let payload = serde_json::from_slice::<Value>(&output).expect("json payload");
let entries = payload.as_array().expect("array payload");
assert!(entries.len() >= 4, "expected at least 4 matching types");
assert!(
entries
.iter()
.any(|entry| entry["full_name"]
== "Game.UI.Windows.Windows.SpaceCraftConstructionWindow")
);
assert!(
entries.iter().any(|entry| entry["kind"] == "struct"
&& entry["full_name"] == "Data.SpacecraftConstructData")
);
}
#[test]
fn json_no_match_is_successful_empty_array_for_pipelines() {
let mut command = cargo_command();
command
.arg(fixture_assembly())
.arg("--match")
.arg("^DefinitelyMissingType$")
.arg("--json")
.assert()
.success()
.stdout(predicate::eq("[]\n"));
}
#[test]
fn supports_powershell_pipeline_input() {
let binary = assert_cmd::cargo::cargo_bin("asmtype");
let input = fixture_assembly();
let script = format!(
"'{}' | & '{}' --input-format lines --match SpaceCraft --json",
input.display(),
binary.display()
);
let mut command = Command::new("pwsh");
command
.arg("-NoProfile")
.arg("-Command")
.arg(script)
.assert()
.success()
.stdout(predicate::str::contains(
"\"full_name\":\"Game.UI.Windows.Windows.SpaceCraftConstructionWindow\"",
));
}
#[test]
fn show_matched_members_hides_backing_fields_when_user_facing_hits_exist() {
let mut command = cargo_command();
let output = command
.arg(fixture_assembly())
.arg("--with-member-match")
.arg("StartProject|QueueVehicle|k__BackingField")
.arg("--show-matched-members")
.arg("--json")
.assert()
.success()
.get_output()
.stdout
.clone();
let payload = serde_json::from_slice::<Value>(&output).expect("json payload");
let entries = payload.as_array().expect("array payload");
let construction = entries
.iter()
.find(|entry| entry["full_name"] == "Game.UI.Windows.Windows.SpaceCraftConstructionWindow")
.expect("construction row");
assert_eq!(
construction["matched_members"],
Value::Array(vec![Value::String("StartProject".to_string())])
);
}
+31
View File
@@ -0,0 +1,31 @@
[package]
name = "await"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "Wait for paths, ports, HTTP endpoints, or repeated commands with bounded polling."
keywords.workspace = true
categories.workspace = true
[lib]
name = "awaitcmd"
[lints]
workspace = true
[dependencies]
common = { path = "../common", default-features = false }
lexopt.workspace = true
native-tls.workspace = true
runtimekit = { path = "../runtimekit" }
serde.workspace = true
serde_json.workspace = true
[dev-dependencies]
assert_cmd.workspace = true
predicates.workspace = true
tempfile.workspace = true
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
//! Binary entry point for `await`.
#![allow(
clippy::multiple_crate_versions,
reason = "native-tls is already used elsewhere in the workspace and cargo deny remains the dependency audit gate"
)]
fn main() {
std::process::exit(awaitcmd::main_entry());
}
+175
View File
@@ -0,0 +1,175 @@
//! Integration tests for the `await` command.
use std::fs;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::thread;
use assert_cmd::Command;
use predicates::prelude::*;
use tempfile::TempDir;
fn cargo_command() -> Command {
Command::cargo_bin("await").expect("binary")
}
#[test]
fn no_args_prints_quick_help_card() {
let mut command = cargo_command();
command
.assert()
.code(2)
.stdout(predicate::str::is_empty())
.stderr(predicate::str::contains(
"error: provide a subcommand: path, port, http, or run",
))
.stderr(predicate::str::contains("await - Mercury Toolbox"))
.stderr(predicate::str::contains("Usage:"))
.stderr(predicate::str::contains("await [OPTIONS] path <PATH>"))
.stderr(predicate::str::contains("await [OPTIONS] port <TARGET>"))
.stderr(predicate::str::contains(
"Type 'await --help' for the full command reference.",
));
}
fn spawn_http_server(response: &'static [u8]) -> u16 {
let listener = TcpListener::bind(("127.0.0.1", 0)).expect("listener");
let port = listener.local_addr().expect("address").port();
thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
let mut buffer = [0_u8; 1024];
let _ = stream.read(&mut buffer);
let _ = stream.write_all(response);
}
});
port
}
#[test]
fn help_mentions_v3_modes() {
let mut command = cargo_command();
command
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("path <PATH>"))
.stdout(predicate::str::contains("port <TARGET>"))
.stdout(predicate::str::contains("http <URL>"))
.stdout(predicate::str::contains("--state exists|missing"));
}
#[test]
fn path_mode_supports_missing_state() {
let temp = TempDir::new().expect("temp dir");
let missing = temp.path().join("missing.flag");
let mut command = cargo_command();
command
.arg("path")
.arg(missing)
.arg("--json")
.arg("--state")
.arg("missing")
.assert()
.success()
.stdout(predicate::str::contains("\"ok\":true"))
.stdout(predicate::str::contains("\"kind\":\"path\""))
.stdout(predicate::str::contains("\"state\":\"missing\""));
}
#[test]
fn port_mode_uses_tcp_scheme_target() {
let listener = TcpListener::bind(("127.0.0.1", 0)).expect("listener");
let address = listener.local_addr().expect("address");
let mut command = cargo_command();
command
.arg("--json")
.arg("--timeout")
.arg("800ms")
.arg("--interval")
.arg("50ms")
.arg("port")
.arg(format!("tcp://127.0.0.1:{}", address.port()))
.assert()
.success()
.stdout(predicate::str::contains("\"ok\":true"))
.stdout(predicate::str::contains("\"kind\":\"port\""))
.stdout(predicate::str::contains("\"target\":\"tcp://127.0.0.1:"));
drop(listener);
}
#[test]
fn http_mode_supports_status_matching() {
let port = spawn_http_server(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n");
let mut command = cargo_command();
command
.arg("--json")
.arg("--timeout")
.arg("800ms")
.arg("--interval")
.arg("50ms")
.arg("http")
.arg(format!("http://127.0.0.1:{port}/health"))
.arg("--status")
.arg("204")
.assert()
.success()
.stdout(predicate::str::contains("\"ok\":true"))
.stdout(predicate::str::contains("\"kind\":\"http\""))
.stdout(predicate::str::contains("\"status_code\":204"));
}
#[cfg(windows)]
#[test]
fn run_mode_retries_until_exit_code_matches() {
let temp = TempDir::new().expect("temp dir");
let marker = temp.path().join("ready.flag");
let script = temp.path().join("flip.ps1");
fs::write(
&script,
format!(
"if (Test-Path '{}') {{ exit 0 }}\nNew-Item -ItemType File -Path '{}' | Out-Null\nexit 9\n",
marker.display(),
marker.display()
),
)
.expect("script");
let mut command = cargo_command();
command
.arg("--json")
.arg("--timeout")
.arg("20s")
.arg("--interval")
.arg("100ms")
.arg("run")
.arg("--shell")
.arg("pwsh")
.arg("--exit-code")
.arg("0")
.arg("--")
.arg(script)
.assert()
.success()
.stdout(predicate::str::contains("\"ok\":true"))
.stdout(predicate::str::contains("\"attempts\":2"))
.stdout(predicate::str::contains("\"kind\":\"run\""))
.stdout(predicate::str::contains("\"exit_code\":0"));
}
#[test]
fn path_mode_times_out_when_requested_state_never_arrives() {
let temp = TempDir::new().expect("temp dir");
let existing = temp.path().join("ready.flag");
fs::write(&existing, "ready").expect("flag");
let mut command = cargo_command();
command
.args(["--timeout", "150ms", "--interval", "50ms", "path"])
.arg(&existing)
.arg("--state")
.arg("missing")
.assert()
.code(1)
.stdout(predicate::str::contains("timeout condition=path:missing:"))
.stdout(predicate::str::contains("last=path:ok=false:exists=true"));
}
+39
View File
@@ -0,0 +1,39 @@
[package]
name = "binmeta"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "Inspect Windows binary metadata with AI-friendly output."
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[dependencies]
common = { path = "../common", default-features = false }
goblin.workspace = true
humantime.workspace = true
lexopt.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
[target.'cfg(windows)'.dependencies]
windows-sys = { workspace = true, features = [
"Win32_Foundation",
"Win32_Security_Cryptography",
"Win32_Security_Cryptography_Catalog",
"Win32_Security_Cryptography_Sip",
"Win32_Security_WinTrust",
"Win32_Storage_FileSystem",
] }
[dev-dependencies]
assert_cmd.workspace = true
predicates.workspace = true
tempfile.workspace = true
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
//! Binary entry point for `binmeta`.
fn main() {
std::process::exit(binmeta::main_entry());
}
+182
View File
@@ -0,0 +1,182 @@
//! Integration tests for the `binmeta` command.
use std::path::{Path, PathBuf};
use assert_cmd::Command;
use predicates::prelude::*;
use serde_json::Value;
fn cargo_command() -> Command {
Command::cargo_bin("binmeta").expect("binary")
}
fn fixture(path: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("fixtures")
.join(path)
}
fn inspect_json(path: &Path) -> Value {
let mut command = cargo_command();
let output = command
.arg(path)
.arg("--json")
.assert()
.success()
.get_output()
.stdout
.clone();
serde_json::from_slice::<Value>(&output).expect("json payload")
}
#[test]
fn help_includes_examples_and_json_pipeline_usage() {
let mut command = cargo_command();
command
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains(
"binmeta .\\fixtures\\binmeta\\plain.txt",
))
.stdout(predicate::str::contains("ConvertFrom-Json"))
.stdout(predicate::str::contains("--input-format"));
}
#[test]
fn inspects_plain_text_fixture_as_not_pe() {
let input = fixture("binmeta/plain.txt");
let payload = inspect_json(&input);
let entries = payload.as_array().expect("array payload");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0]["kind"], "not_pe");
assert_eq!(entries[0]["extension"], "txt");
assert_eq!(entries[0]["sha256"].as_str().map(str::len), Some(64));
assert!(
entries[0]["size_bytes"]
.as_u64()
.is_some_and(|value| value > 0)
);
assert!(entries[0]["pe"].is_null());
assert!(entries[0]["version"].is_null());
assert!(entries[0]["signature"].is_null());
}
#[test]
fn supports_powershell_pipeline_input() {
let binary = assert_cmd::cargo::cargo_bin("binmeta");
let input = fixture("binmeta/plain.txt");
let script = format!("'{}' | & '{}' --json", input.display(), binary.display());
let mut command = Command::new("pwsh");
command
.arg("-NoProfile")
.arg("-Command")
.arg(script)
.assert()
.success()
.stdout(predicate::str::contains("\"kind\":\"not_pe\""));
}
#[cfg(windows)]
#[test]
fn inspects_current_test_binary_as_pe() {
let current_exe = std::env::current_exe().expect("current exe");
let payload = inspect_json(&current_exe);
let entry = &payload.as_array().expect("array payload")[0];
assert_eq!(entry["kind"], "pe");
assert_eq!(entry["path"], current_exe.display().to_string());
assert_eq!(entry["sha256"].as_str().map(str::len), Some(64));
assert!(
entry["pe"]["machine"]
.as_str()
.is_some_and(|value| !value.is_empty())
);
assert!(
entry["pe"]["architecture"]
.as_str()
.is_some_and(|value| !value.is_empty())
);
assert!(
entry["pe"]["section_count"]
.as_u64()
.is_some_and(|value| value > 0)
);
assert!(
entry["pe"]["sections"]
.as_array()
.is_some_and(|value| !value.is_empty())
);
assert!(entry["pe"]["library_count"].as_u64().is_some());
assert!(entry.get("version").is_some());
assert!(entry.get("signature").is_some());
assert_eq!(entry["signature"]["status"], "not_signed");
assert_eq!(entry["signature"]["signature_type"], "none");
}
#[cfg(windows)]
#[test]
fn inspects_windows_binary_version_and_catalog_signature() {
let notepad = PathBuf::from(r"C:\Windows\System32\notepad.exe");
if !notepad.is_file() {
eprintln!(
"skipping notepad signature smoke: {} missing",
notepad.display()
);
return;
}
let payload = inspect_json(&notepad);
let entry = &payload.as_array().expect("array payload")[0];
assert_eq!(entry["kind"], "pe");
assert!(
entry["version"]["company_name"]
.as_str()
.is_some_and(|value| !value.is_empty())
);
assert!(
entry["version"]["file_version"]
.as_str()
.is_some_and(|value| !value.is_empty())
);
assert_eq!(entry["signature"]["status"], "valid");
assert_eq!(entry["signature"]["catalog_signed"], true);
assert_eq!(entry["signature"]["signature_type"], "catalog");
assert!(
entry["signature"]["signer"]["subject"]
.as_str()
.is_some_and(|value| value.contains("Microsoft"))
);
}
#[cfg(windows)]
#[test]
fn malformed_version_resource_still_reports_pe_identity_when_reference_exists() {
let nuitka_exe = PathBuf::from(r"C:\Users\example\Desktop\SampleTool.exe");
if !nuitka_exe.is_file() {
eprintln!(
"skipping Nuitka resource compatibility smoke: {} missing",
nuitka_exe.display()
);
return;
}
let payload = inspect_json(&nuitka_exe);
let entry = &payload.as_array().expect("array payload")[0];
assert_eq!(entry["kind"], "pe");
assert_eq!(entry["version"]["company_name"], "Example Corp");
assert_eq!(
entry["version"]["file_description"],
"Codex Thread Importer"
);
assert_eq!(entry["version"]["product_name"], "Codex Thread Importer");
assert!(
entry["parse_error"]
.as_str()
.is_some_and(|message| message.contains("ResourceString value_len"))
);
}
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "chunkcat"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "List and read deterministic chunks from large text files."
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[dependencies]
common = { path = "../common", default-features = false }
lexopt.workspace = true
serde.workspace = true
[dev-dependencies]
assert_cmd.workspace = true
predicates.workspace = true
serde_json.workspace = true
tempfile.workspace = true
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
//! Binary entry point for `chunkcat`.
fn main() {
std::process::exit(chunkcat::main_entry());
}
+196
View File
@@ -0,0 +1,196 @@
//! Integration tests for the `chunkcat` command.
use std::fs;
use std::path::{Path, PathBuf};
use assert_cmd::Command;
use predicates::prelude::*;
use serde_json::Value;
use tempfile::{TempDir, tempdir};
const SAMPLE_RS: &str = "reading/sample.rs";
fn cargo_command() -> Command {
Command::cargo_bin("chunkcat").expect("binary")
}
fn fixture(path: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("fixtures")
.join(path)
}
fn temp_file(name: &str, contents: impl AsRef<[u8]>) -> (TempDir, PathBuf) {
let dir = tempdir().expect("tempdir");
let path = dir.path().join(name);
fs::write(&path, contents).expect("fixture");
(dir, path)
}
fn pwsh_command(script: impl AsRef<str>) -> Command {
let mut command = Command::new("pwsh");
command
.arg("-NoProfile")
.arg("-Command")
.arg(script.as_ref());
command
}
fn ps_quote(value: impl std::fmt::Display) -> String {
format!("'{}'", value.to_string().replace('\'', "''"))
}
fn display_path(path: &Path) -> String {
path.display().to_string()
}
#[test]
fn lists_chunk_inventory_in_text_mode() {
let mut command = cargo_command();
command
.arg(fixture(SAMPLE_RS))
.arg("--max-lines")
.arg("8")
.arg("--overlap")
.arg("2")
.arg("--inventory")
.assert()
.success()
.stdout(predicate::str::contains("chunks=6"))
.stdout(predicate::str::contains("0 lines=1:8"))
.stdout(predicate::str::contains("5 lines=31:35"));
}
#[test]
fn renders_selected_chunk_with_line_numbers() {
let mut command = cargo_command();
command
.arg(fixture(SAMPLE_RS))
.arg("--max-lines")
.arg("8")
.arg("--overlap")
.arg("2")
.arg("--chunk")
.arg("2")
.assert()
.success()
.stdout(predicate::str::contains("chunk=2 lines=13:20"))
.stdout(predicate::str::contains("13: pub enum Mode {"))
.stdout(predicate::str::contains("20: Mode::Safe"));
}
#[test]
fn emits_json_inventory_and_selected_chunk_payloads() {
let mut inventory = cargo_command();
let inventory_output = inventory
.arg("--json")
.arg(fixture(SAMPLE_RS))
.arg("--max-lines")
.arg("8")
.arg("--overlap")
.arg("2")
.assert()
.success()
.get_output()
.stdout
.clone();
let inventory_json = serde_json::from_slice::<Value>(&inventory_output).expect("inventory");
assert_eq!(inventory_json["path"], display_path(&fixture(SAMPLE_RS)));
assert_eq!(inventory_json["total_lines"], 35);
assert_eq!(inventory_json["chunks"][0]["start_line"], 1);
assert_eq!(inventory_json["chunks"][5]["end_line"], 35);
assert!(inventory_json["selected_chunk"].is_null());
let mut selected = cargo_command();
let selected_output = selected
.arg("--json")
.arg(fixture(SAMPLE_RS))
.arg("--max-lines")
.arg("8")
.arg("--overlap")
.arg("2")
.arg("--chunk")
.arg("4")
.assert()
.success()
.get_output()
.stdout
.clone();
let selected_json = serde_json::from_slice::<Value>(&selected_output).expect("selected");
assert_eq!(selected_json["selected_chunk"]["index"], 4);
assert_eq!(selected_json["selected_chunk"]["start_line"], 25);
assert_eq!(selected_json["selected_chunk"]["lines"][0]["number"], 25);
assert_eq!(
selected_json["selected_chunk"]["lines"][0]["text"],
" match mode {"
);
}
#[test]
fn supports_powershell_pipeline_for_path_input() {
let binary = assert_cmd::cargo::cargo_bin("chunkcat");
let input = fixture(SAMPLE_RS);
let script = format!(
"{} | & {} --json --max-lines 10 --chunk 1",
ps_quote(input.display()),
ps_quote(binary.display())
);
let mut command = pwsh_command(script);
command
.assert()
.success()
.stdout(predicate::str::contains("\"selected_chunk\""))
.stdout(predicate::str::contains("\"index\":1"));
}
#[test]
fn utf8_bom_file_does_not_pollute_first_chunk_line() {
let (_dir, path) = temp_file("bom.txt", "\u{feff}alpha\nbeta\n");
let mut command = cargo_command();
command
.arg(&path)
.arg("--max-lines")
.arg("2")
.arg("--chunk")
.arg("0")
.assert()
.success()
.stdout(predicate::str::contains("1: alpha"))
.stdout(predicate::str::contains("\u{feff}alpha").not());
}
#[test]
fn invalid_utf8_file_reports_text_read_error() {
let (_dir, path) = temp_file("invalid-utf8.txt", [0x61, 0x80, 0x0A]);
let mut command = cargo_command();
command
.arg(&path)
.arg("--max-lines")
.arg("2")
.arg("--chunk")
.arg("0")
.assert()
.failure()
.stderr(predicate::str::contains("looks like a binary file"))
.stderr(predicate::str::contains("invalid-utf8.txt"));
}
#[test]
fn help_includes_examples_and_pipeline_usage() {
let mut command = cargo_command();
command
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains(
"chunkcat .\\fixtures\\reading\\sample.rs --max-lines 8",
))
.stdout(predicate::str::contains("--inventory"))
.stdout(predicate::str::contains("ConvertFrom-Json"))
.stdout(predicate::str::contains("--chunk"));
}
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "cjson"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "Compact JSON and JSONL into stable single-line output."
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[dependencies]
common = { path = "../common", default-features = false }
lexopt.workspace = true
serde.workspace = true
serde_json = { workspace = true, features = ["preserve_order"] }
[dev-dependencies]
assert_cmd.workspace = true
predicates.workspace = true
+829
View File
@@ -0,0 +1,829 @@
//! The `cjson` command compacts JSON and JSONL.
use std::ffi::OsString;
use std::fs;
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use common::{
CliError, CommonArgs, ExitCode, InputFormat, RenderMode, parse_color_choice,
parse_format_choice, parse_input_format, print_json, print_quick_help_error, print_structured,
read_existing_stdin_paths, should_read_stdin,
};
use lexopt::prelude::{Long, Short, Value as ArgValue};
use serde::Serialize;
use serde_json::Value;
const MAX_JSON_INPUT_BYTES: u64 = 64 * 1024 * 1024;
const MAX_SORT_DEPTH: usize = 512;
const HELP: &str = "\
Compact JSON and JSONL into single-line output with optional recursive key sorting.
Usage:
cjson [OPTIONS] [PATH]
Options:
--format <FORMAT> Structured output format: text, json, toon
--json Shortcut for --format json
--toon Shortcut for --format toon
--input-format <FORMAT> Override stdin parsing mode: auto, json, lines, jsonl
--color <WHEN> Control ANSI color output: auto, never
--quiet Suppress non-essential status output
--sort-keys Sort object keys recursively before rendering
-h, --help Show this help text
-V, --version Show the command version
Examples:
cjson .\\fixtures\\cjson\\sample.json
bat --style=plain --paging=never .\\fixtures\\cjson\\records.jsonl | cjson --input-format jsonl --sort-keys
bat --style=plain --paging=never .\\fixtures\\cjson\\sample.json | cjson --input-format json
'.\\fixtures\\cjson\\sample.json' | cjson --input-format lines
cjson --sort-keys --json .\\fixtures\\cjson\\sample.json | ConvertFrom-Json
";
#[derive(Debug, Clone)]
struct Cli {
common: CommonArgs,
sort_keys: bool,
path: Option<PathBuf>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ParseOutcome {
Help,
Version,
Run,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CompactFormat {
Json,
Jsonl,
}
#[derive(Debug, Clone, PartialEq)]
struct ParsedDocuments {
format: CompactFormat,
documents: Vec<Value>,
skipped_empty: usize,
skipped_empty_paths: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct CompactJsonPayload {
format: &'static str,
documents: usize,
skipped_empty: usize,
skipped_empty_paths: Vec<String>,
text: String,
}
#[derive(Debug, Clone, PartialEq)]
enum LoadedInput {
Content(String),
Paths(Vec<PathBuf>),
}
/// Parses CLI arguments and returns a process exit code.
#[must_use]
pub fn main_entry() -> i32 {
match parse_cli_from(std::env::args_os()) {
Ok((ParseOutcome::Help, _)) => {
print!("{HELP}");
ExitCode::Success.as_i32()
}
Ok((ParseOutcome::Version, _)) => {
println!("cjson {}", env!("CARGO_PKG_VERSION"));
ExitCode::Success.as_i32()
}
Ok((ParseOutcome::Run, cli)) => match run(&cli) {
Ok(code) => code.as_i32(),
Err(error) => {
print_quick_help_error(&error, HELP);
error.exit_code().as_i32()
}
},
Err(error) => {
print_quick_help_error(&error, HELP);
error.exit_code().as_i32()
}
}
}
fn parse_cli_from<I, T>(args: I) -> Result<(ParseOutcome, Cli), CliError>
where
I: IntoIterator<Item = T>,
T: Into<OsString>,
{
let mut parser = lexopt::Parser::from_iter(args);
let mut cli = Cli {
common: CommonArgs::default(),
sort_keys: false,
path: None,
};
while let Some(argument) = parser
.next()
.map_err(|error| CliError::usage(error.to_string()))?
{
match argument {
Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)),
Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)),
Long("json") => cli.common.set_render_mode(RenderMode::Json),
Long("toon") => cli.common.set_render_mode(RenderMode::Toon),
Long("format") => {
let value = parser_value_string(&mut parser, "--format")?;
cli.common.set_render_mode(parse_format_choice(&value)?);
}
Long("input-format") => {
let value = parser_value_string(&mut parser, "--input-format")?;
cli.common.input_format = parse_cjson_input_format(&value)?;
}
Long("color") => {
let value = parser_value_string(&mut parser, "--color")?;
cli.common.color = parse_color_choice(&value)?;
}
Long("quiet") => cli.common.quiet = true,
Long("sort-keys") => cli.sort_keys = true,
ArgValue(path) => {
if cli.path.replace(PathBuf::from(path)).is_some() {
return Err(CliError::usage("cjson accepts at most one explicit path"));
}
}
_ => {
return Err(CliError::usage(
"unsupported argument; use --help to see available options",
));
}
}
}
Ok((ParseOutcome::Run, cli))
}
fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result<String, CliError> {
let value = parser
.value()
.map_err(|error| CliError::usage(error.to_string()))?;
value.into_string().map_err(|invalid| {
CliError::usage(format!(
"{flag} expects UTF-8 text, got '{}'",
invalid.to_string_lossy()
))
})
}
fn run(cli: &Cli) -> Result<ExitCode, CliError> {
let input = load_input(cli)?;
let parsed = match input {
LoadedInput::Content(content) => {
let input_format =
if cli.path.is_some() && cli.common.input_format == InputFormat::Lines {
InputFormat::Auto
} else {
cli.common.input_format
};
parse_documents(&content, input_format, None)?
}
LoadedInput::Paths(paths) => parse_documents_from_paths(&paths)?,
};
let document_count = parsed.documents.len();
let text = compact_documents(parsed.documents, parsed.format, cli.sort_keys)?;
match cli.common.render_mode() {
RenderMode::Json => print_json(&CompactJsonPayload {
format: parsed.format.as_str(),
documents: document_count,
skipped_empty: parsed.skipped_empty,
skipped_empty_paths: parsed.skipped_empty_paths,
text,
})?,
RenderMode::Toon => print_structured(
&CompactJsonPayload {
format: parsed.format.as_str(),
documents: document_count,
skipped_empty: parsed.skipped_empty,
skipped_empty_paths: parsed.skipped_empty_paths,
text,
},
RenderMode::Toon,
)?,
RenderMode::Text => {
write_text_output(&text)?;
emit_skipped_empty_note(
parsed.skipped_empty,
&parsed.skipped_empty_paths,
cli.common.quiet,
);
}
}
Ok(ExitCode::Success)
}
fn load_input(cli: &Cli) -> Result<LoadedInput, CliError> {
if should_read_stdin(cli.path.is_some(), cli.common.stdin_is_terminal()) {
let buffer = read_to_string_limited(io::stdin(), MAX_JSON_INPUT_BYTES, "stdin")?;
if !buffer.is_empty() {
if cli.common.input_format != InputFormat::Jsonl
&& let Some(paths) =
read_existing_stdin_paths(&buffer, cli.common.input_format, "cjson")?
{
return Ok(LoadedInput::Paths(paths));
}
return Ok(LoadedInput::Content(buffer));
}
}
let Some(path) = &cli.path else {
return Err(CliError::usage(
"provide one JSON path or pipe JSON/JSONL into stdin",
));
};
Ok(LoadedInput::Paths(common::expand_input_patterns(
std::slice::from_ref(path),
"cjson",
)?))
}
fn parse_documents_from_paths(paths: &[PathBuf]) -> Result<ParsedDocuments, CliError> {
let mut documents = Vec::new();
let mut skipped_empty = 0_usize;
let mut skipped_empty_paths = Vec::new();
let mut format = if paths.len() > 1 {
CompactFormat::Jsonl
} else {
CompactFormat::Json
};
for path in paths {
let content = read_path_to_string_limited(path, MAX_JSON_INPUT_BYTES)?;
if paths.len() > 1 && content.trim().is_empty() {
skipped_empty += 1;
skipped_empty_paths.push(path.display().to_string());
continue;
}
let parsed = parse_documents(&content, InputFormat::Auto, Some(path))?;
if paths.len() == 1 {
format = parsed.format;
}
documents.extend(parsed.documents);
skipped_empty += parsed.skipped_empty;
skipped_empty_paths.extend(parsed.skipped_empty_paths);
}
if documents.is_empty() {
if skipped_empty > 0 {
return Err(CliError::runtime(format!(
"all {skipped_empty} JSON input path(s) were empty"
)));
}
return Err(CliError::runtime("JSON input is empty"));
}
Ok(ParsedDocuments {
format,
documents,
skipped_empty,
skipped_empty_paths,
})
}
fn read_path_to_string_limited(path: &Path, max_bytes: u64) -> Result<String, CliError> {
let metadata = fs::metadata(path).map_err(|error| {
CliError::runtime(format!(
"failed to read metadata for {}: {error}",
path.display()
))
})?;
if metadata.len() > max_bytes {
return Err(CliError::runtime(format!(
"{} is {} byte(s), above the cjson input limit of {max_bytes} byte(s)",
path.display(),
metadata.len()
)));
}
fs::read_to_string(path)
.map_err(|error| CliError::runtime(format!("failed to read {}: {error}", path.display())))
}
fn read_to_string_limited<R: Read>(
reader: R,
max_bytes: u64,
label: &str,
) -> Result<String, CliError> {
let mut limited = reader.take(max_bytes.saturating_add(1));
let mut buffer = String::new();
limited
.read_to_string(&mut buffer)
.map_err(|error| CliError::runtime(format!("failed to read {label}: {error}")))?;
if buffer.len() as u64 > max_bytes {
return Err(CliError::runtime(format!(
"{label} exceeds the cjson input limit of {max_bytes} byte(s)"
)));
}
Ok(buffer)
}
fn parse_documents(
input: &str,
input_format: InputFormat,
source_path: Option<&std::path::Path>,
) -> Result<ParsedDocuments, CliError> {
if input.trim().is_empty() {
return Err(empty_input_error("JSON", source_path));
}
match input_format {
InputFormat::Auto => parse_auto_documents(input, source_path),
InputFormat::Jsonl => parse_jsonl_documents(input, source_path),
InputFormat::Lines => Err(CliError::usage(
"cjson does not support --input-format lines; use auto or jsonl",
)),
}
}
fn parse_cjson_input_format(value: &str) -> Result<InputFormat, CliError> {
if value.eq_ignore_ascii_case("json") {
Ok(InputFormat::Auto)
} else {
parse_input_format(value)
}
}
fn parse_auto_documents(
input: &str,
source_path: Option<&Path>,
) -> Result<ParsedDocuments, CliError> {
if source_path.is_some_and(has_jsonl_extension) {
return parse_jsonl_documents(input, source_path);
}
let trimmed = input.trim();
match serde_json::from_str::<Value>(trimmed) {
Ok(document) => Ok(ParsedDocuments {
format: CompactFormat::Json,
documents: vec![document],
skipped_empty: 0,
skipped_empty_paths: Vec::new(),
}),
Err(json_error) => {
if source_path.is_some_and(has_json_extension) {
return Err(invalid_json_input_error(&json_error));
}
let mut non_empty_lines = input.lines().map(str::trim).filter(|line| !line.is_empty());
if non_empty_lines.next().is_some() && non_empty_lines.next().is_some() {
match parse_jsonl_documents(input, source_path) {
Ok(parsed) => return Ok(parsed),
Err(line_stream_error) => {
if looks_like_json_document(trimmed) {
return Err(invalid_json_input_error(&json_error));
}
return Err(line_stream_error);
}
}
}
if looks_like_json_document(trimmed) {
return Err(invalid_json_input_error(&json_error));
}
parse_jsonl_documents(input, source_path)
}
}
}
fn parse_jsonl_documents(
input: &str,
source_path: Option<&Path>,
) -> Result<ParsedDocuments, CliError> {
let mut documents = Vec::new();
for (index, line) in input.lines().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let document = serde_json::from_str::<Value>(trimmed).map_err(|error| {
CliError::runtime(format!("invalid JSONL at line {}: {error}", index + 1))
})?;
documents.push(document);
}
if documents.is_empty() {
return Err(empty_input_error("JSONL", source_path));
}
Ok(ParsedDocuments {
format: CompactFormat::Jsonl,
documents,
skipped_empty: 0,
skipped_empty_paths: Vec::new(),
})
}
fn empty_input_error(kind: &str, source_path: Option<&Path>) -> CliError {
source_path.map_or_else(
|| CliError::runtime(format!("{kind} input is empty")),
|path| CliError::runtime(format!("{kind} input is empty: {}", path.display())),
)
}
fn compact_documents(
documents: Vec<Value>,
format: CompactFormat,
sort_keys: bool,
) -> Result<String, CliError> {
match format {
CompactFormat::Json => compact_single_document(documents, sort_keys),
CompactFormat::Jsonl => compact_jsonl_documents(documents, sort_keys),
}
}
fn compact_single_document(documents: Vec<Value>, sort_keys: bool) -> Result<String, CliError> {
let mut iter = documents.into_iter();
let Some(document) = iter.next() else {
return Err(CliError::runtime(
"internal error: missing JSON document for compaction",
));
};
if iter.next().is_some() {
return Err(CliError::runtime(
"internal error: JSON compaction received multiple documents",
));
}
serialize_document(document, sort_keys)
}
fn compact_jsonl_documents(documents: Vec<Value>, sort_keys: bool) -> Result<String, CliError> {
let mut rendered = String::with_capacity(documents.len().saturating_mul(96));
for (index, document) in documents.into_iter().enumerate() {
if index > 0 {
rendered.push('\n');
}
rendered.push_str(&serialize_document(document, sort_keys)?);
}
Ok(rendered)
}
fn serialize_document(mut document: Value, sort_keys: bool) -> Result<String, CliError> {
if sort_keys {
sort_value(&mut document, 0)?;
}
serde_json::to_string(&document)
.map_err(|error| CliError::runtime(format!("failed to render JSON: {error}")))
}
fn sort_value(value: &mut Value, depth: usize) -> Result<(), CliError> {
if depth > MAX_SORT_DEPTH {
return Err(CliError::runtime(format!(
"JSON nesting exceeds cjson --sort-keys limit of {MAX_SORT_DEPTH}"
)));
}
match value {
Value::Object(map) => {
let mut entries = std::mem::take(map).into_iter().collect::<Vec<_>>();
entries.sort_unstable_by(|left, right| left.0.cmp(&right.0));
for (key, mut child) in entries {
sort_value(&mut child, depth + 1)?;
let _ = map.insert(key, child);
}
}
Value::Array(items) => {
for item in items {
sort_value(item, depth + 1)?;
}
}
Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
}
Ok(())
}
fn write_text_output(text: &str) -> Result<(), CliError> {
let mut stdout = io::stdout().lock();
stdout
.write_all(text.as_bytes())
.map_err(|error| CliError::runtime(format!("failed to write stdout: {error}")))?;
stdout
.write_all(b"\n")
.map_err(|error| CliError::runtime(format!("failed to write stdout: {error}")))
}
fn emit_skipped_empty_note(skipped_empty: usize, skipped_empty_paths: &[String], quiet: bool) {
if quiet || skipped_empty == 0 {
return;
}
let preview = skipped_empty_paths
.iter()
.take(3)
.cloned()
.collect::<Vec<_>>()
.join(", ");
let suffix = if skipped_empty_paths.len() > 3 {
format!(" (+{} more)", skipped_empty_paths.len() - 3)
} else {
String::new()
};
eprintln!("note: skipped {skipped_empty} empty JSON input path(s): {preview}{suffix}");
}
impl CompactFormat {
const fn as_str(self) -> &'static str {
match self {
Self::Json => "json",
Self::Jsonl => "jsonl",
}
}
}
fn has_json_extension(path: &Path) -> bool {
has_extension(path, "json")
}
fn has_jsonl_extension(path: &Path) -> bool {
has_extension(path, "jsonl") || has_extension(path, "ndjson")
}
fn has_extension(path: &Path, expected: &str) -> bool {
path.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case(expected))
}
fn looks_like_json_document(input: &str) -> bool {
input.starts_with('{')
|| input.starts_with('[')
|| input.starts_with('"')
|| matches!(input.as_bytes().first(), Some(b'-' | b'0'..=b'9'))
|| input == "true"
|| input == "false"
|| input == "null"
}
fn invalid_json_input_error(error: &serde_json::Error) -> CliError {
CliError::runtime(format!("invalid JSON input: {error}"))
}
#[cfg(test)]
mod tests {
use common::{ColorChoice, InputFormat};
use serde_json::json;
use super::*;
fn common_args(json: bool, input_format: InputFormat) -> CommonArgs {
CommonArgs {
json,
format: None,
input_format,
color: ColorChoice::Never,
quiet: false,
}
}
#[test]
fn parse_documents_supports_auto_json_and_jsonl() {
let single = parse_documents(
"{\n \"z\": 3,\n \"a\": {\"y\": 2, \"x\": 1}\n}\n",
InputFormat::Auto,
None,
)
.expect("single JSON document");
assert_eq!(
single,
ParsedDocuments {
format: CompactFormat::Json,
documents: vec![json!({"z": 3, "a": {"y": 2, "x": 1}})],
skipped_empty: 0,
skipped_empty_paths: Vec::new(),
}
);
let stream = parse_documents(
"{\"ok\":true,\"event\":\"login\"}\n{\"ok\":false,\"event\":\"logout\"}\n",
InputFormat::Jsonl,
None,
)
.expect("jsonl documents");
assert_eq!(stream.format, CompactFormat::Jsonl);
assert_eq!(
stream.documents,
vec![
json!({"ok": true, "event": "login"}),
json!({"ok": false, "event": "logout"}),
]
);
}
#[test]
fn parse_auto_jsonl_preserves_invalid_json_precedence() {
let stream = parse_documents(
"{\"ok\":true,\"event\":\"login\"}\n\n{\"ok\":false,\"event\":\"logout\"}\n",
InputFormat::Auto,
None,
)
.expect("auto jsonl documents");
assert_eq!(stream.format, CompactFormat::Jsonl);
assert_eq!(
stream.documents,
vec![
json!({"ok": true, "event": "login"}),
json!({"ok": false, "event": "logout"}),
]
);
let invalid_json = parse_documents("{\"ok\": true}\nnot-json\n", InputFormat::Auto, None)
.expect_err("json-looking input should prefer JSON error");
assert!(matches!(
invalid_json,
CliError::Runtime(message)
if message.contains("invalid JSON input")
&& !message.contains("JSONL")
));
let invalid_jsonl = parse_documents("not-json\n{\"ok\":true}\n", InputFormat::Auto, None)
.expect_err("non-json-looking input should report JSONL line error");
assert!(matches!(
invalid_jsonl,
CliError::Runtime(message)
if message.contains("invalid JSONL at line 1")
));
}
#[test]
fn parse_documents_rejects_lines_mode_and_empty_input() {
let lines_error =
parse_documents("{\"ok\":true}\n", InputFormat::Lines, None).expect_err("lines mode");
assert!(matches!(
lines_error,
CliError::Usage(message)
if message.contains("does not support --input-format lines")
));
let empty_error =
parse_documents(" \n\t", InputFormat::Auto, None).expect_err("empty input");
assert!(matches!(
empty_error,
CliError::Runtime(message)
if message.contains("JSON input is empty")
));
let invalid_json = parse_documents("{\"ok\": true,,}\n", InputFormat::Auto, None)
.expect_err("invalid json should fail");
assert!(matches!(
invalid_json,
CliError::Runtime(message)
if message.contains("invalid JSON input")
&& !message.contains("JSONL")
));
}
#[test]
fn cjson_input_format_accepts_json_alias() {
assert_eq!(
parse_cjson_input_format("json").expect("json alias"),
InputFormat::Auto
);
assert_eq!(
parse_cjson_input_format("jsonl").expect("jsonl"),
InputFormat::Jsonl
);
}
#[test]
fn input_readers_reject_payloads_above_size_limit() {
let error = read_to_string_limited(std::io::Cursor::new("abcd"), 3, "stdin")
.expect_err("oversize stdin rejected");
assert!(error.to_string().contains("cjson input limit"));
let temp = std::env::temp_dir().join(format!(
"cjson-large-input-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_nanos()
));
std::fs::write(&temp, "abcd").expect("fixture");
let error = read_path_to_string_limited(&temp, 3).expect_err("oversize file rejected");
assert!(error.to_string().contains("cjson input limit"));
std::fs::remove_file(temp).expect("cleanup");
}
#[test]
fn sort_keys_rejects_extreme_json_nesting() {
let mut value = json!(true);
for _ in 0..(MAX_SORT_DEPTH + 2) {
value = json!({ "child": value });
}
let error = serialize_document(value, true).expect_err("deep sort rejected");
assert!(error.to_string().contains("sort-keys limit"));
}
#[test]
fn compaction_can_sort_keys_recursively() {
let rendered = compact_documents(
vec![json!({
"z": 3,
"a": {"y": 2, "x": 1},
"items": [{"b": 2, "a": 1}],
"name": "Ada",
})],
CompactFormat::Json,
true,
)
.expect("sorted compaction");
assert_eq!(
rendered,
"{\"a\":{\"x\":1,\"y\":2},\"items\":[{\"a\":1,\"b\":2}],\"name\":\"Ada\",\"z\":3}"
);
}
#[test]
fn run_supports_text_and_json_wrapper_modes() {
let text_exit = run(&Cli {
common: common_args(false, InputFormat::Auto),
sort_keys: false,
path: Some(
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("fixtures")
.join("cjson")
.join("sample.json"),
),
})
.expect("text run");
assert_eq!(text_exit, ExitCode::Success);
let json_exit = run(&Cli {
common: common_args(true, InputFormat::Jsonl),
sort_keys: true,
path: Some(
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("fixtures")
.join("cjson")
.join("records.jsonl"),
),
})
.expect("json run");
assert_eq!(json_exit, ExitCode::Success);
}
#[test]
fn load_input_accepts_multiple_stdin_paths() {
let temp = std::env::temp_dir().join(format!(
"cjson-stdin-paths-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_nanos()
));
std::fs::create_dir_all(&temp).expect("tempdir");
let sample = temp.join("sample.json");
let second = temp.join("second.json");
std::fs::write(&sample, "{\"ok\":true}\n").expect("sample");
std::fs::write(&second, "{\"ok\":false}\n").expect("second");
let loaded_input = load_input_from_buffer(
&Cli {
common: common_args(false, InputFormat::Lines),
sort_keys: false,
path: None,
},
&format!("{}\n{}\n", sample.display(), second.display()),
)
.expect("stdin paths");
let LoadedInput::Paths(paths) = loaded_input else {
panic!("expected path stream input");
};
let loaded = parse_documents_from_paths(&paths).expect("parsed");
assert_eq!(loaded.format, CompactFormat::Jsonl);
assert_eq!(loaded.documents.len(), 2);
std::fs::remove_dir_all(temp).expect("cleanup");
}
fn load_input_from_buffer(cli: &Cli, buffer: &str) -> Result<LoadedInput, CliError> {
if cli.common.input_format != InputFormat::Jsonl
&& let Some(paths) =
read_existing_stdin_paths(buffer, cli.common.input_format, "cjson")?
{
return Ok(LoadedInput::Paths(paths));
}
Ok(LoadedInput::Content(buffer.to_string()))
}
}
+5
View File
@@ -0,0 +1,5 @@
//! Binary entry point for `cjson`.
fn main() {
std::process::exit(cjson::main_entry());
}
+247
View File
@@ -0,0 +1,247 @@
//! Integration tests for the `cjson` command.
use std::{
fs,
path::PathBuf,
sync::atomic::{AtomicU64, Ordering},
};
use assert_cmd::Command;
use predicates::prelude::*;
use serde_json::Value;
fn cargo_command() -> Command {
Command::cargo_bin("cjson").expect("binary")
}
fn cargo_binary() -> PathBuf {
assert_cmd::cargo::cargo_bin("cjson")
}
fn powershell_command(script: String) -> Command {
let mut command = Command::new("pwsh");
command.args(["-NoProfile", "-Command"]).arg(script);
command
}
fn ps_quote(value: impl std::fmt::Display) -> String {
format!("'{}'", value.to_string().replace('\'', "''"))
}
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
}
fn fixture(path: &str) -> PathBuf {
let fixture = workspace_root().join("fixtures").join(path);
assert!(
fixture.exists(),
"missing fixture `{path}` at {}",
fixture.display()
);
fixture
}
fn json_stdout(output: &[u8]) -> Value {
serde_json::from_slice(output).unwrap_or_else(|error| {
panic!(
"stdout should be valid JSON: {error}\n{}",
String::from_utf8_lossy(output)
)
})
}
struct TempTestDir {
path: PathBuf,
}
impl TempTestDir {
fn path(&self) -> &std::path::Path {
&self.path
}
}
impl Drop for TempTestDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
fn temp_test_dir(name: &str) -> TempTestDir {
static NEXT_ID: AtomicU64 = AtomicU64::new(0);
loop {
let unique = NEXT_ID.fetch_add(1, Ordering::Relaxed);
let path =
std::env::temp_dir().join(format!("cjson-{}-{name}-{unique}", std::process::id()));
match fs::create_dir(&path) {
Ok(()) => return TempTestDir { path },
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(error) => panic!("temp test dir: {error}"),
}
}
}
#[test]
fn compacts_json_file_in_text_mode() {
let output = cargo_command()
.arg(fixture("cjson/sample.json"))
.assert()
.success()
.get_output()
.stdout
.clone();
let compacted: Value = json_stdout(&output);
assert_eq!(
compacted["name"],
"Ada",
"stdout={}",
String::from_utf8_lossy(&output)
);
assert_eq!(
compacted["a"]["x"],
1,
"stdout={}",
String::from_utf8_lossy(&output)
);
}
#[test]
fn sort_keys_reorders_objects_recursively() {
cargo_command()
.arg("--sort-keys")
.write_stdin(
"{\"z\":3,\"a\":{\"y\":2,\"x\":1},\"items\":[{\"b\":2,\"a\":1}],\"name\":\"Ada\"}",
)
.assert()
.success()
.stdout(
"{\"a\":{\"x\":1,\"y\":2},\"items\":[{\"a\":1,\"b\":2}],\"name\":\"Ada\",\"z\":3}\n",
);
}
#[test]
fn explicit_path_wins_over_piped_stdin_noise() {
cargo_command()
.arg(fixture("cjson/sample.json"))
.write_stdin("not json from upstream pipeline")
.assert()
.success()
.stdout(predicate::str::contains("\"name\":\"Ada\""));
}
#[test]
fn json_wrapper_reports_jsonl_documents() {
let output = cargo_command()
.arg("--input-format")
.arg("jsonl")
.arg("--sort-keys")
.arg("--json")
.arg(fixture("cjson/records.jsonl"))
.assert()
.success()
.get_output()
.stdout
.clone();
let payload = json_stdout(&output);
assert_eq!(
payload["format"],
"jsonl",
"stdout={}",
String::from_utf8_lossy(&output)
);
assert_eq!(
payload["documents"],
2,
"stdout={}",
String::from_utf8_lossy(&output)
);
assert!(
payload["text"]
.as_str()
.is_some_and(|text| text.contains("\"event\":\"login\",\"ok\":true")),
"stdout={}",
String::from_utf8_lossy(&output)
);
}
#[test]
fn auto_mode_treats_explicit_jsonl_and_ndjson_paths_as_line_streams() {
let temp = temp_test_dir("auto-jsonl-paths");
let jsonl = temp.path().join("single.jsonl");
let ndjson = temp.path().join("single.ndjson");
fs::write(&jsonl, "{\"ok\":true}\n").expect("jsonl fixture");
fs::write(&ndjson, "{\"ok\":true}\n").expect("ndjson fixture");
for path in [&jsonl, &ndjson] {
let mut command = cargo_command();
command
.arg("--json")
.arg(path)
.assert()
.success()
.stdout(predicate::str::contains("\"format\":\"jsonl\""))
.stdout(predicate::str::contains("\"documents\":1"));
}
}
#[test]
fn supports_powershell_pipeline() {
let binary = cargo_binary();
let input = fixture("cjson/records.jsonl");
let script = format!(
"[System.IO.File]::ReadLines({}) | & {} --input-format jsonl --sort-keys",
ps_quote(input.display()),
ps_quote(binary.display())
);
powershell_command(script)
.assert()
.success()
.stdout(predicate::str::contains(
"{\"event\":\"login\",\"ok\":true}",
))
.stdout(predicate::str::contains(
"{\"event\":\"logout\",\"ok\":false}",
));
}
#[test]
fn accepts_single_stdin_path_stream_in_lines_mode() {
cargo_command()
.args(["--input-format", "lines"])
.write_stdin(format!("{}\n", fixture("cjson/sample.json").display()))
.assert()
.success()
.stdout(predicate::str::contains("\"name\":\"Ada\""));
}
#[test]
fn lines_mode_accepts_windows_paths_with_quotes_and_spaces() {
let temp = temp_test_dir("quoted path");
let path = temp.path().join("Ada's sample.json");
fs::write(&path, "{\"name\":\"Ada\",\"ok\":true}\n").expect("quoted path fixture");
cargo_command()
.args(["--input-format", "lines"])
.write_stdin(format!("{}\n", path.display()))
.assert()
.success()
.stdout(predicate::str::contains("\"name\":\"Ada\""));
}
#[test]
fn help_includes_examples_and_sort_keys_flag() {
cargo_command()
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("--sort-keys"))
.stdout(predicate::str::contains(
"bat --style=plain --paging=never .\\fixtures\\cjson\\records.jsonl",
))
.stdout(predicate::str::contains("ConvertFrom-Json"));
}
+49
View File
@@ -0,0 +1,49 @@
[package]
name = "codeindex"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "Shared tree-sitter-based code indexing for Mercury Toolbox."
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[features]
default = ["all-languages"]
all-languages = [
"lang-rust",
"lang-csharp",
"lang-powershell",
"lang-python",
"lang-go",
"lang-java",
"lang-javascript",
"lang-typescript",
]
lang-rust = ["dep:tree-sitter-rust"]
lang-csharp = ["dep:tree-sitter-c-sharp"]
lang-powershell = ["dep:tree-sitter-powershell"]
lang-python = ["dep:tree-sitter-python"]
lang-go = ["dep:tree-sitter-go"]
lang-java = ["dep:tree-sitter-java"]
lang-javascript = ["dep:tree-sitter-javascript"]
lang-typescript = ["dep:tree-sitter-typescript"]
[dependencies]
common = { path = "../common", default-features = false }
serde.workspace = true
tree-sitter.workspace = true
tree-sitter-c-sharp = { workspace = true, optional = true }
tree-sitter-go = { workspace = true, optional = true }
tree-sitter-javascript = { workspace = true, optional = true }
tree-sitter-java = { workspace = true, optional = true }
tree-sitter-powershell = { workspace = true, optional = true }
tree-sitter-python = { workspace = true, optional = true }
tree-sitter-rust = { workspace = true, optional = true }
tree-sitter-typescript = { workspace = true, optional = true }
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "codeshape"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "Emit recursive AST-backed codebase maps with compact signatures."
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[dependencies]
codeindex = { path = "../codeindex" }
common = { path = "../common", default-features = false }
lexopt.workspace = true
serde.workspace = true
[dev-dependencies]
assert_cmd.workspace = true
predicates.workspace = true
+700
View File
@@ -0,0 +1,700 @@
//! The `codeshape` command emits recursive AST-backed project maps.
use codeindex::{
CodeIndexer, CodeLanguage, ENGINE_NAME, IndexedSymbol, SUPPORTED_LANGUAGE_LIST, SymbolKind,
detect_language, parse_language_label,
};
use common::{
CliError, CommonArgs, ExitCode, InputFormat, RenderMode, collect_matching_files,
parse_color_choice, parse_format_choice, parse_input_format, print_json,
print_quick_help_error, print_structured, should_read_stdin,
};
use lexopt::prelude::{Long, Short, Value as ArgValue};
use serde::Serialize;
use std::collections::BTreeSet;
use std::ffi::OsString;
use std::fmt::Write as _;
use std::fs;
use std::io::{self, Read};
use std::path::{Path, PathBuf};
const MAX_SOURCE_BYTES: u64 = 8 * 1024 * 1024;
const HELP: &str = "\
Emit recursive AST-backed codebase maps with compact signatures via the shared codeindex engine.
Usage:
codeshape [OPTIONS] [PATH...]
Options:
--format <FORMAT> Structured output format: text, json, toon
--json Shortcut for --format json
--toon Shortcut for --format toon
--input-format <FORMAT> Override stdin parsing mode: auto, lines, jsonl
--color <WHEN> Control ANSI color output: auto, never
--quiet Suppress non-essential status output
--lang <LIST> Restrict languages: rust,csharp,powershell,python,go,java,javascript,typescript
--max-files <COUNT> Maximum number of files to include
--max-depth <COUNT> Maximum symbol depth to include
--limit-per-file <COUNT> Maximum symbols to include per file after depth filtering
-h, --help Show this help text
-V, --version Show the command version
Examples:
codeshape .\\fixtures\\polyglot\\repo
codeshape --max-depth 1 --limit-per-file 8 . --json | ConvertFrom-Json
'.\\fixtures\\polyglot\\repo' | codeshape --json | ConvertFrom-Json
JSON fields:
engine, roots, files[].path, files[].language, files[].items[], totals
";
#[derive(Debug, Clone)]
struct Cli {
common: CommonArgs,
paths: Vec<PathBuf>,
languages: Option<BTreeSet<CodeLanguage>>,
max_files: usize,
max_depth: Option<usize>,
limit_per_file: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ParseOutcome {
Help,
Version,
Run,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct CodeShapeItem {
kind: SymbolKind,
name: String,
qualified_name: String,
signature: String,
depth: usize,
start_line: usize,
end_line: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct FileReport {
path: String,
language: CodeLanguage,
items: Vec<CodeShapeItem>,
omitted_items: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct Totals {
files_seen: usize,
files_indexed: usize,
files_omitted_by_limit: usize,
symbols_emitted: usize,
symbols_omitted: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct CodeShapeReport {
engine: &'static str,
roots: Vec<String>,
files: Vec<FileReport>,
totals: Totals,
}
/// Parses CLI arguments and returns a process exit code.
#[must_use]
pub fn main_entry() -> i32 {
match parse_cli_from(std::env::args_os()) {
Ok((ParseOutcome::Help, _)) => {
print!("{HELP}");
ExitCode::Success.as_i32()
}
Ok((ParseOutcome::Version, _)) => {
println!("codeshape {}", env!("CARGO_PKG_VERSION"));
ExitCode::Success.as_i32()
}
Ok((ParseOutcome::Run, cli)) => match run(&cli) {
Ok(code) => code.as_i32(),
Err(error) => {
print_quick_help_error(&error, HELP);
error.exit_code().as_i32()
}
},
Err(error) => {
print_quick_help_error(&error, HELP);
error.exit_code().as_i32()
}
}
}
#[allow(
clippy::too_many_lines,
reason = "single-pass CLI parsing keeps global and shared output flags auditable"
)]
fn parse_cli_from<I, T>(args: I) -> Result<(ParseOutcome, Cli), CliError>
where
I: IntoIterator<Item = T>,
T: Into<OsString>,
{
let mut parser = lexopt::Parser::from_iter(args);
let mut common = CommonArgs::default();
let mut paths = Vec::new();
let mut languages = None::<BTreeSet<CodeLanguage>>;
let mut max_files = 200_usize;
let mut max_depth = None::<usize>;
let mut limit_per_file = 32_usize;
while let Some(argument) = parser
.next()
.map_err(|error| CliError::usage(error.to_string()))?
{
match argument {
Long("help") | Short('h') => {
return Ok((
ParseOutcome::Help,
Cli {
common,
paths,
languages,
max_files,
max_depth,
limit_per_file,
},
));
}
Long("version") | Short('V') => {
return Ok((
ParseOutcome::Version,
Cli {
common,
paths,
languages,
max_files,
max_depth,
limit_per_file,
},
));
}
Long("json") => common.set_render_mode(RenderMode::Json),
Long("toon") => common.set_render_mode(RenderMode::Toon),
Long("format") => {
let value = parser_value_string(&mut parser, "--format")?;
common.set_render_mode(parse_format_choice(&value)?);
}
Long("input-format") => {
common.input_format =
parse_input_format(&parser_value_string(&mut parser, "--input-format")?)?;
}
Long("color") => {
common.color = parse_color_choice(&parser_value_string(&mut parser, "--color")?)?;
}
Long("quiet") => common.quiet = true,
Long("lang") => {
languages = Some(parse_language_list(&parser_value_string(
&mut parser,
"--lang",
)?)?);
}
Long("max-files") => {
max_files = parse_usize_flag(
"--max-files",
&parser_value_string(&mut parser, "--max-files")?,
)?;
}
Long("max-depth") => {
max_depth = Some(parse_usize_flag(
"--max-depth",
&parser_value_string(&mut parser, "--max-depth")?,
)?);
}
Long("limit-per-file") => {
limit_per_file = parse_usize_flag(
"--limit-per-file",
&parser_value_string(&mut parser, "--limit-per-file")?,
)?;
}
ArgValue(value) => paths.push(PathBuf::from(value)),
_ => {
return Err(CliError::usage(
"unsupported argument; use --help to see available options",
));
}
}
}
if max_files == 0 {
return Err(CliError::usage("--max-files must be greater than 0"));
}
if limit_per_file == 0 {
return Err(CliError::usage("--limit-per-file must be greater than 0"));
}
Ok((
ParseOutcome::Run,
Cli {
common,
paths,
languages,
max_files,
max_depth,
limit_per_file,
},
))
}
fn run(cli: &Cli) -> Result<ExitCode, CliError> {
let report = build_report(cli)?;
match cli.common.render_mode() {
RenderMode::Json => print_json(&report)?,
RenderMode::Toon => print_structured(&report, RenderMode::Toon)?,
RenderMode::Text => print!("{}", render_text(&report)),
}
Ok(if report.files.is_empty() {
ExitCode::NoResults
} else {
ExitCode::Success
})
}
fn build_report(cli: &Cli) -> Result<CodeShapeReport, CliError> {
let roots = collect_roots(cli)?;
let discovered = discover_supported_files(&roots)?;
let mut indexer = CodeIndexer::new();
let mut files = Vec::new();
let mut totals = Totals {
files_seen: discovered.len(),
files_indexed: 0,
files_omitted_by_limit: 0,
symbols_emitted: 0,
symbols_omitted: 0,
};
for path in discovered {
if files.len() >= cli.max_files {
totals.files_omitted_by_limit += 1;
continue;
}
let Some(language) = detect_language(&path) else {
continue;
};
if cli
.languages
.as_ref()
.is_some_and(|languages| !languages.contains(&language))
{
continue;
}
if is_source_too_large(&path, MAX_SOURCE_BYTES)? {
totals.files_omitted_by_limit += 1;
continue;
}
let source = fs::read_to_string(&path).map_err(|error| {
CliError::runtime(format!("failed to read {}: {error}", path.display()))
})?;
let mut items = indexer
.index_source_summary(&path, &source)?
.into_iter()
.filter(|item| cli.max_depth.is_none_or(|depth| item.depth <= depth))
.map(into_codeshape_item)
.collect::<Vec<_>>();
items.sort_unstable_by(|left, right| {
(left.depth, left.start_line, left.name.as_str()).cmp(&(
right.depth,
right.start_line,
right.name.as_str(),
))
});
if items.is_empty() {
continue;
}
let omitted_items = items.len().saturating_sub(cli.limit_per_file);
items.truncate(cli.limit_per_file);
totals.files_indexed += 1;
totals.symbols_emitted += items.len();
totals.symbols_omitted += omitted_items;
files.push(FileReport {
path: path.display().to_string(),
language,
items,
omitted_items,
});
}
Ok(CodeShapeReport {
engine: ENGINE_NAME,
roots: roots
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>(),
files,
totals,
})
}
fn is_source_too_large(path: &Path, max_bytes: u64) -> Result<bool, CliError> {
let metadata = fs::metadata(path).map_err(|error| {
CliError::runtime(format!(
"failed to read metadata for {}: {error}",
path.display()
))
})?;
Ok(metadata.len() > max_bytes)
}
fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result<String, CliError> {
let value = parser
.value()
.map_err(|error| CliError::usage(error.to_string()))?;
value.into_string().map_err(|invalid| {
CliError::usage(format!(
"{flag} expects UTF-8 text, got '{}'",
invalid.to_string_lossy()
))
})
}
fn parse_usize_flag(flag: &str, value: &str) -> Result<usize, CliError> {
value
.parse::<usize>()
.map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}")))
}
fn parse_language_list(value: &str) -> Result<BTreeSet<CodeLanguage>, CliError> {
let mut languages = BTreeSet::new();
for raw in value
.split(',')
.map(str::trim)
.filter(|part| !part.is_empty())
{
let language = parse_language_label(raw).ok_or_else(|| {
CliError::usage(format!(
"invalid --lang entry '{raw}'; expected {SUPPORTED_LANGUAGE_LIST}"
))
})?;
let _ = languages.insert(language);
}
Ok(languages)
}
fn collect_roots(cli: &Cli) -> Result<Vec<PathBuf>, CliError> {
if should_read_stdin(!cli.paths.is_empty(), cli.common.stdin_is_terminal()) {
let mut buffer = String::new();
io::stdin()
.read_to_string(&mut buffer)
.map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?;
let roots = parse_paths_from_string(&buffer, cli.common.input_format)?;
if !roots.is_empty() {
return Ok(roots);
}
}
if cli.paths.is_empty() {
Ok(vec![PathBuf::from(".")])
} else {
common::expand_input_patterns(&cli.paths, "codeshape")
}
}
fn parse_paths_from_string(
buffer: &str,
input_format: InputFormat,
) -> Result<Vec<PathBuf>, CliError> {
common::read_existing_stdin_path_records(buffer, input_format, "codeshape")?
.map_or_else(|| Ok(Vec::new()), Ok)
}
fn discover_supported_files(roots: &[PathBuf]) -> Result<Vec<PathBuf>, CliError> {
collect_matching_files(roots, &|path| detect_language(path).is_some())
}
fn into_codeshape_item(symbol: IndexedSymbol) -> CodeShapeItem {
CodeShapeItem {
kind: symbol.kind,
name: symbol.name,
qualified_name: symbol.qualified_name,
signature: symbol.signature,
depth: symbol.depth,
start_line: symbol.start_line,
end_line: symbol.end_line,
}
}
fn render_text(report: &CodeShapeReport) -> String {
let mut output = String::new();
for file in &report.files {
let _ = writeln!(output, "{} [{}]", file.path, language_label(file.language));
for item in &file.items {
let indent = " ".repeat(item.depth + 1);
let _ = writeln!(
output,
"{}{} {} :: {}",
indent,
kind_label(item.kind),
item.qualified_name,
item.signature,
);
}
if file.omitted_items > 0 {
let _ = writeln!(output, " ... {} more item(s)", file.omitted_items);
}
}
let _ = writeln!(
output,
"totals: files_indexed={} symbols_emitted={} symbols_omitted={} files_omitted_by_limit={}",
report.totals.files_indexed,
report.totals.symbols_emitted,
report.totals.symbols_omitted,
report.totals.files_omitted_by_limit
);
output
}
const fn language_label(language: CodeLanguage) -> &'static str {
match language {
CodeLanguage::Rust
| CodeLanguage::Csharp
| CodeLanguage::Powershell
| CodeLanguage::Python
| CodeLanguage::Go
| CodeLanguage::Java
| CodeLanguage::Javascript
| CodeLanguage::Typescript => language.label(),
}
}
const fn kind_label(kind: SymbolKind) -> &'static str {
match kind {
SymbolKind::Module => "module",
SymbolKind::Namespace => "namespace",
SymbolKind::Class => "class",
SymbolKind::Struct => "struct",
SymbolKind::Enum => "enum",
SymbolKind::Interface => "interface",
SymbolKind::Record => "record",
SymbolKind::Trait => "trait",
SymbolKind::Impl => "impl",
SymbolKind::TypeAlias => "type_alias",
SymbolKind::Function => "function",
SymbolKind::Method => "method",
SymbolKind::Constructor => "constructor",
SymbolKind::Const => "const",
SymbolKind::Static => "static",
}
}
#[cfg(test)]
mod tests {
use super::*;
use common::ColorChoice;
fn fixture_repo() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("fixtures")
.join("polyglot")
.join("repo")
}
fn cli_for(root: PathBuf) -> Cli {
Cli {
common: CommonArgs {
json: false,
format: None,
input_format: InputFormat::Auto,
color: ColorChoice::Never,
quiet: false,
},
paths: vec![root],
languages: None,
max_files: 200,
max_depth: None,
limit_per_file: 32,
}
}
fn sample_symbol() -> IndexedSymbol {
IndexedSymbol {
engine: ENGINE_NAME,
path: "fixture.ts".to_string(),
language: CodeLanguage::Typescript,
kind: SymbolKind::Function,
name: "helper".to_string(),
qualified_name: "web::helper".to_string(),
signature: "export const helper = (name: string) => name.trim();".to_string(),
parents: Vec::new(),
depth: 0,
start_line: 1,
end_line: 1,
text: "export const helper = (name: string) => name.trim();".to_string(),
}
}
#[test]
fn parse_cli_and_lists_cover_help_version_and_validation() {
assert_eq!(
parse_cli_from(["codeshape", "--help"]).expect("help").0,
ParseOutcome::Help
);
assert_eq!(
parse_cli_from(["codeshape", "--version"])
.expect("version")
.0,
ParseOutcome::Version
);
let (_, cli) = parse_cli_from([
"codeshape",
"--json",
"--input-format",
"jsonl",
"--color",
"never",
"--lang",
"rust,java,typescript",
"--max-files",
"4",
"--max-depth",
"1",
"--limit-per-file",
"6",
"fixtures/polyglot/repo",
])
.expect("parsed cli");
assert!(cli.common.json);
assert_eq!(cli.common.input_format, InputFormat::Jsonl);
assert_eq!(cli.common.color, ColorChoice::Never);
assert_eq!(cli.max_files, 4);
assert_eq!(cli.max_depth, Some(1));
assert_eq!(cli.limit_per_file, 6);
assert!(
cli.languages
.as_ref()
.expect("languages")
.contains(&CodeLanguage::Typescript)
&& cli
.languages
.as_ref()
.expect("languages")
.contains(&CodeLanguage::Java)
);
assert!(parse_cli_from(["codeshape", "--max-files", "0"]).is_err());
assert!(parse_cli_from(["codeshape", "--limit-per-file", "0"]).is_err());
assert!(parse_language_list("lua").is_err());
assert!(parse_usize_flag("--max-files", "nope").is_err());
}
#[test]
fn path_parsing_and_discovery_cover_supported_inputs_and_failures() {
let source = PathBuf::from("src/lib.rs");
let manifest = PathBuf::from("Cargo.toml");
let line_paths = parse_paths_from_string("src/lib.rs\nCargo.toml\n", InputFormat::Lines)
.expect("line paths");
assert_eq!(line_paths, vec![manifest.clone(), source.clone()]);
let json_paths = parse_paths_from_string("{\"path\":\"src/lib.rs\"}\n", InputFormat::Jsonl)
.expect("json paths");
assert_eq!(json_paths, vec![source.clone()]);
let auto_paths = parse_paths_from_string("src/lib.rs\nCargo.toml\n", InputFormat::Auto)
.expect("auto paths");
assert_eq!(auto_paths, vec![manifest, source]);
assert!(
discover_supported_files(&[fixture_repo()])
.expect("discover")
.len()
>= 8
);
assert!(discover_supported_files(&[fixture_repo().join("missing")]).is_err());
}
#[test]
fn build_report_skips_supported_sources_above_size_limit() {
let root = std::env::temp_dir().join(format!(
"codeshape-large-source-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_nanos()
));
fs::create_dir_all(&root).expect("temp root");
let large_len = usize::try_from(MAX_SOURCE_BYTES + 1).expect("test size fits usize");
fs::write(root.join("large.rs"), vec![b' '; large_len]).expect("large source");
let report = build_report(&cli_for(root.clone())).expect("report");
assert!(report.files.is_empty());
assert_eq!(report.totals.files_omitted_by_limit, 1);
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn build_report_render_and_run_cover_truncation_and_empty_results() {
let mut cli = cli_for(fixture_repo());
let report = build_report(&cli).expect("report");
assert_eq!(report.engine, ENGINE_NAME);
assert!(report.files.len() >= 6);
assert!(report.totals.files_seen >= report.totals.files_indexed);
assert!(
report
.files
.iter()
.any(|file| file.path.ends_with("web\\app.ts"))
);
cli.languages = Some(BTreeSet::from([CodeLanguage::Rust]));
cli.max_files = 1;
cli.max_depth = Some(1);
cli.limit_per_file = 2;
let limited = build_report(&cli).expect("limited report");
assert_eq!(limited.files.len(), 1);
assert!(limited.files[0].omitted_items <= limited.totals.symbols_omitted);
let item = into_codeshape_item(sample_symbol());
assert_eq!(item.name, "helper");
let text = render_text(&CodeShapeReport {
engine: ENGINE_NAME,
roots: vec!["repo".to_string()],
files: vec![FileReport {
path: "repo/web/app.ts".to_string(),
language: CodeLanguage::Typescript,
items: vec![item],
omitted_items: 2,
}],
totals: Totals {
files_seen: 1,
files_indexed: 1,
files_omitted_by_limit: 0,
symbols_emitted: 1,
symbols_omitted: 2,
},
});
assert!(text.contains("repo/web/app.ts [typescript]"));
assert!(text.contains("function web::helper"));
assert!(text.contains("... 2 more item(s)"));
assert!(text.contains("totals: files_indexed=1"));
assert_eq!(language_label(CodeLanguage::Javascript), "javascript");
assert_eq!(language_label(CodeLanguage::Java), "java");
assert_eq!(kind_label(SymbolKind::Static), "static");
let success_cli = cli_for(fixture_repo());
assert_eq!(run(&success_cli).expect("run success"), ExitCode::Success);
let empty_cli = cli_for(
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("README.md"),
);
assert_eq!(run(&empty_cli).expect("run empty"), ExitCode::NoResults);
}
}
+5
View File
@@ -0,0 +1,5 @@
//! Binary entry point for `codeshape`.
fn main() {
std::process::exit(codeshape::main_entry());
}
+92
View File
@@ -0,0 +1,92 @@
//! Integration tests for the `codeshape` command.
use assert_cmd::Command;
use predicates::prelude::*;
use std::path::PathBuf;
fn cargo_command() -> Command {
Command::cargo_bin("codeshape").expect("binary")
}
fn fixture_path(relative: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("fixtures")
.join("polyglot")
.join("repo")
.join(relative)
}
fn pwsh_command(script: impl AsRef<str>) -> Command {
let mut command = Command::new("pwsh");
command
.arg("-NoProfile")
.arg("-Command")
.arg(script.as_ref());
command
}
#[test]
fn summarizes_polyglot_repo_as_json() {
let mut command = cargo_command();
command
.arg("--json")
.arg(fixture_path(""))
.assert()
.success()
.stdout(predicate::str::contains("\"roots\""))
.stdout(predicate::str::contains("\"language\":\"rust\""))
.stdout(predicate::str::contains("\"language\":\"go\""))
.stdout(predicate::str::contains("\"language\":\"java\""))
.stdout(predicate::str::contains(
"\"qualified_name\":\"Service::execute\"",
))
.stdout(predicate::str::contains(
"\"signature\":\"pub fn helper(name: &str) -> String\"",
))
.stdout(predicate::str::contains(
"\"signature\":\"function Invoke-Helper\"",
));
}
#[test]
fn renders_compact_tree_text() {
let mut command = cargo_command();
command
.args(["--limit-per-file", "4"])
.arg(fixture_path(""))
.assert()
.success()
.stdout(predicate::str::contains("src\\lib.rs"))
.stdout(predicate::str::contains("function helper"))
.stdout(predicate::str::contains("namespace Mercury.Game"));
}
#[test]
fn supports_powershell_pipeline_roots() {
let binary = assert_cmd::cargo::cargo_bin("codeshape");
let root = fixture_path("");
let script = format!(
"'{}' | & '{}' --json | ConvertFrom-Json | Select-Object -ExpandProperty totals | Select-Object -ExpandProperty files_indexed",
root.display(),
binary.display()
);
let mut command = pwsh_command(script);
command
.assert()
.success()
.stdout(predicate::str::contains("10"));
}
#[test]
fn help_includes_repo_map_examples() {
let mut command = cargo_command();
command
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("--max-files"))
.stdout(predicate::str::contains("ConvertFrom-Json"))
.stdout(predicate::str::contains("codeshape"));
}
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "common"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "Shared CLI runtime helpers for the AI-friendly CLI toolbox."
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
walkdir.workspace = true
[dev-dependencies]
tempfile.workspace = true
+4
View File
@@ -0,0 +1,4 @@
pub mod ison;
pub mod tonl;
pub mod toon;
pub mod zon;
+365
View File
@@ -0,0 +1,365 @@
//! Shared ISON/ISONL v1 helpers for compact JSON-family records.
use std::fmt::Write as _;
use serde_json::{Map, Number, Value};
use crate::CliError;
const RECORD_SEGMENT_ERROR: &str = "ISONL record expects 3 pipe-delimited segments";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FieldType {
Int,
Float,
Bool,
Str,
Null,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Field {
name: String,
kind: FieldType,
}
/// Encodes JSON object records into newline-delimited ISONL text.
///
/// # Errors
///
/// Returns [`CliError::Usage`] when any record is not an object or has no fields.
pub fn encode_records(records: &[Value], record_name: &str) -> Result<String, CliError> {
let mut output = String::with_capacity(records.len().saturating_mul(96));
for record in records {
output.push_str(&encode_record(record, record_name)?);
output.push('\n');
}
Ok(output)
}
/// Encodes one JSON object into an ISONL record line.
///
/// # Errors
///
/// Returns [`CliError::Usage`] when `record` is not an object or has no fields.
pub fn encode_record(record: &Value, record_name: &str) -> Result<String, CliError> {
let object = record
.as_object()
.ok_or_else(|| CliError::usage("ISONL v1 expects each record to be a JSON object"))?;
if object.is_empty() {
return Err(CliError::usage(
"ISONL v1 cannot infer fields from an empty object",
));
}
let fields = infer_fields(object);
let mut output =
String::with_capacity(record_name.len() + fields.len().saturating_mul(24) + 16);
output.push_str("object.");
output.push_str(record_name);
output.push('|');
push_fields(&mut output, &fields);
output.push('|');
push_row(&mut output, object, &fields);
Ok(output)
}
/// Decodes one ISONL record line into a JSON object.
///
/// # Errors
///
/// Returns [`CliError::Usage`] when the line is malformed.
pub fn decode_record_line(line: &str, line_number: usize) -> Result<Value, CliError> {
let (header, fields_text, row_text) = split_record_line(line, line_number)?;
parse_header(header.trim(), line_number)?;
let fields = parse_fields(fields_text.trim(), line_number)?;
let row = parse_row(row_text.trim(), line_number, &fields)?;
Ok(Value::Object(row))
}
/// Decodes newline-delimited ISONL records into JSON objects.
///
/// Reuses the previous field schema when consecutive records share the same
/// header and field definition, which is the common JSONL-style stream shape.
///
/// # Errors
///
/// Returns [`CliError::Usage`] when any line is malformed.
pub fn decode_records(input: &str) -> Result<Vec<Value>, CliError> {
let mut records = Vec::new();
let mut cached_header = "";
let mut cached_fields_text = "";
let mut cached_fields = Vec::new();
for (index, raw_line) in input.lines().enumerate() {
let line_number = index + 1;
let line = raw_line.trim();
if line.is_empty() {
continue;
}
let (header, fields_text, row_text) = split_record_line(line, line_number)?;
if header != cached_header || fields_text != cached_fields_text {
parse_header(header, line_number)?;
cached_fields = parse_fields(fields_text, line_number)?;
cached_header = header;
cached_fields_text = fields_text;
}
records.push(Value::Object(parse_row(
row_text,
line_number,
&cached_fields,
)?));
}
Ok(records)
}
fn split_record_line(line: &str, line_number: usize) -> Result<(&str, &str, &str), CliError> {
let mut parts = line.splitn(3, '|');
let Some(header) = parts.next() else {
return Err(record_segment_error(line_number));
};
let Some(fields_text) = parts.next() else {
return Err(record_segment_error(line_number));
};
let Some(row_text) = parts.next() else {
return Err(record_segment_error(line_number));
};
Ok((header.trim(), fields_text.trim(), row_text.trim()))
}
fn record_segment_error(line_number: usize) -> CliError {
CliError::usage(format!("line {line_number}: {RECORD_SEGMENT_ERROR}"))
}
fn infer_fields(object: &Map<String, Value>) -> Vec<Field> {
object
.iter()
.map(|(name, value)| Field {
name: name.clone(),
kind: infer_type(value),
})
.collect()
}
fn infer_type(value: &Value) -> FieldType {
match value {
Value::Bool(_) => FieldType::Bool,
Value::Number(number) if number.is_i64() || number.is_u64() => FieldType::Int,
Value::Number(_) => FieldType::Float,
Value::String(_) | Value::Array(_) | Value::Object(_) => FieldType::Str,
Value::Null => FieldType::Null,
}
}
fn push_fields(output: &mut String, fields: &[Field]) {
for (index, field) in fields.iter().enumerate() {
if index > 0 {
output.push(' ');
}
output.push_str(&field.name);
output.push(':');
output.push_str(field.kind.as_str());
}
}
fn push_row(output: &mut String, object: &Map<String, Value>, fields: &[Field]) {
for (index, field) in fields.iter().enumerate() {
if index > 0 {
output.push(' ');
}
push_value(output, object.get(&field.name).unwrap_or(&Value::Null));
}
}
fn push_value(output: &mut String, value: &Value) {
match value {
Value::Null => output.push_str("null"),
Value::Bool(value) => output.push_str(if *value { "true" } else { "false" }),
Value::Number(value) => {
let _ = write!(output, "{value}");
}
Value::String(value) => push_string(output, value),
Value::Array(_) | Value::Object(_) => push_string(output, &value.to_string()),
}
}
fn push_string(output: &mut String, value: &str) {
if value.is_empty()
|| value
.chars()
.any(|character| character.is_whitespace() || matches!(character, '"' | '\\' | '|'))
{
output.push_str(&serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string()));
} else {
output.push_str(value);
}
}
fn parse_header(line: &str, line_number: usize) -> Result<(), CliError> {
let Some((kind, name)) = line.split_once('.') else {
return Err(CliError::usage(format!(
"line {line_number}: expected block header kind.name"
)));
};
if kind != "object" || name.is_empty() {
return Err(CliError::usage(format!(
"line {line_number}: expected object.<name> header"
)));
}
Ok(())
}
fn parse_fields(line: &str, line_number: usize) -> Result<Vec<Field>, CliError> {
let fields = line
.split_whitespace()
.map(|token| parse_field(token, line_number))
.collect::<Result<Vec<_>, _>>()?;
if fields.is_empty() {
return Err(CliError::usage(format!(
"line {line_number}: field definition is empty"
)));
}
Ok(fields)
}
fn parse_field(token: &str, line_number: usize) -> Result<Field, CliError> {
let Some((name, kind)) = token.split_once(':') else {
return Err(CliError::usage(format!(
"line {line_number}: field '{token}' must be name:type"
)));
};
if name.is_empty() {
return Err(CliError::usage(format!(
"line {line_number}: field name must not be empty"
)));
}
Ok(Field {
name: name.to_string(),
kind: parse_field_type(kind, line_number)?,
})
}
fn parse_field_type(kind: &str, line_number: usize) -> Result<FieldType, CliError> {
match kind {
"int" => Ok(FieldType::Int),
"float" => Ok(FieldType::Float),
"bool" => Ok(FieldType::Bool),
"str" => Ok(FieldType::Str),
"null" => Ok(FieldType::Null),
other => Err(CliError::usage(format!(
"line {line_number}: unknown field type '{other}'"
))),
}
}
fn parse_row(
line: &str,
line_number: usize,
fields: &[Field],
) -> Result<Map<String, Value>, CliError> {
let values = split_row(line)
.map_err(|message| CliError::usage(format!("line {line_number}: {message}")))?;
if values.len() != fields.len() {
return Err(CliError::usage(format!(
"line {line_number}: expected {} values, got {}",
fields.len(),
values.len()
)));
}
fields
.iter()
.zip(values)
.map(|(field, raw)| {
parse_value(raw, field.kind, line_number).map(|value| (field.name.clone(), value))
})
.collect()
}
fn split_row(line: &str) -> Result<Vec<&str>, String> {
let mut values = Vec::new();
let mut token_start = None;
let mut in_string = false;
let mut escaped = false;
for (index, character) in line.char_indices() {
if in_string {
if escaped {
escaped = false;
} else if character == '\\' {
escaped = true;
} else if character == '"' {
in_string = false;
}
continue;
}
if character == '"' {
in_string = true;
token_start.get_or_insert(index);
} else if character.is_whitespace() {
if let Some(start) = token_start.take() {
values.push(&line[start..index]);
}
} else {
token_start.get_or_insert(index);
}
}
if in_string {
return Err("unterminated quoted string".to_string());
}
if let Some(start) = token_start {
values.push(&line[start..]);
}
Ok(values)
}
fn parse_value(raw: &str, kind: FieldType, line_number: usize) -> Result<Value, CliError> {
match kind {
FieldType::Int => parse_number_value(raw, line_number),
FieldType::Float => raw
.parse::<f64>()
.ok()
.and_then(serde_json::Number::from_f64)
.map(Value::Number)
.ok_or_else(|| CliError::usage(format!("line {line_number}: invalid float '{raw}'"))),
FieldType::Bool => raw.parse::<bool>().map(Value::Bool).map_err(|error| {
CliError::usage(format!("line {line_number}: invalid bool '{raw}': {error}"))
}),
FieldType::Str => {
if raw.starts_with('"') {
serde_json::from_str::<String>(raw)
.map(Value::String)
.map_err(|error| {
CliError::usage(format!("line {line_number}: invalid string: {error}"))
})
} else {
Ok(Value::String(raw.to_string()))
}
}
FieldType::Null => Ok(Value::Null),
}
}
fn parse_number_value(raw: &str, line_number: usize) -> Result<Value, CliError> {
if let Ok(value) = raw.parse::<i64>() {
return Ok(Value::Number(Number::from(value)));
}
raw.parse::<u64>()
.map(Number::from)
.map(Value::Number)
.map_err(|error| {
CliError::usage(format!("line {line_number}: invalid int '{raw}': {error}"))
})
}
impl FieldType {
const fn as_str(self) -> &'static str {
match self {
Self::Int => "int",
Self::Float => "float",
Self::Bool => "bool",
Self::Str => "str",
Self::Null => "null",
}
}
}
+144
View File
@@ -0,0 +1,144 @@
//! Shared TONL v1 helpers for JSON-backed key/value documents.
use serde_json::{Map, Value};
use crate::CliError;
/// Encodes JSON records into TONL documents.
///
/// # Errors
///
/// Returns [`CliError::Runtime`] when a JSON value cannot be rendered.
pub fn encode_documents(records: &[Value]) -> Result<String, CliError> {
let mut output = String::with_capacity(records.len().saturating_mul(96));
for (index, record) in records.iter().enumerate() {
if records.len() > 1 {
output.push_str("---\n");
} else if index > 0 {
output.push('\n');
}
push_document(&mut output, record)?;
}
Ok(output)
}
/// Decodes TONL documents into JSON records.
///
/// # Errors
///
/// Returns [`CliError::Usage`] when a line is malformed or a value is not JSON.
pub fn decode_documents(content: &str) -> Result<Vec<Value>, CliError> {
let mut records = Vec::new();
let mut current = Map::new();
let mut root_value = None;
for (index, line) in content.lines().enumerate() {
let line_number = index + 1;
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if trimmed == "---" {
finish_record(&mut records, &mut current, &mut root_value);
continue;
}
let (key, raw_value) = trimmed.split_once('=').ok_or_else(|| {
CliError::usage(format!(
"line {line_number}: expected 'key = <valid JSON value>'"
))
})?;
let key = key.trim();
if key.is_empty() {
return Err(CliError::usage(format!(
"line {line_number}: key cannot be empty"
)));
}
let value = serde_json::from_str::<Value>(raw_value.trim()).map_err(|error| {
CliError::usage(format!(
"line {line_number}: value must be a valid JSON value: {error}"
))
})?;
if key == "$" {
if !current.is_empty() {
return Err(CliError::usage(format!(
"line {line_number}: '$' root value cannot be mixed with object fields"
)));
}
root_value = Some(value);
} else {
if root_value.is_some() {
return Err(CliError::usage(format!(
"line {line_number}: object fields cannot be mixed with '$' root value"
)));
}
current.insert(key.to_string(), value);
}
}
finish_record(&mut records, &mut current, &mut root_value);
if records.is_empty() {
return Err(CliError::usage("no TONL records found"));
}
Ok(records)
}
fn push_document(output: &mut String, record: &Value) -> Result<(), CliError> {
match record {
Value::Object(object) => {
for (key, value) in object {
output.push_str(key);
output.push_str(" = ");
output.push_str(&compact_json(value)?);
output.push('\n');
}
}
value => {
output.push_str("$ = ");
output.push_str(&compact_json(value)?);
output.push('\n');
}
}
Ok(())
}
fn finish_record(
records: &mut Vec<Value>,
current: &mut Map<String, Value>,
root_value: &mut Option<Value>,
) {
if let Some(value) = root_value.take() {
records.push(value);
} else if !current.is_empty() {
records.push(Value::Object(std::mem::take(current)));
}
}
fn compact_json(value: &Value) -> Result<String, CliError> {
serde_json::to_string(value)
.map_err(|error| CliError::runtime(format!("failed to render json: {error}")))
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{decode_documents, encode_documents};
#[test]
fn shared_tonl_roundtrips_objects_and_root_values() {
let records = vec![json!({"id": "a", "ok": true}), json!([1, 2])];
let encoded = encode_documents(&records).expect("encode tonl");
assert!(encoded.contains("---\nid = \"a\"\nok = true\n"));
assert!(encoded.contains("---\n$ = [1,2]\n"));
assert_eq!(decode_documents(&encoded).expect("decode tonl"), records);
}
#[test]
fn shared_tonl_reports_malformed_inputs() {
assert!(decode_documents("\n# comment\n").is_err());
assert!(decode_documents("missing separator\n").is_err());
assert!(decode_documents(" = 1\n").is_err());
assert!(decode_documents("id = not-json\n").is_err());
assert!(decode_documents("id = 1\n$ = 2\n").is_err());
assert!(decode_documents("$ = 1\nid = 2\n").is_err());
}
}
File diff suppressed because it is too large Load Diff
+168
View File
@@ -0,0 +1,168 @@
//! Shared Zero Overhead Notation (ZON) v1 helpers.
use serde_json::{Map, Value};
use crate::CliError;
/// Encodes a JSON value into the Mercury ZON v1 subset.
///
/// # Errors
///
/// Returns [`CliError::Runtime`] when a JSON value cannot be rendered.
pub fn encode_value(value: &Value) -> Result<String, CliError> {
let mut output = String::with_capacity(estimate_capacity(value));
match value {
Value::Object(object) => encode_object(object, &mut output)?,
primitive => {
output.push_str(&render_inline_value(primitive)?);
output.push('\n');
}
}
Ok(output)
}
fn estimate_capacity(value: &Value) -> usize {
match value {
Value::Object(object) => object.len().saturating_mul(64),
Value::Array(items) => items.len().saturating_mul(48),
Value::String(text) => text.len() + 8,
Value::Null | Value::Bool(_) | Value::Number(_) => 16,
}
}
/// Decodes the Mercury ZON v1 subset into JSON.
///
/// # Errors
///
/// Returns [`CliError::Usage`] when a line is malformed.
pub fn decode_str(input: &str) -> Result<Value, CliError> {
let mut object = Map::new();
let mut scalar = None;
for (index, line) in input.lines().enumerate() {
let line_number = index + 1;
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if let Some((key, raw_value)) = trimmed.split_once(':') {
if key.is_empty() {
return Err(CliError::usage(format!(
"line {line_number}: key cannot be empty"
)));
}
object.insert(
key.to_string(),
parse_inline_value(raw_value.trim(), line_number)?,
);
} else if scalar
.replace(parse_inline_value(trimmed, line_number)?)
.is_some()
{
return Err(CliError::usage(format!(
"line {line_number}: multiple scalar ZON values are not supported"
)));
}
}
if object.is_empty() {
scalar.ok_or_else(|| CliError::usage("ZON input is empty"))
} else {
Ok(Value::Object(object))
}
}
fn encode_object(object: &Map<String, Value>, output: &mut String) -> Result<(), CliError> {
for (key, value) in object {
output.push_str(key);
output.push(':');
output.push_str(&render_inline_value(value)?);
output.push('\n');
}
Ok(())
}
fn render_inline_value(value: &Value) -> Result<String, CliError> {
serde_json::to_string(value)
.map_err(|error| CliError::runtime(format!("failed to render ZON inline value: {error}")))
}
fn parse_inline_value(raw: &str, line_number: usize) -> Result<Value, CliError> {
if raw.is_empty() {
return Ok(Value::String(String::new()));
}
if let Some(value) = parse_keyword_value(raw) {
return Ok(value);
}
if raw.starts_with(['"', '[', '{']) || looks_like_number(raw) {
return serde_json::from_str::<Value>(raw).map_err(|error| {
CliError::usage(format!(
"line {line_number}: invalid inline JSON value: {error}"
))
});
}
Ok(Value::String(raw.to_string()))
}
fn parse_keyword_value(raw: &str) -> Option<Value> {
match raw {
"true" => Some(Value::Bool(true)),
"false" => Some(Value::Bool(false)),
"null" => Some(Value::Null),
_ => None,
}
}
fn looks_like_number(value: &str) -> bool {
value
.as_bytes()
.first()
.is_some_and(|byte| matches!(byte, b'-' | b'0'..=b'9'))
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn encodes_and_decodes_object_and_scalar_values() {
let object = json!({
"active": true,
"count": 3,
"name": "mercury",
"notes": "",
"tags": ["fast", "portable"],
});
let encoded = encode_value(&object).expect("encoded object");
let decoded = decode_str(&encoded).expect("decoded object");
assert_eq!(decoded, object);
assert_eq!(decode_str("null\n").expect("null scalar"), Value::Null);
assert_eq!(
decode_str("plain-text\n").expect("bare string"),
json!("plain-text")
);
}
#[test]
fn reports_malformed_zon_inputs() {
let empty_key = decode_str(":true\n").expect_err("empty key");
assert!(matches!(
empty_key,
CliError::Usage(message) if message.contains("key cannot be empty")
));
let multiple_scalars = decode_str("1\n2\n").expect_err("multiple scalars");
assert!(matches!(
multiple_scalars,
CliError::Usage(message) if message.contains("multiple scalar ZON values")
));
let invalid_inline = decode_str("value:[1,,2]\n").expect_err("invalid inline JSON");
assert!(matches!(
invalid_inline,
CliError::Usage(message) if message.contains("invalid inline JSON value")
));
}
}
File diff suppressed because it is too large Load Diff
+276
View File
@@ -0,0 +1,276 @@
//! Tests for the generated Mercury Toolbox AI assets.
use std::fs;
use std::path::{Path, PathBuf};
use serde_json::Value;
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.canonicalize()
.expect("workspace root")
}
fn workspace_path(root: &Path, relative_path: &[&str]) -> PathBuf {
relative_path
.iter()
.fold(root.to_path_buf(), |path, component| path.join(component))
}
fn read_workspace_text(root: &Path, relative_path: &[&str], label: &str) -> String {
fs::read_to_string(workspace_path(root, relative_path))
.unwrap_or_else(|error| panic!("failed to read {label}: {error}"))
}
fn toolbox_commands(root: &Path) -> Vec<String> {
let commands = read_workspace_text(
root,
&["scripts", "toolbox-commands.ps1"],
"scripts/toolbox-commands.ps1",
);
let mut in_command_list = false;
let mut consumed_command_list = false;
let mut names = commands
.lines()
.filter_map(|line| {
let trimmed = line.trim();
if trimmed == "return @(" && !in_command_list && !consumed_command_list {
in_command_list = true;
return None;
}
if in_command_list && trimmed == ")" {
in_command_list = false;
consumed_command_list = true;
return None;
}
if !in_command_list {
return None;
}
trimmed
.strip_prefix('\'')
.and_then(|rest| rest.split_once('\''))
.map(|(name, _)| name.to_string())
})
.collect::<Vec<_>>();
names.sort();
names.dedup();
names
}
fn assert_lf_only(root: &Path, relative_path: &[&str]) {
let path = workspace_path(root, relative_path);
let bytes = fs::read(&path).unwrap_or_else(|error| panic!("read {}: {error}", path.display()));
assert!(
!bytes.windows(2).any(|window| window == b"\r\n"),
"{} should use LF line endings",
path.display()
);
}
#[test]
fn ai_prompt_assets_exist_and_cover_every_tool() {
let root = workspace_root();
let prompt = read_workspace_text(
&root,
&["docs", "ai", "mercury-toolbox-ai-prompt.md"],
"generated AI prompt",
);
let notes = read_workspace_text(
&root,
&["docs", "ai", "toolbox-ai-prompt-notes.json"],
"AI prompt notes",
);
let commands = toolbox_commands(&root);
assert!(prompt.contains("Mercury Toolbox"));
assert!(prompt.contains("PowerShell"));
assert!(prompt.contains("`--json`"));
assert!(prompt.contains("`--toon`"));
assert!(prompt.contains("Available tools:"));
assert!(prompt.contains("Rules:"));
assert!(prompt.contains("Pipe external JSON into `toon`"));
assert!(prompt.contains("Every tool has guided triage metadata"));
assert!(prompt.contains("Guided: answer="));
assert!(prompt.contains("`report_quality`"));
assert!(prompt.contains("`next_actions`"));
assert!(!prompt.contains("toon --from json --to toon"));
assert!(prompt.contains("Usage: `"));
assert!(prompt.contains("Example:"));
assert!(prompt.contains("msudo:"));
assert!(prompt.contains("Top-level high-risk command"));
assert!(prompt.contains(
"msudo status --json | ConvertFrom-Json | Select-Object ok,host,supports_runas,is_elevated"
));
assert!(!prompt.contains("## Tool Catalog"));
assert!(!prompt.contains("### `"));
assert!(!prompt.contains("$Fence"));
assert!(
prompt.lines().count() <= 120,
"prompt should stay compact for AI consumption"
);
assert_eq!(commands.len(), 59, "toolbox command inventory changed");
let notes_json = serde_json::from_str::<Value>(&notes).expect("AI prompt notes JSON");
let tools = notes_json["tools"].as_object().expect("notes tools object");
for command in commands {
assert!(
prompt.contains(&format!("{command}:")),
"prompt should contain {command}"
);
assert!(
notes.contains(&format!("\"{command}\"")),
"notes should contain {command}"
);
let guided = tools
.get(&command)
.and_then(|tool| tool.get("guided_triage"))
.unwrap_or_else(|| panic!("notes should contain guided_triage for {command}"));
assert!(
guided["answer"]
.as_str()
.is_some_and(|value| !value.is_empty()),
"guided_triage.answer should be non-empty for {command}"
);
assert!(
guided["trust"]
.as_str()
.is_some_and(|value| !value.is_empty()),
"guided_triage.trust should be non-empty for {command}"
);
assert!(
guided["next_actions"]
.as_array()
.is_some_and(|items| items.len() >= 2
&& items
.iter()
.all(|item| item.as_str().is_some_and(|value| !value.is_empty()))),
"guided_triage.next_actions should list at least two actions for {command}"
);
}
assert!(
notes.contains("msudo status --json | ConvertFrom-Json | Select-Object ok,host,supports_runas,is_elevated"),
"notes should document the stable msudo status discovery fields"
);
}
#[test]
fn ai_skill_assets_exist_and_cover_every_tool() {
let root = workspace_root();
let skill = read_workspace_text(
&root,
&["skills", "mercury-toolbox", "SKILL.md"],
"generated skill",
);
let catalog = read_workspace_text(
&root,
&[
"skills",
"mercury-toolbox",
"references",
"command-catalog.md",
],
"generated skill catalog",
);
let openai_yaml = read_workspace_text(
&root,
&["skills", "mercury-toolbox", "agents", "openai.yaml"],
"generated openai.yaml",
);
assert!(skill.contains("Mercury Toolbox"));
assert!(skill.contains("Prefer Mercury readers over `Get-Content`"));
assert!(skill.contains("## Modern Pairings"));
assert!(skill.contains("## Job Routing"));
assert!(skill.contains("Windows driver: start with `drvshape <SYS>`"));
assert!(skill.contains("`report_quality` and `next_actions`"));
assert!(skill.contains("Use `rg` over recursive `grep`"));
assert!(skill.contains("Treat `msudo` as the top-level high-risk toolbox command"));
assert!(skill.contains("`msudo status --json`"));
assert!(skill.contains("references/command-catalog.md"));
assert!(skill.contains("switch to `--toon` or `--format toon`"));
assert!(!skill.contains("toon --from json --to toon"));
assert!(skill.lines().count() <= 95, "skill should stay concise");
assert!(catalog.contains("# Mercury Toolbox Command Catalog"));
assert!(catalog.contains("Keep output compact"));
assert!(catalog.contains("TOON example:"));
assert!(catalog.contains("Guided answer:"));
assert!(catalog.contains("Trust basis:"));
assert!(catalog.contains("Next actions:"));
assert!(catalog.contains("--toon"));
assert!(!catalog.contains("toon --from json --to toon"));
assert!(catalog.contains("### `msudo`"));
assert!(catalog.contains("Top-level high-risk command"));
assert!(catalog.contains(
"msudo status --json | ConvertFrom-Json | Select-Object ok,host,supports_runas,is_elevated"
));
assert!(
catalog.lines().count() <= 520,
"catalog should stay compact"
);
assert!(openai_yaml.contains("display_name: \"Mercury Toolbox\""));
assert!(openai_yaml.contains("icon_small: \"./assets/logo.png\""));
assert!(openai_yaml.contains("icon_large: \"./assets/logo.png\""));
assert!(openai_yaml.contains("brand_color: \"#35C2FF\""));
assert!(openai_yaml.contains("default_prompt: \"Use $mercury-toolbox first"));
assert!(
root.join("skills")
.join("mercury-toolbox")
.join("assets")
.join("logo.png")
.is_file(),
"generated skill should include its logo asset"
);
for command in toolbox_commands(&root) {
assert!(
catalog.contains(&format!("### `{command}`")),
"catalog should contain {command}"
);
}
}
#[test]
fn generated_ai_markdown_assets_use_lf_line_endings() {
let root = workspace_root();
assert_lf_only(&root, &["docs", "ai", "mercury-toolbox-ai-prompt.md"]);
assert_lf_only(&root, &["skills", "mercury-toolbox", "SKILL.md"]);
assert_lf_only(
&root,
&[
"skills",
"mercury-toolbox",
"references",
"command-catalog.md",
],
);
}
#[test]
fn readme_tool_map_covers_every_toolbox_command() {
let root = workspace_root();
let readme = read_workspace_text(&root, &["README.md"], "README.md");
assert!(readme.contains("### Tool Map"));
assert!(readme.contains("## Which Tool First"));
assert!(readme.contains("Safe starter commands"));
assert!(readme.contains("Every command has guided triage notes"));
assert!(readme.contains("`report_quality` and `next_actions`"));
assert!(readme.contains("Every command supports `--help`"));
for command in toolbox_commands(&root) {
assert!(
readme.contains(&format!("| `{command}` |")),
"README tool map should contain {command}"
);
}
assert!(readme.contains("### `asmflow`"));
assert!(readme.contains("### `unityasset`"));
assert!(readme.contains("### `unityprobe`"));
assert!(readme.contains("### `unitydiag`"));
}
+150
View File
@@ -0,0 +1,150 @@
//! Contract tests for shared CLI helpers.
use common::{
CliError, ColorChoice, CommonArgs, ExitCode, InputFormat, RenderMode, emit_json,
emit_structured, formats, map_result_count, parse_color_choice, parse_format_choice,
parse_input_format, should_read_stdin,
};
use serde::Serialize;
use serde_json::{Map, Value, json};
#[test]
fn parses_shared_choice_values() {
let parsed = CommonArgs {
json: true,
format: None,
input_format: parse_input_format("jsonl").expect("input format"),
color: parse_color_choice("never").expect("color choice"),
quiet: false,
};
assert!(parsed.json);
assert_eq!(parsed.input_format, InputFormat::Jsonl);
assert_eq!(parsed.color, ColorChoice::Never);
assert_eq!(parsed.render_mode(), RenderMode::Json);
assert_eq!(
parse_format_choice("toon").expect("format choice"),
RenderMode::Toon
);
let input_error = parse_input_format("yaml").expect_err("invalid input format");
let color_error = parse_color_choice("always").expect_err("invalid color choice");
let format_error = parse_format_choice("yaml").expect_err("invalid format choice");
assert!(matches!(input_error, CliError::Usage(_)));
assert!(matches!(color_error, CliError::Usage(_)));
assert!(matches!(format_error, CliError::Usage(_)));
}
#[test]
fn auto_stdin_reads_only_without_explicit_input_and_when_not_terminal() {
assert!(should_read_stdin(false, false));
assert!(!should_read_stdin(false, true));
assert!(!should_read_stdin(true, false));
}
#[test]
fn maps_result_count_to_exit_code() {
assert_eq!(map_result_count(1), ExitCode::Success);
assert_eq!(map_result_count(0), ExitCode::NoResults);
}
#[test]
fn emits_single_json_document() {
#[derive(Serialize)]
struct Demo<'a> {
name: &'a str,
}
let rendered = emit_json(&Demo { name: "jsonlgrep" }).expect("json output");
assert_eq!(rendered, "{\"name\":\"jsonlgrep\"}\n");
}
#[test]
fn emits_structured_toon_document() {
#[derive(Serialize)]
struct Demo<'a> {
name: &'a str,
count: u8,
}
let rendered = emit_structured(
&Demo {
name: "jsonlgrep",
count: 2,
},
RenderMode::Toon,
)
.expect("toon output");
let lines = rendered.lines().collect::<Vec<_>>();
assert_eq!(lines.len(), 2);
assert!(lines.contains(&"name: jsonlgrep"));
assert!(lines.contains(&"count: 2"));
assert!(rendered.ends_with('\n'));
}
#[test]
fn structured_toon_render_rejects_values_past_explicit_depth_limit() {
let error = emit_structured(&deeply_nested_value(260), RenderMode::Toon)
.expect_err("deep TOON encode should fail");
assert!(matches!(
error,
CliError::Runtime(message) if message.contains("maximum TOON encoding depth")
));
}
#[test]
fn exposes_json_family_format_modules() {
let records = vec![json!({"id": "user-1", "active": true})];
let ison = formats::ison::encode_records(&records, "record").expect("ison records");
assert!(ison.contains("object.record|"));
assert!(ison.contains("id:str"));
assert!(ison.contains("active:bool"));
assert_eq!(
formats::ison::decode_record_line(&ison, 1).expect("ison decode"),
records[0]
);
let zon = formats::zon::encode_value(&records[0]).expect("zon");
assert!(zon.contains("id:\"user-1\""));
assert_eq!(
formats::zon::decode_str(&zon).expect("zon decode"),
records[0]
);
let tonl = formats::tonl::encode_documents(&records).expect("tonl");
assert!(tonl.contains("id = \"user-1\""));
assert_eq!(
formats::tonl::decode_documents(&tonl).expect("tonl decode"),
records
);
}
fn deeply_nested_value(depth: usize) -> Value {
let mut value = json!(1);
for index in 0..depth {
let mut object = Map::new();
object.insert(format!("k{index}"), value);
value = Value::Object(object);
}
value
}
#[test]
fn isonl_roundtrips_pipe_inside_quoted_string() {
let records = vec![json!({"message": "left|right", "ok": true})];
let ison = formats::ison::encode_records(&records, "record").expect("ison records");
assert_eq!(
formats::ison::decode_record_line(ison.trim_end(), 1).expect("ison decode"),
records[0]
);
assert_eq!(
formats::ison::decode_records(&ison).expect("ison batch decode"),
records
);
}
+484
View File
@@ -0,0 +1,484 @@
//! Workspace policy tests for Jade Discipline.
use std::fs;
use std::path::PathBuf;
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.canonicalize()
.expect("workspace root")
}
fn workspace_path(components: &[&str]) -> PathBuf {
components
.iter()
.fold(workspace_root(), |path, component| path.join(component))
}
fn script_path(script_name: &str) -> PathBuf {
workspace_path(&["scripts", script_name])
}
fn read_workspace_text(components: &[&str], label: &str) -> String {
fs::read_to_string(workspace_path(components))
.unwrap_or_else(|error| panic!("failed to read {label}: {error}"))
}
#[test]
fn workspace_cargo_toml_publishes_jade_lints_and_profiles() {
let cargo_toml = read_workspace_text(&["Cargo.toml"], "Cargo.toml");
for required_line in [
"rust-version = \"1.86\"",
"missing_docs = \"deny\"",
"pedantic = { level = \"deny\", priority = -3 }",
"nursery = { level = \"deny\", priority = -2 }",
"[profile.release-fast]",
"lto = \"fat\"",
"[profile.release-size]",
"opt-level = \"z\"",
] {
assert!(
cargo_toml.contains(required_line),
"Cargo.toml should contain {required_line:?}"
);
}
for forbidden_line in [
"missing_copy_implementations",
"implicit_return",
"missing_const_for_fn",
"module_name_repetitions",
"multiple_crate_versions",
"must_use_candidate",
"needless_pass_by_value",
] {
assert!(
!cargo_toml.contains(forbidden_line),
"Cargo.toml should not contain {forbidden_line:?}"
);
}
}
#[test]
fn cargo_config_and_jade_docs_exist() {
let config = read_workspace_text(&[".cargo", "config.toml"], ".cargo/config.toml");
assert!(config.contains("git-fetch-with-cli = true"));
assert!(config.contains("rustflags = [\"-Dwarnings\"]"));
assert!(config.contains("frequency = \"always\""));
assert!(workspace_path(&["justfile"]).is_file(), "missing justfile");
assert!(
workspace_path(&["bacon.toml"]).is_file(),
"missing bacon.toml"
);
let docs = read_workspace_text(&["docs", "jade-discipline.md"], "jade discipline docs");
let maintainer_notes =
read_workspace_text(&["docs", "maintainer-notes.md"], "maintainer notes");
assert!(docs.contains("Jade Discipline"));
assert!(docs.contains("cargo clippy --all-targets --all-features -- -D warnings -W clippy::pedantic -W clippy::nursery"));
assert!(docs.contains("Miri, fuzzing, sanitizer, no-panic, and Loom checks are Jade gates"));
assert!(docs.contains(
"Missing tools, missing harnesses, or platform discomfort are failures by default"
));
assert!(docs.contains("Global `allow` is reserved for two cases only"));
assert!(docs.contains("smallest code-local scope"));
assert!(docs.contains("Install"));
assert!(docs.contains("just"));
assert!(docs.contains("bacon"));
assert!(maintainer_notes.contains("Jade has no optional safety tier"));
assert!(maintainer_notes.contains("Every JSON-capable Mercury tool"));
assert!(maintainer_notes.contains("is the shared AST/indexing engine"));
}
#[test]
fn release_packaging_scripts_and_docs_exist() {
for script_name in [
"package-toolbox.ps1",
"install-package-toolbox.ps1",
"uninstall-package-toolbox.ps1",
"generate-ai-skill.ps1",
"check-ai-skill.ps1",
] {
assert!(
script_path(script_name).is_file(),
"missing packaging script {script_name}"
);
}
let readme = read_workspace_text(&["README.md"], "README.md");
assert!(readme.contains("just"));
assert!(readme.contains("bacon"));
assert!(readme.contains("## Portable Package"));
assert!(readme.contains(r".\scripts\package-toolbox.ps1"));
assert!(readme.contains("install-package-toolbox.ps1"));
assert!(readme.contains("mercury-toolbox-package.json"));
assert!(readme.contains("SHA256SUMS.txt"));
assert!(readme.contains("generate-ai-skill.ps1"));
assert!(readme.contains(r".\skills\mercury-toolbox\"));
assert!(readme.contains("### `msudo`"));
assert!(readme.contains("HIGH RISK"));
assert!(readme.contains("top-level high-risk toolbox command"));
assert!(readme.contains("msudo status --json"));
assert!(readme.contains("Select-Object ok,host,supports_runas,is_elevated"));
assert!(readme.contains("msudo --help"));
assert!(readme.contains("msudo run --help"));
}
#[test]
#[allow(clippy::too_many_lines)]
fn powershell_gate_assets_and_docs_exist() {
let root = workspace_root();
assert!(
script_path("check-powershell.ps1").is_file(),
"missing PowerShell gate script"
);
assert!(
script_path("cargo-flamegraph-windows.ps1").is_file(),
"missing Windows flamegraph wrapper script"
);
assert!(
workspace_path(&["PSScriptAnalyzerSettings.psd1"]).is_file(),
"missing PowerShell analyzer settings"
);
let check_jade = read_workspace_text(&["scripts", "check-jade.ps1"], "scripts/check-jade.ps1");
assert!(check_jade.contains("check-powershell.ps1"));
assert!(check_jade.contains("check-ai-skill.ps1"));
assert!(check_jade.contains("check-jade-hardening.ps1"));
assert!(
check_jade.contains("Invoke-TimedNativeWithEnvironment"),
"Jade coverage gate should be able to isolate cargo-llvm-cov environment"
);
assert!(
check_jade.contains("CARGO_INCREMENTAL") && check_jade.contains("RUSTC_WRAPPER"),
"Jade coverage gate should disable incremental and rustc-wrapper for cargo-llvm-cov"
);
assert!(
check_jade.contains("CARGO_TARGET_DIR")
&& check_jade.contains("mercury-jade-llvm-cov")
&& check_jade.contains("cargo llvm-cov clean")
&& check_jade
.contains("Invoke-TimedNativeWithEnvironment -Name 'cargo llvm-cov clean'"),
"Jade coverage gate should use a per-run isolated cargo target dir for clean and nextest"
);
assert!(
check_jade.contains("MERCURY_JADE_COVERAGE_ROOT")
&& check_jade.contains("C:\\tmp")
&& check_jade.contains("'mtcov'")
&& check_jade.contains(".Substring(0, 8)"),
"Jade coverage target dir should stay short enough for Windows llvm-cov object argv"
);
assert!(
check_jade.contains(
"'llvm-cov',\n '--jobs',\n '1',\n 'nextest'"
),
"Jade coverage gate should limit cargo-llvm-cov build jobs before the nextest subcommand"
);
assert_justfile_test_recipes(&root);
let ecosystem = read_workspace_text(
&["scripts", "check-ecosystem.ps1"],
"ecosystem check script",
);
assert!(
ecosystem.contains("toolbox:all-binaries-report-version")
&& ecosystem.contains("Test-ToolboxBinaryVersions"),
"ecosystem gate should prove every toolbox binary reports --version"
);
assert!(
ecosystem.contains("toolbox:all-binaries-no-args-contract")
&& ecosystem.contains("Test-ToolboxNoArgsContracts"),
"ecosystem gate should prove every toolbox binary has bounded no-args behavior"
);
assert!(
ecosystem.contains("toolbox:all-binaries-invalid-flag-contract")
&& ecosystem.contains("Test-ToolboxInvalidFlagContracts"),
"ecosystem gate should prove every toolbox binary has bounded invalid-flag diagnostics"
);
assert!(
ecosystem.contains("toolbox:all-binaries-structured-output-help")
&& ecosystem.contains("Test-ToolboxStructuredOutputHelpContracts"),
"ecosystem gate should prove every toolbox binary exposes structured output help"
);
assert!(
ecosystem.contains("toolbox:malformed-jsonl-stdin-contract")
&& ecosystem.contains("Test-ToolboxMalformedJsonlStdinContracts"),
"ecosystem gate should prove malformed JSONL stdin is bounded for input-format commands"
);
assert!(
ecosystem.contains("toolbox:valid-jsonl-path-stream-smokes"),
"ecosystem gate should prove representative positive JSONL path-stream behavior"
);
assert!(
ecosystem.contains("toolbox:functional-toon-smokes"),
"ecosystem gate should prove representative functional TOON output smokes"
);
let check_hardening = read_workspace_text(
&["scripts", "check-jade-hardening.ps1"],
"scripts/check-jade-hardening.ps1",
);
for required_gate in [
"cargo miri setup",
"NightlyToolchain",
"'fuzz'",
"'run'",
"json_family_decode",
"-Zsanitizer=address",
"check-no-panic.ps1",
"loom_capture",
"Mode 'hardening'",
"ValidateRange(1, 3600)",
"Only = 'All'",
] {
assert!(
check_hardening.contains(required_gate),
"hardening script should contain {required_gate:?}"
);
}
assert!(check_hardening.contains("exemption requires a non-empty reason"));
let jade_install = read_workspace_text(
&["scripts", "install-jade-tooling.ps1"],
"scripts/install-jade-tooling.ps1",
);
assert!(jade_install.contains("PSScriptAnalyzer"));
assert!(jade_install.contains("cargo-binstall"));
assert!(jade_install.contains("\"just\", \"bacon\""));
assert!(jade_install.contains("\"component\", \"add\", \"miri\""));
assert!(jade_install.contains("\"cargo-udeps\", \"cargo-llvm-cov\""));
assert!(jade_install.contains("\"install\", \"cargo-fuzz\""));
let docs = read_workspace_text(&["docs", "jade-discipline.md"], "jade discipline docs");
assert!(docs.contains("PowerShell Gate"));
assert!(docs.contains("check-powershell.ps1"));
assert!(docs.contains("check-ai-skill.ps1"));
assert!(docs.contains("PSScriptAnalyzer"));
assert!(docs.contains("cargo-flamegraph-windows.ps1"));
let readme = read_workspace_text(&["README.md"], "README.md");
assert!(readme.contains("cargo-flamegraph-windows.ps1"));
}
fn assert_justfile_test_recipes(root: &std::path::Path) {
let justfile = fs::read_to_string(root.join("justfile")).expect("justfile");
assert!(
justfile.contains("coverage:\n cargo llvm-cov nextest --all-features --summary-only"),
"just coverage should keep a fast local coverage path without forcing Jade's serial coverage gate"
);
assert!(
!justfile.contains("coverage:\n cargo llvm-cov clean --workspace"),
"just coverage should not pre-clean coverage artifacts on every local iteration"
);
assert!(
justfile.contains("test:\n cargo nextest run --all-features"),
"just test should keep the fast incremental nextest path for local iteration"
);
assert!(
justfile.contains("stable-test:")
&& justfile.contains(
"CARGO_INCREMENTAL = '0'; cargo nextest run --all-features --run-ignored all",
),
"stable-test should keep the non-incremental Windows cleanup-race path and slow integration coverage"
);
}
#[test]
#[allow(clippy::too_many_lines)]
fn repo_temp_paths_are_audited_and_use_exclusive_writes() {
let root = workspace_root();
let production_temp_dir_hits = production_source_hits(
&root,
&[
"std::env::temp_dir()",
"env::temp_dir()",
"tempfile::",
"NamedTempFile",
"TempDir::new",
"tempdir()",
],
);
assert_eq!(
production_temp_dir_hits,
[
"crates\\argv\\src\\lib.rs:std::env::temp_dir().join(format!(",
"crates\\envdiff\\src\\lib.rs:std::env::temp_dir().join(format!(",
"crates\\msudo\\src\\lib.rs:let temp_dir = std::env::temp_dir();",
"crates\\runtimekit\\src\\lib.rs:std::env::temp_dir().join(format!(\"{prefix}-{unique}.{extension}\"))",
],
"production temp root use must stay explicitly audited"
);
for (path, required) in [
(
"crates/runtimekit/src/lib.rs",
&[
"fn write_shell_wrapper_file",
".create_new(true)",
"refusing to replace existing shell wrapper",
][..],
),
(
"crates/argv/src/lib.rs",
&[
"fn write_cmd_wrapper_file",
".create_new(true)",
"refusing to replace existing cmd inspect wrapper",
][..],
),
(
"crates/envdiff/src/lib.rs",
&[
"fn write_temp_file_exclusive",
".create_new(true)",
"fn create_temp_dir_exclusive",
"fs::create_dir(path)",
][..],
),
(
"crates/msudo/src/lib.rs",
&[
"fn write_relay_exit_status",
".create_new(true)",
"failed to create relay exit status",
][..],
),
(
"crates/runprobe/src/lib.rs",
&[
"fn write_log_file_exclusive",
".create_new(true)",
"refusing to replace existing runprobe log",
][..],
),
(
"crates/windowsupport/src/sudo.rs",
&[
"fn create_relay_output_file",
".create_new(true)",
"FILE_FLAG_OPEN_REPARSE_POINT",
][..],
),
] {
let body = fs::read_to_string(root.join(path)).unwrap_or_else(|error| {
panic!("failed to read {path}: {error}");
});
for needle in required {
assert!(
body.contains(needle),
"{path} should keep temp/log output guard {needle:?}"
);
}
}
}
fn production_source_hits(root: &std::path::Path, needles: &[&str]) -> Vec<String> {
let mut hits = Vec::new();
collect_production_source_hits(&root.join("crates"), root, needles, &mut hits);
hits.sort();
hits
}
fn collect_production_source_hits(
directory: &std::path::Path,
root: &std::path::Path,
needles: &[&str],
hits: &mut Vec<String>,
) {
for entry in fs::read_dir(directory).unwrap_or_else(|error| {
panic!("failed to read {}: {error}", directory.display());
}) {
let path = entry.expect("directory entry").path();
if path.is_dir() {
if path.file_name().and_then(|name| name.to_str()) != Some("tests") {
collect_production_source_hits(&path, root, needles, hits);
}
continue;
}
if path.extension().and_then(|extension| extension.to_str()) != Some("rs") {
continue;
}
collect_file_hits(&path, root, needles, hits);
}
}
fn collect_file_hits(
path: &std::path::Path,
root: &std::path::Path,
needles: &[&str],
hits: &mut Vec<String>,
) {
let body = fs::read_to_string(path).unwrap_or_else(|error| {
panic!("failed to read {}: {error}", path.display());
});
let mut in_test_region = path.components().any(|component| {
component
.as_os_str()
.to_string_lossy()
.eq_ignore_ascii_case("tests")
});
for line in body.lines() {
let trimmed = line.trim();
if trimmed == "#[cfg(test)]" || trimmed.starts_with("#[test]") {
in_test_region = true;
}
if !in_test_region && needles.iter().any(|needle| trimmed.contains(needle)) {
let relative = path.strip_prefix(root).unwrap_or(path);
hits.push(format!("{}:{}", relative.display(), trimmed));
}
}
}
#[test]
fn deny_advisory_ignores_carry_review_evidence() {
let deny = read_workspace_text(&["deny.toml"], "deny.toml");
let advisory = "RUSTSEC-2024-0436";
let offset = deny
.find(advisory)
.unwrap_or_else(|| panic!("deny.toml should mention {advisory}"));
let context_start = deny[..offset]
.rfind("[advisories]")
.expect("advisories section");
let context = &deny[context_start..offset];
for required in [
"Package:",
"Reachability:",
"Reviewed:",
"Upgrade/follow-up:",
] {
assert!(
context.contains(required),
"advisory ignore {advisory} should document {required}"
);
}
}
#[test]
fn ai_asset_checks_self_heal_generated_drift_before_failing() {
for script_name in ["check-ai-prompt.ps1", "check-ai-skill.ps1"] {
let script = read_workspace_text(&["scripts", script_name], script_name);
assert!(
script.contains("Invoke-GeneratorCheck"),
"{script_name} should use the shared check/regenerate/recheck helper"
);
assert!(
script.contains("Invoke-GeneratorWrite"),
"{script_name} should regenerate stale generated assets automatically"
);
assert!(
script.contains("still out of date after regeneration"),
"{script_name} should only ask for manual intervention after regeneration fails"
);
}
let docs = read_workspace_text(&["docs", "jade-discipline.md"], "jade docs");
assert!(
docs.contains("self-heal generated asset drift"),
"Jade docs should describe the AI asset gate's self-healing behavior"
);
}
+63
View File
@@ -0,0 +1,63 @@
//! Miri regression coverage for compact JSON-family parsers.
use common::formats::toon::{DecodeOptions, EncodeOptions};
use common::formats::{ison, tonl, toon, zon};
use serde_json::json;
#[test]
fn toon_roundtrip_exercises_nested_and_tabular_paths() {
for value in [
json!({
"meta": {
"ok": true,
"count": 2
},
"items": [
{"id": 1, "name": "Ada"},
{"id": 2, "name": "Bob"}
],
"literal.path": "quoted when encoded"
}),
json!([1, 2, 3]),
json!([{"id": 1}, {"id": 2}]),
json!("plain"),
] {
let encoded = toon::encode_value(&value, EncodeOptions::default()).expect("TOON encode");
let decoded = toon::decode_str(&encoded, DecodeOptions::default());
assert_eq!(decoded.expect("TOON decode"), value);
}
}
#[test]
fn toon_decoder_rejects_malformed_counts_and_path_conflicts() {
let bad_count = toon::decode_str("[3]: a,b\n", DecodeOptions::default());
assert!(bad_count.is_err());
let path_conflict = toon::decode_str(
"a: 1\na.b: 2\n",
DecodeOptions {
expand_paths: common::formats::toon::SafeMode::Safe,
..DecodeOptions::default()
},
);
assert!(path_conflict.is_err());
}
#[test]
fn record_formats_decode_without_panicking() {
let records = vec![json!({"id": 1, "name": "Ada", "active": true})];
let isonl = ison::encode_records(&records, "user").expect("ISONL encode");
assert_eq!(ison::decode_records(&isonl).expect("ISONL decode"), records);
assert_eq!(
zon::decode_str("id:1\nname:\"Ada\"\nactive:true\n").expect("ZON decode"),
json!({"id": 1, "name": "Ada", "active": true})
);
let tonl = tonl::encode_documents(&[json!({"id": 1, "name": "Ada"})]).expect("TONL encode");
assert_eq!(
tonl::decode_documents(&tonl).expect("TONL decode"),
vec![json!({"id": 1, "name": "Ada"})]
);
}
+29
View File
@@ -0,0 +1,29 @@
[package]
name = "config"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "Summarize common config file formats into compact AI-friendly key paths."
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[dependencies]
configsupport = { path = "../configsupport" }
common = { path = "../common" }
lexopt.workspace = true
serde.workspace = true
serde_json = { workspace = true, features = ["preserve_order"] }
serde_yaml = "0.9.34"
toml = "0.8.23"
[dev-dependencies]
assert_cmd.workspace = true
predicates.workspace = true
tempfile.workspace = true
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
//! Binary entry point for `config`.
fn main() {
std::process::exit(config::main_entry());
}
+247
View File
@@ -0,0 +1,247 @@
//! Integration tests for the `config` command.
use std::fs;
use assert_cmd::Command;
use predicates::prelude::*;
use serde_json::json;
use tempfile::tempdir;
fn cargo_command() -> Command {
Command::cargo_bin("config").expect("binary")
}
#[test]
fn no_args_prints_quick_help_card() {
let mut command = cargo_command();
command
.assert()
.code(2)
.stdout(predicate::str::is_empty())
.stderr(predicate::str::contains(
"error: provide a subcommand: get, set, delete, or ls",
))
.stderr(predicate::str::contains("config - Mercury Toolbox"))
.stderr(predicate::str::contains("Usage:"))
.stderr(predicate::str::contains("get [PATH] [POINTER]"))
.stderr(predicate::str::contains(
"config [OPTIONS] inspect [PATH] [POINTER]",
))
.stderr(predicate::str::contains(
"Type 'config --help' for the full command reference.",
));
}
#[test]
fn get_reads_toml_pointer_as_json() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("sample.toml");
fs::write(
&path,
"[package]\nname = \"toolbox\"\nversion = \"3.0.0\"\n",
)
.expect("fixture");
let mut command = cargo_command();
command
.arg("--json")
.arg("get")
.arg(&path)
.arg("/package/version")
.assert()
.success()
.stdout(predicate::str::contains("\"format\":\"toml\""))
.stdout(predicate::str::contains("\"pointer\":\"/package/version\""))
.stdout(predicate::str::contains("\"value\":\"3.0.0\""));
}
#[test]
fn set_then_get_updates_json_number_value() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("config.json");
fs::write(&path, "{\"retries\":1}\n").expect("fixture");
let mut set = cargo_command();
set.arg("set")
.arg(&path)
.arg("/retries")
.arg("5")
.arg("--json")
.arg("--value-type")
.arg("number")
.assert()
.success()
.stdout(predicate::str::contains("\"changed\":true"));
let mut get = cargo_command();
get.arg("--json")
.arg("get")
.arg(&path)
.arg("/retries")
.assert()
.success()
.stdout(predicate::str::contains("\"value\":5"));
}
#[test]
fn delete_removes_env_key() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join(".env");
fs::write(&path, "KEEP=yes\nDROP=no\n").expect("fixture");
let mut command = cargo_command();
command
.arg("delete")
.arg(&path)
.arg("/DROP")
.assert()
.success()
.stdout(predicate::str::contains("op=delete"));
let content = fs::read_to_string(&path).expect("updated");
assert!(content.contains("KEEP=yes"));
assert!(!content.contains("DROP=no"));
}
#[test]
fn ls_lists_ini_section_entries() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("settings.ini");
fs::write(&path, "mode=dev\n[server]\nport=8080\nhost=127.0.0.1\n").expect("fixture");
let mut command = cargo_command();
command
.arg("--json")
.arg("ls")
.arg(&path)
.arg("/server")
.assert()
.success()
.stdout(predicate::str::contains("\"key\":\"host\""))
.stdout(predicate::str::contains("\"key\":\"port\""));
}
#[test]
fn ls_accepts_single_stdin_path_stream() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("config.json");
fs::write(&path, "{\"mode\":\"dev\",\"nested\":{\"ok\":true}}\n").expect("fixture");
let mut command = cargo_command();
command
.arg("--json")
.arg("ls")
.write_stdin(format!("{}\n", path.display()))
.assert()
.success()
.stdout(predicate::str::contains("\"pointer\":\"/\""))
.stdout(predicate::str::contains("\"key\":\"mode\""))
.stdout(predicate::str::contains("\"key\":\"nested\""));
}
#[test]
fn help_includes_new_subcommands_and_flags() {
let mut command = cargo_command();
command
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("get [PATH] [POINTER]"))
.stdout(predicate::str::contains("set <PATH> <POINTER> <VALUE>"))
.stdout(predicate::str::contains("--value-type"))
.stdout(predicate::str::contains("--format"))
.stdout(predicate::str::contains("config"));
}
#[test]
fn ls_on_scalar_pointer_returns_no_results_exit_code() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("config.json");
fs::write(&path, "{\"mode\":\"dev\"}\n").expect("fixture");
let mut command = cargo_command();
command
.arg("ls")
.arg(&path)
.arg("/mode")
.assert()
.code(1)
.stdout(predicate::str::contains("entries=0"));
}
#[test]
fn delete_missing_json_key_returns_no_results_and_preserves_file() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("config.json");
fs::write(&path, "{\"present\":true}\n").expect("fixture");
let mut command = cargo_command();
command
.arg("--json")
.arg("delete")
.arg(&path)
.arg("/missing")
.assert()
.code(1)
.stdout(predicate::str::contains("\"changed\":false"))
.stdout(predicate::str::contains("\"pointer\":\"/missing\""));
let content = fs::read_to_string(&path).expect("updated");
let parsed: serde_json::Value = serde_json::from_str(&content).expect("valid json");
assert_eq!(parsed, json!({"present": true}));
}
#[test]
fn set_creates_nested_json_arrays_and_objects() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("config.json");
fs::write(&path, "{}\n").expect("fixture");
let mut command = cargo_command();
command
.arg("--json")
.arg("set")
.arg(&path)
.arg("/pkg/deps/0/name")
.arg("serde")
.assert()
.success()
.stdout(predicate::str::contains("\"changed\":true"));
let content = fs::read_to_string(&path).expect("updated");
let parsed: serde_json::Value = serde_json::from_str(&content).expect("valid json");
assert_eq!(parsed, json!({"pkg": {"deps": [{"name": "serde"}]}}));
}
#[test]
fn env_set_rejects_nested_pointer_and_non_scalar_json_values() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join(".env");
fs::write(&path, "KEEP=yes\n").expect("fixture");
let mut nested_pointer = cargo_command();
nested_pointer
.arg("set")
.arg(&path)
.arg("/A/B")
.arg("x")
.assert()
.code(3)
.stderr(predicate::str::contains(
"env write operations only support root keys like /NAME",
));
let mut object_value = cargo_command();
object_value
.arg("set")
.arg(&path)
.arg("/OBJ")
.arg("{\"nested\":true}")
.arg("--value-type")
.arg("json")
.assert()
.code(3)
.stderr(predicate::str::contains(
"env/ini writes require scalar string|number|bool values",
));
}
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "configsupport"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "Shared CLI helpers for standalone Mercury Toolbox config and repo commands."
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
+614
View File
@@ -0,0 +1,614 @@
//! Shared CLI helpers for standalone Mercury Toolbox commands.
use std::fmt::Display;
use std::io::{self, IsTerminal, Write};
use serde::Serialize;
use thiserror::Error;
/// Controls ANSI color behavior for command output.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ColorChoice {
/// Enable color only when output looks interactive.
#[default]
Auto,
/// Never emit ANSI color sequences.
Never,
}
/// Selects the output surface for a command invocation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RenderMode {
/// Emit compact text.
Text,
/// Emit compact JSON.
Json,
/// Emit compact TOON.
Toon,
}
/// Shared flags that every standalone command accepts.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct CommonArgs {
/// Emit JSON instead of text.
pub json: bool,
/// Explicit structured output format selected by `--format`, `--json`, or `--toon`.
pub format: Option<RenderMode>,
/// Suppress non-essential status output.
pub quiet: bool,
/// Control ANSI color output.
pub color: ColorChoice,
}
impl CommonArgs {
/// Records an explicit output format selection.
pub fn set_render_mode(&mut self, render_mode: RenderMode) {
self.json = render_mode == RenderMode::Json;
self.format = Some(render_mode);
}
/// Returns the output mode implied by the current settings.
#[must_use]
pub const fn render_mode(self) -> RenderMode {
if let Some(format) = self.format {
return format;
}
if self.json {
RenderMode::Json
} else {
RenderMode::Text
}
}
/// Reports whether stdin is attached to an interactive terminal.
#[must_use]
pub fn stdin_is_terminal(&self) -> bool {
io::stdin().is_terminal()
}
}
/// Stable process exit codes shared by the standalone commands.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExitCode {
/// The command succeeded and produced at least one result.
Success = 0,
/// The command succeeded but produced no matching results.
NoResults = 1,
/// The invocation was rejected because the input was invalid.
UsageError = 2,
/// The command hit an operational failure at runtime.
RuntimeError = 3,
}
impl ExitCode {
/// Converts the enum into a process exit code.
#[must_use]
pub const fn as_i32(self) -> i32 {
self as i32
}
}
/// Represents user-facing command failures.
#[derive(Debug, Error)]
pub enum CliError {
/// The user supplied invalid arguments or malformed input.
#[error("{0}")]
Usage(String),
/// The command failed while reading, probing, or rendering data.
#[error("{0}")]
Runtime(String),
}
impl CliError {
/// Builds a usage error.
#[must_use]
pub fn usage(message: impl Into<String>) -> Self {
Self::Usage(message.into())
}
/// Builds a runtime error.
#[must_use]
pub fn runtime(message: impl Into<String>) -> Self {
Self::Runtime(message.into())
}
/// Returns the exit code associated with the error category.
#[must_use]
pub const fn exit_code(&self) -> ExitCode {
match self {
Self::Usage(_) => ExitCode::UsageError,
Self::Runtime(_) => ExitCode::RuntimeError,
}
}
}
/// Parses the shared `--color` argument.
///
/// # Errors
///
/// Returns [`CliError::Usage`] when the value is not supported.
pub fn parse_color_choice(value: &str) -> Result<ColorChoice, CliError> {
match value {
"auto" => Ok(ColorChoice::Auto),
"never" => Ok(ColorChoice::Never),
other => Err(CliError::usage(format!(
"invalid --color value '{other}'; expected auto or never"
))),
}
}
/// Parses the shared `--format` output argument.
///
/// # Errors
///
/// Returns [`CliError::Usage`] when the value is not supported.
pub fn parse_format_choice(value: &str) -> Result<RenderMode, CliError> {
match value {
"text" => Ok(RenderMode::Text),
"json" => Ok(RenderMode::Json),
"toon" => Ok(RenderMode::Toon),
other => Err(CliError::usage(format!(
"invalid --format value '{other}'; expected text, json, or toon"
))),
}
}
/// Writes a JSON value to stdout.
///
/// # Errors
///
/// Returns [`CliError::Runtime`] when serialization or stdout writes fail.
pub fn print_json<T>(value: &T) -> Result<(), CliError>
where
T: Serialize,
{
let mut stdout = io::stdout().lock();
if let Err(error) = serde_json::to_writer(&mut stdout, value) {
return match error.io_error_kind() {
Some(io::ErrorKind::BrokenPipe) => Ok(()),
_ => Err(CliError::runtime(format!(
"failed to write stdout: {error}"
))),
};
}
stdout_write_result(stdout.write_all(b"\n"))
}
/// Writes a structured value to stdout as JSON or TOON.
///
/// # Errors
///
/// Returns [`CliError::Runtime`] when serialization or stdout writes fail.
pub fn print_structured<T>(value: &T, render_mode: RenderMode) -> Result<(), CliError>
where
T: Serialize,
{
match render_mode {
RenderMode::Json => print_json(value),
RenderMode::Toon => {
let value = serde_json::to_value(value).map_err(|error| {
CliError::runtime(format!("failed to serialize structured output: {error}"))
})?;
let mut stdout = io::stdout().lock();
write_toon_value(&mut stdout, &value, 0)?;
stdout_write_result(stdout.write_all(b"\n"))
}
RenderMode::Text => Err(CliError::runtime(
"structured text rendering requires command-specific text output",
)),
}
}
fn write_toon_value(
writer: &mut impl Write,
value: &serde_json::Value,
depth: usize,
) -> Result<(), CliError> {
match value {
serde_json::Value::Object(object) => {
for (key, value) in object {
match value {
serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
stdout_write_result(writeln!(writer, "{}{}:", " ".repeat(depth), key))?;
write_toon_value(writer, value, depth + 1)?;
}
primitive => {
stdout_write_result(writeln!(
writer,
"{}{}: {}",
" ".repeat(depth),
key,
toon_primitive(primitive)
))?;
}
}
}
}
serde_json::Value::Array(array) => {
for value in array {
stdout_write_result(writeln!(
writer,
"{}- {}",
" ".repeat(depth),
toon_primitive(value)
))?;
}
}
primitive => {
stdout_write_result(write!(writer, "{}", toon_primitive(primitive)))?;
}
}
Ok(())
}
fn stdout_write_result(result: io::Result<()>) -> Result<(), CliError> {
match result {
Ok(()) => Ok(()),
Err(error) if error.kind() == io::ErrorKind::BrokenPipe => Ok(()),
Err(error) => Err(CliError::runtime(format!(
"failed to write stdout: {error}"
))),
}
}
fn toon_primitive(value: &serde_json::Value) -> String {
match value {
serde_json::Value::Null => "null".to_string(),
serde_json::Value::Bool(value) => value.to_string(),
serde_json::Value::Number(value) => value.to_string(),
serde_json::Value::String(value) => value.clone(),
serde_json::Value::Array(_) | serde_json::Value::Object(_) => "{}".to_string(),
}
}
/// Writes a single line of text to stdout.
///
/// # Errors
///
/// Returns [`CliError::Runtime`] when stdout cannot be written.
pub fn print_text(text: impl Display) -> Result<(), CliError> {
let mut stdout = io::stdout().lock();
stdout_write_result(writeln!(stdout, "{text}"))
}
/// Writes a formatted error message to stderr.
pub fn print_error(error: &CliError) {
let _ = writeln!(io::stderr().lock(), "{error}");
}
/// Writes a compact first-page help card for usage failures.
pub fn print_quick_help_error(error: &CliError, help: &str) {
let use_color = stderr_supports_color();
let mut stderr = io::stderr().lock();
if use_color {
let _ = writeln!(stderr, "\x1b[31;1merror:\x1b[0m {error}");
} else {
let _ = writeln!(stderr, "error: {error}");
}
let _ = writeln!(stderr);
let _ = write_quick_help(&mut stderr, help, use_color);
}
fn stderr_supports_color() -> bool {
io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none()
}
fn write_quick_help(mut writer: impl Write, help: &str, use_color: bool) -> io::Result<()> {
let summary = first_help_line(help).unwrap_or("Mercury Toolbox command");
let command = quick_help_command(help).unwrap_or("mercury");
let title = format!("{command} - Mercury Toolbox");
write_quick_heading(&mut writer, &title, use_color)?;
writeln!(writer, " {summary}")?;
writeln!(writer)?;
write_named_section(
&mut writer,
help,
"Usage:",
&[
"Commands:",
"Subcommands:",
"Options:",
"Shared Options:",
"Examples:",
],
"Usage:",
5,
use_color,
)?;
write_named_section(
&mut writer,
help,
"Commands:",
&["Options:", "Shared Options:", "Examples:"],
"Commands:",
8,
use_color,
)?;
write_named_section(
&mut writer,
help,
"Subcommands:",
&["Options:", "Shared Options:", "Examples:"],
"Commands:",
8,
use_color,
)?;
write_named_section(
&mut writer,
help,
"Options:",
&["Commands:", "Subcommands:", "Examples:"],
"Common options:",
8,
use_color,
)?;
write_named_section(
&mut writer,
help,
"Shared Options:",
&[
"Commands:",
"Subcommands:",
"Find Options:",
"Body Options:",
"Examples:",
],
"Common options:",
8,
use_color,
)?;
write_named_section(
&mut writer,
help,
"Examples:",
&[],
"Examples:",
3,
use_color,
)?;
if use_color {
writeln!(
writer,
"Type \x1b[1m{command} --help\x1b[0m for the full command reference."
)
} else {
writeln!(
writer,
"Type '{command} --help' for the full command reference."
)
}
}
fn first_help_line(help: &str) -> Option<&str> {
help.lines().map(str::trim).find(|line| !line.is_empty())
}
fn quick_help_command(help: &str) -> Option<&str> {
help_section(
help,
"Usage:",
&[
"Commands:",
"Subcommands:",
"Options:",
"Shared Options:",
"Examples:",
],
)
.and_then(|lines| lines.into_iter().find_map(first_usage_token))
}
fn first_usage_token(line: &str) -> Option<&str> {
line.split_whitespace()
.next()
.filter(|token| token.chars().any(char::is_alphanumeric))
}
fn write_quick_heading(writer: &mut impl Write, heading: &str, use_color: bool) -> io::Result<()> {
if use_color {
writeln!(writer, "\x1b[1;36m{heading}\x1b[0m")
} else {
writeln!(writer, "{heading}")
}
}
fn write_named_section(
writer: &mut impl Write,
help: &str,
source_heading: &str,
stop_headings: &[&str],
display_heading: &str,
limit: usize,
use_color: bool,
) -> io::Result<()> {
if let Some(lines) = help_section(help, source_heading, stop_headings) {
write_quick_heading(writer, display_heading, use_color)?;
write_limited_section(writer, lines, limit)?;
writeln!(writer)?;
}
Ok(())
}
fn help_section<'a>(help: &'a str, heading: &str, stop_headings: &[&str]) -> Option<Vec<&'a str>> {
let mut lines = help.lines();
for line in lines.by_ref() {
if line.trim() == heading {
let mut section = Vec::new();
for candidate in lines {
let trimmed = candidate.trim();
if stop_headings.contains(&trimmed) || is_top_level_help_heading(candidate, trimmed)
{
break;
}
if !trimmed.is_empty() {
section.push(candidate);
}
}
return Some(section);
}
}
None
}
fn is_top_level_help_heading(raw: &str, trimmed: &str) -> bool {
!trimmed.is_empty() && raw == trimmed && trimmed.ends_with(':')
}
fn write_limited_section(
writer: &mut impl Write,
lines: Vec<&str>,
limit: usize,
) -> io::Result<()> {
for line in lines.into_iter().take(limit) {
writeln!(writer, "{line}")?;
}
Ok(())
}
/// Maps a result count to the shared exit code contract.
#[must_use]
pub const fn map_result_count(count: usize) -> ExitCode {
if count == 0 {
ExitCode::NoResults
} else {
ExitCode::Success
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn cli_error_exit_codes_match_categories() {
assert_eq!(CliError::usage("bad").exit_code(), ExitCode::UsageError);
assert_eq!(CliError::runtime("bad").exit_code(), ExitCode::RuntimeError);
}
#[test]
fn print_helpers_succeed() {
assert!(print_json(&json!({"ok": true})).is_ok());
assert!(print_text("ok").is_ok());
}
#[test]
fn stdout_write_errors_ignore_broken_pipe_only() {
assert!(
stdout_write_result(Err(io::Error::new(io::ErrorKind::BrokenPipe, "closed"))).is_ok()
);
assert!(matches!(
stdout_write_result(Err(io::Error::other("disk"))),
Err(CliError::Runtime(message)) if message.contains("failed to write stdout: disk")
));
}
#[test]
fn common_args_render_mode_and_result_mapping_are_stable() {
assert_eq!(CommonArgs::default().render_mode(), RenderMode::Text);
assert_eq!(
CommonArgs {
json: true,
format: None,
quiet: false,
color: ColorChoice::Auto,
}
.render_mode(),
RenderMode::Json
);
assert_eq!(map_result_count(0), ExitCode::NoResults);
assert_eq!(map_result_count(3), ExitCode::Success);
}
#[test]
fn parse_color_choice_accepts_known_values_and_rejects_unknowns() {
assert_eq!(parse_color_choice("auto").expect("auto"), ColorChoice::Auto);
assert_eq!(
parse_color_choice("never").expect("never"),
ColorChoice::Never
);
assert!(matches!(
parse_color_choice("always"),
Err(CliError::Usage(message))
if message.contains("invalid --color value 'always'")
));
}
#[test]
fn format_choice_and_toon_writer_cover_nested_values() {
assert_eq!(parse_format_choice("text").expect("text"), RenderMode::Text);
assert_eq!(parse_format_choice("json").expect("json"), RenderMode::Json);
assert_eq!(parse_format_choice("toon").expect("toon"), RenderMode::Toon);
assert!(parse_format_choice("yaml").is_err());
let mut output = Vec::new();
write_toon_value(
&mut output,
&json!({
"meta": {"ok": true, "count": 2},
"items": ["a", {"nested": true}],
"none": null
}),
0,
)
.expect("toon writer");
let rendered = String::from_utf8(output).expect("utf8");
assert!(rendered.contains("meta:\n"));
assert!(rendered.contains(" ok: true\n"));
assert!(rendered.contains(" count: 2\n"));
assert!(rendered.contains("items:\n - a\n - {}\n"));
assert!(rendered.contains("none: null\n"));
assert_eq!(toon_primitive(&json!("plain")), "plain");
assert_eq!(toon_primitive(&json!({ "nested": true })), "{}");
}
#[test]
fn quick_help_extracts_sections_and_limits_output() {
let help = "\
Example command.
Usage:
example [OPTIONS] <PATH>
Commands:
run
inspect
Options:
--json
--toon
--verbose
Examples:
example README.md
example --json config.json
";
assert_eq!(first_help_line(help), Some("Example command."));
assert_eq!(quick_help_command(help), Some("example"));
assert_eq!(
help_section(help, "Commands:", &["Options:"]).expect("commands"),
vec![" run", " inspect"]
);
assert!(is_top_level_help_heading("Options:", "Options:"));
assert!(!is_top_level_help_heading(" --json", "--json"));
let mut output = Vec::new();
write_quick_help(&mut output, help, false).expect("plain quick help");
let rendered = String::from_utf8(output).expect("utf8");
assert!(rendered.contains("example - Mercury Toolbox"));
assert!(rendered.contains("Common options:"));
assert!(rendered.contains("Type 'example --help'"));
let mut colored = Vec::new();
write_quick_heading(&mut colored, "Title", true).expect("colored heading");
assert!(
String::from_utf8(colored)
.expect("utf8")
.contains("\x1b[1;36m")
);
}
}
+31
View File
@@ -0,0 +1,31 @@
[package]
name = "context"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "Pack files, hits, snippets, and definitions into compact context blocks."
keywords.workspace = true
categories.workspace = true
autobins = false
[lints]
workspace = true
[dependencies]
codeindex = { path = "../codeindex" }
common = { path = "../common", default-features = false }
lexopt.workspace = true
serde.workspace = true
serde_json.workspace = true
[dev-dependencies]
assert_cmd.workspace = true
predicates.workspace = true
[[bin]]
name = "ctxpack"
path = "src/main.rs"
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
//! Binary entry point for `ctxpack`.
fn main() {
std::process::exit(context::main_entry());
}
+129
View File
@@ -0,0 +1,129 @@
//! Integration tests for the `ctxpack` command.
use assert_cmd::Command;
use predicates::prelude::*;
use std::path::PathBuf;
fn cargo_command() -> Command {
Command::cargo_bin("ctxpack").expect("binary")
}
fn fixture_path(relative: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("fixtures")
.join("polyglot")
.join("repo")
.join(relative)
}
fn pwsh_command(script: impl AsRef<str>) -> Command {
let mut command = Command::new("pwsh");
command
.arg("-NoProfile")
.arg("-Command")
.arg(script.as_ref());
command
}
#[test]
fn emits_definition_blocks_for_line_hits() {
let mut command = cargo_command();
command
.arg(format!("{}:26", fixture_path("src/lib.rs").display()))
.assert()
.success()
.stdout(predicate::str::contains("kind=definition"))
.stdout(predicate::str::contains("call_helper"));
}
#[test]
fn emits_json_blocks_from_definition_records() {
let mut command = cargo_command();
command
.arg("--json")
.arg("{\"path\":\"demo.rs\",\"qualified_name\":\"helper\",\"start_line\":1,\"end_line\":2,\"text\":\"fn helper() {}\"}")
.assert()
.failure();
}
#[test]
fn supports_powershell_pipeline_json_input() {
let binary = assert_cmd::cargo::cargo_bin("ctxpack");
let input = "[{\"path\":\"demo.rs\",\"qualified_name\":\"helper\",\"start_line\":1,\"end_line\":2,\"text\":\"fn helper() {}\"}]";
let script = format!(
"'{}' | & '{}' --input-format auto --json | ConvertFrom-Json | Select-Object -First 1 -ExpandProperty header",
input,
binary.display()
);
let mut command = pwsh_command(script);
command
.assert()
.success()
.stdout(predicate::str::contains("helper"));
}
#[test]
fn rejects_scalar_json_from_stdin_auto_mode() {
let mut command = cargo_command();
command
.args(["--json", "--input-format", "auto"])
.write_stdin("42\n")
.assert()
.failure()
.stderr(predicate::str::contains(
"JSON input must be an object or array of objects",
));
}
#[test]
fn rejects_non_array_wrapper_fields_from_jsonl_stdin() {
let mut command = cargo_command();
command
.args(["--json", "--input-format", "jsonl"])
.write_stdin("{\"hits\":{\"path\":\"demo.rs\",\"line\":7}}\n")
.assert()
.failure()
.stderr(predicate::str::contains(
"context JSON field 'hits' must be an array when present",
));
}
#[test]
fn expands_jsonl_wrappers_and_applies_dedupe_limits() {
let input = concat!(
"{\"items\":[{\"path\":\"demo.rs\",\"name\":\"first\",\"text\":\"fn first() {}\"}]}\n",
"{\"path\":\"demo.rs\",\"name\":\"first\",\"text\":\"fn first() {}\"}\n",
"{\"path\":\"demo.rs\",\"name\":\"second\",\"text\":\"fn second() {}\"}\n"
);
let mut command = cargo_command();
command
.args([
"--json",
"--input-format",
"jsonl",
"--dedupe",
"--sort",
"path",
"--max-blocks",
"1",
])
.write_stdin(input)
.assert()
.success()
.stdout(predicate::str::contains("\"header\":\"first\""))
.stdout(predicate::str::contains("\"header\":\"second\"").not());
}
#[test]
fn help_includes_examples_and_pipeline_usage() {
let mut command = cargo_command();
command
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("--max-blocks"))
.stdout(predicate::str::contains("ConvertFrom-Json"))
.stdout(predicate::str::contains("ctxpack"));
}
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "csvshape"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "Summarize large CSV and TSV files with bounded, AI-friendly output."
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[dependencies]
common = { path = "../common", default-features = false }
csv.workspace = true
lexopt.workspace = true
serde.workspace = true
[dev-dependencies]
assert_cmd.workspace = true
predicates.workspace = true
tempfile.workspace = true
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
//! Binary entry point for `csvshape`.
fn main() {
std::process::exit(csvshape::main_entry());
}
+45
View File
@@ -0,0 +1,45 @@
//! Integration tests for the `csvshape` command.
use std::fs;
use assert_cmd::Command;
use predicates::prelude::*;
use tempfile::tempdir;
fn cargo_command() -> Command {
Command::cargo_bin("csvshape").expect("binary")
}
#[test]
fn summarizes_csv_as_json() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("sample.csv");
fs::write(
&path,
"name,age,city\nAda,34,London\nBob,,Paris\nCara,29,Paris\n",
)
.expect("fixture");
cargo_command()
.arg("--json")
.arg(&path)
.assert()
.success()
.stdout(predicate::str::contains("\"row_count\":3"))
.stdout(predicate::str::contains("\"column_count\":3"))
.stdout(predicate::str::contains("\"name\":\"age\""));
}
#[test]
fn help_includes_csv_examples() {
cargo_command()
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains(
"csvshape [OPTIONS] diff <BEFORE> <AFTER>",
))
.stdout(predicate::str::contains("--sample-rows"))
.stdout(predicate::str::contains("ConvertFrom-Json"))
.stdout(predicate::str::contains("csvshape"));
}
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "defsnip"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "Extract full code definitions by exact symbol name with AST-backed matching."
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[dependencies]
codeindex = { path = "../codeindex" }
common = { path = "../common", default-features = false }
lexopt.workspace = true
[dev-dependencies]
assert_cmd.workspace = true
predicates.workspace = true
serde_json.workspace = true
tempfile.workspace = true
+672
View File
@@ -0,0 +1,672 @@
//! The `defsnip` command extracts full AST-backed definitions by exact symbol name.
use codeindex::{
CodeIndexer, CodeLanguage, IndexedSymbol, SUPPORTED_LANGUAGE_LIST, SymbolKind, detect_language,
parse_language_label,
};
use common::{
CliError, CommonArgs, ExitCode, InputFormat, RenderMode, collect_matching_files,
parse_color_choice, parse_format_choice, parse_input_format, print_json,
print_quick_help_error, print_structured, should_read_stdin,
};
use lexopt::prelude::{Long, Short, Value as ArgValue};
use std::collections::BTreeSet;
use std::ffi::OsString;
use std::fmt::Write as _;
use std::fs;
use std::io::{self, Read};
use std::path::PathBuf;
const HELP: &str = "\
Extract full code definitions by exact symbol name via the shared codeindex engine.
Usage:
defsnip [OPTIONS] <SYMBOL> [PATH...]
Options:
--format <FORMAT> Structured output format: text, json, toon
--json Shortcut for --format json
--toon Shortcut for --format toon
--input-format <FORMAT> Override stdin parsing mode: auto, lines, jsonl
--color <WHEN> Control ANSI color output: auto, never
--quiet Suppress non-essential status output
--lang <LIST> Restrict languages: rust,csharp,powershell,python,go,java,javascript,typescript
--kind <LIST> Restrict kinds: module,namespace,class,struct,enum,interface,record,trait,impl,type_alias,function,method,constructor,const,static
--limit <COUNT> Maximum number of matching definitions to emit
--allow-empty Exit 0 when no matching definitions are found
--parents Include parent-chain metadata in text output
-h, --help Show this help text
-V, --version Show the command version
Examples:
defsnip helper .\\fixtures\\polyglot\\repo
defsnip build_report . --json | ConvertFrom-Json
defsnip --kind method --parents Build .\\fixtures\\polyglot\\repo
'.\\fixtures\\polyglot\\repo\\web\\app.ts' | defsnip helper --json | ConvertFrom-Json
Notes:
exact-name lookups can return multiple definitions; narrow with PATH, --kind, or --lang
JSON fields:
engine, path, language, kind, name, qualified_name, signature, start_line, end_line, text
";
#[derive(Debug, Clone)]
struct Cli {
common: CommonArgs,
symbol: String,
paths: Vec<PathBuf>,
languages: Option<BTreeSet<CodeLanguage>>,
kinds: Option<BTreeSet<SymbolKind>>,
limit: usize,
allow_empty: bool,
parents: bool,
}
#[derive(Debug, Clone)]
struct CliDraft {
common: CommonArgs,
paths: Vec<PathBuf>,
languages: Option<BTreeSet<CodeLanguage>>,
kinds: Option<BTreeSet<SymbolKind>>,
limit: usize,
allow_empty: bool,
parents: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ParseOutcome {
Help,
Version,
Run,
}
/// Parses CLI arguments and returns a process exit code.
#[must_use]
pub fn main_entry() -> i32 {
match parse_cli_from(std::env::args_os()) {
Ok((ParseOutcome::Help, _)) => {
print!("{HELP}");
ExitCode::Success.as_i32()
}
Ok((ParseOutcome::Version, _)) => {
println!("defsnip {}", env!("CARGO_PKG_VERSION"));
ExitCode::Success.as_i32()
}
Ok((ParseOutcome::Run, cli)) => match run(&cli) {
Ok(code) => code.as_i32(),
Err(error) => {
print_quick_help_error(&error, HELP);
error.exit_code().as_i32()
}
},
Err(error) => {
print_quick_help_error(&error, HELP);
error.exit_code().as_i32()
}
}
}
fn parse_cli_from<I, T>(args: I) -> Result<(ParseOutcome, Cli), CliError>
where
I: IntoIterator<Item = T>,
T: Into<OsString>,
{
let mut parser = lexopt::Parser::from_iter(args);
let mut draft = CliDraft {
common: CommonArgs::default(),
paths: Vec::new(),
languages: None,
kinds: None,
limit: 20,
allow_empty: false,
parents: false,
};
let mut symbol = None::<String>;
while let Some(argument) = parser
.next()
.map_err(|error| CliError::usage(error.to_string()))?
{
match argument {
Long("help") | Short('h') => {
return Ok((ParseOutcome::Help, draft.clone().into_cli(String::new())));
}
Long("version") | Short('V') => {
return Ok((ParseOutcome::Version, draft.clone().into_cli(String::new())));
}
Long("json") => draft.common.set_render_mode(RenderMode::Json),
Long("toon") => draft.common.set_render_mode(RenderMode::Toon),
Long("format") => {
let value = parser_value_string(&mut parser, "--format")?;
draft.common.set_render_mode(parse_format_choice(&value)?);
}
Long("input-format") => {
draft.common.input_format =
parse_input_format(&parser_value_string(&mut parser, "--input-format")?)?;
}
Long("color") => {
draft.common.color =
parse_color_choice(&parser_value_string(&mut parser, "--color")?)?;
}
Long("quiet") => draft.common.quiet = true,
Long("lang") => {
draft.languages = Some(parse_language_list(&parser_value_string(
&mut parser,
"--lang",
)?)?);
}
Long("kind") => {
draft.kinds = Some(parse_kind_list(&parser_value_string(
&mut parser,
"--kind",
)?)?);
}
Long("limit") => {
draft.limit = parse_positive_usize_flag(
"--limit",
&parser_value_string(&mut parser, "--limit")?,
)?;
}
Long("allow-empty") => draft.allow_empty = true,
Long("parents") => draft.parents = true,
ArgValue(value) => {
if symbol.is_none() {
symbol = Some(os_value_string(value, "symbol")?);
} else {
draft.paths.push(PathBuf::from(value));
}
}
_ => {
return Err(CliError::usage(
"unsupported argument; use --help to see available options",
));
}
}
}
let symbol =
symbol.ok_or_else(|| CliError::usage("provide an exact symbol name to extract"))?;
Ok((ParseOutcome::Run, draft.into_cli(symbol)))
}
impl CliDraft {
fn into_cli(self, symbol: String) -> Cli {
Cli {
common: self.common,
symbol,
paths: self.paths,
languages: self.languages,
kinds: self.kinds,
limit: self.limit,
allow_empty: self.allow_empty,
parents: self.parents,
}
}
}
fn run(cli: &Cli) -> Result<ExitCode, CliError> {
let matches = collect_matches(cli)?;
match cli.common.render_mode() {
RenderMode::Json => print_json(&matches)?,
RenderMode::Toon => print_structured(&matches, RenderMode::Toon)?,
RenderMode::Text => {
if matches.is_empty() {
if !cli.common.quiet {
if cli.allow_empty {
println!("0 matches");
} else {
println!("0 matches (use --allow-empty to exit 0)");
}
}
} else {
print!("{}", render_text(&matches, cli.parents, !cli.common.quiet));
}
}
}
Ok(if matches.is_empty() {
if cli.allow_empty {
ExitCode::Success
} else {
ExitCode::NoResults
}
} else {
ExitCode::Success
})
}
fn collect_matches(cli: &Cli) -> Result<Vec<IndexedSymbol>, CliError> {
let roots = collect_roots(cli)?;
let files = discover_supported_files(&roots)?;
let mut indexer = CodeIndexer::new();
let mut matches = Vec::new();
for path in files {
let source = fs::read_to_string(&path).map_err(|error| {
CliError::runtime(format!("failed to read {}: {error}", path.display()))
})?;
let symbols = indexer.index_source(&path, &source)?;
for symbol in symbols {
if !matches_symbol(&symbol, cli) {
continue;
}
matches.push(symbol);
if matches.len() >= cli.limit {
break;
}
}
if matches.len() >= cli.limit {
break;
}
}
Ok(matches)
}
fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result<String, CliError> {
let value = parser
.value()
.map_err(|error| CliError::usage(error.to_string()))?;
os_value_string(value, flag)
}
fn os_value_string(value: OsString, flag: &str) -> Result<String, CliError> {
value.into_string().map_err(|invalid| {
CliError::usage(format!(
"{flag} expects UTF-8 text, got '{}'",
invalid.to_string_lossy()
))
})
}
fn parse_usize_flag(flag: &str, value: &str) -> Result<usize, CliError> {
value
.parse::<usize>()
.map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}")))
}
fn parse_positive_usize_flag(flag: &str, value: &str) -> Result<usize, CliError> {
let parsed = parse_usize_flag(flag, value)?;
if parsed == 0 {
return Err(CliError::usage(format!("{flag} must be greater than 0")));
}
Ok(parsed)
}
fn parse_language_list(value: &str) -> Result<BTreeSet<CodeLanguage>, CliError> {
let mut languages = BTreeSet::new();
for raw in split_csv_values(value) {
let language = parse_language_label(raw).ok_or_else(|| {
CliError::usage(format!(
"invalid --lang entry '{raw}'; expected {SUPPORTED_LANGUAGE_LIST}"
))
})?;
let _ = languages.insert(language);
}
Ok(languages)
}
fn parse_kind_list(value: &str) -> Result<BTreeSet<SymbolKind>, CliError> {
let mut kinds = BTreeSet::new();
for raw in split_csv_values(value) {
let kind = match raw {
"module" => SymbolKind::Module,
"namespace" => SymbolKind::Namespace,
"class" => SymbolKind::Class,
"struct" => SymbolKind::Struct,
"enum" => SymbolKind::Enum,
"interface" => SymbolKind::Interface,
"record" => SymbolKind::Record,
"trait" => SymbolKind::Trait,
"impl" => SymbolKind::Impl,
"type_alias" => SymbolKind::TypeAlias,
"function" => SymbolKind::Function,
"method" => SymbolKind::Method,
"constructor" => SymbolKind::Constructor,
"const" => SymbolKind::Const,
"static" => SymbolKind::Static,
other => {
return Err(CliError::usage(format!(
"invalid --kind entry '{other}'; expected module,namespace,class,struct,enum,interface,record,trait,impl,type_alias,function,method,constructor,const,static"
)));
}
};
let _ = kinds.insert(kind);
}
Ok(kinds)
}
fn split_csv_values(value: &str) -> impl Iterator<Item = &str> {
value
.split(',')
.map(str::trim)
.filter(|part| !part.is_empty())
}
fn collect_roots(cli: &Cli) -> Result<Vec<PathBuf>, CliError> {
if should_read_stdin(!cli.paths.is_empty(), cli.common.stdin_is_terminal()) {
let mut buffer = String::new();
io::stdin()
.read_to_string(&mut buffer)
.map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?;
let roots = parse_paths_from_string(&buffer, cli.common.input_format)?;
if !roots.is_empty() {
return Ok(roots);
}
}
if cli.paths.is_empty() {
Ok(vec![PathBuf::from(".")])
} else {
common::expand_input_patterns(&cli.paths, "defsnip")
}
}
fn parse_paths_from_string(
buffer: &str,
input_format: InputFormat,
) -> Result<Vec<PathBuf>, CliError> {
common::read_existing_stdin_path_records(buffer, input_format, "defsnip")?
.map_or_else(|| Ok(Vec::new()), Ok)
}
fn discover_supported_files(roots: &[PathBuf]) -> Result<Vec<PathBuf>, CliError> {
collect_matching_files(roots, &|path| detect_language(path).is_some())
}
fn matches_symbol(symbol: &IndexedSymbol, cli: &Cli) -> bool {
if symbol.name != cli.symbol {
return false;
}
if let Some(languages) = &cli.languages {
if !languages.contains(&symbol.language) {
return false;
}
}
if let Some(kinds) = &cli.kinds {
if !kinds.contains(&symbol.kind) {
return false;
}
}
true
}
fn render_text(matches: &[IndexedSymbol], include_parents: bool, include_guidance: bool) -> String {
let mut output = String::new();
if include_guidance && matches.len() > 1 {
let _ = writeln!(
output,
"matches={} narrow_with=path|--kind|--lang",
matches.len()
);
let _ = writeln!(output);
}
for (index, symbol) in matches.iter().enumerate() {
if index > 0 {
let _ = writeln!(output);
}
let _ = writeln!(
output,
"== {}:{}-{} | {} {}",
symbol.path,
symbol.start_line,
symbol.end_line,
language_label(symbol.language),
kind_label(symbol.kind),
);
let _ = writeln!(output, "qualified: {}", symbol.qualified_name);
if include_parents && !symbol.parents.is_empty() {
let _ = writeln!(output, "parents: {}", symbol.parents.join("::"));
}
let _ = writeln!(output, "{}", symbol.text);
}
output
}
const fn language_label(language: CodeLanguage) -> &'static str {
match language {
CodeLanguage::Rust
| CodeLanguage::Csharp
| CodeLanguage::Powershell
| CodeLanguage::Python
| CodeLanguage::Go
| CodeLanguage::Java
| CodeLanguage::Javascript
| CodeLanguage::Typescript => language.label(),
}
}
const fn kind_label(kind: SymbolKind) -> &'static str {
match kind {
SymbolKind::Module => "module",
SymbolKind::Namespace => "namespace",
SymbolKind::Class => "class",
SymbolKind::Struct => "struct",
SymbolKind::Enum => "enum",
SymbolKind::Interface => "interface",
SymbolKind::Record => "record",
SymbolKind::Trait => "trait",
SymbolKind::Impl => "impl",
SymbolKind::TypeAlias => "type_alias",
SymbolKind::Function => "function",
SymbolKind::Method => "method",
SymbolKind::Constructor => "constructor",
SymbolKind::Const => "const",
SymbolKind::Static => "static",
}
}
#[cfg(test)]
mod tests {
use super::*;
use common::ColorChoice;
use std::fs;
use tempfile::tempdir;
fn fixture_repo() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("fixtures")
.join("polyglot")
.join("repo")
}
fn sample_symbol() -> IndexedSymbol {
IndexedSymbol {
engine: codeindex::ENGINE_NAME,
path: "fixture.rs".to_string(),
language: CodeLanguage::Rust,
kind: SymbolKind::Function,
name: "helper".to_string(),
qualified_name: "nested::Widget::helper".to_string(),
signature: "pub fn helper()".to_string(),
parents: vec!["nested".to_string(), "Widget".to_string()],
depth: 2,
start_line: 3,
end_line: 6,
text: "pub fn helper() {\n 42\n}".to_string(),
}
}
fn cli_for(symbol: &str) -> Cli {
Cli {
common: CommonArgs {
json: false,
format: None,
input_format: InputFormat::Auto,
color: ColorChoice::Never,
quiet: false,
},
symbol: symbol.to_string(),
paths: vec![fixture_repo()],
languages: None,
kinds: None,
limit: 20,
allow_empty: false,
parents: false,
}
}
#[test]
fn parse_cli_and_filters_cover_help_version_and_validation() {
assert_eq!(
parse_cli_from(["defsnip", "--help"]).expect("help").0,
ParseOutcome::Help
);
assert_eq!(
parse_cli_from(["defsnip", "--version"]).expect("version").0,
ParseOutcome::Version
);
let (_, cli) = parse_cli_from([
"defsnip",
"--json",
"--input-format",
"jsonl",
"--color",
"never",
"--lang",
"rust,typescript",
"--kind",
"function,method",
"--limit",
"5",
"--parents",
"helper",
"fixtures/polyglot/repo",
])
.expect("parsed cli");
assert!(cli.common.json);
assert_eq!(cli.common.input_format, InputFormat::Jsonl);
assert_eq!(cli.common.color, ColorChoice::Never);
assert_eq!(cli.limit, 5);
assert!(cli.parents);
assert!(
cli.languages
.as_ref()
.expect("languages")
.contains(&CodeLanguage::Rust)
);
assert!(
cli.kinds
.as_ref()
.expect("kinds")
.contains(&SymbolKind::Method)
);
assert!(parse_cli_from(["defsnip"]).is_err());
assert!(parse_language_list("lua").is_err());
assert!(parse_kind_list("macro").is_err());
assert!(parse_usize_flag("--limit", "nope").is_err());
assert!(parse_positive_usize_flag("--limit", "0").is_err());
assert!(os_value_string(OsString::from("helper"), "symbol").is_ok());
}
#[test]
fn path_parsing_and_discovery_cover_auto_jsonl_and_missing_paths() {
let temp = tempdir().expect("tempdir");
let first = temp.path().join("alpha.rs");
let second = temp.path().join("beta.ts");
let third = temp.path().join("plain.rs");
let fourth = temp.path().join("code.py");
fs::write(&first, "a").expect("first");
fs::write(&second, "b").expect("second");
fs::write(&third, "c").expect("third");
fs::write(&fourth, "d").expect("fourth");
let line_paths = parse_paths_from_string(
&format!("{}\n{}\n", first.display(), second.display()),
InputFormat::Lines,
)
.expect("line paths");
assert_eq!(line_paths, vec![first, second]);
let json_paths = parse_paths_from_string(
&format!(
"{{\"path\":{}}}\n",
serde_json::to_string(&third.display().to_string()).expect("json path")
),
InputFormat::Jsonl,
)
.expect("json paths");
assert_eq!(json_paths, vec![third.clone()]);
let auto_paths = parse_paths_from_string(
&format!("{}\n{}\n", third.display(), fourth.display()),
InputFormat::Auto,
)
.expect("auto paths");
assert_eq!(auto_paths, vec![fourth, third]);
assert!(
discover_supported_files(&[fixture_repo()])
.expect("discover")
.len()
>= 8
);
assert!(discover_supported_files(&[fixture_repo().join("missing")]).is_err());
}
#[test]
fn collect_matches_render_and_run_cover_success_and_no_results() {
let mut cli = cli_for("helper");
let matches = collect_matches(&cli).expect("matches");
assert!(matches.len() >= 4);
assert!(matches.iter().all(|symbol| symbol.name == "helper"));
assert!(
matches
.iter()
.all(|symbol| symbol.engine == codeindex::ENGINE_NAME)
);
cli.languages = Some(BTreeSet::from([CodeLanguage::Typescript]));
cli.kinds = Some(BTreeSet::from([SymbolKind::Function]));
cli.limit = 2;
let filtered = collect_matches(&cli).expect("filtered");
assert!(!filtered.is_empty());
assert!(filtered.len() <= 2);
assert!(
filtered
.iter()
.all(|symbol| symbol.language == CodeLanguage::Typescript)
);
let text = render_text(&[sample_symbol()], true, true);
assert!(text.contains("qualified: nested::Widget::helper"));
assert!(text.contains("parents: nested::Widget"));
assert!(text.contains("pub fn helper()"));
assert_eq!(language_label(CodeLanguage::Powershell), "powershell");
assert_eq!(language_label(CodeLanguage::Java), "java");
assert_eq!(kind_label(SymbolKind::Constructor), "constructor");
let ambiguous_text = render_text(&[sample_symbol(), sample_symbol()], false, true);
assert!(ambiguous_text.contains("matches=2"));
assert!(ambiguous_text.contains("narrow_with=path|--kind|--lang"));
let match_cli = cli_for("helper");
assert_eq!(run(&match_cli).expect("run success"), ExitCode::Success);
let missing_cli = cli_for("does_not_exist");
assert_eq!(run(&missing_cli).expect("run empty"), ExitCode::NoResults);
}
#[test]
fn symbol_matching_honors_language_and_kind_filters() {
let symbol = sample_symbol();
let mut cli = cli_for("helper");
assert!(matches_symbol(&symbol, &cli));
cli.languages = Some(BTreeSet::from([CodeLanguage::Rust]));
assert!(matches_symbol(&symbol, &cli));
cli.languages = Some(BTreeSet::from([CodeLanguage::Python]));
assert!(!matches_symbol(&symbol, &cli));
cli.languages = None;
cli.kinds = Some(BTreeSet::from([SymbolKind::Method]));
assert!(!matches_symbol(&symbol, &cli));
}
}
+5
View File
@@ -0,0 +1,5 @@
//! Binary entry point for `defsnip`.
fn main() {
std::process::exit(defsnip::main_entry());
}
+85
View File
@@ -0,0 +1,85 @@
//! Integration tests for the `defsnip` command.
use assert_cmd::Command;
use predicates::prelude::*;
use std::path::PathBuf;
fn cargo_command() -> Command {
Command::cargo_bin("defsnip").expect("binary")
}
fn fixture_path(relative: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("fixtures")
.join("polyglot")
.join("repo")
.join(relative)
}
fn pwsh_command(script: impl AsRef<str>) -> Command {
let mut command = Command::new("pwsh");
command
.arg("-NoProfile")
.arg("-Command")
.arg(script.as_ref());
command
}
#[test]
fn extracts_exact_symbol_matches_as_json() {
let mut command = cargo_command();
command
.arg("--json")
.arg("helper")
.arg(fixture_path(""))
.assert()
.success()
.stdout(predicate::str::contains("\"qualified_name\":\"helper\""))
.stdout(predicate::str::contains(
"\"qualified_name\":\"nested::Widget::helper\"",
))
.stdout(predicate::str::contains("\"qualified_name\":\"Worker::build\"").not());
}
#[test]
fn filters_method_matches_and_renders_text() {
let mut command = cargo_command();
command
.args(["--kind", "method", "--parents", "Build"])
.arg(fixture_path(""))
.assert()
.success()
.stdout(predicate::str::contains("RocketBuilder::Build"))
.stdout(predicate::str::contains("NestedThing::Build"))
.stdout(predicate::str::contains("public void Build(string name)"));
}
#[test]
fn supports_powershell_pipeline_paths() {
let binary = assert_cmd::cargo::cargo_bin("defsnip");
let path = fixture_path("web/app.ts");
let script = format!(
"'{}' | & '{}' helper --json | ConvertFrom-Json | Select-Object -ExpandProperty qualified_name",
path.display(),
binary.display()
);
let mut command = pwsh_command(script);
command
.assert()
.success()
.stdout(predicate::str::contains("helper"));
}
#[test]
fn help_includes_examples_and_pipeline_usage() {
let mut command = cargo_command();
command
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("--kind"))
.stdout(predicate::str::contains("ConvertFrom-Json"))
.stdout(predicate::str::contains("defsnip helper"));
}
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "diagpick"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "Extract actionable diagnostics from noisy logs."
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[dependencies]
codeindex = { path = "../codeindex" }
common = { path = "../common", default-features = false }
lexopt.workspace = true
serde.workspace = true
serde_json.workspace = true
[dev-dependencies]
assert_cmd.workspace = true
predicates.workspace = true
tempfile.workspace = true
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
//! Binary entry point for `diagpick`.
fn main() {
std::process::exit(diagpick::main_entry());
}
+178
View File
@@ -0,0 +1,178 @@
//! Integration tests for the `diagpick` command.
use std::fs;
use std::path::PathBuf;
use tempfile::tempdir;
use assert_cmd::Command;
use predicates::prelude::*;
fn cargo_command() -> Command {
Command::cargo_bin("diagpick").expect("binary")
}
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.canonicalize()
.expect("workspace root")
}
fn fixture_relative(path: &str) -> PathBuf {
PathBuf::from("fixtures").join(path)
}
fn fixture(path: &str) -> PathBuf {
workspace_root().join(fixture_relative(path))
}
fn relative_to_workspace(path: &str) -> String {
fixture_relative(path).display().to_string()
}
fn pwsh_command(script: impl AsRef<str>) -> Command {
let mut command = Command::new("pwsh");
command
.arg("-NoProfile")
.arg("-Command")
.arg(script.as_ref());
command
}
#[test]
fn extracts_rust_diagnostics_in_text_mode() {
let mut command = cargo_command();
command
.current_dir(workspace_root())
.arg(fixture("diag/rust-errors.txt"))
.assert()
.success()
.stdout(predicate::str::contains("severity=error code=E0425"))
.stdout(predicate::str::contains("sample.rs line=25 column=11"))
.stdout(predicate::str::contains("severity=warning code=-"))
.stdout(predicate::str::contains("sample.rs line=33 column=4"));
}
#[test]
fn filters_unity_diagnostics_and_attaches_source_as_json() {
let mut command = cargo_command();
command
.current_dir(workspace_root())
.arg("--json")
.arg("--severity")
.arg("error")
.arg("--with-source")
.arg("--context")
.arg("1")
.arg(fixture("diag/unity-errors.txt"))
.assert()
.success()
.stdout(predicate::str::contains("\"severity\":\"error\""))
.stdout(predicate::str::contains("\"code\":\"CS0103\""))
.stdout(predicate::str::contains("\"path\":\""))
.stdout(predicate::str::contains("sample.cs"))
.stdout(predicate::str::contains("\"start_line\":8"))
.stdout(predicate::str::contains("ComputeScore"))
.stdout(predicate::str::contains("CS0168").not());
}
#[test]
fn supports_powershell_pipeline_input() {
let binary = assert_cmd::cargo::cargo_bin("diagpick");
let input = relative_to_workspace("diag/unity-errors.txt");
let script = format!(
"[System.IO.File]::ReadLines('{}') | & '{}' --json",
input,
binary.display()
);
let mut command = pwsh_command(script);
command
.current_dir(workspace_root())
.assert()
.success()
.stdout(predicate::str::contains("\"code\":\"CS0103\""))
.stdout(predicate::str::contains("\"severity\":\"warning\""));
}
#[test]
fn utf8_bom_stdin_still_extracts_first_diagnostic() {
let mut command = cargo_command();
command
.write_stdin(
"\u{feff}error[E0425]: cannot find value `missing` in this scope\n --> sample.rs:25:11\n",
)
.assert()
.success()
.stdout(predicate::str::contains("severity=error code=E0425"))
.stdout(predicate::str::contains("sample.rs line=25 column=11"));
}
#[test]
fn utf8_bom_log_file_still_extracts_diagnostics() {
let dir = tempdir().expect("tempdir");
let log_path = dir.path().join("bom-rust-errors.txt");
let mut bytes = vec![0xEF, 0xBB, 0xBF];
bytes.extend(fs::read(fixture("diag/rust-errors.txt")).expect("fixture"));
fs::write(&log_path, bytes).expect("log fixture");
let mut command = cargo_command();
command
.current_dir(workspace_root())
.arg(&log_path)
.assert()
.success()
.stdout(predicate::str::contains("severity=error code=E0425"))
.stdout(predicate::str::contains("sample.rs line=25 column=11"));
}
#[test]
fn invalid_utf8_log_file_reports_read_error() {
let dir = tempdir().expect("tempdir");
let log_path = dir.path().join("invalid-utf8.log");
fs::write(&log_path, [0x66, 0x6F, 0x80, 0x6F]).expect("log fixture");
let mut command = cargo_command();
command
.current_dir(workspace_root())
.arg(&log_path)
.assert()
.failure()
.stderr(predicate::str::contains("failed to read"))
.stderr(predicate::str::contains("invalid-utf8.log"));
}
#[test]
fn help_includes_diagnostic_examples() {
let mut command = cargo_command();
command
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("--with-source"))
.stdout(predicate::str::contains("ConvertFrom-Json"))
.stdout(predicate::str::contains(
"diagpick .\\fixtures\\diag\\rust-errors.txt",
));
}
#[test]
fn emits_tool_fallback_for_actionable_cargo_errors() {
let temp = tempdir().expect("tempdir");
let log = temp.path().join("cargo.log");
fs::write(
&log,
"error: package ID specification `missing-crate` did not match any packages\n",
)
.expect("fixture");
let mut command = cargo_command();
command
.arg("--json")
.arg(&log)
.assert()
.success()
.stdout(predicate::str::contains("\"tool_hint\":\"cargo\""))
.stdout(predicate::str::contains("\"path\":\"<cargo>\""));
}
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "dotnetshape"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "Inspect .NET project graphs, package references, and MSBuild configuration shape."
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[dependencies]
common = { path = "../common", default-features = false }
ignore.workspace = true
lexopt.workspace = true
quick-xml.workspace = true
serde.workspace = true
[dev-dependencies]
assert_cmd.workspace = true
predicates.workspace = true
serde_json.workspace = true
tempfile.workspace = true
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
//! Binary entry point for `dotnetshape`.
fn main() {
std::process::exit(dotnetshape::main_entry());
}
+386
View File
@@ -0,0 +1,386 @@
//! Integration tests for the `dotnetshape` command.
use std::fs;
use std::path::Path;
use assert_cmd::Command;
use predicates::prelude::*;
use serde_json::Value;
use tempfile::TempDir;
fn cargo_command() -> Command {
Command::cargo_bin("dotnetshape").expect("binary")
}
fn write_file(path: &Path, contents: &str) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("parent directory");
}
fs::write(path, contents).expect("write fixture");
}
fn json_report(root: &Path) -> Value {
let output = Command::cargo_bin("dotnetshape")
.expect("binary")
.arg("--json")
.arg(root)
.output()
.expect("run dotnetshape");
assert!(
output.status.success(),
"dotnetshape failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
serde_json::from_slice(&output.stdout).expect("json output")
}
fn fixture_repo() -> TempDir {
let temp = tempfile::tempdir().expect("tempdir");
write_file(
&temp.path().join("Directory.Build.props"),
r"<Project>
<PropertyGroup>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AnalysisMode>AllEnabledByDefault</AnalysisMode>
<BaseOutputPath>artifacts/bin/</BaseOutputPath>
<AssemblyName>InheritedName</AssemblyName>
</PropertyGroup>
</Project>",
);
write_file(
&temp.path().join("Directory.Build.targets"),
r"<Project>
<PropertyGroup>
<OutputType>Exe</OutputType>
</PropertyGroup>
</Project>",
);
write_file(
&temp.path().join("Directory.Packages.props"),
r#"<Project>
<ItemGroup>
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
<PackageVersion Update="xunit" Version="2.9.2" />
</ItemGroup>
</Project>"#,
);
write_file(
&temp.path().join("repo.sln"),
"Microsoft Visual Studio Solution File\n",
);
write_file(
&temp.path().join("src").join("App").join("App.csproj"),
r#"<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<AssemblyName>SharedAssembly</AssemblyName>
<OutputType>Library</OutputType>
<OutputPath>artifacts/shared/</OutputPath>
<Nullable Condition="'$(Configuration)' == 'Release'">disable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../Lib/Lib.csproj" />
<ProjectReference Include="../Missing/Missing.csproj" />
<Reference Include="Legacy">
<HintPath>..\lib\Legacy.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Implicit">
<HintPath>..\lib\Implicit.dll</HintPath>
</Reference>
<PackageReference Include="Newtonsoft.Json" />
<PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="Missing.Version" />
</ItemGroup>
<Target Name="ManualCompile">
<Csc Sources="Program.cs" />
</Target>
</Project>"#,
);
write_file(
&temp.path().join("src").join("Lib").join("Lib.csproj"),
r#"<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>netstandard2.0;net8.0</TargetFrameworks>
<AssemblyName>SharedAssembly</AssemblyName>
<OutputPath>artifacts/shared/</OutputPath>
</PropertyGroup>
<ItemGroup>
<Reference Include="PrivateLegacy">
<HintPath>..\lib\PrivateLegacy.dll</HintPath>
<Private>true</Private>
</Reference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageReference Include="xunit" />
</ItemGroup>
</Project>"#,
);
write_file(
&temp
.path()
.join("tests")
.join("Unit.Tests")
.join("Unit.Tests.csproj"),
r#"<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
</Project>"#,
);
write_file(
&temp.path().join("build.ps1"),
"Write-Host build\ncsc.exe Program.cs\ndotnet build src/App/App.csproj\n",
);
temp
}
#[test]
fn help_lists_shared_and_repo_options() {
let mut command = cargo_command();
command
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("--format <FORMAT>"))
.stdout(predicate::str::contains("--json"))
.stdout(predicate::str::contains("--toon"))
.stdout(predicate::str::contains("--max-depth <COUNT>"))
.stdout(predicate::str::contains("--hidden"))
.stdout(predicate::str::contains("dotnetshape . --json"));
}
#[test]
#[allow(clippy::too_many_lines)]
fn json_reports_static_project_shape_and_diagnostics() {
let temp = fixture_repo();
let report = json_report(temp.path());
assert_eq!(report["evaluation_mode"], "static_ancestor_merge");
let projects = report["projects"].as_array().expect("projects");
assert_eq!(projects.len(), 3);
let app = projects
.iter()
.find(|project| project["path"] == "src/App/App.csproj")
.expect("app project");
assert_eq!(app["sdk"], "Microsoft.NET.Sdk");
assert_eq!(app["target_frameworks"], serde_json::json!(["net8.0"]));
assert_eq!(app["assembly_name"], "SharedAssembly");
assert_eq!(app["output_type"], "Library");
assert_eq!(app["nullable"], "enable");
assert_eq!(app["treat_warnings_as_errors"], "true");
assert_eq!(app["analysis_mode"], "AllEnabledByDefault");
assert_eq!(app["kind"], "production");
assert!(
app["conditioned_properties"]
.as_array()
.expect("conditioned properties")
.iter()
.any(|property| property["name"] == "Nullable"
&& property["value"] == "disable"
&& property["condition"]
.as_str()
.is_some_and(|condition| condition.contains("Release")))
);
let references = report["project_references"]
.as_array()
.expect("project refs");
assert!(
references
.iter()
.any(|edge| edge["from"] == "src/App/App.csproj"
&& edge["to"] == "src/Lib/Lib.csproj"
&& edge["resolved"] == true)
);
assert!(
references
.iter()
.any(|edge| edge["from"] == "src/App/App.csproj"
&& edge["to"] == "src/Missing/Missing.csproj"
&& edge["resolved"] == false)
);
let hint_refs = report["reference_hints"].as_array().expect("hint refs");
assert!(hint_refs.iter().any(|edge| edge["include"] == "Legacy"
&& edge["hint_path"] == "../lib/Legacy.dll"
&& edge["private"] == false));
assert!(
hint_refs
.iter()
.any(|edge| edge["include"] == "Implicit" && edge["private"].is_null())
);
assert!(
hint_refs
.iter()
.any(|edge| edge["include"] == "PrivateLegacy" && edge["private"] == true)
);
let packages = report["package_references"].as_array().expect("packages");
assert!(
packages
.iter()
.any(|package| package["include"] == "Newtonsoft.Json"
&& package["version"] == "13.0.3"
&& package["version_source"] == "central")
);
assert!(packages.iter().any(|package| package["include"] == "Dapper"
&& package["version"] == "2.1.66"
&& package["version_source"] == "inline"));
assert!(
packages
.iter()
.any(|package| package["include"] == "Missing.Version"
&& package["version"].is_null()
&& package["version_source"] == "missing")
);
assert!(packages.iter().any(|package| package["include"] == "xunit"
&& package["version"] == "2.9.2"
&& package["version_source"] == "central"));
assert!(projects.iter().any(|project| {
project["path"] == "src/Lib/Lib.csproj"
&& project["kind"] == "test"
&& project["kind_reasons"]
.as_array()
.expect("kind reasons")
.iter()
.any(|reason| {
reason
.as_str()
.is_some_and(|value| value.contains("Microsoft.NET.Test.Sdk"))
})
}));
assert!(projects.iter().any(|project| {
project["path"] == "tests/Unit.Tests/Unit.Tests.csproj"
&& project["kind"] == "test"
&& project["kind_reasons"]
.as_array()
.expect("kind reasons")
.iter()
.any(|reason| {
reason
.as_str()
.is_some_and(|value| value.contains("IsTestProject"))
})
}));
let diagnostics = report["diagnostics"].as_array().expect("diagnostics");
assert!(
diagnostics
.iter()
.any(|diagnostic| diagnostic["kind"] == "duplicate_assembly_name")
);
assert!(
diagnostics
.iter()
.any(|diagnostic| diagnostic["kind"] == "shared_output_path")
);
assert!(
diagnostics
.iter()
.any(|diagnostic| diagnostic["kind"] == "unresolved_project_reference")
);
assert!(
diagnostics
.iter()
.any(|diagnostic| diagnostic["kind"] == "missing_package_version")
);
let bypass_hints = report["build_bypass_hints"]
.as_array()
.expect("bypass hints");
assert!(
bypass_hints
.iter()
.any(|hint| hint["kind"] == "csc_task" && hint["path"] == "src/App/App.csproj")
);
assert!(
bypass_hints.iter().any(|hint| hint["kind"] == "csc_exe"
&& hint["path"] == "build.ps1"
&& hint["line"] == 2)
);
assert!(
bypass_hints
.iter()
.any(|hint| hint["kind"] == "direct_project_build"
&& hint["path"] == "build.ps1"
&& hint["line"] == 3)
);
}
#[test]
fn max_depth_and_hidden_control_project_discovery() {
let temp = tempfile::tempdir().expect("tempdir");
write_file(
&temp.path().join("Root.csproj"),
r"<Project><PropertyGroup><TargetFramework>net8.0</TargetFramework></PropertyGroup></Project>",
);
write_file(
&temp.path().join("deep").join("Nested.csproj"),
r"<Project><PropertyGroup><TargetFramework>net8.0</TargetFramework></PropertyGroup></Project>",
);
write_file(
&temp.path().join(".hidden").join("Hidden.csproj"),
r"<Project><PropertyGroup><TargetFramework>net8.0</TargetFramework></PropertyGroup></Project>",
);
let shallow = json_report_with_args(temp.path(), &["--max-depth", "1"]);
assert_eq!(shallow["projects"].as_array().expect("projects").len(), 1);
assert!(
shallow["projects"]
.as_array()
.expect("projects")
.iter()
.all(|project| project["path"] != "deep/Nested.csproj"
&& project["path"] != ".hidden/Hidden.csproj")
);
let hidden = json_report_with_args(temp.path(), &["--hidden"]);
assert!(
hidden["projects"]
.as_array()
.expect("projects")
.iter()
.any(|project| project["path"] == ".hidden/Hidden.csproj")
);
}
#[test]
fn text_and_toon_outputs_are_structured() {
let temp = fixture_repo();
let mut text = cargo_command();
text.arg(temp.path())
.assert()
.success()
.stdout(predicate::str::contains("dotnetshape"))
.stdout(predicate::str::contains("projects:"))
.stdout(predicate::str::contains("diagnostics:"));
let mut toon = cargo_command();
toon.arg("--toon")
.arg(temp.path())
.assert()
.success()
.stdout(predicate::str::contains("evaluation_mode"))
.stdout(predicate::str::contains("projects"));
}
fn json_report_with_args(root: &Path, args: &[&str]) -> Value {
let mut command = cargo_command();
command.arg("--json");
for arg in args {
command.arg(arg);
}
let output = command.arg(root).output().expect("run dotnetshape");
assert!(
output.status.success(),
"dotnetshape failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
serde_json::from_slice(&output.stdout).expect("json output")
}
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "envdiff"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
readme.workspace = true
publish.workspace = true
description = "Capture and compare environment variable state for AI-friendly debugging."
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[dependencies]
common = { path = "../common", default-features = false }
serde.workspace = true
serde_json.workspace = true
windowsupport = { path = "../windowsupport" }
[dev-dependencies]
assert_cmd.workspace = true
predicates.workspace = true

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