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;
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)]
32pub enum Route {
33    /// `--help` / `-help`: print the usage synopsis, nothing else.
34    Help,
35    /// `-ping`: print one IM-format header line per image/stream,
36    /// no pixel decode, no output write.
37    Ping,
38    /// `--probe`: decode far enough for structural metadata, print a
39    /// summary, no output write.
40    Probe,
41    /// PDF input: Scene-aware runner (vector-preserving PDF/SVG
42    /// output, or per-page rasterisation).
43    PdfSideChannel,
44    /// 3D-asset input paired with a 3D-asset output: decode →
45    /// re-encode through the `Mesh3DRegistry`.
46    #[cfg(feature = "mesh3d")]
47    Mesh3dToMesh3d,
48    /// 3D-asset input paired with a raster output: software-render
49    /// the scene, then apply raster ops and encode.
50    #[cfg(feature = "mesh3d")]
51    Mesh3dToRaster,
52    /// `.ico` output on a non-PDF, non-3D input: multi-resolution
53    /// icon writer driven by `-define icon:auto-resize=…`.
54    #[cfg(feature = "ico")]
55    IcoOutput,
56    /// The regular path: [`crate::plan_to_job`] → pipeline executor.
57    Pipeline,
58}
59
60impl Route {
61    /// Stable lowercase tag, handy for log lines and test diagnostics.
62    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
79/// Classify a plan into its execution [`Route`], or return the typed
80/// error for unroutable pairings (3D input × non-3D/non-raster
81/// output, `%d` template on a single-document 3D scene).
82///
83/// Pure with one caveat: PDF-input detection falls back to sniffing
84/// the file's magic bytes when the extension isn't `.pdf`, so an
85/// extensionless path that exists on disk may be classified by
86/// content. Paths that don't exist are classified by extension alone.
87pub fn decide(plan: &ConvertPlan) -> Result<Route, Error> {
88    // `--help` outranks everything — the user asked for the synopsis.
89    if plan.help {
90        return Ok(Route::Help);
91    }
92    // `-ping` short-circuits everything else, including `--probe`.
93    if plan.ping {
94        return Ok(Route::Ping);
95    }
96    if plan.probe {
97        return Ok(Route::Probe);
98    }
99
100    // Input classification outranks output classification: a PDF
101    // input always takes the Scene-aware side-channel, whatever the
102    // output extension says.
103    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        // Anything else (e.g. .pdf, .svg, .mp4, …): pairing-mismatch
125        // error with a did-you-mean hint over the 3D-output set.
126        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        // The icon writer bundles every size into ONE file; a `%d`
138        // template would be taken as a literal filename.
139        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    // A `%d` template on the pipeline route fans out one file per
149    // frame of the selected stream (image sequences, animations,
150    // video). Whether the output can do that — a still-image encoder
151    // and an input whose streams can be read — is the planner's call:
152    // it needs the registries, which this pure layer does not see.
153
154    Ok(Route::Pipeline)
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use crate::op::{ConvertPlan, PrintfTemplate};
161
162    /// Build a minimal plan for routing tests. Input paths here never
163    /// exist on disk, so classification is extension-only.
164    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    // ---- mode switches ----
190
191    #[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        // A probe on a PDF must NOT take the PDF conversion
218        // side-channel — probe owns its own PDF handling.
219        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    // ---- PDF input ----
229
230    #[test]
231    fn pdf_input_routes_to_side_channel_for_every_output_class() {
232        // Scene, vector, raster, template — all PDF-runner business.
233        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        // Input classification wins: the PDF side-channel gets first
255        // refusal (and will reject .ico itself as an unsupported
256        // output class) — the ICO writer never sees a PDF input.
257        assert_eq!(route_of("in.pdf", "icon.ico"), Route::PdfSideChannel);
258    }
259
260    // ---- 3D-asset input (mesh3d feature) ----
261
262    #[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        // FBX has a decoder but no encoder in the workspace; `.fbx`
309        // output is NOT in the 3D-output set and classify_output
310        // rejects it, so the pairing falls through to the typed error.
311        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        // `.slt` is one transposition away from `.stl`.
333        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        // `.svg` is a vector class the PDF path supports, but the 3D
342        // renderer only produces raster canvases today.
343        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    // ---- .ico output (ico feature) ----
362
363    #[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        // `.cur` needs a hotspot grammar first — documented in
374        // ico_runner. It stays on the regular pipeline path.
375        assert_eq!(route_of("logo.png", "pointer.cur"), Route::Pipeline);
376    }
377
378    // ---- regular pipeline path ----
379
380    #[test]
381    fn media_pairs_route_to_pipeline() {
382        // image → image, image → video-ish, video → video,
383        // audio → audio, generator → image: all regular pipeline.
384        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        // Per-frame fan-out is planned on the pipeline route; the
409        // router no longer gatekeeps it (the planner refuses outputs
410        // that cannot fan out, with a typed error).
411        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        // Routing doesn't gatekeep unknown formats — the pipeline
435        // (codec/container registries) owns that error so new sibling
436        // crates extend the set without touching the router.
437        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}