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