use std::fs;
use assert_cmd::Command;
use cadmpeg_ir::examples::unit_cube;
use predicates::prelude::{predicate, PredicateBooleanExt};
use tempfile::tempdir;
use crate::support::{fixture, geometryless_creo, minimal_rhino_archive};
#[test]
fn garbage_reports_supported_formats() {
let dir = tempdir().unwrap();
let input = dir.path().join("garbage.bin");
fs::write(&input, b"not a CAD file").unwrap();
Command::cargo_bin("cadmpeg")
.unwrap()
.args(["check", input.to_str().unwrap()])
.assert()
.code(2)
.stderr(predicate::str::contains(
"supported: FCStd, f3d, Inventor IPT/IAM, sldprt, CATPart, NX/Creo prt, Rhino 3DM, IGES, STEP",
));
}
#[test]
fn exit_codes_distinguish_semantic_and_operational_failures() {
let dir = tempdir().unwrap();
let garbage = dir.path().join("garbage");
fs::write(&garbage, b"garbage").unwrap();
Command::cargo_bin("cadmpeg")
.unwrap()
.args(["dump", garbage.to_str().unwrap()])
.assert()
.code(2);
Command::cargo_bin("cadmpeg")
.unwrap()
.args(["diff", garbage.to_str().unwrap(), garbage.to_str().unwrap()])
.assert()
.code(2);
let mut invalid = unit_cube().expect("unit cube fixture is admitted");
invalid.model.faces[0].surface = cadmpeg_ir::ids::SurfaceId::mint("test:model:surface#missing")
.expect("valid identity for an absent surface");
let invalid = fixture(dir.path(), "invalid.json", &invalid);
Command::cargo_bin("cadmpeg")
.unwrap()
.args(["check", invalid.to_str().unwrap()])
.assert()
.code(1);
}
#[test]
fn reject_lossy_refuses_lossy_export_as_a_model_refusal() {
let dir = tempdir().unwrap();
let lossy = geometryless_creo(dir.path(), "lossy.prt");
Command::cargo_bin("cadmpeg")
.unwrap()
.args([
"convert",
lossy.to_str().unwrap(),
"-f",
"step",
"--allow-empty",
"--reject-lossy",
])
.assert()
.code(1)
.stderr(predicate::str::contains("refusing to write a lossy"));
let report = dir.path().join("lossy-report.json");
Command::cargo_bin("cadmpeg")
.unwrap()
.args([
"convert",
lossy.to_str().unwrap(),
"-f",
"step",
"--allow-empty",
"--reject-lossy",
"--report",
report.to_str().unwrap(),
])
.assert()
.code(1);
let value: serde_json::Value = serde_json::from_slice(&fs::read(report).unwrap()).unwrap();
assert!(value["decode_report"].is_object());
assert!(value["export"].is_null());
Command::cargo_bin("cadmpeg")
.unwrap()
.args([
"convert",
lossy.to_str().unwrap(),
"-f",
"step",
"--allow-empty",
])
.assert()
.success();
}
#[test]
fn strict_decode_refusal_is_semantic_and_serializes_the_completed_report() {
let dir = tempdir().unwrap();
let input = minimal_rhino_archive(dir.path(), "undeclared.3dm", "100");
for (command, extra) in [
("dump", Vec::<&str>::new()),
("convert", vec!["--to", "cadir", "--reject-lossy"]),
] {
let report = dir.path().join(format!("{command}-strict-report.json"));
let mut args = vec![command, input.to_str().unwrap(), "--no-salvage"];
args.extend(extra);
args.extend(["--report", report.to_str().unwrap()]);
Command::cargo_bin("cadmpeg")
.unwrap()
.args(args)
.assert()
.code(1)
.stderr(predicate::str::contains("strict mode rejects"));
let value: serde_json::Value = serde_json::from_slice(&fs::read(&report).unwrap()).unwrap();
assert_eq!(value["status"], "refused");
assert_eq!(value["refusal"]["stage"], "decode");
assert_eq!(value["refusal"]["code"], "strict_decode_rejected");
let decode_report = value["decode_report"]
.as_object()
.expect("strict refusal keeps its completed decode report");
assert!(decode_report["losses"]
.as_array()
.unwrap()
.iter()
.any(|loss| {
loss["code"]["namespace"] == "rhino"
&& loss["code"]["code"] == "source.dialect-unverified"
}));
}
}
#[test]
fn convert_rejects_empty_native_geometry_unless_allowed() {
let dir = tempdir().unwrap();
let input = geometryless_creo(dir.path(), "empty.prt");
Command::cargo_bin("cadmpeg")
.unwrap()
.args(["convert", input.to_str().unwrap(), "-f", "step"])
.assert()
.code(1)
.stderr(predicate::str::contains("--allow-empty"));
Command::cargo_bin("cadmpeg")
.unwrap()
.args([
"convert",
input.to_str().unwrap(),
"-f",
"step",
"--allow-empty",
])
.assert()
.success()
.stdout(predicate::str::starts_with("ISO-10303-21"));
}
#[test]
fn convert_refuses_container_only_geometry_at_its_depth() {
let dir = tempdir().unwrap();
let input = geometryless_creo(dir.path(), "empty.prt");
for allow_empty in [false, true] {
let report = dir.path().join(format!("depth-{allow_empty}.json"));
let mut args = vec![
"convert",
input.to_str().unwrap(),
"-f",
"step",
"--container-only",
"--report",
report.to_str().unwrap(),
];
if allow_empty {
args.push("--allow-empty");
}
Command::cargo_bin("cadmpeg")
.unwrap()
.args(args)
.assert()
.code(1)
.stdout(predicate::str::is_empty())
.stderr(predicate::str::contains(
"the step encoder needs a geometry-depth decode, but the document records container depth",
));
let value: serde_json::Value = serde_json::from_slice(&fs::read(&report).unwrap()).unwrap();
assert_eq!(value["refusal"]["stage"], "plan");
assert_eq!(value["refusal"]["code"], "insufficient_depth");
assert_eq!(value["refusal"]["depth"]["needed"], "geometry");
assert_eq!(value["refusal"]["depth"]["recorded"], "container");
}
}
#[test]
fn an_unwritable_output_format_refuses_before_reading_input() {
let dir = tempdir().unwrap();
let missing = dir.path().join("does-not-exist.cadir.json");
let path = missing.to_str().unwrap();
Command::cargo_bin("cadmpeg")
.unwrap()
.args(["convert", path, "--to", "catia:v5"])
.assert()
.code(1)
.stderr(
predicate::str::contains("catia is not an output format of this build")
.and(predicate::str::contains("step")),
);
let output = dir.path().join("out.igs");
Command::cargo_bin("cadmpeg")
.unwrap()
.args([
"convert",
path,
"--to",
"catia",
"-o",
output.to_str().unwrap(),
])
.assert()
.code(1)
.stderr(predicate::str::contains(
"catia is not an output format of this build",
));
let input = fixture(
dir.path(),
"cube.cadir.json",
&unit_cube().expect("unit cube fixture is admitted"),
);
let report = dir.path().join("unwritable-format-report.json");
Command::cargo_bin("cadmpeg")
.unwrap()
.args([
"convert",
input.to_str().unwrap(),
"--to",
"catia:v5",
"--report",
report.to_str().unwrap(),
])
.assert()
.code(1);
let value: serde_json::Value = serde_json::from_slice(&fs::read(report).unwrap()).unwrap();
assert_eq!(value["refusal"]["stage"], "plan");
assert_eq!(value["refusal"]["code"], "unsupported_output_format");
assert!(value["decode_report"].is_null());
let missing_report = dir.path().join("missing-input-report.json");
Command::cargo_bin("cadmpeg")
.unwrap()
.args([
"convert",
path,
"--to",
"5.1",
"-o",
output.to_str().unwrap(),
"--report",
missing_report.to_str().unwrap(),
])
.assert()
.code(2)
.stderr(
predicate::str::contains(format!("opening {}", missing.display()))
.and(predicate::str::contains("not an output format").not()),
);
Command::cargo_bin("cadmpeg")
.unwrap()
.args(["convert", path, "--to", "ap242e3"])
.assert()
.code(2)
.stderr(predicate::str::contains(
"no output path to read a format from",
));
Command::cargo_bin("cadmpeg")
.unwrap()
.args(["convert", path, "--to", "step:"])
.assert()
.code(2)
.stderr(predicate::str::contains("nothing after the colon"));
}
#[test]
fn an_unknown_dialect_is_refused_with_the_encoder_catalog() {
let dir = tempdir().unwrap();
let input = fixture(
dir.path(),
"cube.cadir.json",
&unit_cube().expect("unit cube fixture is admitted"),
);
let output = dir.path().join("cube.igs");
Command::cargo_bin("cadmpeg")
.unwrap()
.args([
"convert",
input.to_str().unwrap(),
"-o",
output.to_str().unwrap(),
"--to",
"ap242e3",
"--allow-errors",
])
.assert()
.code(1)
.stderr(
predicate::str::contains("ap242e3")
.and(predicate::str::contains("iges:5.3-fixed-ascii"))
.and(predicate::str::contains("iges:4.0-fixed-ascii")),
);
assert!(!output.exists());
let native_input = dir.path().join("native-cube.igs");
Command::cargo_bin("cadmpeg")
.unwrap()
.args([
"convert",
input.to_str().unwrap(),
"-o",
native_input.to_str().unwrap(),
"--allow-errors",
])
.assert()
.success();
let report = dir.path().join("unsupported-target-report.json");
let refused_output = dir.path().join("refused.igs");
Command::cargo_bin("cadmpeg")
.unwrap()
.args([
"convert",
native_input.to_str().unwrap(),
"-o",
refused_output.to_str().unwrap(),
"--to",
"9.9",
"--report",
report.to_str().unwrap(),
"--allow-errors",
])
.assert()
.code(1)
.stderr(predicate::str::contains("iges cannot write 9.9"));
let value: serde_json::Value = serde_json::from_slice(&fs::read(&report).unwrap()).unwrap();
assert_eq!(value["refusal"]["code"], "unsupported_target");
assert_eq!(
value["refusal"]["target"]["refusal"]["kind"],
"unknown_explicit"
);
assert_eq!(value["refusal"]["target"]["format"], "iges");
assert_eq!(value["refusal"]["target"]["refusal"]["requested"], "9.9");
assert!(value["refusal"]["target"]["available"]
.as_array()
.is_some_and(|available| !available.is_empty()));
assert!(value["decode_report"].is_object());
assert!(value["decode_report"]["identity"]["dialects"].is_object());
assert!(value["check_report"].is_object());
Command::cargo_bin("cadmpeg")
.unwrap()
.args([
"convert",
native_input.to_str().unwrap(),
"-o",
refused_output.to_str().unwrap(),
"--to",
"8.8",
"--report",
report.to_str().unwrap(),
"--allow-errors",
])
.assert()
.code(2)
.stderr(
predicate::str::contains("exists; pass --force")
.and(predicate::str::contains("iges cannot write 8.8").not()),
);
let missing_input = dir.path().join("missing.cadir.json");
Command::cargo_bin("cadmpeg")
.unwrap()
.args([
"convert",
missing_input.to_str().unwrap(),
"-o",
refused_output.to_str().unwrap(),
"--to",
"9.9",
])
.assert()
.code(2)
.stderr(
predicate::str::contains("error: opening")
.and(predicate::str::contains("missing.cadir.json"))
.and(predicate::str::contains("iges cannot write 9.9").not()),
);
Command::cargo_bin("cadmpeg")
.unwrap()
.args([
"convert",
input.to_str().unwrap(),
"-f",
"step:ap999",
"--allow-errors",
])
.assert()
.code(1)
.stderr(
predicate::str::contains("step cannot write ap999")
.and(predicate::str::contains("step:ap242-e3")),
);
}
#[test]
fn rhino_word_5_is_refused_with_the_encoder_catalog() {
let dir = tempdir().unwrap();
let input = fixture(
dir.path(),
"cube.cadir.json",
&unit_cube().expect("unit cube fixture is admitted"),
);
let output = dir.path().join("cube.3dm");
Command::cargo_bin("cadmpeg")
.unwrap()
.args([
"convert",
input.to_str().unwrap(),
"-o",
output.to_str().unwrap(),
"--to",
"5",
"--allow-errors",
])
.assert()
.code(1)
.stderr(
predicate::str::contains("rhino cannot write 5")
.and(predicate::str::contains("rhino:archive-50"))
.and(predicate::str::contains("rhino:archive-80")),
);
assert!(!output.exists());
}
#[test]
fn reject_lossy_scopes_select_which_losses_refuse() {
let dir = tempdir().unwrap();
let lossy = geometryless_creo(dir.path(), "lossy.prt");
let path = lossy.to_str().unwrap();
let export_report = dir.path().join("export-loss-refusal.json");
for scope in [
"--reject-lossy",
"--reject-lossy=decode",
"--reject-lossy=any",
] {
Command::cargo_bin("cadmpeg")
.unwrap()
.args(["convert", path, "-f", "step", "--allow-empty", scope])
.assert()
.code(1)
.stderr(predicate::str::contains("decode reported"));
}
Command::cargo_bin("cadmpeg")
.unwrap()
.args([
"convert",
path,
"-f",
"step",
"--allow-empty",
"--reject-lossy=export",
"--report",
export_report.to_str().unwrap(),
])
.assert()
.code(1)
.stderr(
predicate::str::contains("export planning reported 1 loss(es)")
.and(predicate::str::contains(
"uninterpreted passthrough record(s)",
))
.and(predicate::str::contains("decode reported").not()),
);
let report: serde_json::Value =
serde_json::from_slice(&fs::read(export_report).unwrap()).unwrap();
assert_eq!(report["refusal"]["code"], "export_loss_rejected");
assert!(report["export"].is_object());
assert_eq!(report["export"]["identity"]["payload"], "native");
assert_eq!(report["export"]["losses"].as_array().unwrap().len(), 1);
let lossless = fixture(
dir.path(),
"cube.cadir.json",
&unit_cube().expect("unit cube fixture is admitted"),
);
for scope in [
"--reject-lossy",
"--reject-lossy=decode",
"--reject-lossy=export",
] {
Command::cargo_bin("cadmpeg")
.unwrap()
.args(["convert", lossless.to_str().unwrap(), "-f", "step", scope])
.assert()
.success();
}
Command::cargo_bin("cadmpeg")
.unwrap()
.args([
"convert",
path,
"-f",
"step",
"--allow-empty",
"--reject-lossy=nonesuch",
])
.assert()
.code(2)
.stderr(predicate::str::contains("invalid value"));
}
#[test]
fn json_on_artifact_commands_is_a_teaching_error() {
let dir = tempdir().unwrap();
let ir = unit_cube().expect("unit cube fixture is admitted");
let model = fixture(dir.path(), "cube.cadir.json", &ir);
let path = model.to_str().unwrap();
Command::cargo_bin("cadmpeg")
.unwrap()
.args(["dump", path, "--json"])
.assert()
.code(2)
.stderr(
predicate::str::contains("already JSON").and(predicate::str::contains("cadmpeg query")),
);
Command::cargo_bin("cadmpeg")
.unwrap()
.args(["convert", path, "--json"])
.assert()
.code(2)
.stderr(
predicate::str::contains("not an output selector")
.and(predicate::str::contains("--report")),
);
}
#[test]
fn report_to_an_unwritable_path_is_an_operational_error() {
let dir = tempdir().unwrap();
let input = fixture(
dir.path(),
"cube.cadir.json",
&unit_cube().expect("unit cube fixture is admitted"),
);
let report = dir.path().join("missing-subdir").join("report.json");
Command::cargo_bin("cadmpeg")
.unwrap()
.args([
"check",
input.to_str().unwrap(),
"--report",
report.to_str().unwrap(),
])
.assert()
.code(2);
assert!(!report.exists());
}
#[cfg(unix)]
#[test]
fn closed_stdout_pipe_exits_on_sigpipe_without_panic() {
use std::io::Read;
use std::os::unix::process::ExitStatusExt;
use std::process::{Command as ProcessCommand, Stdio};
let dir = tempdir().unwrap();
let input = dir.path().join("zeros.bin");
fs::write(&input, vec![0u8; 64 * 1024]).unwrap();
let mut child = ProcessCommand::new(assert_cmd::cargo::cargo_bin("cadmpeg"))
.args(["inspect", "hex", input.to_str().unwrap(), "--len", "65536"])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let mut stdout = child.stdout.take().unwrap();
let mut first = [0u8; 1];
stdout.read_exact(&mut first).unwrap();
drop(stdout);
let mut stderr = child.stderr.take().unwrap();
let status = child.wait().unwrap();
let mut err = String::new();
stderr.read_to_string(&mut err).unwrap();
assert!(
!err.contains("panicked"),
"closed stdout pipe panicked:\n{err}"
);
assert_eq!(
status.signal(),
Some(13),
"expected SIGPIPE, got {status:?}; stderr={err}"
);
}