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