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