oxideav_cli_convert/
args.rs1use crate::op::{ConvertPlan, Dither, Op};
10use oxideav_core::Error;
11
12pub 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 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 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 "-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#[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
190fn 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
208fn 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}