1use crate::op::ConvertPlan;
27use oxideav_core::Error;
28
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub enum Route {
33 Help,
35 Ping,
38 Probe,
41 PdfSideChannel,
44 #[cfg(feature = "mesh3d")]
47 Mesh3dToMesh3d,
48 #[cfg(feature = "mesh3d")]
51 Mesh3dToRaster,
52 #[cfg(feature = "ico")]
55 IcoOutput,
56 Pipeline,
58}
59
60impl Route {
61 pub fn as_tag(&self) -> &'static str {
63 match self {
64 Route::Help => "help",
65 Route::Ping => "ping",
66 Route::Probe => "probe",
67 Route::PdfSideChannel => "pdf",
68 #[cfg(feature = "mesh3d")]
69 Route::Mesh3dToMesh3d => "mesh3d-to-mesh3d",
70 #[cfg(feature = "mesh3d")]
71 Route::Mesh3dToRaster => "mesh3d-to-raster",
72 #[cfg(feature = "ico")]
73 Route::IcoOutput => "ico",
74 Route::Pipeline => "pipeline",
75 }
76 }
77}
78
79pub fn decide(plan: &ConvertPlan) -> Result<Route, Error> {
88 if plan.help {
90 return Ok(Route::Help);
91 }
92 if plan.ping {
94 return Ok(Route::Ping);
95 }
96 if plan.probe {
97 return Ok(Route::Probe);
98 }
99
100 if crate::pdf_runner::is_pdf_input(&plan.input) {
104 return Ok(Route::PdfSideChannel);
105 }
106
107 #[cfg(feature = "mesh3d")]
108 if crate::mesh3d_runner::is_mesh3d_input(&plan.input) {
109 if plan.output_template.is_some() {
110 return Err(Error::invalid(format!(
111 "convert: output '{}' has a `%d` template but 3D scenes are single-document; remove the template",
112 plan.output
113 )));
114 }
115 if crate::mesh3d_runner::is_mesh3d_output(&plan.output) {
116 return Ok(Route::Mesh3dToMesh3d);
117 }
118 if matches!(
119 crate::raster_io::classify_output(&plan.output),
120 Ok(crate::raster_io::OutputClass::Raster(_))
121 ) {
122 return Ok(Route::Mesh3dToRaster);
123 }
124 let out_ext = plan.output.rsplit('.').next().unwrap_or("");
127 let hint =
128 crate::suggest::format_hint(out_ext, &["stl", "obj", "gltf", "glb", "mtl", "usdz"]);
129 return Err(Error::invalid(format!(
130 "convert: 3D input '{}' must pair with a 3D output (.stl/.obj/.gltf/.glb/.mtl/.usdz) OR a raster output (.png/.jpg/.bmp/.webp); got '{}'{hint}",
131 plan.input, plan.output
132 )));
133 }
134
135 #[cfg(feature = "ico")]
136 if crate::ico_runner::is_ico_output(&plan.output) {
137 if plan.output_template.is_some() {
140 return Err(Error::unsupported(format!(
141 "convert: output '{}' has a `%d` template, but an .ico holds every size in one file; give a literal output filename",
142 plan.output
143 )));
144 }
145 return Ok(Route::IcoOutput);
146 }
147
148 Ok(Route::Pipeline)
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160 use crate::op::{ConvertPlan, PrintfTemplate};
161
162 fn plan(input: &str, output: &str) -> ConvertPlan {
165 ConvertPlan {
166 input: input.into(),
167 input_pages: None,
168 ops: vec![],
169 output: output.into(),
170 output_template: None,
171 help: false,
172 ping: false,
173 probe: false,
174 probe_json: false,
175 probe_watch: false,
176 mesh3d_options: Default::default(),
177 }
178 }
179
180 fn route_of(input: &str, output: &str) -> Route {
181 decide(&plan(input, output)).expect("routable")
182 }
183
184 fn err_of(input: &str, output: &str) -> String {
185 let p = plan(input, output);
186 format!("{}", decide(&p).expect_err("unroutable"))
187 }
188
189 #[test]
192 fn ping_short_circuits_everything() {
193 let mut p = plan("in.pdf", "out.png");
194 p.ping = true;
195 assert_eq!(decide(&p).unwrap(), Route::Ping);
196 }
197
198 #[test]
199 fn ping_outranks_probe() {
200 let mut p = plan("in.png", "");
201 p.ping = true;
202 p.probe = true;
203 assert_eq!(decide(&p).unwrap(), Route::Ping);
204 }
205
206 #[test]
207 fn help_outranks_every_other_mode() {
208 let mut p = plan("in.pdf", "out.png");
209 p.help = true;
210 p.ping = true;
211 p.probe = true;
212 assert_eq!(decide(&p).unwrap(), Route::Help);
213 }
214
215 #[test]
216 fn probe_outranks_input_classification() {
217 let mut p = plan("in.pdf", "");
220 p.probe = true;
221 assert_eq!(decide(&p).unwrap(), Route::Probe);
222
223 let mut p = plan("cube.stl", "");
224 p.probe = true;
225 assert_eq!(decide(&p).unwrap(), Route::Probe);
226 }
227
228 #[test]
231 fn pdf_input_routes_to_side_channel_for_every_output_class() {
232 for out in [
234 "out.pdf",
235 "out.svg",
236 "out.png",
237 "out.jpg",
238 "out.bmp",
239 "out.webp",
240 "page-%03d.png",
241 ] {
242 assert_eq!(route_of("in.pdf", out), Route::PdfSideChannel, "out={out}");
243 }
244 }
245
246 #[test]
247 fn pdf_extension_is_case_insensitive() {
248 assert_eq!(route_of("REPORT.PDF", "out.png"), Route::PdfSideChannel);
249 assert_eq!(route_of("Report.Pdf", "out.png"), Route::PdfSideChannel);
250 }
251
252 #[test]
253 fn pdf_input_outranks_ico_output() {
254 assert_eq!(route_of("in.pdf", "icon.ico"), Route::PdfSideChannel);
258 }
259
260 #[cfg(feature = "mesh3d")]
263 #[test]
264 fn mesh3d_to_mesh3d_covers_the_full_output_set() {
265 for out in [
266 "out.stl", "out.obj", "out.gltf", "out.glb", "out.mtl", "out.usdz",
267 ] {
268 assert_eq!(
269 route_of("cube.stl", out),
270 Route::Mesh3dToMesh3d,
271 "out={out}"
272 );
273 }
274 }
275
276 #[cfg(feature = "mesh3d")]
277 #[test]
278 fn mesh3d_to_raster_covers_the_raster_set() {
279 for input in [
280 "cube.stl",
281 "model.obj",
282 "scene.gltf",
283 "scene.glb",
284 "archive.usdz",
285 "materials.mtl",
286 "rig.fbx",
287 ] {
288 for out in ["out.png", "out.jpg", "out.jpeg", "out.bmp", "out.webp"] {
289 assert_eq!(
290 route_of(input, out),
291 Route::Mesh3dToRaster,
292 "input={input} out={out}"
293 );
294 }
295 }
296 }
297
298 #[cfg(feature = "mesh3d")]
299 #[test]
300 fn mesh3d_input_extension_is_case_insensitive() {
301 assert_eq!(route_of("CUBE.STL", "out.obj"), Route::Mesh3dToMesh3d);
302 assert_eq!(route_of("Scene.GlTF", "out.png"), Route::Mesh3dToRaster);
303 }
304
305 #[cfg(feature = "mesh3d")]
306 #[test]
307 fn fbx_is_input_only_no_same_class_reencode() {
308 let msg = err_of("rig.fbx", "out.fbx");
312 assert!(msg.contains("must pair with"), "got: {msg}");
313 }
314
315 #[cfg(feature = "mesh3d")]
316 #[test]
317 fn mesh3d_to_unroutable_output_errors_with_pairing_message() {
318 for out in ["out.mp4", "out.pdf", "out.wav", "out.ico"] {
319 let msg = err_of("cube.stl", out);
320 assert!(
321 msg.contains("must pair with a 3D output"),
322 "out={out} got: {msg}"
323 );
324 assert!(msg.contains("cube.stl"), "out={out} got: {msg}");
325 assert!(msg.contains(out), "out={out} got: {msg}");
326 }
327 }
328
329 #[cfg(feature = "mesh3d")]
330 #[test]
331 fn mesh3d_to_close_typo_output_carries_did_you_mean_hint() {
332 let msg = err_of("cube.stl", "out.slt");
334 assert!(msg.contains("did you mean"), "got: {msg}");
335 assert!(msg.contains("stl"), "got: {msg}");
336 }
337
338 #[cfg(feature = "mesh3d")]
339 #[test]
340 fn mesh3d_to_svg_errors_rather_than_vector_rendering() {
341 let msg = err_of("cube.stl", "out.svg");
344 assert!(msg.contains("must pair with"), "got: {msg}");
345 }
346
347 #[cfg(feature = "mesh3d")]
348 #[test]
349 fn mesh3d_with_printf_template_errors() {
350 let mut p = plan("cube.stl", "frame-%03d.png");
351 p.output_template = Some(PrintfTemplate {
352 prefix: "frame-".into(),
353 width: 3,
354 suffix: ".png".into(),
355 });
356 let msg = format!("{}", decide(&p).expect_err("template must be rejected"));
357 assert!(msg.contains("single-document"), "got: {msg}");
358 assert!(msg.contains("frame-%03d.png"), "got: {msg}");
359 }
360
361 #[cfg(feature = "ico")]
364 #[test]
365 fn raster_input_to_ico_routes_to_icon_writer() {
366 assert_eq!(route_of("logo.png", "logo.ico"), Route::IcoOutput);
367 assert_eq!(route_of("logo.bmp", "LOGO.ICO"), Route::IcoOutput);
368 }
369
370 #[cfg(feature = "ico")]
371 #[test]
372 fn cur_output_does_not_route_to_icon_writer() {
373 assert_eq!(route_of("logo.png", "pointer.cur"), Route::Pipeline);
376 }
377
378 #[test]
381 fn media_pairs_route_to_pipeline() {
382 for (input, output) in [
385 ("in.png", "out.jpg"),
386 ("in.jpg", "out.webp"),
387 ("in.bmp", "out.png"),
388 ("movie.mp4", "movie.mkv"),
389 ("movie.mkv", "movie.webm"),
390 ("in.png", "out.gif"),
391 ("sound.wav", "sound.flac"),
392 ("sound.mp3", "sound.ogg"),
393 ("sound.flac", "sound.wav"),
394 ("xc:red", "red.png"),
395 ("gradient:red-blue", "g.png"),
396 ("subs.srt", "subs.vtt"),
397 ] {
398 assert_eq!(
399 route_of(input, output),
400 Route::Pipeline,
401 "input={input} output={output}"
402 );
403 }
404 }
405
406 #[test]
407 fn printf_template_on_pipeline_route_routes_to_the_planner() {
408 let mut p = plan("movie.mp4", "frame-%03d.png");
412 p.output_template = Some(PrintfTemplate {
413 prefix: "frame-".into(),
414 width: 3,
415 suffix: ".png".into(),
416 });
417 assert_eq!(decide(&p).unwrap(), Route::Pipeline);
418 }
419
420 #[cfg(feature = "ico")]
421 #[test]
422 fn printf_template_on_ico_route_is_a_typed_error() {
423 let mut p = plan("logo.png", "icon-%d.ico");
424 p.output_template = Some(PrintfTemplate {
425 prefix: "icon-".into(),
426 width: 0,
427 suffix: ".ico".into(),
428 });
429 assert!(decide(&p).is_err());
430 }
431
432 #[test]
433 fn unknown_extensions_still_route_to_pipeline() {
434 assert_eq!(route_of("in.xyz", "out.abc"), Route::Pipeline);
438 assert_eq!(route_of("noext", "out"), Route::Pipeline);
439 }
440
441 #[test]
442 fn route_tags_are_stable() {
443 assert_eq!(Route::Help.as_tag(), "help");
444 assert_eq!(Route::Ping.as_tag(), "ping");
445 assert_eq!(Route::Probe.as_tag(), "probe");
446 assert_eq!(Route::PdfSideChannel.as_tag(), "pdf");
447 assert_eq!(Route::Pipeline.as_tag(), "pipeline");
448 #[cfg(feature = "mesh3d")]
449 {
450 assert_eq!(Route::Mesh3dToMesh3d.as_tag(), "mesh3d-to-mesh3d");
451 assert_eq!(Route::Mesh3dToRaster.as_tag(), "mesh3d-to-raster");
452 }
453 #[cfg(feature = "ico")]
454 assert_eq!(Route::IcoOutput.as_tag(), "ico");
455 }
456}