1use 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
16const KNOWN_FLAG_NAMES: &[&str] = &[
31 "aa",
32 "alpha",
33 "auto-gamma",
34 "background",
35 "bg",
36 "blur",
37 "brightness-contrast",
38 "camera",
39 "colorize",
40 "colors",
41 "colorspace",
42 "contrast",
43 "crop",
44 "define",
45 "density",
46 "depth",
47 "dither",
48 "edge",
49 "equalize",
50 "extent",
51 "flip",
52 "flop",
53 "format",
54 "fov",
55 "fuzz",
56 "gamma",
57 "gltf-format",
58 "help",
59 "json",
60 "level",
61 "light",
62 "modulate",
63 "monochrome",
64 "negate",
65 "normalize",
66 "opt",
67 "ping",
68 "posterize",
69 "probe",
70 "projection",
71 "quality",
72 "render",
73 "resize",
74 "roll",
75 "rotate",
76 "sepia",
77 "sharpen",
78 "solarize",
79 "stl-format",
80 "strip",
81 "thumbnail",
82 "threshold",
83 "trim",
84 "unsharp",
85 "vignette",
86 "watch",
87];
88
89pub fn parse(args: &[String]) -> Result<ConvertPlan, Error> {
95 if args.is_empty() {
96 return Err(Error::invalid(
97 "convert: no input file given (usage: convert [-op VALUE]... INPUT [-op VALUE]... OUTPUT)",
98 ));
99 }
100
101 let mut ops: Vec<Op> = Vec::new();
110 let mut positionals: Vec<String> = Vec::new();
111 let mut pending_dither = Dither::None;
112 let mut help = false;
113 let mut ping = false;
114 let mut probe = false;
115 let mut probe_json = false;
116 let mut probe_watch = false;
117 let mut mesh3d_options = Mesh3DOptions::default();
118 let mut current_bg: [u8; 4] = [255, 255, 255, 255];
123 let mut current_fuzz: u8 = 0;
131
132 let mut no_more_flags = false;
136
137 let mut i = 0;
138 while i < args.len() {
139 let flag = &args[i];
140 let val = |k: usize| -> Result<&str, Error> {
141 args.get(k)
142 .map(|s| s.as_str())
143 .ok_or_else(|| Error::invalid(format!("convert: {flag}: missing value")))
144 };
145
146 if no_more_flags || !flag.starts_with('-') {
151 if flag.is_empty() {
152 return Err(Error::invalid(
153 "convert: empty argument (positional filenames must be non-empty; check for an unset shell variable)",
154 ));
155 }
156 positionals.push(flag.clone());
157 i += 1;
158 continue;
159 }
160
161 match flag.as_str() {
162 "--" => {
164 no_more_flags = true;
165 i += 1;
166 }
167 "-resize" => {
168 let v = val(i + 1)?;
169 let (mode, core) = ResizeMode::split_suffix(v);
170 let (w, h) = parse_resize_geometry(core, mode)
171 .map_err(|e| Error::invalid(format!("convert: -resize: {e}")))?;
172 ops.push(Op::Resize {
173 width: w,
174 height: h,
175 mode,
176 });
177 i += 2;
178 }
179 "-thumbnail" => {
187 let v = val(i + 1)?;
188 let (mode, core) = ResizeMode::split_suffix(v);
189 let (w, h) = parse_resize_geometry(core, mode)
190 .map_err(|e| Error::invalid(format!("convert: -thumbnail: {e}")))?;
191 ops.push(Op::Thumbnail {
192 width: w,
193 height: h,
194 mode,
195 });
196 i += 2;
197 }
198 "-define" => {
205 let v = val(i + 1)?;
206 let (key, value) = match v.split_once('=') {
207 Some((k, val)) => (k.to_string(), Some(val.to_string())),
208 None => (v.to_string(), None),
209 };
210 if key.is_empty() {
211 return Err(Error::invalid(format!(
212 "convert: -define: '{v}' has an empty KEY (expected KEY[=VALUE])"
213 )));
214 }
215 ops.push(Op::Define { key, value });
216 i += 2;
217 }
218 "-blur" => {
219 let v = val(i + 1)?;
220 let (radius, sigma) =
221 parse_blur(v).map_err(|e| Error::invalid(format!("convert: -blur: {e}")))?;
222 ops.push(Op::Blur { radius, sigma });
223 i += 2;
224 }
225 "-edge" => {
226 let v = val(i + 1)?;
227 let r: u32 = v.parse().map_err(|_| {
228 Error::invalid(format!(
229 "convert: -edge: '{v}' is not a non-negative integer"
230 ))
231 })?;
232 ops.push(Op::Edge { radius: r });
233 i += 2;
234 }
235 "-colors" => {
236 let v = val(i + 1)?;
237 let n: u32 = v.parse().map_err(|_| {
238 Error::invalid(format!(
239 "convert: -colors: '{v}' is not a non-negative integer"
240 ))
241 })?;
242 if !(2..=256).contains(&n) {
243 return Err(Error::invalid(format!(
244 "convert: -colors {n} out of range (2..=256)"
245 )));
246 }
247 ops.push(Op::Colors {
248 count: n,
249 dither: pending_dither,
250 });
251 i += 2;
252 }
253 "-dither" => {
254 let v = val(i + 1)?;
255 pending_dither = Dither::parse(v).map_err(Error::invalid)?;
256 i += 2;
257 }
258 "-monochrome" => {
272 ops.push(Op::Colorspace("gray".to_string()));
273 ops.push(Op::Colors {
274 count: 2,
275 dither: Dither::FloydSteinberg,
276 });
277 i += 1;
278 }
279 "-format" => {
280 let v = val(i + 1)?;
281 ops.push(Op::Format(v.to_string()));
282 i += 2;
283 }
284 "-depth" => {
288 let v = val(i + 1)?;
289 let d: u8 = match v.parse() {
290 Ok(d @ (8 | 16)) => d,
291 _ => {
292 return Err(Error::invalid(format!(
293 "convert: -depth: '{v}' must be 8 or 16"
294 )))
295 }
296 };
297 ops.push(Op::Depth(d));
298 i += 2;
299 }
300 "--opt" | "-opt" => {
305 let v = val(i + 1)?;
306 let (key, value) = v.split_once('=').ok_or_else(|| {
307 Error::invalid(format!("convert: --opt: '{v}' is not KEY=VALUE"))
308 })?;
309 if key.is_empty() {
310 return Err(Error::invalid(format!(
311 "convert: --opt: '{v}' has an empty KEY (expected KEY=VALUE)"
312 )));
313 }
314 ops.push(Op::EncoderOpt {
315 key: key.to_string(),
316 value: value.to_string(),
317 });
318 i += 2;
319 }
320 "-quality" => {
321 let v = val(i + 1)?;
322 let q: u32 = v.parse().map_err(|_| {
323 Error::invalid(format!(
324 "convert: -quality: '{v}' is not a non-negative integer"
325 ))
326 })?;
327 if q > 100 {
339 return Err(Error::invalid(format!(
340 "convert: -quality: {q} out of range (must be in 0..=100; common values: 75 default, 85 web, 95 archival)"
341 )));
342 }
343 ops.push(Op::Quality(q));
344 i += 2;
345 }
346 "-strip" => {
347 ops.push(Op::Strip);
348 i += 1;
349 }
350 "-ping" => {
351 ping = true;
352 i += 1;
353 }
354 "--help" | "-help" => {
359 help = true;
360 i += 1;
361 }
362 "--probe" => {
369 probe = true;
370 i += 1;
371 }
372 "--json" => {
378 probe_json = true;
379 i += 1;
380 }
381 "--watch" => {
388 probe_watch = true;
389 i += 1;
390 }
391 "-density" => {
392 let v = val(i + 1)?;
393 let n: u32 = v.parse().map_err(|_| {
394 Error::invalid(format!(
395 "convert: -density: '{v}' is not a non-negative integer"
396 ))
397 })?;
398 if n == 0 {
399 return Err(Error::invalid("convert: -density must be > 0"));
400 }
401 ops.push(Op::Density(n));
402 i += 2;
403 }
404 "-background" => {
405 let v = val(i + 1)?;
406 let rgba = parse_color(v).map_err(Error::invalid)?;
407 current_bg = rgba;
411 ops.push(Op::Background(rgba));
412 i += 2;
413 }
414 "-alpha" => {
415 let v = val(i + 1)?;
416 let a = AlphaOp::parse(v).map_err(Error::invalid)?;
417 ops.push(Op::Alpha(a));
418 i += 2;
419 }
420 "-rotate" => {
421 let v = val(i + 1)?;
422 let n: i32 = v.parse().map_err(|_| {
423 Error::invalid(format!("convert: -rotate: '{v}' is not an integer"))
424 })?;
425 if !matches!(n, 90 | 180 | 270 | -90 | -180 | -270) {
430 return Err(Error::invalid(format!(
431 "convert: -rotate: only multiples of 90 supported (got {n})"
432 )));
433 }
434 ops.push(Op::Rotate { degrees: n });
435 i += 2;
436 }
437 "-flip" => {
438 ops.push(Op::Flip);
439 i += 1;
440 }
441 "-flop" => {
442 ops.push(Op::Flop);
443 i += 1;
444 }
445 "-negate" => {
446 ops.push(Op::Negate);
447 i += 1;
448 }
449 "-crop" => {
450 let v = val(i + 1)?;
451 let (w, h, x, y) =
452 parse_crop(v).map_err(|e| Error::invalid(format!("convert: -crop: {e}")))?;
453 ops.push(Op::Crop { x, y, w, h });
454 i += 2;
455 }
456 "-extent" => {
457 let v = val(i + 1)?;
464 let (w, h, x, y) = parse_extent(v)
465 .map_err(|e| Error::invalid(format!("convert: -extent: {e}")))?;
466 ops.push(Op::Extent {
467 width: w,
468 height: h,
469 x,
470 y,
471 bg: current_bg,
472 });
473 i += 2;
474 }
475 "-fuzz" => {
481 let v = val(i + 1)?;
482 current_fuzz = parse_threshold_pct(v)
483 .map_err(|e| Error::invalid(format!("convert: -fuzz: {e}")))?;
484 i += 2;
485 }
486 "-trim" => {
492 ops.push(Op::Trim { fuzz: current_fuzz });
493 i += 1;
494 }
495 "-roll" => {
501 let v = val(i + 1)?;
502 let (dx, dy) =
503 parse_roll(v).map_err(|e| Error::invalid(format!("convert: -roll: {e}")))?;
504 ops.push(Op::Roll { dx, dy });
505 i += 2;
506 }
507 "-sharpen" => {
510 let v = val(i + 1)?;
511 let (radius, sigma) =
512 parse_blur(v).map_err(|e| Error::invalid(format!("convert: -sharpen: {e}")))?;
513 ops.push(Op::Sharpen { radius, sigma });
514 i += 2;
515 }
516 "-unsharp" => {
517 let v = val(i + 1)?;
518 let (radius, sigma, amount, threshold) = parse_unsharp(v)
519 .map_err(|e| Error::invalid(format!("convert: -unsharp: {e}")))?;
520 ops.push(Op::Unsharp {
521 radius,
522 sigma,
523 amount,
524 threshold,
525 });
526 i += 2;
527 }
528 "-gamma" => {
529 let v = val(i + 1)?;
530 let g: f32 = v.parse().map_err(|_| {
531 Error::invalid(format!("convert: -gamma: '{v}' is not a finite float"))
532 })?;
533 if !g.is_finite() || g <= 0.0 {
534 return Err(Error::invalid(format!(
535 "convert: -gamma: value must be > 0 (got {g})"
536 )));
537 }
538 ops.push(Op::Gamma { value: g });
539 i += 2;
540 }
541 "-brightness-contrast" => {
542 let v = val(i + 1)?;
543 let (b, c) = parse_brightness_contrast(v)
544 .map_err(|e| Error::invalid(format!("convert: -brightness-contrast: {e}")))?;
545 ops.push(Op::BrightnessContrast {
546 brightness: b,
547 contrast: c,
548 });
549 i += 2;
550 }
551 "-contrast" => {
552 ops.push(Op::Contrast { delta: 1 });
555 i += 1;
556 }
557 "-sepia" => {
558 let v = val(i + 1)?;
559 let t = parse_percent_or_unit(v)
560 .map_err(|e| Error::invalid(format!("convert: -sepia: {e}")))?;
561 ops.push(Op::Sepia { threshold: t });
562 i += 2;
563 }
564 "-modulate" => {
565 let v = val(i + 1)?;
566 let (b, s, h) = parse_modulate(v)
567 .map_err(|e| Error::invalid(format!("convert: -modulate: {e}")))?;
568 ops.push(Op::Modulate {
569 brightness: b,
570 saturation: s,
571 hue: h,
572 });
573 i += 2;
574 }
575 "-level" => {
576 let v = val(i + 1)?;
577 let (b, g, w) =
578 parse_level(v).map_err(|e| Error::invalid(format!("convert: -level: {e}")))?;
579 ops.push(Op::Level {
580 black: b,
581 gamma: g,
582 white: w,
583 });
584 i += 2;
585 }
586 "-normalize" => {
587 ops.push(Op::Normalize);
588 i += 1;
589 }
590 "-threshold" => {
591 let v = val(i + 1)?;
592 let n = parse_threshold_pct(v)
593 .map_err(|e| Error::invalid(format!("convert: -threshold: {e}")))?;
594 ops.push(Op::Threshold { value: n });
595 i += 2;
596 }
597 "-posterize" => {
598 let v = val(i + 1)?;
599 let n: u32 = v.parse().map_err(|_| {
600 Error::invalid(format!(
601 "convert: -posterize: '{v}' is not a non-negative integer"
602 ))
603 })?;
604 if n < 2 {
605 return Err(Error::invalid(format!(
606 "convert: -posterize: levels must be >= 2 (got {n})"
607 )));
608 }
609 ops.push(Op::Posterize { levels: n });
610 i += 2;
611 }
612 "-solarize" => {
613 let v = val(i + 1)?;
614 let n = parse_threshold_pct(v)
615 .map_err(|e| Error::invalid(format!("convert: -solarize: {e}")))?;
616 ops.push(Op::Solarize { value: n });
617 i += 2;
618 }
619 "-vignette" => {
620 let v = val(i + 1)?;
621 let (radius, sigma, x, y) = parse_vignette(v)
622 .map_err(|e| Error::invalid(format!("convert: -vignette: {e}")))?;
623 ops.push(Op::Vignette {
624 radius,
625 sigma,
626 x,
627 y,
628 });
629 i += 2;
630 }
631 "-colorize" => {
632 let v = val(i + 1)?;
633 let (color, amount) = parse_colorize(v)
634 .map_err(|e| Error::invalid(format!("convert: -colorize: {e}")))?;
635 ops.push(Op::Colorize { color, amount });
636 i += 2;
637 }
638 "-equalize" => {
639 ops.push(Op::Equalize);
640 i += 1;
641 }
642 "-auto-gamma" => {
643 ops.push(Op::AutoGamma);
644 i += 1;
645 }
646 "-colorspace" => {
647 let v = val(i + 1)?;
648 let cs = v.trim().to_string();
649 let lower = cs.to_ascii_lowercase();
653 if !matches!(lower.as_str(), "gray" | "grey" | "rgb" | "srgb") {
654 return Err(Error::unsupported(format!(
655 "convert: -colorspace '{cs}' is not yet wired (round-1 supports gray/grey/rgb/srgb)"
656 )));
657 }
658 ops.push(Op::Colorspace(cs));
659 i += 2;
660 }
661 "-stl-format" => {
671 let v = val(i + 1)?;
672 let choice = StlFormatChoice::parse(v).map_err(Error::invalid)?;
673 mesh3d_options.stl_format = Some(choice);
674 i += 2;
675 }
676 "-gltf-format" => {
677 let v = val(i + 1)?;
678 let choice = GltfFormatChoice::parse(v).map_err(Error::invalid)?;
679 mesh3d_options.gltf_format = Some(choice);
680 i += 2;
681 }
682 "-render" => {
688 let v = val(i + 1)?;
689 let mode = Mesh3DRenderMode::parse(v).map_err(Error::invalid)?;
690 mesh3d_options.render_mode = Some(mode);
691 i += 2;
692 }
693 "-light" => {
699 let v = val(i + 1)?;
700 let spec = LightSpec::parse(v).map_err(Error::invalid)?;
701 mesh3d_options.light = Some(spec);
702 i += 2;
703 }
704 "-camera" => {
707 let v = val(i + 1)?;
708 let spec = CameraSpec::parse(v).map_err(Error::invalid)?;
709 mesh3d_options.camera = Some(spec);
710 i += 2;
711 }
712 "-projection" => {
715 let v = val(i + 1)?;
716 let mode = ProjectionMode::parse(v).map_err(Error::invalid)?;
717 mesh3d_options.projection = Some(mode);
718 i += 2;
719 }
720 "-fov" => {
724 let v = val(i + 1)?;
725 let fov: f32 = v
726 .parse()
727 .map_err(|_| Error::invalid(format!("convert: -fov: '{v}' is not a number")))?;
728 if !fov.is_finite() || fov <= 0.0 || fov >= 180.0 {
729 return Err(Error::invalid(format!(
730 "convert: -fov: {fov} must be in (0, 180) degrees"
731 )));
732 }
733 mesh3d_options.fov_deg = Some(fov);
734 i += 2;
735 }
736 "-bg" => {
740 let v = val(i + 1)?;
741 let colour = parse_color(v).map_err(Error::invalid)?;
742 mesh3d_options.bg = Some(colour);
743 i += 2;
744 }
745 "-aa" => {
755 let v = val(i + 1)?;
756 let factor: u32 = v.parse().map_err(|_| {
757 Error::invalid(format!("convert: -aa: '{v}' is not a positive integer"))
758 })?;
759 if !(1..=8).contains(&factor) {
760 return Err(Error::invalid(format!(
761 "convert: -aa: factor {factor} out of range (must be in 1..=8)"
762 )));
763 }
764 mesh3d_options.aa = Some(factor);
765 i += 2;
766 }
767 other => {
768 let (lead, bare) = if let Some(rest) = other.strip_prefix("--") {
781 ("--", rest)
782 } else {
783 ("-", other.strip_prefix('-').unwrap_or(other))
784 };
785 let hint = crate::suggest::closest_match(bare, KNOWN_FLAG_NAMES)
786 .map(|s| format!(" (did you mean '{lead}{s}'?)"))
787 .unwrap_or_default();
788 return Err(Error::invalid(format!(
789 "convert: unknown flag '{other}'{hint}"
790 )));
791 }
792 }
793 }
794
795 if help {
799 return Ok(ConvertPlan {
800 input: String::new(),
801 input_pages: None,
802 ops,
803 output: String::new(),
804 output_template: None,
805 help: true,
806 ping,
807 probe,
808 probe_json,
809 probe_watch,
810 mesh3d_options,
811 });
812 }
813
814 if positionals.is_empty() {
815 return Err(Error::invalid(
816 "convert: no input file given (usage: convert [-op VALUE]... INPUT [-op VALUE]... OUTPUT)",
817 ));
818 }
819 if probe && positionals.len() >= 2 {
825 return Err(Error::invalid(format!(
826 "convert: --probe cannot be combined with an output file ('{}'); use either --probe OR an output file, not both",
827 positionals[1]
828 )));
829 }
830 if positionals.len() < 2 && !ping && !probe {
831 return Err(Error::invalid(
832 "convert: no output file given (usage: convert [-op VALUE]... INPUT [-op VALUE]... OUTPUT)",
833 ));
834 }
835 if positionals.len() > 2 {
836 return Err(Error::unsupported(format!(
837 "convert: {} positional arguments given but multi-input is not yet supported (round-2 follow-up); pass exactly INPUT OUTPUT",
838 positionals.len()
839 )));
840 }
841 if probe_json && !probe {
845 return Err(Error::invalid(
846 "convert: --json requires --probe (today --json only formats the probe summary)",
847 ));
848 }
849 if probe_watch && !probe {
853 return Err(Error::invalid(
854 "convert: --watch requires --probe (today --watch only re-runs the probe on input mtime change)",
855 ));
856 }
857
858 let (raw_input, input_pages) = split_input_selector(&positionals[0])?;
859 let input = translate_input_shorthand(raw_input);
860 let output = positionals.get(1).cloned().unwrap_or_default();
861 let output_template = if output.is_empty() {
862 None
863 } else {
864 parse_printf_template(&output)?
865 };
866
867 Ok(ConvertPlan {
868 input,
869 input_pages,
870 ops,
871 output,
872 output_template,
873 help: false,
874 ping,
875 probe,
876 probe_json,
877 probe_watch,
878 mesh3d_options,
879 })
880}
881
882pub fn usage() -> String {
889 let mut s = String::new();
890 s.push_str("usage: oxideav convert [-op VALUE]... INPUT [-op VALUE]... OUTPUT\n");
891 s.push_str(" oxideav convert --probe [--json] [--watch] INPUT\n");
892 s.push_str(" oxideav convert -ping INPUT\n");
893 s.push_str(" oxideav convert --help\n");
894 s.push('\n');
895 s.push_str("Ops may appear before AND after the input; the first non-flag\n");
896 s.push_str("positional is the input, the last is the output. `--` ends flag\n");
897 s.push_str("parsing so filenames may start with `-`. PDF inputs accept a\n");
898 s.push_str("[N] / [N-M] / [A,B,...] page-selector suffix; `%d` output\n");
899 s.push_str("templates fan out one file per selected page.\n");
900 s.push('\n');
901 s.push_str("Recognised flags (see the crate README for per-flag grammar):\n");
902 for chunk in KNOWN_FLAG_NAMES.chunks(5) {
903 s.push(' ');
904 for name in chunk {
905 let lead = if matches!(*name, "probe" | "json" | "watch" | "help" | "opt") {
908 "--"
909 } else {
910 "-"
911 };
912 s.push(' ');
913 s.push_str(lead);
914 s.push_str(name);
915 }
916 s.push('\n');
917 }
918 s
919}
920
921pub(crate) fn split_input_selector(s: &str) -> Result<(&str, Option<PageSelector>), Error> {
937 if !s.ends_with(']') {
938 return Ok((s, None));
939 }
940 let open = match s.rfind('[') {
941 Some(i) => i,
942 None => {
943 return Err(Error::invalid(format!(
944 "convert: input '{s}' has a closing `]` with no matching `[`"
945 )));
946 }
947 };
948 let body = &s[open + 1..s.len() - 1];
949 if body.is_empty() {
950 return Err(Error::invalid(format!(
951 "convert: input '{s}' has an empty `[]` page selector"
952 )));
953 }
954 let sel = parse_page_selector_body(s, body)?;
955 Ok((&s[..open], Some(sel)))
956}
957
958fn parse_page_selector_body(input: &str, body: &str) -> Result<PageSelector, Error> {
963 let pieces: Vec<&str> = body.split(',').collect();
966 let mut atoms: Vec<PageAtom> = Vec::with_capacity(pieces.len());
967 for piece in &pieces {
968 let trimmed = piece.trim();
969 if trimmed.is_empty() {
970 return Err(Error::invalid(format!(
971 "convert: input '{input}': page selector '{body}' has an empty atom (between commas)"
972 )));
973 }
974 atoms.push(parse_page_atom(input, trimmed)?);
975 }
976 Ok(if atoms.len() == 1 {
977 match atoms.into_iter().next().unwrap() {
981 PageAtom::Single(n) => PageSelector::Single(n),
982 PageAtom::Range(a, b) => PageSelector::Range(a, b),
983 }
984 } else {
985 PageSelector::List(atoms)
986 })
987}
988
989fn parse_page_atom(input: &str, atom: &str) -> Result<PageAtom, Error> {
999 let (first_sign, rest) = if let Some(rest) = atom.strip_prefix('-') {
1001 ("-", rest)
1002 } else {
1003 ("", atom)
1004 };
1005 if rest.is_empty() {
1006 return Err(Error::invalid(format!(
1007 "convert: input '{input}': page selector atom '{atom}' is just a sign with no digits"
1008 )));
1009 }
1010 match rest.find('-') {
1013 None => {
1014 let n: isize = format!("{first_sign}{rest}").parse().map_err(|_| {
1016 Error::invalid(format!(
1017 "convert: input '{input}': page selector atom '{atom}' is not an integer page index"
1018 ))
1019 })?;
1020 Ok(PageAtom::Single(n))
1021 }
1022 Some(sep) => {
1023 let left = format!("{first_sign}{}", &rest[..sep]);
1025 let right_raw = &rest[sep + 1..];
1026 if left.is_empty() || left == "-" {
1027 return Err(Error::invalid(format!(
1028 "convert: input '{input}': page selector atom '{atom}' has no left endpoint"
1029 )));
1030 }
1031 if right_raw.is_empty() {
1032 return Err(Error::invalid(format!(
1033 "convert: input '{input}': page selector atom '{atom}' has no right endpoint"
1034 )));
1035 }
1036 let a: isize = left.parse().map_err(|_| {
1041 Error::invalid(format!(
1042 "convert: input '{input}': page selector atom '{atom}': left endpoint '{left}' is not an integer"
1043 ))
1044 })?;
1045 let b: isize = right_raw.parse().map_err(|_| {
1046 Error::invalid(format!(
1047 "convert: input '{input}': page selector atom '{atom}': right endpoint '{right_raw}' is not an integer (expected `N-M`, `-N-M`, or `N--M`)"
1048 ))
1049 })?;
1050 Ok(PageAtom::Range(a, b))
1051 }
1052 }
1053}
1054
1055pub(crate) fn parse_printf_template(s: &str) -> Result<Option<PrintfTemplate>, Error> {
1064 let bytes = s.as_bytes();
1065 let mut i = 0usize;
1066 let mut found: Option<(usize, u8, usize)> = None; while i < bytes.len() {
1068 if bytes[i] != b'%' {
1069 i += 1;
1070 continue;
1071 }
1072 let start = i;
1073 i += 1;
1074 let mut width: u8 = 0;
1075 while i < bytes.len() && bytes[i].is_ascii_digit() {
1076 let d = (bytes[i] - b'0') as u32;
1078 let next = (width as u32) * 10 + d;
1079 if next > 20 {
1080 return Err(Error::invalid(format!(
1081 "convert: output template '{s}' has an unreasonable width specifier"
1082 )));
1083 }
1084 width = next as u8;
1085 i += 1;
1086 }
1087 if i >= bytes.len() {
1088 return Err(Error::invalid(format!(
1089 "convert: output template '{s}' has a `%` with no format specifier"
1090 )));
1091 }
1092 match bytes[i] {
1093 b'd' => {
1094 if found.is_some() {
1095 return Err(Error::invalid(format!(
1096 "convert: output template '{s}' has more than one `%d` token (expected exactly one)"
1097 )));
1098 }
1099 found = Some((start, width, i + 1));
1100 i += 1;
1101 }
1102 other => {
1103 return Err(Error::invalid(format!(
1104 "convert: output template '{s}' uses unsupported format specifier '%{}' (only `%d`, `%03d`, etc. are accepted)",
1105 other as char
1106 )));
1107 }
1108 }
1109 }
1110 Ok(found.map(|(start, width, end)| PrintfTemplate {
1111 prefix: s[..start].to_string(),
1112 width,
1113 suffix: s[end..].to_string(),
1114 }))
1115}
1116
1117fn parse_color(s: &str) -> Result<[u8; 4], String> {
1122 let s = s.trim();
1123 if let Some(hex) = s.strip_prefix('#') {
1124 return parse_hex_color(hex);
1125 }
1126 match s.to_ascii_lowercase().as_str() {
1127 "transparent" | "none" => Ok([0, 0, 0, 0]),
1128 "black" => Ok([0, 0, 0, 255]),
1129 "white" => Ok([255, 255, 255, 255]),
1130 "red" => Ok([255, 0, 0, 255]),
1131 "green" => Ok([0, 128, 0, 255]),
1132 "lime" => Ok([0, 255, 0, 255]),
1133 "blue" => Ok([0, 0, 255, 255]),
1134 "yellow" => Ok([255, 255, 0, 255]),
1135 "cyan" | "aqua" => Ok([0, 255, 255, 255]),
1136 "magenta" | "fuchsia" => Ok([255, 0, 255, 255]),
1137 "gray" | "grey" => Ok([128, 128, 128, 255]),
1138 "silver" => Ok([192, 192, 192, 255]),
1139 "maroon" => Ok([128, 0, 0, 255]),
1140 "olive" => Ok([128, 128, 0, 255]),
1141 "purple" => Ok([128, 0, 128, 255]),
1142 "teal" => Ok([0, 128, 128, 255]),
1143 "navy" => Ok([0, 0, 128, 255]),
1144 "orange" => Ok([255, 165, 0, 255]),
1145 other => Err(format!(
1146 "convert: unknown colour '{other}' (try a `#hex` value or one of: black/white/red/green/blue/yellow/cyan/magenta/gray/transparent/…)"
1147 )),
1148 }
1149}
1150
1151fn parse_hex_color(hex: &str) -> Result<[u8; 4], String> {
1152 fn hex_digit(c: u8) -> Result<u8, String> {
1153 match c {
1154 b'0'..=b'9' => Ok(c - b'0'),
1155 b'a'..=b'f' => Ok(c - b'a' + 10),
1156 b'A'..=b'F' => Ok(c - b'A' + 10),
1157 _ => Err(format!("'{}' is not a hex digit", c as char)),
1158 }
1159 }
1160 let h = hex.as_bytes();
1161 let pair = |a: u8, b: u8| -> Result<u8, String> { Ok(hex_digit(a)? * 16 + hex_digit(b)?) };
1162 let dup = |a: u8| -> Result<u8, String> {
1163 let d = hex_digit(a)?;
1164 Ok(d * 16 + d)
1165 };
1166 match h.len() {
1167 3 => Ok([dup(h[0])?, dup(h[1])?, dup(h[2])?, 255]),
1168 4 => Ok([dup(h[0])?, dup(h[1])?, dup(h[2])?, dup(h[3])?]),
1169 6 => Ok([pair(h[0], h[1])?, pair(h[2], h[3])?, pair(h[4], h[5])?, 255]),
1170 8 => Ok([
1171 pair(h[0], h[1])?,
1172 pair(h[2], h[3])?,
1173 pair(h[4], h[5])?,
1174 pair(h[6], h[7])?,
1175 ]),
1176 _ => Err(format!(
1177 "'#{hex}' is not a valid hex colour (expected 3/4/6/8 hex digits)"
1178 )),
1179 }
1180}
1181
1182#[cfg(feature = "generator")]
1188fn translate_input_shorthand(input: &str) -> String {
1189 oxideav_generator::shorthand::translate(input)
1190}
1191
1192#[cfg(not(feature = "generator"))]
1193fn translate_input_shorthand(input: &str) -> String {
1194 input.to_string()
1195}
1196
1197fn parse_resize_geometry(s: &str, mode: ResizeMode) -> Result<(u32, u32), String> {
1212 if matches!(mode, ResizeMode::Percent) {
1213 let s = s.trim();
1215 let (w_str, h_str) = match s.split_once(['x', 'X']) {
1216 None => (s, s), Some((w, h)) => {
1218 let w = if w.is_empty() { h } else { w };
1219 let h = if h.is_empty() { w } else { h };
1220 (w, h)
1221 }
1222 };
1223 let w: u32 = w_str
1224 .parse()
1225 .map_err(|_| format!("'{w_str}' is not a non-negative integer percent"))?;
1226 let h: u32 = h_str
1227 .parse()
1228 .map_err(|_| format!("'{h_str}' is not a non-negative integer percent"))?;
1229 if w == 0 || h == 0 {
1230 return Err("percent values must both be > 0".into());
1231 }
1232 return Ok((w, h));
1233 }
1234 parse_wxh(s)
1235}
1236
1237fn parse_wxh(s: &str) -> Result<(u32, u32), String> {
1240 let (w, h) = s
1241 .split_once(['x', 'X'])
1242 .ok_or_else(|| format!("'{s}' is not in WxH form"))?;
1243 let w: u32 = w
1244 .parse()
1245 .map_err(|_| format!("'{w}' is not a non-negative integer"))?;
1246 let h: u32 = h
1247 .parse()
1248 .map_err(|_| format!("'{h}' is not a non-negative integer"))?;
1249 if w == 0 || h == 0 {
1250 return Err("width and height must both be positive".into());
1251 }
1252 Ok((w, h))
1253}
1254
1255fn parse_crop(s: &str) -> Result<(u32, u32, u32, u32), String> {
1262 for mark in ['%', '!', '^', '<', '>', '@'] {
1265 if s.contains(mark) {
1266 return Err(format!(
1267 "'{s}' uses IM modifier '{mark}' (round-1 supports plain WxH+X+Y only)"
1268 ));
1269 }
1270 }
1271 let mut parts = s.split('+');
1274 let wh = parts
1275 .next()
1276 .ok_or_else(|| format!("'{s}' is missing the WxH component"))?;
1277 let x_str = parts
1278 .next()
1279 .ok_or_else(|| format!("'{s}' is missing the +X offset (expected WxH+X+Y)"))?;
1280 let y_str = parts
1281 .next()
1282 .ok_or_else(|| format!("'{s}' is missing the +Y offset (expected WxH+X+Y)"))?;
1283 if parts.next().is_some() {
1284 return Err(format!("'{s}' has more than two `+` separators"));
1285 }
1286 let (w, h) = parse_wxh(wh)?;
1287 let x: u32 = x_str
1288 .parse()
1289 .map_err(|_| format!("'{x_str}' is not a non-negative integer X offset"))?;
1290 let y: u32 = y_str
1291 .parse()
1292 .map_err(|_| format!("'{y_str}' is not a non-negative integer Y offset"))?;
1293 Ok((w, h, x, y))
1294}
1295
1296fn parse_extent(s: &str) -> Result<(u32, u32, i32, i32), String> {
1310 for mark in ['%', '!', '^', '<', '>', '@'] {
1311 if s.contains(mark) {
1312 return Err(format!(
1313 "'{s}' uses IM modifier '{mark}' (-extent currently supports plain WxH[±X±Y] only)"
1314 ));
1315 }
1316 }
1317 let head_end = s
1324 .char_indices()
1325 .skip(1)
1326 .find(|(_, c)| *c == '+' || *c == '-')
1327 .map(|(i, _)| i)
1328 .unwrap_or(s.len());
1329 let head = &s[..head_end];
1330 let tail = &s[head_end..];
1331 let (w, h) = parse_wxh(head)?;
1332 let (x, y) = parse_extent_offsets(tail)?;
1333 Ok((w, h, x, y))
1334}
1335
1336fn parse_extent_offsets(s: &str) -> Result<(i32, i32), String> {
1341 if s.is_empty() {
1342 return Ok((0, 0));
1343 }
1344 let bytes = s.as_bytes();
1348 if bytes[0] != b'+' && bytes[0] != b'-' {
1349 return Err(format!(
1350 "'{s}' offset section must start with `+` or `-` (expected WxH+X+Y, WxH-X-Y, or mixed)"
1351 ));
1352 }
1353 let mut split = None;
1354 for (i, b) in bytes.iter().enumerate().skip(1) {
1355 if *b == b'+' || *b == b'-' {
1356 split = Some(i);
1357 break;
1358 }
1359 }
1360 let split = split
1361 .ok_or_else(|| format!("'{s}' has a single offset; expected both X and Y (WxH±X±Y)"))?;
1362 let x_str = &s[..split];
1363 let y_str = &s[split..];
1364 if y_str[1..].contains('+') || y_str[1..].contains('-') {
1366 return Err(format!("'{s}' has more than two signed offsets"));
1367 }
1368 let x: i32 = x_str
1369 .parse()
1370 .map_err(|_| format!("'{x_str}' is not a signed integer X offset"))?;
1371 let y: i32 = y_str
1372 .parse()
1373 .map_err(|_| format!("'{y_str}' is not a signed integer Y offset"))?;
1374 Ok((x, y))
1375}
1376
1377fn parse_roll(s: &str) -> Result<(i32, i32), String> {
1385 if s.is_empty() {
1386 return Err("empty offset (expected ±X±Y, e.g. +5+10)".to_string());
1387 }
1388 parse_extent_offsets(s)
1389}
1390
1391fn parse_blur(s: &str) -> Result<(u32, f32), String> {
1395 let (radius_str, sigma_str) = match s.split_once(['x', 'X']) {
1396 Some((r, s)) => (r, Some(s)),
1397 None => (s, None),
1398 };
1399 let radius: u32 = radius_str
1400 .parse()
1401 .map_err(|_| format!("'{radius_str}' is not a non-negative integer"))?;
1402 let sigma: f32 = match sigma_str {
1403 Some(s) => s
1404 .parse()
1405 .map_err(|_| format!("'{s}' is not a non-negative float"))?,
1406 None => (radius as f32) / 2.0,
1407 };
1408 Ok((radius, sigma))
1409}
1410
1411fn parse_unsharp(s: &str) -> Result<(u32, f32, f32, u8), String> {
1417 let mut parts = s.splitn(3, '+');
1420 let head = parts.next().unwrap_or(s);
1421 let amount_str = parts.next();
1422 let threshold_str = parts.next();
1423 let (radius, sigma) = parse_blur(head)?;
1424 let amount: f32 = match amount_str {
1425 Some(a) => a
1426 .parse()
1427 .map_err(|_| format!("'{a}' is not a finite float"))?,
1428 None => 1.0,
1429 };
1430 let threshold: u8 = match threshold_str {
1431 Some(t) => parse_threshold_pct(t)?,
1434 None => 0,
1435 };
1436 Ok((radius, sigma, amount, threshold))
1437}
1438
1439fn parse_brightness_contrast(s: &str) -> Result<(f32, f32), String> {
1443 let (bs, cs) = match s.split_once(['x', 'X', ',']) {
1444 Some((a, b)) => (a, Some(b)),
1445 None => (s, None),
1446 };
1447 let b: f32 = bs
1448 .parse()
1449 .map_err(|_| format!("'{bs}' is not a finite float"))?;
1450 let c: f32 = match cs {
1451 Some(s) if !s.is_empty() => s
1452 .parse()
1453 .map_err(|_| format!("'{s}' is not a finite float"))?,
1454 _ => 0.0,
1455 };
1456 if !(-100.0..=100.0).contains(&b) {
1457 return Err(format!("brightness {b} out of range (-100..=100)"));
1458 }
1459 if !(-100.0..=100.0).contains(&c) {
1460 return Err(format!("contrast {c} out of range (-100..=100)"));
1461 }
1462 Ok((b, c))
1463}
1464
1465fn parse_percent_or_unit(s: &str) -> Result<f32, String> {
1468 let s = s.trim();
1469 if let Some(p) = s.strip_suffix('%') {
1470 let v: f32 = p
1471 .parse()
1472 .map_err(|_| format!("'{p}' is not a finite float"))?;
1473 if !(0.0..=100.0).contains(&v) {
1474 return Err(format!("'{s}' is out of range (0%..=100%)"));
1475 }
1476 return Ok(v / 100.0);
1477 }
1478 let v: f32 = s
1479 .parse()
1480 .map_err(|_| format!("'{s}' is not a finite float"))?;
1481 if !(0.0..=1.0).contains(&v) {
1482 return Err(format!("'{s}' is out of range (0.0..=1.0)"));
1483 }
1484 Ok(v)
1485}
1486
1487fn parse_modulate(s: &str) -> Result<(f32, f32, f32), String> {
1490 let mut parts = s.split(',').map(|p| p.trim());
1491 let b = parts
1492 .next()
1493 .ok_or_else(|| format!("'{s}' is missing the brightness value"))?;
1494 let s_field = parts.next();
1495 let h_field = parts.next();
1496 if parts.next().is_some() {
1497 return Err(format!(
1498 "'{s}' has more than 3 components (expected B[,S[,H]])"
1499 ));
1500 }
1501 let bv: f32 = b
1502 .parse()
1503 .map_err(|_| format!("'{b}' is not a finite float"))?;
1504 let sv: f32 = match s_field {
1505 Some(t) if !t.is_empty() => t
1506 .parse()
1507 .map_err(|_| format!("'{t}' is not a finite float"))?,
1508 _ => 100.0,
1509 };
1510 let hv: f32 = match h_field {
1511 Some(t) if !t.is_empty() => t
1512 .parse()
1513 .map_err(|_| format!("'{t}' is not a finite float"))?,
1514 _ => 100.0,
1515 };
1516 Ok((bv, sv, hv))
1517}
1518
1519fn parse_level(s: &str) -> Result<(u8, f32, u8), String> {
1523 let parts: Vec<&str> = s.split(['/', ',']).map(|p| p.trim()).collect();
1524 if parts.is_empty() || parts.len() > 3 {
1525 return Err(format!("'{s}' is not in B[/G[/W]] form (1..=3 components)"));
1526 }
1527 let black = parse_byte_or_percent(parts[0])?;
1528 let gamma: f32 = if parts.len() >= 2 && !parts[1].is_empty() {
1529 let g: f32 = parts[1]
1530 .parse()
1531 .map_err(|_| format!("'{}' is not a finite float", parts[1]))?;
1532 if !g.is_finite() || g <= 0.0 {
1533 return Err(format!("gamma {g} must be > 0"));
1534 }
1535 g
1536 } else {
1537 1.0
1538 };
1539 let white = if parts.len() == 3 && !parts[2].is_empty() {
1540 parse_byte_or_percent(parts[2])?
1541 } else {
1542 255
1543 };
1544 if black > white {
1545 return Err(format!(
1546 "black point ({black}) must be <= white point ({white})"
1547 ));
1548 }
1549 Ok((black, gamma, white))
1550}
1551
1552fn parse_byte_or_percent(s: &str) -> Result<u8, String> {
1554 if let Some(p) = s.strip_suffix('%') {
1555 let v: f32 = p
1556 .parse()
1557 .map_err(|_| format!("'{p}' is not a finite float"))?;
1558 if !(0.0..=100.0).contains(&v) {
1559 return Err(format!("'{s}' is out of range (0%..=100%)"));
1560 }
1561 return Ok((v * 2.55).round().clamp(0.0, 255.0) as u8);
1562 }
1563 let n: u32 = s
1564 .parse()
1565 .map_err(|_| format!("'{s}' is not a non-negative integer"))?;
1566 if n > 255 {
1567 return Err(format!("'{s}' is out of range (0..=255)"));
1568 }
1569 Ok(n as u8)
1570}
1571
1572fn parse_threshold_pct(s: &str) -> Result<u8, String> {
1575 parse_byte_or_percent(s.trim())
1576}
1577
1578fn parse_vignette(s: &str) -> Result<(f32, f32, f32, f32), String> {
1590 let mut parts = s.split('+');
1591 let r_str = parts
1592 .next()
1593 .ok_or_else(|| format!("'{s}' is missing the radius component"))?;
1594 let radius: f32 = r_str
1595 .parse()
1596 .map_err(|_| format!("'{r_str}' is not a finite float radius"))?;
1597 if !radius.is_finite() || radius < 0.0 {
1598 return Err(format!("radius {radius} must be >= 0"));
1599 }
1600 let s_part = parts.next();
1601 let sigma: f32 = match s_part {
1602 Some(t) if !t.is_empty() => t
1603 .parse()
1604 .map_err(|_| format!("'{t}' is not a finite float sigma"))?,
1605 _ => radius / 2.0,
1606 };
1607 if !sigma.is_finite() || sigma < 0.0 {
1608 return Err(format!("sigma {sigma} must be >= 0"));
1609 }
1610 let x: f32 = match parts.next() {
1611 Some(t) if !t.is_empty() => t
1612 .parse()
1613 .map_err(|_| format!("'{t}' is not a finite float x"))?,
1614 _ => 0.5,
1615 };
1616 let y: f32 = match parts.next() {
1617 Some(t) if !t.is_empty() => t
1618 .parse()
1619 .map_err(|_| format!("'{t}' is not a finite float y"))?,
1620 _ => 0.5,
1621 };
1622 if parts.next().is_some() {
1623 return Err(format!("'{s}' has more than 4 components"));
1624 }
1625 if !x.is_finite() || !y.is_finite() {
1626 return Err(format!("x/y must be finite (got x={x}, y={y})"));
1627 }
1628 Ok((radius, sigma, x, y))
1629}
1630
1631fn parse_colorize(s: &str) -> Result<([u8; 4], f32), String> {
1640 let s = s.trim();
1641 let (color_part, amount_part) = match s.split_once('/') {
1642 Some((c, a)) => (c, a),
1643 None => ("", s),
1646 };
1647 let color = if color_part.is_empty() {
1648 [255, 255, 255, 255]
1649 } else if color_part.contains('x') || color_part.contains('X') {
1650 let mut parts = color_part.split(['x', 'X']);
1652 let r_str = parts
1653 .next()
1654 .ok_or_else(|| format!("'{color_part}' is missing the R component"))?;
1655 let r: u32 = r_str
1656 .parse()
1657 .map_err(|_| format!("'{r_str}' is not a non-negative integer"))?;
1658 if r > 255 {
1659 return Err(format!("R component {r} out of range (0..=255)"));
1660 }
1661 let g: u32 = match parts.next() {
1662 Some(t) if !t.is_empty() => t
1663 .parse()
1664 .map_err(|_| format!("'{t}' is not a non-negative integer"))?,
1665 _ => r,
1666 };
1667 if g > 255 {
1668 return Err(format!("G component {g} out of range (0..=255)"));
1669 }
1670 let b: u32 = match parts.next() {
1671 Some(t) if !t.is_empty() => t
1672 .parse()
1673 .map_err(|_| format!("'{t}' is not a non-negative integer"))?,
1674 _ => g,
1675 };
1676 if b > 255 {
1677 return Err(format!("B component {b} out of range (0..=255)"));
1678 }
1679 if parts.next().is_some() {
1680 return Err(format!(
1681 "'{color_part}' has more than 3 components (expected R[xG[xB]])"
1682 ));
1683 }
1684 [r as u8, g as u8, b as u8, 255]
1685 } else {
1686 parse_color(color_part)?
1688 };
1689 let amount = parse_percent_or_unit(amount_part)?;
1690 Ok((color, amount))
1691}
1692
1693#[cfg(test)]
1694mod tests {
1695 use super::*;
1696
1697 fn to_vec(s: &[&str]) -> Vec<String> {
1698 s.iter().map(|x| x.to_string()).collect()
1699 }
1700
1701 #[test]
1702 fn minimal_input_output() {
1703 let p = parse(&to_vec(&["in.png", "out.jpg"])).unwrap();
1704 assert_eq!(p.input, "in.png");
1705 assert_eq!(p.output, "out.jpg");
1706 assert!(p.ops.is_empty());
1707 }
1708
1709 #[test]
1710 fn resize_bilinear_basic() {
1711 let p = parse(&to_vec(&["a.png", "-resize", "800x600", "b.jpg"])).unwrap();
1712 assert_eq!(
1713 p.ops,
1714 vec![Op::Resize {
1715 width: 800,
1716 height: 600,
1717 mode: ResizeMode::Default,
1718 }]
1719 );
1720 }
1721
1722 #[test]
1723 fn resize_bang_flag() {
1724 let p = parse(&to_vec(&["a.png", "-resize", "64x32!", "b.jpg"])).unwrap();
1725 assert_eq!(
1726 p.ops,
1727 vec![Op::Resize {
1728 width: 64,
1729 height: 32,
1730 mode: ResizeMode::Force,
1731 }]
1732 );
1733 }
1734
1735 #[test]
1736 fn resize_fill_caret_suffix() {
1737 let p = parse(&to_vec(&["a.png", "-resize", "200x100^", "b.jpg"])).unwrap();
1738 assert_eq!(
1739 p.ops,
1740 vec![Op::Resize {
1741 width: 200,
1742 height: 100,
1743 mode: ResizeMode::Fill,
1744 }]
1745 );
1746 }
1747
1748 #[test]
1749 fn resize_shrink_only_suffix() {
1750 let p = parse(&to_vec(&["a.png", "-resize", "100x100>", "b.jpg"])).unwrap();
1751 assert_eq!(
1752 p.ops,
1753 vec![Op::Resize {
1754 width: 100,
1755 height: 100,
1756 mode: ResizeMode::Shrink,
1757 }]
1758 );
1759 }
1760
1761 #[test]
1762 fn resize_grow_only_suffix() {
1763 let p = parse(&to_vec(&["a.png", "-resize", "1024x1024<", "b.jpg"])).unwrap();
1764 assert_eq!(
1765 p.ops,
1766 vec![Op::Resize {
1767 width: 1024,
1768 height: 1024,
1769 mode: ResizeMode::Grow,
1770 }]
1771 );
1772 }
1773
1774 #[test]
1775 fn resize_percent_two_axis() {
1776 let p = parse(&to_vec(&["a.png", "-resize", "50x200%", "b.jpg"])).unwrap();
1777 assert_eq!(
1778 p.ops,
1779 vec![Op::Resize {
1780 width: 50,
1781 height: 200,
1782 mode: ResizeMode::Percent,
1783 }]
1784 );
1785 }
1786
1787 #[test]
1788 fn resize_percent_single_value_replicates() {
1789 let p = parse(&to_vec(&["a.png", "-resize", "75%", "b.jpg"])).unwrap();
1791 assert_eq!(
1792 p.ops,
1793 vec![Op::Resize {
1794 width: 75,
1795 height: 75,
1796 mode: ResizeMode::Percent,
1797 }]
1798 );
1799 }
1800
1801 #[test]
1802 fn resize_area_at_suffix() {
1803 let p = parse(&to_vec(&["a.png", "-resize", "640x480@", "b.jpg"])).unwrap();
1804 assert_eq!(
1805 p.ops,
1806 vec![Op::Resize {
1807 width: 640,
1808 height: 480,
1809 mode: ResizeMode::Area,
1810 }]
1811 );
1812 }
1813
1814 #[test]
1815 fn thumbnail_with_geometry_modifier() {
1816 let p = parse(&to_vec(&["a.png", "-thumbnail", "128x128^", "b.jpg"])).unwrap();
1817 assert_eq!(
1818 p.ops,
1819 vec![Op::Thumbnail {
1820 width: 128,
1821 height: 128,
1822 mode: ResizeMode::Fill,
1823 }]
1824 );
1825 }
1826
1827 #[test]
1828 fn thumbnail_default_mode() {
1829 let p = parse(&to_vec(&["a.png", "-thumbnail", "100x100", "b.jpg"])).unwrap();
1830 assert_eq!(
1831 p.ops,
1832 vec![Op::Thumbnail {
1833 width: 100,
1834 height: 100,
1835 mode: ResizeMode::Default,
1836 }]
1837 );
1838 }
1839
1840 #[test]
1841 fn define_with_value_round_trips_namespaced_key() {
1842 let p = parse(&to_vec(&[
1843 "a.png",
1844 "-define",
1845 "jpeg:dct-method=float",
1846 "b.jpg",
1847 ]))
1848 .unwrap();
1849 assert_eq!(
1850 p.ops,
1851 vec![Op::Define {
1852 key: "jpeg:dct-method".into(),
1853 value: Some("float".into()),
1854 }]
1855 );
1856 }
1857
1858 #[test]
1859 fn define_bare_key_no_value() {
1860 let p = parse(&to_vec(&[
1862 "a.png",
1863 "-define",
1864 "png:strip-comments",
1865 "b.png",
1866 ]))
1867 .unwrap();
1868 assert_eq!(
1869 p.ops,
1870 vec![Op::Define {
1871 key: "png:strip-comments".into(),
1872 value: None,
1873 }]
1874 );
1875 }
1876
1877 #[test]
1878 fn define_empty_key_rejected() {
1879 let err = parse(&to_vec(&["a.png", "-define", "=value", "b.png"])).unwrap_err();
1880 assert!(
1881 format!("{err:?}").contains("empty KEY"),
1882 "unexpected error: {err:?}"
1883 );
1884 }
1885
1886 #[test]
1887 fn blur_sigma_defaults_to_half_radius() {
1888 let p = parse(&to_vec(&["a.png", "-blur", "4", "b.jpg"])).unwrap();
1889 assert_eq!(
1890 p.ops,
1891 vec![Op::Blur {
1892 radius: 4,
1893 sigma: 2.0
1894 }]
1895 );
1896 }
1897
1898 #[test]
1899 fn blur_explicit_sigma() {
1900 let p = parse(&to_vec(&["a.png", "-blur", "3x1.5", "b.jpg"])).unwrap();
1901 match p.ops.as_slice() {
1902 [Op::Blur { radius: 3, sigma }] => assert!((sigma - 1.5).abs() < 1e-6),
1903 other => panic!("unexpected ops: {other:?}"),
1904 }
1905 }
1906
1907 #[test]
1908 fn colors_picks_up_prior_dither() {
1909 let p = parse(&to_vec(&[
1910 "a.png",
1911 "-dither",
1912 "floyd_steinberg",
1913 "-colors",
1914 "64",
1915 "b.gif",
1916 ]))
1917 .unwrap();
1918 assert_eq!(
1919 p.ops,
1920 vec![Op::Colors {
1921 count: 64,
1922 dither: Dither::FloydSteinberg
1923 }]
1924 );
1925 }
1926
1927 #[test]
1937 fn monochrome_expands_to_gray_2_colors_floyd_steinberg() {
1938 let p = parse(&to_vec(&["a.png", "-monochrome", "b.png"])).unwrap();
1939 assert_eq!(
1940 p.ops,
1941 vec![
1942 Op::Colorspace("gray".to_string()),
1943 Op::Colors {
1944 count: 2,
1945 dither: Dither::FloydSteinberg
1946 },
1947 ]
1948 );
1949 }
1950
1951 #[test]
1955 fn monochrome_is_valueless() {
1956 let p = parse(&to_vec(&["a.png", "-monochrome", "b.png"])).unwrap();
1957 assert_eq!(p.input, "a.png");
1958 assert_eq!(p.output, "b.png");
1959 }
1960
1961 #[test]
1967 fn monochrome_ignores_prior_pending_dither() {
1968 let p = parse(&to_vec(&[
1969 "a.png",
1970 "-dither",
1971 "none",
1972 "-monochrome",
1973 "b.png",
1974 ]))
1975 .unwrap();
1976 assert_eq!(
1977 p.ops,
1978 vec![
1979 Op::Colorspace("gray".to_string()),
1980 Op::Colors {
1981 count: 2,
1982 dither: Dither::FloydSteinberg
1983 },
1984 ]
1985 );
1986 }
1987
1988 #[test]
1994 fn monochrome_keeps_source_order_with_neighbours() {
1995 let p = parse(&to_vec(&[
1996 "a.png",
1997 "-resize",
1998 "100x100",
1999 "-monochrome",
2000 "-strip",
2001 "b.png",
2002 ]))
2003 .unwrap();
2004 assert_eq!(
2005 p.ops,
2006 vec![
2007 Op::Resize {
2008 width: 100,
2009 height: 100,
2010 mode: ResizeMode::Default,
2011 },
2012 Op::Colorspace("gray".to_string()),
2013 Op::Colors {
2014 count: 2,
2015 dither: Dither::FloydSteinberg
2016 },
2017 Op::Strip,
2018 ]
2019 );
2020 }
2021
2022 #[test]
2023 fn rotate_90_parses() {
2024 let p = parse(&to_vec(&["a.png", "-rotate", "90", "b.png"])).unwrap();
2025 assert_eq!(p.ops, vec![Op::Rotate { degrees: 90 }]);
2026 }
2027
2028 #[test]
2029 fn rotate_negative_270_parses() {
2030 let p = parse(&to_vec(&["a.png", "-rotate", "-270", "b.png"])).unwrap();
2031 assert_eq!(p.ops, vec![Op::Rotate { degrees: -270 }]);
2032 }
2033
2034 #[test]
2035 fn rotate_45_rejected() {
2036 let err = parse(&to_vec(&["a.png", "-rotate", "45", "b.png"])).unwrap_err();
2037 let msg = format!("{err:?}");
2038 assert!(
2039 msg.contains("only multiples of 90 supported"),
2040 "unexpected message: {msg}"
2041 );
2042 assert!(msg.contains("got 45"));
2043 }
2044
2045 #[test]
2046 fn rotate_non_integer_rejected() {
2047 let err = parse(&to_vec(&["a.png", "-rotate", "abc", "b.png"])).unwrap_err();
2048 assert!(format!("{err:?}").contains("not an integer"));
2049 }
2050
2051 #[test]
2052 fn flip_and_flop_are_valueless() {
2053 let p = parse(&to_vec(&["a.png", "-flip", "-flop", "b.png"])).unwrap();
2054 assert_eq!(p.ops, vec![Op::Flip, Op::Flop]);
2055 }
2056
2057 #[test]
2058 fn negate_is_valueless() {
2059 let p = parse(&to_vec(&["a.png", "-negate", "b.png"])).unwrap();
2060 assert_eq!(p.ops, vec![Op::Negate]);
2061 }
2062
2063 #[test]
2064 fn crop_basic() {
2065 let p = parse(&to_vec(&["a.png", "-crop", "10x20+3+4", "b.png"])).unwrap();
2066 assert_eq!(
2067 p.ops,
2068 vec![Op::Crop {
2069 x: 3,
2070 y: 4,
2071 w: 10,
2072 h: 20
2073 }]
2074 );
2075 }
2076
2077 #[test]
2078 fn crop_zero_offset() {
2079 let p = parse(&to_vec(&["a.png", "-crop", "32x32+0+0", "b.png"])).unwrap();
2080 assert_eq!(
2081 p.ops,
2082 vec![Op::Crop {
2083 x: 0,
2084 y: 0,
2085 w: 32,
2086 h: 32
2087 }]
2088 );
2089 }
2090
2091 #[test]
2092 fn crop_missing_offset_rejected() {
2093 let err = parse(&to_vec(&["a.png", "-crop", "32x32", "b.png"])).unwrap_err();
2094 assert!(format!("{err:?}").contains("missing the +X offset"));
2095 }
2096
2097 #[test]
2098 fn crop_im_modifier_rejected() {
2099 let err = parse(&to_vec(&["a.png", "-crop", "50%+0+0", "b.png"])).unwrap_err();
2100 assert!(format!("{err:?}").contains("IM modifier"));
2101 }
2102
2103 #[test]
2109 fn extent_default_offset_and_background() {
2110 let p = parse(&to_vec(&["a.png", "-extent", "100x80", "b.png"])).unwrap();
2111 assert_eq!(
2112 p.ops,
2113 vec![Op::Extent {
2114 width: 100,
2115 height: 80,
2116 x: 0,
2117 y: 0,
2118 bg: [255, 255, 255, 255],
2119 }]
2120 );
2121 }
2122
2123 #[test]
2125 fn extent_positive_offsets() {
2126 let p = parse(&to_vec(&["a.png", "-extent", "120x90+10+5", "b.png"])).unwrap();
2127 match p.ops.as_slice() {
2128 [Op::Extent {
2129 width: 120,
2130 height: 90,
2131 x: 10,
2132 y: 5,
2133 bg: _,
2134 }] => {}
2135 other => panic!("unexpected ops: {other:?}"),
2136 }
2137 }
2138
2139 #[test]
2142 fn extent_negative_offsets() {
2143 let p = parse(&to_vec(&["a.png", "-extent", "200x150-25-30", "b.png"])).unwrap();
2144 match p.ops.as_slice() {
2145 [Op::Extent {
2146 width: 200,
2147 height: 150,
2148 x: -25,
2149 y: -30,
2150 bg: _,
2151 }] => {}
2152 other => panic!("unexpected ops: {other:?}"),
2153 }
2154 }
2155
2156 #[test]
2159 fn extent_mixed_signs() {
2160 let p = parse(&to_vec(&["a.png", "-extent", "64x64+10-5", "b.png"])).unwrap();
2161 match p.ops.as_slice() {
2162 [Op::Extent {
2163 width: 64,
2164 height: 64,
2165 x: 10,
2166 y: -5,
2167 bg: _,
2168 }] => {}
2169 other => panic!("unexpected ops: {other:?}"),
2170 }
2171 }
2172
2173 #[test]
2176 fn extent_picks_up_prior_background() {
2177 let p = parse(&to_vec(&[
2178 "a.png",
2179 "-background",
2180 "red",
2181 "-extent",
2182 "50x50",
2183 "b.png",
2184 ]))
2185 .unwrap();
2186 assert_eq!(p.ops.len(), 2);
2189 assert!(matches!(p.ops[0], Op::Background([255, 0, 0, 255])));
2190 match &p.ops[1] {
2191 Op::Extent { bg, .. } => assert_eq!(*bg, [255, 0, 0, 255]),
2192 other => panic!("expected Extent, got {other:?}"),
2193 }
2194 }
2195
2196 #[test]
2199 fn extent_ignores_subsequent_background() {
2200 let p = parse(&to_vec(&[
2201 "a.png",
2202 "-extent",
2203 "50x50",
2204 "-background",
2205 "red",
2206 "b.png",
2207 ]))
2208 .unwrap();
2209 match &p.ops[0] {
2211 Op::Extent { bg, .. } => assert_eq!(*bg, [255, 255, 255, 255]),
2212 other => panic!("expected Extent, got {other:?}"),
2213 }
2214 }
2215
2216 #[test]
2219 fn extent_two_calls_capture_independent_backgrounds() {
2220 let p = parse(&to_vec(&[
2221 "a.png",
2222 "-background",
2223 "red",
2224 "-extent",
2225 "50x50",
2226 "-background",
2227 "blue",
2228 "-extent",
2229 "60x60",
2230 "b.png",
2231 ]))
2232 .unwrap();
2233 let extents: Vec<&Op> = p
2234 .ops
2235 .iter()
2236 .filter(|o| matches!(o, Op::Extent { .. }))
2237 .collect();
2238 assert_eq!(extents.len(), 2);
2239 match extents[0] {
2240 Op::Extent { width: 50, bg, .. } => assert_eq!(*bg, [255, 0, 0, 255]),
2241 other => panic!("unexpected first extent: {other:?}"),
2242 }
2243 match extents[1] {
2244 Op::Extent { width: 60, bg, .. } => assert_eq!(*bg, [0, 0, 255, 255]),
2245 other => panic!("unexpected second extent: {other:?}"),
2246 }
2247 }
2248
2249 #[test]
2252 fn extent_single_offset_rejected() {
2253 let err = parse(&to_vec(&["a.png", "-extent", "64x64+10", "b.png"])).unwrap_err();
2254 assert!(
2255 format!("{err:?}").contains("single offset"),
2256 "unexpected error: {err:?}"
2257 );
2258 }
2259
2260 #[test]
2263 fn extent_im_modifier_rejected() {
2264 let err = parse(&to_vec(&["a.png", "-extent", "50%+0+0", "b.png"])).unwrap_err();
2265 assert!(format!("{err:?}").contains("IM modifier"));
2266 }
2267
2268 #[test]
2269 fn sharpen_basic() {
2270 let p = parse(&to_vec(&["a.png", "-sharpen", "1x0.5", "b.png"])).unwrap();
2271 match p.ops.as_slice() {
2272 [Op::Sharpen { radius: 1, sigma }] => assert!((sigma - 0.5).abs() < 1e-6),
2273 other => panic!("unexpected ops: {other:?}"),
2274 }
2275 }
2276
2277 #[test]
2278 fn sharpen_radius_only_defaults_sigma() {
2279 let p = parse(&to_vec(&["a.png", "-sharpen", "4", "b.png"])).unwrap();
2280 assert_eq!(
2282 p.ops,
2283 vec![Op::Sharpen {
2284 radius: 4,
2285 sigma: 2.0
2286 }]
2287 );
2288 }
2289
2290 #[test]
2291 fn unsharp_full_grammar() {
2292 let p = parse(&to_vec(&["a.png", "-unsharp", "2x1.5+0.8+5", "b.png"])).unwrap();
2293 match p.ops.as_slice() {
2294 [Op::Unsharp {
2295 radius: 2,
2296 sigma,
2297 amount,
2298 threshold: 5,
2299 }] => {
2300 assert!((sigma - 1.5).abs() < 1e-6);
2301 assert!((amount - 0.8).abs() < 1e-6);
2302 }
2303 other => panic!("unexpected ops: {other:?}"),
2304 }
2305 }
2306
2307 #[test]
2308 fn unsharp_partial_defaults() {
2309 let p = parse(&to_vec(&["a.png", "-unsharp", "3", "b.png"])).unwrap();
2310 match p.ops.as_slice() {
2311 [Op::Unsharp {
2312 radius: 3,
2313 sigma,
2314 amount,
2315 threshold: 0,
2316 }] => {
2317 assert!((sigma - 1.5).abs() < 1e-6, "sigma = {sigma}");
2318 assert!((amount - 1.0).abs() < 1e-6, "amount = {amount}");
2319 }
2320 other => panic!("unexpected ops: {other:?}"),
2321 }
2322 }
2323
2324 #[test]
2325 fn gamma_basic() {
2326 let p = parse(&to_vec(&["a.png", "-gamma", "1.8", "b.png"])).unwrap();
2327 match p.ops.as_slice() {
2328 [Op::Gamma { value }] => assert!((value - 1.8).abs() < 1e-6),
2329 other => panic!("unexpected ops: {other:?}"),
2330 }
2331 }
2332
2333 #[test]
2334 fn gamma_zero_rejected() {
2335 let err = parse(&to_vec(&["a.png", "-gamma", "0", "b.png"])).unwrap_err();
2336 assert!(format!("{err:?}").contains("must be > 0"));
2337 }
2338
2339 #[test]
2340 fn gamma_negative_rejected() {
2341 let err = parse(&to_vec(&["a.png", "-gamma", "-1.5", "b.png"])).unwrap_err();
2342 assert!(format!("{err:?}").contains("must be > 0"));
2343 }
2344
2345 #[test]
2346 fn brightness_contrast_basic_comma() {
2347 let p = parse(&to_vec(&["a.png", "-brightness-contrast", "10,5", "b.png"])).unwrap();
2348 assert_eq!(
2349 p.ops,
2350 vec![Op::BrightnessContrast {
2351 brightness: 10.0,
2352 contrast: 5.0
2353 }]
2354 );
2355 }
2356
2357 #[test]
2358 fn brightness_contrast_basic_x_separator() {
2359 let p = parse(&to_vec(&[
2360 "a.png",
2361 "-brightness-contrast",
2362 "20x-15",
2363 "b.png",
2364 ]))
2365 .unwrap();
2366 assert_eq!(
2367 p.ops,
2368 vec![Op::BrightnessContrast {
2369 brightness: 20.0,
2370 contrast: -15.0
2371 }]
2372 );
2373 }
2374
2375 #[test]
2376 fn brightness_contrast_brightness_only() {
2377 let p = parse(&to_vec(&["a.png", "-brightness-contrast", "30", "b.png"])).unwrap();
2378 assert_eq!(
2379 p.ops,
2380 vec![Op::BrightnessContrast {
2381 brightness: 30.0,
2382 contrast: 0.0
2383 }]
2384 );
2385 }
2386
2387 #[test]
2388 fn brightness_contrast_out_of_range_rejected() {
2389 let err = parse(&to_vec(&[
2390 "a.png",
2391 "-brightness-contrast",
2392 "150,0",
2393 "b.png",
2394 ]))
2395 .unwrap_err();
2396 assert!(format!("{err:?}").contains("out of range"));
2397 }
2398
2399 #[test]
2400 fn contrast_step_default_one() {
2401 let p = parse(&to_vec(&["a.png", "-contrast", "b.png"])).unwrap();
2402 assert_eq!(p.ops, vec![Op::Contrast { delta: 1 }]);
2403 }
2404
2405 #[test]
2406 fn contrast_repeated_accumulates_via_chain() {
2407 let p = parse(&to_vec(&["a.png", "-contrast", "-contrast", "b.png"])).unwrap();
2410 assert_eq!(
2411 p.ops,
2412 vec![Op::Contrast { delta: 1 }, Op::Contrast { delta: 1 }]
2413 );
2414 }
2415
2416 #[test]
2417 fn sepia_percent_form() {
2418 let p = parse(&to_vec(&["a.png", "-sepia", "80%", "b.png"])).unwrap();
2419 match p.ops.as_slice() {
2420 [Op::Sepia { threshold }] => assert!((threshold - 0.8).abs() < 1e-6),
2421 other => panic!("unexpected ops: {other:?}"),
2422 }
2423 }
2424
2425 #[test]
2426 fn sepia_unit_form() {
2427 let p = parse(&to_vec(&["a.png", "-sepia", "0.5", "b.png"])).unwrap();
2428 match p.ops.as_slice() {
2429 [Op::Sepia { threshold }] => assert!((threshold - 0.5).abs() < 1e-6),
2430 other => panic!("unexpected ops: {other:?}"),
2431 }
2432 }
2433
2434 #[test]
2435 fn modulate_full_triplet() {
2436 let p = parse(&to_vec(&["a.png", "-modulate", "120,80,150", "b.png"])).unwrap();
2437 assert_eq!(
2438 p.ops,
2439 vec![Op::Modulate {
2440 brightness: 120.0,
2441 saturation: 80.0,
2442 hue: 150.0,
2443 }]
2444 );
2445 }
2446
2447 #[test]
2448 fn modulate_brightness_only_defaults_other_components() {
2449 let p = parse(&to_vec(&["a.png", "-modulate", "150", "b.png"])).unwrap();
2450 assert_eq!(
2451 p.ops,
2452 vec![Op::Modulate {
2453 brightness: 150.0,
2454 saturation: 100.0,
2455 hue: 100.0,
2456 }]
2457 );
2458 }
2459
2460 #[test]
2461 fn level_full_triplet() {
2462 let p = parse(&to_vec(&["a.png", "-level", "10/1.2/250", "b.png"])).unwrap();
2463 match p.ops.as_slice() {
2464 [Op::Level {
2465 black: 10,
2466 gamma,
2467 white: 250,
2468 }] => assert!((gamma - 1.2).abs() < 1e-6),
2469 other => panic!("unexpected ops: {other:?}"),
2470 }
2471 }
2472
2473 #[test]
2474 fn level_percent_endpoints() {
2475 let p = parse(&to_vec(&["a.png", "-level", "10%/1.0/90%", "b.png"])).unwrap();
2476 match p.ops.as_slice() {
2477 [Op::Level {
2478 black,
2479 gamma: _,
2480 white,
2481 }] => {
2482 assert_eq!(*black, 26);
2484 assert_eq!(*white, 230);
2485 }
2486 other => panic!("unexpected ops: {other:?}"),
2487 }
2488 }
2489
2490 #[test]
2491 fn level_inverted_endpoints_rejected() {
2492 let err = parse(&to_vec(&["a.png", "-level", "200/1.0/100", "b.png"])).unwrap_err();
2493 assert!(format!("{err:?}").contains("must be <="));
2494 }
2495
2496 #[test]
2497 fn normalize_is_valueless() {
2498 let p = parse(&to_vec(&["a.png", "-normalize", "b.png"])).unwrap();
2499 assert_eq!(p.ops, vec![Op::Normalize]);
2500 }
2501
2502 #[test]
2503 fn threshold_percent() {
2504 let p = parse(&to_vec(&["a.png", "-threshold", "50%", "b.png"])).unwrap();
2505 assert_eq!(p.ops, vec![Op::Threshold { value: 128 }]);
2507 }
2508
2509 #[test]
2510 fn threshold_byte() {
2511 let p = parse(&to_vec(&["a.png", "-threshold", "200", "b.png"])).unwrap();
2512 assert_eq!(p.ops, vec![Op::Threshold { value: 200 }]);
2513 }
2514
2515 #[test]
2516 fn posterize_basic() {
2517 let p = parse(&to_vec(&["a.png", "-posterize", "4", "b.png"])).unwrap();
2518 assert_eq!(p.ops, vec![Op::Posterize { levels: 4 }]);
2519 }
2520
2521 #[test]
2522 fn posterize_one_rejected() {
2523 let err = parse(&to_vec(&["a.png", "-posterize", "1", "b.png"])).unwrap_err();
2524 assert!(format!("{err:?}").contains("levels must be >= 2"));
2525 }
2526
2527 #[test]
2528 fn solarize_percent() {
2529 let p = parse(&to_vec(&["a.png", "-solarize", "100", "b.png"])).unwrap();
2530 assert_eq!(p.ops, vec![Op::Solarize { value: 100 }]);
2531 }
2532
2533 #[test]
2534 fn colorspace_gray_recognised() {
2535 let p = parse(&to_vec(&["a.png", "-colorspace", "Gray", "b.png"])).unwrap();
2536 assert_eq!(p.ops, vec![Op::Colorspace("Gray".into())]);
2537 }
2538
2539 #[test]
2540 fn colorspace_rgb_recognised() {
2541 let p = parse(&to_vec(&["a.png", "-colorspace", "rgb", "b.png"])).unwrap();
2542 assert_eq!(p.ops, vec![Op::Colorspace("rgb".into())]);
2543 }
2544
2545 #[test]
2546 fn colorspace_unsupported_rejected() {
2547 let err = parse(&to_vec(&["a.png", "-colorspace", "CMYK", "b.png"])).unwrap_err();
2548 assert!(format!("{err:?}").contains("not yet wired"));
2549 }
2550
2551 #[test]
2552 fn unknown_flag_errors() {
2553 let err = parse(&to_vec(&["a.png", "-fnord", "42", "b.png"])).unwrap_err();
2554 let msg = format!("{err:?}");
2555 assert!(msg.contains("unknown flag"));
2556 assert!(
2558 !msg.contains("did you mean"),
2559 "distant token should not get a hint, got: {msg}"
2560 );
2561 }
2562
2563 #[test]
2564 fn unknown_flag_typo_suggests_close_match() {
2565 let err = parse(&to_vec(&["a.png", "-quailty", "90", "b.jpg"])).unwrap_err();
2567 let msg = format!("{err:?}");
2568 assert!(msg.contains("unknown flag"), "got: {msg}");
2569 assert!(
2570 msg.contains("did you mean '-quality'?"),
2571 "expected quality suggestion, got: {msg}"
2572 );
2573 }
2574
2575 #[test]
2576 fn unknown_flag_typo_preserves_single_vs_double_dash() {
2577 let err = parse(&to_vec(&["--prbe", "in.png"])).unwrap_err();
2580 let msg = format!("{err:?}");
2581 assert!(msg.contains("unknown flag"), "got: {msg}");
2582 assert!(
2583 msg.contains("did you mean '--probe'?"),
2584 "expected --probe suggestion with two dashes, got: {msg}"
2585 );
2586 }
2587
2588 #[test]
2589 fn unknown_flag_typo_resize_transposition() {
2590 let err = parse(&to_vec(&["a.png", "-reszie", "100x100", "b.png"])).unwrap_err();
2591 let msg = format!("{err:?}");
2592 assert!(
2593 msg.contains("did you mean '-resize'?"),
2594 "expected resize suggestion, got: {msg}"
2595 );
2596 }
2597
2598 #[test]
2599 fn missing_output_errors() {
2600 let err = parse(&to_vec(&["in.png"])).unwrap_err();
2601 assert!(format!("{err:?}").contains("no output file given"));
2602 }
2603
2604 #[test]
2605 fn quality_strip() {
2606 let p = parse(&to_vec(&["a.png", "-quality", "85", "-strip", "b.jpg"])).unwrap();
2607 assert_eq!(p.ops, vec![Op::Quality(85), Op::Strip]);
2608 }
2609
2610 #[test]
2611 fn quality_zero_and_hundred_accepted() {
2612 let p0 = parse(&to_vec(&["a.png", "-quality", "0", "b.jpg"])).unwrap();
2615 assert_eq!(p0.ops, vec![Op::Quality(0)]);
2616 let p100 = parse(&to_vec(&["a.png", "-quality", "100", "b.jpg"])).unwrap();
2617 assert_eq!(p100.ops, vec![Op::Quality(100)]);
2618 }
2619
2620 #[test]
2621 fn quality_out_of_range_rejected() {
2622 let err = parse(&to_vec(&["a.png", "-quality", "1000", "b.jpg"])).unwrap_err();
2625 let msg = format!("{err:?}");
2626 assert!(
2627 msg.contains("-quality") && msg.contains("out of range"),
2628 "got: {msg}"
2629 );
2630 assert!(
2631 msg.contains("0..=100"),
2632 "must point at the legal range; got: {msg}"
2633 );
2634 let err = parse(&to_vec(&["a.png", "-quality", "150", "b.jpg"])).unwrap_err();
2636 assert!(format!("{err:?}").contains("out of range"));
2637 }
2638
2639 #[test]
2640 fn quality_non_numeric_rejected() {
2641 let err = parse(&to_vec(&["a.png", "-quality", "-5", "b.jpg"])).unwrap_err();
2644 assert!(format!("{err:?}").contains("not a non-negative integer"));
2645 let err = parse(&to_vec(&["a.png", "-quality", "high", "b.jpg"])).unwrap_err();
2646 assert!(format!("{err:?}").contains("not a non-negative integer"));
2647 }
2648
2649 #[test]
2650 fn density_background_alpha_chain() {
2651 let p = parse(&to_vec(&[
2652 "in.pdf",
2653 "-density",
2654 "300",
2655 "-background",
2656 "white",
2657 "-alpha",
2658 "remove",
2659 "-alpha",
2660 "off",
2661 "page-%03d.png",
2662 ]))
2663 .unwrap();
2664 assert_eq!(p.input, "in.pdf");
2665 assert_eq!(p.output, "page-%03d.png");
2666 assert_eq!(
2667 p.ops,
2668 vec![
2669 Op::Density(300),
2670 Op::Background([255, 255, 255, 255]),
2671 Op::Alpha(AlphaOp::Remove),
2672 Op::Alpha(AlphaOp::Off),
2673 ]
2674 );
2675 }
2676
2677 #[test]
2678 fn density_zero_rejected() {
2679 let err = parse(&to_vec(&["a.png", "-density", "0", "b.png"])).unwrap_err();
2680 assert!(format!("{err:?}").contains("-density must be > 0"));
2681 }
2682
2683 #[test]
2684 fn alpha_unknown_subcommand_rejected() {
2685 let err = parse(&to_vec(&["a.png", "-alpha", "fnord", "b.png"])).unwrap_err();
2686 assert!(format!("{err:?}").contains("unknown subcommand"));
2687 }
2688
2689 #[test]
2690 fn background_hex_color() {
2691 let p = parse(&to_vec(&["a.png", "-background", "#ff8000", "b.png"])).unwrap();
2692 assert_eq!(p.ops, vec![Op::Background([255, 128, 0, 255])]);
2693 }
2694
2695 #[test]
2696 fn background_hex_with_alpha() {
2697 let p = parse(&to_vec(&["a.png", "-background", "#80808040", "b.png"])).unwrap();
2698 assert_eq!(p.ops, vec![Op::Background([128, 128, 128, 64])]);
2699 }
2700
2701 #[test]
2702 fn printf_template_zero_padded() {
2703 let t = parse_printf_template("page-%03d.png").unwrap().unwrap();
2704 assert_eq!(t.prefix, "page-");
2705 assert_eq!(t.width, 3);
2706 assert_eq!(t.suffix, ".png");
2707 }
2708
2709 #[test]
2710 fn printf_template_unpadded() {
2711 let t = parse_printf_template("out%d.jpg").unwrap().unwrap();
2712 assert_eq!(t.prefix, "out");
2713 assert_eq!(t.width, 0);
2714 assert_eq!(t.suffix, ".jpg");
2715 }
2716
2717 #[test]
2718 fn printf_template_absent_returns_none() {
2719 assert!(parse_printf_template("out.png").unwrap().is_none());
2720 assert!(parse_printf_template("/some/dir/out.jpg")
2721 .unwrap()
2722 .is_none());
2723 }
2724
2725 #[test]
2726 fn printf_template_multiple_d_rejected() {
2727 let err = parse_printf_template("page-%d-frame-%d.png").unwrap_err();
2728 assert!(format!("{err:?}").contains("more than one"));
2729 }
2730
2731 #[test]
2732 fn printf_template_unsupported_specifier_rejected() {
2733 let err = parse_printf_template("out-%s.png").unwrap_err();
2734 assert!(format!("{err:?}").contains("unsupported format specifier"));
2735 }
2736
2737 #[test]
2738 fn parse_populates_output_template_when_present() {
2739 let p = parse(&to_vec(&["in.pdf", "page-%02d.png"])).unwrap();
2740 let t = p.output_template.unwrap();
2741 assert_eq!(t.prefix, "page-");
2742 assert_eq!(t.width, 2);
2743 assert_eq!(t.suffix, ".png");
2744 }
2745
2746 #[test]
2747 fn parse_leaves_output_template_none_for_literal_path() {
2748 let p = parse(&to_vec(&["in.png", "out.jpg"])).unwrap();
2749 assert!(p.output_template.is_none());
2750 }
2751
2752 #[test]
2753 fn ping_flag_off_by_default() {
2754 let p = parse(&to_vec(&["in.png", "out.jpg"])).unwrap();
2755 assert!(!p.ping);
2756 }
2757
2758 #[test]
2759 fn ping_flag_set_when_present() {
2760 let p = parse(&to_vec(&["-ping", "in.png", "out.jpg"])).unwrap();
2761 assert!(p.ping);
2762 }
2763
2764 #[test]
2765 fn ping_makes_output_optional() {
2766 let p = parse(&to_vec(&["-ping", "in.png"])).unwrap();
2767 assert!(p.ping);
2768 assert_eq!(p.output, "");
2769 assert!(p.output_template.is_none());
2770 }
2771
2772 #[test]
2773 fn missing_output_without_ping_still_errors() {
2774 let err = parse(&to_vec(&["in.png"])).unwrap_err();
2775 assert!(format!("{err:?}").contains("no output file"));
2777 }
2778
2779 #[test]
2780 fn input_selector_single_page() {
2781 let (path, sel) = split_input_selector("input.pdf[0]").unwrap();
2782 assert_eq!(path, "input.pdf");
2783 assert_eq!(sel, Some(PageSelector::Single(0)));
2784 }
2785
2786 #[test]
2787 fn input_selector_range() {
2788 let (path, sel) = split_input_selector("foo.pdf[2-5]").unwrap();
2789 assert_eq!(path, "foo.pdf");
2790 assert_eq!(sel, Some(PageSelector::Range(2, 5)));
2791 }
2792
2793 #[test]
2794 fn input_selector_absent_passes_through() {
2795 let (path, sel) = split_input_selector("plain.pdf").unwrap();
2796 assert_eq!(path, "plain.pdf");
2797 assert_eq!(sel, None);
2798 }
2799
2800 #[test]
2801 fn input_selector_empty_brackets_rejected() {
2802 assert!(split_input_selector("foo.pdf[]").is_err());
2803 }
2804
2805 #[test]
2806 fn input_selector_non_numeric_rejected() {
2807 assert!(split_input_selector("foo.pdf[abc]").is_err());
2808 assert!(split_input_selector("foo.pdf[1-x]").is_err());
2809 }
2810
2811 #[test]
2812 fn input_selector_extra_dash_rejected() {
2813 assert!(split_input_selector("foo.pdf[1-2-3]").is_err());
2814 }
2815
2816 #[test]
2817 fn parse_pulls_selector_into_input_pages() {
2818 let p = parse(&to_vec(&["in.pdf[0]", "out.png"])).unwrap();
2819 assert_eq!(p.input, "in.pdf");
2820 assert_eq!(p.input_pages, Some(PageSelector::Single(0)));
2821 }
2822
2823 #[test]
2824 fn parse_pulls_range_selector_into_input_pages() {
2825 let p = parse(&to_vec(&["in.pdf[2-4]", "page-%02d.png"])).unwrap();
2826 assert_eq!(p.input, "in.pdf");
2827 assert_eq!(p.input_pages, Some(PageSelector::Range(2, 4)));
2828 assert!(p.output_template.is_some());
2829 }
2830
2831 #[test]
2834 fn input_selector_negative_index_parses() {
2835 let (path, sel) = split_input_selector("foo.pdf[-1]").unwrap();
2836 assert_eq!(path, "foo.pdf");
2837 assert_eq!(sel, Some(PageSelector::Single(-1)));
2838 }
2839
2840 #[test]
2841 fn input_selector_negative_range_endpoint_parses() {
2842 let (path, sel) = split_input_selector("foo.pdf[5--1]").unwrap();
2844 assert_eq!(path, "foo.pdf");
2845 assert_eq!(sel, Some(PageSelector::Range(5, -1)));
2846 }
2847
2848 #[test]
2849 fn input_selector_negative_range_both_endpoints() {
2850 let (path, sel) = split_input_selector("foo.pdf[-3--1]").unwrap();
2852 assert_eq!(path, "foo.pdf");
2853 assert_eq!(sel, Some(PageSelector::Range(-3, -1)));
2854 }
2855
2856 #[test]
2857 fn input_selector_comma_list_of_singles() {
2858 let (path, sel) = split_input_selector("foo.pdf[0,2,4]").unwrap();
2859 assert_eq!(path, "foo.pdf");
2860 assert_eq!(
2861 sel,
2862 Some(PageSelector::List(vec![
2863 PageAtom::Single(0),
2864 PageAtom::Single(2),
2865 PageAtom::Single(4),
2866 ]))
2867 );
2868 }
2869
2870 #[test]
2871 fn input_selector_comma_list_with_ranges_and_negatives() {
2872 let (path, sel) = split_input_selector("foo.pdf[0-2,5,-1]").unwrap();
2874 assert_eq!(path, "foo.pdf");
2875 assert_eq!(
2876 sel,
2877 Some(PageSelector::List(vec![
2878 PageAtom::Range(0, 2),
2879 PageAtom::Single(5),
2880 PageAtom::Single(-1),
2881 ]))
2882 );
2883 }
2884
2885 #[test]
2886 fn input_selector_single_atom_list_collapses_to_single_variant() {
2887 let (_, sel) = split_input_selector("foo.pdf[3]").unwrap();
2890 assert!(matches!(sel, Some(PageSelector::Single(3))));
2891 }
2892
2893 #[test]
2894 fn input_selector_empty_atom_in_list_rejected() {
2895 assert!(split_input_selector("foo.pdf[0,,1]").is_err());
2896 assert!(split_input_selector("foo.pdf[,0]").is_err());
2897 assert!(split_input_selector("foo.pdf[0,]").is_err());
2898 }
2899
2900 #[test]
2901 fn input_selector_bare_dash_rejected() {
2902 assert!(split_input_selector("foo.pdf[-]").is_err());
2903 }
2904
2905 #[test]
2906 fn page_selector_negative_single_resolves_to_last_page() {
2907 let s = PageSelector::Single(-1);
2908 assert_eq!(s.resolve(5).unwrap(), vec![4]);
2909 let s = PageSelector::Single(-2);
2910 assert_eq!(s.resolve(5).unwrap(), vec![3]);
2911 assert!(PageSelector::Single(-6).resolve(5).is_err());
2913 }
2914
2915 #[test]
2916 fn page_selector_negative_range_endpoint_resolves() {
2917 let s = PageSelector::Range(5, -1);
2919 assert_eq!(s.resolve(8).unwrap(), vec![5, 6, 7]);
2920 let s = PageSelector::Range(-3, -1);
2922 assert_eq!(s.resolve(8).unwrap(), vec![5, 6, 7]);
2923 }
2924
2925 #[test]
2926 fn page_selector_list_resolves_in_source_order() {
2927 let s = PageSelector::List(vec![
2928 PageAtom::Single(0),
2929 PageAtom::Single(2),
2930 PageAtom::Range(4, 5),
2931 PageAtom::Single(-1),
2932 ]);
2933 assert_eq!(s.resolve(8).unwrap(), vec![0, 2, 4, 5, 7]);
2934 }
2935
2936 #[test]
2937 fn page_selector_list_preserves_duplicates() {
2938 let s = PageSelector::List(vec![
2940 PageAtom::Single(0),
2941 PageAtom::Single(0),
2942 PageAtom::Single(0),
2943 ]);
2944 assert_eq!(s.resolve(3).unwrap(), vec![0, 0, 0]);
2945 }
2946
2947 #[test]
2948 fn page_selector_list_propagates_out_of_range() {
2949 let s = PageSelector::List(vec![PageAtom::Single(0), PageAtom::Single(99)]);
2950 assert!(s.resolve(3).is_err());
2951 }
2952
2953 #[test]
2956 fn vignette_radius_only_defaults_sigma_and_centre() {
2957 let p = parse(&to_vec(&["a.png", "-vignette", "50", "b.png"])).unwrap();
2958 match p.ops.as_slice() {
2959 [Op::Vignette {
2960 radius,
2961 sigma,
2962 x,
2963 y,
2964 }] => {
2965 assert!((radius - 50.0).abs() < 1e-6);
2966 assert!((sigma - 25.0).abs() < 1e-6);
2968 assert!((x - 0.5).abs() < 1e-6);
2970 assert!((y - 0.5).abs() < 1e-6);
2971 }
2972 other => panic!("unexpected ops: {other:?}"),
2973 }
2974 }
2975
2976 #[test]
2977 fn vignette_radius_and_sigma() {
2978 let p = parse(&to_vec(&["a.png", "-vignette", "50+10", "b.png"])).unwrap();
2979 match p.ops.as_slice() {
2980 [Op::Vignette {
2981 radius,
2982 sigma,
2983 x,
2984 y,
2985 }] => {
2986 assert!((radius - 50.0).abs() < 1e-6);
2987 assert!((sigma - 10.0).abs() < 1e-6);
2988 assert!((x - 0.5).abs() < 1e-6);
2989 assert!((y - 0.5).abs() < 1e-6);
2990 }
2991 other => panic!("unexpected ops: {other:?}"),
2992 }
2993 }
2994
2995 #[test]
2996 fn vignette_full_grammar_with_xy() {
2997 let p = parse(&to_vec(&["a.png", "-vignette", "50+10+0.25+0.75", "b.png"])).unwrap();
2998 match p.ops.as_slice() {
2999 [Op::Vignette {
3000 radius,
3001 sigma,
3002 x,
3003 y,
3004 }] => {
3005 assert!((radius - 50.0).abs() < 1e-6);
3006 assert!((sigma - 10.0).abs() < 1e-6);
3007 assert!((x - 0.25).abs() < 1e-6);
3008 assert!((y - 0.75).abs() < 1e-6);
3009 }
3010 other => panic!("unexpected ops: {other:?}"),
3011 }
3012 }
3013
3014 #[test]
3015 fn vignette_too_many_components_rejected() {
3016 let err = parse(&to_vec(&["a.png", "-vignette", "1+2+3+4+5", "b.png"])).unwrap_err();
3017 assert!(format!("{err:?}").contains("more than 4"));
3018 }
3019
3020 #[test]
3021 fn vignette_negative_radius_rejected() {
3022 let err = parse(&to_vec(&["a.png", "-vignette", "-5", "b.png"])).unwrap_err();
3023 let msg = format!("{err:?}");
3024 assert!(msg.contains("not a finite float radius") || msg.contains("must be >= 0"));
3025 }
3026
3027 #[test]
3028 fn colorize_named_color_with_percent_amount() {
3029 let p = parse(&to_vec(&["a.png", "-colorize", "red/40%", "b.png"])).unwrap();
3030 match p.ops.as_slice() {
3031 [Op::Colorize { color, amount }] => {
3032 assert_eq!(*color, [255, 0, 0, 255]);
3033 assert!((amount - 0.4).abs() < 1e-6);
3034 }
3035 other => panic!("unexpected ops: {other:?}"),
3036 }
3037 }
3038
3039 #[test]
3040 fn colorize_hex_color() {
3041 let p = parse(&to_vec(&["a.png", "-colorize", "#ff8000/50%", "b.png"])).unwrap();
3042 match p.ops.as_slice() {
3043 [Op::Colorize { color, amount }] => {
3044 assert_eq!(*color, [255, 128, 0, 255]);
3045 assert!((amount - 0.5).abs() < 1e-6);
3046 }
3047 other => panic!("unexpected ops: {other:?}"),
3048 }
3049 }
3050
3051 #[test]
3052 fn colorize_per_channel_triplet() {
3053 let p = parse(&to_vec(&["a.png", "-colorize", "200x100x50/25%", "b.png"])).unwrap();
3054 match p.ops.as_slice() {
3055 [Op::Colorize { color, amount }] => {
3056 assert_eq!(*color, [200, 100, 50, 255]);
3057 assert!((amount - 0.25).abs() < 1e-6);
3058 }
3059 other => panic!("unexpected ops: {other:?}"),
3060 }
3061 }
3062
3063 #[test]
3064 fn colorize_single_channel_replicated() {
3065 let err = parse(&to_vec(&["a.png", "-colorize", "100/50%", "b.png"])).unwrap_err();
3070 let msg = format!("{err:?}");
3071 assert!(msg.contains("unknown colour") || msg.contains("not a valid hex"));
3072 }
3073
3074 #[test]
3075 fn colorize_per_channel_one_value_replicates() {
3076 let p = parse(&to_vec(&["a.png", "-colorize", "100x100x100/30%", "b.png"])).unwrap();
3078 match p.ops.as_slice() {
3079 [Op::Colorize { color, amount: _ }] => {
3080 assert_eq!(*color, [100, 100, 100, 255]);
3081 }
3082 other => panic!("unexpected ops: {other:?}"),
3083 }
3084 }
3085
3086 #[test]
3087 fn colorize_amount_only_defaults_white() {
3088 let p = parse(&to_vec(&["a.png", "-colorize", "/40%", "b.png"])).unwrap();
3090 match p.ops.as_slice() {
3091 [Op::Colorize { color, amount }] => {
3092 assert_eq!(*color, [255, 255, 255, 255]);
3093 assert!((amount - 0.4).abs() < 1e-6);
3094 }
3095 other => panic!("unexpected ops: {other:?}"),
3096 }
3097 }
3098
3099 #[test]
3100 fn colorize_amount_unit_scalar() {
3101 let p = parse(&to_vec(&["a.png", "-colorize", "red/0.6", "b.png"])).unwrap();
3103 match p.ops.as_slice() {
3104 [Op::Colorize { color, amount }] => {
3105 assert_eq!(*color, [255, 0, 0, 255]);
3106 assert!((amount - 0.6).abs() < 1e-6);
3107 }
3108 other => panic!("unexpected ops: {other:?}"),
3109 }
3110 }
3111
3112 #[test]
3113 fn colorize_out_of_range_channel_rejected() {
3114 let err = parse(&to_vec(&["a.png", "-colorize", "300x0x0/50%", "b.png"])).unwrap_err();
3115 assert!(format!("{err:?}").contains("out of range"));
3116 }
3117
3118 #[test]
3119 fn equalize_is_valueless() {
3120 let p = parse(&to_vec(&["a.png", "-equalize", "b.png"])).unwrap();
3121 assert_eq!(p.ops, vec![Op::Equalize]);
3122 }
3123
3124 #[test]
3125 fn auto_gamma_is_valueless() {
3126 let p = parse(&to_vec(&["a.png", "-auto-gamma", "b.png"])).unwrap();
3127 assert_eq!(p.ops, vec![Op::AutoGamma]);
3128 }
3129
3130 #[test]
3131 fn vignette_rejects_negative_sigma() {
3132 let err = parse(&to_vec(&["a.png", "-vignette", "5+-1", "b.png"])).unwrap_err();
3133 let msg = format!("{err:?}");
3134 assert!(
3135 msg.contains("not a finite float sigma") || msg.contains("must be >= 0"),
3136 "unexpected message: {msg}"
3137 );
3138 }
3139
3140 #[test]
3147 fn stl_format_default_is_none() {
3148 let p = parse(&to_vec(&["in.stl", "out.stl"])).unwrap();
3149 assert!(p.mesh3d_options.stl_format.is_none());
3150 assert!(p.ops.is_empty(), "no -stl-format → no Op pushed");
3151 }
3152
3153 #[test]
3154 fn stl_format_ascii_parses() {
3155 let p = parse(&to_vec(&["in.stl", "-stl-format", "ascii", "out.stl"])).unwrap();
3156 assert_eq!(p.mesh3d_options.stl_format, Some(StlFormatChoice::Ascii));
3157 }
3158
3159 #[test]
3160 fn stl_format_binary_parses() {
3161 let p = parse(&to_vec(&["in.stl", "-stl-format", "binary", "out.stl"])).unwrap();
3162 assert_eq!(p.mesh3d_options.stl_format, Some(StlFormatChoice::Binary));
3163 }
3164
3165 #[test]
3166 fn stl_format_aliases_parse() {
3167 let p = parse(&to_vec(&["in.stl", "-stl-format", "bin", "out.stl"])).unwrap();
3169 assert_eq!(p.mesh3d_options.stl_format, Some(StlFormatChoice::Binary));
3170 let p = parse(&to_vec(&["in.stl", "-stl-format", "text", "out.stl"])).unwrap();
3171 assert_eq!(p.mesh3d_options.stl_format, Some(StlFormatChoice::Ascii));
3172 }
3173
3174 #[test]
3175 fn stl_format_case_insensitive() {
3176 let p = parse(&to_vec(&["in.stl", "-stl-format", "ASCII", "out.stl"])).unwrap();
3177 assert_eq!(p.mesh3d_options.stl_format, Some(StlFormatChoice::Ascii));
3178 }
3179
3180 #[test]
3181 fn stl_format_unknown_value_rejected() {
3182 let err = parse(&to_vec(&["in.stl", "-stl-format", "xml", "out.stl"])).unwrap_err();
3183 let msg = format!("{err:?}");
3184 assert!(
3185 msg.contains("unknown flavour 'xml'") && msg.contains("expected 'binary'"),
3186 "unexpected message: {msg}"
3187 );
3188 }
3189
3190 #[test]
3191 fn stl_format_missing_value_rejected() {
3192 let err = parse(&to_vec(&["in.stl", "-stl-format"])).unwrap_err();
3193 assert!(format!("{err:?}").contains("-stl-format: missing value"));
3194 }
3195
3196 #[test]
3197 fn gltf_format_default_is_none() {
3198 let p = parse(&to_vec(&["in.stl", "out.gltf"])).unwrap();
3199 assert!(p.mesh3d_options.gltf_format.is_none());
3200 }
3201
3202 #[test]
3203 fn gltf_format_glb_parses() {
3204 let p = parse(&to_vec(&["in.stl", "-gltf-format", "glb", "out.glb"])).unwrap();
3205 assert_eq!(p.mesh3d_options.gltf_format, Some(GltfFormatChoice::Glb));
3206 }
3207
3208 #[test]
3209 fn gltf_format_embedded_parses() {
3210 let p = parse(&to_vec(&["in.stl", "-gltf-format", "embedded", "out.gltf"])).unwrap();
3211 assert_eq!(
3212 p.mesh3d_options.gltf_format,
3213 Some(GltfFormatChoice::JsonEmbedded)
3214 );
3215 }
3216
3217 #[test]
3218 fn gltf_format_external_parses_even_though_runtime_rejects() {
3219 let p = parse(&to_vec(&["in.stl", "-gltf-format", "external", "out.gltf"])).unwrap();
3223 assert_eq!(
3224 p.mesh3d_options.gltf_format,
3225 Some(GltfFormatChoice::JsonExternal)
3226 );
3227 }
3228
3229 #[test]
3230 fn gltf_format_aliases_parse() {
3231 let p = parse(&to_vec(&["in.stl", "-gltf-format", "binary", "out.glb"])).unwrap();
3233 assert_eq!(p.mesh3d_options.gltf_format, Some(GltfFormatChoice::Glb));
3234 let p = parse(&to_vec(&[
3236 "in.stl",
3237 "-gltf-format",
3238 "json-embedded",
3239 "out.gltf",
3240 ]))
3241 .unwrap();
3242 assert_eq!(
3243 p.mesh3d_options.gltf_format,
3244 Some(GltfFormatChoice::JsonEmbedded)
3245 );
3246 }
3247
3248 #[test]
3249 fn gltf_format_unknown_value_rejected() {
3250 let err = parse(&to_vec(&["in.stl", "-gltf-format", "obj", "out.gltf"])).unwrap_err();
3251 let msg = format!("{err:?}");
3252 assert!(
3253 msg.contains("unknown flavour 'obj'") && msg.contains("expected 'glb'"),
3254 "unexpected message: {msg}"
3255 );
3256 }
3257
3258 #[test]
3259 fn render_gouraud_parses() {
3260 let p = parse(&to_vec(&["in.stl", "-render", "gouraud", "out.png"])).unwrap();
3261 assert_eq!(
3262 p.mesh3d_options.render_mode,
3263 Some(Mesh3DRenderMode::Gouraud)
3264 );
3265 }
3266
3267 #[test]
3268 fn render_phong_parses() {
3269 let p = parse(&to_vec(&["in.stl", "-render", "phong", "out.png"])).unwrap();
3270 assert_eq!(p.mesh3d_options.render_mode, Some(Mesh3DRenderMode::Phong));
3271 }
3272
3273 #[test]
3274 fn render_wire_alias_parses() {
3275 let p = parse(&to_vec(&["in.stl", "-render", "wire", "out.png"])).unwrap();
3276 assert_eq!(
3277 p.mesh3d_options.render_mode,
3278 Some(Mesh3DRenderMode::Wireframe)
3279 );
3280 }
3281
3282 #[test]
3283 fn render_unknown_mode_rejected_lists_all_options() {
3284 let err = parse(&to_vec(&["in.stl", "-render", "raytrace", "out.png"])).unwrap_err();
3285 let msg = format!("{err:?}");
3286 assert!(
3287 msg.contains("unknown mode 'raytrace'")
3288 && msg.contains("'gouraud'")
3289 && msg.contains("'phong'"),
3290 "unexpected message: {msg}"
3291 );
3292 }
3293
3294 #[test]
3295 fn light_parses_three_components() {
3296 let p = parse(&to_vec(&["in.stl", "-light", "30,45,1.5", "out.png"])).unwrap();
3297 let light = p.mesh3d_options.light.expect("-light should populate");
3298 assert_eq!(light.azimuth_deg, 30.0);
3299 assert_eq!(light.elevation_deg, 45.0);
3300 assert_eq!(light.intensity, 1.5);
3301 }
3302
3303 #[test]
3304 fn light_rejects_negative_intensity() {
3305 let err = parse(&to_vec(&["in.stl", "-light", "0,0,-1", "out.png"])).unwrap_err();
3306 assert!(format!("{err:?}").contains("intensity"));
3307 }
3308
3309 #[test]
3310 fn light_rejects_two_component_input() {
3311 let err = parse(&to_vec(&["in.stl", "-light", "30,45", "out.png"])).unwrap_err();
3312 let msg = format!("{err:?}");
3313 assert!(
3314 msg.contains("AZIMUTH,ELEVATION,INTENSITY"),
3315 "expected grammar hint, got: {msg}"
3316 );
3317 }
3318
3319 #[test]
3320 fn camera_parses_three_components() {
3321 let p = parse(&to_vec(&["in.stl", "-camera", "20,30,2.0", "out.png"])).unwrap();
3322 let cam = p.mesh3d_options.camera.expect("-camera should populate");
3323 assert_eq!(cam.elevation_deg, 20.0);
3324 assert_eq!(cam.azimuth_deg, 30.0);
3325 assert_eq!(cam.distance, 2.0);
3326 }
3327
3328 #[test]
3329 fn camera_rejects_zero_distance() {
3330 let err = parse(&to_vec(&["in.stl", "-camera", "0,0,0", "out.png"])).unwrap_err();
3331 assert!(format!("{err:?}").contains("distance"));
3332 }
3333
3334 #[test]
3335 fn projection_orthographic_parses() {
3336 let p = parse(&to_vec(&[
3337 "in.stl",
3338 "-projection",
3339 "orthographic",
3340 "out.png",
3341 ]))
3342 .unwrap();
3343 assert_eq!(
3344 p.mesh3d_options.projection,
3345 Some(ProjectionMode::Orthographic)
3346 );
3347 }
3348
3349 #[test]
3350 fn projection_perspective_default_is_unset() {
3351 let p = parse(&to_vec(&["in.stl", "out.png"])).unwrap();
3352 assert!(p.mesh3d_options.projection.is_none());
3353 }
3354
3355 #[test]
3356 fn projection_alias_parses() {
3357 let p = parse(&to_vec(&["in.stl", "-projection", "ortho", "out.png"])).unwrap();
3358 assert_eq!(
3359 p.mesh3d_options.projection,
3360 Some(ProjectionMode::Orthographic)
3361 );
3362 }
3363
3364 #[test]
3365 fn projection_unknown_rejected() {
3366 let err = parse(&to_vec(&["in.stl", "-projection", "isometric", "out.png"])).unwrap_err();
3367 assert!(format!("{err:?}").contains("unknown mode 'isometric'"));
3368 }
3369
3370 #[test]
3371 fn fov_parses_within_range() {
3372 let p = parse(&to_vec(&["in.stl", "-fov", "45", "out.png"])).unwrap();
3373 assert_eq!(p.mesh3d_options.fov_deg, Some(45.0));
3374 }
3375
3376 #[test]
3377 fn fov_rejects_zero() {
3378 let err = parse(&to_vec(&["in.stl", "-fov", "0", "out.png"])).unwrap_err();
3379 assert!(format!("{err:?}").contains("(0, 180)"));
3380 }
3381
3382 #[test]
3383 fn fov_rejects_180_or_above() {
3384 let err = parse(&to_vec(&["in.stl", "-fov", "180", "out.png"])).unwrap_err();
3385 assert!(format!("{err:?}").contains("(0, 180)"));
3386 }
3387
3388 #[test]
3389 fn bg_named_colour_parses() {
3390 let p = parse(&to_vec(&["in.stl", "-bg", "black", "out.png"])).unwrap();
3391 assert_eq!(p.mesh3d_options.bg, Some([0, 0, 0, 255]));
3392 }
3393
3394 #[test]
3395 fn bg_hex_colour_parses() {
3396 let p = parse(&to_vec(&["in.stl", "-bg", "#abcdef", "out.png"])).unwrap();
3397 assert_eq!(p.mesh3d_options.bg, Some([0xab, 0xcd, 0xef, 255]));
3398 }
3399
3400 #[test]
3401 fn bg_transparent_parses() {
3402 let p = parse(&to_vec(&["in.stl", "-bg", "transparent", "out.png"])).unwrap();
3403 assert_eq!(p.mesh3d_options.bg, Some([0, 0, 0, 0]));
3404 }
3405
3406 #[test]
3407 fn mesh3d_flags_can_appear_before_input() {
3408 let p = parse(&to_vec(&[
3411 "-stl-format",
3412 "ascii",
3413 "-gltf-format",
3414 "glb",
3415 "in.stl",
3416 "out.stl",
3417 ]))
3418 .unwrap();
3419 assert_eq!(p.mesh3d_options.stl_format, Some(StlFormatChoice::Ascii));
3420 assert_eq!(p.mesh3d_options.gltf_format, Some(GltfFormatChoice::Glb));
3421 assert_eq!(p.input, "in.stl");
3422 assert_eq!(p.output, "out.stl");
3423 }
3424
3425 #[test]
3428 fn render_normal_debug_parses() {
3429 let p = parse(&to_vec(&["in.stl", "-render", "normal-debug", "out.png"])).unwrap();
3430 assert_eq!(
3431 p.mesh3d_options.render_mode,
3432 Some(Mesh3DRenderMode::NormalDebug)
3433 );
3434 }
3435
3436 #[test]
3437 fn render_depth_debug_parses() {
3438 let p = parse(&to_vec(&["in.stl", "-render", "depth-debug", "out.png"])).unwrap();
3439 assert_eq!(
3440 p.mesh3d_options.render_mode,
3441 Some(Mesh3DRenderMode::DepthDebug)
3442 );
3443 }
3444
3445 #[test]
3446 fn render_normal_alias_parses() {
3447 let p = parse(&to_vec(&["in.stl", "-render", "normal", "out.png"])).unwrap();
3450 assert_eq!(
3451 p.mesh3d_options.render_mode,
3452 Some(Mesh3DRenderMode::NormalDebug)
3453 );
3454 let p = parse(&to_vec(&["in.stl", "-render", "normals", "out.png"])).unwrap();
3455 assert_eq!(
3456 p.mesh3d_options.render_mode,
3457 Some(Mesh3DRenderMode::NormalDebug)
3458 );
3459 }
3460
3461 #[test]
3462 fn render_depth_alias_parses() {
3463 let p = parse(&to_vec(&["in.stl", "-render", "depth", "out.png"])).unwrap();
3464 assert_eq!(
3465 p.mesh3d_options.render_mode,
3466 Some(Mesh3DRenderMode::DepthDebug)
3467 );
3468 let p = parse(&to_vec(&["in.stl", "-render", "z", "out.png"])).unwrap();
3469 assert_eq!(
3470 p.mesh3d_options.render_mode,
3471 Some(Mesh3DRenderMode::DepthDebug)
3472 );
3473 }
3474
3475 #[test]
3476 fn render_unknown_mode_lists_debug_modes() {
3477 let err = parse(&to_vec(&["in.stl", "-render", "raytrace", "out.png"])).unwrap_err();
3480 let msg = format!("{err:?}");
3481 assert!(
3482 msg.contains("normal-debug") && msg.contains("depth-debug"),
3483 "expected new debug-mode names in error, got: {msg}"
3484 );
3485 }
3486
3487 #[test]
3488 fn aa_default_is_unset() {
3489 let p = parse(&to_vec(&["in.stl", "out.png"])).unwrap();
3490 assert!(p.mesh3d_options.aa.is_none());
3491 }
3492
3493 #[test]
3494 fn aa_factor_parses() {
3495 let p = parse(&to_vec(&["in.stl", "-aa", "4", "out.png"])).unwrap();
3496 assert_eq!(p.mesh3d_options.aa, Some(4));
3497 }
3498
3499 #[test]
3500 fn aa_factor_one_is_allowed_meaning_no_supersampling() {
3501 let p = parse(&to_vec(&["in.stl", "-aa", "1", "out.png"])).unwrap();
3505 assert_eq!(p.mesh3d_options.aa, Some(1));
3506 }
3507
3508 #[test]
3509 fn aa_factor_zero_rejected() {
3510 let err = parse(&to_vec(&["in.stl", "-aa", "0", "out.png"])).unwrap_err();
3511 let msg = format!("{err:?}");
3512 assert!(
3513 msg.contains("out of range") && msg.contains("1..=8"),
3514 "expected range hint, got: {msg}"
3515 );
3516 }
3517
3518 #[test]
3519 fn aa_factor_above_cap_rejected() {
3520 let err = parse(&to_vec(&["in.stl", "-aa", "16", "out.png"])).unwrap_err();
3521 assert!(format!("{err:?}").contains("1..=8"));
3522 }
3523
3524 #[test]
3525 fn aa_non_integer_rejected() {
3526 let err = parse(&to_vec(&["in.stl", "-aa", "2.5", "out.png"])).unwrap_err();
3527 assert!(format!("{err:?}").contains("not a positive integer"));
3528 }
3529
3530 #[test]
3535 fn trim_default_fuzz_is_zero() {
3536 let p = parse(&to_vec(&["a.png", "-trim", "b.png"])).unwrap();
3537 assert_eq!(p.ops, vec![Op::Trim { fuzz: 0 }]);
3538 }
3539
3540 #[test]
3543 fn fuzz_byte_then_trim_captures_value() {
3544 let p = parse(&to_vec(&["a.png", "-fuzz", "12", "-trim", "b.png"])).unwrap();
3545 assert_eq!(p.ops, vec![Op::Trim { fuzz: 12 }]);
3546 }
3547
3548 #[test]
3551 fn fuzz_percent_then_trim_captures_scaled_value() {
3552 let p = parse(&to_vec(&["a.png", "-fuzz", "10%", "-trim", "b.png"])).unwrap();
3553 assert_eq!(p.ops, vec![Op::Trim { fuzz: 26 }]);
3554 let p = parse(&to_vec(&["a.png", "-fuzz", "100%", "-trim", "b.png"])).unwrap();
3555 assert_eq!(p.ops, vec![Op::Trim { fuzz: 255 }]);
3556 }
3557
3558 #[test]
3563 fn fuzz_after_trim_only_affects_following_trim() {
3564 let p = parse(&to_vec(&[
3565 "a.png", "-fuzz", "5", "-trim", "-fuzz", "20", "-trim", "b.png",
3566 ]))
3567 .unwrap();
3568 assert_eq!(p.ops, vec![Op::Trim { fuzz: 5 }, Op::Trim { fuzz: 20 }]);
3569 }
3570
3571 #[test]
3574 fn fuzz_without_trim_is_no_op() {
3575 let p = parse(&to_vec(&["a.png", "-fuzz", "30", "b.png"])).unwrap();
3576 assert_eq!(p.ops, vec![]);
3577 }
3578
3579 #[test]
3581 fn fuzz_missing_value_rejected() {
3582 let err = parse(&to_vec(&["a.png", "-fuzz"])).unwrap_err();
3583 assert!(format!("{err:?}").contains("missing value"));
3584 }
3585
3586 #[test]
3588 fn fuzz_garbage_rejected() {
3589 let err = parse(&to_vec(&["a.png", "-fuzz", "xyz", "-trim", "b.png"])).unwrap_err();
3590 assert!(format!("{err:?}").contains("-fuzz"));
3591 }
3592
3593 #[test]
3596 fn fuzz_percent_out_of_range_rejected() {
3597 let err = parse(&to_vec(&["a.png", "-fuzz", "150%", "-trim", "b.png"])).unwrap_err();
3598 assert!(format!("{err:?}").contains("out of range"));
3599 }
3600
3601 #[test]
3604 fn fuzz_byte_above_255_rejected() {
3605 let err = parse(&to_vec(&["a.png", "-fuzz", "300", "-trim", "b.png"])).unwrap_err();
3606 assert!(format!("{err:?}").contains("out of range"));
3607 }
3608
3609 #[test]
3614 fn trim_keeps_source_order_with_neighbours() {
3615 let p = parse(&to_vec(&[
3616 "a.png", "-resize", "100x100", "-fuzz", "8", "-trim", "-strip", "b.png",
3617 ]))
3618 .unwrap();
3619 assert_eq!(
3620 p.ops,
3621 vec![
3622 Op::Resize {
3623 width: 100,
3624 height: 100,
3625 mode: ResizeMode::Default,
3626 },
3627 Op::Trim { fuzz: 8 },
3628 Op::Strip,
3629 ]
3630 );
3631 }
3632
3633 #[test]
3638 fn roll_positive_offsets() {
3639 let p = parse(&to_vec(&["a.png", "-roll", "+5+10", "b.png"])).unwrap();
3640 assert_eq!(p.ops, vec![Op::Roll { dx: 5, dy: 10 }]);
3641 }
3642
3643 #[test]
3647 fn roll_negative_offsets() {
3648 let p = parse(&to_vec(&["a.png", "-roll", "-3-7", "b.png"])).unwrap();
3649 assert_eq!(p.ops, vec![Op::Roll { dx: -3, dy: -7 }]);
3650 }
3651
3652 #[test]
3655 fn roll_mixed_signs() {
3656 let p = parse(&to_vec(&["a.png", "-roll", "+5-10", "b.png"])).unwrap();
3657 assert_eq!(p.ops, vec![Op::Roll { dx: 5, dy: -10 }]);
3658 }
3659
3660 #[test]
3663 fn roll_single_offset_rejected() {
3664 let err = parse(&to_vec(&["a.png", "-roll", "+5", "b.png"])).unwrap_err();
3665 assert!(
3666 format!("{err:?}").contains("single offset"),
3667 "unexpected error: {err:?}"
3668 );
3669 }
3670
3671 #[test]
3674 fn roll_empty_value_rejected() {
3675 let err = parse(&to_vec(&["a.png", "-roll", "", "b.png"])).unwrap_err();
3676 assert!(
3677 format!("{err:?}").contains("empty offset"),
3678 "unexpected error: {err:?}"
3679 );
3680 }
3681
3682 #[test]
3686 fn roll_unsigned_value_rejected() {
3687 let err = parse(&to_vec(&["a.png", "-roll", "5+10", "b.png"])).unwrap_err();
3688 let s = format!("{err:?}");
3689 assert!(
3690 s.contains("must start with `+` or `-`") || s.contains("not a signed integer"),
3691 "unexpected error: {err:?}"
3692 );
3693 }
3694
3695 #[test]
3698 fn roll_keeps_source_order_with_neighbours() {
3699 let p = parse(&to_vec(&[
3700 "a.png", "-resize", "100x100", "-roll", "+5+5", "-strip", "b.png",
3701 ]))
3702 .unwrap();
3703 assert_eq!(
3704 p.ops,
3705 vec![
3706 Op::Resize {
3707 width: 100,
3708 height: 100,
3709 mode: ResizeMode::Default,
3710 },
3711 Op::Roll { dx: 5, dy: 5 },
3712 Op::Strip,
3713 ]
3714 );
3715 }
3716
3717 #[test]
3726 fn adversarial_argv_never_panics() {
3727 let long = "x".repeat(10_000);
3728 let long_selector = format!("in.pdf[{}]", "9".repeat(5_000));
3729 let cases: Vec<Vec<&str>> = vec![
3730 vec!["in.png", "-resize", "x", "out.png"],
3732 vec!["in.png", "-resize", "0x0", "out.png"],
3733 vec!["in.png", "-resize", "-1x-1", "out.png"],
3734 vec!["in.png", "-resize", "99999999999999999999x1", "out.png"],
3735 vec!["in.png", "-resize", "8x", "out.png"],
3736 vec!["in.png", "-resize", "!", "out.png"],
3737 vec!["in.png", "-crop", "1x1", "out.png"],
3738 vec!["in.png", "-crop", "1x1+", "out.png"],
3739 vec!["in.png", "-crop", "1x1+1+1+1", "out.png"],
3740 vec!["in.png", "-extent", "+5+5", "out.png"],
3741 vec!["in.png", "-roll", "++", "out.png"],
3742 vec!["in.png", "-roll", "5", "out.png"],
3743 vec!["in.png", "-vignette", "+", "out.png"],
3744 vec!["in.png", "-vignette", "1+2+3+4+5", "out.png"],
3745 vec!["in.pdf[", "out.png"],
3747 vec!["in.pdf[]", "out.png"],
3748 vec!["in.pdf[1-2-3]", "out.png"],
3749 vec!["in.pdf[,]", "out.png"],
3750 vec!["in.pdf[0,,1]", "out.png"],
3751 vec!["in.pdf[abc]", "out.png"],
3752 vec!["in.pdf[999999999999999999999]", "out.png"],
3753 vec!["]", "out.png"],
3754 vec!["[0]", "out.png"],
3755 vec!["in.png", "-background", "#", "out.png"],
3757 vec!["in.png", "-background", "#12345", "out.png"],
3758 vec!["in.png", "-background", "#gggggg", "out.png"],
3759 vec!["in.png", "-background", "notacolor", "out.png"],
3760 vec!["in.png", "-colorize", "/", "out.png"],
3761 vec!["in.png", "-colorize", "300x300x300/2", "out.png"],
3762 vec!["in.png", "-quality", "-1", "out.png"],
3764 vec!["in.png", "-quality", "99999999999", "out.png"],
3765 vec!["in.png", "-gamma", "NaN", "out.png"],
3766 vec!["in.png", "-gamma", "inf", "out.png"],
3767 vec!["in.png", "-gamma", "-0.0", "out.png"],
3768 vec!["in.png", "-fov", "NaN", "out.png"],
3769 vec!["in.png", "-modulate", ",,,", "out.png"],
3770 vec!["in.png", "-modulate", "1,2,3,4", "out.png"],
3771 vec!["in.png", "-level", "//", "out.png"],
3772 vec!["in.png", "-fuzz", "300%", "out.png"],
3773 vec!["in.png", "-aa", "0", "out.png"],
3774 vec!["in.png", "-define", "=v", "out.png"],
3776 vec!["in.png", "-define", "=", "out.png"],
3777 vec!["in.png", "--opt", "=", "out.png"],
3778 vec!["in.png", "--opt", "==", "out.png"],
3779 vec!["in.png", "--opt"],
3780 vec!["in.png", "-depth", "", "out.png"],
3781 vec!["in.png", "-depth", "99999999999", "out.png"],
3782 vec!["日本語.png", "出力.jpg"],
3783 vec!["in.png", "-résize", "8x8", "out.png"],
3784 vec![&long, "out.png"],
3785 vec!["in.png", &long_selector, "out.png"],
3786 vec!["in.png", "-resize"],
3788 vec!["-"],
3789 vec!["--"],
3790 vec!["in.png", "-", "out.png"],
3791 ];
3792 for argv in cases {
3793 let _ = parse(&to_vec(&argv));
3796 }
3797 }
3798
3799 #[test]
3802 fn help_parses_with_no_positionals() {
3803 for argv in [vec!["--help"], vec!["-help"]] {
3804 let p = parse(&to_vec(&argv)).unwrap();
3805 assert!(p.help, "argv={argv:?}");
3806 }
3807 }
3808
3809 #[test]
3810 fn help_anywhere_on_a_full_line_still_wins() {
3811 let p = parse(&to_vec(&["in.png", "--help", "--probe", "out.png"])).unwrap();
3814 assert!(p.help);
3815 }
3816
3817 #[test]
3821 fn usage_lists_every_known_flag_with_correct_lead() {
3822 let text = usage();
3823 for name in KNOWN_FLAG_NAMES {
3824 let lead = if matches!(*name, "probe" | "json" | "watch" | "help" | "opt") {
3825 "--"
3826 } else {
3827 "-"
3828 };
3829 let rendered = format!(" {lead}{name}");
3830 assert!(
3831 text.contains(&rendered),
3832 "usage text is missing '{rendered}':\n{text}"
3833 );
3834 }
3835 assert!(text.starts_with("usage: oxideav convert"));
3836 }
3837
3838 #[test]
3841 fn double_dash_treats_following_dash_args_as_positionals() {
3842 let p = parse(&to_vec(&["--", "-weird.png", "-out.jpg"])).unwrap();
3844 assert_eq!(p.input, "-weird.png");
3845 assert_eq!(p.output, "-out.jpg");
3846 assert!(p.ops.is_empty());
3847 }
3848
3849 #[test]
3850 fn flags_before_double_dash_still_parse() {
3851 let p = parse(&to_vec(&["-resize", "64x64", "--", "-in.png", "out.png"])).unwrap();
3852 assert_eq!(p.input, "-in.png");
3853 assert_eq!(p.output, "out.png");
3854 assert_eq!(p.ops.len(), 1);
3855 }
3856
3857 #[test]
3858 fn flags_after_double_dash_are_positionals_not_flags() {
3859 let err = parse(&to_vec(&["--", "-resize", "64x64", "out.png"])).unwrap_err();
3862 let s = format!("{err}");
3863 assert!(s.contains("multi-input"), "got: {err}");
3864 }
3865
3866 #[test]
3867 fn empty_positional_is_rejected_with_actionable_message() {
3868 for argv in [
3869 vec!["", "out.png"],
3870 vec!["in.png", ""],
3871 vec!["--", "", "out.png"],
3872 ] {
3873 let err = parse(&to_vec(&argv)).unwrap_err();
3874 let s = format!("{err}");
3875 assert!(s.contains("empty argument"), "argv={argv:?} got: {err}");
3876 }
3877 }
3878
3879 #[test]
3889 fn opt_parses_key_value_pairs_in_order() {
3890 let p = parse(&to_vec(&[
3891 "a.png",
3892 "--opt",
3893 "filter=none",
3894 "--opt",
3895 "tiles=2x2",
3896 "b.png",
3897 ]))
3898 .unwrap();
3899 assert_eq!(
3900 p.ops,
3901 vec![
3902 Op::EncoderOpt {
3903 key: "filter".into(),
3904 value: "none".into()
3905 },
3906 Op::EncoderOpt {
3907 key: "tiles".into(),
3908 value: "2x2".into()
3909 },
3910 ]
3911 );
3912 let p = parse(&to_vec(&[
3914 "a.png", "--opt", "k=a=b", "--opt", "e=", "b.png",
3915 ]))
3916 .unwrap();
3917 assert_eq!(
3918 p.ops,
3919 vec![
3920 Op::EncoderOpt {
3921 key: "k".into(),
3922 value: "a=b".into()
3923 },
3924 Op::EncoderOpt {
3925 key: "e".into(),
3926 value: String::new()
3927 },
3928 ]
3929 );
3930 for bad in ["novalue", "=v"] {
3931 let err = parse(&to_vec(&["a.png", "--opt", bad, "b.png"])).unwrap_err();
3932 assert!(format!("{err}").contains("--opt"), "{bad}: {err}");
3933 }
3934 }
3935
3936 #[test]
3937 fn depth_accepts_the_two_storage_depths() {
3938 let p = parse(&to_vec(&["a.heic", "-depth", "8", "b.png"])).unwrap();
3939 assert_eq!(p.ops, vec![Op::Depth(8)]);
3940 let p = parse(&to_vec(&["a.heic", "-depth", "16", "b.png"])).unwrap();
3941 assert_eq!(p.ops, vec![Op::Depth(16)]);
3942 for bad in ["12", "0", "abc", "-8", "256"] {
3943 let err = parse(&to_vec(&["a.heic", "-depth", bad, "b.png"])).unwrap_err();
3944 assert!(
3945 format!("{err}").contains("-depth") && format!("{err}").contains("8 or 16"),
3946 "{bad}: {err}"
3947 );
3948 }
3949 }
3950
3951 #[test]
3952 fn every_known_flag_name_is_accepted_by_the_parser() {
3953 let cases: &[(&str, &[&str])] = &[
3955 ("-aa", &["2"]),
3956 ("-alpha", &["on"]),
3957 ("-auto-gamma", &[]),
3958 ("-background", &["white"]),
3959 ("-bg", &["white"]),
3960 ("-blur", &["1x1"]),
3961 ("-brightness-contrast", &["10,5"]),
3962 ("-camera", &["30,45,1.5"]),
3963 ("-colorize", &["red/50%"]),
3964 ("-colors", &["16"]),
3965 ("-colorspace", &["gray"]),
3966 ("-contrast", &[]),
3967 ("-crop", &["1x1+0+0"]),
3968 ("-define", &["k=v"]),
3969 ("-density", &["72"]),
3970 ("-depth", &["8"]),
3971 ("-dither", &["none"]),
3972 ("-edge", &["1"]),
3973 ("-equalize", &[]),
3974 ("-extent", &["10x10"]),
3975 ("-flip", &[]),
3976 ("-flop", &[]),
3977 ("-format", &["png"]),
3978 ("-fov", &["60"]),
3979 ("-fuzz", &["5"]),
3980 ("-gamma", &["1.2"]),
3981 ("-gltf-format", &["glb"]),
3982 ("-level", &["16"]),
3983 ("-light", &["45,45,1.0"]),
3984 ("-modulate", &["100,100,100"]),
3985 ("-monochrome", &[]),
3986 ("-negate", &[]),
3987 ("-normalize", &[]),
3988 ("--opt", &["filter=none"]),
3989 ("-ping", &[]),
3990 ("-posterize", &["4"]),
3991 ("-projection", &["persp"]),
3992 ("-quality", &["85"]),
3993 ("-render", &["flat"]),
3994 ("-resize", &["64x64"]),
3995 ("-roll", &["+1+1"]),
3996 ("-rotate", &["90"]),
3997 ("-sepia", &["80%"]),
3998 ("-sharpen", &["1x0.5"]),
3999 ("-solarize", &["50%"]),
4000 ("-stl-format", &["binary"]),
4001 ("-strip", &[]),
4002 ("-thumbnail", &["64x64"]),
4003 ("-threshold", &["50%"]),
4004 ("-trim", &[]),
4005 ("-unsharp", &["1x1"]),
4006 ("-vignette", &["50"]),
4007 ];
4008 let mode_cases: &[&[&str]] = &[
4012 &["--probe", "in.png"],
4013 &["--probe", "--json", "in.png"],
4014 &["--probe", "--watch", "in.png"],
4015 &["--help"],
4016 ];
4017
4018 let mut covered: Vec<&str> = Vec::new();
4019 for (flag, extra) in cases {
4020 let mut argv: Vec<&str> = vec!["in.png"];
4021 argv.push(flag);
4022 argv.extend_from_slice(extra);
4023 argv.push("out.png");
4024 let res = parse(&to_vec(&argv));
4025 assert!(
4026 res.is_ok(),
4027 "flag {flag} rejected with sample value {extra:?}: {:?}",
4028 res.err()
4029 );
4030 covered.push(flag.trim_start_matches('-'));
4031 }
4032 for argv in mode_cases {
4033 let res = parse(&to_vec(argv));
4034 assert!(res.is_ok(), "argv {argv:?} rejected: {:?}", res.err());
4035 }
4036 covered.extend(["probe", "json", "watch", "help"]);
4037
4038 for name in KNOWN_FLAG_NAMES {
4040 assert!(
4041 covered.contains(name),
4042 "KNOWN_FLAG_NAMES entry '{name}' has no acceptance case — add one above"
4043 );
4044 }
4045 for name in &covered {
4048 assert!(
4049 KNOWN_FLAG_NAMES.contains(name),
4050 "flag '{name}' parses but is missing from KNOWN_FLAG_NAMES"
4051 );
4052 }
4053 }
4054}