Skip to main content

oxideav_cli_convert/
route.rs

1//! Pure routing layer: decide which execution path a parsed
2//! [`ConvertPlan`] takes, without performing any conversion work.
3//!
4//! [`crate::run`] used to make these decisions inline, which meant the
5//! media-kind × media-kind dispatch matrix could only be exercised by
6//! actually running conversions. Extracting the decision into
7//! [`decide`] keeps `run` a thin dispatcher and lets tests pin every
8//! cell of the matrix with nothing but a `ConvertPlan`.
9//!
10//! Precedence (first match wins), mirroring the historical inline
11//! order:
12//!
13//! 1. `-ping` — header-only inspection, no decode.
14//! 2. `--probe` — structural inspection, no output write.
15//! 3. PDF input — Scene-aware side-channel (`pdf_runner`).
16//! 4. 3D-asset input (`mesh3d` feature) — same-class re-encode,
17//!    3D→raster render, or a typed pairing error.
18//! 5. `.ico` output (`ico` feature) — multi-resolution icon writer.
19//! 6. Everything else — the regular `oxideav-pipeline` path.
20//!
21//! The precedence order is observable behaviour: `in.pdf out.ico`
22//! routes to the PDF side-channel (which then rejects `.ico` as an
23//! unsupported output class), NOT to the ICO writer, because input
24//! classification outranks output classification.
25
26use crate::op::ConvertPlan;
27use oxideav_core::{Error, RuntimeContext};
28
29/// One cell of the routing matrix — the execution path [`crate::run`]
30/// dispatches to for a given plan.
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32#[non_exhaustive]
33pub enum Route {
34    /// `--help` / `-help`: print the usage synopsis, nothing else.
35    Help,
36    /// `-ping`: print one IM-format header line per image/stream,
37    /// no pixel decode, no output write.
38    Ping,
39    /// `--probe`: decode far enough for structural metadata, print a
40    /// summary, no output write.
41    Probe,
42    /// PDF input: Scene-aware runner (vector-preserving PDF/SVG
43    /// output, or per-page rasterisation).
44    PdfSideChannel,
45    /// 3D-asset input paired with a 3D-asset output: decode →
46    /// re-encode through the `Mesh3DRegistry`.
47    #[cfg(feature = "mesh3d")]
48    Mesh3dToMesh3d,
49    /// 3D-asset input paired with a raster output: software-render
50    /// the scene, then apply raster ops and encode.
51    #[cfg(feature = "mesh3d")]
52    Mesh3dToRaster,
53    /// `.ico` output on a non-PDF, non-3D input: multi-resolution
54    /// icon writer driven by `-define icon:auto-resize=…`.
55    #[cfg(feature = "ico")]
56    IcoOutput,
57    /// The regular path: [`crate::plan_to_job`] → pipeline executor.
58    Pipeline,
59}
60
61impl Route {
62    /// Stable lowercase tag, handy for log lines and test diagnostics.
63    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
80/// Classify a plan into its execution [`Route`], or return the typed
81/// error for unroutable pairings (3D input × non-3D/non-raster
82/// output, `%d` template on a single-document 3D scene).
83///
84/// Pure with one caveat: PDF-input detection falls back to sniffing
85/// the file's magic bytes when the extension isn't `.pdf`, so an
86/// extensionless path that exists on disk may be classified by
87/// content. Paths that don't exist are classified by extension alone.
88///
89/// Without a registry the raster output set of the 3D→raster pairing
90/// is the four registry-free families (png / jpg / bmp / webp);
91/// [`decide_with`] widens it to every still-image extension the
92/// caller's `RuntimeContext` can encode.
93pub fn decide(plan: &ConvertPlan) -> Result<Route, Error> {
94    decide_with(plan, None)
95}
96
97/// [`decide`] with the caller's registries: a 3D input paired with any
98/// extension `ctx` has a still-image encoder for (tiff, gif, qoi, …)
99/// routes to [`Route::Mesh3dToRaster`] instead of the pairing error.
100pub fn decide_with(plan: &ConvertPlan, ctx: Option<&RuntimeContext>) -> Result<Route, Error> {
101    // `--help` outranks everything — the user asked for the synopsis.
102    if plan.help {
103        return Ok(Route::Help);
104    }
105    // `-ping` short-circuits everything else, including `--probe`.
106    if plan.ping {
107        return Ok(Route::Ping);
108    }
109    if plan.probe {
110        return Ok(Route::Probe);
111    }
112
113    // Input classification outranks output classification: a PDF
114    // input always takes the Scene-aware side-channel, whatever the
115    // output extension says.
116    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        // Anything else (e.g. .pdf, .svg, .mp4, …): pairing-mismatch
138        // error with a did-you-mean hint over the 3D-output set.
139        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        // The icon writer bundles every size into ONE file; a `%d`
151        // template would be taken as a literal filename.
152        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    // A `%d` template on the pipeline route fans out one file per
162    // frame of the selected stream (image sequences, animations,
163    // video). Whether the output can do that — a still-image encoder
164    // and an input whose streams can be read — is the planner's call:
165    // it needs the registries, which this pure layer does not see.
166
167    Ok(Route::Pipeline)
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use crate::op::{ConvertPlan, PrintfTemplate};
174
175    /// Build a minimal plan for routing tests. Input paths here never
176    /// exist on disk, so classification is extension-only.
177    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    // ---- mode switches ----
203
204    #[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        // A probe on a PDF must NOT take the PDF conversion
231        // side-channel — probe owns its own PDF handling.
232        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    // ---- PDF input ----
242
243    #[test]
244    fn pdf_input_routes_to_side_channel_for_every_output_class() {
245        // Scene, vector, raster, template — all PDF-runner business.
246        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        // Input classification wins: the PDF side-channel gets first
268        // refusal (and will reject .ico itself as an unsupported
269        // output class) — the ICO writer never sees a PDF input.
270        assert_eq!(route_of("in.pdf", "icon.ico"), Route::PdfSideChannel);
271    }
272
273    // ---- 3D-asset input (mesh3d feature) ----
274
275    #[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        // FBX has a decoder but no encoder in the workspace; `.fbx`
322        // output is NOT in the 3D-output set and classify_output
323        // rejects it, so the pairing falls through to the typed error.
324        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        // `.slt` is one transposition away from `.stl`.
346        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        // `.svg` is a vector class the PDF path supports, but the 3D
355        // renderer only produces raster canvases today.
356        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    // ---- .ico output (ico feature) ----
375
376    #[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        // `.cur` needs a hotspot grammar first — documented in
387        // ico_runner. It stays on the regular pipeline path.
388        assert_eq!(route_of("logo.png", "pointer.cur"), Route::Pipeline);
389    }
390
391    // ---- regular pipeline path ----
392
393    #[test]
394    fn media_pairs_route_to_pipeline() {
395        // image → image, image → video-ish, video → video,
396        // audio → audio, generator → image: all regular pipeline.
397        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        // Per-frame fan-out is planned on the pipeline route; the
422        // router no longer gatekeeps it (the planner refuses outputs
423        // that cannot fan out, with a typed error).
424        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        // Routing doesn't gatekeep unknown formats — the pipeline
448        // (codec/container registries) owns that error so new sibling
449        // crates extend the set without touching the router.
450        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}