Skip to main content

oxideav_cli_convert/
args.rs

1//! IM-style arg parser.
2//!
3//! `convert INPUT [-op VALUE]... OUTPUT` — first positional arg is
4//! the input, last positional arg is the output, everything in
5//! between is `-flag` pairs.  Most single-word ops take exactly one
6//! following value; a few (`-strip`) are valueless.  Any unrecognised
7//! flag errors out with a clear message — we never silently drop.
8
9use crate::op::{ConvertPlan, Dither, Op};
10use oxideav_core::Error;
11
12/// Parse the slice that comes after `oxideav convert`.
13///
14/// Returns a `ConvertPlan` on success or an `Error::Invalid` /
15/// `Error::Unsupported` tagged with the specific `-flag` so callers
16/// can print the offending argument.
17pub fn parse(args: &[String]) -> Result<ConvertPlan, Error> {
18    if args.is_empty() {
19        return Err(Error::invalid(
20            "convert: no input file given (usage: convert INPUT [-op VALUE]... OUTPUT)",
21        ));
22    }
23    if args.len() < 2 {
24        return Err(Error::invalid(
25            "convert: no output file given (usage: convert INPUT [-op VALUE]... OUTPUT)",
26        ));
27    }
28
29    // First positional is input; last positional is output; the
30    // middle is a stream of -flag [value] pairs.
31    let input = args[0].clone();
32    let output = args[args.len() - 1].clone();
33    if output.starts_with('-') {
34        return Err(Error::invalid(format!(
35            "convert: output '{output}' looks like a flag — outputs must be the last positional argument",
36        )));
37    }
38
39    let mut ops: Vec<Op> = Vec::new();
40    // Dither state carries forward until the next -colors; it can
41    // precede or follow -colors in the arg list, but the pairing
42    // rule is "the most recent dither before a -colors".
43    let mut pending_dither = Dither::None;
44
45    let middle = &args[1..args.len() - 1];
46    let mut i = 0;
47    while i < middle.len() {
48        let flag = &middle[i];
49        let val = |k: usize| -> Result<&str, Error> {
50            middle
51                .get(k)
52                .map(|s| s.as_str())
53                .ok_or_else(|| Error::invalid(format!("convert: {flag}: missing value")))
54        };
55
56        match flag.as_str() {
57            "-resize" => {
58                let v = val(i + 1)?;
59                let (bang, core) = match v.strip_suffix('!') {
60                    Some(c) => (true, c),
61                    None => (false, v),
62                };
63                let (w, h) = parse_wxh(core)
64                    .map_err(|e| Error::invalid(format!("convert: -resize: {e}")))?;
65                ops.push(Op::Resize {
66                    width: w,
67                    height: h,
68                    bang,
69                });
70                i += 2;
71            }
72            "-blur" => {
73                let v = val(i + 1)?;
74                let (radius, sigma) =
75                    parse_blur(v).map_err(|e| Error::invalid(format!("convert: -blur: {e}")))?;
76                ops.push(Op::Blur { radius, sigma });
77                i += 2;
78            }
79            "-edge" => {
80                let v = val(i + 1)?;
81                let r: u32 = v.parse().map_err(|_| {
82                    Error::invalid(format!(
83                        "convert: -edge: '{v}' is not a non-negative integer"
84                    ))
85                })?;
86                ops.push(Op::Edge { radius: r });
87                i += 2;
88            }
89            "-colors" => {
90                let v = val(i + 1)?;
91                let n: u32 = v.parse().map_err(|_| {
92                    Error::invalid(format!(
93                        "convert: -colors: '{v}' is not a non-negative integer"
94                    ))
95                })?;
96                if !(2..=256).contains(&n) {
97                    return Err(Error::invalid(format!(
98                        "convert: -colors {n} out of range (2..=256)"
99                    )));
100                }
101                ops.push(Op::Colors {
102                    count: n,
103                    dither: pending_dither,
104                });
105                i += 2;
106            }
107            "-dither" => {
108                let v = val(i + 1)?;
109                pending_dither = Dither::parse(v).map_err(Error::invalid)?;
110                i += 2;
111            }
112            "-format" => {
113                let v = val(i + 1)?;
114                ops.push(Op::Format(v.to_string()));
115                i += 2;
116            }
117            "-quality" => {
118                let v = val(i + 1)?;
119                let q: u32 = v.parse().map_err(|_| {
120                    Error::invalid(format!(
121                        "convert: -quality: '{v}' is not a non-negative integer"
122                    ))
123                })?;
124                ops.push(Op::Quality(q));
125                i += 2;
126            }
127            "-strip" => {
128                ops.push(Op::Strip);
129                i += 1;
130            }
131            // Known IM ops we don't yet have a primitive for. Friendly
132            // message so users know what's missing, not a generic
133            // parse failure.
134            "-rotate"
135            | "-crop"
136            | "-flip"
137            | "-flop"
138            | "-negate"
139            | "-brightness-contrast"
140            | "-contrast"
141            | "-gamma"
142            | "-sepia"
143            | "-modulate"
144            | "-colorspace"
145            | "-level"
146            | "-normalize"
147            | "-sharpen"
148            | "-unsharp"
149            | "-threshold" => {
150                return Err(Error::unsupported(format!(
151                    "convert: {flag} is not yet implemented"
152                )));
153            }
154            other if other.starts_with('-') => {
155                return Err(Error::invalid(format!("convert: unknown flag '{other}'")));
156            }
157            other => {
158                return Err(Error::invalid(format!(
159                    "convert: unexpected positional argument '{other}' between input and output"
160                )));
161            }
162        }
163    }
164
165    Ok(ConvertPlan { input, ops, output })
166}
167
168/// Parse `WxH` — width × height. Accepts either lowercase `x` or
169/// uppercase `X`. Both parts must be non-negative integers.
170fn parse_wxh(s: &str) -> Result<(u32, u32), String> {
171    let (w, h) = s
172        .split_once(['x', 'X'])
173        .ok_or_else(|| format!("'{s}' is not in WxH form"))?;
174    let w: u32 = w
175        .parse()
176        .map_err(|_| format!("'{w}' is not a non-negative integer"))?;
177    let h: u32 = h
178        .parse()
179        .map_err(|_| format!("'{h}' is not a non-negative integer"))?;
180    if w == 0 || h == 0 {
181        return Err("width and height must both be positive".into());
182    }
183    Ok((w, h))
184}
185
186/// Parse `-blur RxS` or `-blur R`. When sigma is omitted we follow
187/// IM's convention of `sigma = radius / 2.0`. Unlike IM we don't
188/// accept floats for radius — `Blur::new` takes `u32`.
189fn parse_blur(s: &str) -> Result<(u32, f32), String> {
190    let (radius_str, sigma_str) = match s.split_once(['x', 'X']) {
191        Some((r, s)) => (r, Some(s)),
192        None => (s, None),
193    };
194    let radius: u32 = radius_str
195        .parse()
196        .map_err(|_| format!("'{radius_str}' is not a non-negative integer"))?;
197    let sigma: f32 = match sigma_str {
198        Some(s) => s
199            .parse()
200            .map_err(|_| format!("'{s}' is not a non-negative float"))?,
201        None => (radius as f32) / 2.0,
202    };
203    Ok((radius, sigma))
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    fn to_vec(s: &[&str]) -> Vec<String> {
211        s.iter().map(|x| x.to_string()).collect()
212    }
213
214    #[test]
215    fn minimal_input_output() {
216        let p = parse(&to_vec(&["in.png", "out.jpg"])).unwrap();
217        assert_eq!(p.input, "in.png");
218        assert_eq!(p.output, "out.jpg");
219        assert!(p.ops.is_empty());
220    }
221
222    #[test]
223    fn resize_bilinear_basic() {
224        let p = parse(&to_vec(&["a.png", "-resize", "800x600", "b.jpg"])).unwrap();
225        assert_eq!(
226            p.ops,
227            vec![Op::Resize {
228                width: 800,
229                height: 600,
230                bang: false
231            }]
232        );
233    }
234
235    #[test]
236    fn resize_bang_flag() {
237        let p = parse(&to_vec(&["a.png", "-resize", "64x32!", "b.jpg"])).unwrap();
238        assert_eq!(
239            p.ops,
240            vec![Op::Resize {
241                width: 64,
242                height: 32,
243                bang: true
244            }]
245        );
246    }
247
248    #[test]
249    fn blur_sigma_defaults_to_half_radius() {
250        let p = parse(&to_vec(&["a.png", "-blur", "4", "b.jpg"])).unwrap();
251        assert_eq!(
252            p.ops,
253            vec![Op::Blur {
254                radius: 4,
255                sigma: 2.0
256            }]
257        );
258    }
259
260    #[test]
261    fn blur_explicit_sigma() {
262        let p = parse(&to_vec(&["a.png", "-blur", "3x1.5", "b.jpg"])).unwrap();
263        match p.ops.as_slice() {
264            [Op::Blur { radius: 3, sigma }] => assert!((sigma - 1.5).abs() < 1e-6),
265            other => panic!("unexpected ops: {other:?}"),
266        }
267    }
268
269    #[test]
270    fn colors_picks_up_prior_dither() {
271        let p = parse(&to_vec(&[
272            "a.png",
273            "-dither",
274            "floyd_steinberg",
275            "-colors",
276            "64",
277            "b.gif",
278        ]))
279        .unwrap();
280        assert_eq!(
281            p.ops,
282            vec![Op::Colors {
283                count: 64,
284                dither: Dither::FloydSteinberg
285            }]
286        );
287    }
288
289    #[test]
290    fn rotate_is_unsupported() {
291        let err = parse(&to_vec(&["a.png", "-rotate", "90", "b.png"])).unwrap_err();
292        assert!(format!("{err:?}").contains("-rotate is not yet implemented"));
293    }
294
295    #[test]
296    fn unknown_flag_errors() {
297        let err = parse(&to_vec(&["a.png", "-fnord", "42", "b.png"])).unwrap_err();
298        assert!(format!("{err:?}").contains("unknown flag"));
299    }
300
301    #[test]
302    fn missing_output_errors() {
303        let err = parse(&to_vec(&["in.png"])).unwrap_err();
304        assert!(format!("{err:?}").contains("no output file given"));
305    }
306
307    #[test]
308    fn quality_strip() {
309        let p = parse(&to_vec(&["a.png", "-quality", "85", "-strip", "b.jpg"])).unwrap();
310        assert_eq!(p.ops, vec![Op::Quality(85), Op::Strip]);
311    }
312}