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 = 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 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 "-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
168fn 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
186fn 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}