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    //
32    // Run the input through the generator-shorthand translator first
33    // so things like `xc:red` / `synth:5,sine,440` rewrite to canonical
34    // `generate://...` URIs before the source registry sees them.
35    // Other inputs (file paths, http(s):// URIs, already-canonical
36    // generate:// URIs) pass through unchanged. Only the convert verb
37    // does this — transcode/probe/remux/run accept canonical form only.
38    let input = translate_input_shorthand(&args[0]);
39    let output = args[args.len() - 1].clone();
40    if output.starts_with('-') {
41        return Err(Error::invalid(format!(
42            "convert: output '{output}' looks like a flag — outputs must be the last positional argument",
43        )));
44    }
45
46    let mut ops: Vec<Op> = Vec::new();
47    // Dither state carries forward until the next -colors; it can
48    // precede or follow -colors in the arg list, but the pairing
49    // rule is "the most recent dither before a -colors".
50    let mut pending_dither = Dither::None;
51
52    let middle = &args[1..args.len() - 1];
53    let mut i = 0;
54    while i < middle.len() {
55        let flag = &middle[i];
56        let val = |k: usize| -> Result<&str, Error> {
57            middle
58                .get(k)
59                .map(|s| s.as_str())
60                .ok_or_else(|| Error::invalid(format!("convert: {flag}: missing value")))
61        };
62
63        match flag.as_str() {
64            "-resize" => {
65                let v = val(i + 1)?;
66                let (bang, core) = match v.strip_suffix('!') {
67                    Some(c) => (true, c),
68                    None => (false, v),
69                };
70                let (w, h) = parse_wxh(core)
71                    .map_err(|e| Error::invalid(format!("convert: -resize: {e}")))?;
72                ops.push(Op::Resize {
73                    width: w,
74                    height: h,
75                    bang,
76                });
77                i += 2;
78            }
79            "-blur" => {
80                let v = val(i + 1)?;
81                let (radius, sigma) =
82                    parse_blur(v).map_err(|e| Error::invalid(format!("convert: -blur: {e}")))?;
83                ops.push(Op::Blur { radius, sigma });
84                i += 2;
85            }
86            "-edge" => {
87                let v = val(i + 1)?;
88                let r: u32 = v.parse().map_err(|_| {
89                    Error::invalid(format!(
90                        "convert: -edge: '{v}' is not a non-negative integer"
91                    ))
92                })?;
93                ops.push(Op::Edge { radius: r });
94                i += 2;
95            }
96            "-colors" => {
97                let v = val(i + 1)?;
98                let n: u32 = v.parse().map_err(|_| {
99                    Error::invalid(format!(
100                        "convert: -colors: '{v}' is not a non-negative integer"
101                    ))
102                })?;
103                if !(2..=256).contains(&n) {
104                    return Err(Error::invalid(format!(
105                        "convert: -colors {n} out of range (2..=256)"
106                    )));
107                }
108                ops.push(Op::Colors {
109                    count: n,
110                    dither: pending_dither,
111                });
112                i += 2;
113            }
114            "-dither" => {
115                let v = val(i + 1)?;
116                pending_dither = Dither::parse(v).map_err(Error::invalid)?;
117                i += 2;
118            }
119            "-format" => {
120                let v = val(i + 1)?;
121                ops.push(Op::Format(v.to_string()));
122                i += 2;
123            }
124            "-quality" => {
125                let v = val(i + 1)?;
126                let q: u32 = v.parse().map_err(|_| {
127                    Error::invalid(format!(
128                        "convert: -quality: '{v}' is not a non-negative integer"
129                    ))
130                })?;
131                ops.push(Op::Quality(q));
132                i += 2;
133            }
134            "-strip" => {
135                ops.push(Op::Strip);
136                i += 1;
137            }
138            // Known IM ops we don't yet have a primitive for. Friendly
139            // message so users know what's missing, not a generic
140            // parse failure.
141            "-rotate"
142            | "-crop"
143            | "-flip"
144            | "-flop"
145            | "-negate"
146            | "-brightness-contrast"
147            | "-contrast"
148            | "-gamma"
149            | "-sepia"
150            | "-modulate"
151            | "-colorspace"
152            | "-level"
153            | "-normalize"
154            | "-sharpen"
155            | "-unsharp"
156            | "-threshold" => {
157                return Err(Error::unsupported(format!(
158                    "convert: {flag} is not yet implemented"
159                )));
160            }
161            other if other.starts_with('-') => {
162                return Err(Error::invalid(format!("convert: unknown flag '{other}'")));
163            }
164            other => {
165                return Err(Error::invalid(format!(
166                    "convert: unexpected positional argument '{other}' between input and output"
167                )));
168            }
169        }
170    }
171
172    Ok(ConvertPlan { input, ops, output })
173}
174
175/// Apply the `oxideav-generator` shorthand translator when it's
176/// linked in; otherwise return the input verbatim. Keeping the call
177/// behind a feature gate means the convert verb still works (with a
178/// clear error from the source registry) when the generator crate is
179/// excluded.
180#[cfg(feature = "generator")]
181fn translate_input_shorthand(input: &str) -> String {
182    oxideav_generator::shorthand::translate(input)
183}
184
185#[cfg(not(feature = "generator"))]
186fn translate_input_shorthand(input: &str) -> String {
187    input.to_string()
188}
189
190/// Parse `WxH` — width × height. Accepts either lowercase `x` or
191/// uppercase `X`. Both parts must be non-negative integers.
192fn parse_wxh(s: &str) -> Result<(u32, u32), String> {
193    let (w, h) = s
194        .split_once(['x', 'X'])
195        .ok_or_else(|| format!("'{s}' is not in WxH form"))?;
196    let w: u32 = w
197        .parse()
198        .map_err(|_| format!("'{w}' is not a non-negative integer"))?;
199    let h: u32 = h
200        .parse()
201        .map_err(|_| format!("'{h}' is not a non-negative integer"))?;
202    if w == 0 || h == 0 {
203        return Err("width and height must both be positive".into());
204    }
205    Ok((w, h))
206}
207
208/// Parse `-blur RxS` or `-blur R`. When sigma is omitted we follow
209/// IM's convention of `sigma = radius / 2.0`. Unlike IM we don't
210/// accept floats for radius — `Blur::new` takes `u32`.
211fn parse_blur(s: &str) -> Result<(u32, f32), String> {
212    let (radius_str, sigma_str) = match s.split_once(['x', 'X']) {
213        Some((r, s)) => (r, Some(s)),
214        None => (s, None),
215    };
216    let radius: u32 = radius_str
217        .parse()
218        .map_err(|_| format!("'{radius_str}' is not a non-negative integer"))?;
219    let sigma: f32 = match sigma_str {
220        Some(s) => s
221            .parse()
222            .map_err(|_| format!("'{s}' is not a non-negative float"))?,
223        None => (radius as f32) / 2.0,
224    };
225    Ok((radius, sigma))
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    fn to_vec(s: &[&str]) -> Vec<String> {
233        s.iter().map(|x| x.to_string()).collect()
234    }
235
236    #[test]
237    fn minimal_input_output() {
238        let p = parse(&to_vec(&["in.png", "out.jpg"])).unwrap();
239        assert_eq!(p.input, "in.png");
240        assert_eq!(p.output, "out.jpg");
241        assert!(p.ops.is_empty());
242    }
243
244    #[test]
245    fn resize_bilinear_basic() {
246        let p = parse(&to_vec(&["a.png", "-resize", "800x600", "b.jpg"])).unwrap();
247        assert_eq!(
248            p.ops,
249            vec![Op::Resize {
250                width: 800,
251                height: 600,
252                bang: false
253            }]
254        );
255    }
256
257    #[test]
258    fn resize_bang_flag() {
259        let p = parse(&to_vec(&["a.png", "-resize", "64x32!", "b.jpg"])).unwrap();
260        assert_eq!(
261            p.ops,
262            vec![Op::Resize {
263                width: 64,
264                height: 32,
265                bang: true
266            }]
267        );
268    }
269
270    #[test]
271    fn blur_sigma_defaults_to_half_radius() {
272        let p = parse(&to_vec(&["a.png", "-blur", "4", "b.jpg"])).unwrap();
273        assert_eq!(
274            p.ops,
275            vec![Op::Blur {
276                radius: 4,
277                sigma: 2.0
278            }]
279        );
280    }
281
282    #[test]
283    fn blur_explicit_sigma() {
284        let p = parse(&to_vec(&["a.png", "-blur", "3x1.5", "b.jpg"])).unwrap();
285        match p.ops.as_slice() {
286            [Op::Blur { radius: 3, sigma }] => assert!((sigma - 1.5).abs() < 1e-6),
287            other => panic!("unexpected ops: {other:?}"),
288        }
289    }
290
291    #[test]
292    fn colors_picks_up_prior_dither() {
293        let p = parse(&to_vec(&[
294            "a.png",
295            "-dither",
296            "floyd_steinberg",
297            "-colors",
298            "64",
299            "b.gif",
300        ]))
301        .unwrap();
302        assert_eq!(
303            p.ops,
304            vec![Op::Colors {
305                count: 64,
306                dither: Dither::FloydSteinberg
307            }]
308        );
309    }
310
311    #[test]
312    fn rotate_is_unsupported() {
313        let err = parse(&to_vec(&["a.png", "-rotate", "90", "b.png"])).unwrap_err();
314        assert!(format!("{err:?}").contains("-rotate is not yet implemented"));
315    }
316
317    #[test]
318    fn unknown_flag_errors() {
319        let err = parse(&to_vec(&["a.png", "-fnord", "42", "b.png"])).unwrap_err();
320        assert!(format!("{err:?}").contains("unknown flag"));
321    }
322
323    #[test]
324    fn missing_output_errors() {
325        let err = parse(&to_vec(&["in.png"])).unwrap_err();
326        assert!(format!("{err:?}").contains("no output file given"));
327    }
328
329    #[test]
330    fn quality_strip() {
331        let p = parse(&to_vec(&["a.png", "-quality", "85", "-strip", "b.jpg"])).unwrap();
332        assert_eq!(p.ops, vec![Op::Quality(85), Op::Strip]);
333    }
334}