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)]
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.
88pub fn decide(plan: &ConvertPlan) -> Result<Route, Error> {
89    // `--help` outranks everything — the user asked for the synopsis.
90    if plan.help {
91        return Ok(Route::Help);
92    }
93    // `-ping` short-circuits everything else, including `--probe`.
94    if plan.ping {
95        return Ok(Route::Ping);
96    }
97    if plan.probe {
98        return Ok(Route::Probe);
99    }
100
101    // Input classification outranks output classification: a PDF
102    // input always takes the Scene-aware side-channel, whatever the
103    // output extension says.
104    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        // Anything else (e.g. .pdf, .svg, .mp4, …): pairing-mismatch
126        // error with a did-you-mean hint over the 3D-output set.
127        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        // The icon writer bundles every size into ONE file; a `%d`
139        // template would be taken as a literal filename.
140        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    // A `%d` template on the pipeline route fans out one file per
150    // frame of the selected stream (image sequences, animations,
151    // video). Whether the output can do that — a still-image encoder
152    // and an input whose streams can be read — is the planner's call:
153    // it needs the registries, which this pure layer does not see.
154
155    Ok(Route::Pipeline)
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use crate::op::{ConvertPlan, PrintfTemplate};
162
163    /// Build a minimal plan for routing tests. Input paths here never
164    /// exist on disk, so classification is extension-only.
165    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    // ---- mode switches ----
191
192    #[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        // A probe on a PDF must NOT take the PDF conversion
219        // side-channel — probe owns its own PDF handling.
220        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    // ---- PDF input ----
230
231    #[test]
232    fn pdf_input_routes_to_side_channel_for_every_output_class() {
233        // Scene, vector, raster, template — all PDF-runner business.
234        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        // Input classification wins: the PDF side-channel gets first
256        // refusal (and will reject .ico itself as an unsupported
257        // output class) — the ICO writer never sees a PDF input.
258        assert_eq!(route_of("in.pdf", "icon.ico"), Route::PdfSideChannel);
259    }
260
261    // ---- 3D-asset input (mesh3d feature) ----
262
263    #[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        // FBX has a decoder but no encoder in the workspace; `.fbx`
310        // output is NOT in the 3D-output set and classify_output
311        // rejects it, so the pairing falls through to the typed error.
312        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        // `.slt` is one transposition away from `.stl`.
334        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        // `.svg` is a vector class the PDF path supports, but the 3D
343        // renderer only produces raster canvases today.
344        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    // ---- .ico output (ico feature) ----
363
364    #[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        // `.cur` needs a hotspot grammar first — documented in
375        // ico_runner. It stays on the regular pipeline path.
376        assert_eq!(route_of("logo.png", "pointer.cur"), Route::Pipeline);
377    }
378
379    // ---- regular pipeline path ----
380
381    #[test]
382    fn media_pairs_route_to_pipeline() {
383        // image → image, image → video-ish, video → video,
384        // audio → audio, generator → image: all regular pipeline.
385        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        // Per-frame fan-out is planned on the pipeline route; the
410        // router no longer gatekeeps it (the planner refuses outputs
411        // that cannot fan out, with a typed error).
412        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        // Routing doesn't gatekeep unknown formats — the pipeline
436        // (codec/container registries) owns that error so new sibling
437        // crates extend the set without touching the router.
438        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}