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