oxideav-cli-convert 0.0.6

ImageMagick-style convert engine for the oxideav CLI — translates IM arg syntax into an oxideav-pipeline Job
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
//! Pure routing layer: decide which execution path a parsed
//! [`ConvertPlan`] takes, without performing any conversion work.
//!
//! [`crate::run`] used to make these decisions inline, which meant the
//! media-kind × media-kind dispatch matrix could only be exercised by
//! actually running conversions. Extracting the decision into
//! [`decide`] keeps `run` a thin dispatcher and lets tests pin every
//! cell of the matrix with nothing but a `ConvertPlan`.
//!
//! Precedence (first match wins), mirroring the historical inline
//! order:
//!
//! 1. `-ping` — header-only inspection, no decode.
//! 2. `--probe` — structural inspection, no output write.
//! 3. PDF input — Scene-aware side-channel (`pdf_runner`).
//! 4. 3D-asset input (`mesh3d` feature) — same-class re-encode,
//!    3D→raster render, or a typed pairing error.
//! 5. `.ico` output (`ico` feature) — multi-resolution icon writer.
//! 6. Everything else — the regular `oxideav-pipeline` path.
//!
//! The precedence order is observable behaviour: `in.pdf out.ico`
//! routes to the PDF side-channel (which then rejects `.ico` as an
//! unsupported output class), NOT to the ICO writer, because input
//! classification outranks output classification.

use crate::op::ConvertPlan;
use oxideav_core::Error;

/// One cell of the routing matrix — the execution path [`crate::run`]
/// dispatches to for a given plan.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Route {
    /// `--help` / `-help`: print the usage synopsis, nothing else.
    Help,
    /// `-ping`: print one IM-format header line per image/stream,
    /// no pixel decode, no output write.
    Ping,
    /// `--probe`: decode far enough for structural metadata, print a
    /// summary, no output write.
    Probe,
    /// PDF input: Scene-aware runner (vector-preserving PDF/SVG
    /// output, or per-page rasterisation).
    PdfSideChannel,
    /// 3D-asset input paired with a 3D-asset output: decode →
    /// re-encode through the `Mesh3DRegistry`.
    #[cfg(feature = "mesh3d")]
    Mesh3dToMesh3d,
    /// 3D-asset input paired with a raster output: software-render
    /// the scene, then apply raster ops and encode.
    #[cfg(feature = "mesh3d")]
    Mesh3dToRaster,
    /// `.ico` output on a non-PDF, non-3D input: multi-resolution
    /// icon writer driven by `-define icon:auto-resize=…`.
    #[cfg(feature = "ico")]
    IcoOutput,
    /// The regular path: [`crate::plan_to_job`] → pipeline executor.
    Pipeline,
}

impl Route {
    /// Stable lowercase tag, handy for log lines and test diagnostics.
    pub fn as_tag(&self) -> &'static str {
        match self {
            Route::Help => "help",
            Route::Ping => "ping",
            Route::Probe => "probe",
            Route::PdfSideChannel => "pdf",
            #[cfg(feature = "mesh3d")]
            Route::Mesh3dToMesh3d => "mesh3d-to-mesh3d",
            #[cfg(feature = "mesh3d")]
            Route::Mesh3dToRaster => "mesh3d-to-raster",
            #[cfg(feature = "ico")]
            Route::IcoOutput => "ico",
            Route::Pipeline => "pipeline",
        }
    }
}

