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