mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-31 06:16:51 -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.
787 lines
34 KiB
Rust
787 lines
34 KiB
Rust
//! The command line, as clap types. Values are parsed into their types here and the relations
|
|
//! between flags are checked, 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. A flag a subcommand
|
|
//! doesn't take is a usage error too, so a typo can't be silently ignored. A value flag never takes
|
|
//! the next flag as its value: a value starting with `-` is written `--save=-out.dxf` (or
|
|
//! `./-out.dxf`).
|
|
|
|
use std::ffi::{OsStr, OsString};
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use cadcraft_engine::geom::{Bounds2, Vec2};
|
|
use cadcraft_io::Format;
|
|
use cadcraft_mcp::args::{blank_env_is_unset, name_env_vars};
|
|
use clap::builder::{OsStringValueParser, PathBufValueParser, TypedValueParser};
|
|
use clap::error::ErrorKind;
|
|
use clap::parser::ValueSource;
|
|
use clap::{ArgAction, ArgGroup, ArgMatches, Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
|
|
use serde_json::Value;
|
|
|
|
/// Help text that names the formats of `list`, built once from [`Format::READ`] or [`Format::WRITE`]
|
|
/// so it can't drift from what the code accepts.
|
|
fn with_formats(cell: &'static std::sync::OnceLock<String>, before: &str, list: &[Format], after: &str) -> &'static str {
|
|
cell.get_or_init(|| format!("{before}({}){after}", Format::list(list)))
|
|
}
|
|
|
|
/// The drawing an input argument names.
|
|
fn read_help() -> &'static str {
|
|
static C: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
|
with_formats(&C, "The drawing to read ", Format::READ, "")
|
|
}
|
|
|
|
fn open_help() -> &'static str {
|
|
static C: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
|
with_formats(&C, "Drawings to open ", Format::READ, "")
|
|
}
|
|
|
|
fn write_help() -> &'static str {
|
|
static C: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
|
with_formats(&C, "The file to write; its extension picks the format ", Format::WRITE, "")
|
|
}
|
|
|
|
fn save_help() -> &'static str {
|
|
static C: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
|
with_formats(&C, "Save the drawing to this file; its extension picks the format ", Format::WRITE, "")
|
|
}
|
|
|
|
fn export_help() -> &'static str {
|
|
static C: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
|
with_formats(&C, "Export the drawing to this file; its extension picks the format ", Format::WRITE, "")
|
|
}
|
|
|
|
fn convert_about() -> &'static str {
|
|
static C: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
|
with_formats(&C, "Convert a drawing; OUT's extension picks the format ", Format::WRITE, "")
|
|
}
|
|
|
|
fn convert_long_about() -> &'static str {
|
|
static C: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
|
C.get_or_init(|| {
|
|
format!(
|
|
"{}\n\nImage output (png 2400x1600, svg, pdf) shows model space, fitted to the drawing extents unless\n--window or --view says otherwise.",
|
|
convert_about()
|
|
)
|
|
})
|
|
}
|
|
|
|
/// cadcraft-cli: headless CADCraft
|
|
#[derive(Parser)]
|
|
#[command(name = "cadcraft-cli", version = cadcraft_engine::build_version(), arg_required_else_help = true)]
|
|
pub(crate) struct Cli {
|
|
#[command(subcommand)]
|
|
pub(crate) command: Command,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
pub(crate) enum Command {
|
|
Info(InfoArgs),
|
|
Convert(ConvertArgs),
|
|
Run(RunArgs),
|
|
Commands(CommandsArgs),
|
|
Mcp(McpArgs),
|
|
Perf(PerfArgs),
|
|
Sample(SampleArgs),
|
|
}
|
|
|
|
/// Print a drawing's summary as JSON
|
|
#[derive(Args)]
|
|
pub(crate) struct InfoArgs {
|
|
#[arg(help = read_help())]
|
|
pub(crate) file: PathBuf,
|
|
/// One line of JSON instead of pretty-printed
|
|
#[arg(long)]
|
|
pub(crate) compact: bool,
|
|
}
|
|
|
|
/// Convert a drawing; OUT's extension picks the format
|
|
#[derive(Args)]
|
|
#[command(about = convert_about(), long_about = convert_long_about())]
|
|
pub(crate) struct ConvertArgs {
|
|
#[arg(value_name = "IN", help = read_help())]
|
|
pub(crate) input: PathBuf,
|
|
#[arg(value_name = "OUT", help = write_help())]
|
|
pub(crate) output: PathBuf,
|
|
#[command(flatten)]
|
|
pub(crate) framing: FramingArgs,
|
|
/// Allow an output that is an input file, replacing the original
|
|
#[arg(long)]
|
|
pub(crate) in_place: bool,
|
|
#[command(flatten)]
|
|
pub(crate) settings: Settings,
|
|
}
|
|
|
|
/// The settings the desktop app reads from its environment, for the subcommands that draw or run
|
|
/// commands. A variable is the flag's fallback; an empty one counts as unset, except
|
|
/// `CADCRAFT_CONFIG_DIR`.
|
|
#[derive(Args)]
|
|
#[command(next_help_heading = "Settings")]
|
|
pub(crate) struct Settings {
|
|
/// The settings folder; plotter configurations are in its `plotters` folder (default: the
|
|
/// platform's settings folder). An empty value means no settings folder
|
|
// Not RFC-0012 rule 1.7's "an empty variable counts as unset": an empty value is the
|
|
// documented way to run with no settings folder (isolated runs, tests), and it predates the
|
|
// CLI port. Keep it out of any blanket treatment of empty variables.
|
|
#[arg(long, value_name = "DIR", env = "CADCRAFT_CONFIG_DIR", value_parser = OsStringValueParser::new().map(PathBuf::from))]
|
|
pub(crate) config_dir: Option<PathBuf>,
|
|
/// The font that replaces a missing one (FONTALT)
|
|
#[arg(long, value_name = "FONT", env = "CADCRAFT_FONTALT")]
|
|
pub(crate) fontalt: Option<String>,
|
|
/// The fonts tried, in order, for characters a font lacks, comma-separated (FONTFALLBACK)
|
|
#[arg(long, value_name = "FONTS", env = "CADCRAFT_FONTFALLBACK")]
|
|
pub(crate) fontfallback: Option<String>,
|
|
}
|
|
|
|
impl Settings {
|
|
/// Install the settings for this run; an unset one is the default. An empty or blank
|
|
/// `--config-dir` is no settings folder.
|
|
pub(crate) fn apply(&self) {
|
|
cadcraft_engine::settings_dir::set(self.settings_dir());
|
|
cadcraft_engine::fonts::ttf::set_font_alt(self.fontalt.as_deref().unwrap_or(""));
|
|
cadcraft_engine::fonts::ttf::set_fallback_fonts(self.fontfallback.as_deref().unwrap_or(""));
|
|
}
|
|
|
|
/// The settings folder: `--config-dir`, else the platform's; `None` when the flag is empty or
|
|
/// blank.
|
|
fn settings_dir(&self) -> Option<PathBuf> {
|
|
match &self.config_dir {
|
|
Some(dir) if dir.to_str().is_some_and(|d| d.trim().is_empty()) => None,
|
|
Some(dir) => Some(dir.clone()),
|
|
None => cadcraft_engine::settings_dir::platform(|k| std::env::var_os(k)),
|
|
}
|
|
}
|
|
}
|
|
|
|
// What part of model space an image shows: shared by `convert` and `run`.
|
|
#[derive(Args)]
|
|
pub(crate) struct FramingArgs {
|
|
/// Show this rectangle (drawing units, x0<x1, y0<y1), centred and scaled to fit the image
|
|
/// without distortion. A negative x0 needs the `=` form: --window=-10,0,10,5
|
|
#[arg(long, value_name = "x0,y0,x1,y1", value_parser = parse_window, conflicts_with = "view")]
|
|
pub(crate) window: Option<Bounds2>,
|
|
/// What part of model space the image shows (case-insensitive)
|
|
#[arg(long, value_name = "extents|current", value_enum, ignore_case = true)]
|
|
pub(crate) view: Option<ViewMode>,
|
|
}
|
|
|
|
/// `--view`.
|
|
#[derive(Clone, Copy, Debug, PartialEq, ValueEnum)]
|
|
pub(crate) enum ViewMode {
|
|
/// Always fit the drawing extents
|
|
Extents,
|
|
/// The current view, as last set by zoom, pan or view.set (a freshly opened drawing's view is
|
|
/// its extents plus a 10% margin)
|
|
Current,
|
|
}
|
|
|
|
/// Open drawings (or start a new one), run scripts and commands in order, save or export the result
|
|
///
|
|
/// Drawings open in the order given, and the last one is current; without any, a new imperial
|
|
/// drawing is used. --script, --script-file and --cmd steps run in the order given, and each
|
|
/// --cmd prints its JSON result on one line (JSON lines); a command with no result prints nothing.
|
|
/// An image --export (png 2400x1600, svg, pdf) shows model space:
|
|
/// the current view if the steps changed it (zoom, pan, view.set…), otherwise the drawing
|
|
/// extents, unless --window or --view says otherwise.
|
|
#[derive(Args)]
|
|
#[command(group(ArgGroup::new("framing").args(["window", "view"]).multiple(true).requires("export")))]
|
|
#[command(after_long_help = r#"--cmd chaining:
|
|
A --cmd parameter value of exactly "{last}" is replaced with the last object handle an
|
|
earlier --cmd in the same run returned, so a command can act on what the previous one made:
|
|
--cmd 'mview {"p1":[20,20],"p2":[200,150]}' prints {"viewports":["100"]}
|
|
--cmd 'viewport.set {"handle":"{last}","scale":1}' changes viewport 100
|
|
It works in any parameter, at any depth (e.g. "handles":["{last}"]); every other string is
|
|
passed unchanged. With no earlier returned handle the run fails and says so."#)]
|
|
pub(crate) struct RunArgs {
|
|
#[arg(value_name = "FILE", help = open_help())]
|
|
pub(crate) files: Vec<PathBuf>,
|
|
/// Open the built-in sample drawing
|
|
#[arg(long)]
|
|
pub(crate) sample: bool,
|
|
/// Start a new metric drawing
|
|
#[arg(long)]
|
|
pub(crate) metric: bool,
|
|
/// Command-line script text to run (`\n` separates lines)
|
|
#[arg(long, value_name = "TEXT", action = ArgAction::Append)]
|
|
pub(crate) script: Vec<String>,
|
|
/// A script file (.scr) to run
|
|
#[arg(long, value_name = "F.scr", action = ArgAction::Append)]
|
|
pub(crate) script_file: Vec<PathBuf>,
|
|
/// An engine command and its JSON parameters (default {}); see "--cmd chaining" in --help
|
|
#[arg(long, value_name = "id {json}", action = ArgAction::Append, value_parser = cmd_step)]
|
|
pub(crate) cmd: Vec<CmdStep>,
|
|
#[arg(long, value_name = "OUT.dxf", help = save_help())]
|
|
pub(crate) save: Option<PathBuf>,
|
|
#[arg(long, value_name = "OUT.png", help = export_help())]
|
|
pub(crate) export: Option<PathBuf>,
|
|
#[command(flatten)]
|
|
pub(crate) framing: FramingArgs,
|
|
/// Allow an output that is an input file, replacing the original
|
|
#[arg(long)]
|
|
pub(crate) in_place: bool,
|
|
#[command(flatten)]
|
|
pub(crate) settings: Settings,
|
|
/// The drawings, in command-line order, set by [`parse`].
|
|
#[arg(skip)]
|
|
pub(crate) drawings: Vec<Drawing>,
|
|
/// The steps, in command-line order, set by [`parse`].
|
|
#[arg(skip)]
|
|
pub(crate) steps: Vec<Step>,
|
|
}
|
|
|
|
/// A drawing `run` opens.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub(crate) enum Drawing {
|
|
File(PathBuf),
|
|
Sample,
|
|
Metric,
|
|
}
|
|
|
|
/// A `--script`, `--script-file` or `--cmd` of `run`.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub(crate) enum Step {
|
|
Script(String),
|
|
ScriptFile(PathBuf),
|
|
Cmd(CmdStep),
|
|
}
|
|
|
|
/// `--cmd 'id {json}'`: an engine command and its parameters. `{last}` placeholders are filled in
|
|
/// when it runs.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub(crate) struct CmdStep {
|
|
pub(crate) id: String,
|
|
pub(crate) params: Value,
|
|
}
|
|
|
|
/// Print the command catalog as JSON
|
|
#[derive(Args)]
|
|
pub(crate) struct CommandsArgs {
|
|
/// Only the commands whose id or label contains this text (case-insensitive)
|
|
pub(crate) filter: Option<String>,
|
|
/// One line of JSON instead of pretty-printed
|
|
#[arg(long)]
|
|
pub(crate) compact: bool,
|
|
}
|
|
|
|
/// Run the MCP server on stdio: a headless drawing session, or a bridge to the running app
|
|
#[derive(Args)]
|
|
pub(crate) struct McpArgs {
|
|
/// Bridge to the app's control channel (its --control port, e.g. 127.0.0.1:7979)
|
|
#[arg(long, value_name = "HOST:PORT", value_parser = connect_addr)]
|
|
pub(crate) connect: Option<String>,
|
|
/// Reach the app at the first call instead of at startup, so the server can start before the
|
|
/// app is open
|
|
#[arg(long, requires = "connect")]
|
|
pub(crate) lazy: bool,
|
|
/// Like --lazy, and a call that finds nothing at HOST:PORT opens the app with that control
|
|
/// port first (macOS)
|
|
#[arg(long, requires = "connect")]
|
|
pub(crate) launch: bool,
|
|
#[command(flatten)]
|
|
pub(crate) settings: Settings,
|
|
}
|
|
|
|
/// 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
|
|
#[derive(Args)]
|
|
pub(crate) struct PerfArgs {
|
|
/// An entity count (clamped to 1-20000000), or a drawing to time
|
|
#[arg(value_name = "N|FILE", value_parser = PathBufValueParser::new().map(perf_target))]
|
|
pub(crate) target: Option<PerfTarget>,
|
|
}
|
|
|
|
/// What `perf` times.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub(crate) enum PerfTarget {
|
|
/// A synthetic drawing with this many entities, within [`MAX_PERF_COUNT`].
|
|
Count(usize),
|
|
File(PathBuf),
|
|
}
|
|
|
|
/// The largest synthetic drawing `perf` builds.
|
|
const MAX_PERF_COUNT: usize = 20_000_000;
|
|
|
|
/// `perf N|FILE`: a value that reads as a count is one, clamped to 1..=[`MAX_PERF_COUNT`] so `perf
|
|
/// 0` still times something; anything else is a drawing.
|
|
fn perf_target(p: PathBuf) -> PerfTarget {
|
|
match p.to_str().and_then(|t| t.parse::<usize>().ok()) {
|
|
Some(n) => PerfTarget::Count(n.clamp(1, MAX_PERF_COUNT)),
|
|
None => PerfTarget::File(p),
|
|
}
|
|
}
|
|
|
|
/// Write a built-in sample drawing
|
|
#[derive(Args)]
|
|
pub(crate) struct SampleArgs {
|
|
/// Which sample
|
|
#[arg(value_name = "bracket|floorplan", value_enum)]
|
|
pub(crate) name: SampleName,
|
|
#[arg(value_name = "OUT", help = write_help())]
|
|
pub(crate) output: PathBuf,
|
|
#[command(flatten)]
|
|
pub(crate) settings: Settings,
|
|
}
|
|
|
|
/// `sample`'s drawings.
|
|
#[derive(Clone, Copy, Debug, PartialEq, ValueEnum)]
|
|
pub(crate) enum SampleName {
|
|
Bracket,
|
|
#[value(alias = "floor")]
|
|
Floorplan,
|
|
}
|
|
|
|
/// What part of model space an image export shows.
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub(crate) enum Framing {
|
|
/// `run`: the current view if the commands changed it, else the extents. `convert`: extents.
|
|
Auto,
|
|
Extents,
|
|
Current,
|
|
Window(Bounds2),
|
|
}
|
|
|
|
/// Why a `--window` value is refused: clap prints it after the flag and its value (exit 2).
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub(crate) enum WindowError {
|
|
#[error("expected four numbers x0,y0,x1,y1")]
|
|
NeedsFour,
|
|
#[error("{name} must be a finite number, got `{value}`")]
|
|
NotFinite { name: &'static str, value: String },
|
|
#[error("need x0 < x1 and y0 < y1 (lower-left corner first)")]
|
|
Order,
|
|
#[error(transparent)]
|
|
Limits(#[from] cadcraft_io::IoError),
|
|
}
|
|
|
|
/// Why a value is refused: clap prints it after the flag and its value (exit 2).
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub(crate) enum ValueError {
|
|
#[error("expected HOST:PORT (the app's --control port, e.g. 127.0.0.1:7979)")]
|
|
Connect,
|
|
#[error("expected a command id, then optionally its JSON parameters")]
|
|
CmdId,
|
|
#[error(transparent)]
|
|
CmdJson(#[from] serde_json::Error),
|
|
}
|
|
|
|
/// Why `parse` refuses flags clap accepted: clap prints it as a usage error (exit 2).
|
|
#[derive(Debug, thiserror::Error)]
|
|
enum UsageError {
|
|
/// The framing flags choose what an image shows; a drawing file has nothing to frame.
|
|
#[error("{flag} only applies to image output (png, svg, pdf), not `{}`", .out.display())]
|
|
FramingNeedsImage { flag: &'static str, out: PathBuf },
|
|
/// The output is one of the input files, so writing it would replace the original.
|
|
#[error("{} is an input file, so the result would replace the original: choose another output, or pass --in-place to overwrite it", .0.display())]
|
|
OutIsInput(PathBuf),
|
|
}
|
|
|
|
impl UsageError {
|
|
fn into_clap(self, cmd: &mut clap::Command) -> clap::Error {
|
|
let kind = match self {
|
|
Self::FramingNeedsImage { .. } | Self::OutIsInput(_) => ErrorKind::ArgumentConflict,
|
|
};
|
|
cmd.error(kind, self)
|
|
}
|
|
}
|
|
|
|
/// Parse `--window x0,y0,x1,y1` (min corner first). Hostile values are errors, never panics.
|
|
pub(crate) fn parse_window(text: &str) -> Result<Bounds2, WindowError> {
|
|
let parts: Vec<&str> = text.split(',').map(str::trim).collect();
|
|
let [x0, y0, x1, y1] = parts.as_slice() else {
|
|
return Err(WindowError::NeedsFour);
|
|
};
|
|
let num = |name: &'static str, v: &str| -> Result<f64, WindowError> {
|
|
match v.parse::<f64>() {
|
|
Ok(n) if n.is_finite() => Ok(n),
|
|
_ => Err(WindowError::NotFinite { name, value: v.to_string() }),
|
|
}
|
|
};
|
|
let (x0, y0, x1, y1) = (num("x0", x0)?, num("y0", y0)?, num("x1", x1)?, num("y1", y1)?);
|
|
if !(x0 < x1 && y0 < y1) {
|
|
return Err(WindowError::Order);
|
|
}
|
|
let w = Bounds2 { min: Vec2::new(x0, y0), max: Vec2::new(x1, y1) };
|
|
cadcraft_io::check_window(&w)?;
|
|
Ok(w)
|
|
}
|
|
|
|
/// `run --cmd 'id {json}'`: the id up to the first space, then JSON parameters (default `{}`).
|
|
/// Whether they suit the command is the command's to say.
|
|
fn cmd_step(s: &str) -> Result<CmdStep, ValueError> {
|
|
let (id, json) = s.split_once(' ').unwrap_or((s, "{}"));
|
|
if id.is_empty() {
|
|
return Err(ValueError::CmdId);
|
|
}
|
|
Ok(CmdStep { id: id.to_string(), params: serde_json::from_str(json)? })
|
|
}
|
|
|
|
impl FramingArgs {
|
|
/// The framing `--window` or `--view` asks for (clap refuses both together).
|
|
pub(crate) fn framing(&self) -> Framing {
|
|
match (self.window, self.view) {
|
|
(Some(w), _) => Framing::Window(w),
|
|
(None, Some(ViewMode::Extents)) => Framing::Extents,
|
|
(None, Some(ViewMode::Current)) => Framing::Current,
|
|
(None, None) => Framing::Auto,
|
|
}
|
|
}
|
|
|
|
/// The framing flag given, if any.
|
|
fn flag(&self) -> Option<&'static str> {
|
|
if self.window.is_some() {
|
|
Some("--window")
|
|
} else if self.view.is_some() {
|
|
Some("--view")
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A framing flag given with an output that isn't an image (the extension decides). `run` without
|
|
/// `--export` is clap's `requires` error.
|
|
fn framing_error(cli: &Cli) -> Option<UsageError> {
|
|
let (framing, out): (&FramingArgs, Option<&Path>) = match &cli.command {
|
|
Command::Convert(c) => (&c.framing, Some(&c.output)),
|
|
Command::Run(r) => (&r.framing, r.export.as_deref()),
|
|
_ => return None,
|
|
};
|
|
let (flag, out) = (framing.flag()?, out?);
|
|
(!cadcraft_io::is_image(out)).then(|| UsageError::FramingNeedsImage { flag, out: out.to_path_buf() })
|
|
}
|
|
|
|
/// An output that is the same file as an input, without `--in-place`. An output that doesn't exist
|
|
/// can't be an input, and an I/O error in the comparison is left to the open or write that follows.
|
|
fn out_is_input(cli: &Cli) -> Option<UsageError> {
|
|
let same = |input: &Path, out: &Path| out.exists() && same_file::is_same_file(input, out).unwrap_or(false);
|
|
match &cli.command {
|
|
Command::Convert(c) if !c.in_place && same(&c.input, &c.output) => Some(UsageError::OutIsInput(c.output.clone())),
|
|
Command::Run(r) if !r.in_place => {
|
|
let inputs = r.drawings.iter().filter_map(|d| if let Drawing::File(f) = d { Some(f) } else { None });
|
|
let inputs: Vec<&PathBuf> = inputs.collect();
|
|
[&r.save, &r.export].into_iter().flatten().find(|out| inputs.iter().any(|i| same(i, out))).map(|out| UsageError::OutIsInput(out.clone()))
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// The backend `cadcraft-cli mcp` serves from.
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
pub(crate) enum McpTarget {
|
|
/// An in-process drawing session (no `--connect`).
|
|
Headless,
|
|
/// The running app's control channel at `HOST:PORT`; `lazy`: reached at the first call;
|
|
/// `launch`: opened then if it isn't running.
|
|
Connect { addr: String, lazy: bool, launch: bool },
|
|
}
|
|
|
|
impl McpArgs {
|
|
pub(crate) fn target(&self) -> McpTarget {
|
|
match &self.connect {
|
|
// `--launch` connects at the first call too (the app opens then).
|
|
Some(addr) => McpTarget::Connect { addr: addr.clone(), lazy: self.lazy || self.launch, launch: self.launch },
|
|
None => McpTarget::Headless,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `mcp --connect`: an empty, flag-like or port-less address is an error, never a silent fall back
|
|
/// to a headless session (#103). The host is not resolved here.
|
|
fn connect_addr(s: &str) -> Result<String, ValueError> {
|
|
let s = s.trim();
|
|
let valid = !s.starts_with('-')
|
|
&& s.rsplit_once(':').is_some_and(|(host, port)| {
|
|
let host = host.strip_prefix('[').and_then(|h| h.strip_suffix(']')).unwrap_or(host);
|
|
!host.is_empty() && port.parse::<u16>().is_ok()
|
|
});
|
|
if valid { Ok(s.to_string()) } else { Err(ValueError::Connect) }
|
|
}
|
|
|
|
/// The values of `id` with their command-line indices.
|
|
fn indexed<'a, T: Clone + Send + Sync + 'static>(m: &'a ArgMatches, id: &str) -> impl Iterator<Item = (usize, &'a T)> {
|
|
let at = m.indices_of(id).into_iter().flatten();
|
|
let values = m.try_get_many::<T>(id).ok().flatten().into_iter().flatten();
|
|
at.zip(values)
|
|
}
|
|
|
|
/// The command-line index of flag `id`, when it was given.
|
|
fn flag_index(m: &ArgMatches, id: &str) -> Option<usize> {
|
|
if m.value_source(id) == Some(ValueSource::CommandLine) { m.indices_of(id).and_then(Iterator::last) } else { None }
|
|
}
|
|
|
|
/// `run`'s drawings and steps back in command-line order: clap keeps each flag's values apart,
|
|
/// their indices say how they interleave.
|
|
fn run_order(m: &ArgMatches) -> (Vec<Drawing>, Vec<Step>) {
|
|
let mut drawings: Vec<(usize, Drawing)> = indexed(m, "files").map(|(i, f): (_, &PathBuf)| (i, Drawing::File(f.clone()))).collect();
|
|
drawings.extend(flag_index(m, "sample").map(|i| (i, Drawing::Sample)));
|
|
drawings.extend(flag_index(m, "metric").map(|i| (i, Drawing::Metric)));
|
|
drawings.sort_by_key(|d| d.0);
|
|
let mut steps: Vec<(usize, Step)> = indexed(m, "script").map(|(i, s): (_, &String)| (i, Step::Script(s.clone()))).collect();
|
|
steps.extend(indexed(m, "script_file").map(|(i, s): (_, &PathBuf)| (i, Step::ScriptFile(s.clone()))));
|
|
steps.extend(indexed(m, "cmd").map(|(i, c): (_, &CmdStep)| (i, Step::Cmd(c.clone()))));
|
|
steps.sort_by_key(|s| s.0);
|
|
(drawings.into_iter().map(|d| d.1).collect(), steps.into_iter().map(|s| s.1).collect())
|
|
}
|
|
|
|
/// Parse the arguments (without the binary name). clap reads them as `OsString`s: a path need not
|
|
/// be valid Unicode, text must. A bare `--help` or `-h` is help even after a mistake on the same
|
|
/// line (`run --script --help`): clap reports the mistake first.
|
|
pub(crate) fn parse(args: &[OsString]) -> Result<Cli, clap::Error> {
|
|
parse_with(command(|k| std::env::var_os(k)), args)
|
|
}
|
|
|
|
/// The command line. An empty or blank variable counts as unset, except `CADCRAFT_CONFIG_DIR`:
|
|
/// empty means no settings folder. `var` looks a variable up.
|
|
fn command(var: impl Fn(&OsStr) -> Option<OsString> + Copy) -> clap::Command {
|
|
let blank = move |a: clap::Arg| if a.get_id() == "config_dir" { a } else { blank_env_is_unset(a, var) };
|
|
Cli::command().mut_args(blank).mut_subcommands(|s| s.mut_args(blank))
|
|
}
|
|
|
|
/// [`parse`] with `cmd` from [`command`]; a bad value from a variable is a usage error naming the
|
|
/// variable.
|
|
fn parse_with(mut cmd: clap::Command, args: &[OsString]) -> Result<Cli, clap::Error> {
|
|
let argv = std::iter::once(OsString::from("cadcraft-cli")).chain(args.iter().cloned());
|
|
let matches = match cmd.try_get_matches_from_mut(argv) {
|
|
// `use_stderr`: a real error, not the help `-h` asked for (a retry would make it `--help`).
|
|
Err(e) if e.use_stderr() && args.iter().take_while(|a| *a != "--").any(|a| *a == "--help" || *a == "-h") => {
|
|
let help = [OsString::from("cadcraft-cli"), args.first().cloned().unwrap_or_default(), OsString::from("--help")];
|
|
return Err(cmd.try_get_matches_from_mut(help).err().unwrap_or(e));
|
|
}
|
|
matches => matches.map_err(|e| name_env_vars(e, &cmd, args))?,
|
|
};
|
|
let mut cli = Cli::from_arg_matches(&matches)?;
|
|
if let (Command::Run(r), Some((_, m))) = (&mut cli.command, matches.subcommand()) {
|
|
(r.drawings, r.steps) = run_order(m);
|
|
}
|
|
if let Some(e) = framing_error(&cli).or_else(|| out_is_input(&cli)) {
|
|
if let Some(sub) = matches.subcommand_name().and_then(|n| cmd.find_subcommand_mut(n)) {
|
|
return Err(e.into_clap(sub));
|
|
}
|
|
return Err(e.into_clap(&mut cmd));
|
|
}
|
|
Ok(cli)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
|
|
fn parse_args(a: &[&str]) -> Result<Cli, clap::Error> {
|
|
parse(&a.iter().map(OsString::from).collect::<Vec<_>>())
|
|
}
|
|
|
|
fn mcp(a: &[&str]) -> Result<McpTarget, clap::Error> {
|
|
let args: Vec<&str> = std::iter::once("mcp").chain(a.iter().copied()).collect();
|
|
match parse_args(&args)?.command {
|
|
Command::Mcp(m) => Ok(m.target()),
|
|
_ => panic!("{a:?} is not mcp"),
|
|
}
|
|
}
|
|
|
|
/// Every file argument's help names each format of the list it uses.
|
|
#[test]
|
|
fn help_names_every_format() {
|
|
let cli = Cli::command();
|
|
let help = |sub: &str, arg: &str| {
|
|
let a = cli.find_subcommand(sub).and_then(|c| c.get_arguments().find(|a| a.get_id() == arg)).expect("argument exists");
|
|
a.get_help().map(|h| h.to_string()).unwrap_or_default()
|
|
};
|
|
let cases: [(&str, &str, &[Format]); 8] = [
|
|
("info", "file", Format::READ),
|
|
("convert", "input", Format::READ),
|
|
("convert", "output", Format::WRITE),
|
|
("run", "files", Format::READ),
|
|
("run", "save", Format::WRITE),
|
|
("run", "export", Format::WRITE),
|
|
("sample", "output", Format::WRITE),
|
|
("convert", "", Format::WRITE),
|
|
];
|
|
for (sub, arg, list) in cases {
|
|
let text = if arg.is_empty() {
|
|
cli.find_subcommand(sub).and_then(|c| c.get_about()).map(|a| a.to_string()).unwrap_or_default()
|
|
} else {
|
|
help(sub, arg)
|
|
};
|
|
for f in list {
|
|
assert!(text.contains(f.extension()), "`{sub} {arg}` help `{text}` lacks {}", f.extension());
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_without_connect_is_headless() {
|
|
assert_eq!(mcp(&[]).unwrap(), McpTarget::Headless);
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_connect_with_address_selects_remote() {
|
|
let to = |addr: &str, lazy, launch| McpTarget::Connect { addr: addr.into(), lazy, launch };
|
|
assert_eq!(mcp(&["--connect", "127.0.0.1:7979"]).unwrap(), to("127.0.0.1:7979", false, false));
|
|
assert_eq!(mcp(&["--connect", " localhost:7979 "]).unwrap(), to("localhost:7979", false, false));
|
|
assert_eq!(mcp(&["--lazy", "--connect", "localhost:7979"]).unwrap(), to("localhost:7979", true, false));
|
|
assert_eq!(mcp(&["--connect", "localhost:7979", "--launch"]).unwrap(), to("localhost:7979", true, true));
|
|
assert_eq!(mcp(&["--connect", "[::1]:7979"]).unwrap(), to("[::1]:7979", false, false));
|
|
// Without an app to reach they mean nothing: never a headless session.
|
|
for flag in ["--lazy", "--launch"] {
|
|
assert_eq!(mcp(&[flag]).unwrap_err().kind(), clap::error::ErrorKind::MissingRequiredArgument, "{flag}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_connect_without_address_is_an_error_not_headless() {
|
|
for bad in [
|
|
&["--connect"][..],
|
|
&["--connect", ""],
|
|
&["--connect", " "],
|
|
&["--connect=-v"],
|
|
&["--connect", "example.com"],
|
|
&["--connect", ":7979"],
|
|
&["--connect", "host:99999"],
|
|
&["--connect", "[]:1"],
|
|
] {
|
|
let r = mcp(bad);
|
|
let Err(e) = r else { panic!("{bad:?} should be rejected, got {r:?}") };
|
|
assert_eq!(e.exit_code(), 2, "{bad:?}");
|
|
assert!(e.to_string().contains("--connect <HOST:PORT>"), "{bad:?}: {e}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_rejects_unknown_and_repeated_arguments() {
|
|
assert!(mcp(&["--conect", "127.0.0.1:7979"]).is_err());
|
|
assert!(mcp(&["127.0.0.1:7979"]).is_err());
|
|
// clap names the unknown flag, not the value it left missing.
|
|
for flag in ["--foo", "-v"] {
|
|
assert_eq!(mcp(&["--connect", flag]).unwrap_err().kind(), clap::error::ErrorKind::UnknownArgument, "{flag}");
|
|
}
|
|
assert!(mcp(&["--connect", "127.0.0.1:1", "--connect", "127.0.0.1:2"]).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn run_keeps_command_line_order() {
|
|
let args =
|
|
["run", "a.dxf", "--script", "S1", "--metric", "--cmd", "c1", "b.dxf", "--script-file", "f.scr", "--sample", "--cmd", "c2 {\"n\":1}"];
|
|
let Command::Run(r) = parse_args(&args).unwrap().command else { panic!("not run") };
|
|
let file = |f: &str| Drawing::File(f.into());
|
|
let cmd = |id: &str, params| Step::Cmd(CmdStep { id: id.into(), params });
|
|
assert_eq!(r.drawings, [file("a.dxf"), Drawing::Metric, file("b.dxf"), Drawing::Sample]);
|
|
assert_eq!(r.steps, [Step::Script("S1".into()), cmd("c1", json!({})), Step::ScriptFile("f.scr".into()), cmd("c2", json!({"n": 1}))]);
|
|
}
|
|
|
|
#[test]
|
|
fn cmd_values() {
|
|
assert_eq!(cmd_step("line").unwrap(), CmdStep { id: "line".into(), params: json!({}) });
|
|
assert_eq!(cmd_step("line [1]").unwrap(), CmdStep { id: "line".into(), params: json!([1]) });
|
|
for bad in ["", " {}", "line {bad json", "line "] {
|
|
assert!(cmd_step(bad).is_err(), "`{bad}` should be rejected");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn perf_targets() {
|
|
let target = |a: &[&str]| -> Option<PerfTarget> {
|
|
let args: Vec<&str> = std::iter::once("perf").chain(a.iter().copied()).collect();
|
|
let Command::Perf(p) = parse_args(&args).unwrap().command else { panic!("not perf") };
|
|
p.target
|
|
};
|
|
assert_eq!(target(&[]), None);
|
|
assert_eq!(target(&["5"]), Some(PerfTarget::Count(5)));
|
|
assert_eq!(target(&["0"]), Some(PerfTarget::Count(1)));
|
|
assert_eq!(target(&["999999999"]), Some(PerfTarget::Count(MAX_PERF_COUNT)));
|
|
assert_eq!(target(&["a.dxf"]), Some(PerfTarget::File("a.dxf".into())));
|
|
assert_eq!(target(&["1e3"]), Some(PerfTarget::File("1e3".into())));
|
|
}
|
|
|
|
#[test]
|
|
fn framing_options() {
|
|
let framing = |out: &str, a: &[&str]| -> Result<Framing, clap::Error> {
|
|
let args: Vec<&str> = ["convert", "a.dxf", out].iter().chain(a).copied().collect();
|
|
let Command::Convert(c) = parse_args(&args)?.command else { panic!("not convert") };
|
|
Ok(c.framing.framing())
|
|
};
|
|
let b = |x0, y0, x1, y1| Bounds2::new(Vec2::new(x0, y0), Vec2::new(x1, y1));
|
|
assert_eq!(framing("b.png", &[]).unwrap(), Framing::Auto);
|
|
assert_eq!(framing("b.png", &["--view", "extents"]).unwrap(), Framing::Extents);
|
|
assert_eq!(framing("b.png", &["--view", "CURRENT"]).unwrap(), Framing::Current);
|
|
assert_eq!(framing("b.png", &["--window", "0,0,2,1"]).unwrap(), Framing::Window(b(0.0, 0.0, 2.0, 1.0)));
|
|
assert_eq!(framing("b.dxf", &[]).unwrap(), Framing::Auto);
|
|
let kind = |out: &str, a: &[&str]| framing(out, a).map(|_| ()).unwrap_err().kind();
|
|
assert_eq!(kind("b.png", &["--view", "selection"]), ErrorKind::InvalidValue);
|
|
assert_eq!(kind("b.png", &["--window", "0,0,2,1", "--view", "extents"]), ErrorKind::ArgumentConflict);
|
|
assert_eq!(kind("b.dxf", &["--view", "extents"]), ErrorKind::ArgumentConflict);
|
|
assert_eq!(kind("b.dxf", &["--window", "0,0,2,1"]), ErrorKind::ArgumentConflict);
|
|
}
|
|
|
|
#[test]
|
|
fn window_parses() {
|
|
let b = |x0, y0, x1, y1| Bounds2::new(Vec2::new(x0, y0), Vec2::new(x1, y1));
|
|
assert_eq!(parse_window("0,0,30,20").unwrap(), b(0.0, 0.0, 30.0, 20.0));
|
|
assert_eq!(parse_window(" -5.5, -1e3 ,2e3,4 ").unwrap(), b(-5.5, -1000.0, 2000.0, 4.0));
|
|
}
|
|
|
|
#[test]
|
|
fn hostile_window_is_an_error() {
|
|
for w in [
|
|
"",
|
|
"1,2,3",
|
|
"1,2,3,4,5",
|
|
"a,0,1,1",
|
|
"NaN,0,1,1",
|
|
"0,0,inf,1",
|
|
"0,0,1e400,1",
|
|
"0,0,0,1",
|
|
"0,0,1,0",
|
|
"5,0,1,1",
|
|
"0,5,1,1",
|
|
"-1e300,-1e300,1e300,1e300",
|
|
"0,0,1e-12,1e-12",
|
|
] {
|
|
assert!(parse_window(w).is_err(), "`{w}` should be rejected");
|
|
}
|
|
}
|
|
|
|
/// The subcommands that draw or run commands take the settings flags; the others refuse them.
|
|
#[test]
|
|
fn settings_flags() {
|
|
let Command::Convert(c) =
|
|
parse_args(&["convert", "a.dxf", "b.png", "--config-dir", "/p", "--fontalt", "Noto Sans", "--fontfallback="]).unwrap().command
|
|
else {
|
|
panic!("not convert")
|
|
};
|
|
assert_eq!(c.settings.config_dir, Some(PathBuf::from("/p")));
|
|
assert_eq!((c.settings.fontalt.as_deref(), c.settings.fontfallback.as_deref()), (Some("Noto Sans"), Some("")));
|
|
for empty in ["--config-dir=", "--config-dir= "] {
|
|
let Command::Run(r) = parse_args(&["run", "--sample", empty]).unwrap().command else { panic!("not run") };
|
|
assert_eq!(r.settings.settings_dir(), None, "`{empty}` is no settings folder");
|
|
}
|
|
let Command::Convert(c) = parse_args(&["convert", "a.dxf", "b.png", "--config-dir", "/p"]).unwrap().command else { panic!("not convert") };
|
|
assert_eq!(c.settings.settings_dir(), Some(PathBuf::from("/p")));
|
|
assert!(parse_args(&["sample", "bracket", "b.dxf", "--fontalt", "x"]).is_ok());
|
|
assert!(parse_args(&["mcp", "--config-dir", "/p"]).is_ok());
|
|
assert!(parse_args(&["info", "a.dxf", "--config-dir", "/p"]).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn blank_variables_count_as_unset_except_the_config_dir() {
|
|
let cmd = command(|_| Some("".into()));
|
|
for sub in cmd.get_subcommands().filter(|s| s.get_arguments().any(|a| a.get_id() == "fontalt")) {
|
|
let env = |id: &str| sub.get_arguments().find(|a| a.get_id() == id).and_then(|a| a.get_env());
|
|
assert_eq!((env("fontalt"), env("fontfallback")), (None, None), "{}", sub.get_name());
|
|
assert_eq!(env("config_dir"), Some(OsStr::new("CADCRAFT_CONFIG_DIR")), "{}", sub.get_name());
|
|
}
|
|
}
|
|
|
|
/// `--fontalt` falls back to `PATH` and rejects every value here, as a bad value from a
|
|
/// variable would; tests can't set a variable (`set_var` is unsafe).
|
|
#[test]
|
|
fn a_bad_value_from_a_variable_names_the_variable() {
|
|
// Cargo sets CARGO_PKG_NAME for every test binary, so the variable always has a value.
|
|
let reject = clap::builder::PossibleValuesParser::new(["never"]);
|
|
let cmd = || Cli::command().mut_subcommand("sample", |s| s.mut_arg("fontalt", |a| a.env("CARGO_PKG_NAME").value_parser(reject.clone())));
|
|
let args = |a: &[&str]| a.iter().map(OsString::from).collect::<Vec<_>>();
|
|
let text = parse_with(cmd(), &args(&["sample"])).err().map(|e| e.to_string()).unwrap_or_default();
|
|
assert!(text.contains("for 'CARGO_PKG_NAME'") && !text.contains("--fontalt"), "{text}");
|
|
let text = parse_with(cmd(), &args(&["sample", "--fontalt", "x"])).err().map(|e| e.to_string()).unwrap_or_default();
|
|
assert!(text.contains("--fontalt"), "{text}");
|
|
}
|
|
}
|