/// Classify a plan into its execution [`Route`], or return the typed
/// error for unroutable pairings (3D input × non-3D/non-raster
/// output, `%d` template on a single-document 3D scene).
///
/// Pure with one caveat: PDF-input detection falls back to sniffing
/// the file's magic bytes when the extension isn't `.pdf`, so an
/// extensionless path that exists on disk may be classified by
/// content. Paths that don't exist are classified by extension alone.
pub fn decide(plan: &ConvertPlan) -> Result<Route, Error> {
    // `--help` outranks everything — the user asked for the synopsis.
    if plan.help {
        return Ok(Route::Help);
    }
    // `-ping` short-circuits everything else, including `--probe`.
    if plan.ping {
        return Ok(Route::Ping);
    }
    if plan.probe {
        return Ok(Route::Probe);
    }

    // Input classification outranks output classification: a PDF
    // input always takes the Scene-aware side-channel, whatever the
    // output extension says.
    if crate::pdf_runner::is_pdf_input(&plan.input) {
        return Ok(Route::PdfSideChannel);
    }

    #[cfg(feature = "mesh3d")]
    if crate::mesh3d_runner::is_mesh3d_input(&plan.input) {
        if plan.output_template.is_some() {
            return Err(Error::invalid(format!(
                "convert: output '{}' has a `%d` template but 3D scenes are single-document; remove the template",
                plan.output
            )));
        }
        if crate::mesh3d_runner::is_mesh3d_output(&plan.output) {
            return Ok(Route::Mesh3dToMesh3d);
        }
        if matches!(
            crate::raster_io::classify_output(&plan.output),
            Ok(crate::raster_io::OutputClass::Raster(_))
        ) {
            return Ok(Route::Mesh3dToRaster);
        }
        // Anything else (e.g. .pdf, .svg, .mp4, …): pairing-mismatch
        // error with a did-you-mean hint over the 3D-output set.
        let out_ext = plan.output.rsplit('.').next().unwrap_or("");
        let hint =
            crate::suggest::format_hint(out_ext, &["stl", "obj", "gltf", "glb", "mtl", "usdz"]);
        return Err(Error::invalid(format!(
            "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}",
            plan.input, plan.output
        )));
    }

    #[cfg(feature = "ico")]
    if crate::ico_runner::is_ico_output(&plan.output) {
        // The icon writer bundles every size into ONE file; a `%d`
        // template would be taken as a literal filename.
        if plan.output_template.is_some() {
            return Err(Error::unsupported(format!(
                "convert: output '{}' has a `%d` template, but an .ico holds every size in one file; give a literal output filename",
                plan.output
            )));
        }
        return Ok(Route::IcoOutput);
    }

    // A `%d` template on the pipeline route fans out one file per
    // frame of the selected stream (image sequences, animations,
    // video). Whether the output can do that — a still-image encoder
    // and an input whose streams can be read — is the planner's call:
    // it needs the registries, which this pure layer does not see.

    Ok(Route::Pipeline)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::op::{ConvertPlan, PrintfTemplate};

    /// Build a minimal plan for routing tests. Input paths here never
    /// exist on disk, so classification is extension-only.
    fn plan(input: &str, output: &str) -> ConvertPlan {
        ConvertPlan {
            input: input.into(),
            input_pages: None,
            ops: vec![],
            output: output.into(),
            output_template: None,
            help: false,
            ping: false,
            probe: false,
            probe_json: false,
            probe_watch: false,
            mesh3d_options: Default::default(),
        }
    }

    fn route_of(input: &str, output: &str) -> Route {
        decide(&plan(input, output)).expect("routable")
    }

    fn err_of(input: &str, output: &str) -> String {
        let p = plan(input, output);
        format!("{}", decide(&p).expect_err("unroutable"))
    }

    // ---- mode switches ----

    #[test]
    fn ping_short_circuits_everything() {
        let mut p = plan("in.pdf", "out.png");
        p.ping = true;
        assert_eq!(decide(&p).unwrap(), Route::Ping);
    }

    #[test]
    fn ping_outranks_probe() {
        let mut p = plan("in.png", "");
        p.ping = true;
        p.probe = true;
        assert_eq!(decide(&p).unwrap(), Route::Ping);
    }

    #[test]
    fn help_outranks_every_other_mode() {
        let mut p = plan("in.pdf", "out.png");
        p.help = true;
        p.ping = true;
        p.probe = true;
        assert_eq!(decide(&p).unwrap(), Route::Help);
    }

    #[test]
    fn probe_outranks_input_classification() {
        // A probe on a PDF must NOT take the PDF conversion
        // side-channel — probe owns its own PDF handling.
        let mut p = plan("in.pdf", "");
        p.probe = true;
        assert_eq!(decide(&p).unwrap(), Route::Probe);

        let mut p = plan("cube.stl", "");
        p.probe = true;
        assert_eq!(decide(&p).unwrap(), Route::Probe);
    }

    // ---- PDF input ----

    #[test]
    fn pdf_input_routes_to_side_channel_for_every_output_class() {
        // Scene, vector, raster, template — all PDF-runner business.
        for out in [
            "out.pdf",
            "out.svg",
            "out.png",
            "out.jpg",
            "out.bmp",
            "out.webp",
            "page-%03d.png",
        ] {
            assert_eq!(route_of("in.pdf", out), Route::PdfSideChannel, "out={out}");
        }
    }

    #[test]
    fn pdf_extension_is_case_insensitive() {
        assert_eq!(route_of("REPORT.PDF", "out.png"), Route::PdfSideChannel);
        assert_eq!(route_of("Report.Pdf", "out.png"), Route::PdfSideChannel);
    }

    #[test]
    fn pdf_input_outranks_ico_output() {
        // Input classification wins: the PDF side-channel gets first
        // refusal (and will reject .ico itself as an unsupported
        // output class) — the ICO writer never sees a PDF input.
        assert_eq!(route_of("in.pdf", "icon.ico"), Route::PdfSideChannel);
    }

    // ---- 3D-asset input (mesh3d feature) ----

    #[cfg(feature = "mesh3d")]
    #[test]
    fn mesh3d_to_mesh3d_covers_the_full_output_set() {
        for out in [
            "out.stl", "out.obj", "out.gltf", "out.glb", "out.mtl", "out.usdz",
        ] {
            assert_eq!(
                route_of("cube.stl", out),
                Route::Mesh3dToMesh3d,
                "out={out}"
            );
        }
    }

    #[cfg(feature = "mesh3d")]
    #[test]
    fn mesh3d_to_raster_covers_the_raster_set() {
        for input in [
            "cube.stl",
            "model.obj",
            "scene.gltf",
            "scene.glb",
            "archive.usdz",
            "materials.mtl",
            "rig.fbx",
        ] {
            for out in ["out.png", "out.jpg", "out.jpeg", "out.bmp", "out.webp"] {
                assert_eq!(
                    route_of(input, out),
                    Route::Mesh3dToRaster,
                    "input={input} out={out}"
                );
            }
        }
    }

    #[cfg(feature = "mesh3d")]
    #[test]
    fn mesh3d_input_extension_is_case_insensitive() {
        assert_eq!(route_of("CUBE.STL", "out.obj"), Route::Mesh3dToMesh3d);
        assert_eq!(route_of("Scene.GlTF", "out.png"), Route::Mesh3dToRaster);
    }

    #[cfg(feature = "mesh3d")]
    #[test]
    fn fbx_is_input_only_no_same_class_reencode() {
        // FBX has a decoder but no encoder in the workspace; `.fbx`
        // output is NOT in the 3D-output set and classify_output
        // rejects it, so the pairing falls through to the typed error.
        let msg = err_of("rig.fbx", "out.fbx");
        assert!(msg.contains("must pair with"), "got: {msg}");
    }

    #[cfg(feature = "mesh3d")]
    #[test]
    fn mesh3d_to_unroutable_output_errors_with_pairing_message() {
        for out in ["out.mp4", "out.pdf", "out.wav", "out.ico"] {
            let msg = err_of("cube.stl", out);
            assert!(
                msg.contains("must pair with a 3D output"),
                "out={out} got: {msg}"
            );
            assert!(msg.contains("cube.stl"), "out={out} got: {msg}");
            assert!(msg.contains(out), "out={out} got: {msg}");
        }
    }

    #[cfg(feature = "mesh3d")]
    #[test]
    fn mesh3d_to_close_typo_output_carries_did_you_mean_hint() {
        // `.slt` is one transposition away from `.stl`.
        let msg = err_of("cube.stl", "out.slt");
        assert!(msg.contains("did you mean"), "got: {msg}");
        assert!(msg.contains("stl"), "got: {msg}");
    }

    #[cfg(feature = "mesh3d")]
    #[test]
    fn mesh3d_to_svg_errors_rather_than_vector_rendering() {
        // `.svg` is a vector class the PDF path supports, but the 3D
        // renderer only produces raster canvases today.
        let msg = err_of("cube.stl", "out.svg");
        assert!(msg.contains("must pair with"), "got: {msg}");
    }

    #[cfg(feature = "mesh3d")]
    #[test]
    fn mesh3d_with_printf_template_errors() {
        let mut p = plan("cube.stl", "frame-%03d.png");
        p.output_template = Some(PrintfTemplate {
            prefix: "frame-".into(),
            width: 3,
            suffix: ".png".into(),
        });
        let msg = format!("{}", decide(&p).expect_err("template must be rejected"));
        assert!(msg.contains("single-document"), "got: {msg}");
        assert!(msg.contains("frame-%03d.png"), "got: {msg}");
    }

    // ---- .ico output (ico feature) ----

    #[cfg(feature = "ico")]
    #[test]
    fn raster_input_to_ico_routes_to_icon_writer() {
        assert_eq!(route_of("logo.png", "logo.ico"), Route::IcoOutput);
        assert_eq!(route_of("logo.bmp", "LOGO.ICO"), Route::IcoOutput);
    }

    #[cfg(feature = "ico")]
    #[test]
    fn cur_output_does_not_route_to_icon_writer() {
        // `.cur` needs a hotspot grammar first — documented in
        // ico_runner. It stays on the regular pipeline path.
        assert_eq!(route_of("logo.png", "pointer.cur"), Route::Pipeline);
    }

    // ---- regular pipeline path ----

    #[test]
    fn media_pairs_route_to_pipeline() {
        // image → image, image → video-ish, video → video,
        // audio → audio, generator → image: all regular pipeline.
        for (input, output) in [
            ("in.png", "out.jpg"),
            ("in.jpg", "out.webp"),
            ("in.bmp", "out.png"),
            ("movie.mp4", "movie.mkv"),
            ("movie.mkv", "movie.webm"),
            ("in.png", "out.gif"),
            ("sound.wav", "sound.flac"),
            ("sound.mp3", "sound.ogg"),
            ("sound.flac", "sound.wav"),
            ("xc:red", "red.png"),
            ("gradient:red-blue", "g.png"),
            ("subs.srt", "subs.vtt"),
        ] {
            assert_eq!(
                route_of(input, output),
                Route::Pipeline,
                "input={input} output={output}"
            );
        }
    }

    #[test]
    fn printf_template_on_pipeline_route_routes_to_the_planner() {
        // Per-frame fan-out is planned on the pipeline route; the
        // router no longer gatekeeps it (the planner refuses outputs
        // that cannot fan out, with a typed error).
        let mut p = plan("movie.mp4", "frame-%03d.png");
        p.output_template = Some(PrintfTemplate {
            prefix: "frame-".into(),
            width: 3,
            suffix: ".png".into(),
        });
        assert_eq!(decide(&p).unwrap(), Route::Pipeline);
    }

    #[cfg(feature = "ico")]
    #[test]
    fn printf_template_on_ico_route_is_a_typed_error() {
        let mut p = plan("logo.png", "icon-%d.ico");
        p.output_template = Some(PrintfTemplate {
            prefix: "icon-".into(),
            width: 0,
            suffix: ".ico".into(),
        });
        assert!(decide(&p).is_err());
    }

    #[test]
    fn unknown_extensions_still_route_to_pipeline() {
        // Routing doesn't gatekeep unknown formats — the pipeline
        // (codec/container registries) owns that error so new sibling
        // crates extend the set without touching the router.
        assert_eq!(route_of("in.xyz", "out.abc"), Route::Pipeline);
        assert_eq!(route_of("noext", "out"), Route::Pipeline);
    }

    #[test]
    fn route_tags_are_stable() {
        assert_eq!(Route::Help.as_tag(), "help");
        assert_eq!(Route::Ping.as_tag(), "ping");
        assert_eq!(Route::Probe.as_tag(), "probe");
        assert_eq!(Route::PdfSideChannel.as_tag(), "pdf");
        assert_eq!(Route::Pipeline.as_tag(), "pipeline");
        #[cfg(feature = "mesh3d")]
        {
            assert_eq!(Route::Mesh3dToMesh3d.as_tag(), "mesh3d-to-mesh3d");
            assert_eq!(Route::Mesh3dToRaster.as_tag(), "mesh3d-to-raster");
        }
        #[cfg(feature = "ico")]
        assert_eq!(Route::IcoOutput.as_tag(), "ico");
    }
}