use crate::op::ConvertPlan;
use oxideav_core::Error;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Route {
Help,
Ping,
Probe,
PdfSideChannel,
#[cfg(feature = "mesh3d")]
Mesh3dToMesh3d,
#[cfg(feature = "mesh3d")]
Mesh3dToRaster,
#[cfg(feature = "ico")]
IcoOutput,
Pipeline,
}
impl Route {
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",
}
}
}
pub fn decide(plan: &ConvertPlan) -> Result<Route, Error> {
if plan.help {
return Ok(Route::Help);
}
if plan.ping {
return Ok(Route::Ping);
}
if plan.probe {
return Ok(Route::Probe);
}
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);
}
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) {
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);
}
Ok(Route::Pipeline)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::op::{ConvertPlan, PrintfTemplate};
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"))
}
#[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() {
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);
}
#[test]
fn pdf_input_routes_to_side_channel_for_every_output_class() {
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() {
assert_eq!(route_of("in.pdf", "icon.ico"), Route::PdfSideChannel);
}
#[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() {
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() {
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() {
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}");
}
#[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() {
assert_eq!(route_of("logo.png", "pointer.cur"), Route::Pipeline);
}
#[test]
fn media_pairs_route_to_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() {
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() {
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");
}
}