mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-28 21:06:56 -06:00
* test(cli): pin today's command-line behaviour
The later commits change these tests on purpose, so each behaviour change shows in their diff.
* refactor(cli): split main.rs into modules, move only
- output: outln!, stdout state, atomic write, export helpers, view/window helpers
- args: USAGE, option validation, framing options, MCP argument parsing and its tests
- info, convert, sample, mcp: one subcommand each
- perf: synthetic drawing, Rng, perf subcommands
- run: run subcommand and {last} handle chaining
- main: crate docs, mod lines, install_io, open, main, cli, unit tests
No behaviour change.
* refactor(cli): typed errors and run(args, out, err)
cadcraft-cli is now a library plus a thin binary. `cadcraft_cli::run(args, out, err)
-> i32` is the whole program; main.rs only passes `args_os()` and the process
streams. Handlers write through a `Streams` wrapper (output.rs) instead of the
process streams; it keeps the closed-stdout semantics (drop later output, carry
on, a closed pipe is not an error, other write errors are reported once and make
the exit code 1). `mcp` still speaks MCP on the real stdin/stdout.
Errors are typed (thiserror): CliError (error.rs) wraps EngineError, DocError,
IoError, io::Error and the per-module WindowError, ArgsError, RunError,
OutputError and PerfError. `run` prints `error: {e}` and exits 1.
The usage text and the "unknown option/argument" messages stay as one
transitional CliError::Usage(String), printed without the `error: ` prefix.
USAGE stays a const; step 4 (clap) removes both. The engine's IoHooks take
`Result<_, String>`, so the three `.map_err(|e| e.to_string())` in install_io
remain. The hooks are set-once (OnceLock), so installing them on every `run`
call is harmless.
tests/it/usage.rs calls `run` in-process with Vec<u8> writers; the headless mcp
case runs through the binary (it reads the real stdin) and a non-Unicode
argument test is new.
Behaviour changes (old -> new):
- Every error line other than the usage-class ones gains the prefix `error: `
(e.g. `--view needs ...` -> `error: --view needs ...`, `unknown command
`x`` -> `error: unknown command `x``, `invalid parameters for `open`: ...`
-> `error: invalid parameters ...`, `unsupported file type` likewise).
- `cadcraft-cli: cannot connect to HOST:PORT: e` -> `error: cannot connect to
HOST:PORT: e` (the old prefix is replaced).
- A non-Unicode argument: panic, exit 101 -> `error: argument "..." is not
valid Unicode`, exit 1.
- Unchanged: usage text, `X: unknown option --y`, `unknown option --y`,
`mcp: unknown argument ...` (no prefix); warnings and `wrote ...` lines;
the `cadcraft-cli: can't write to stdout: e` report; exit codes.
* refactor(cli): parse arguments with clap
Replace the hand-written parser (USAGE, validate_options, OptionSpec,
framing_option, parse_mcp_args, CliError::Usage) with clap derive types
in args.rs: a Cli parser and one Args struct per subcommand, with the
framing flags (--window, --view) in a FramingArgs struct flattened into
convert and run. USAGE's framing and --cmd chaining text moves into the
subcommands' and flags' doc comments and run's long help, so --help
carries it. `commands` moves from an inline closure to commands.rs.
`command()` exposes the clap definition for man pages and completions.
clap reads the arguments as OsStrings. Help and --version go to stdout
with exit 0; usage errors are clap's, on stderr with exit 2. A bare
--help or -h before `--` shows the subcommand's help even after a
mistake on the line (`run --script --help`). run opens its drawings
(FILEs, --sample, --metric) and runs its steps (--script, --script-file,
--cmd) in command-line order, from ArgMatches::indices_of. Values stay
strings checked by the handlers, with their messages and exit 1
(--window, --view, --cmd JSON, sample names, perf counts). The last of a
repeated flag wins (args_override_self on every subcommand but mcp).
Behaviour changes:
Help and version
- --help, -h, help: the USAGE page -> clap's help, first line
"cadcraft-cli: headless CADCraft".
- bare cadcraft-cli: USAGE on stderr, exit 1 -> clap's help on stderr,
exit 2.
- <subcommand> --help / -h, help <subcommand>: "unknown option --help"
(exit 1), the USAGE page (convert, help <subcommand>) or a file named
-h (info -h, run -h) -> that subcommand's help, stdout, exit 0.
- <subcommand> --version: "unknown option --version", exit 1 -> clap's
unexpected argument, exit 2.
Usage errors (exit 1 -> exit 2, clap's messages)
- Unknown subcommand, empty subcommand, unknown top-level flag: USAGE ->
"error: unrecognized subcommand 'foo'" / "error: unexpected argument
'--bogus' found".
- Unknown flag on any subcommand: "info: unknown option --bogus",
"unknown option --bogus", "mcp: unknown argument --bogus" or USAGE
(convert) -> "error: unexpected argument '--bogus' found".
- A short flag where a file is expected (info -x): opened as a file ->
unexpected argument; `--` before it keeps it a file name.
- A value flag at the end or followed by a flag (--window, --view,
--script, --script-file, --cmd, --save, --export, --connect): "error:
--script needs text" and the like, or the next flag taken as the
value (--window --view, --script --sample, --cmd --sample,
--script-file --sample) -> "error: a value is required for '--script
<TEXT>' but none was supplied". --connect followed by an unknown flag
(--connect --foo, --connect -v) names that flag instead (clap reports
the unknown argument, not the missing value).
- mcp --connect with an empty value: "error: --connect needs HOST:PORT
..." -> "error: invalid value ' ' for '--connect <HOST:PORT>':
expected HOST:PORT (...)"; --connect=-v likewise.
- mcp --connect twice: "error: --connect given more than once" ->
"error: the argument '--connect <HOST:PORT>' cannot be used multiple
times".
- mcp --lazy / --launch without --connect: "error: --lazy and --launch
need --connect HOST:PORT" -> "error: the following required arguments
were not provided: --connect <HOST:PORT>".
- Missing positionals (info, convert with 0 or 1 files, sample with 0
or 1 arguments): USAGE -> "error: the following required arguments
were not provided".
- Extra positionals: convert with three files (USAGE), and info,
commands, perf, sample, which ignored the extra argument -> "error:
unexpected argument 'extra' found".
- perf -5: opened as a file -> unexpected argument '-5'.
- A non-Unicode argument where text is expected: "error: argument ... is
not valid Unicode", exit 1 -> "error: invalid UTF-8 was detected in
one or more arguments", exit 2.
Accepted where they were errors
- --x=v is a spelling of --x v for every value flag (--window, --view,
--script, --script-file, --cmd, --save, --export, --connect); before
it was an unknown option or USAGE. A value starting with - needs the
= form (--cmd=-x).
- commands --: "unknown option --" -> `--` ends the options (all
commands listed).
- run --view extents --view current: "use only one of --window and
--view" -> the last wins, like every other repeat.
Other
- run --sample --sample opens one sample drawing (was two): repeated
flags are last-wins.
- run checks --window/--view values before opening any drawing, so a
bad framing value is reported ahead of a missing file.
- `-h` after a mistake shows the long help (`--help`): the retry asks
for --help.
Unchanged: --window with --view, framing without an image output,
--view's case-insensitivity, all value messages (--window numbers,
--view values, --cmd JSON, {last}, sample names, --script-file read
errors), multiple FILEs, --sample with --metric or a FILE, last-wins
for --save, --export, --window, --script/--cmd order, and `\n` in
--script.
clap forced: exit 2 for usage errors, its message wording, and the
unknown-flag-over-missing-value precedence above.
* refactor(cli): reject repeated flags, long version (photocraft#7071 decisions)
Behaviour changes, old -> new:
- --save, --export, --window, --view, --sample, --metric, --lazy, --launch given twice:
last one won -> usage error, exit 2 ("the argument ... cannot be used multiple times").
--connect was already rejected. --script, --script-file, --cmd and run's FILEs still repeat.
- --version / -V: "cadcraft-cli <version>" -> "cadcraft-cli <version> (<sha>)", sha from
CADCRAFT_BUILD_SHA or "dev", as the desktop app prints it.
- "cadcraft-cli version" is an unrecognized-subcommand usage error (as before; now tested).
- A value starting with "-" needs the = form for every value flag (as before, tested).
--window -10,-10,10,10 stays a usage error ("unexpected argument -1"); write
--window=-10,-10,10,10. clap's allow_negative_numbers does not accept a comma list, and
allow_hyphen_values would let --window swallow the next flag, so neither is set. The
--window help text names the = form.
* refactor(cli): typed values and flag relations
Step 6 of #2228: values are parsed into their types and flag relations
are checked at parse time, so a bad value or combination is a usage
error (exit 2) naming the flag, before any work starts. The handlers
receive typed values only. Runtime failures (files, the engine, a
`{last}` with no earlier handle) stay exit 1.
- --window parses to a Bounds2 (parse_window plus cadcraft_io's
check_window); its WindowError is the value parser's error.
- --view is a ValueEnum (extents, current), case-insensitive; the long
help lists what each value does.
- --window conflicts_with --view. In run, --window/--view require
--export (an ArgGroup "framing" with requires). A framing flag with
an output that isn't an image (convert's OUT, run's --export) is a
UsageError turned into a clap error by into_clap (ArgumentConflict).
- sample's name is a ValueEnum (bracket, floorplan); `floor` stays a
hidden alias.
- perf's N|FILE is one typed positional: a value that parses as a
count is one, clamped to 1..=20000000 as before (so `perf 0` still
times one entity); anything else is a drawing.
- --cmd 'id {json}' parses to an id and a serde_json::Value (default
{}); `{last}` is still filled in when the command runs.
- Paths are PathBufs (info FILE, convert IN/OUT, run FILEs,
--script-file, --save, --export, sample OUT, perf FILE). The engine
and io crates take file names as text, so a path that isn't valid
Unicode is refused by the handler, naming the file (exit 1).
- Deleted: check_framed_output, the --view match, ArgsError,
CliError::Window/Args/UnknownSample, RunError::CmdJson,
PerfError::BadCount.
- run's FILEs, --sample and --metric still combine (each opens another
drawing, the last is current): neither photocraft#7071 nor RFC-0012 requires
otherwise.
Behaviour changes (old -> new):
- --window with a bad value (wrong count, not a number, NaN/inf,
reversed corners, beyond 1e12): exit 1 `error: --window ...` ->
exit 2 `error: invalid value 'V' for '--window <x0,y0,x1,y1>':
<reason>`. The reasons no longer repeat the flag or the value.
- --view with a bad value: exit 1 `error: --view needs `extents` or
`current`` -> exit 2 `error: invalid value 'V' for '--view
<extents|current>'` plus clap's possible-values line.
- --window with --view: exit 1 `error: use only one of --window and
--view` -> exit 2 `error: the argument '--window <x0,y0,x1,y1>'
cannot be used with '--view <extents|current>'`.
- run --window/--view without --export: exit 1 `error: --window/--view
need an image output ...` -> exit 2 `error: the following required
arguments were not provided: --export <OUT.png>`.
- A framing flag with a non-image output: exit 1 `error:
--window/--view only apply to image output (png, svg, pdf), not
`OUT`` -> exit 2 `error: --window only applies to image output (png,
svg, pdf), not `OUT`` (or --view: the message names the flag given).
- sample with an unknown name: exit 1 `error: unknown sample `X`
(bracket, floorplan)` -> exit 2 `error: invalid value 'X' for
'<bracket|floorplan>'` plus the possible values. sample's help lists
the possible values.
- --cmd with JSON that doesn't parse: exit 1 `error: --cmd ID:
<serde error>`, after the earlier steps ran -> exit 2 `error: invalid
value 'V' for '--cmd <id {json}>': <serde error>`, before anything
runs.
- --cmd with an empty id (`--cmd=` or `--cmd ' {}'`): exit 1 `error:
unknown command `` ` -> exit 2 `... expected a command id, then
optionally its JSON parameters`.
- An empty path argument (info "", sample bracket "", perf "", ...):
exit 1 from the engine or io -> exit 2 `error: a value is required
for '<FILE>' but none was supplied` (clap's PathBuf parser).
- A path that isn't valid Unicode: exit 2 `error: invalid UTF-8 was
detected ...` -> exit 1 `error: PATH: the file name is not valid
Unicode` (text values that aren't Unicode stay exit 2). A non-Unicode
--save or --export is refused before any drawing opens.
- perf's help gives the count range.
Tests: usage.rs rows above moved to exit 2 with clap's messages; new
rows for --view with a non-image output, run's framing with a dxf
--export, an empty --cmd id, bad --cmd JSON after a good step, a
`{last}` with no earlier handle, --window beyond 1e12, case-sensitive
sample names and empty paths. The non-Unicode test covers both a path
(exit 1) and text (exit 2). Unit tests: the --window parser tests moved
to args.rs; new ones for --cmd values and perf targets; framing_options
checks the clap error kinds.
* refactor(cadcraft): move launch-argument parsing into launch_args
Moves the hand-written parser (the argument loop, control_port_from, control_flag and their tests) from main.rs into launch_args.rs, which returns LaunchArgs. No behaviour change.
* refactor(cadcraft): parse launch flags with clap (RFC-0012)
Replaces the hand-written loop in launch_args.rs with clap derive types, as photocraft's launch_args.rs does. --help is generated from the doc comments and lists the other CADCRAFT_* variables.
Behaviour changes (old -> new):
- An unknown flag (--sampel) was opened as a file; it is now a usage error on stderr, exit 2, before the window opens. A file starting with "-" goes after "--".
- A repeated --control or --sample was accepted (last wins); it is now exit 2.
- A bad port (--control abc, 70000, -1) was logged and the control channel left off; it is now exit 2. The same for CADCRAFT_CONTROL_PORT, and the error names the variable when the value came from it.
- A bare --control (no value) kept CADCRAFT_CONTROL_PORT's port with a warning; it is now exit 2 ("a value is required"). A following flag is never taken as the value.
- An empty or blank CADCRAFT_CONTROL_PORT was logged as a bad port; it is now unset.
- --control=7979 is accepted (the equals form was opened as a file before).
- --version / -V and --help stop parsing at once and exit 0; version text is unchanged ("cadcraft <version> (<sha>)").
- -psn_* (Finder process serial number) is dropped instead of opened as a file.
- A file name that is not valid Unicode used to panic in std::env::args(); it is now read from args_os and reported with a log warning, not opened, because CadApp::open_path takes text.
Copied from photocraft's crates/automation/src/args.rs: blank_env_is_unset and name_env_vars unchanged (name_env_vars gets a local use for ContextKind/ContextValue); control_port and NotAPort unchanged. allow_negative_numbers is set on --control as in photocraft, so -1 is a bad value rather than a missing one.
* feat(cli): shell completions and man pages
Adds examples/man-and-completions.rs, which writes cadcraft-cli's bash, zsh, fish, elvish and
PowerShell completions (clap_complete) and man pages (clap_mangen: cadcraft-cli.1 and one per
subcommand) into a directory. clap_complete and clap_mangen are dev-dependencies, so the binary
is unchanged.
packaging/linux/package.sh builds the example in its own cargo run, runs it (through $EMULATOR
for cross builds; warns and leaves the files out without one) and installs the man pages and the
bash, zsh and fish completions into the staged tree. nfpm.yaml already ships the whole
usr/share tree, so it needs no change.
CADCraft has no FreeBSD packaging, so there is no FreeBSD script to update. macOS and Windows
packaging are untouched.
tests/man_and_completions.rs checks the generated file names.
* ci(cli): check the command-line code with ast-grep (RFC-0012)
Adds ast-grep rules from RFC-0012, run by craft-common-actions'
reusable ast-grep workflow (Linux, no Rust build, path-filtered):
- clap-error-message: a string literal, format!, .to_string() or
SCREAMING_CASE const passed to Command::error / Error::raw
- clap-error-message-variable: the same message built into a variable
first, in the same function
- string-error: Result<_, String>, Err(format!(..)), Err(".."),
Err(x.to_string())
- args-os: std::env::args(), which panics on non-Unicode arguments
Scan paths: apps/cadcraft-cli/src, apps/cadcraft/src/launch_args.rs,
apps/cadcraft/src/main.rs. Rule tests run first in CI.
Source: photocraft#9750 at d16686cd. Only the scan paths and the
workflow path filters differ from it.
Fixes: none needed. The rule tests pass (4 passed) and the scan finds
nothing in the scanned paths.
* docs(cli): cadcraft-cli page, following RFC-0012
docs/cli.md: --help is the reference; exit codes, streams, usage
errors, values starting with -, paths, completions and man pages; the
environment variables (none for cadcraft-cli, CADCRAFT_CONTROL_PORT for
the desktop app's --control); and the rules not followed yet, with the
reason. The README links it.
* feat(cli): refuse an output that replaces an input
RFC-0012 rule 1.6: nothing destructive by default.
Behaviour change: `convert a.dxf a.dxf`, `run a.dxf … --save a.dxf` and
`run a.dxf … --export a.dxf` used to overwrite the input. They are now a
usage error (exit 2, `error: …` on stderr, nothing opened or written)
unless `--in-place` is given. "Same file" is file identity (same-file
crate), so `dir/./a.dxf` and a symlink to the input count. An output
that does not exist, or a failed identity check, is not refused; the
open or write reports the real error. Replacing an existing file that
is not an input is still allowed.
Scope: `--save` and `--export` only. A save that a --cmd or script runs
(for example `saveas`) is the engine's and is not checked.
Every write the CLI makes (convert OUT, --save, --export, sample OUT) resolves
an existing output first, so writing through a symlink updates the file it
points to and the link stays a link; the atomic rename used to replace the
link with a regular file, leaving the target (with --in-place, the input)
unchanged.
* feat(cli): --compact for JSON output
RFC-0012 rule 1.3: info and commands pretty-print their JSON by
default; --compact prints it on one line. run prints one JSON line per
--cmd result (JSON lines, one result per step), as its help and the
README document, so it takes no --compact. mcp's stdout is the MCP
protocol and is unchanged.
Behaviour change: info and commands accept --compact (it was an
unknown flag, exit 2). Their default output is unchanged.
docs/cli.md says which subcommand prints which form, and lists perf's
text table (no --json) and saves a --cmd or script runs (not checked
by --in-place) under "Not followed yet".
* fix(cadcraft): one Switch parser for boolean environment switches
CADCRAFT_VSYNC, CADCRAFT_SYSTEM_CURSOR, CADCRAFT_WAYLAND and
CADCRAFT_NO_NATIVE_MENU each had their own idea of "on". They now share
cadcraft_ui_egui::switch::Switch: 1/on/true/yes is On, 0/off/false/no is
Off (case- and space-insensitive), unset or blank is Default, and any
other value is Default and logged with the variable name. The ENVIRONMENT
help text says the same for all four.
Behaviour changes (old -> new):
- CADCRAFT_WAYLAND=0 counted as on (any value did); now only On keeps
native Wayland.
- CADCRAFT_NO_NATIVE_MENU=0 counted as on (any value did); now only On
keeps the menus inside the window.
- CADCRAFT_SYSTEM_CURSOR treated every value except ""/0/false/off as on;
now only the On spellings do, and an unknown value is logged.
- CADCRAFT_VSYNC is unchanged except that an unknown value is logged.
* fix(cli): keep paths as paths until a file is opened
A path is now text only where text is needed.
- cadcraft: FileNames.unreadable is Vec<PathBuf>, logged with {:?}
(escaped bytes) instead of a lossy rendering.
- cadcraft-cli: unicode() is called only inside open(), the one place
that sends a path to the engine as JSON. convert, sample and run
--save/--export pass the output as &Path. write_atomic, export,
export_framed and OutputError::Write take or hold a Path/PathBuf;
errors use .display(), and the `wrote ...` line uses .display().
- Dropped the dead find_subcommand lookup in launch_args (LaunchArgs has
no subcommands).
- docs/cli.md: non-Unicode names are refused only where a drawing is
opened.
Behaviour change: an output name that is not valid Unicode used to be
refused (exit 1); it is now written under that exact name. Opening such
a drawing is still refused.
Lossy strings that remain (all feed APIs that take text and only look at
the extension, never at the path the bytes are written to):
- cadcraft_io::write / write_framed (name picks the format) in sample.rs
and output.rs::export_framed;
- cadcraft_io::is_image in convert.rs, run.rs and args.rs (framing flag
check);
- cadcraft_engine::cmd::file::drops_unknown in export_framed.
These go away when cadcraft_io and the engine take impl AsRef<Path>
(the A1 follow-up). A non-Unicode extension is not a known format either
way, so it is rejected as an unsupported type, as before.
* refactor(cadcraft): read the environment once into Env
The desktop app reads its CADCRAFT_* variables once at startup into
apps/cadcraft/src/env.rs `Env` and passes the values on; no library crate
reads a CADCRAFT_* variable at run time any more.
- One `VARS` table (name, help line) feeds both the reader and the
Environment section of `cadcraft --help`. A test checks that every
variable the reader asks for is in the table, that every table entry
sets a field, and that the help lists every entry.
- vsync goes to `gpu::surface_config`, the system cursor to
`CadApp::set_system_cursor_default` (Options reset and Reset Palettes go
back to it), FONTALT/FONTFALLBACK to the `cadcraft_fonts::ttf` setters
(re-exported as `cadcraft_engine::fonts`), the settings directory to the
new `cadcraft_engine::settings_dir::set`, and to `Workspace::load`.
- The three copies of the settings-directory rule (main.rs, workspace.rs,
the engine's plotter folder) are one: `settings_dir::platform`.
- `Switch::from_env` is gone; `Switch::parse` stays.
- The child-process tests for CADCRAFT_CONTROL_PORT are replaced by tests
that inject the variable lookup (`blank_env_is_unset`) or point
`--control`'s fallback at PATH.
cadcraft-cli: plotter configurations matter to its output (`run --cmd plot`
or a script's PLOT with a user device, and the same through `mcp`), and the
font variables change how convert/run/sample draw text. So, following
RFC-0012 rule 1.7, the variables become fallbacks of flags declared with
`env =`: --config-dir (CADCRAFT_CONFIG_DIR), --fontalt (CADCRAFT_FONTALT)
and --fontfallback (CADCRAFT_FONTFALLBACK) on convert, run, mcp and sample.
Behaviour changes:
- CADCRAFT_CONFIG_DIR set but empty counts as unset everywhere: the
workspace file and the plotter folder use the platform folder (was:
workspace persistence and the plotter folder off). Logs and autosave
already treated it so.
- The workspace file and the plotter folder ignore a relative
XDG_CONFIG_HOME, as logs and autosave did (was: used it).
- A blank CADCRAFT_LOCALE means the system's languages (was: [""]).
- cadcraft-cli: the three variables apply only to convert, run, mcp and
sample, as flag fallbacks (was: read by the engine and fonts in every
subcommand, perf included). A font variable that isn't valid Unicode is
a usage error naming the flag (was: ignored).
* refactor(io): one Format enum for file extensions
`cadcraft_doc::Format` (re-exported as `cadcraft_io::Format`) is the one
place that maps a file name's extension to a format: Dxf, Dwg, Dwt, Svg,
Svgz, Png, Pdf. `from_path` is ASCII case-insensitive; no extension (or a
trailing dot) is Dxf; an unknown extension is None. `READ` and `WRITE`
list the formats `read` and `write` accept. It lives in cadcraft-doc
because the engine depends on cadcraft-io only for tests.
Each place used to match on its own lower-cased extension string and now
matches on Format, with the same results:
- io::read: svg/svgz -> SVG reader, dxf/none -> DXF reader, other ->
try DXF, else Unsupported(ext).
- io::write_with: dxf/none -> DXF, dwg -> DWG bridge, other with binary
-> error, rest -> write_framed.
- io::write_framed: dwt -> DWG; dxf/none/dwg -> write_with; svg, png,
pdf -> image writers; other -> Unsupported(ext).
- io::is_image: svg, png, pdf (not svgz, which is read only).
- engine file.rs is_dxf, drops_unknown (dwg, dwt), is_drawing_file
(dxf, dwg, dwt, none).
Behaviour change (help text only): the CLI help lists the formats from
Format::READ / Format::WRITE instead of hand-written lists. `info` and
`convert IN` now name svg and svgz as readable, and `convert`, `sample`,
`run --save` and `run --export` name dwt (and `sample` pdf) as writable.
A test checks each file argument's help names every format of its list.
* refactor: one version string for the app and the CLI
cadcraft_engine::build_version() builds "<version> (<commit>)" once; the
desktop app's --version, its start-up log line and cadcraft-cli --version
all use it. No change in output.
* refactor(cli): share the environment helpers with the CLI
Move blank_env_is_unset and name_env_vars from the desktop app into
cadcraft_mcp::args and apply them in the CLI parser.
Behaviour changes (old -> new):
- Empty or blank CADCRAFT_FONTALT / CADCRAFT_FONTFALLBACK: reached clap as
Some("") -> counts as unset.
- A bad value from an environment variable: the error named the flag ->
names the variable.
- CADCRAFT_CONFIG_DIR is unchanged: empty still means no settings folder.
- Desktop app: unchanged; it uses the shared helpers.
* refactor: pass paths, not text, through io, engine and open_path
cadcraft_io::{read, write, write_with, write_framed, is_image}, the engine's
drops_unknown/is_drawing_file/read_text_file and CadApp::open_path take
paths (impl AsRef<Path> / &Path). The desktop launch arguments and the CLI
no longer convert paths to text to call them.
Behaviour changes (old -> new):
- A dropped file whose name is not valid Unicode opened with a "can't
read" error naming a mangled path; it now opens natively. (Its path is
not remembered for QSAVE, which then asks where to save.)
- A launch argument that is not valid Unicode was logged and skipped; it
now opens like any other.
- A .dwt export builds the .dwg name as a path, not formatted text.
- On the web, a non-Unicode path passed to open_path reports "the path
is not valid Unicode" instead of a lossy name.
- Opening errors print path.display().
FileNames and LaunchArgs::file_names are removed; the launch tests assert
the parsed PathBufs. New io test: non-Unicode names pick the format by
extension.
* docs(cli): CADCRAFT_CONFIG_DIR is the one flag fallback where empty isn't unset
* fix(engine): refuse a drawing path that isn't valid Unicode
read_file opened such a path with no saved path, so QSAVE could not save back to it.
It now returns an open error naming the file before reading it. The ui-egui opener
already reports read_file errors like any other failed open.
* fix(cli): report a failed write of help or version
A write error on help or version output (other than a closed pipe) now exits 1 and
tries to say so on stderr; it was ignored and exited with the clap code. A closed pipe
keeps the clap exit code.
* fix(cli): --connect needs HOST:PORT
--connect example.com was accepted and failed later. It is now a usage error (exit 2)
unless the value is a non-empty host (or a bracketed IPv6 address) and a u16 port.
The host is not resolved.
* docs(cli): run prints no line for a command with no result
* refactor(cli): explicit imports and smaller functions for the stricter lints
Lints: clippy::wildcard_imports, too_many_arguments, cognitive_complexity (30).
Replaced the non-test `use super::*;` in args, commands, convert, info,
output, perf and run with explicit imports; lib.rs keeps only the imports
its non-test code uses, and its test module imports the rest itself. The
`super::*` in the test modules stays.
A clippy run with all three lints and threshold 30 reports nothing in
apps/cadcraft-cli or in code this branch added, so no function needed
splitting.
951 lines
43 KiB
Rust
951 lines
43 KiB
Rust
//! The command line's behaviour: exit codes, streams and messages for help requests, unknown and
|
|
//! malformed options, value flags in both spellings, repeats and arity. Every case goes through
|
|
//! `invoke`, so the table rows are the record of what each input does. Usage errors are clap's
|
|
//! (exit 2, stderr); a subcommand's own failures are `error: …` with exit 1.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
struct Invoke {
|
|
code: i32,
|
|
stdout: String,
|
|
stderr: String,
|
|
}
|
|
|
|
fn invoke(args: &[&str]) -> Invoke {
|
|
let (mut out, mut err) = (Vec::new(), Vec::new());
|
|
let code = cadcraft_cli::run(args, &mut out, &mut err);
|
|
Invoke { code, stdout: String::from_utf8_lossy(&out).into_owned(), stderr: String::from_utf8_lossy(&err).into_owned() }
|
|
}
|
|
|
|
/// What a stream holds.
|
|
#[derive(Clone, Copy)]
|
|
enum Expect {
|
|
Empty,
|
|
/// The first line equals this.
|
|
Line(&'static str),
|
|
/// The first line starts with this.
|
|
Starts(&'static str),
|
|
}
|
|
|
|
use Expect::{Empty, Line, Starts};
|
|
|
|
struct Case {
|
|
name: &'static str,
|
|
args: &'static [&'static str],
|
|
code: i32,
|
|
stdout: Expect,
|
|
stderr: Expect,
|
|
}
|
|
|
|
const fn case(name: &'static str, args: &'static [&'static str], code: i32, stdout: Expect, stderr: Expect) -> Case {
|
|
Case { name, args, code, stdout, stderr }
|
|
}
|
|
|
|
/// A scratch directory holding `b.dxf` (the bracket sample) and `s.scr` (a one-line script).
|
|
struct Scratch(PathBuf);
|
|
|
|
impl Scratch {
|
|
fn new(tag: &str) -> Scratch {
|
|
let dir = std::env::temp_dir().join(format!("cc-cli-usage-{tag}-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).expect("create scratch dir");
|
|
let b = dir.join("b.dxf");
|
|
let r = invoke(&["sample", "bracket", b.to_str().expect("utf-8 path")]);
|
|
assert_eq!(r.code, 0, "{}", r.stderr);
|
|
std::fs::write(dir.join("s.scr"), "LINE 0,0 1,1\n").expect("write script");
|
|
Scratch(dir)
|
|
}
|
|
|
|
/// Replaces `{dir}` with the scratch directory and `{b}` with the sample drawing.
|
|
fn expand(&self, text: &str) -> String {
|
|
let dir = self.0.to_str().expect("utf-8 path");
|
|
text.replace("{b}", &Path::new(dir).join("b.dxf").to_string_lossy()).replace("{dir}", dir)
|
|
}
|
|
}
|
|
|
|
impl Drop for Scratch {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
fn check(what: &str, stream: &str, got: &str, expect: Expect, scratch: &Scratch) {
|
|
let first = got.lines().next().unwrap_or("");
|
|
match expect {
|
|
Empty => assert!(got.is_empty(), "{what}: {stream} should be empty, got {got:?}"),
|
|
Line(l) => assert_eq!(first, scratch.expand(l), "{what}: {stream}"),
|
|
Starts(p) => assert!(first.starts_with(&scratch.expand(p)), "{what}: {stream} should start with {p:?}, got {first:?}"),
|
|
}
|
|
}
|
|
|
|
fn run_cases(tag: &str, cases: &[Case]) {
|
|
let scratch = Scratch::new(tag);
|
|
for c in cases {
|
|
let args: Vec<String> = c.args.iter().map(|a| scratch.expand(a)).collect();
|
|
let args: Vec<&str> = args.iter().map(String::as_str).collect();
|
|
let what = format!("{}: {args:?}", c.name);
|
|
let r = invoke(&args);
|
|
assert_eq!(r.code, c.code, "{what}: exit code (stderr {:?})", r.stderr);
|
|
check(&what, "stdout", &r.stdout, c.stdout, &scratch);
|
|
check(&what, "stderr", &r.stderr, c.stderr, &scratch);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn top_level_requests() {
|
|
run_cases(
|
|
"top",
|
|
&[
|
|
case("--help", &["--help"], 0, Line("cadcraft-cli: headless CADCraft"), Empty),
|
|
case("-h", &["-h"], 0, Line("cadcraft-cli: headless CADCraft"), Empty),
|
|
case("help", &["help"], 0, Line("cadcraft-cli: headless CADCraft"), Empty),
|
|
case("--version", &["--version"], 0, Starts("cadcraft-cli "), Empty),
|
|
case("-V", &["-V"], 0, Starts("cadcraft-cli "), Empty),
|
|
case("version is not a subcommand", &["version"], 2, Empty, Line("error: unrecognized subcommand 'version'")),
|
|
case("bare", &[], 2, Empty, Line("cadcraft-cli: headless CADCraft")),
|
|
case("unknown subcommand", &["foo"], 2, Empty, Line("error: unrecognized subcommand 'foo'")),
|
|
case("unknown top-level flag", &["--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")),
|
|
case("empty subcommand", &[""], 2, Empty, Line("error: unrecognized subcommand ''")),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// Each subcommand's help, from `--help`, `-h` or `help <subcommand>`: its first line is the
|
|
/// subcommand's summary.
|
|
#[test]
|
|
fn subcommand_help_requests() {
|
|
run_cases(
|
|
"subhelp",
|
|
&[
|
|
case("info --help", &["info", "--help"], 0, Line("Print a drawing's summary as JSON"), Empty),
|
|
case("info -h", &["info", "-h"], 0, Line("Print a drawing's summary as JSON"), Empty),
|
|
case(
|
|
"convert --help",
|
|
&["convert", "--help"],
|
|
0,
|
|
Line("Convert a drawing; OUT's extension picks the format (dxf, dwg, dwt, svg, png, pdf)"),
|
|
Empty,
|
|
),
|
|
case(
|
|
"convert -h",
|
|
&["convert", "-h"],
|
|
0,
|
|
Line("Convert a drawing; OUT's extension picks the format (dxf, dwg, dwt, svg, png, pdf)"),
|
|
Empty,
|
|
),
|
|
case(
|
|
"run --help",
|
|
&["run", "--help"],
|
|
0,
|
|
Line("Open drawings (or start a new one), run scripts and commands in order, save or export the result"),
|
|
Empty,
|
|
),
|
|
case(
|
|
"run -h",
|
|
&["run", "-h"],
|
|
0,
|
|
Line("Open drawings (or start a new one), run scripts and commands in order, save or export the result"),
|
|
Empty,
|
|
),
|
|
case("commands --help", &["commands", "--help"], 0, Line("Print the command catalog as JSON"), Empty),
|
|
case(
|
|
"mcp --help",
|
|
&["mcp", "--help"],
|
|
0,
|
|
Line("Run the MCP server on stdio: a headless drawing session, or a bridge to the running app"),
|
|
Empty,
|
|
),
|
|
case(
|
|
"perf --help",
|
|
&["perf", "--help"],
|
|
0,
|
|
Line(
|
|
"Time the engine on a synthetic N-entity drawing (default 200000), or the canvas display-list build of a drawing, zoomed in step by step",
|
|
),
|
|
Empty,
|
|
),
|
|
case("sample --help", &["sample", "--help"], 0, Line("Write a built-in sample drawing"), Empty),
|
|
case(
|
|
"help run",
|
|
&["help", "run"],
|
|
0,
|
|
Line("Open drawings (or start a new one), run scripts and commands in order, save or export the result"),
|
|
Empty,
|
|
),
|
|
case("help info", &["help", "info"], 0, Line("Print a drawing's summary as JSON"), Empty),
|
|
case(
|
|
"help after a flag missing its value",
|
|
&["run", "--sample", "--script", "--help"],
|
|
0,
|
|
Line("Open drawings (or start a new one), run scripts and commands in order, save or export the result"),
|
|
Empty,
|
|
),
|
|
case(
|
|
"help after an unknown flag",
|
|
&["run", "--bogus", "--help"],
|
|
0,
|
|
Line("Open drawings (or start a new one), run scripts and commands in order, save or export the result"),
|
|
Empty,
|
|
),
|
|
case("help after an extra argument", &["info", "{b}", "extra", "-h"], 0, Line("Print a drawing's summary as JSON"), Empty),
|
|
case("--help after -- is a file name", &["run", "--", "--help"], 1, Empty, Starts("error: invalid parameters for `open`: --help:")),
|
|
case("run --version", &["run", "--version"], 2, Empty, Line("error: unexpected argument '--version' found")),
|
|
case("info --version", &["info", "--version"], 2, Empty, Line("error: unexpected argument '--version' found")),
|
|
],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_flags_on_every_subcommand() {
|
|
run_cases(
|
|
"unknown",
|
|
&[
|
|
case("info", &["info", "{b}", "--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")),
|
|
case("info, flag first", &["info", "--bogus", "{b}"], 2, Empty, Line("error: unexpected argument '--bogus' found")),
|
|
case("info, short flag", &["info", "-x"], 2, Empty, Line("error: unexpected argument '-x' found")),
|
|
case("info, a file name starting with - after --", &["info", "--", "-x"], 1, Empty, Starts("error: invalid parameters for `open`: -x:")),
|
|
case("convert", &["convert", "{b}", "{dir}/o.svg", "--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")),
|
|
case("run", &["run", "--sample", "--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")),
|
|
case("run, flag first", &["run", "--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")),
|
|
case("commands", &["commands", "--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")),
|
|
case("commands -- ends the options", &["commands", "--"], 0, Line("["), Empty),
|
|
case("mcp", &["mcp", "--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")),
|
|
case("mcp positional", &["mcp", "extra"], 2, Empty, Line("error: unexpected argument 'extra' found")),
|
|
case("perf", &["perf", "--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")),
|
|
case("sample", &["sample", "--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")),
|
|
case("sample after name", &["sample", "bracket", "--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// `--x=v` is a spelling of `--x v`, and the one a value starting with `-` needs.
|
|
#[test]
|
|
fn value_flags_in_both_forms() {
|
|
run_cases(
|
|
"forms",
|
|
&[
|
|
case("--window v", &["convert", "{b}", "{dir}/w1.svg", "--window", "0,0,100,100"], 0, Empty, Starts("wrote {dir}/w1.svg (")),
|
|
case("--window=v", &["convert", "{b}", "{dir}/w2.svg", "--window=0,0,100,100"], 0, Empty, Starts("wrote {dir}/w2.svg (")),
|
|
case(
|
|
"run --window v",
|
|
&["run", "--sample", "--window", "0,0,100,100", "--export", "{dir}/w3.svg"],
|
|
0,
|
|
Empty,
|
|
Starts("wrote {dir}/w3.svg ("),
|
|
),
|
|
case(
|
|
"run --window=v",
|
|
&["run", "--sample", "--window=0,0,100,100", "--export", "{dir}/w4.svg"],
|
|
0,
|
|
Empty,
|
|
Starts("wrote {dir}/w4.svg ("),
|
|
),
|
|
case("--view v", &["convert", "{b}", "{dir}/v1.svg", "--view", "extents"], 0, Empty, Starts("wrote {dir}/v1.svg (")),
|
|
case("--view=v", &["convert", "{b}", "{dir}/v2.svg", "--view=extents"], 0, Empty, Starts("wrote {dir}/v2.svg (")),
|
|
case("run --view v", &["run", "--sample", "--view", "current", "--export", "{dir}/v3.svg"], 0, Empty, Starts("wrote {dir}/v3.svg (")),
|
|
case("run --view=v", &["run", "--sample", "--view=extents", "--export={dir}/v4.svg"], 0, Empty, Starts("wrote {dir}/v4.svg (")),
|
|
case("--script v", &["run", "--sample", "--script", "LINE 0,0 1,1"], 0, Empty, Line("Command: LINE")),
|
|
case("--script=v", &["run", "--sample", "--script=LINE 0,0 1,1"], 0, Empty, Line("Command: LINE")),
|
|
case("--script-file v", &["run", "--sample", "--script-file", "{dir}/s.scr"], 0, Empty, Starts("Command: LINE")),
|
|
case("--script-file=v", &["run", "--sample", "--script-file={dir}/s.scr"], 0, Empty, Starts("Command: LINE")),
|
|
case("--cmd v", &["run", "--sample", "--cmd", "nosuch"], 1, Empty, Line("error: unknown command `nosuch`")),
|
|
case("--cmd=v", &["run", "--sample", "--cmd=nosuch"], 1, Empty, Line("error: unknown command `nosuch`")),
|
|
case("--cmd=-v", &["run", "--sample", "--cmd=-x"], 1, Empty, Line("error: unknown command `-x`")),
|
|
case("--save v", &["run", "--sample", "--save", "{dir}/s1.dxf"], 0, Empty, Starts("wrote {dir}/s1.dxf (")),
|
|
case("--save=v", &["run", "--sample", "--save={dir}/s2.dxf"], 0, Empty, Starts("wrote {dir}/s2.dxf (")),
|
|
case("--export v", &["run", "--sample", "--export", "{dir}/e1.svg"], 0, Empty, Starts("wrote {dir}/e1.svg (")),
|
|
case("--export=v", &["run", "--sample", "--export={dir}/e2.svg"], 0, Empty, Starts("wrote {dir}/e2.svg (")),
|
|
case(
|
|
"--window=-v: negative coordinates need =",
|
|
&["convert", "{b}", "{dir}/w5.svg", "--window=-1000,-1000,1000,1000"],
|
|
0,
|
|
Empty,
|
|
Starts("wrote {dir}/w5.svg ("),
|
|
),
|
|
case(
|
|
"--window -v: a value starting with - is a flag",
|
|
&["convert", "{b}", "{dir}/w6.svg", "--window", "-1000,-1000,1000,1000"],
|
|
2,
|
|
Empty,
|
|
Line("error: unexpected argument '-1' found"),
|
|
),
|
|
case("--connect v", &["mcp", "--connect", "127.0.0.1:1"], 1, Empty, Starts("error: cannot connect to 127.0.0.1:1:")),
|
|
case("--connect=v", &["mcp", "--connect=127.0.0.1:1"], 1, Empty, Starts("error: cannot connect to 127.0.0.1:1:")),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// A value flag at the end of the arguments, or followed by another flag, is missing its value:
|
|
/// a flag is never taken as a value.
|
|
#[test]
|
|
fn flags_missing_their_value() {
|
|
run_cases(
|
|
"missing",
|
|
&[
|
|
case(
|
|
"convert --window last",
|
|
&["convert", "{b}", "{dir}/o.svg", "--window"],
|
|
2,
|
|
Empty,
|
|
Line("error: a value is required for '--window <x0,y0,x1,y1>' but none was supplied"),
|
|
),
|
|
case(
|
|
"convert --view last",
|
|
&["convert", "{b}", "{dir}/o.svg", "--view"],
|
|
2,
|
|
Empty,
|
|
Line("error: a value is required for '--view <extents|current>' but none was supplied"),
|
|
),
|
|
case(
|
|
"convert --window then flag",
|
|
&["convert", "{b}", "{dir}/o.svg", "--window", "--view", "extents"],
|
|
2,
|
|
Empty,
|
|
Line("error: a value is required for '--window <x0,y0,x1,y1>' but none was supplied"),
|
|
),
|
|
case(
|
|
"convert --view then flag",
|
|
&["convert", "{b}", "{dir}/o.svg", "--view", "--window", "0,0,1,1"],
|
|
2,
|
|
Empty,
|
|
Line("error: a value is required for '--view <extents|current>' but none was supplied"),
|
|
),
|
|
case(
|
|
"run --window last",
|
|
&["run", "--sample", "--window"],
|
|
2,
|
|
Empty,
|
|
Line("error: a value is required for '--window <x0,y0,x1,y1>' but none was supplied"),
|
|
),
|
|
case(
|
|
"run --view last",
|
|
&["run", "--sample", "--view"],
|
|
2,
|
|
Empty,
|
|
Line("error: a value is required for '--view <extents|current>' but none was supplied"),
|
|
),
|
|
case(
|
|
"run --script last",
|
|
&["run", "--sample", "--script"],
|
|
2,
|
|
Empty,
|
|
Line("error: a value is required for '--script <TEXT>' but none was supplied"),
|
|
),
|
|
case(
|
|
"run --script then flag",
|
|
&["run", "--sample", "--script", "--sample"],
|
|
2,
|
|
Empty,
|
|
Line("error: a value is required for '--script <TEXT>' but none was supplied"),
|
|
),
|
|
case(
|
|
"run --script-file last",
|
|
&["run", "--script-file"],
|
|
2,
|
|
Empty,
|
|
Line("error: a value is required for '--script-file <F.scr>' but none was supplied"),
|
|
),
|
|
case(
|
|
"run --script-file then flag",
|
|
&["run", "--sample", "--script-file", "--sample"],
|
|
2,
|
|
Empty,
|
|
Line("error: a value is required for '--script-file <F.scr>' but none was supplied"),
|
|
),
|
|
case(
|
|
"run --cmd last",
|
|
&["run", "--sample", "--cmd"],
|
|
2,
|
|
Empty,
|
|
Line("error: a value is required for '--cmd <id {json}>' but none was supplied"),
|
|
),
|
|
case(
|
|
"run --cmd then flag",
|
|
&["run", "--sample", "--cmd", "--sample"],
|
|
2,
|
|
Empty,
|
|
Line("error: a value is required for '--cmd <id {json}>' but none was supplied"),
|
|
),
|
|
case(
|
|
"run --save last",
|
|
&["run", "--sample", "--save"],
|
|
2,
|
|
Empty,
|
|
Line("error: a value is required for '--save <OUT.dxf>' but none was supplied"),
|
|
),
|
|
case(
|
|
"run --export last",
|
|
&["run", "--sample", "--export"],
|
|
2,
|
|
Empty,
|
|
Line("error: a value is required for '--export <OUT.png>' but none was supplied"),
|
|
),
|
|
case(
|
|
"mcp --connect last",
|
|
&["mcp", "--connect"],
|
|
2,
|
|
Empty,
|
|
Line("error: a value is required for '--connect <HOST:PORT>' but none was supplied"),
|
|
),
|
|
case(
|
|
"mcp --connect then flag",
|
|
&["mcp", "--connect", "--lazy"],
|
|
2,
|
|
Empty,
|
|
Line("error: a value is required for '--connect <HOST:PORT>' but none was supplied"),
|
|
),
|
|
case(
|
|
"mcp --connect empty",
|
|
&["mcp", "--connect", " "],
|
|
2,
|
|
Empty,
|
|
Line("error: invalid value ' ' for '--connect <HOST:PORT>': expected HOST:PORT (the app's --control port, e.g. 127.0.0.1:7979)"),
|
|
),
|
|
case(
|
|
"mcp --connect without a port",
|
|
&["mcp", "--connect", "example.com"],
|
|
2,
|
|
Empty,
|
|
Line(
|
|
"error: invalid value 'example.com' for '--connect <HOST:PORT>': expected HOST:PORT (the app's --control port, e.g. 127.0.0.1:7979)",
|
|
),
|
|
),
|
|
case(
|
|
"mcp --connect port out of range",
|
|
&["mcp", "--connect", "host:99999"],
|
|
2,
|
|
Empty,
|
|
Line(
|
|
"error: invalid value 'host:99999' for '--connect <HOST:PORT>': expected HOST:PORT (the app's --control port, e.g. 127.0.0.1:7979)",
|
|
),
|
|
),
|
|
case(
|
|
"mcp --connect=-v",
|
|
&["mcp", "--connect=-v"],
|
|
2,
|
|
Empty,
|
|
Line("error: invalid value '-v' for '--connect <HOST:PORT>': expected HOST:PORT (the app's --control port, e.g. 127.0.0.1:7979)"),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn repeated_and_combined_flags() {
|
|
run_cases(
|
|
"repeat",
|
|
&[
|
|
case(
|
|
"--save twice",
|
|
&["run", "--sample", "--save", "{dir}/a.dxf", "--save", "{dir}/b2.dxf"],
|
|
2,
|
|
Empty,
|
|
Line("error: the argument '--save <OUT.dxf>' cannot be used multiple times"),
|
|
),
|
|
case(
|
|
"--export twice",
|
|
&["run", "--sample", "--export", "{dir}/x1.svg", "--export", "{dir}/x2.svg"],
|
|
2,
|
|
Empty,
|
|
Line("error: the argument '--export <OUT.png>' cannot be used multiple times"),
|
|
),
|
|
case(
|
|
"--window twice",
|
|
&["run", "--sample", "--window", "0,0,1,1", "--window", "0,0,2,2", "--export", "{dir}/ww.svg"],
|
|
2,
|
|
Empty,
|
|
Line("error: the argument '--window <x0,y0,x1,y1>' cannot be used multiple times"),
|
|
),
|
|
case(
|
|
"convert --window twice",
|
|
&["convert", "{b}", "{dir}/cw.svg", "--window", "0,0,1,1", "--window", "0,0,2,2"],
|
|
2,
|
|
Empty,
|
|
Line("error: the argument '--window <x0,y0,x1,y1>' cannot be used multiple times"),
|
|
),
|
|
case(
|
|
"--view twice, different",
|
|
&["run", "--sample", "--view", "extents", "--view", "current", "--export", "{dir}/vv.svg"],
|
|
2,
|
|
Empty,
|
|
Line("error: the argument '--view <extents|current>' cannot be used multiple times"),
|
|
),
|
|
case(
|
|
"--view twice, same",
|
|
&["run", "--sample", "--view", "extents", "--view", "extents", "--export", "{dir}/vs.svg"],
|
|
2,
|
|
Empty,
|
|
Line("error: the argument '--view <extents|current>' cannot be used multiple times"),
|
|
),
|
|
case("--sample twice", &["run", "--sample", "--sample"], 2, Empty, Line("error: the argument '--sample' cannot be used multiple times")),
|
|
case("--metric twice", &["run", "--metric", "--metric"], 2, Empty, Line("error: the argument '--metric' cannot be used multiple times")),
|
|
case(
|
|
"--lazy twice",
|
|
&["mcp", "--connect", "127.0.0.1:1", "--lazy", "--lazy"],
|
|
2,
|
|
Empty,
|
|
Line("error: the argument '--lazy' cannot be used multiple times"),
|
|
),
|
|
case(
|
|
"--launch twice",
|
|
&["mcp", "--connect", "127.0.0.1:1", "--launch", "--launch"],
|
|
2,
|
|
Empty,
|
|
Line("error: the argument '--launch' cannot be used multiple times"),
|
|
),
|
|
case(
|
|
"--script, --script-file and --cmd repeat",
|
|
&[
|
|
"run",
|
|
"--sample",
|
|
"--script",
|
|
"LINE 0,0 1,1",
|
|
"--script",
|
|
"LINE 0,0 2,2",
|
|
"--script-file",
|
|
"{dir}/s.scr",
|
|
"--script-file",
|
|
"{dir}/s.scr",
|
|
],
|
|
0,
|
|
Empty,
|
|
Starts("Command: LINE"),
|
|
),
|
|
case(
|
|
"--window and --view",
|
|
&["run", "--sample", "--window", "0,0,1,1", "--view", "extents", "--export", "{dir}/wv.svg"],
|
|
2,
|
|
Empty,
|
|
Line("error: the argument '--window <x0,y0,x1,y1>' cannot be used with '--view <extents|current>'"),
|
|
),
|
|
case(
|
|
"convert --window and --view",
|
|
&["convert", "{b}", "{dir}/cwv.svg", "--window", "0,0,1,1", "--view", "extents"],
|
|
2,
|
|
Empty,
|
|
Line("error: the argument '--window <x0,y0,x1,y1>' cannot be used with '--view <extents|current>'"),
|
|
),
|
|
case(
|
|
"--view is case-insensitive",
|
|
&["run", "--sample", "--view", "EXTENTS", "--export", "{dir}/vu.svg"],
|
|
0,
|
|
Empty,
|
|
Starts("wrote {dir}/vu.svg ("),
|
|
),
|
|
case(
|
|
"--connect twice",
|
|
&["mcp", "--connect", "127.0.0.1:1", "--connect", "127.0.0.1:2"],
|
|
2,
|
|
Empty,
|
|
Line("error: the argument '--connect <HOST:PORT>' cannot be used multiple times"),
|
|
),
|
|
case("--lazy without --connect", &["mcp", "--lazy"], 2, Empty, Line("error: the following required arguments were not provided:")),
|
|
case("--launch without --connect", &["mcp", "--launch"], 2, Empty, Line("error: the following required arguments were not provided:")),
|
|
case("--sample --metric: both accepted", &["run", "--sample", "--metric"], 0, Empty, Empty),
|
|
case("--sample and a file", &["run", "--sample", "{b}"], 0, Empty, Empty),
|
|
case("two files", &["run", "{b}", "{b}"], 0, Empty, Empty),
|
|
case(
|
|
"--window without export",
|
|
&["run", "--sample", "--window", "0,0,1,1"],
|
|
2,
|
|
Empty,
|
|
Line("error: the following required arguments were not provided:"),
|
|
),
|
|
case(
|
|
"--view without export",
|
|
&["run", "--sample", "--view", "current"],
|
|
2,
|
|
Empty,
|
|
Line("error: the following required arguments were not provided:"),
|
|
),
|
|
case(
|
|
"--window with a dxf output",
|
|
&["convert", "{b}", "{dir}/o.dxf", "--window", "0,0,1,1"],
|
|
2,
|
|
Empty,
|
|
Line("error: --window only applies to image output (png, svg, pdf), not `{dir}/o.dxf`"),
|
|
),
|
|
case(
|
|
"--view with a dxf output",
|
|
&["convert", "{b}", "{dir}/o.dxf", "--view", "extents"],
|
|
2,
|
|
Empty,
|
|
Line("error: --view only applies to image output (png, svg, pdf), not `{dir}/o.dxf`"),
|
|
),
|
|
case(
|
|
"run --window with a dxf export",
|
|
&["run", "--sample", "--window", "0,0,1,1", "--export", "{dir}/o.dxf"],
|
|
2,
|
|
Empty,
|
|
Line("error: --window only applies to image output (png, svg, pdf), not `{dir}/o.dxf`"),
|
|
),
|
|
case(
|
|
"run --view with a dxf export",
|
|
&["run", "--sample", "--view", "current", "--export", "{dir}/o.dxf"],
|
|
2,
|
|
Empty,
|
|
Line("error: --view only applies to image output (png, svg, pdf), not `{dir}/o.dxf`"),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn arity_and_positional_arguments() {
|
|
run_cases(
|
|
"arity",
|
|
&[
|
|
case("info without a file", &["info"], 2, Empty, Line("error: the following required arguments were not provided:")),
|
|
case("info with two files", &["info", "{b}", "extra"], 2, Empty, Line("error: unexpected argument 'extra' found")),
|
|
case("info missing file", &["info", "missing.dxf"], 1, Empty, Starts("error: invalid parameters for `open`: missing.dxf:")),
|
|
case("convert with no files", &["convert"], 2, Empty, Line("error: the following required arguments were not provided:")),
|
|
case("convert with one file", &["convert", "{b}"], 2, Empty, Line("error: the following required arguments were not provided:")),
|
|
case(
|
|
"convert with three files",
|
|
&["convert", "{b}", "{dir}/o.svg", "{dir}/x.svg"],
|
|
2,
|
|
Empty,
|
|
Line("error: unexpected argument '{dir}/x.svg' found"),
|
|
),
|
|
case("run missing file", &["run", "missing.dxf"], 1, Empty, Starts("error: invalid parameters for `open`: missing.dxf:")),
|
|
case("commands, no match", &["commands", "zzzznomatch"], 0, Line("[]"), Empty),
|
|
case(
|
|
"commands with an extra argument",
|
|
&["commands", "zzzznomatch", "extra"],
|
|
2,
|
|
Empty,
|
|
Line("error: unexpected argument 'extra' found"),
|
|
),
|
|
case("sample without arguments", &["sample"], 2, Empty, Line("error: the following required arguments were not provided:")),
|
|
case("sample without an output", &["sample", "bracket"], 2, Empty, Line("error: the following required arguments were not provided:")),
|
|
case(
|
|
"sample with a bad name",
|
|
&["sample", "nosuch", "{dir}/o.dxf"],
|
|
2,
|
|
Empty,
|
|
Line("error: invalid value 'nosuch' for '<bracket|floorplan>'"),
|
|
),
|
|
case(
|
|
"sample names are case-sensitive",
|
|
&["sample", "Bracket", "{dir}/o.dxf"],
|
|
2,
|
|
Empty,
|
|
Line("error: invalid value 'Bracket' for '<bracket|floorplan>'"),
|
|
),
|
|
case(
|
|
"sample with an empty output",
|
|
&["sample", "bracket", ""],
|
|
2,
|
|
Empty,
|
|
Line("error: a value is required for '<OUT>' but none was supplied"),
|
|
),
|
|
case("info with an empty file name", &["info", ""], 2, Empty, Line("error: a value is required for '<FILE>' but none was supplied")),
|
|
case("perf with an empty file name", &["perf", ""], 2, Empty, Line("error: a value is required for '[N|FILE]' but none was supplied")),
|
|
case(
|
|
"sample with an extra argument",
|
|
&["sample", "bracket", "{dir}/sx.dxf", "extra"],
|
|
2,
|
|
Empty,
|
|
Line("error: unexpected argument 'extra' found"),
|
|
),
|
|
case("sample floor alias", &["sample", "floor", "{dir}/sf.dxf"], 0, Empty, Starts("wrote {dir}/sf.dxf (")),
|
|
case("sample with a bad extension", &["sample", "bracket", "{dir}/o.xyz"], 1, Empty, Line("error: unsupported file type `xyz`")),
|
|
case("perf with a word", &["perf", "abc"], 1, Empty, Starts("error: invalid parameters for `open`: abc:")),
|
|
case("perf with a negative number", &["perf", "-5"], 2, Empty, Line("error: unexpected argument '-5' found")),
|
|
case("perf with a missing file", &["perf", "missing.dxf"], 1, Empty, Starts("error: invalid parameters for `open`: missing.dxf:")),
|
|
case("perf 1", &["perf", "1"], 0, Starts("operation"), Empty),
|
|
case("perf 0 is raised to 1", &["perf", "0"], 0, Starts("operation"), Empty),
|
|
case("perf with two counts", &["perf", "1", "2"], 2, Empty, Line("error: unexpected argument '2' found")),
|
|
],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn bad_values() {
|
|
run_cases(
|
|
"values",
|
|
&[
|
|
case(
|
|
"--view sideways",
|
|
&["convert", "{b}", "{dir}/o.svg", "--view", "sideways"],
|
|
2,
|
|
Empty,
|
|
Line("error: invalid value 'sideways' for '--view <extents|current>'"),
|
|
),
|
|
case(
|
|
"run --view sideways",
|
|
&["run", "--sample", "--view", "sideways"],
|
|
2,
|
|
Empty,
|
|
Line("error: invalid value 'sideways' for '--view <extents|current>'"),
|
|
),
|
|
case(
|
|
"--view sideways before the output check",
|
|
&["convert", "{b}", "{dir}/o.dxf", "--view", "sideways"],
|
|
2,
|
|
Empty,
|
|
Line("error: invalid value 'sideways' for '--view <extents|current>'"),
|
|
),
|
|
case(
|
|
"--window 3 numbers",
|
|
&["convert", "{b}", "{dir}/o.svg", "--window", "1,2,3"],
|
|
2,
|
|
Empty,
|
|
Line("error: invalid value '1,2,3' for '--window <x0,y0,x1,y1>': expected four numbers x0,y0,x1,y1"),
|
|
),
|
|
case(
|
|
"--window 5 numbers",
|
|
&["convert", "{b}", "{dir}/o.svg", "--window", "1,2,3,4,5"],
|
|
2,
|
|
Empty,
|
|
Line("error: invalid value '1,2,3,4,5' for '--window <x0,y0,x1,y1>': expected four numbers x0,y0,x1,y1"),
|
|
),
|
|
case(
|
|
"--window reversed",
|
|
&["convert", "{b}", "{dir}/o.svg", "--window", "3,3,1,1"],
|
|
2,
|
|
Empty,
|
|
Line("error: invalid value '3,3,1,1' for '--window <x0,y0,x1,y1>': need x0 < x1 and y0 < y1 (lower-left corner first)"),
|
|
),
|
|
case(
|
|
"--window not numbers",
|
|
&["convert", "{b}", "{dir}/o.svg", "--window", "a,b,c,d"],
|
|
2,
|
|
Empty,
|
|
Line("error: invalid value 'a,b,c,d' for '--window <x0,y0,x1,y1>': x0 must be a finite number, got `a`"),
|
|
),
|
|
case(
|
|
"--window NaN",
|
|
&["convert", "{b}", "{dir}/o.svg", "--window", "NaN,0,1,1"],
|
|
2,
|
|
Empty,
|
|
Line("error: invalid value 'NaN,0,1,1' for '--window <x0,y0,x1,y1>': x0 must be a finite number, got `NaN`"),
|
|
),
|
|
case(
|
|
"--window too large",
|
|
&["convert", "{b}", "{dir}/o.svg", "--window", "0,0,1e13,1"],
|
|
2,
|
|
Empty,
|
|
Line("error: invalid value '0,0,1e13,1' for '--window <x0,y0,x1,y1>': export window: coordinates must lie within ±1e12"),
|
|
),
|
|
case(
|
|
"run --window 3 numbers",
|
|
&["run", "--sample", "--window", "1,2,3", "--export", "{dir}/o.svg"],
|
|
2,
|
|
Empty,
|
|
Line("error: invalid value '1,2,3' for '--window <x0,y0,x1,y1>': expected four numbers x0,y0,x1,y1"),
|
|
),
|
|
case(
|
|
"run --window reversed",
|
|
&["run", "--sample", "--window", "3,3,1,1", "--export", "{dir}/o.svg"],
|
|
2,
|
|
Empty,
|
|
Line("error: invalid value '3,3,1,1' for '--window <x0,y0,x1,y1>': need x0 < x1 and y0 < y1 (lower-left corner first)"),
|
|
),
|
|
case(
|
|
"--cmd bad json",
|
|
&["run", "--sample", "--cmd", "line {bad json"],
|
|
2,
|
|
Empty,
|
|
Line("error: invalid value 'line {bad json' for '--cmd <id {json}>': key must be a string at line 1 column 2"),
|
|
),
|
|
case(
|
|
"--cmd bad json after a good step: nothing runs",
|
|
&["run", "--sample", "--cmd", "line {\"points\":[[0,0],[1,1]]}", "--cmd", "line {"],
|
|
2,
|
|
Empty,
|
|
Starts("error: invalid value 'line {' for '--cmd <id {json}>': "),
|
|
),
|
|
case(
|
|
"--cmd without an id",
|
|
&["run", "--sample", "--cmd", " {}"],
|
|
2,
|
|
Empty,
|
|
Line("error: invalid value ' {}' for '--cmd <id {json}>': expected a command id, then optionally its JSON parameters"),
|
|
),
|
|
case(
|
|
"--cmd empty",
|
|
&["run", "--sample", "--cmd="],
|
|
2,
|
|
Empty,
|
|
Line("error: invalid value '' for '--cmd <id {json}>': expected a command id, then optionally its JSON parameters"),
|
|
),
|
|
case("--cmd unknown id", &["run", "--sample", "--cmd", "nosuch"], 1, Empty, Line("error: unknown command `nosuch`")),
|
|
case(
|
|
"--cmd {last} with no earlier handle",
|
|
&["run", "--sample", "--cmd", "erase {\"handles\":[\"{last}\"]}"],
|
|
1,
|
|
Empty,
|
|
Line("error: --cmd erase: `{last}` needs an earlier --cmd in this run to have returned a handle"),
|
|
),
|
|
case("--script-file missing", &["run", "--sample", "--script-file", "missing.scr"], 1, Empty, Starts("error: missing.scr:")),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// `mcp` serves on the process's real stdin and stdout, so the headless server runs through the
|
|
/// binary, with a closed stdin.
|
|
#[test]
|
|
fn mcp_headless_with_a_closed_stdin() {
|
|
let out = std::process::Command::new(env!("CARGO_BIN_EXE_cadcraft-cli"))
|
|
.arg("mcp")
|
|
.stdin(std::process::Stdio::null())
|
|
.output()
|
|
.expect("run cadcraft-cli");
|
|
assert_eq!(out.status.code(), Some(0));
|
|
assert!(out.stdout.is_empty() && out.stderr.is_empty());
|
|
}
|
|
|
|
/// A drawing to open whose name isn't valid Unicode reaches the handler as a path; the engine opens
|
|
/// files by text name, so it is refused there (exit 1), naming the file. Text that isn't Unicode is
|
|
/// a usage error.
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn non_unicode_arguments() {
|
|
use std::ffi::OsString;
|
|
use std::os::unix::ffi::OsStringExt;
|
|
let invoke_os = |args: Vec<OsString>| {
|
|
let (mut out, mut err) = (Vec::new(), Vec::new());
|
|
let code = cadcraft_cli::run(args, &mut out, &mut err);
|
|
assert!(out.is_empty());
|
|
(code, String::from_utf8_lossy(&err).into_owned())
|
|
};
|
|
let latin1 = || OsString::from_vec(b"caf\xe9.dxf".to_vec());
|
|
let (code, err) = invoke_os(vec![OsString::from("info"), latin1()]);
|
|
assert_eq!(code, 1);
|
|
assert_eq!(err.lines().next(), Some("error: caf\u{FFFD}.dxf: the file name is not valid Unicode"));
|
|
let (code, err) = invoke_os(vec![OsString::from("run"), OsString::from("--sample"), OsString::from("--cmd"), latin1()]);
|
|
assert_eq!(code, 2);
|
|
assert!(err.starts_with("error: invalid UTF-8 was detected"), "{err}");
|
|
}
|
|
|
|
/// An output name that isn't valid Unicode is written as given: only its extension picks the format.
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn non_unicode_output_names() {
|
|
use std::ffi::OsString;
|
|
use std::os::unix::ffi::OsStringExt;
|
|
let s = Scratch::new("nonunicode-out");
|
|
let latin1 = |ext: &str| {
|
|
let mut n = b"caf\xe9.".to_vec();
|
|
n.extend_from_slice(ext.as_bytes());
|
|
s.0.join(OsString::from_vec(n))
|
|
};
|
|
let run = |args: Vec<OsString>| {
|
|
let (mut out, mut err) = (Vec::new(), Vec::new());
|
|
let code = cadcraft_cli::run(args, &mut out, &mut err);
|
|
(code, String::from_utf8_lossy(&err).into_owned())
|
|
};
|
|
let b = s.0.join("b.dxf");
|
|
let cases: [(Vec<OsString>, std::path::PathBuf); 3] = [
|
|
(vec!["sample".into(), "bracket".into(), latin1("dxf").into()], latin1("dxf")),
|
|
(vec!["convert".into(), b.clone().into(), latin1("svg").into()], latin1("svg")),
|
|
(vec!["run".into(), "--sample".into(), "--save".into(), latin1("dwg").into()], latin1("dwg")),
|
|
];
|
|
for (args, path) in cases {
|
|
let (code, err) = run(args.clone());
|
|
assert_eq!(code, 0, "{args:?}: {err}");
|
|
assert!(path.exists(), "{path:?}");
|
|
assert!(std::fs::metadata(&path).expect("stat output").len() > 0, "{path:?}");
|
|
}
|
|
// An input with such a name is still refused.
|
|
let (code, err) = run(vec!["info".into(), latin1("dxf").into()]);
|
|
assert_eq!(code, 1);
|
|
assert!(err.contains("not valid Unicode"), "{err}");
|
|
}
|
|
|
|
/// An output that is an input file is refused before any work (exit 2, the input untouched) unless
|
|
/// `--in-place` allows it; the same file under another spelling counts, another existing file doesn't.
|
|
#[test]
|
|
fn output_that_replaces_an_input() {
|
|
let s = Scratch::new("inplace");
|
|
let dir = s.0.to_str().expect("utf-8 path").to_string();
|
|
let b = format!("{dir}/b.dxf");
|
|
let original = std::fs::read(&b).expect("read input");
|
|
std::fs::create_dir_all(format!("{dir}/sub")).expect("create sub");
|
|
let dotted = format!("{dir}/sub/../b.dxf");
|
|
#[allow(unused_mut)]
|
|
let mut spellings = vec![b.clone(), dotted];
|
|
#[cfg(unix)]
|
|
{
|
|
let link = format!("{dir}/link.dxf");
|
|
std::os::unix::fs::symlink(&b, &link).expect("symlink");
|
|
spellings.push(link);
|
|
}
|
|
for out in &spellings {
|
|
for args in [vec!["convert", &b, out], vec!["run", &b, "--save", out], vec!["run", &b, "--export", out]] {
|
|
let r = invoke(&args);
|
|
assert_eq!(r.code, 2, "{args:?}: {}", r.stderr);
|
|
assert!(r.stdout.is_empty(), "{args:?}");
|
|
assert!(r.stderr.starts_with(&format!("error: {out} is an input file")), "{args:?}: {}", r.stderr);
|
|
assert!(r.stderr.contains("--in-place"), "{args:?}: {}", r.stderr);
|
|
assert_eq!(std::fs::read(&b).expect("read input"), original, "{args:?}: input changed");
|
|
}
|
|
}
|
|
|
|
// Another existing file is replaced as before.
|
|
let other = format!("{dir}/other.dxf");
|
|
std::fs::write(&other, "x").expect("write other");
|
|
let r = invoke(&["convert", &b, &other]);
|
|
assert_eq!(r.code, 0, "{}", r.stderr);
|
|
assert_ne!(std::fs::read(&other).expect("read other"), b"x");
|
|
|
|
// --in-place allows it, for each spelling and flag: the input itself is rewritten, and a
|
|
// symlink stays a link to it.
|
|
let line = "line {\"points\":[[0,0],[1,1]]}";
|
|
let lines = |p: &str| std::fs::read_to_string(p).expect("read input").lines().filter(|l| l.trim_end() == "LINE").count();
|
|
let before = lines(&b);
|
|
for out in &spellings {
|
|
std::fs::write(&b, &original).expect("restore input");
|
|
let r = invoke(&["convert", &b, out, "--in-place"]);
|
|
assert_eq!(r.code, 0, "convert {out}: {}", r.stderr);
|
|
assert_eq!(lines(&b), before, "convert {out} keeps the drawing");
|
|
for flag in ["--save", "--export"] {
|
|
std::fs::write(&b, &original).expect("restore input");
|
|
let r = invoke(&["run", &b, "--cmd", line, "--in-place", flag, out]);
|
|
assert_eq!(r.code, 0, "{flag} {out}: {}", r.stderr);
|
|
assert_eq!(lines(&b), before + 1, "{flag} {out}: the input gains the line");
|
|
}
|
|
}
|
|
#[cfg(unix)]
|
|
{
|
|
let link = format!("{dir}/link.dxf");
|
|
assert!(std::fs::symlink_metadata(&link).expect("stat link").file_type().is_symlink(), "{link} is still a symlink");
|
|
}
|
|
|
|
// An output that doesn't exist yet is never an input.
|
|
let new = format!("{dir}/new.dxf");
|
|
let r = invoke(&["run", &b, "--save", &new]);
|
|
assert_eq!(r.code, 0, "{}", r.stderr);
|
|
assert!(std::path::Path::new(&new).is_file(), "{new} written");
|
|
}
|
|
|
|
/// JSON results are pretty-printed; `--compact` puts each on one line (RFC-0012 rule 1.3). `run`
|
|
/// prints one line per `--cmd` result either way.
|
|
#[test]
|
|
fn json_output_is_pretty_unless_compact() {
|
|
let s = Scratch::new("compact");
|
|
let b = format!("{}/b.dxf", s.0.to_str().expect("utf-8 path"));
|
|
for args in [&["info", b.as_str()][..], &["commands", "ellipse"]] {
|
|
let help = invoke(&[args[0], "--help"]);
|
|
assert!(
|
|
help.stdout.contains("--compact") && help.stdout.contains("One line of JSON instead of pretty-printed"),
|
|
"{} --help: {}",
|
|
args[0],
|
|
help.stdout
|
|
);
|
|
let pretty = invoke(args);
|
|
assert_eq!(pretty.code, 0, "{args:?}: {}", pretty.stderr);
|
|
assert!(pretty.stdout.lines().count() > 1, "{args:?} is pretty-printed: {}", pretty.stdout);
|
|
let compact = invoke(&[args, &["--compact"]].concat());
|
|
assert_eq!(compact.code, 0, "{args:?} --compact: {}", compact.stderr);
|
|
assert_eq!(compact.stdout.lines().count(), 1, "{args:?} --compact is one line: {}", compact.stdout);
|
|
let a: serde_json::Value = serde_json::from_str(&pretty.stdout).expect("pretty JSON");
|
|
let c: serde_json::Value = serde_json::from_str(&compact.stdout).expect("compact JSON");
|
|
assert_eq!(a, c, "{args:?}: same JSON either way");
|
|
}
|
|
let r = invoke(&["run", "--sample", "--cmd", "drawing.inspect", "--cmd", "drawing.inspect"]);
|
|
assert_eq!(r.code, 0, "{}", r.stderr);
|
|
assert_eq!(r.stdout.lines().count(), 2, "one line per --cmd result: {}", r.stdout);
|
|
assert_eq!(invoke(&["run", "--sample", "--compact"]).code, 2, "run takes no --compact");
|
|
}
|