Skip to main content

oxideav_cli_convert/
args.rs

1//! IM-style arg parser.
2//!
3//! `convert INPUT [-op VALUE]... OUTPUT` — first positional arg is
4//! the input, last positional arg is the output, everything in
5//! between is `-flag` pairs.  Most single-word ops take exactly one
6//! following value; a few (`-strip`) are valueless.  Any unrecognised
7//! flag errors out with a clear message — we never silently drop.
8
9use crate::op::{
10    AlphaOp, CameraSpec, ConvertPlan, Dither, GltfFormatChoice, LightSpec, Mesh3DOptions,
11    Mesh3DRenderMode, Op, PageAtom, PageSelector, PrintfTemplate, ProjectionMode, ResizeMode,
12    StlFormatChoice,
13};
14use oxideav_core::Error;
15
16/// Every flag the parser below recognises, stored without the leading
17/// dash(es). Single-dash IM-style ops AND the double-dash GNU-style
18/// modes (`--probe`, `--json`, `--watch`) both land in here; the
19/// caller reattaches whichever lead the user typed when rendering a
20/// suggestion. Kept as a flat sorted slice rather than a `HashSet`
21/// because the list is small (~55 entries), the lookup is a single
22/// scan inside the Levenshtein helper, and a sorted slice is trivial
23/// to keep aligned with the `match` arms below.
24///
25/// MUST stay in sync with the `match flag.as_str()` arms in [`parse`].
26/// A flag missing from this table downgrades the suggestion quality
27/// (the user mistypes a real flag and gets no hint) but doesn't break
28/// parsing, so the worst case is a quiet UX regression — easy to spot
29/// in review.
30const KNOWN_FLAG_NAMES: &[&str] = &[
31    "aa",
32    "alpha",
33    "auto-gamma",
34    "background",
35    "bg",
36    "blur",
37    "brightness-contrast",
38    "camera",
39    "colorize",
40    "colors",
41    "colorspace",
42    "contrast",
43    "crop",
44    "define",
45    "density",
46    "depth",
47    "dither",
48    "edge",
49    "equalize",
50    "extent",
51    "flip",
52    "flop",
53    "format",
54    "fov",
55    "fuzz",
56    "gamma",
57    "gltf-format",
58    "help",
59    "json",
60    "level",
61    "light",
62    "modulate",
63    "monochrome",
64    "negate",
65    "normalize",
66    "opt",
67    "ping",
68    "posterize",
69    "probe",
70    "projection",
71    "quality",
72    "render",
73    "resize",
74    "roll",
75    "rotate",
76    "sepia",
77    "sharpen",
78    "solarize",
79    "stl-format",
80    "strip",
81    "thumbnail",
82    "threshold",
83    "trim",
84    "unsharp",
85    "vignette",
86    "watch",
87];
88
89/// Parse the slice that comes after `oxideav convert`.
90///
91/// Returns a `ConvertPlan` on success or an `Error::Invalid` /
92/// `Error::Unsupported` tagged with the specific `-flag` so callers
93/// can print the offending argument.
94pub fn parse(args: &[String]) -> Result<ConvertPlan, Error> {
95    if args.is_empty() {
96        return Err(Error::invalid(
97            "convert: no input file given (usage: convert [-op VALUE]... INPUT [-op VALUE]... OUTPUT)",
98        ));
99    }
100
101    // ImageMagick allows ops to appear before AND after the input
102    // (e.g. `convert -density 300 in.pdf -resize 800x600 out.png`).
103    // Walk every arg in order: anything starting with `-` is a flag
104    // (which may consume the following arg as its value); everything
105    // else is a positional. After parsing, the FIRST positional is the
106    // input and the LAST is the output. Multi-input is a documented
107    // round-2 follow-up; we error if more than two positionals are
108    // present.
109    let mut ops: Vec<Op> = Vec::new();
110    let mut positionals: Vec<String> = Vec::new();
111    let mut pending_dither = Dither::None;
112    let mut help = false;
113    let mut ping = false;
114    let mut probe = false;
115    let mut probe_json = false;
116    let mut probe_watch = false;
117    let mut mesh3d_options = Mesh3DOptions::default();
118    // Track the most recent `-background` colour in source order so
119    // `-extent` (which paints padding pixels with the active background)
120    // captures the correct value at parse time. Defaults to IM's opaque
121    // white when the user didn't set `-background` before `-extent`.
122    let mut current_bg: [u8; 4] = [255, 255, 255, 255];
123    // Track the most recent `-fuzz N[%]` value in source order so a
124    // following `-trim` reads this tolerance. IM treats `-fuzz` as a
125    // state setting that survives until the next `-fuzz`; pinning the
126    // value onto each emitted `Op::Trim` at parse time preserves the
127    // source-order semantics for the plan walker and side-channel
128    // (a later `-fuzz` does NOT retroactively rewrite an earlier
129    // `-trim`'s tolerance).
130    let mut current_fuzz: u8 = 0;
131
132    // `--` end-of-flags marker: everything after it is a positional,
133    // even when it starts with `-`. Lets users name files like
134    // `-weird.png` (`convert -- -weird.png out.jpg`).
135    let mut no_more_flags = false;
136
137    let mut i = 0;
138    while i < args.len() {
139        let flag = &args[i];
140        let val = |k: usize| -> Result<&str, Error> {
141            args.get(k)
142                .map(|s| s.as_str())
143                .ok_or_else(|| Error::invalid(format!("convert: {flag}: missing value")))
144        };
145
146        // Non-flag → positional. An empty argument can only be a
147        // mistake (usually an unquoted empty shell variable) — reject
148        // it here with a convert-speak message instead of letting an
149        // unopenable "" filename fail somewhere deep in a runner.
150        if no_more_flags || !flag.starts_with('-') {
151            if flag.is_empty() {
152                return Err(Error::invalid(
153                    "convert: empty argument (positional filenames must be non-empty; check for an unset shell variable)",
154                ));
155            }
156            positionals.push(flag.clone());
157            i += 1;
158            continue;
159        }
160
161        match flag.as_str() {
162            // GNU-style end-of-flags marker.
163            "--" => {
164                no_more_flags = true;
165                i += 1;
166            }
167            "-resize" => {
168                let v = val(i + 1)?;
169                let (mode, core) = ResizeMode::split_suffix(v);
170                let (w, h) = parse_resize_geometry(core, mode)
171                    .map_err(|e| Error::invalid(format!("convert: -resize: {e}")))?;
172                ops.push(Op::Resize {
173                    width: w,
174                    height: h,
175                    mode,
176                });
177                i += 2;
178            }
179            // `-thumbnail` is IM's "make a small representative image"
180            // convenience flag. Same geometry grammar as `-resize`;
181            // semantics differ in that IM also strips metadata and
182            // (for JPEG inputs) honours EXIF orientation. We unroll it
183            // into [`Op::Thumbnail`] which the runners expand to
184            // `Resize { mode } + Strip` (auto-orient is documented as
185            // a follow-up — needs an EXIF reader on the source side).
186            "-thumbnail" => {
187                let v = val(i + 1)?;
188                let (mode, core) = ResizeMode::split_suffix(v);
189                let (w, h) = parse_resize_geometry(core, mode)
190                    .map_err(|e| Error::invalid(format!("convert: -thumbnail: {e}")))?;
191                ops.push(Op::Thumbnail {
192                    width: w,
193                    height: h,
194                    mode,
195                });
196                i += 2;
197            }
198            // `-define KEY[=VALUE]` — opaque codec-specific tunable.
199            // Forwarded literally to the sink encoder; the encoder
200            // ignores keys it doesn't recognise. IM's grammar uses `:`
201            // inside the key as a namespace separator (e.g.
202            // `jpeg:dct-method=float`); we don't parse that — the key
203            // is preserved verbatim.
204            "-define" => {
205                let v = val(i + 1)?;
206                let (key, value) = match v.split_once('=') {
207                    Some((k, val)) => (k.to_string(), Some(val.to_string())),
208                    None => (v.to_string(), None),
209                };
210                if key.is_empty() {
211                    return Err(Error::invalid(format!(
212                        "convert: -define: '{v}' has an empty KEY (expected KEY[=VALUE])"
213                    )));
214                }
215                ops.push(Op::Define { key, value });
216                i += 2;
217            }
218            "-blur" => {
219                let v = val(i + 1)?;
220                let (radius, sigma) =
221                    parse_blur(v).map_err(|e| Error::invalid(format!("convert: -blur: {e}")))?;
222                ops.push(Op::Blur { radius, sigma });
223                i += 2;
224            }
225            "-edge" => {
226                let v = val(i + 1)?;
227                let r: u32 = v.parse().map_err(|_| {
228                    Error::invalid(format!(
229                        "convert: -edge: '{v}' is not a non-negative integer"
230                    ))
231                })?;
232                ops.push(Op::Edge { radius: r });
233                i += 2;
234            }
235            "-colors" => {
236                let v = val(i + 1)?;
237                let n: u32 = v.parse().map_err(|_| {
238                    Error::invalid(format!(
239                        "convert: -colors: '{v}' is not a non-negative integer"
240                    ))
241                })?;
242                if !(2..=256).contains(&n) {
243                    return Err(Error::invalid(format!(
244                        "convert: -colors {n} out of range (2..=256)"
245                    )));
246                }
247                ops.push(Op::Colors {
248                    count: n,
249                    dither: pending_dither,
250                });
251                i += 2;
252            }
253            "-dither" => {
254                let v = val(i + 1)?;
255                pending_dither = Dither::parse(v).map_err(Error::invalid)?;
256                i += 2;
257            }
258            // `-monochrome` (no value) — IM's "make me a 1-bit dithered
259            // black-and-white image" convenience flag. Equivalent to the
260            // chain `-colorspace gray -colors 2 -dither floyd_steinberg`,
261            // so we lower it to those three primitive ops here rather
262            // than carving out a dedicated runner path. The order
263            // matters: grayscale-first means the palette-builder sees
264            // luminance values rather than colour, so the 2-entry
265            // palette lands on black + white instead of two arbitrary
266            // colour samples. Floyd-Steinberg is IM's documented
267            // default for the 1-bit reduction (newer IM exposes
268            // `-define dither:` to override; we don't yet plumb that).
269            // A user who wants the un-dithered "Atkinson cut" behaviour
270            // can still write the chain by hand with `-dither none`.
271            "-monochrome" => {
272                ops.push(Op::Colorspace("gray".to_string()));
273                ops.push(Op::Colors {
274                    count: 2,
275                    dither: Dither::FloydSteinberg,
276                });
277                i += 1;
278            }
279            "-format" => {
280                let v = val(i + 1)?;
281                ops.push(Op::Format(v.to_string()));
282                i += 2;
283            }
284            // `-depth N` — IM's per-channel output depth. Only the
285            // two storage depths still-image encoders carry are
286            // meaningful; anything else is a typo worth surfacing.
287            "-depth" => {
288                let v = val(i + 1)?;
289                let d: u8 = match v.parse() {
290                    Ok(d @ (8 | 16)) => d,
291                    _ => {
292                        return Err(Error::invalid(format!(
293                            "convert: -depth: '{v}' must be 8 or 16"
294                        )))
295                    }
296                };
297                ops.push(Op::Depth(d));
298                i += 2;
299            }
300            // `--opt KEY=VALUE` — encoder option for still-image
301            // sinks. GNU-style double dash: it is not an IM op. The
302            // key is validated against the target encoder's schema at
303            // plan time (the parser does not know the output codec).
304            "--opt" | "-opt" => {
305                let v = val(i + 1)?;
306                let (key, value) = v.split_once('=').ok_or_else(|| {
307                    Error::invalid(format!("convert: --opt: '{v}' is not KEY=VALUE"))
308                })?;
309                if key.is_empty() {
310                    return Err(Error::invalid(format!(
311                        "convert: --opt: '{v}' has an empty KEY (expected KEY=VALUE)"
312                    )));
313                }
314                ops.push(Op::EncoderOpt {
315                    key: key.to_string(),
316                    value: value.to_string(),
317                });
318                i += 2;
319            }
320            "-quality" => {
321                let v = val(i + 1)?;
322                let q: u32 = v.parse().map_err(|_| {
323                    Error::invalid(format!(
324                        "convert: -quality: '{v}' is not a non-negative integer"
325                    ))
326                })?;
327                // IM-conventional cap: every lossy codec convert
328                // forwards `-quality` to (JPEG, WebP, AVIF, …) expects
329                // `0..=100`. Without this guard `-quality 9999` and
330                // `-quality 150` silently propagate and the encoder
331                // drops the key — looking to the user like the flag
332                // was honoured. Reject up-front with a clear range so
333                // the typo (`-quality 1000` instead of `-quality 100`)
334                // surfaces at parse time. The 0/100 endpoints stay
335                // accepted: 0 is "smallest acceptable" on JPEG and
336                // 100 is "highest". Out-of-band values were never
337                // useful — codecs ignore them.
338                if q > 100 {
339                    return Err(Error::invalid(format!(
340                        "convert: -quality: {q} out of range (must be in 0..=100; common values: 75 default, 85 web, 95 archival)"
341                    )));
342                }
343                ops.push(Op::Quality(q));
344                i += 2;
345            }
346            "-strip" => {
347                ops.push(Op::Strip);
348                i += 1;
349            }
350            "-ping" => {
351                ping = true;
352                i += 1;
353            }
354            // `--help` (GNU-style) and `-help` (IM-style) both print
355            // the usage synopsis. Recognised anywhere on the line and
356            // outranks everything else — positional validation is
357            // skipped so a bare `convert --help` works.
358            "--help" | "-help" => {
359                help = true;
360                i += 1;
361            }
362            // `--probe` is GNU-style double-dash because it's a
363            // session-scope mode switch (no value, no per-input
364            // semantics) — same shape as `--help` / `--version` would
365            // have if the convert verb owned them. Single-dash `-probe`
366            // is reserved as a future IM-style probe-OP that might
367            // legitimately want an argument.
368            "--probe" => {
369                probe = true;
370                i += 1;
371            }
372            // `--json` selects a machine-readable output flavour for
373            // the probe summary. It's only meaningful paired with
374            // `--probe`; the validation runs after the loop so we can
375            // emit a clear "needs --probe" error rather than a generic
376            // "unknown flag" one.
377            "--json" => {
378                probe_json = true;
379                i += 1;
380            }
381            // `--watch` re-runs the probe whenever the input file's
382            // mtime changes. Like `--json`, it's only meaningful paired
383            // with `--probe` (there's no other long-running mode for it
384            // to attach to); the post-loop validation surfaces a clear
385            // "needs --probe" error rather than a generic "unknown
386            // flag" one.
387            "--watch" => {
388                probe_watch = true;
389                i += 1;
390            }
391            "-density" => {
392                let v = val(i + 1)?;
393                let n: u32 = v.parse().map_err(|_| {
394                    Error::invalid(format!(
395                        "convert: -density: '{v}' is not a non-negative integer"
396                    ))
397                })?;
398                if n == 0 {
399                    return Err(Error::invalid("convert: -density must be > 0"));
400                }
401                ops.push(Op::Density(n));
402                i += 2;
403            }
404            "-background" => {
405                let v = val(i + 1)?;
406                let rgba = parse_color(v).map_err(Error::invalid)?;
407                // Bake the current background into the parse state so a
408                // subsequent `-extent` reads this value rather than the
409                // initial IM-default white.
410                current_bg = rgba;
411                ops.push(Op::Background(rgba));
412                i += 2;
413            }
414            "-alpha" => {
415                let v = val(i + 1)?;
416                let a = AlphaOp::parse(v).map_err(Error::invalid)?;
417                ops.push(Op::Alpha(a));
418                i += 2;
419            }
420            "-rotate" => {
421                let v = val(i + 1)?;
422                let n: i32 = v.parse().map_err(|_| {
423                    Error::invalid(format!("convert: -rotate: '{v}' is not an integer"))
424                })?;
425                // Round-1 supports only quarter-turn rotations.
426                // Other angles need a real resampler (bilinear / lanczos
427                // sample at non-integer coordinates), which we'll wire
428                // through oxideav-image-filter once it lands.
429                if !matches!(n, 90 | 180 | 270 | -90 | -180 | -270) {
430                    return Err(Error::invalid(format!(
431                        "convert: -rotate: only multiples of 90 supported (got {n})"
432                    )));
433                }
434                ops.push(Op::Rotate { degrees: n });
435                i += 2;
436            }
437            "-flip" => {
438                ops.push(Op::Flip);
439                i += 1;
440            }
441            "-flop" => {
442                ops.push(Op::Flop);
443                i += 1;
444            }
445            "-negate" => {
446                ops.push(Op::Negate);
447                i += 1;
448            }
449            "-crop" => {
450                let v = val(i + 1)?;
451                let (w, h, x, y) =
452                    parse_crop(v).map_err(|e| Error::invalid(format!("convert: -crop: {e}")))?;
453                ops.push(Op::Crop { x, y, w, h });
454                i += 2;
455            }
456            "-extent" => {
457                // IM-style canvas re-window: `WxH`, `WxH+X+Y`, `WxH-X-Y`,
458                // or any mix of signs on the two offsets. The background
459                // colour painted under padding pixels is the one set by
460                // the most recent `-background` op in source order — we
461                // bake it into the op here so the plan walker / inline
462                // pixel-xform path don't need to track parser state.
463                let v = val(i + 1)?;
464                let (w, h, x, y) = parse_extent(v)
465                    .map_err(|e| Error::invalid(format!("convert: -extent: {e}")))?;
466                ops.push(Op::Extent {
467                    width: w,
468                    height: h,
469                    x,
470                    y,
471                    bg: current_bg,
472                });
473                i += 2;
474            }
475            // `-fuzz N[%]` updates the in-band fuzz state. IM's grammar
476            // accepts either a raw byte value (`0..=255`) or a percent
477            // (`N%`, `0..=100`) — share the `-threshold` / `-solarize`
478            // parser. The flag itself produces no `Op`; it only mutates
479            // `current_fuzz`, which a following `-trim` reads.
480            "-fuzz" => {
481                let v = val(i + 1)?;
482                current_fuzz = parse_threshold_pct(v)
483                    .map_err(|e| Error::invalid(format!("convert: -fuzz: {e}")))?;
484                i += 2;
485            }
486            // `-trim` (valueless) — auto-crop the bounding box of pixels
487            // differing from the corner-pixel reference background by
488            // more than the active `-fuzz` tolerance. The tolerance is
489            // captured at parse time so source-order semantics survive
490            // through the plan walker and the inline side-channel.
491            "-trim" => {
492                ops.push(Op::Trim { fuzz: current_fuzz });
493                i += 1;
494            }
495            // `-roll ±X±Y` — IM-style circular pixel shift. Both X and
496            // Y offsets must be present and signed; columns wrap around
497            // the right edge when `dx > 0`, rows wrap around the bottom
498            // edge when `dy > 0`. Width / height stay unchanged. Shares
499            // the signed-offset parser used by `-extent`'s `±X±Y` tail.
500            "-roll" => {
501                let v = val(i + 1)?;
502                let (dx, dy) =
503                    parse_roll(v).map_err(|e| Error::invalid(format!("convert: -roll: {e}")))?;
504                ops.push(Op::Roll { dx, dy });
505                i += 2;
506            }
507            // ---- Round-next: IM-style image-filter flags wired
508            // through to the matching `oxideav-image-filter` factory. ----
509            "-sharpen" => {
510                let v = val(i + 1)?;
511                let (radius, sigma) =
512                    parse_blur(v).map_err(|e| Error::invalid(format!("convert: -sharpen: {e}")))?;
513                ops.push(Op::Sharpen { radius, sigma });
514                i += 2;
515            }
516            "-unsharp" => {
517                let v = val(i + 1)?;
518                let (radius, sigma, amount, threshold) = parse_unsharp(v)
519                    .map_err(|e| Error::invalid(format!("convert: -unsharp: {e}")))?;
520                ops.push(Op::Unsharp {
521                    radius,
522                    sigma,
523                    amount,
524                    threshold,
525                });
526                i += 2;
527            }
528            "-gamma" => {
529                let v = val(i + 1)?;
530                let g: f32 = v.parse().map_err(|_| {
531                    Error::invalid(format!("convert: -gamma: '{v}' is not a finite float"))
532                })?;
533                if !g.is_finite() || g <= 0.0 {
534                    return Err(Error::invalid(format!(
535                        "convert: -gamma: value must be > 0 (got {g})"
536                    )));
537                }
538                ops.push(Op::Gamma { value: g });
539                i += 2;
540            }
541            "-brightness-contrast" => {
542                let v = val(i + 1)?;
543                let (b, c) = parse_brightness_contrast(v)
544                    .map_err(|e| Error::invalid(format!("convert: -brightness-contrast: {e}")))?;
545                ops.push(Op::BrightnessContrast {
546                    brightness: b,
547                    contrast: c,
548                });
549                i += 2;
550            }
551            "-contrast" => {
552                // IM's `-contrast` takes no arg and applies a single
553                // small contrast bump. Repeated `-contrast` accumulates.
554                ops.push(Op::Contrast { delta: 1 });
555                i += 1;
556            }
557            "-sepia" => {
558                let v = val(i + 1)?;
559                let t = parse_percent_or_unit(v)
560                    .map_err(|e| Error::invalid(format!("convert: -sepia: {e}")))?;
561                ops.push(Op::Sepia { threshold: t });
562                i += 2;
563            }
564            "-modulate" => {
565                let v = val(i + 1)?;
566                let (b, s, h) = parse_modulate(v)
567                    .map_err(|e| Error::invalid(format!("convert: -modulate: {e}")))?;
568                ops.push(Op::Modulate {
569                    brightness: b,
570                    saturation: s,
571                    hue: h,
572                });
573                i += 2;
574            }
575            "-level" => {
576                let v = val(i + 1)?;
577                let (b, g, w) =
578                    parse_level(v).map_err(|e| Error::invalid(format!("convert: -level: {e}")))?;
579                ops.push(Op::Level {
580                    black: b,
581                    gamma: g,
582                    white: w,
583                });
584                i += 2;
585            }
586            "-normalize" => {
587                ops.push(Op::Normalize);
588                i += 1;
589            }
590            "-threshold" => {
591                let v = val(i + 1)?;
592                let n = parse_threshold_pct(v)
593                    .map_err(|e| Error::invalid(format!("convert: -threshold: {e}")))?;
594                ops.push(Op::Threshold { value: n });
595                i += 2;
596            }
597            "-posterize" => {
598                let v = val(i + 1)?;
599                let n: u32 = v.parse().map_err(|_| {
600                    Error::invalid(format!(
601                        "convert: -posterize: '{v}' is not a non-negative integer"
602                    ))
603                })?;
604                if n < 2 {
605                    return Err(Error::invalid(format!(
606                        "convert: -posterize: levels must be >= 2 (got {n})"
607                    )));
608                }
609                ops.push(Op::Posterize { levels: n });
610                i += 2;
611            }
612            "-solarize" => {
613                let v = val(i + 1)?;
614                let n = parse_threshold_pct(v)
615                    .map_err(|e| Error::invalid(format!("convert: -solarize: {e}")))?;
616                ops.push(Op::Solarize { value: n });
617                i += 2;
618            }
619            "-vignette" => {
620                let v = val(i + 1)?;
621                let (radius, sigma, x, y) = parse_vignette(v)
622                    .map_err(|e| Error::invalid(format!("convert: -vignette: {e}")))?;
623                ops.push(Op::Vignette {
624                    radius,
625                    sigma,
626                    x,
627                    y,
628                });
629                i += 2;
630            }
631            "-colorize" => {
632                let v = val(i + 1)?;
633                let (color, amount) = parse_colorize(v)
634                    .map_err(|e| Error::invalid(format!("convert: -colorize: {e}")))?;
635                ops.push(Op::Colorize { color, amount });
636                i += 2;
637            }
638            "-equalize" => {
639                ops.push(Op::Equalize);
640                i += 1;
641            }
642            "-auto-gamma" => {
643                ops.push(Op::AutoGamma);
644                i += 1;
645            }
646            "-colorspace" => {
647                let v = val(i + 1)?;
648                let cs = v.trim().to_string();
649                // Round-1 only routes `gray`/`grey` to the grayscale
650                // factory; other colourspaces become a recorded no-op
651                // (the input keeps its native colourspace).
652                let lower = cs.to_ascii_lowercase();
653                if !matches!(lower.as_str(), "gray" | "grey" | "rgb" | "srgb") {
654                    return Err(Error::unsupported(format!(
655                        "convert: -colorspace '{cs}' is not yet wired (round-1 supports gray/grey/rgb/srgb)"
656                    )));
657                }
658                ops.push(Op::Colorspace(cs));
659                i += 2;
660            }
661            // ---- Per-format encoder option flags for the 3D
662            //      side-channel. Stored on `ConvertPlan::mesh3d_options`,
663            //      not as `Op` entries, since they're plan-level
664            //      switches that only the mesh3d_runner consumes
665            //      (raster ops have no analogous notion). The flags
666            //      are accepted on every input shape (PDF / raster /
667            //      etc.) and silently ignored when the side-channel
668            //      doesn't fire — matches IM's tolerant-of-irrelevant-
669            //      flags posture.
670            "-stl-format" => {
671                let v = val(i + 1)?;
672                let choice = StlFormatChoice::parse(v).map_err(Error::invalid)?;
673                mesh3d_options.stl_format = Some(choice);
674                i += 2;
675            }
676            "-gltf-format" => {
677                let v = val(i + 1)?;
678                let choice = GltfFormatChoice::parse(v).map_err(Error::invalid)?;
679                mesh3d_options.gltf_format = Some(choice);
680                i += 2;
681            }
682            // `-render flat|wireframe` selects the 3D→raster surface
683            // model. Stored on `Mesh3DOptions` (sister to `-stl-format`
684            // and `-gltf-format`) so it never leaks into the regular
685            // pipeline path. Silently ignored when the input/output
686            // pair doesn't trigger the 3D→raster side-channel.
687            "-render" => {
688                let v = val(i + 1)?;
689                let mode = Mesh3DRenderMode::parse(v).map_err(Error::invalid)?;
690                mesh3d_options.render_mode = Some(mode);
691                i += 2;
692            }
693            // `-light AZIMUTH,ELEVATION,INTENSITY` — directional light
694            // override for the 3D→raster renderer's Gouraud / Phong
695            // shading paths. Stored on `Mesh3DOptions::light`; silently
696            // ignored when the input/output pair doesn't trigger 3D
697            // rendering.
698            "-light" => {
699                let v = val(i + 1)?;
700                let spec = LightSpec::parse(v).map_err(Error::invalid)?;
701                mesh3d_options.light = Some(spec);
702                i += 2;
703            }
704            // `-camera ELEVATION,AZIMUTH,DISTANCE` — camera override for
705            // the 3D→raster renderer (replaces the auto-framed default).
706            "-camera" => {
707                let v = val(i + 1)?;
708                let spec = CameraSpec::parse(v).map_err(Error::invalid)?;
709                mesh3d_options.camera = Some(spec);
710                i += 2;
711            }
712            // `-projection perspective|orthographic` — projection mode
713            // for the 3D→raster renderer. Default is perspective.
714            "-projection" => {
715                let v = val(i + 1)?;
716                let mode = ProjectionMode::parse(v).map_err(Error::invalid)?;
717                mesh3d_options.projection = Some(mode);
718                i += 2;
719            }
720            // `-fov DEGREES` — vertical field of view for the 3D→raster
721            // renderer's perspective projection. Default is 60°. Ignored
722            // for orthographic projection.
723            "-fov" => {
724                let v = val(i + 1)?;
725                let fov: f32 = v
726                    .parse()
727                    .map_err(|_| Error::invalid(format!("convert: -fov: '{v}' is not a number")))?;
728                if !fov.is_finite() || fov <= 0.0 || fov >= 180.0 {
729                    return Err(Error::invalid(format!(
730                        "convert: -fov: {fov} must be in (0, 180) degrees"
731                    )));
732                }
733                mesh3d_options.fov_deg = Some(fov);
734                i += 2;
735            }
736            // `-bg COLOR` — background fill for the 3D render canvas.
737            // Default is transparent black. Distinct from `-background`
738            // which is the IM canvas-fill for alpha-remove + PDF.
739            "-bg" => {
740                let v = val(i + 1)?;
741                let colour = parse_color(v).map_err(Error::invalid)?;
742                mesh3d_options.bg = Some(colour);
743                i += 2;
744            }
745            // `-aa N` — supersampling factor for the 3D→raster
746            // renderer (rasterise at N × output, box-filter back to
747            // 1 × output). `1` means no supersampling. `>= 2` trades
748            // memory + CPU for smoother triangle edges. Stored on
749            // `Mesh3DOptions::aa`; silently ignored when the
750            // input/output pair doesn't trigger 3D rendering. We
751            // cap at 8 to keep memory bounded — at 8× a 1024²
752            // render needs ~80 MB just for the framebuffer + z-buffer
753            // and that already costs more than most users want.
754            "-aa" => {
755                let v = val(i + 1)?;
756                let factor: u32 = v.parse().map_err(|_| {
757                    Error::invalid(format!("convert: -aa: '{v}' is not a positive integer"))
758                })?;
759                if !(1..=8).contains(&factor) {
760                    return Err(Error::invalid(format!(
761                        "convert: -aa: factor {factor} out of range (must be in 1..=8)"
762                    )));
763                }
764                mesh3d_options.aa = Some(factor);
765                i += 2;
766            }
767            other => {
768                // Reach here only on `-`-prefixed args (non-`-` was
769                // pushed to `positionals` above). Add a "did you mean?"
770                // hint when the bogus token is a close edit of a known
771                // flag — same Levenshtein-cutoff helper the mesh3d-runner
772                // uses for extension typos. We strip the leading dashes
773                // before comparing so `-quailty` matches `-quality` at a
774                // 2-edit distance (transposition) instead of being
775                // scored 3 because of the dash overhead, and we
776                // reattach the same dash flavour (single or double) the
777                // user typed when rendering the suggestion. Unknown
778                // tokens that aren't close to any flag (e.g. `-fnord`)
779                // produce the bare error, exactly as before.
780                let (lead, bare) = if let Some(rest) = other.strip_prefix("--") {
781                    ("--", rest)
782                } else {
783                    ("-", other.strip_prefix('-').unwrap_or(other))
784                };
785                let hint = crate::suggest::closest_match(bare, KNOWN_FLAG_NAMES)
786                    .map(|s| format!(" (did you mean '{lead}{s}'?)"))
787                    .unwrap_or_default();
788                return Err(Error::invalid(format!(
789                    "convert: unknown flag '{other}'{hint}"
790                )));
791            }
792        }
793    }
794
795    // `--help` short-circuits every validation below: the user asked
796    // for the synopsis, not a conversion, so missing/extra positionals
797    // are irrelevant.
798    if help {
799        return Ok(ConvertPlan {
800            input: String::new(),
801            input_pages: None,
802            ops,
803            output: String::new(),
804            output_template: None,
805            help: true,
806            ping,
807            probe,
808            probe_json,
809            probe_watch,
810            mesh3d_options,
811        });
812    }
813
814    if positionals.is_empty() {
815        return Err(Error::invalid(
816            "convert: no input file given (usage: convert [-op VALUE]... INPUT [-op VALUE]... OUTPUT)",
817        ));
818    }
819    // `--probe` is mutually exclusive with an output positional —
820    // probing means "describe, don't write", and the user passing both
821    // is almost always a mistake (typo'd a flag, copy-pasted from a
822    // real conversion). Surface it as a clear actionable error rather
823    // than silently writing or silently dropping the output arg.
824    if probe && positionals.len() >= 2 {
825        return Err(Error::invalid(format!(
826            "convert: --probe cannot be combined with an output file ('{}'); use either --probe OR an output file, not both",
827            positionals[1]
828        )));
829    }
830    if positionals.len() < 2 && !ping && !probe {
831        return Err(Error::invalid(
832            "convert: no output file given (usage: convert [-op VALUE]... INPUT [-op VALUE]... OUTPUT)",
833        ));
834    }
835    if positionals.len() > 2 {
836        return Err(Error::unsupported(format!(
837            "convert: {} positional arguments given but multi-input is not yet supported (round-2 follow-up); pass exactly INPUT OUTPUT",
838            positionals.len()
839        )));
840    }
841    // `--json` is only meaningful as a formatting modifier on
842    // `--probe`. Without `--probe` there's no probe summary to
843    // serialise, so we'd otherwise silently swallow the flag.
844    if probe_json && !probe {
845        return Err(Error::invalid(
846            "convert: --json requires --probe (today --json only formats the probe summary)",
847        ));
848    }
849    // `--watch` re-runs the probe on mtime change; only meaningful
850    // paired with `--probe`. Without `--probe` there's no long-running
851    // mode for it to attach to.
852    if probe_watch && !probe {
853        return Err(Error::invalid(
854            "convert: --watch requires --probe (today --watch only re-runs the probe on input mtime change)",
855        ));
856    }
857
858    let (raw_input, input_pages) = split_input_selector(&positionals[0])?;
859    let input = translate_input_shorthand(raw_input);
860    let output = positionals.get(1).cloned().unwrap_or_default();
861    let output_template = if output.is_empty() {
862        None
863    } else {
864        parse_printf_template(&output)?
865    };
866
867    Ok(ConvertPlan {
868        input,
869        input_pages,
870        ops,
871        output,
872        output_template,
873        help: false,
874        ping,
875        probe,
876        probe_json,
877        probe_watch,
878        mesh3d_options,
879    })
880}
881
882/// Human-readable usage synopsis for the convert verb.
883///
884/// The flag list is generated from [`KNOWN_FLAG_NAMES`] — the same
885/// table that powers "did you mean?" hints and is sync-tested against
886/// the parser's `match` arms — so the help text cannot drift from
887/// what the parser actually accepts.
888pub fn usage() -> String {
889    let mut s = String::new();
890    s.push_str("usage: oxideav convert [-op VALUE]... INPUT [-op VALUE]... OUTPUT\n");
891    s.push_str("       oxideav convert --probe [--json] [--watch] INPUT\n");
892    s.push_str("       oxideav convert -ping INPUT\n");
893    s.push_str("       oxideav convert --help\n");
894    s.push('\n');
895    s.push_str("Ops may appear before AND after the input; the first non-flag\n");
896    s.push_str("positional is the input, the last is the output. `--` ends flag\n");
897    s.push_str("parsing so filenames may start with `-`. PDF inputs accept a\n");
898    s.push_str("[N] / [N-M] / [A,B,...] page-selector suffix; `%d` output\n");
899    s.push_str("templates fan out one file per selected page.\n");
900    s.push('\n');
901    s.push_str("Recognised flags (see the crate README for per-flag grammar):\n");
902    for chunk in KNOWN_FLAG_NAMES.chunks(5) {
903        s.push(' ');
904        for name in chunk {
905            // The four GNU-style mode switches take a double dash;
906            // everything else is a single-dash IM-style op.
907            let lead = if matches!(*name, "probe" | "json" | "watch" | "help" | "opt") {
908                "--"
909            } else {
910                "-"
911            };
912            s.push(' ');
913            s.push_str(lead);
914            s.push_str(name);
915        }
916        s.push('\n');
917    }
918    s
919}
920
921/// Strip an ImageMagick-style `[N]` / `[N-M]` / `[A,B,…]` page-selector
922/// suffix from the input path. Examples:
923///
924/// * `input.pdf[0]` → `("input.pdf", Some(Single(0)))`
925/// * `input.pdf[2-5]` → `("input.pdf", Some(Range(2, 5)))`
926/// * `input.pdf[-1]` → `("input.pdf", Some(Single(-1)))` (resolves to last page)
927/// * `input.pdf[5--1]` → `("input.pdf", Some(Range(5, -1)))` (page 5 to last)
928/// * `input.pdf[0,2,4]` → `("input.pdf", Some(List([Single(0), Single(2), Single(4)])))`
929/// * `input.pdf[0-2,5,-1]` → `("input.pdf", Some(List([Range(0,2), Single(5), Single(-1)])))`
930///
931/// Inputs with no `[…]` suffix return `(input, None)`.
932///
933/// Returns an `Err` for malformed selectors (`[abc]`, `[1-2-3]`, `[]`,
934/// `[,]`, `[0,,1]`, unbalanced brackets, etc.) so the user gets a
935/// clear message.
936pub(crate) fn split_input_selector(s: &str) -> Result<(&str, Option<PageSelector>), Error> {
937    if !s.ends_with(']') {
938        return Ok((s, None));
939    }
940    let open = match s.rfind('[') {
941        Some(i) => i,
942        None => {
943            return Err(Error::invalid(format!(
944                "convert: input '{s}' has a closing `]` with no matching `[`"
945            )));
946        }
947    };
948    let body = &s[open + 1..s.len() - 1];
949    if body.is_empty() {
950        return Err(Error::invalid(format!(
951            "convert: input '{s}' has an empty `[]` page selector"
952        )));
953    }
954    let sel = parse_page_selector_body(s, body)?;
955    Ok((&s[..open], Some(sel)))
956}
957
958/// Parse the inside of a `[…]` page-selector. Splits on `,`, then each
959/// piece on a single `-` (taking care to keep a LEADING `-` as the
960/// sign of a negative endpoint, not a range separator). One atom →
961/// `Single`/`Range` variant; multiple atoms → `List`.
962fn parse_page_selector_body(input: &str, body: &str) -> Result<PageSelector, Error> {
963    // Comma-separated list of atoms. IM accepts `[0,2,4]` for "pages
964    // 0, 2, and 4"; we extend to ranges-within-lists (`[0-2,5,-1]`).
965    let pieces: Vec<&str> = body.split(',').collect();
966    let mut atoms: Vec<PageAtom> = Vec::with_capacity(pieces.len());
967    for piece in &pieces {
968        let trimmed = piece.trim();
969        if trimmed.is_empty() {
970            return Err(Error::invalid(format!(
971                "convert: input '{input}': page selector '{body}' has an empty atom (between commas)"
972            )));
973        }
974        atoms.push(parse_page_atom(input, trimmed)?);
975    }
976    Ok(if atoms.len() == 1 {
977        // Preserve the single-atom variants — most callers only care
978        // about the one-atom case and matching `Single(2)` is far
979        // friendlier than walking a one-element `List([Single(2)])`.
980        match atoms.into_iter().next().unwrap() {
981            PageAtom::Single(n) => PageSelector::Single(n),
982            PageAtom::Range(a, b) => PageSelector::Range(a, b),
983        }
984    } else {
985        PageSelector::List(atoms)
986    })
987}
988
989/// Parse one atom of a page selector. Accepts `N`, `-N`, `N-M`,
990/// `-N-M`, `N--M`, `-N--M`. Returns `Single(n)` for plain indices
991/// (possibly negative) and `Range(a, b)` for two-endpoint specs.
992///
993/// The trick: `-` is both the unary sign for negative indices AND the
994/// range separator. We split off a leading `-` first (so `-1` doesn't
995/// look like a range with an empty start), then look for the next `-`
996/// as the range delimiter, treating a leading `-` on the second
997/// endpoint the same way.
998fn parse_page_atom(input: &str, atom: &str) -> Result<PageAtom, Error> {
999    // Peel off a leading `-` as the sign of the first endpoint (if any).
1000    let (first_sign, rest) = if let Some(rest) = atom.strip_prefix('-') {
1001        ("-", rest)
1002    } else {
1003        ("", atom)
1004    };
1005    if rest.is_empty() {
1006        return Err(Error::invalid(format!(
1007            "convert: input '{input}': page selector atom '{atom}' is just a sign with no digits"
1008        )));
1009    }
1010    // Now look for a `-` separator (range). Anything past the first
1011    // digit-run that's `-` opens the second endpoint.
1012    match rest.find('-') {
1013        None => {
1014            // Single atom — `first_sign + rest` is a plain integer.
1015            let n: isize = format!("{first_sign}{rest}").parse().map_err(|_| {
1016                Error::invalid(format!(
1017                    "convert: input '{input}': page selector atom '{atom}' is not an integer page index"
1018                ))
1019            })?;
1020            Ok(PageAtom::Single(n))
1021        }
1022        Some(sep) => {
1023            // Range. Left endpoint is `first_sign + rest[..sep]`.
1024            let left = format!("{first_sign}{}", &rest[..sep]);
1025            let right_raw = &rest[sep + 1..];
1026            if left.is_empty() || left == "-" {
1027                return Err(Error::invalid(format!(
1028                    "convert: input '{input}': page selector atom '{atom}' has no left endpoint"
1029                )));
1030            }
1031            if right_raw.is_empty() {
1032                return Err(Error::invalid(format!(
1033                    "convert: input '{input}': page selector atom '{atom}' has no right endpoint"
1034                )));
1035            }
1036            // The right side may itself start with `-` to mark a
1037            // negative endpoint (e.g. `5--1` = page 5 to last). After
1038            // splitting on the FIRST `-`, anything beyond that
1039            // shouldn't carry yet ANOTHER unparseable `-`.
1040            let a: isize = left.parse().map_err(|_| {
1041                Error::invalid(format!(
1042                    "convert: input '{input}': page selector atom '{atom}': left endpoint '{left}' is not an integer"
1043                ))
1044            })?;
1045            let b: isize = right_raw.parse().map_err(|_| {
1046                Error::invalid(format!(
1047                    "convert: input '{input}': page selector atom '{atom}': right endpoint '{right_raw}' is not an integer (expected `N-M`, `-N-M`, or `N--M`)"
1048                ))
1049            })?;
1050            Ok(PageAtom::Range(a, b))
1051        }
1052    }
1053}
1054
1055/// Scan an output filename for a single `%[0-9]*d` token.
1056///
1057/// Returns `Ok(Some(template))` when exactly one such token is found;
1058/// `Ok(None)` when there is no `%` at all (the literal-filename case);
1059/// `Err` when there are multiple `%d` tokens or any unsupported format
1060/// specifier (`%s`, `%x`, `%%`, …). The two-pass design keeps the
1061/// invariant that `output_template == None` iff the filename is a plain
1062/// path that should be written to verbatim.
1063pub(crate) fn parse_printf_template(s: &str) -> Result<Option<PrintfTemplate>, Error> {
1064    let bytes = s.as_bytes();
1065    let mut i = 0usize;
1066    let mut found: Option<(usize, u8, usize)> = None; // (start, width, end)
1067    while i < bytes.len() {
1068        if bytes[i] != b'%' {
1069            i += 1;
1070            continue;
1071        }
1072        let start = i;
1073        i += 1;
1074        let mut width: u8 = 0;
1075        while i < bytes.len() && bytes[i].is_ascii_digit() {
1076            // Cap width to a sensible limit to avoid pathological inputs.
1077            let d = (bytes[i] - b'0') as u32;
1078            let next = (width as u32) * 10 + d;
1079            if next > 20 {
1080                return Err(Error::invalid(format!(
1081                    "convert: output template '{s}' has an unreasonable width specifier"
1082                )));
1083            }
1084            width = next as u8;
1085            i += 1;
1086        }
1087        if i >= bytes.len() {
1088            return Err(Error::invalid(format!(
1089                "convert: output template '{s}' has a `%` with no format specifier"
1090            )));
1091        }
1092        match bytes[i] {
1093            b'd' => {
1094                if found.is_some() {
1095                    return Err(Error::invalid(format!(
1096                        "convert: output template '{s}' has more than one `%d` token (expected exactly one)"
1097                    )));
1098                }
1099                found = Some((start, width, i + 1));
1100                i += 1;
1101            }
1102            other => {
1103                return Err(Error::invalid(format!(
1104                    "convert: output template '{s}' uses unsupported format specifier '%{}' (only `%d`, `%03d`, etc. are accepted)",
1105                    other as char
1106                )));
1107            }
1108        }
1109    }
1110    Ok(found.map(|(start, width, end)| PrintfTemplate {
1111        prefix: s[..start].to_string(),
1112        width,
1113        suffix: s[end..].to_string(),
1114    }))
1115}
1116
1117/// CSS L3 named colours + `#hex` 3/4/6/8 form. Same grammar as
1118/// `oxideav-generator`'s `xc:` parser. Kept local to convert so we
1119/// don't have to pull `oxideav-generator` in when the `generator`
1120/// feature is off.
1121fn parse_color(s: &str) -> Result<[u8; 4], String> {
1122    let s = s.trim();
1123    if let Some(hex) = s.strip_prefix('#') {
1124        return parse_hex_color(hex);
1125    }
1126    match s.to_ascii_lowercase().as_str() {
1127        "transparent" | "none" => Ok([0, 0, 0, 0]),
1128        "black" => Ok([0, 0, 0, 255]),
1129        "white" => Ok([255, 255, 255, 255]),
1130        "red" => Ok([255, 0, 0, 255]),
1131        "green" => Ok([0, 128, 0, 255]),
1132        "lime" => Ok([0, 255, 0, 255]),
1133        "blue" => Ok([0, 0, 255, 255]),
1134        "yellow" => Ok([255, 255, 0, 255]),
1135        "cyan" | "aqua" => Ok([0, 255, 255, 255]),
1136        "magenta" | "fuchsia" => Ok([255, 0, 255, 255]),
1137        "gray" | "grey" => Ok([128, 128, 128, 255]),
1138        "silver" => Ok([192, 192, 192, 255]),
1139        "maroon" => Ok([128, 0, 0, 255]),
1140        "olive" => Ok([128, 128, 0, 255]),
1141        "purple" => Ok([128, 0, 128, 255]),
1142        "teal" => Ok([0, 128, 128, 255]),
1143        "navy" => Ok([0, 0, 128, 255]),
1144        "orange" => Ok([255, 165, 0, 255]),
1145        other => Err(format!(
1146            "convert: unknown colour '{other}' (try a `#hex` value or one of: black/white/red/green/blue/yellow/cyan/magenta/gray/transparent/…)"
1147        )),
1148    }
1149}
1150
1151fn parse_hex_color(hex: &str) -> Result<[u8; 4], String> {
1152    fn hex_digit(c: u8) -> Result<u8, String> {
1153        match c {
1154            b'0'..=b'9' => Ok(c - b'0'),
1155            b'a'..=b'f' => Ok(c - b'a' + 10),
1156            b'A'..=b'F' => Ok(c - b'A' + 10),
1157            _ => Err(format!("'{}' is not a hex digit", c as char)),
1158        }
1159    }
1160    let h = hex.as_bytes();
1161    let pair = |a: u8, b: u8| -> Result<u8, String> { Ok(hex_digit(a)? * 16 + hex_digit(b)?) };
1162    let dup = |a: u8| -> Result<u8, String> {
1163        let d = hex_digit(a)?;
1164        Ok(d * 16 + d)
1165    };
1166    match h.len() {
1167        3 => Ok([dup(h[0])?, dup(h[1])?, dup(h[2])?, 255]),
1168        4 => Ok([dup(h[0])?, dup(h[1])?, dup(h[2])?, dup(h[3])?]),
1169        6 => Ok([pair(h[0], h[1])?, pair(h[2], h[3])?, pair(h[4], h[5])?, 255]),
1170        8 => Ok([
1171            pair(h[0], h[1])?,
1172            pair(h[2], h[3])?,
1173            pair(h[4], h[5])?,
1174            pair(h[6], h[7])?,
1175        ]),
1176        _ => Err(format!(
1177            "'#{hex}' is not a valid hex colour (expected 3/4/6/8 hex digits)"
1178        )),
1179    }
1180}
1181
1182/// Apply the `oxideav-generator` shorthand translator when it's
1183/// linked in; otherwise return the input verbatim. Keeping the call
1184/// behind a feature gate means the convert verb still works (with a
1185/// clear error from the source registry) when the generator crate is
1186/// excluded.
1187#[cfg(feature = "generator")]
1188fn translate_input_shorthand(input: &str) -> String {
1189    oxideav_generator::shorthand::translate(input)
1190}
1191
1192#[cfg(not(feature = "generator"))]
1193fn translate_input_shorthand(input: &str) -> String {
1194    input.to_string()
1195}
1196
1197/// Parse the geometry-without-suffix portion of a `-resize` /
1198/// `-thumbnail` arg, branching on the scaling mode the suffix already
1199/// selected.
1200///
1201/// Most modes parse exactly like `WxH` (two positive integers). A few
1202/// modes have looser grammar:
1203///
1204/// - [`ResizeMode::Percent`] — `N`, `Nx`, `xN`, `NxN`. A bare `N`
1205///   applies to both axes; missing axes default to the other one
1206///   (`200x` → `200x200`; `x200` → `200x200`). Components must be
1207///   integer percentages > 0.
1208/// - [`ResizeMode::Area`] — same `WxH` shape; the runtime treats
1209///   `W*H` as the target pixel area.
1210/// - Everything else — strict `WxH`, both > 0.
1211fn parse_resize_geometry(s: &str, mode: ResizeMode) -> Result<(u32, u32), String> {
1212    if matches!(mode, ResizeMode::Percent) {
1213        // `N` / `Nx` / `xN` / `NxN`. Single-number form replicates.
1214        let s = s.trim();
1215        let (w_str, h_str) = match s.split_once(['x', 'X']) {
1216            None => (s, s), // bare N → both axes
1217            Some((w, h)) => {
1218                let w = if w.is_empty() { h } else { w };
1219                let h = if h.is_empty() { w } else { h };
1220                (w, h)
1221            }
1222        };
1223        let w: u32 = w_str
1224            .parse()
1225            .map_err(|_| format!("'{w_str}' is not a non-negative integer percent"))?;
1226        let h: u32 = h_str
1227            .parse()
1228            .map_err(|_| format!("'{h_str}' is not a non-negative integer percent"))?;
1229        if w == 0 || h == 0 {
1230            return Err("percent values must both be > 0".into());
1231        }
1232        return Ok((w, h));
1233    }
1234    parse_wxh(s)
1235}
1236
1237/// Parse `WxH` — width × height. Accepts either lowercase `x` or
1238/// uppercase `X`. Both parts must be non-negative integers.
1239fn parse_wxh(s: &str) -> Result<(u32, u32), String> {
1240    let (w, h) = s
1241        .split_once(['x', 'X'])
1242        .ok_or_else(|| format!("'{s}' is not in WxH form"))?;
1243    let w: u32 = w
1244        .parse()
1245        .map_err(|_| format!("'{w}' is not a non-negative integer"))?;
1246    let h: u32 = h
1247        .parse()
1248        .map_err(|_| format!("'{h}' is not a non-negative integer"))?;
1249    if w == 0 || h == 0 {
1250        return Err("width and height must both be positive".into());
1251    }
1252    Ok((w, h))
1253}
1254
1255/// Parse `-crop WxH+X+Y` (and the simpler `WxH+X+Y` permutations IM
1256/// also accepts). All four numbers are required; IM-style geometry
1257/// modifiers (`%`, `!`, `^`, `<`, `>`, `@`, `-X-Y` negative offsets)
1258/// are NOT yet supported in round-1 — they'd need centering / aspect
1259/// math we don't have. Bbox bounds checking against the actual image
1260/// dims happens at apply time, not here.
1261fn parse_crop(s: &str) -> Result<(u32, u32, u32, u32), String> {
1262    // Reject IM modifiers up front with a clear message, otherwise
1263    // they'd be silently swallowed by parse() of `0` etc.
1264    for mark in ['%', '!', '^', '<', '>', '@'] {
1265        if s.contains(mark) {
1266            return Err(format!(
1267                "'{s}' uses IM modifier '{mark}' (round-1 supports plain WxH+X+Y only)"
1268            ));
1269        }
1270    }
1271    // IM also accepts `-X` for negative offsets; round-1 sticks to
1272    // `+X+Y` only.
1273    let mut parts = s.split('+');
1274    let wh = parts
1275        .next()
1276        .ok_or_else(|| format!("'{s}' is missing the WxH component"))?;
1277    let x_str = parts
1278        .next()
1279        .ok_or_else(|| format!("'{s}' is missing the +X offset (expected WxH+X+Y)"))?;
1280    let y_str = parts
1281        .next()
1282        .ok_or_else(|| format!("'{s}' is missing the +Y offset (expected WxH+X+Y)"))?;
1283    if parts.next().is_some() {
1284        return Err(format!("'{s}' has more than two `+` separators"));
1285    }
1286    let (w, h) = parse_wxh(wh)?;
1287    let x: u32 = x_str
1288        .parse()
1289        .map_err(|_| format!("'{x_str}' is not a non-negative integer X offset"))?;
1290    let y: u32 = y_str
1291        .parse()
1292        .map_err(|_| format!("'{y_str}' is not a non-negative integer Y offset"))?;
1293    Ok((w, h, x, y))
1294}
1295
1296/// Parse `-extent WxH[±X±Y]` — IM's canvas re-window.
1297///
1298/// Accepts `WxH` (offset defaults to `(0, 0)`), `WxH+X+Y`, `WxH-X-Y`,
1299/// `WxH+X-Y`, or `WxH-X+Y`. Width / height must both be positive (a
1300/// zero-area canvas has nothing to paint); offsets are signed so the
1301/// IM convention of negative translation (moving the source toward the
1302/// upper-left so its right or bottom edge can fit inside the window)
1303/// rides through unchanged.
1304///
1305/// We deliberately reject the geometry modifiers (`%`, `!`, `^`, `<`,
1306/// `>`, `@`) the same way `parse_crop` does — `-extent` in IM accepts
1307/// some of those but their semantics are source-size-dependent and
1308/// would need a runtime resolution pass that hasn't landed yet.
1309fn parse_extent(s: &str) -> Result<(u32, u32, i32, i32), String> {
1310    for mark in ['%', '!', '^', '<', '>', '@'] {
1311        if s.contains(mark) {
1312            return Err(format!(
1313                "'{s}' uses IM modifier '{mark}' (-extent currently supports plain WxH[±X±Y] only)"
1314            ));
1315        }
1316    }
1317    // Find the boundary between `WxH` and the first signed offset (if
1318    // any). The first `+` or `-` AFTER the initial character marks
1319    // where the offset section starts; we keep position 0 untouched so
1320    // a leading `-` on `WxH` (illegal — `W` must be positive) is
1321    // surfaced by `parse_wxh`'s unsigned parse rather than mis-split as
1322    // an offset.
1323    let head_end = s
1324        .char_indices()
1325        .skip(1)
1326        .find(|(_, c)| *c == '+' || *c == '-')
1327        .map(|(i, _)| i)
1328        .unwrap_or(s.len());
1329    let head = &s[..head_end];
1330    let tail = &s[head_end..];
1331    let (w, h) = parse_wxh(head)?;
1332    let (x, y) = parse_extent_offsets(tail)?;
1333    Ok((w, h, x, y))
1334}
1335
1336/// Parse the `±X±Y` tail of an `-extent` geometry string. Returns
1337/// `(0, 0)` for an empty tail. Both signs are required when any
1338/// offset is given — `WxH+X` (single offset) is rejected so the user
1339/// gets a clearer message than "parse failed on empty Y".
1340fn parse_extent_offsets(s: &str) -> Result<(i32, i32), String> {
1341    if s.is_empty() {
1342        return Ok((0, 0));
1343    }
1344    // Walk char-by-char to find the start of the Y offset — the
1345    // SECOND sign character in the tail. Splitting on `+`/`-` directly
1346    // would mishandle the mix-of-signs cases (`+X-Y`, `-X+Y`).
1347    let bytes = s.as_bytes();
1348    if bytes[0] != b'+' && bytes[0] != b'-' {
1349        return Err(format!(
1350            "'{s}' offset section must start with `+` or `-` (expected WxH+X+Y, WxH-X-Y, or mixed)"
1351        ));
1352    }
1353    let mut split = None;
1354    for (i, b) in bytes.iter().enumerate().skip(1) {
1355        if *b == b'+' || *b == b'-' {
1356            split = Some(i);
1357            break;
1358        }
1359    }
1360    let split = split
1361        .ok_or_else(|| format!("'{s}' has a single offset; expected both X and Y (WxH±X±Y)"))?;
1362    let x_str = &s[..split];
1363    let y_str = &s[split..];
1364    // Reject IM's `WxH+X+Y+Z` accidental extra-offset case.
1365    if y_str[1..].contains('+') || y_str[1..].contains('-') {
1366        return Err(format!("'{s}' has more than two signed offsets"));
1367    }
1368    let x: i32 = x_str
1369        .parse()
1370        .map_err(|_| format!("'{x_str}' is not a signed integer X offset"))?;
1371    let y: i32 = y_str
1372        .parse()
1373        .map_err(|_| format!("'{y_str}' is not a signed integer Y offset"))?;
1374    Ok((x, y))
1375}
1376
1377/// Parse the value after `-roll`. IM's canonical grammar is
1378/// `±X±Y` — both offsets present, each prefixed by an explicit sign.
1379/// Reuses [`parse_extent_offsets`] for the actual signed-int split so
1380/// the two flags stay grammar-identical; rejects the empty / single-
1381/// offset cases with the same shape of error the user sees on
1382/// `-extent`. Negative offsets are accepted as-is — the inline /
1383/// pipeline shift routines wrap them mod width / height.
1384fn parse_roll(s: &str) -> Result<(i32, i32), String> {
1385    if s.is_empty() {
1386        return Err("empty offset (expected ±X±Y, e.g. +5+10)".to_string());
1387    }
1388    parse_extent_offsets(s)
1389}
1390
1391/// Parse `-blur RxS` or `-blur R`. When sigma is omitted we follow
1392/// IM's convention of `sigma = radius / 2.0`. Unlike IM we don't
1393/// accept floats for radius — `Blur::new` takes `u32`.
1394fn parse_blur(s: &str) -> Result<(u32, f32), String> {
1395    let (radius_str, sigma_str) = match s.split_once(['x', 'X']) {
1396        Some((r, s)) => (r, Some(s)),
1397        None => (s, None),
1398    };
1399    let radius: u32 = radius_str
1400        .parse()
1401        .map_err(|_| format!("'{radius_str}' is not a non-negative integer"))?;
1402    let sigma: f32 = match sigma_str {
1403        Some(s) => s
1404            .parse()
1405            .map_err(|_| format!("'{s}' is not a non-negative float"))?,
1406        None => (radius as f32) / 2.0,
1407    };
1408    Ok((radius, sigma))
1409}
1410
1411/// Parse `-unsharp RxS+amount+threshold` (any subset accepted in
1412/// source order: `R`, `RxS`, `RxS+A`, `RxS+A+T`).
1413///
1414/// Defaults match `oxideav_image_filter::Unsharp`'s conventions:
1415/// sigma = radius/2, amount = 1.0, threshold = 0 (out of 255).
1416fn parse_unsharp(s: &str) -> Result<(u32, f32, f32, u8), String> {
1417    // `+` separates the three optional tail components. Keep the
1418    // first chunk (radius / sigma) intact.
1419    let mut parts = s.splitn(3, '+');
1420    let head = parts.next().unwrap_or(s);
1421    let amount_str = parts.next();
1422    let threshold_str = parts.next();
1423    let (radius, sigma) = parse_blur(head)?;
1424    let amount: f32 = match amount_str {
1425        Some(a) => a
1426            .parse()
1427            .map_err(|_| format!("'{a}' is not a finite float"))?,
1428        None => 1.0,
1429    };
1430    let threshold: u8 = match threshold_str {
1431        // IM lets the threshold be either an integer 0..=255 or a
1432        // percentage. Accept both shapes; cap to u8.
1433        Some(t) => parse_threshold_pct(t)?,
1434        None => 0,
1435    };
1436    Ok((radius, sigma, amount, threshold))
1437}
1438
1439/// Parse `-brightness-contrast B[xC]` / `B[,C]` / `B`. IM uses both
1440/// `x` and `,` as separators in different docs; accept either.
1441/// Range is `[-100..=100]` per IM.
1442fn parse_brightness_contrast(s: &str) -> Result<(f32, f32), String> {
1443    let (bs, cs) = match s.split_once(['x', 'X', ',']) {
1444        Some((a, b)) => (a, Some(b)),
1445        None => (s, None),
1446    };
1447    let b: f32 = bs
1448        .parse()
1449        .map_err(|_| format!("'{bs}' is not a finite float"))?;
1450    let c: f32 = match cs {
1451        Some(s) if !s.is_empty() => s
1452            .parse()
1453            .map_err(|_| format!("'{s}' is not a finite float"))?,
1454        _ => 0.0,
1455    };
1456    if !(-100.0..=100.0).contains(&b) {
1457        return Err(format!("brightness {b} out of range (-100..=100)"));
1458    }
1459    if !(-100.0..=100.0).contains(&c) {
1460        return Err(format!("contrast {c} out of range (-100..=100)"));
1461    }
1462    Ok((b, c))
1463}
1464
1465/// Parse a value that's either a percentage (`50%`) or a unit-range
1466/// scalar (`0.5`). Used by `-sepia`. Returns the scalar in 0..=1.
1467fn parse_percent_or_unit(s: &str) -> Result<f32, String> {
1468    let s = s.trim();
1469    if let Some(p) = s.strip_suffix('%') {
1470        let v: f32 = p
1471            .parse()
1472            .map_err(|_| format!("'{p}' is not a finite float"))?;
1473        if !(0.0..=100.0).contains(&v) {
1474            return Err(format!("'{s}' is out of range (0%..=100%)"));
1475        }
1476        return Ok(v / 100.0);
1477    }
1478    let v: f32 = s
1479        .parse()
1480        .map_err(|_| format!("'{s}' is not a finite float"))?;
1481    if !(0.0..=1.0).contains(&v) {
1482        return Err(format!("'{s}' is out of range (0.0..=1.0)"));
1483    }
1484    Ok(v)
1485}
1486
1487/// Parse `-modulate B[,S[,H]]`. Each element is percent-of-base
1488/// (100 = identity). Missing tail elements default to 100 / 100 / 100.
1489fn parse_modulate(s: &str) -> Result<(f32, f32, f32), String> {
1490    let mut parts = s.split(',').map(|p| p.trim());
1491    let b = parts
1492        .next()
1493        .ok_or_else(|| format!("'{s}' is missing the brightness value"))?;
1494    let s_field = parts.next();
1495    let h_field = parts.next();
1496    if parts.next().is_some() {
1497        return Err(format!(
1498            "'{s}' has more than 3 components (expected B[,S[,H]])"
1499        ));
1500    }
1501    let bv: f32 = b
1502        .parse()
1503        .map_err(|_| format!("'{b}' is not a finite float"))?;
1504    let sv: f32 = match s_field {
1505        Some(t) if !t.is_empty() => t
1506            .parse()
1507            .map_err(|_| format!("'{t}' is not a finite float"))?,
1508        _ => 100.0,
1509    };
1510    let hv: f32 = match h_field {
1511        Some(t) if !t.is_empty() => t
1512            .parse()
1513            .map_err(|_| format!("'{t}' is not a finite float"))?,
1514        _ => 100.0,
1515    };
1516    Ok((bv, sv, hv))
1517}
1518
1519/// Parse `-level B[/G[/W]]` or `-level B,G,W`. IM accepts both
1520/// separators. Black/white are 0..=255 (or `N%` for a percentage).
1521/// Gamma is `> 0`.
1522fn parse_level(s: &str) -> Result<(u8, f32, u8), String> {
1523    let parts: Vec<&str> = s.split(['/', ',']).map(|p| p.trim()).collect();
1524    if parts.is_empty() || parts.len() > 3 {
1525        return Err(format!("'{s}' is not in B[/G[/W]] form (1..=3 components)"));
1526    }
1527    let black = parse_byte_or_percent(parts[0])?;
1528    let gamma: f32 = if parts.len() >= 2 && !parts[1].is_empty() {
1529        let g: f32 = parts[1]
1530            .parse()
1531            .map_err(|_| format!("'{}' is not a finite float", parts[1]))?;
1532        if !g.is_finite() || g <= 0.0 {
1533            return Err(format!("gamma {g} must be > 0"));
1534        }
1535        g
1536    } else {
1537        1.0
1538    };
1539    let white = if parts.len() == 3 && !parts[2].is_empty() {
1540        parse_byte_or_percent(parts[2])?
1541    } else {
1542        255
1543    };
1544    if black > white {
1545        return Err(format!(
1546            "black point ({black}) must be <= white point ({white})"
1547        ));
1548    }
1549    Ok((black, gamma, white))
1550}
1551
1552/// Parse `0..=255` or `N%` → byte. Used by `-level`.
1553fn parse_byte_or_percent(s: &str) -> Result<u8, String> {
1554    if let Some(p) = s.strip_suffix('%') {
1555        let v: f32 = p
1556            .parse()
1557            .map_err(|_| format!("'{p}' is not a finite float"))?;
1558        if !(0.0..=100.0).contains(&v) {
1559            return Err(format!("'{s}' is out of range (0%..=100%)"));
1560        }
1561        return Ok((v * 2.55).round().clamp(0.0, 255.0) as u8);
1562    }
1563    let n: u32 = s
1564        .parse()
1565        .map_err(|_| format!("'{s}' is not a non-negative integer"))?;
1566    if n > 255 {
1567        return Err(format!("'{s}' is out of range (0..=255)"));
1568    }
1569    Ok(n as u8)
1570}
1571
1572/// Parse `-threshold N%` / `-threshold N` / `-solarize N%` —
1573/// returns the resulting 0..=255 byte value.
1574fn parse_threshold_pct(s: &str) -> Result<u8, String> {
1575    parse_byte_or_percent(s.trim())
1576}
1577
1578/// Parse `-vignette R[+S][+X[+Y]]`.
1579///
1580/// Mirrors IM's `-vignette geometry`-style argument. `R+S` are the
1581/// Gaussian radius and sigma in pixels; `S` defaults to `R / 2.0` when
1582/// omitted (matching IM and our `-blur RxS` convention). `+X+Y` are the
1583/// **normalised** image-relative centre offsets in `[0.0, 1.0]`
1584/// (default `0.5 / 0.5` = image centre). IM's `-vignette R+S+X+Y` uses
1585/// pixel offsets for `X+Y`, but the image-filter `Vignette` factory is
1586/// resolution-independent and takes them normalised; document the
1587/// difference here. To target the centre — by far the common case —
1588/// pass just `R` (e.g. `-vignette 50`).
1589fn parse_vignette(s: &str) -> Result<(f32, f32, f32, f32), String> {
1590    let mut parts = s.split('+');
1591    let r_str = parts
1592        .next()
1593        .ok_or_else(|| format!("'{s}' is missing the radius component"))?;
1594    let radius: f32 = r_str
1595        .parse()
1596        .map_err(|_| format!("'{r_str}' is not a finite float radius"))?;
1597    if !radius.is_finite() || radius < 0.0 {
1598        return Err(format!("radius {radius} must be >= 0"));
1599    }
1600    let s_part = parts.next();
1601    let sigma: f32 = match s_part {
1602        Some(t) if !t.is_empty() => t
1603            .parse()
1604            .map_err(|_| format!("'{t}' is not a finite float sigma"))?,
1605        _ => radius / 2.0,
1606    };
1607    if !sigma.is_finite() || sigma < 0.0 {
1608        return Err(format!("sigma {sigma} must be >= 0"));
1609    }
1610    let x: f32 = match parts.next() {
1611        Some(t) if !t.is_empty() => t
1612            .parse()
1613            .map_err(|_| format!("'{t}' is not a finite float x"))?,
1614        _ => 0.5,
1615    };
1616    let y: f32 = match parts.next() {
1617        Some(t) if !t.is_empty() => t
1618            .parse()
1619            .map_err(|_| format!("'{t}' is not a finite float y"))?,
1620        _ => 0.5,
1621    };
1622    if parts.next().is_some() {
1623        return Err(format!("'{s}' has more than 4 components"));
1624    }
1625    if !x.is_finite() || !y.is_finite() {
1626        return Err(format!("x/y must be finite (got x={x}, y={y})"));
1627    }
1628    Ok((radius, sigma, x, y))
1629}
1630
1631/// Parse `-colorize C[xC[xC]]/A%`.
1632///
1633/// The colour part `C[xC[xC]]` accepts the same grammar as
1634/// [`parse_color`] — CSS L3 named colours and `#hex` 3/4/6/8 forms —
1635/// PLUS the IM-style `R[xG[xB]]` per-channel byte triplet. When only
1636/// `/A%` is given (no colour part), the target defaults to white. The
1637/// `A%` portion is a percentage in `[0%..=100%]`; we accept either
1638/// `N%` or a unit scalar `0.0..=1.0`.
1639fn parse_colorize(s: &str) -> Result<([u8; 4], f32), String> {
1640    let s = s.trim();
1641    let (color_part, amount_part) = match s.split_once('/') {
1642        Some((c, a)) => (c, a),
1643        // No `/A%` — interpret the whole string as the amount, with a
1644        // white default colour. IM's rare "single-arg" form.
1645        None => ("", s),
1646    };
1647    let color = if color_part.is_empty() {
1648        [255, 255, 255, 255]
1649    } else if color_part.contains('x') || color_part.contains('X') {
1650        // R[xG[xB]] per-channel byte triplet.
1651        let mut parts = color_part.split(['x', 'X']);
1652        let r_str = parts
1653            .next()
1654            .ok_or_else(|| format!("'{color_part}' is missing the R component"))?;
1655        let r: u32 = r_str
1656            .parse()
1657            .map_err(|_| format!("'{r_str}' is not a non-negative integer"))?;
1658        if r > 255 {
1659            return Err(format!("R component {r} out of range (0..=255)"));
1660        }
1661        let g: u32 = match parts.next() {
1662            Some(t) if !t.is_empty() => t
1663                .parse()
1664                .map_err(|_| format!("'{t}' is not a non-negative integer"))?,
1665            _ => r,
1666        };
1667        if g > 255 {
1668            return Err(format!("G component {g} out of range (0..=255)"));
1669        }
1670        let b: u32 = match parts.next() {
1671            Some(t) if !t.is_empty() => t
1672                .parse()
1673                .map_err(|_| format!("'{t}' is not a non-negative integer"))?,
1674            _ => g,
1675        };
1676        if b > 255 {
1677            return Err(format!("B component {b} out of range (0..=255)"));
1678        }
1679        if parts.next().is_some() {
1680            return Err(format!(
1681                "'{color_part}' has more than 3 components (expected R[xG[xB]])"
1682            ));
1683        }
1684        [r as u8, g as u8, b as u8, 255]
1685    } else {
1686        // CSS named / `#hex`.
1687        parse_color(color_part)?
1688    };
1689    let amount = parse_percent_or_unit(amount_part)?;
1690    Ok((color, amount))
1691}
1692
1693#[cfg(test)]
1694mod tests {
1695    use super::*;
1696
1697    fn to_vec(s: &[&str]) -> Vec<String> {
1698        s.iter().map(|x| x.to_string()).collect()
1699    }
1700
1701    #[test]
1702    fn minimal_input_output() {
1703        let p = parse(&to_vec(&["in.png", "out.jpg"])).unwrap();
1704        assert_eq!(p.input, "in.png");
1705        assert_eq!(p.output, "out.jpg");
1706        assert!(p.ops.is_empty());
1707    }
1708
1709    #[test]
1710    fn resize_bilinear_basic() {
1711        let p = parse(&to_vec(&["a.png", "-resize", "800x600", "b.jpg"])).unwrap();
1712        assert_eq!(
1713            p.ops,
1714            vec![Op::Resize {
1715                width: 800,
1716                height: 600,
1717                mode: ResizeMode::Default,
1718            }]
1719        );
1720    }
1721
1722    #[test]
1723    fn resize_bang_flag() {
1724        let p = parse(&to_vec(&["a.png", "-resize", "64x32!", "b.jpg"])).unwrap();
1725        assert_eq!(
1726            p.ops,
1727            vec![Op::Resize {
1728                width: 64,
1729                height: 32,
1730                mode: ResizeMode::Force,
1731            }]
1732        );
1733    }
1734
1735    #[test]
1736    fn resize_fill_caret_suffix() {
1737        let p = parse(&to_vec(&["a.png", "-resize", "200x100^", "b.jpg"])).unwrap();
1738        assert_eq!(
1739            p.ops,
1740            vec![Op::Resize {
1741                width: 200,
1742                height: 100,
1743                mode: ResizeMode::Fill,
1744            }]
1745        );
1746    }
1747
1748    #[test]
1749    fn resize_shrink_only_suffix() {
1750        let p = parse(&to_vec(&["a.png", "-resize", "100x100>", "b.jpg"])).unwrap();
1751        assert_eq!(
1752            p.ops,
1753            vec![Op::Resize {
1754                width: 100,
1755                height: 100,
1756                mode: ResizeMode::Shrink,
1757            }]
1758        );
1759    }
1760
1761    #[test]
1762    fn resize_grow_only_suffix() {
1763        let p = parse(&to_vec(&["a.png", "-resize", "1024x1024<", "b.jpg"])).unwrap();
1764        assert_eq!(
1765            p.ops,
1766            vec![Op::Resize {
1767                width: 1024,
1768                height: 1024,
1769                mode: ResizeMode::Grow,
1770            }]
1771        );
1772    }
1773
1774    #[test]
1775    fn resize_percent_two_axis() {
1776        let p = parse(&to_vec(&["a.png", "-resize", "50x200%", "b.jpg"])).unwrap();
1777        assert_eq!(
1778            p.ops,
1779            vec![Op::Resize {
1780                width: 50,
1781                height: 200,
1782                mode: ResizeMode::Percent,
1783            }]
1784        );
1785    }
1786
1787    #[test]
1788    fn resize_percent_single_value_replicates() {
1789        // IM lets a bare `N%` mean "N percent on both axes"; we follow.
1790        let p = parse(&to_vec(&["a.png", "-resize", "75%", "b.jpg"])).unwrap();
1791        assert_eq!(
1792            p.ops,
1793            vec![Op::Resize {
1794                width: 75,
1795                height: 75,
1796                mode: ResizeMode::Percent,
1797            }]
1798        );
1799    }
1800
1801    #[test]
1802    fn resize_area_at_suffix() {
1803        let p = parse(&to_vec(&["a.png", "-resize", "640x480@", "b.jpg"])).unwrap();
1804        assert_eq!(
1805            p.ops,
1806            vec![Op::Resize {
1807                width: 640,
1808                height: 480,
1809                mode: ResizeMode::Area,
1810            }]
1811        );
1812    }
1813
1814    #[test]
1815    fn thumbnail_with_geometry_modifier() {
1816        let p = parse(&to_vec(&["a.png", "-thumbnail", "128x128^", "b.jpg"])).unwrap();
1817        assert_eq!(
1818            p.ops,
1819            vec![Op::Thumbnail {
1820                width: 128,
1821                height: 128,
1822                mode: ResizeMode::Fill,
1823            }]
1824        );
1825    }
1826
1827    #[test]
1828    fn thumbnail_default_mode() {
1829        let p = parse(&to_vec(&["a.png", "-thumbnail", "100x100", "b.jpg"])).unwrap();
1830        assert_eq!(
1831            p.ops,
1832            vec![Op::Thumbnail {
1833                width: 100,
1834                height: 100,
1835                mode: ResizeMode::Default,
1836            }]
1837        );
1838    }
1839
1840    #[test]
1841    fn define_with_value_round_trips_namespaced_key() {
1842        let p = parse(&to_vec(&[
1843            "a.png",
1844            "-define",
1845            "jpeg:dct-method=float",
1846            "b.jpg",
1847        ]))
1848        .unwrap();
1849        assert_eq!(
1850            p.ops,
1851            vec![Op::Define {
1852                key: "jpeg:dct-method".into(),
1853                value: Some("float".into()),
1854            }]
1855        );
1856    }
1857
1858    #[test]
1859    fn define_bare_key_no_value() {
1860        // `-define KEY` (no `=VALUE`) sets the flag-style key to JSON true.
1861        let p = parse(&to_vec(&[
1862            "a.png",
1863            "-define",
1864            "png:strip-comments",
1865            "b.png",
1866        ]))
1867        .unwrap();
1868        assert_eq!(
1869            p.ops,
1870            vec![Op::Define {
1871                key: "png:strip-comments".into(),
1872                value: None,
1873            }]
1874        );
1875    }
1876
1877    #[test]
1878    fn define_empty_key_rejected() {
1879        let err = parse(&to_vec(&["a.png", "-define", "=value", "b.png"])).unwrap_err();
1880        assert!(
1881            format!("{err:?}").contains("empty KEY"),
1882            "unexpected error: {err:?}"
1883        );
1884    }
1885
1886    #[test]
1887    fn blur_sigma_defaults_to_half_radius() {
1888        let p = parse(&to_vec(&["a.png", "-blur", "4", "b.jpg"])).unwrap();
1889        assert_eq!(
1890            p.ops,
1891            vec![Op::Blur {
1892                radius: 4,
1893                sigma: 2.0
1894            }]
1895        );
1896    }
1897
1898    #[test]
1899    fn blur_explicit_sigma() {
1900        let p = parse(&to_vec(&["a.png", "-blur", "3x1.5", "b.jpg"])).unwrap();
1901        match p.ops.as_slice() {
1902            [Op::Blur { radius: 3, sigma }] => assert!((sigma - 1.5).abs() < 1e-6),
1903            other => panic!("unexpected ops: {other:?}"),
1904        }
1905    }
1906
1907    #[test]
1908    fn colors_picks_up_prior_dither() {
1909        let p = parse(&to_vec(&[
1910            "a.png",
1911            "-dither",
1912            "floyd_steinberg",
1913            "-colors",
1914            "64",
1915            "b.gif",
1916        ]))
1917        .unwrap();
1918        assert_eq!(
1919            p.ops,
1920            vec![Op::Colors {
1921                count: 64,
1922                dither: Dither::FloydSteinberg
1923            }]
1924        );
1925    }
1926
1927    // ---- -monochrome ----
1928
1929    /// `-monochrome` expands to the canonical IM chain `-colorspace
1930    /// gray -colors 2 -dither floyd_steinberg` so existing IM scripts
1931    /// (`convert in.png -monochrome out.png`) lower to the same op
1932    /// records the parser would have produced for the explicit form.
1933    /// We pin both the count and the ORDER (grayscale must come first
1934    /// so the 2-entry palette lands on black + white rather than two
1935    /// arbitrary colour samples).
1936    #[test]
1937    fn monochrome_expands_to_gray_2_colors_floyd_steinberg() {
1938        let p = parse(&to_vec(&["a.png", "-monochrome", "b.png"])).unwrap();
1939        assert_eq!(
1940            p.ops,
1941            vec![
1942                Op::Colorspace("gray".to_string()),
1943                Op::Colors {
1944                    count: 2,
1945                    dither: Dither::FloydSteinberg
1946                },
1947            ]
1948        );
1949    }
1950
1951    /// `-monochrome` takes no value — the next positional after it
1952    /// must be the output filename. Verifies the valueless-flag wiring
1953    /// (no consumption of the input file as a "value").
1954    #[test]
1955    fn monochrome_is_valueless() {
1956        let p = parse(&to_vec(&["a.png", "-monochrome", "b.png"])).unwrap();
1957        assert_eq!(p.input, "a.png");
1958        assert_eq!(p.output, "b.png");
1959    }
1960
1961    /// Source-order semantics: a `-dither none` placed BEFORE
1962    /// `-monochrome` does NOT bleed into the expansion — the flag
1963    /// always emits its own `FloydSteinberg` choice. This pins the
1964    /// "monochrome is a self-contained shorthand" contract; users who
1965    /// want `-dither none` semantics write the chain by hand.
1966    #[test]
1967    fn monochrome_ignores_prior_pending_dither() {
1968        let p = parse(&to_vec(&[
1969            "a.png",
1970            "-dither",
1971            "none",
1972            "-monochrome",
1973            "b.png",
1974        ]))
1975        .unwrap();
1976        assert_eq!(
1977            p.ops,
1978            vec![
1979                Op::Colorspace("gray".to_string()),
1980                Op::Colors {
1981                    count: 2,
1982                    dither: Dither::FloydSteinberg
1983                },
1984            ]
1985        );
1986    }
1987
1988    /// `-monochrome` is composable with other ops in source order:
1989    /// upstream ops keep their record, the two expanded entries appear
1990    /// at the position where `-monochrome` was given, and downstream
1991    /// ops follow. Catches a regression where someone might be tempted
1992    /// to push the expanded ops at the front of the vec.
1993    #[test]
1994    fn monochrome_keeps_source_order_with_neighbours() {
1995        let p = parse(&to_vec(&[
1996            "a.png",
1997            "-resize",
1998            "100x100",
1999            "-monochrome",
2000            "-strip",
2001            "b.png",
2002        ]))
2003        .unwrap();
2004        assert_eq!(
2005            p.ops,
2006            vec![
2007                Op::Resize {
2008                    width: 100,
2009                    height: 100,
2010                    mode: ResizeMode::Default,
2011                },
2012                Op::Colorspace("gray".to_string()),
2013                Op::Colors {
2014                    count: 2,
2015                    dither: Dither::FloydSteinberg
2016                },
2017                Op::Strip,
2018            ]
2019        );
2020    }
2021
2022    #[test]
2023    fn rotate_90_parses() {
2024        let p = parse(&to_vec(&["a.png", "-rotate", "90", "b.png"])).unwrap();
2025        assert_eq!(p.ops, vec![Op::Rotate { degrees: 90 }]);
2026    }
2027
2028    #[test]
2029    fn rotate_negative_270_parses() {
2030        let p = parse(&to_vec(&["a.png", "-rotate", "-270", "b.png"])).unwrap();
2031        assert_eq!(p.ops, vec![Op::Rotate { degrees: -270 }]);
2032    }
2033
2034    #[test]
2035    fn rotate_45_rejected() {
2036        let err = parse(&to_vec(&["a.png", "-rotate", "45", "b.png"])).unwrap_err();
2037        let msg = format!("{err:?}");
2038        assert!(
2039            msg.contains("only multiples of 90 supported"),
2040            "unexpected message: {msg}"
2041        );
2042        assert!(msg.contains("got 45"));
2043    }
2044
2045    #[test]
2046    fn rotate_non_integer_rejected() {
2047        let err = parse(&to_vec(&["a.png", "-rotate", "abc", "b.png"])).unwrap_err();
2048        assert!(format!("{err:?}").contains("not an integer"));
2049    }
2050
2051    #[test]
2052    fn flip_and_flop_are_valueless() {
2053        let p = parse(&to_vec(&["a.png", "-flip", "-flop", "b.png"])).unwrap();
2054        assert_eq!(p.ops, vec![Op::Flip, Op::Flop]);
2055    }
2056
2057    #[test]
2058    fn negate_is_valueless() {
2059        let p = parse(&to_vec(&["a.png", "-negate", "b.png"])).unwrap();
2060        assert_eq!(p.ops, vec![Op::Negate]);
2061    }
2062
2063    #[test]
2064    fn crop_basic() {
2065        let p = parse(&to_vec(&["a.png", "-crop", "10x20+3+4", "b.png"])).unwrap();
2066        assert_eq!(
2067            p.ops,
2068            vec![Op::Crop {
2069                x: 3,
2070                y: 4,
2071                w: 10,
2072                h: 20
2073            }]
2074        );
2075    }
2076
2077    #[test]
2078    fn crop_zero_offset() {
2079        let p = parse(&to_vec(&["a.png", "-crop", "32x32+0+0", "b.png"])).unwrap();
2080        assert_eq!(
2081            p.ops,
2082            vec![Op::Crop {
2083                x: 0,
2084                y: 0,
2085                w: 32,
2086                h: 32
2087            }]
2088        );
2089    }
2090
2091    #[test]
2092    fn crop_missing_offset_rejected() {
2093        let err = parse(&to_vec(&["a.png", "-crop", "32x32", "b.png"])).unwrap_err();
2094        assert!(format!("{err:?}").contains("missing the +X offset"));
2095    }
2096
2097    #[test]
2098    fn crop_im_modifier_rejected() {
2099        let err = parse(&to_vec(&["a.png", "-crop", "50%+0+0", "b.png"])).unwrap_err();
2100        assert!(format!("{err:?}").contains("IM modifier"));
2101    }
2102
2103    // ---- -extent ----
2104    //
2105    // Default canvas (no offset section) → `(0, 0)` offsets and the
2106    // IM-default opaque white background (alpha 255) because no
2107    // `-background` was set.
2108    #[test]
2109    fn extent_default_offset_and_background() {
2110        let p = parse(&to_vec(&["a.png", "-extent", "100x80", "b.png"])).unwrap();
2111        assert_eq!(
2112            p.ops,
2113            vec![Op::Extent {
2114                width: 100,
2115                height: 80,
2116                x: 0,
2117                y: 0,
2118                bg: [255, 255, 255, 255],
2119            }]
2120        );
2121    }
2122
2123    // `WxH+X+Y` lands as positive signed offsets — basic case.
2124    #[test]
2125    fn extent_positive_offsets() {
2126        let p = parse(&to_vec(&["a.png", "-extent", "120x90+10+5", "b.png"])).unwrap();
2127        match p.ops.as_slice() {
2128            [Op::Extent {
2129                width: 120,
2130                height: 90,
2131                x: 10,
2132                y: 5,
2133                bg: _,
2134            }] => {}
2135            other => panic!("unexpected ops: {other:?}"),
2136        }
2137    }
2138
2139    // `WxH-X-Y` lands as negative signed offsets — exercises the
2140    // IM convention of translating the source toward the upper-left.
2141    #[test]
2142    fn extent_negative_offsets() {
2143        let p = parse(&to_vec(&["a.png", "-extent", "200x150-25-30", "b.png"])).unwrap();
2144        match p.ops.as_slice() {
2145            [Op::Extent {
2146                width: 200,
2147                height: 150,
2148                x: -25,
2149                y: -30,
2150                bg: _,
2151            }] => {}
2152            other => panic!("unexpected ops: {other:?}"),
2153        }
2154    }
2155
2156    // Mixed signs `WxH+X-Y` / `WxH-X+Y` ride through unchanged — IM
2157    // accepts every combination.
2158    #[test]
2159    fn extent_mixed_signs() {
2160        let p = parse(&to_vec(&["a.png", "-extent", "64x64+10-5", "b.png"])).unwrap();
2161        match p.ops.as_slice() {
2162            [Op::Extent {
2163                width: 64,
2164                height: 64,
2165                x: 10,
2166                y: -5,
2167                bg: _,
2168            }] => {}
2169            other => panic!("unexpected ops: {other:?}"),
2170        }
2171    }
2172
2173    // A `-background` op preceding `-extent` is captured into the
2174    // resulting `Op::Extent.bg` so the plan walker stays stateless.
2175    #[test]
2176    fn extent_picks_up_prior_background() {
2177        let p = parse(&to_vec(&[
2178            "a.png",
2179            "-background",
2180            "red",
2181            "-extent",
2182            "50x50",
2183            "b.png",
2184        ]))
2185        .unwrap();
2186        // Two ops emitted: the explicit Background, then Extent
2187        // carrying the captured colour.
2188        assert_eq!(p.ops.len(), 2);
2189        assert!(matches!(p.ops[0], Op::Background([255, 0, 0, 255])));
2190        match &p.ops[1] {
2191            Op::Extent { bg, .. } => assert_eq!(*bg, [255, 0, 0, 255]),
2192            other => panic!("expected Extent, got {other:?}"),
2193        }
2194    }
2195
2196    // A `-background` AFTER the `-extent` must NOT retroactively
2197    // change the captured padding colour — source order matters.
2198    #[test]
2199    fn extent_ignores_subsequent_background() {
2200        let p = parse(&to_vec(&[
2201            "a.png",
2202            "-extent",
2203            "50x50",
2204            "-background",
2205            "red",
2206            "b.png",
2207        ]))
2208        .unwrap();
2209        // First Op is Extent with the IM-default white.
2210        match &p.ops[0] {
2211            Op::Extent { bg, .. } => assert_eq!(*bg, [255, 255, 255, 255]),
2212            other => panic!("expected Extent, got {other:?}"),
2213        }
2214    }
2215
2216    // A second `-extent` after a different `-background` picks up
2217    // the new colour while the earlier Extent retains the original.
2218    #[test]
2219    fn extent_two_calls_capture_independent_backgrounds() {
2220        let p = parse(&to_vec(&[
2221            "a.png",
2222            "-background",
2223            "red",
2224            "-extent",
2225            "50x50",
2226            "-background",
2227            "blue",
2228            "-extent",
2229            "60x60",
2230            "b.png",
2231        ]))
2232        .unwrap();
2233        let extents: Vec<&Op> = p
2234            .ops
2235            .iter()
2236            .filter(|o| matches!(o, Op::Extent { .. }))
2237            .collect();
2238        assert_eq!(extents.len(), 2);
2239        match extents[0] {
2240            Op::Extent { width: 50, bg, .. } => assert_eq!(*bg, [255, 0, 0, 255]),
2241            other => panic!("unexpected first extent: {other:?}"),
2242        }
2243        match extents[1] {
2244            Op::Extent { width: 60, bg, .. } => assert_eq!(*bg, [0, 0, 255, 255]),
2245            other => panic!("unexpected second extent: {other:?}"),
2246        }
2247    }
2248
2249    // Single-offset `WxH+X` is rejected with a clear message rather
2250    // than silently treating it as `WxH+X+0`.
2251    #[test]
2252    fn extent_single_offset_rejected() {
2253        let err = parse(&to_vec(&["a.png", "-extent", "64x64+10", "b.png"])).unwrap_err();
2254        assert!(
2255            format!("{err:?}").contains("single offset"),
2256            "unexpected error: {err:?}"
2257        );
2258    }
2259
2260    // Geometry modifiers (`%`, `!`, `^`, `<`, `>`, `@`) are rejected
2261    // identically to `-crop`.
2262    #[test]
2263    fn extent_im_modifier_rejected() {
2264        let err = parse(&to_vec(&["a.png", "-extent", "50%+0+0", "b.png"])).unwrap_err();
2265        assert!(format!("{err:?}").contains("IM modifier"));
2266    }
2267
2268    #[test]
2269    fn sharpen_basic() {
2270        let p = parse(&to_vec(&["a.png", "-sharpen", "1x0.5", "b.png"])).unwrap();
2271        match p.ops.as_slice() {
2272            [Op::Sharpen { radius: 1, sigma }] => assert!((sigma - 0.5).abs() < 1e-6),
2273            other => panic!("unexpected ops: {other:?}"),
2274        }
2275    }
2276
2277    #[test]
2278    fn sharpen_radius_only_defaults_sigma() {
2279        let p = parse(&to_vec(&["a.png", "-sharpen", "4", "b.png"])).unwrap();
2280        // Sigma defaults to radius/2 (matches IM and parse_blur).
2281        assert_eq!(
2282            p.ops,
2283            vec![Op::Sharpen {
2284                radius: 4,
2285                sigma: 2.0
2286            }]
2287        );
2288    }
2289
2290    #[test]
2291    fn unsharp_full_grammar() {
2292        let p = parse(&to_vec(&["a.png", "-unsharp", "2x1.5+0.8+5", "b.png"])).unwrap();
2293        match p.ops.as_slice() {
2294            [Op::Unsharp {
2295                radius: 2,
2296                sigma,
2297                amount,
2298                threshold: 5,
2299            }] => {
2300                assert!((sigma - 1.5).abs() < 1e-6);
2301                assert!((amount - 0.8).abs() < 1e-6);
2302            }
2303            other => panic!("unexpected ops: {other:?}"),
2304        }
2305    }
2306
2307    #[test]
2308    fn unsharp_partial_defaults() {
2309        let p = parse(&to_vec(&["a.png", "-unsharp", "3", "b.png"])).unwrap();
2310        match p.ops.as_slice() {
2311            [Op::Unsharp {
2312                radius: 3,
2313                sigma,
2314                amount,
2315                threshold: 0,
2316            }] => {
2317                assert!((sigma - 1.5).abs() < 1e-6, "sigma = {sigma}");
2318                assert!((amount - 1.0).abs() < 1e-6, "amount = {amount}");
2319            }
2320            other => panic!("unexpected ops: {other:?}"),
2321        }
2322    }
2323
2324    #[test]
2325    fn gamma_basic() {
2326        let p = parse(&to_vec(&["a.png", "-gamma", "1.8", "b.png"])).unwrap();
2327        match p.ops.as_slice() {
2328            [Op::Gamma { value }] => assert!((value - 1.8).abs() < 1e-6),
2329            other => panic!("unexpected ops: {other:?}"),
2330        }
2331    }
2332
2333    #[test]
2334    fn gamma_zero_rejected() {
2335        let err = parse(&to_vec(&["a.png", "-gamma", "0", "b.png"])).unwrap_err();
2336        assert!(format!("{err:?}").contains("must be > 0"));
2337    }
2338
2339    #[test]
2340    fn gamma_negative_rejected() {
2341        let err = parse(&to_vec(&["a.png", "-gamma", "-1.5", "b.png"])).unwrap_err();
2342        assert!(format!("{err:?}").contains("must be > 0"));
2343    }
2344
2345    #[test]
2346    fn brightness_contrast_basic_comma() {
2347        let p = parse(&to_vec(&["a.png", "-brightness-contrast", "10,5", "b.png"])).unwrap();
2348        assert_eq!(
2349            p.ops,
2350            vec![Op::BrightnessContrast {
2351                brightness: 10.0,
2352                contrast: 5.0
2353            }]
2354        );
2355    }
2356
2357    #[test]
2358    fn brightness_contrast_basic_x_separator() {
2359        let p = parse(&to_vec(&[
2360            "a.png",
2361            "-brightness-contrast",
2362            "20x-15",
2363            "b.png",
2364        ]))
2365        .unwrap();
2366        assert_eq!(
2367            p.ops,
2368            vec![Op::BrightnessContrast {
2369                brightness: 20.0,
2370                contrast: -15.0
2371            }]
2372        );
2373    }
2374
2375    #[test]
2376    fn brightness_contrast_brightness_only() {
2377        let p = parse(&to_vec(&["a.png", "-brightness-contrast", "30", "b.png"])).unwrap();
2378        assert_eq!(
2379            p.ops,
2380            vec![Op::BrightnessContrast {
2381                brightness: 30.0,
2382                contrast: 0.0
2383            }]
2384        );
2385    }
2386
2387    #[test]
2388    fn brightness_contrast_out_of_range_rejected() {
2389        let err = parse(&to_vec(&[
2390            "a.png",
2391            "-brightness-contrast",
2392            "150,0",
2393            "b.png",
2394        ]))
2395        .unwrap_err();
2396        assert!(format!("{err:?}").contains("out of range"));
2397    }
2398
2399    #[test]
2400    fn contrast_step_default_one() {
2401        let p = parse(&to_vec(&["a.png", "-contrast", "b.png"])).unwrap();
2402        assert_eq!(p.ops, vec![Op::Contrast { delta: 1 }]);
2403    }
2404
2405    #[test]
2406    fn contrast_repeated_accumulates_via_chain() {
2407        // IM accumulates `-contrast` across repeated flags. We capture
2408        // each as a separate Op; the executor sums them.
2409        let p = parse(&to_vec(&["a.png", "-contrast", "-contrast", "b.png"])).unwrap();
2410        assert_eq!(
2411            p.ops,
2412            vec![Op::Contrast { delta: 1 }, Op::Contrast { delta: 1 }]
2413        );
2414    }
2415
2416    #[test]
2417    fn sepia_percent_form() {
2418        let p = parse(&to_vec(&["a.png", "-sepia", "80%", "b.png"])).unwrap();
2419        match p.ops.as_slice() {
2420            [Op::Sepia { threshold }] => assert!((threshold - 0.8).abs() < 1e-6),
2421            other => panic!("unexpected ops: {other:?}"),
2422        }
2423    }
2424
2425    #[test]
2426    fn sepia_unit_form() {
2427        let p = parse(&to_vec(&["a.png", "-sepia", "0.5", "b.png"])).unwrap();
2428        match p.ops.as_slice() {
2429            [Op::Sepia { threshold }] => assert!((threshold - 0.5).abs() < 1e-6),
2430            other => panic!("unexpected ops: {other:?}"),
2431        }
2432    }
2433
2434    #[test]
2435    fn modulate_full_triplet() {
2436        let p = parse(&to_vec(&["a.png", "-modulate", "120,80,150", "b.png"])).unwrap();
2437        assert_eq!(
2438            p.ops,
2439            vec![Op::Modulate {
2440                brightness: 120.0,
2441                saturation: 80.0,
2442                hue: 150.0,
2443            }]
2444        );
2445    }
2446
2447    #[test]
2448    fn modulate_brightness_only_defaults_other_components() {
2449        let p = parse(&to_vec(&["a.png", "-modulate", "150", "b.png"])).unwrap();
2450        assert_eq!(
2451            p.ops,
2452            vec![Op::Modulate {
2453                brightness: 150.0,
2454                saturation: 100.0,
2455                hue: 100.0,
2456            }]
2457        );
2458    }
2459
2460    #[test]
2461    fn level_full_triplet() {
2462        let p = parse(&to_vec(&["a.png", "-level", "10/1.2/250", "b.png"])).unwrap();
2463        match p.ops.as_slice() {
2464            [Op::Level {
2465                black: 10,
2466                gamma,
2467                white: 250,
2468            }] => assert!((gamma - 1.2).abs() < 1e-6),
2469            other => panic!("unexpected ops: {other:?}"),
2470        }
2471    }
2472
2473    #[test]
2474    fn level_percent_endpoints() {
2475        let p = parse(&to_vec(&["a.png", "-level", "10%/1.0/90%", "b.png"])).unwrap();
2476        match p.ops.as_slice() {
2477            [Op::Level {
2478                black,
2479                gamma: _,
2480                white,
2481            }] => {
2482                // 10% of 255 ≈ 26; 90% ≈ 230.
2483                assert_eq!(*black, 26);
2484                assert_eq!(*white, 230);
2485            }
2486            other => panic!("unexpected ops: {other:?}"),
2487        }
2488    }
2489
2490    #[test]
2491    fn level_inverted_endpoints_rejected() {
2492        let err = parse(&to_vec(&["a.png", "-level", "200/1.0/100", "b.png"])).unwrap_err();
2493        assert!(format!("{err:?}").contains("must be <="));
2494    }
2495
2496    #[test]
2497    fn normalize_is_valueless() {
2498        let p = parse(&to_vec(&["a.png", "-normalize", "b.png"])).unwrap();
2499        assert_eq!(p.ops, vec![Op::Normalize]);
2500    }
2501
2502    #[test]
2503    fn threshold_percent() {
2504        let p = parse(&to_vec(&["a.png", "-threshold", "50%", "b.png"])).unwrap();
2505        // 50% of 255 ≈ 128.
2506        assert_eq!(p.ops, vec![Op::Threshold { value: 128 }]);
2507    }
2508
2509    #[test]
2510    fn threshold_byte() {
2511        let p = parse(&to_vec(&["a.png", "-threshold", "200", "b.png"])).unwrap();
2512        assert_eq!(p.ops, vec![Op::Threshold { value: 200 }]);
2513    }
2514
2515    #[test]
2516    fn posterize_basic() {
2517        let p = parse(&to_vec(&["a.png", "-posterize", "4", "b.png"])).unwrap();
2518        assert_eq!(p.ops, vec![Op::Posterize { levels: 4 }]);
2519    }
2520
2521    #[test]
2522    fn posterize_one_rejected() {
2523        let err = parse(&to_vec(&["a.png", "-posterize", "1", "b.png"])).unwrap_err();
2524        assert!(format!("{err:?}").contains("levels must be >= 2"));
2525    }
2526
2527    #[test]
2528    fn solarize_percent() {
2529        let p = parse(&to_vec(&["a.png", "-solarize", "100", "b.png"])).unwrap();
2530        assert_eq!(p.ops, vec![Op::Solarize { value: 100 }]);
2531    }
2532
2533    #[test]
2534    fn colorspace_gray_recognised() {
2535        let p = parse(&to_vec(&["a.png", "-colorspace", "Gray", "b.png"])).unwrap();
2536        assert_eq!(p.ops, vec![Op::Colorspace("Gray".into())]);
2537    }
2538
2539    #[test]
2540    fn colorspace_rgb_recognised() {
2541        let p = parse(&to_vec(&["a.png", "-colorspace", "rgb", "b.png"])).unwrap();
2542        assert_eq!(p.ops, vec![Op::Colorspace("rgb".into())]);
2543    }
2544
2545    #[test]
2546    fn colorspace_unsupported_rejected() {
2547        let err = parse(&to_vec(&["a.png", "-colorspace", "CMYK", "b.png"])).unwrap_err();
2548        assert!(format!("{err:?}").contains("not yet wired"));
2549    }
2550
2551    #[test]
2552    fn unknown_flag_errors() {
2553        let err = parse(&to_vec(&["a.png", "-fnord", "42", "b.png"])).unwrap_err();
2554        let msg = format!("{err:?}");
2555        assert!(msg.contains("unknown flag"));
2556        // `-fnord` isn't close enough to any known flag — no hint clause.
2557        assert!(
2558            !msg.contains("did you mean"),
2559            "distant token should not get a hint, got: {msg}"
2560        );
2561    }
2562
2563    #[test]
2564    fn unknown_flag_typo_suggests_close_match() {
2565        // Single-letter transposition of `-quality`.
2566        let err = parse(&to_vec(&["a.png", "-quailty", "90", "b.jpg"])).unwrap_err();
2567        let msg = format!("{err:?}");
2568        assert!(msg.contains("unknown flag"), "got: {msg}");
2569        assert!(
2570            msg.contains("did you mean '-quality'?"),
2571            "expected quality suggestion, got: {msg}"
2572        );
2573    }
2574
2575    #[test]
2576    fn unknown_flag_typo_preserves_single_vs_double_dash() {
2577        // `--prbe` is a 1-edit miss of `--probe`. The suggestion must
2578        // reattach the same `--` lead the user typed, not a single dash.
2579        let err = parse(&to_vec(&["--prbe", "in.png"])).unwrap_err();
2580        let msg = format!("{err:?}");
2581        assert!(msg.contains("unknown flag"), "got: {msg}");
2582        assert!(
2583            msg.contains("did you mean '--probe'?"),
2584            "expected --probe suggestion with two dashes, got: {msg}"
2585        );
2586    }
2587
2588    #[test]
2589    fn unknown_flag_typo_resize_transposition() {
2590        let err = parse(&to_vec(&["a.png", "-reszie", "100x100", "b.png"])).unwrap_err();
2591        let msg = format!("{err:?}");
2592        assert!(
2593            msg.contains("did you mean '-resize'?"),
2594            "expected resize suggestion, got: {msg}"
2595        );
2596    }
2597
2598    #[test]
2599    fn missing_output_errors() {
2600        let err = parse(&to_vec(&["in.png"])).unwrap_err();
2601        assert!(format!("{err:?}").contains("no output file given"));
2602    }
2603
2604    #[test]
2605    fn quality_strip() {
2606        let p = parse(&to_vec(&["a.png", "-quality", "85", "-strip", "b.jpg"])).unwrap();
2607        assert_eq!(p.ops, vec![Op::Quality(85), Op::Strip]);
2608    }
2609
2610    #[test]
2611    fn quality_zero_and_hundred_accepted() {
2612        // Endpoints stay accepted: 0 is JPEG's "smallest acceptable",
2613        // 100 is "highest"; both round-trip through the parser.
2614        let p0 = parse(&to_vec(&["a.png", "-quality", "0", "b.jpg"])).unwrap();
2615        assert_eq!(p0.ops, vec![Op::Quality(0)]);
2616        let p100 = parse(&to_vec(&["a.png", "-quality", "100", "b.jpg"])).unwrap();
2617        assert_eq!(p100.ops, vec![Op::Quality(100)]);
2618    }
2619
2620    #[test]
2621    fn quality_out_of_range_rejected() {
2622        // The common typo case (1000 instead of 100); without this
2623        // guard the encoder silently dropped the over-range value.
2624        let err = parse(&to_vec(&["a.png", "-quality", "1000", "b.jpg"])).unwrap_err();
2625        let msg = format!("{err:?}");
2626        assert!(
2627            msg.contains("-quality") && msg.contains("out of range"),
2628            "got: {msg}"
2629        );
2630        assert!(
2631            msg.contains("0..=100"),
2632            "must point at the legal range; got: {msg}"
2633        );
2634        // And the tame just-over-100 case (150 instead of 100):
2635        let err = parse(&to_vec(&["a.png", "-quality", "150", "b.jpg"])).unwrap_err();
2636        assert!(format!("{err:?}").contains("out of range"));
2637    }
2638
2639    #[test]
2640    fn quality_non_numeric_rejected() {
2641        // Negative integers + non-numeric strings still hit the
2642        // pre-cap parse-error branch with the existing message.
2643        let err = parse(&to_vec(&["a.png", "-quality", "-5", "b.jpg"])).unwrap_err();
2644        assert!(format!("{err:?}").contains("not a non-negative integer"));
2645        let err = parse(&to_vec(&["a.png", "-quality", "high", "b.jpg"])).unwrap_err();
2646        assert!(format!("{err:?}").contains("not a non-negative integer"));
2647    }
2648
2649    #[test]
2650    fn density_background_alpha_chain() {
2651        let p = parse(&to_vec(&[
2652            "in.pdf",
2653            "-density",
2654            "300",
2655            "-background",
2656            "white",
2657            "-alpha",
2658            "remove",
2659            "-alpha",
2660            "off",
2661            "page-%03d.png",
2662        ]))
2663        .unwrap();
2664        assert_eq!(p.input, "in.pdf");
2665        assert_eq!(p.output, "page-%03d.png");
2666        assert_eq!(
2667            p.ops,
2668            vec![
2669                Op::Density(300),
2670                Op::Background([255, 255, 255, 255]),
2671                Op::Alpha(AlphaOp::Remove),
2672                Op::Alpha(AlphaOp::Off),
2673            ]
2674        );
2675    }
2676
2677    #[test]
2678    fn density_zero_rejected() {
2679        let err = parse(&to_vec(&["a.png", "-density", "0", "b.png"])).unwrap_err();
2680        assert!(format!("{err:?}").contains("-density must be > 0"));
2681    }
2682
2683    #[test]
2684    fn alpha_unknown_subcommand_rejected() {
2685        let err = parse(&to_vec(&["a.png", "-alpha", "fnord", "b.png"])).unwrap_err();
2686        assert!(format!("{err:?}").contains("unknown subcommand"));
2687    }
2688
2689    #[test]
2690    fn background_hex_color() {
2691        let p = parse(&to_vec(&["a.png", "-background", "#ff8000", "b.png"])).unwrap();
2692        assert_eq!(p.ops, vec![Op::Background([255, 128, 0, 255])]);
2693    }
2694
2695    #[test]
2696    fn background_hex_with_alpha() {
2697        let p = parse(&to_vec(&["a.png", "-background", "#80808040", "b.png"])).unwrap();
2698        assert_eq!(p.ops, vec![Op::Background([128, 128, 128, 64])]);
2699    }
2700
2701    #[test]
2702    fn printf_template_zero_padded() {
2703        let t = parse_printf_template("page-%03d.png").unwrap().unwrap();
2704        assert_eq!(t.prefix, "page-");
2705        assert_eq!(t.width, 3);
2706        assert_eq!(t.suffix, ".png");
2707    }
2708
2709    #[test]
2710    fn printf_template_unpadded() {
2711        let t = parse_printf_template("out%d.jpg").unwrap().unwrap();
2712        assert_eq!(t.prefix, "out");
2713        assert_eq!(t.width, 0);
2714        assert_eq!(t.suffix, ".jpg");
2715    }
2716
2717    #[test]
2718    fn printf_template_absent_returns_none() {
2719        assert!(parse_printf_template("out.png").unwrap().is_none());
2720        assert!(parse_printf_template("/some/dir/out.jpg")
2721            .unwrap()
2722            .is_none());
2723    }
2724
2725    #[test]
2726    fn printf_template_multiple_d_rejected() {
2727        let err = parse_printf_template("page-%d-frame-%d.png").unwrap_err();
2728        assert!(format!("{err:?}").contains("more than one"));
2729    }
2730
2731    #[test]
2732    fn printf_template_unsupported_specifier_rejected() {
2733        let err = parse_printf_template("out-%s.png").unwrap_err();
2734        assert!(format!("{err:?}").contains("unsupported format specifier"));
2735    }
2736
2737    #[test]
2738    fn parse_populates_output_template_when_present() {
2739        let p = parse(&to_vec(&["in.pdf", "page-%02d.png"])).unwrap();
2740        let t = p.output_template.unwrap();
2741        assert_eq!(t.prefix, "page-");
2742        assert_eq!(t.width, 2);
2743        assert_eq!(t.suffix, ".png");
2744    }
2745
2746    #[test]
2747    fn parse_leaves_output_template_none_for_literal_path() {
2748        let p = parse(&to_vec(&["in.png", "out.jpg"])).unwrap();
2749        assert!(p.output_template.is_none());
2750    }
2751
2752    #[test]
2753    fn ping_flag_off_by_default() {
2754        let p = parse(&to_vec(&["in.png", "out.jpg"])).unwrap();
2755        assert!(!p.ping);
2756    }
2757
2758    #[test]
2759    fn ping_flag_set_when_present() {
2760        let p = parse(&to_vec(&["-ping", "in.png", "out.jpg"])).unwrap();
2761        assert!(p.ping);
2762    }
2763
2764    #[test]
2765    fn ping_makes_output_optional() {
2766        let p = parse(&to_vec(&["-ping", "in.png"])).unwrap();
2767        assert!(p.ping);
2768        assert_eq!(p.output, "");
2769        assert!(p.output_template.is_none());
2770    }
2771
2772    #[test]
2773    fn missing_output_without_ping_still_errors() {
2774        let err = parse(&to_vec(&["in.png"])).unwrap_err();
2775        // Should be Invalid, not Unsupported.
2776        assert!(format!("{err:?}").contains("no output file"));
2777    }
2778
2779    #[test]
2780    fn input_selector_single_page() {
2781        let (path, sel) = split_input_selector("input.pdf[0]").unwrap();
2782        assert_eq!(path, "input.pdf");
2783        assert_eq!(sel, Some(PageSelector::Single(0)));
2784    }
2785
2786    #[test]
2787    fn input_selector_range() {
2788        let (path, sel) = split_input_selector("foo.pdf[2-5]").unwrap();
2789        assert_eq!(path, "foo.pdf");
2790        assert_eq!(sel, Some(PageSelector::Range(2, 5)));
2791    }
2792
2793    #[test]
2794    fn input_selector_absent_passes_through() {
2795        let (path, sel) = split_input_selector("plain.pdf").unwrap();
2796        assert_eq!(path, "plain.pdf");
2797        assert_eq!(sel, None);
2798    }
2799
2800    #[test]
2801    fn input_selector_empty_brackets_rejected() {
2802        assert!(split_input_selector("foo.pdf[]").is_err());
2803    }
2804
2805    #[test]
2806    fn input_selector_non_numeric_rejected() {
2807        assert!(split_input_selector("foo.pdf[abc]").is_err());
2808        assert!(split_input_selector("foo.pdf[1-x]").is_err());
2809    }
2810
2811    #[test]
2812    fn input_selector_extra_dash_rejected() {
2813        assert!(split_input_selector("foo.pdf[1-2-3]").is_err());
2814    }
2815
2816    #[test]
2817    fn parse_pulls_selector_into_input_pages() {
2818        let p = parse(&to_vec(&["in.pdf[0]", "out.png"])).unwrap();
2819        assert_eq!(p.input, "in.pdf");
2820        assert_eq!(p.input_pages, Some(PageSelector::Single(0)));
2821    }
2822
2823    #[test]
2824    fn parse_pulls_range_selector_into_input_pages() {
2825        let p = parse(&to_vec(&["in.pdf[2-4]", "page-%02d.png"])).unwrap();
2826        assert_eq!(p.input, "in.pdf");
2827        assert_eq!(p.input_pages, Some(PageSelector::Range(2, 4)));
2828        assert!(p.output_template.is_some());
2829    }
2830
2831    // ---- Round-after-next: comma-separated and negative page indices ----
2832
2833    #[test]
2834    fn input_selector_negative_index_parses() {
2835        let (path, sel) = split_input_selector("foo.pdf[-1]").unwrap();
2836        assert_eq!(path, "foo.pdf");
2837        assert_eq!(sel, Some(PageSelector::Single(-1)));
2838    }
2839
2840    #[test]
2841    fn input_selector_negative_range_endpoint_parses() {
2842        // `5--1` = page 5 through the last page.
2843        let (path, sel) = split_input_selector("foo.pdf[5--1]").unwrap();
2844        assert_eq!(path, "foo.pdf");
2845        assert_eq!(sel, Some(PageSelector::Range(5, -1)));
2846    }
2847
2848    #[test]
2849    fn input_selector_negative_range_both_endpoints() {
2850        // `-3--1` = last three pages.
2851        let (path, sel) = split_input_selector("foo.pdf[-3--1]").unwrap();
2852        assert_eq!(path, "foo.pdf");
2853        assert_eq!(sel, Some(PageSelector::Range(-3, -1)));
2854    }
2855
2856    #[test]
2857    fn input_selector_comma_list_of_singles() {
2858        let (path, sel) = split_input_selector("foo.pdf[0,2,4]").unwrap();
2859        assert_eq!(path, "foo.pdf");
2860        assert_eq!(
2861            sel,
2862            Some(PageSelector::List(vec![
2863                PageAtom::Single(0),
2864                PageAtom::Single(2),
2865                PageAtom::Single(4),
2866            ]))
2867        );
2868    }
2869
2870    #[test]
2871    fn input_selector_comma_list_with_ranges_and_negatives() {
2872        // `0-2,5,-1` = "pages 0..=2, plus 5, plus the last page".
2873        let (path, sel) = split_input_selector("foo.pdf[0-2,5,-1]").unwrap();
2874        assert_eq!(path, "foo.pdf");
2875        assert_eq!(
2876            sel,
2877            Some(PageSelector::List(vec![
2878                PageAtom::Range(0, 2),
2879                PageAtom::Single(5),
2880                PageAtom::Single(-1),
2881            ]))
2882        );
2883    }
2884
2885    #[test]
2886    fn input_selector_single_atom_list_collapses_to_single_variant() {
2887        // A list with exactly one atom collapses to the single-atom
2888        // variant — most callers only ever inspect Single/Range.
2889        let (_, sel) = split_input_selector("foo.pdf[3]").unwrap();
2890        assert!(matches!(sel, Some(PageSelector::Single(3))));
2891    }
2892
2893    #[test]
2894    fn input_selector_empty_atom_in_list_rejected() {
2895        assert!(split_input_selector("foo.pdf[0,,1]").is_err());
2896        assert!(split_input_selector("foo.pdf[,0]").is_err());
2897        assert!(split_input_selector("foo.pdf[0,]").is_err());
2898    }
2899
2900    #[test]
2901    fn input_selector_bare_dash_rejected() {
2902        assert!(split_input_selector("foo.pdf[-]").is_err());
2903    }
2904
2905    #[test]
2906    fn page_selector_negative_single_resolves_to_last_page() {
2907        let s = PageSelector::Single(-1);
2908        assert_eq!(s.resolve(5).unwrap(), vec![4]);
2909        let s = PageSelector::Single(-2);
2910        assert_eq!(s.resolve(5).unwrap(), vec![3]);
2911        // Negative index out of range.
2912        assert!(PageSelector::Single(-6).resolve(5).is_err());
2913    }
2914
2915    #[test]
2916    fn page_selector_negative_range_endpoint_resolves() {
2917        // `5--1` against 8 pages → 5..=7.
2918        let s = PageSelector::Range(5, -1);
2919        assert_eq!(s.resolve(8).unwrap(), vec![5, 6, 7]);
2920        // `-3--1` against 8 pages → 5..=7 (last three).
2921        let s = PageSelector::Range(-3, -1);
2922        assert_eq!(s.resolve(8).unwrap(), vec![5, 6, 7]);
2923    }
2924
2925    #[test]
2926    fn page_selector_list_resolves_in_source_order() {
2927        let s = PageSelector::List(vec![
2928            PageAtom::Single(0),
2929            PageAtom::Single(2),
2930            PageAtom::Range(4, 5),
2931            PageAtom::Single(-1),
2932        ]);
2933        assert_eq!(s.resolve(8).unwrap(), vec![0, 2, 4, 5, 7]);
2934    }
2935
2936    #[test]
2937    fn page_selector_list_preserves_duplicates() {
2938        // IM convention: `[0,0,0]` writes the same page three times.
2939        let s = PageSelector::List(vec![
2940            PageAtom::Single(0),
2941            PageAtom::Single(0),
2942            PageAtom::Single(0),
2943        ]);
2944        assert_eq!(s.resolve(3).unwrap(), vec![0, 0, 0]);
2945    }
2946
2947    #[test]
2948    fn page_selector_list_propagates_out_of_range() {
2949        let s = PageSelector::List(vec![PageAtom::Single(0), PageAtom::Single(99)]);
2950        assert!(s.resolve(3).is_err());
2951    }
2952
2953    // ---- Round-after-next: -vignette / -colorize / -equalize / -auto-gamma ----
2954
2955    #[test]
2956    fn vignette_radius_only_defaults_sigma_and_centre() {
2957        let p = parse(&to_vec(&["a.png", "-vignette", "50", "b.png"])).unwrap();
2958        match p.ops.as_slice() {
2959            [Op::Vignette {
2960                radius,
2961                sigma,
2962                x,
2963                y,
2964            }] => {
2965                assert!((radius - 50.0).abs() < 1e-6);
2966                // Sigma defaults to radius / 2.
2967                assert!((sigma - 25.0).abs() < 1e-6);
2968                // X/Y default to image centre (0.5, 0.5).
2969                assert!((x - 0.5).abs() < 1e-6);
2970                assert!((y - 0.5).abs() < 1e-6);
2971            }
2972            other => panic!("unexpected ops: {other:?}"),
2973        }
2974    }
2975
2976    #[test]
2977    fn vignette_radius_and_sigma() {
2978        let p = parse(&to_vec(&["a.png", "-vignette", "50+10", "b.png"])).unwrap();
2979        match p.ops.as_slice() {
2980            [Op::Vignette {
2981                radius,
2982                sigma,
2983                x,
2984                y,
2985            }] => {
2986                assert!((radius - 50.0).abs() < 1e-6);
2987                assert!((sigma - 10.0).abs() < 1e-6);
2988                assert!((x - 0.5).abs() < 1e-6);
2989                assert!((y - 0.5).abs() < 1e-6);
2990            }
2991            other => panic!("unexpected ops: {other:?}"),
2992        }
2993    }
2994
2995    #[test]
2996    fn vignette_full_grammar_with_xy() {
2997        let p = parse(&to_vec(&["a.png", "-vignette", "50+10+0.25+0.75", "b.png"])).unwrap();
2998        match p.ops.as_slice() {
2999            [Op::Vignette {
3000                radius,
3001                sigma,
3002                x,
3003                y,
3004            }] => {
3005                assert!((radius - 50.0).abs() < 1e-6);
3006                assert!((sigma - 10.0).abs() < 1e-6);
3007                assert!((x - 0.25).abs() < 1e-6);
3008                assert!((y - 0.75).abs() < 1e-6);
3009            }
3010            other => panic!("unexpected ops: {other:?}"),
3011        }
3012    }
3013
3014    #[test]
3015    fn vignette_too_many_components_rejected() {
3016        let err = parse(&to_vec(&["a.png", "-vignette", "1+2+3+4+5", "b.png"])).unwrap_err();
3017        assert!(format!("{err:?}").contains("more than 4"));
3018    }
3019
3020    #[test]
3021    fn vignette_negative_radius_rejected() {
3022        let err = parse(&to_vec(&["a.png", "-vignette", "-5", "b.png"])).unwrap_err();
3023        let msg = format!("{err:?}");
3024        assert!(msg.contains("not a finite float radius") || msg.contains("must be >= 0"));
3025    }
3026
3027    #[test]
3028    fn colorize_named_color_with_percent_amount() {
3029        let p = parse(&to_vec(&["a.png", "-colorize", "red/40%", "b.png"])).unwrap();
3030        match p.ops.as_slice() {
3031            [Op::Colorize { color, amount }] => {
3032                assert_eq!(*color, [255, 0, 0, 255]);
3033                assert!((amount - 0.4).abs() < 1e-6);
3034            }
3035            other => panic!("unexpected ops: {other:?}"),
3036        }
3037    }
3038
3039    #[test]
3040    fn colorize_hex_color() {
3041        let p = parse(&to_vec(&["a.png", "-colorize", "#ff8000/50%", "b.png"])).unwrap();
3042        match p.ops.as_slice() {
3043            [Op::Colorize { color, amount }] => {
3044                assert_eq!(*color, [255, 128, 0, 255]);
3045                assert!((amount - 0.5).abs() < 1e-6);
3046            }
3047            other => panic!("unexpected ops: {other:?}"),
3048        }
3049    }
3050
3051    #[test]
3052    fn colorize_per_channel_triplet() {
3053        let p = parse(&to_vec(&["a.png", "-colorize", "200x100x50/25%", "b.png"])).unwrap();
3054        match p.ops.as_slice() {
3055            [Op::Colorize { color, amount }] => {
3056                assert_eq!(*color, [200, 100, 50, 255]);
3057                assert!((amount - 0.25).abs() < 1e-6);
3058            }
3059            other => panic!("unexpected ops: {other:?}"),
3060        }
3061    }
3062
3063    #[test]
3064    fn colorize_single_channel_replicated() {
3065        // `100/50%` → no `x`, but contains `/`. The colour is the
3066        // string before `/`; "100" lacks `x`, so we fall through to
3067        // parse_color which won't match (it's not named / hex). Test
3068        // that we get a clean error.
3069        let err = parse(&to_vec(&["a.png", "-colorize", "100/50%", "b.png"])).unwrap_err();
3070        let msg = format!("{err:?}");
3071        assert!(msg.contains("unknown colour") || msg.contains("not a valid hex"));
3072    }
3073
3074    #[test]
3075    fn colorize_per_channel_one_value_replicates() {
3076        // `100x` (or `100xx`) — single value means R = G = B = 100.
3077        let p = parse(&to_vec(&["a.png", "-colorize", "100x100x100/30%", "b.png"])).unwrap();
3078        match p.ops.as_slice() {
3079            [Op::Colorize { color, amount: _ }] => {
3080                assert_eq!(*color, [100, 100, 100, 255]);
3081            }
3082            other => panic!("unexpected ops: {other:?}"),
3083        }
3084    }
3085
3086    #[test]
3087    fn colorize_amount_only_defaults_white() {
3088        // `/40%` form — IM accepts it; default colour is white.
3089        let p = parse(&to_vec(&["a.png", "-colorize", "/40%", "b.png"])).unwrap();
3090        match p.ops.as_slice() {
3091            [Op::Colorize { color, amount }] => {
3092                assert_eq!(*color, [255, 255, 255, 255]);
3093                assert!((amount - 0.4).abs() < 1e-6);
3094            }
3095            other => panic!("unexpected ops: {other:?}"),
3096        }
3097    }
3098
3099    #[test]
3100    fn colorize_amount_unit_scalar() {
3101        // Without `%`, parse_percent_or_unit accepts a unit scalar.
3102        let p = parse(&to_vec(&["a.png", "-colorize", "red/0.6", "b.png"])).unwrap();
3103        match p.ops.as_slice() {
3104            [Op::Colorize { color, amount }] => {
3105                assert_eq!(*color, [255, 0, 0, 255]);
3106                assert!((amount - 0.6).abs() < 1e-6);
3107            }
3108            other => panic!("unexpected ops: {other:?}"),
3109        }
3110    }
3111
3112    #[test]
3113    fn colorize_out_of_range_channel_rejected() {
3114        let err = parse(&to_vec(&["a.png", "-colorize", "300x0x0/50%", "b.png"])).unwrap_err();
3115        assert!(format!("{err:?}").contains("out of range"));
3116    }
3117
3118    #[test]
3119    fn equalize_is_valueless() {
3120        let p = parse(&to_vec(&["a.png", "-equalize", "b.png"])).unwrap();
3121        assert_eq!(p.ops, vec![Op::Equalize]);
3122    }
3123
3124    #[test]
3125    fn auto_gamma_is_valueless() {
3126        let p = parse(&to_vec(&["a.png", "-auto-gamma", "b.png"])).unwrap();
3127        assert_eq!(p.ops, vec![Op::AutoGamma]);
3128    }
3129
3130    #[test]
3131    fn vignette_rejects_negative_sigma() {
3132        let err = parse(&to_vec(&["a.png", "-vignette", "5+-1", "b.png"])).unwrap_err();
3133        let msg = format!("{err:?}");
3134        assert!(
3135            msg.contains("not a finite float sigma") || msg.contains("must be >= 0"),
3136            "unexpected message: {msg}"
3137        );
3138    }
3139
3140    // ---- Per-format encoder option flags (`-stl-format`,
3141    //      `-gltf-format`). End-to-end coverage lives in
3142    //      `tests/format_flags.rs`; the unit tests here only check
3143    //      that the parser populates `ConvertPlan::mesh3d_options`
3144    //      correctly and rejects malformed values up-front. ----
3145
3146    #[test]
3147    fn stl_format_default_is_none() {
3148        let p = parse(&to_vec(&["in.stl", "out.stl"])).unwrap();
3149        assert!(p.mesh3d_options.stl_format.is_none());
3150        assert!(p.ops.is_empty(), "no -stl-format → no Op pushed");
3151    }
3152
3153    #[test]
3154    fn stl_format_ascii_parses() {
3155        let p = parse(&to_vec(&["in.stl", "-stl-format", "ascii", "out.stl"])).unwrap();
3156        assert_eq!(p.mesh3d_options.stl_format, Some(StlFormatChoice::Ascii));
3157    }
3158
3159    #[test]
3160    fn stl_format_binary_parses() {
3161        let p = parse(&to_vec(&["in.stl", "-stl-format", "binary", "out.stl"])).unwrap();
3162        assert_eq!(p.mesh3d_options.stl_format, Some(StlFormatChoice::Binary));
3163    }
3164
3165    #[test]
3166    fn stl_format_aliases_parse() {
3167        // `bin` → Binary, `text` → Ascii.
3168        let p = parse(&to_vec(&["in.stl", "-stl-format", "bin", "out.stl"])).unwrap();
3169        assert_eq!(p.mesh3d_options.stl_format, Some(StlFormatChoice::Binary));
3170        let p = parse(&to_vec(&["in.stl", "-stl-format", "text", "out.stl"])).unwrap();
3171        assert_eq!(p.mesh3d_options.stl_format, Some(StlFormatChoice::Ascii));
3172    }
3173
3174    #[test]
3175    fn stl_format_case_insensitive() {
3176        let p = parse(&to_vec(&["in.stl", "-stl-format", "ASCII", "out.stl"])).unwrap();
3177        assert_eq!(p.mesh3d_options.stl_format, Some(StlFormatChoice::Ascii));
3178    }
3179
3180    #[test]
3181    fn stl_format_unknown_value_rejected() {
3182        let err = parse(&to_vec(&["in.stl", "-stl-format", "xml", "out.stl"])).unwrap_err();
3183        let msg = format!("{err:?}");
3184        assert!(
3185            msg.contains("unknown flavour 'xml'") && msg.contains("expected 'binary'"),
3186            "unexpected message: {msg}"
3187        );
3188    }
3189
3190    #[test]
3191    fn stl_format_missing_value_rejected() {
3192        let err = parse(&to_vec(&["in.stl", "-stl-format"])).unwrap_err();
3193        assert!(format!("{err:?}").contains("-stl-format: missing value"));
3194    }
3195
3196    #[test]
3197    fn gltf_format_default_is_none() {
3198        let p = parse(&to_vec(&["in.stl", "out.gltf"])).unwrap();
3199        assert!(p.mesh3d_options.gltf_format.is_none());
3200    }
3201
3202    #[test]
3203    fn gltf_format_glb_parses() {
3204        let p = parse(&to_vec(&["in.stl", "-gltf-format", "glb", "out.glb"])).unwrap();
3205        assert_eq!(p.mesh3d_options.gltf_format, Some(GltfFormatChoice::Glb));
3206    }
3207
3208    #[test]
3209    fn gltf_format_embedded_parses() {
3210        let p = parse(&to_vec(&["in.stl", "-gltf-format", "embedded", "out.gltf"])).unwrap();
3211        assert_eq!(
3212            p.mesh3d_options.gltf_format,
3213            Some(GltfFormatChoice::JsonEmbedded)
3214        );
3215    }
3216
3217    #[test]
3218    fn gltf_format_external_parses_even_though_runtime_rejects() {
3219        // The parser accepts the token; runtime emits the gltf-rN
3220        // follow-up message. Decouples the user-facing error from the
3221        // arg parser's "unknown flag" path.
3222        let p = parse(&to_vec(&["in.stl", "-gltf-format", "external", "out.gltf"])).unwrap();
3223        assert_eq!(
3224            p.mesh3d_options.gltf_format,
3225            Some(GltfFormatChoice::JsonExternal)
3226        );
3227    }
3228
3229    #[test]
3230    fn gltf_format_aliases_parse() {
3231        // `binary` → Glb (matches IM-style synonym tolerance).
3232        let p = parse(&to_vec(&["in.stl", "-gltf-format", "binary", "out.glb"])).unwrap();
3233        assert_eq!(p.mesh3d_options.gltf_format, Some(GltfFormatChoice::Glb));
3234        // `json-embedded` → JsonEmbedded.
3235        let p = parse(&to_vec(&[
3236            "in.stl",
3237            "-gltf-format",
3238            "json-embedded",
3239            "out.gltf",
3240        ]))
3241        .unwrap();
3242        assert_eq!(
3243            p.mesh3d_options.gltf_format,
3244            Some(GltfFormatChoice::JsonEmbedded)
3245        );
3246    }
3247
3248    #[test]
3249    fn gltf_format_unknown_value_rejected() {
3250        let err = parse(&to_vec(&["in.stl", "-gltf-format", "obj", "out.gltf"])).unwrap_err();
3251        let msg = format!("{err:?}");
3252        assert!(
3253            msg.contains("unknown flavour 'obj'") && msg.contains("expected 'glb'"),
3254            "unexpected message: {msg}"
3255        );
3256    }
3257
3258    #[test]
3259    fn render_gouraud_parses() {
3260        let p = parse(&to_vec(&["in.stl", "-render", "gouraud", "out.png"])).unwrap();
3261        assert_eq!(
3262            p.mesh3d_options.render_mode,
3263            Some(Mesh3DRenderMode::Gouraud)
3264        );
3265    }
3266
3267    #[test]
3268    fn render_phong_parses() {
3269        let p = parse(&to_vec(&["in.stl", "-render", "phong", "out.png"])).unwrap();
3270        assert_eq!(p.mesh3d_options.render_mode, Some(Mesh3DRenderMode::Phong));
3271    }
3272
3273    #[test]
3274    fn render_wire_alias_parses() {
3275        let p = parse(&to_vec(&["in.stl", "-render", "wire", "out.png"])).unwrap();
3276        assert_eq!(
3277            p.mesh3d_options.render_mode,
3278            Some(Mesh3DRenderMode::Wireframe)
3279        );
3280    }
3281
3282    #[test]
3283    fn render_unknown_mode_rejected_lists_all_options() {
3284        let err = parse(&to_vec(&["in.stl", "-render", "raytrace", "out.png"])).unwrap_err();
3285        let msg = format!("{err:?}");
3286        assert!(
3287            msg.contains("unknown mode 'raytrace'")
3288                && msg.contains("'gouraud'")
3289                && msg.contains("'phong'"),
3290            "unexpected message: {msg}"
3291        );
3292    }
3293
3294    #[test]
3295    fn light_parses_three_components() {
3296        let p = parse(&to_vec(&["in.stl", "-light", "30,45,1.5", "out.png"])).unwrap();
3297        let light = p.mesh3d_options.light.expect("-light should populate");
3298        assert_eq!(light.azimuth_deg, 30.0);
3299        assert_eq!(light.elevation_deg, 45.0);
3300        assert_eq!(light.intensity, 1.5);
3301    }
3302
3303    #[test]
3304    fn light_rejects_negative_intensity() {
3305        let err = parse(&to_vec(&["in.stl", "-light", "0,0,-1", "out.png"])).unwrap_err();
3306        assert!(format!("{err:?}").contains("intensity"));
3307    }
3308
3309    #[test]
3310    fn light_rejects_two_component_input() {
3311        let err = parse(&to_vec(&["in.stl", "-light", "30,45", "out.png"])).unwrap_err();
3312        let msg = format!("{err:?}");
3313        assert!(
3314            msg.contains("AZIMUTH,ELEVATION,INTENSITY"),
3315            "expected grammar hint, got: {msg}"
3316        );
3317    }
3318
3319    #[test]
3320    fn camera_parses_three_components() {
3321        let p = parse(&to_vec(&["in.stl", "-camera", "20,30,2.0", "out.png"])).unwrap();
3322        let cam = p.mesh3d_options.camera.expect("-camera should populate");
3323        assert_eq!(cam.elevation_deg, 20.0);
3324        assert_eq!(cam.azimuth_deg, 30.0);
3325        assert_eq!(cam.distance, 2.0);
3326    }
3327
3328    #[test]
3329    fn camera_rejects_zero_distance() {
3330        let err = parse(&to_vec(&["in.stl", "-camera", "0,0,0", "out.png"])).unwrap_err();
3331        assert!(format!("{err:?}").contains("distance"));
3332    }
3333
3334    #[test]
3335    fn projection_orthographic_parses() {
3336        let p = parse(&to_vec(&[
3337            "in.stl",
3338            "-projection",
3339            "orthographic",
3340            "out.png",
3341        ]))
3342        .unwrap();
3343        assert_eq!(
3344            p.mesh3d_options.projection,
3345            Some(ProjectionMode::Orthographic)
3346        );
3347    }
3348
3349    #[test]
3350    fn projection_perspective_default_is_unset() {
3351        let p = parse(&to_vec(&["in.stl", "out.png"])).unwrap();
3352        assert!(p.mesh3d_options.projection.is_none());
3353    }
3354
3355    #[test]
3356    fn projection_alias_parses() {
3357        let p = parse(&to_vec(&["in.stl", "-projection", "ortho", "out.png"])).unwrap();
3358        assert_eq!(
3359            p.mesh3d_options.projection,
3360            Some(ProjectionMode::Orthographic)
3361        );
3362    }
3363
3364    #[test]
3365    fn projection_unknown_rejected() {
3366        let err = parse(&to_vec(&["in.stl", "-projection", "isometric", "out.png"])).unwrap_err();
3367        assert!(format!("{err:?}").contains("unknown mode 'isometric'"));
3368    }
3369
3370    #[test]
3371    fn fov_parses_within_range() {
3372        let p = parse(&to_vec(&["in.stl", "-fov", "45", "out.png"])).unwrap();
3373        assert_eq!(p.mesh3d_options.fov_deg, Some(45.0));
3374    }
3375
3376    #[test]
3377    fn fov_rejects_zero() {
3378        let err = parse(&to_vec(&["in.stl", "-fov", "0", "out.png"])).unwrap_err();
3379        assert!(format!("{err:?}").contains("(0, 180)"));
3380    }
3381
3382    #[test]
3383    fn fov_rejects_180_or_above() {
3384        let err = parse(&to_vec(&["in.stl", "-fov", "180", "out.png"])).unwrap_err();
3385        assert!(format!("{err:?}").contains("(0, 180)"));
3386    }
3387
3388    #[test]
3389    fn bg_named_colour_parses() {
3390        let p = parse(&to_vec(&["in.stl", "-bg", "black", "out.png"])).unwrap();
3391        assert_eq!(p.mesh3d_options.bg, Some([0, 0, 0, 255]));
3392    }
3393
3394    #[test]
3395    fn bg_hex_colour_parses() {
3396        let p = parse(&to_vec(&["in.stl", "-bg", "#abcdef", "out.png"])).unwrap();
3397        assert_eq!(p.mesh3d_options.bg, Some([0xab, 0xcd, 0xef, 255]));
3398    }
3399
3400    #[test]
3401    fn bg_transparent_parses() {
3402        let p = parse(&to_vec(&["in.stl", "-bg", "transparent", "out.png"])).unwrap();
3403        assert_eq!(p.mesh3d_options.bg, Some([0, 0, 0, 0]));
3404    }
3405
3406    #[test]
3407    fn mesh3d_flags_can_appear_before_input() {
3408        // IM grammar lets ops appear on either side of the positional;
3409        // confirm the per-format flags follow the same rule.
3410        let p = parse(&to_vec(&[
3411            "-stl-format",
3412            "ascii",
3413            "-gltf-format",
3414            "glb",
3415            "in.stl",
3416            "out.stl",
3417        ]))
3418        .unwrap();
3419        assert_eq!(p.mesh3d_options.stl_format, Some(StlFormatChoice::Ascii));
3420        assert_eq!(p.mesh3d_options.gltf_format, Some(GltfFormatChoice::Glb));
3421        assert_eq!(p.input, "in.stl");
3422        assert_eq!(p.output, "out.stl");
3423    }
3424
3425    // ---- Round 45: -render normal-debug / depth-debug + -aa N --------
3426
3427    #[test]
3428    fn render_normal_debug_parses() {
3429        let p = parse(&to_vec(&["in.stl", "-render", "normal-debug", "out.png"])).unwrap();
3430        assert_eq!(
3431            p.mesh3d_options.render_mode,
3432            Some(Mesh3DRenderMode::NormalDebug)
3433        );
3434    }
3435
3436    #[test]
3437    fn render_depth_debug_parses() {
3438        let p = parse(&to_vec(&["in.stl", "-render", "depth-debug", "out.png"])).unwrap();
3439        assert_eq!(
3440            p.mesh3d_options.render_mode,
3441            Some(Mesh3DRenderMode::DepthDebug)
3442        );
3443    }
3444
3445    #[test]
3446    fn render_normal_alias_parses() {
3447        // `normal` (singular) and `normals` (plural) both map to
3448        // NormalDebug; convenience for the common typo.
3449        let p = parse(&to_vec(&["in.stl", "-render", "normal", "out.png"])).unwrap();
3450        assert_eq!(
3451            p.mesh3d_options.render_mode,
3452            Some(Mesh3DRenderMode::NormalDebug)
3453        );
3454        let p = parse(&to_vec(&["in.stl", "-render", "normals", "out.png"])).unwrap();
3455        assert_eq!(
3456            p.mesh3d_options.render_mode,
3457            Some(Mesh3DRenderMode::NormalDebug)
3458        );
3459    }
3460
3461    #[test]
3462    fn render_depth_alias_parses() {
3463        let p = parse(&to_vec(&["in.stl", "-render", "depth", "out.png"])).unwrap();
3464        assert_eq!(
3465            p.mesh3d_options.render_mode,
3466            Some(Mesh3DRenderMode::DepthDebug)
3467        );
3468        let p = parse(&to_vec(&["in.stl", "-render", "z", "out.png"])).unwrap();
3469        assert_eq!(
3470            p.mesh3d_options.render_mode,
3471            Some(Mesh3DRenderMode::DepthDebug)
3472        );
3473    }
3474
3475    #[test]
3476    fn render_unknown_mode_lists_debug_modes() {
3477        // Round 45 added two new modes; the error message should
3478        // mention them so users don't have to grep the docs.
3479        let err = parse(&to_vec(&["in.stl", "-render", "raytrace", "out.png"])).unwrap_err();
3480        let msg = format!("{err:?}");
3481        assert!(
3482            msg.contains("normal-debug") && msg.contains("depth-debug"),
3483            "expected new debug-mode names in error, got: {msg}"
3484        );
3485    }
3486
3487    #[test]
3488    fn aa_default_is_unset() {
3489        let p = parse(&to_vec(&["in.stl", "out.png"])).unwrap();
3490        assert!(p.mesh3d_options.aa.is_none());
3491    }
3492
3493    #[test]
3494    fn aa_factor_parses() {
3495        let p = parse(&to_vec(&["in.stl", "-aa", "4", "out.png"])).unwrap();
3496        assert_eq!(p.mesh3d_options.aa, Some(4));
3497    }
3498
3499    #[test]
3500    fn aa_factor_one_is_allowed_meaning_no_supersampling() {
3501        // 1× is the documented "off" value — allowed so callers can
3502        // unconditionally set the flag from a config without special-
3503        // casing the disabled state.
3504        let p = parse(&to_vec(&["in.stl", "-aa", "1", "out.png"])).unwrap();
3505        assert_eq!(p.mesh3d_options.aa, Some(1));
3506    }
3507
3508    #[test]
3509    fn aa_factor_zero_rejected() {
3510        let err = parse(&to_vec(&["in.stl", "-aa", "0", "out.png"])).unwrap_err();
3511        let msg = format!("{err:?}");
3512        assert!(
3513            msg.contains("out of range") && msg.contains("1..=8"),
3514            "expected range hint, got: {msg}"
3515        );
3516    }
3517
3518    #[test]
3519    fn aa_factor_above_cap_rejected() {
3520        let err = parse(&to_vec(&["in.stl", "-aa", "16", "out.png"])).unwrap_err();
3521        assert!(format!("{err:?}").contains("1..=8"));
3522    }
3523
3524    #[test]
3525    fn aa_non_integer_rejected() {
3526        let err = parse(&to_vec(&["in.stl", "-aa", "2.5", "out.png"])).unwrap_err();
3527        assert!(format!("{err:?}").contains("not a positive integer"));
3528    }
3529
3530    // ---- -trim / -fuzz ----
3531
3532    /// Bare `-trim` (no preceding `-fuzz`) lands as `Op::Trim { fuzz: 0 }` —
3533    /// IM's default exact-match background detection.
3534    #[test]
3535    fn trim_default_fuzz_is_zero() {
3536        let p = parse(&to_vec(&["a.png", "-trim", "b.png"])).unwrap();
3537        assert_eq!(p.ops, vec![Op::Trim { fuzz: 0 }]);
3538    }
3539
3540    /// `-fuzz N` updates state but emits no op of its own; the next
3541    /// `-trim` captures the value.
3542    #[test]
3543    fn fuzz_byte_then_trim_captures_value() {
3544        let p = parse(&to_vec(&["a.png", "-fuzz", "12", "-trim", "b.png"])).unwrap();
3545        assert_eq!(p.ops, vec![Op::Trim { fuzz: 12 }]);
3546    }
3547
3548    /// `-fuzz N%` accepts the IM percent grammar and maps `100%` to
3549    /// `255`. `10%` ≈ `26` (`10 × 2.55` rounded).
3550    #[test]
3551    fn fuzz_percent_then_trim_captures_scaled_value() {
3552        let p = parse(&to_vec(&["a.png", "-fuzz", "10%", "-trim", "b.png"])).unwrap();
3553        assert_eq!(p.ops, vec![Op::Trim { fuzz: 26 }]);
3554        let p = parse(&to_vec(&["a.png", "-fuzz", "100%", "-trim", "b.png"])).unwrap();
3555        assert_eq!(p.ops, vec![Op::Trim { fuzz: 255 }]);
3556    }
3557
3558    /// A `-fuzz` placed AFTER a `-trim` does NOT retroactively rewrite
3559    /// the earlier trim's tolerance (source-order semantics, identical to
3560    /// `-background` + `-extent`). The second `-trim` then captures the
3561    /// new fuzz.
3562    #[test]
3563    fn fuzz_after_trim_only_affects_following_trim() {
3564        let p = parse(&to_vec(&[
3565            "a.png", "-fuzz", "5", "-trim", "-fuzz", "20", "-trim", "b.png",
3566        ]))
3567        .unwrap();
3568        assert_eq!(p.ops, vec![Op::Trim { fuzz: 5 }, Op::Trim { fuzz: 20 }]);
3569    }
3570
3571    /// `-fuzz` with no preceding-or-following `-trim` is a recorded
3572    /// state-only setting — no Op emitted, no error.
3573    #[test]
3574    fn fuzz_without_trim_is_no_op() {
3575        let p = parse(&to_vec(&["a.png", "-fuzz", "30", "b.png"])).unwrap();
3576        assert_eq!(p.ops, vec![]);
3577    }
3578
3579    /// `-fuzz` requires a value; missing → clean "missing value" error.
3580    #[test]
3581    fn fuzz_missing_value_rejected() {
3582        let err = parse(&to_vec(&["a.png", "-fuzz"])).unwrap_err();
3583        assert!(format!("{err:?}").contains("missing value"));
3584    }
3585
3586    /// `-fuzz` rejects garbage with the same shape as `-threshold`.
3587    #[test]
3588    fn fuzz_garbage_rejected() {
3589        let err = parse(&to_vec(&["a.png", "-fuzz", "xyz", "-trim", "b.png"])).unwrap_err();
3590        assert!(format!("{err:?}").contains("-fuzz"));
3591    }
3592
3593    /// `-fuzz N%` with `N` outside `[0, 100]` is rejected (same shape as
3594    /// `-threshold` / `-solarize`).
3595    #[test]
3596    fn fuzz_percent_out_of_range_rejected() {
3597        let err = parse(&to_vec(&["a.png", "-fuzz", "150%", "-trim", "b.png"])).unwrap_err();
3598        assert!(format!("{err:?}").contains("out of range"));
3599    }
3600
3601    /// `-fuzz N` with `N > 255` is rejected (raw bytes only go up to
3602    /// `255`; the percent grammar is the documented escape hatch).
3603    #[test]
3604    fn fuzz_byte_above_255_rejected() {
3605        let err = parse(&to_vec(&["a.png", "-fuzz", "300", "-trim", "b.png"])).unwrap_err();
3606        assert!(format!("{err:?}").contains("out of range"));
3607    }
3608
3609    /// `-trim` composes with surrounding ops in source order — typical
3610    /// IM idiom `convert in.png -trim +repage out.png` would land trim
3611    /// between any prior `-resize` and a downstream `-strip`. Pins the
3612    /// no-implicit-reorder contract.
3613    #[test]
3614    fn trim_keeps_source_order_with_neighbours() {
3615        let p = parse(&to_vec(&[
3616            "a.png", "-resize", "100x100", "-fuzz", "8", "-trim", "-strip", "b.png",
3617        ]))
3618        .unwrap();
3619        assert_eq!(
3620            p.ops,
3621            vec![
3622                Op::Resize {
3623                    width: 100,
3624                    height: 100,
3625                    mode: ResizeMode::Default,
3626                },
3627                Op::Trim { fuzz: 8 },
3628                Op::Strip,
3629            ]
3630        );
3631    }
3632
3633    // ---- -roll +X+Y ----
3634
3635    /// Plain `-roll +X+Y` lands as `Op::Roll { dx, dy }` with both
3636    /// offsets positive.
3637    #[test]
3638    fn roll_positive_offsets() {
3639        let p = parse(&to_vec(&["a.png", "-roll", "+5+10", "b.png"])).unwrap();
3640        assert_eq!(p.ops, vec![Op::Roll { dx: 5, dy: 10 }]);
3641    }
3642
3643    /// `-roll -X-Y` accepts negative offsets unchanged — the inline
3644    /// shift handles the modulo wrap, so the args layer doesn't
3645    /// normalise.
3646    #[test]
3647    fn roll_negative_offsets() {
3648        let p = parse(&to_vec(&["a.png", "-roll", "-3-7", "b.png"])).unwrap();
3649        assert_eq!(p.ops, vec![Op::Roll { dx: -3, dy: -7 }]);
3650    }
3651
3652    /// Mixed signs (`+X-Y`, `-X+Y`) ride through the same
3653    /// `parse_extent_offsets` helper as `-extent`.
3654    #[test]
3655    fn roll_mixed_signs() {
3656        let p = parse(&to_vec(&["a.png", "-roll", "+5-10", "b.png"])).unwrap();
3657        assert_eq!(p.ops, vec![Op::Roll { dx: 5, dy: -10 }]);
3658    }
3659
3660    /// Single-offset `+X` is rejected — IM requires both X and Y
3661    /// signed. Mirrors the `-extent +X` rejection path.
3662    #[test]
3663    fn roll_single_offset_rejected() {
3664        let err = parse(&to_vec(&["a.png", "-roll", "+5", "b.png"])).unwrap_err();
3665        assert!(
3666            format!("{err:?}").contains("single offset"),
3667            "unexpected error: {err:?}"
3668        );
3669    }
3670
3671    /// Empty value (e.g. `-roll ""`) is rejected with a clear
3672    /// message rather than silently treated as `0,0`.
3673    #[test]
3674    fn roll_empty_value_rejected() {
3675        let err = parse(&to_vec(&["a.png", "-roll", "", "b.png"])).unwrap_err();
3676        assert!(
3677            format!("{err:?}").contains("empty offset"),
3678            "unexpected error: {err:?}"
3679        );
3680    }
3681
3682    /// Unsigned `5+10` (missing leading sign) is rejected with the
3683    /// IM "must start with `+` or `-`" message — IM grammar requires
3684    /// the explicit sign.
3685    #[test]
3686    fn roll_unsigned_value_rejected() {
3687        let err = parse(&to_vec(&["a.png", "-roll", "5+10", "b.png"])).unwrap_err();
3688        let s = format!("{err:?}");
3689        assert!(
3690            s.contains("must start with `+` or `-`") || s.contains("not a signed integer"),
3691            "unexpected error: {err:?}"
3692        );
3693    }
3694
3695    /// `-roll` preserves source order alongside neighbours — same
3696    /// no-reorder contract as `-trim` / `-flip` / `-extent`.
3697    #[test]
3698    fn roll_keeps_source_order_with_neighbours() {
3699        let p = parse(&to_vec(&[
3700            "a.png", "-resize", "100x100", "-roll", "+5+5", "-strip", "b.png",
3701        ]))
3702        .unwrap();
3703        assert_eq!(
3704            p.ops,
3705            vec![
3706                Op::Resize {
3707                    width: 100,
3708                    height: 100,
3709                    mode: ResizeMode::Default,
3710                },
3711                Op::Roll { dx: 5, dy: 5 },
3712                Op::Strip,
3713            ]
3714        );
3715    }
3716
3717    // ---- adversarial argv: typed errors, never panics ----
3718
3719    /// Whatever garbage arrives, `parse` must return `Ok` or a typed
3720    /// error — never panic. The corpus concentrates on the value
3721    /// grammars with the most parsing surface (geometry, page
3722    /// selectors, colours, numeric ranges) plus token-shape edge
3723    /// cases (unicode, oversized strings, lone dashes, truncated
3724    /// flag/value pairs).
3725    #[test]
3726    fn adversarial_argv_never_panics() {
3727        let long = "x".repeat(10_000);
3728        let long_selector = format!("in.pdf[{}]", "9".repeat(5_000));
3729        let cases: Vec<Vec<&str>> = vec![
3730            // Geometry grammar abuse.
3731            vec!["in.png", "-resize", "x", "out.png"],
3732            vec!["in.png", "-resize", "0x0", "out.png"],
3733            vec!["in.png", "-resize", "-1x-1", "out.png"],
3734            vec!["in.png", "-resize", "99999999999999999999x1", "out.png"],
3735            vec!["in.png", "-resize", "8x", "out.png"],
3736            vec!["in.png", "-resize", "!", "out.png"],
3737            vec!["in.png", "-crop", "1x1", "out.png"],
3738            vec!["in.png", "-crop", "1x1+", "out.png"],
3739            vec!["in.png", "-crop", "1x1+1+1+1", "out.png"],
3740            vec!["in.png", "-extent", "+5+5", "out.png"],
3741            vec!["in.png", "-roll", "++", "out.png"],
3742            vec!["in.png", "-roll", "5", "out.png"],
3743            vec!["in.png", "-vignette", "+", "out.png"],
3744            vec!["in.png", "-vignette", "1+2+3+4+5", "out.png"],
3745            // Page-selector abuse.
3746            vec!["in.pdf[", "out.png"],
3747            vec!["in.pdf[]", "out.png"],
3748            vec!["in.pdf[1-2-3]", "out.png"],
3749            vec!["in.pdf[,]", "out.png"],
3750            vec!["in.pdf[0,,1]", "out.png"],
3751            vec!["in.pdf[abc]", "out.png"],
3752            vec!["in.pdf[999999999999999999999]", "out.png"],
3753            vec!["]", "out.png"],
3754            vec!["[0]", "out.png"],
3755            // Colour grammar abuse.
3756            vec!["in.png", "-background", "#", "out.png"],
3757            vec!["in.png", "-background", "#12345", "out.png"],
3758            vec!["in.png", "-background", "#gggggg", "out.png"],
3759            vec!["in.png", "-background", "notacolor", "out.png"],
3760            vec!["in.png", "-colorize", "/", "out.png"],
3761            vec!["in.png", "-colorize", "300x300x300/2", "out.png"],
3762            // Numeric range / special-float abuse.
3763            vec!["in.png", "-quality", "-1", "out.png"],
3764            vec!["in.png", "-quality", "99999999999", "out.png"],
3765            vec!["in.png", "-gamma", "NaN", "out.png"],
3766            vec!["in.png", "-gamma", "inf", "out.png"],
3767            vec!["in.png", "-gamma", "-0.0", "out.png"],
3768            vec!["in.png", "-fov", "NaN", "out.png"],
3769            vec!["in.png", "-modulate", ",,,", "out.png"],
3770            vec!["in.png", "-modulate", "1,2,3,4", "out.png"],
3771            vec!["in.png", "-level", "//", "out.png"],
3772            vec!["in.png", "-fuzz", "300%", "out.png"],
3773            vec!["in.png", "-aa", "0", "out.png"],
3774            // Define / token-shape abuse.
3775            vec!["in.png", "-define", "=v", "out.png"],
3776            vec!["in.png", "-define", "=", "out.png"],
3777            vec!["in.png", "--opt", "=", "out.png"],
3778            vec!["in.png", "--opt", "==", "out.png"],
3779            vec!["in.png", "--opt"],
3780            vec!["in.png", "-depth", "", "out.png"],
3781            vec!["in.png", "-depth", "99999999999", "out.png"],
3782            vec!["日本語.png", "出力.jpg"],
3783            vec!["in.png", "-résize", "8x8", "out.png"],
3784            vec![&long, "out.png"],
3785            vec!["in.png", &long_selector, "out.png"],
3786            // Truncated flag/value pairs and lone dashes.
3787            vec!["in.png", "-resize"],
3788            vec!["-"],
3789            vec!["--"],
3790            vec!["in.png", "-", "out.png"],
3791        ];
3792        for argv in cases {
3793            // Must not panic; Ok vs Err is case-specific and pinned
3794            // by the focused tests elsewhere in this module.
3795            let _ = parse(&to_vec(&argv));
3796        }
3797    }
3798
3799    // ---- `--help` mode + usage synopsis ----
3800
3801    #[test]
3802    fn help_parses_with_no_positionals() {
3803        for argv in [vec!["--help"], vec!["-help"]] {
3804            let p = parse(&to_vec(&argv)).unwrap();
3805            assert!(p.help, "argv={argv:?}");
3806        }
3807    }
3808
3809    #[test]
3810    fn help_anywhere_on_a_full_line_still_wins() {
3811        // Positional validation is skipped: even a line that would
3812        // otherwise error (probe + output) parses once --help is on it.
3813        let p = parse(&to_vec(&["in.png", "--help", "--probe", "out.png"])).unwrap();
3814        assert!(p.help);
3815    }
3816
3817    /// The usage text must mention every recognised flag — it is
3818    /// generated from [`KNOWN_FLAG_NAMES`], so this pins the
3819    /// generation (right dash flavour included) rather than the table.
3820    #[test]
3821    fn usage_lists_every_known_flag_with_correct_lead() {
3822        let text = usage();
3823        for name in KNOWN_FLAG_NAMES {
3824            let lead = if matches!(*name, "probe" | "json" | "watch" | "help" | "opt") {
3825                "--"
3826            } else {
3827                "-"
3828            };
3829            let rendered = format!(" {lead}{name}");
3830            assert!(
3831                text.contains(&rendered),
3832                "usage text is missing '{rendered}':\n{text}"
3833            );
3834        }
3835        assert!(text.starts_with("usage: oxideav convert"));
3836    }
3837
3838    // ---- `--` end-of-flags marker + empty-argument hardening ----
3839
3840    #[test]
3841    fn double_dash_treats_following_dash_args_as_positionals() {
3842        // A file literally named `-weird.png` is expressible.
3843        let p = parse(&to_vec(&["--", "-weird.png", "-out.jpg"])).unwrap();
3844        assert_eq!(p.input, "-weird.png");
3845        assert_eq!(p.output, "-out.jpg");
3846        assert!(p.ops.is_empty());
3847    }
3848
3849    #[test]
3850    fn flags_before_double_dash_still_parse() {
3851        let p = parse(&to_vec(&["-resize", "64x64", "--", "-in.png", "out.png"])).unwrap();
3852        assert_eq!(p.input, "-in.png");
3853        assert_eq!(p.output, "out.png");
3854        assert_eq!(p.ops.len(), 1);
3855    }
3856
3857    #[test]
3858    fn flags_after_double_dash_are_positionals_not_flags() {
3859        // Three positionals (`-resize` is a filename after `--`) →
3860        // multi-input error, NOT an op.
3861        let err = parse(&to_vec(&["--", "-resize", "64x64", "out.png"])).unwrap_err();
3862        let s = format!("{err}");
3863        assert!(s.contains("multi-input"), "got: {err}");
3864    }
3865
3866    #[test]
3867    fn empty_positional_is_rejected_with_actionable_message() {
3868        for argv in [
3869            vec!["", "out.png"],
3870            vec!["in.png", ""],
3871            vec!["--", "", "out.png"],
3872        ] {
3873            let err = parse(&to_vec(&argv)).unwrap_err();
3874            let s = format!("{err}");
3875            assert!(s.contains("empty argument"), "argv={argv:?} got: {err}");
3876        }
3877    }
3878
3879    /// Every entry in [`KNOWN_FLAG_NAMES`] must be accepted by the
3880    /// parser's `match` — a table entry the parser rejects as an
3881    /// unknown flag means the two drifted apart (the table exists to
3882    /// power "did you mean?" hints for exactly these names, so a
3883    /// stale entry would SUGGEST a flag that then errors).
3884    ///
3885    /// The value column supplies a minimal valid argument per flag so
3886    /// the whole line parses `Ok` — catching not just unknown-flag
3887    /// drift but accidental value-grammar breakage too.
3888    #[test]
3889    fn opt_parses_key_value_pairs_in_order() {
3890        let p = parse(&to_vec(&[
3891            "a.png",
3892            "--opt",
3893            "filter=none",
3894            "--opt",
3895            "tiles=2x2",
3896            "b.png",
3897        ]))
3898        .unwrap();
3899        assert_eq!(
3900            p.ops,
3901            vec![
3902                Op::EncoderOpt {
3903                    key: "filter".into(),
3904                    value: "none".into()
3905                },
3906                Op::EncoderOpt {
3907                    key: "tiles".into(),
3908                    value: "2x2".into()
3909                },
3910            ]
3911        );
3912        // `=` inside the value is kept verbatim; an empty value is legal.
3913        let p = parse(&to_vec(&[
3914            "a.png", "--opt", "k=a=b", "--opt", "e=", "b.png",
3915        ]))
3916        .unwrap();
3917        assert_eq!(
3918            p.ops,
3919            vec![
3920                Op::EncoderOpt {
3921                    key: "k".into(),
3922                    value: "a=b".into()
3923                },
3924                Op::EncoderOpt {
3925                    key: "e".into(),
3926                    value: String::new()
3927                },
3928            ]
3929        );
3930        for bad in ["novalue", "=v"] {
3931            let err = parse(&to_vec(&["a.png", "--opt", bad, "b.png"])).unwrap_err();
3932            assert!(format!("{err}").contains("--opt"), "{bad}: {err}");
3933        }
3934    }
3935
3936    #[test]
3937    fn depth_accepts_the_two_storage_depths() {
3938        let p = parse(&to_vec(&["a.heic", "-depth", "8", "b.png"])).unwrap();
3939        assert_eq!(p.ops, vec![Op::Depth(8)]);
3940        let p = parse(&to_vec(&["a.heic", "-depth", "16", "b.png"])).unwrap();
3941        assert_eq!(p.ops, vec![Op::Depth(16)]);
3942        for bad in ["12", "0", "abc", "-8", "256"] {
3943            let err = parse(&to_vec(&["a.heic", "-depth", bad, "b.png"])).unwrap_err();
3944            assert!(
3945                format!("{err}").contains("-depth") && format!("{err}").contains("8 or 16"),
3946                "{bad}: {err}"
3947            );
3948        }
3949    }
3950
3951    #[test]
3952    fn every_known_flag_name_is_accepted_by_the_parser() {
3953        // (flag-as-typed, extra args, needs_probe)
3954        let cases: &[(&str, &[&str])] = &[
3955            ("-aa", &["2"]),
3956            ("-alpha", &["on"]),
3957            ("-auto-gamma", &[]),
3958            ("-background", &["white"]),
3959            ("-bg", &["white"]),
3960            ("-blur", &["1x1"]),
3961            ("-brightness-contrast", &["10,5"]),
3962            ("-camera", &["30,45,1.5"]),
3963            ("-colorize", &["red/50%"]),
3964            ("-colors", &["16"]),
3965            ("-colorspace", &["gray"]),
3966            ("-contrast", &[]),
3967            ("-crop", &["1x1+0+0"]),
3968            ("-define", &["k=v"]),
3969            ("-density", &["72"]),
3970            ("-depth", &["8"]),
3971            ("-dither", &["none"]),
3972            ("-edge", &["1"]),
3973            ("-equalize", &[]),
3974            ("-extent", &["10x10"]),
3975            ("-flip", &[]),
3976            ("-flop", &[]),
3977            ("-format", &["png"]),
3978            ("-fov", &["60"]),
3979            ("-fuzz", &["5"]),
3980            ("-gamma", &["1.2"]),
3981            ("-gltf-format", &["glb"]),
3982            ("-level", &["16"]),
3983            ("-light", &["45,45,1.0"]),
3984            ("-modulate", &["100,100,100"]),
3985            ("-monochrome", &[]),
3986            ("-negate", &[]),
3987            ("-normalize", &[]),
3988            ("--opt", &["filter=none"]),
3989            ("-ping", &[]),
3990            ("-posterize", &["4"]),
3991            ("-projection", &["persp"]),
3992            ("-quality", &["85"]),
3993            ("-render", &["flat"]),
3994            ("-resize", &["64x64"]),
3995            ("-roll", &["+1+1"]),
3996            ("-rotate", &["90"]),
3997            ("-sepia", &["80%"]),
3998            ("-sharpen", &["1x0.5"]),
3999            ("-solarize", &["50%"]),
4000            ("-stl-format", &["binary"]),
4001            ("-strip", &[]),
4002            ("-thumbnail", &["64x64"]),
4003            ("-threshold", &["50%"]),
4004            ("-trim", &[]),
4005            ("-unsharp", &["1x1"]),
4006            ("-vignette", &["50"]),
4007        ];
4008        // Double-dash mode switches need their own argv shapes
4009        // (`--json` / `--watch` require `--probe`; `--probe` forbids
4010        // an output positional).
4011        let mode_cases: &[&[&str]] = &[
4012            &["--probe", "in.png"],
4013            &["--probe", "--json", "in.png"],
4014            &["--probe", "--watch", "in.png"],
4015            &["--help"],
4016        ];
4017
4018        let mut covered: Vec<&str> = Vec::new();
4019        for (flag, extra) in cases {
4020            let mut argv: Vec<&str> = vec!["in.png"];
4021            argv.push(flag);
4022            argv.extend_from_slice(extra);
4023            argv.push("out.png");
4024            let res = parse(&to_vec(&argv));
4025            assert!(
4026                res.is_ok(),
4027                "flag {flag} rejected with sample value {extra:?}: {:?}",
4028                res.err()
4029            );
4030            covered.push(flag.trim_start_matches('-'));
4031        }
4032        for argv in mode_cases {
4033            let res = parse(&to_vec(argv));
4034            assert!(res.is_ok(), "argv {argv:?} rejected: {:?}", res.err());
4035        }
4036        covered.extend(["probe", "json", "watch", "help"]);
4037
4038        // Table completeness: every advertised name has a test case…
4039        for name in KNOWN_FLAG_NAMES {
4040            assert!(
4041                covered.contains(name),
4042                "KNOWN_FLAG_NAMES entry '{name}' has no acceptance case — add one above"
4043            );
4044        }
4045        // …and every test case is advertised (no hidden flags that a
4046        // typo could never get a hint for).
4047        for name in &covered {
4048            assert!(
4049                KNOWN_FLAG_NAMES.contains(name),
4050                "flag '{name}' parses but is missing from KNOWN_FLAG_NAMES"
4051            );
4052        }
4053    }
4054}