Compare commits

...
Author SHA1 Message Date
Tanish Manku b04273e897 Merge pull request #2685 from storytold/land/1937
Round-trip harness and corpus report: open, save, reopen and compare (#1937, E12)
2026-11-02 21:00:25 -07:00
Tanish Manku a4ff30d11e Round-trip harness: never read the kept copies back as corpus files 2026-11-02 20:49:27 -07:00
Tanish Manku 9d83f21910 Round-trip harness: typed tolerance error, report folders it couldn't read, --out=DIR
From review of #2685. The ast-grep string-error rule refused the tolerance
parser's String error. A folder the walk couldn't read was skipped silently, so a
strict run could pass on part of the corpus; it is now said on stderr and fails a
strict run. xtask corpus resolves --out=DIR against the caller's folder, as
--out DIR.
2026-11-02 20:36:01 -07:00
Tanish Manku 7caf369555 Merge #1937: open, save, reopen round-trip harness and corpus report (E12)
Merged onto current main: the DWG writer's per-type counts of what it leaves out
travel back from the DWG worker, the roundtrip command follows the CLI's clap
subcommands, and read_source keeps main's format detection. The docs/gaps.md
edit is dropped; maintainers update the parity rows in batches.
2026-11-02 20:19:17 -07:00
Francesco BVandClaude Opus 5.5 9107479ade io: open -> save -> reopen round-trip harness with loss report (FF5)
cadcraft_io::roundtrip compares a drawing with itself saved and reopened
(entities by handle, tables, blocks, layouts, unmodelled objects, xdata,
classes, header variables; numbers within a tolerance), and
roundtrip::corpus runs a folder of DXF/DWG files through DXF and DWG into
report.md and report.json; exposed as `cadcraft-cli roundtrip` and
`cargo xtask corpus`. The corpus itself stays outside the repo.

The DWG bridge now says what its writer leaves out, per DXF type
(cadcraft_dwg::dxf_to_dwg_noted, cadcraft_io::write_noted): the report
shows those records as lost on save and a strict corpus run fails on
them. OLE2FRAME, which the writer keeps with its data, is no longer
listed as not saved in a DWG.

CADCRAFT_CORPUS_STRICT=1 makes cadcraft-cli roundtrip (and so cargo xtask
corpus) fail on the same strict failures as the corpus test; xtask makes
a relative corpus folder and --out absolute before running the CLI.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 17:45:34 +01:00
13 changed files with 2946 additions and 31 deletions
+37
View File
@@ -81,6 +81,7 @@ pub(crate) enum Command {
Mcp(McpArgs),
Perf(PerfArgs),
Sample(SampleArgs),
Roundtrip(RoundtripArgs),
}
/// Print a drawing's summary as JSON
@@ -308,6 +309,40 @@ fn perf_target(p: PathBuf) -> PerfTarget {
}
}
/// Open each DXF/DWG, save it as DXF and DWG, reopen and compare; writes report.md and report.json
///
/// FOLDER defaults to $CADCRAFT_CORPUS_DIR. With CADCRAFT_CORPUS_STRICT=1 it fails when a file
/// doesn't open, save or reopen, or loses records on save.
#[derive(Args)]
pub(crate) struct RoundtripArgs {
/// A drawing, or a folder of them
#[arg(value_name = "FILE|FOLDER")]
pub(crate) path: Option<PathBuf>,
/// Where the report goes (default: `report` beside the files)
#[arg(long, value_name = "DIR")]
pub(crate) out: Option<PathBuf>,
/// Keep the saved files, in `saved` under the report folder
#[arg(long)]
pub(crate) keep_saved: bool,
/// Save only in each file's own format
#[arg(long)]
pub(crate) same_format: bool,
/// Don't count what each file holds (faster; no "read lost" column)
#[arg(long)]
pub(crate) no_census: bool,
/// How far numbers may move and still count as equal
#[arg(long, value_name = "T", default_value_t = cadcraft_io::roundtrip::Options::default().abs_tol, value_parser = tolerance)]
pub(crate) tolerance: f64,
}
/// `--tolerance`: a finite number, 0 or more.
fn tolerance(v: &str) -> Result<f64, ValueError> {
match v.parse::<f64>() {
Ok(t) if t.is_finite() && t >= 0.0 => Ok(t),
_ => Err(ValueError::Tolerance(v.to_string())),
}
}
/// Write a built-in sample drawing
#[derive(Args)]
pub(crate) struct SampleArgs {
@@ -360,6 +395,8 @@ pub(crate) enum ValueError {
CmdId,
#[error(transparent)]
CmdJson(#[from] serde_json::Error),
#[error("expected a number of 0 or more, got `{0}`")]
Tolerance(String),
}
/// Why `parse` refuses flags clap accepted: clap prints it as a usage error (exit 2).
+3
View File
@@ -1,5 +1,6 @@
use crate::output::OutputError;
use crate::perf::PerfError;
use crate::roundtrip::RoundtripError;
use crate::run::RunError;
/// Why a subcommand failed: printed as `error: {e}`, exit 1.
@@ -19,6 +20,8 @@ pub(crate) enum CliError {
Output(#[from] OutputError),
#[error(transparent)]
Perf(#[from] PerfError),
#[error(transparent)]
Roundtrip(#[from] RoundtripError),
/// The engine and io crates take file names as text.
#[error("{}: the file name is not valid Unicode", .0.display())]
NotUnicode(std::path::PathBuf),
+4 -1
View File
@@ -16,6 +16,7 @@ mod info;
mod mcp;
mod output;
mod perf;
mod roundtrip;
mod run;
mod sample;
@@ -32,6 +33,7 @@ use output::Streams;
#[cfg(test)]
use output::{export_window, model_views, write_atomic};
use perf::perf;
use roundtrip::roundtrip;
use run::run_cmds;
#[cfg(test)]
use run::{fill_last, returned_handle};
@@ -99,7 +101,7 @@ where
Command::Run(a) => a.settings.apply(),
Command::Mcp(a) => a.settings.apply(),
Command::Sample(a) => a.settings.apply(),
Command::Info(_) | Command::Commands(_) | Command::Perf(_) => {}
Command::Info(_) | Command::Commands(_) | Command::Perf(_) | Command::Roundtrip(_) => {}
}
let mut io = Streams::new(out, err);
let result = match &cli.command {
@@ -110,6 +112,7 @@ where
Command::Mcp(a) => mcp(a),
Command::Perf(a) => perf(a.target.as_ref(), &mut io),
Command::Sample(a) => sample(a, &mut io),
Command::Roundtrip(a) => roundtrip(a, &mut io),
};
let mut code = 0;
if let Err(e) = result {
+93
View File
@@ -0,0 +1,93 @@
//! `roundtrip`: the corpus harness of `cadcraft_io::roundtrip::corpus` over a file or folder.
use super::args::RoundtripArgs;
use super::error::{CliError, Result};
use super::output::Streams;
/// Why `roundtrip` stopped.
#[derive(Debug, thiserror::Error)]
pub(crate) enum RoundtripError {
#[error("roundtrip: give a FILE or FOLDER, or set CADCRAFT_CORPUS_DIR")]
NoPath,
#[error("roundtrip: no such file or folder: {}", .0.display())]
Missing(std::path::PathBuf),
#[error("roundtrip: no DXF or DWG files in {}", .0.display())]
Empty(std::path::PathBuf),
#[error("roundtrip: can't write the report to {}: {source}", .path.display())]
Report { path: std::path::PathBuf, source: std::io::Error },
#[error("roundtrip: {} strict failures:\n{}", .0.len(), .0.join("\n"))]
Strict(Vec<String>),
}
/// What the walk couldn't look at, so the report isn't taken for the whole corpus: said on
/// stderr, and failures of a strict run.
fn left_out(w: &cadcraft_io::roundtrip::corpus::Walk) -> Vec<String> {
let mut out: Vec<String> = w.unreadable.iter().map(|u| format!("not read: {u}")).collect();
if w.truncated {
out.push("left out: files or folders past the harness limits".to_string());
}
out
}
pub(crate) fn roundtrip(a: &RoundtripArgs, io: &mut Streams) -> Result {
use cadcraft_io::roundtrip::corpus;
let path = a.path.clone().or_else(|| std::env::var_os("CADCRAFT_CORPUS_DIR").map(Into::into)).ok_or(RoundtripError::NoPath)?;
let mut missed = Vec::new();
let (root, files, out) = if path.is_dir() {
let out = a.out.clone().unwrap_or_else(|| path.join("report"));
// The report folder, and the saved copies in it, are never read back as corpus files.
let walk = corpus::walk(&path, &[out.clone(), out.join("saved")]);
missed = left_out(&walk);
for m in &missed {
io.errln(format_args!("roundtrip: {m}"));
}
(path, walk.files, out)
} else if path.is_file() {
let root = path.parent().map(std::path::Path::to_path_buf).unwrap_or_default();
let out = a.out.clone().unwrap_or_else(|| root.join("report"));
(root, vec![path], out)
} else {
return Err(RoundtripError::Missing(path).into());
};
if files.is_empty() {
// A folder it couldn't read isn't an empty one.
return Err(if missed.is_empty() { RoundtripError::Empty(root) } else { RoundtripError::Strict(missed) }.into());
}
let mut opts = corpus::CorpusOptions { cross_format: !a.same_format, census: !a.no_census, ..Default::default() };
opts.compare.abs_tol = a.tolerance;
if a.keep_saved {
opts.keep_saved = Some(out.join("saved"));
}
let report = corpus::run(&root, &files, &opts, &mut |i, n, rel| eprintln!("[{}/{n}] {rel}", i + 1));
for f in &report.files {
let routes: Vec<String> = f
.routes
.iter()
.map(|r| match (&r.save_error, &r.reopen_error) {
(Some(_), _) => format!("{}: save failed", r.route),
(None, Some(_)) => format!("{}: reopen failed", r.route),
(None, None) => {
let t = r.comparison.total();
format!("{}: {} lost, {} added, {} changed", r.route, t.lost, t.added, t.changed)
}
})
.collect();
match &f.open_error {
Some(e) => io.outln(format_args!("{}: open failed: {e}", f.path)),
None => io.outln(format_args!("{}: opened; {}", f.path, routes.join("; "))),
}
}
let (md, json) = report.write(&out).map_err(|source| RoundtripError::Report { path: out.clone(), source })?;
io.outln(format_args!("report: {}", md.display()));
io.outln(format_args!(" {}", json.display()));
// CADCRAFT_CORPUS_STRICT=1: a file that doesn't open, save or reopen, or records a writer
// leaves out, fail the run (the report is written first).
if std::env::var("CADCRAFT_CORPUS_STRICT").is_ok_and(|v| v == "1") {
let mut failed = report.strict_failures();
failed.extend(missed);
if !failed.is_empty() {
return Err(CliError::from(RoundtripError::Strict(failed)));
}
}
Ok(())
}
@@ -20,7 +20,7 @@ fn the_example_writes_the_files_the_packages_install() {
let mut written: Vec<String> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().into_string().unwrap()).collect();
written.sort();
let completions = ["cadcraft-cli.bash", "_cadcraft-cli", "cadcraft-cli.fish", "cadcraft-cli.elv", "_cadcraft-cli.ps1"];
let subcommands = ["info", "convert", "run", "commands", "mcp", "perf", "sample"];
let subcommands = ["info", "convert", "run", "commands", "mcp", "perf", "sample", "roundtrip"];
let mut expected: Vec<String> = completions.map(String::from).into();
expected.push("cadcraft-cli.1".into());
expected.extend(subcommands.iter().map(|s| format!("cadcraft-cli-{s}.1")));
+84 -12
View File
@@ -65,7 +65,7 @@ fn mb(n: u64) -> String {
#[cfg(not(no_dwg_bridge))]
pub mod worker;
#[cfg(not(no_dwg_bridge))]
pub use worker::{WORKER_ARG, crash_worker_for_test, decode, encode_as, serve_if_asked, use_own_worker, use_worker};
pub use worker::{WORKER_ARG, crash_worker_for_test, decode, encode_as, encode_noted, serve_if_asked, use_own_worker, use_worker};
#[cfg(not(no_dwg_bridge))]
mod native {
@@ -544,15 +544,22 @@ mod native {
/// Leave out what acadrust can't write as this DWG version: other programs' custom entities
/// and objects without raw DWG data of that version. acadrust 0.6.4 refuses the whole file
/// for one of them (0.6.3 left them out); CADCraft already tells the user which entity types
/// a DWG doesn't keep (`not_saved`), so the rest of the drawing is written. Returns how many.
fn drop_unwritable(doc: &mut acadrust::CadDocument) -> usize {
/// a DWG doesn't keep (`not_saved`), so the rest of the drawing is written. Returns how many
/// records of each DXF type were left out, so a save can say so.
fn drop_unwritable(doc: &mut acadrust::CadDocument) -> super::Unwritten {
let version = doc.version;
let mut dropped = super::Unwritten::new();
let mut count = |name: &str| {
let n = dropped.entry(name.trim().to_ascii_uppercase()).or_insert(0);
*n = n.saturating_add(1);
};
let unwritable: Vec<acadrust::Handle> = doc
.entities()
.filter_map(|e| match e {
acadrust::entities::EntityType::Unknown(u)
if u.raw_dwg_data.is_none() || u.dwg_source_version.is_some_and(|v| v != version) || version == acadrust::DxfVersion::AC1009 =>
{
count(&u.dxf_name);
Some(u.common.handle)
}
_ => None,
@@ -565,9 +572,10 @@ mod native {
.objects
.iter()
.filter_map(|(h, o)| match o {
acadrust::objects::ObjectType::Unknown { raw_dwg_data, raw_dwg_version, .. }
acadrust::objects::ObjectType::Unknown { type_name, raw_dwg_data, raw_dwg_version, .. }
if raw_dwg_data.is_none() || raw_dwg_version.is_some_and(|v| v != version) || version == acadrust::DxfVersion::AC1009 =>
{
count(type_name);
Some(*h)
}
_ => None,
@@ -576,7 +584,7 @@ mod native {
for h in &objects {
doc.objects.remove(h);
}
unwritable.len() + objects.len()
dropped
}
/// Header variables the DWG reader fills in but acadrust's DXF writer (0.6.3) leaves out of
@@ -644,12 +652,19 @@ mod native {
/// Convert DXF bytes into a DWG file of the DXF's version, and at least AutoCAD 2004
/// (AC1018).
pub fn dxf_to_dwg(dxf: &[u8]) -> Result<Vec<u8>, String> {
convert(dxf, None)
convert(dxf, None).map(|(dwg, _)| dwg)
}
/// Convert DXF bytes into a DWG file of `version`, a code from [`super::VERSIONS`] ("AC1032"),
/// whatever version the DXF declares.
pub fn dxf_to_dwg_as(dxf: &[u8], version: &str) -> Result<Vec<u8>, String> {
dxf_to_dwg_noted(dxf, version).map(|(dwg, _)| dwg)
}
/// [`dxf_to_dwg_as`], also returning what the DWG writer left out: how many records of each
/// DXF type it can't write (other programs' custom entities and objects that the DWG reader
/// didn't read from a DWG of this version), empty when the DWG keeps everything.
pub fn dxf_to_dwg_noted(dxf: &[u8], version: &str) -> Result<(Vec<u8>, super::Unwritten), String> {
let v = match version.trim().to_ascii_uppercase().as_str() {
"AC1015" => acadrust::DxfVersion::AC1015,
"AC1018" => acadrust::DxfVersion::AC1018,
@@ -707,7 +722,7 @@ mod native {
}
}
fn convert(dxf: &[u8], version: Option<acadrust::DxfVersion>) -> Result<Vec<u8>, String> {
fn convert(dxf: &[u8], version: Option<acadrust::DxfVersion>) -> Result<(Vec<u8>, super::Unwritten), String> {
// Reject a pathologically large DXF before handing it to the third-party decoder, which
// allocates tables and section buffers from the input's declared sizes and cannot be
// interrupted once it starts (CWE-789). This bounds the large-input variant; the decoder
@@ -732,21 +747,23 @@ mod native {
None => {}
}
// The fix-ups run on the third-party reader's document, so a panic in one is an error, not a crash.
guarded(AssertUnwindSafe(|| {
let unwritten = guarded(AssertUnwindSafe(|| {
super::fail_in_test("convert");
other_layout_owners(&mut doc);
table_style_rows(&mut doc, true);
dimension_flags(&mut doc);
drop_unwritable(&mut doc);
let unwritten = drop_unwritable(&mut doc);
current_dimstyle(&mut doc);
add_variable_dictionary(&mut doc);
current_text_style(&mut doc);
current_layer(&mut doc);
unwritten
}))
.map_err(|_| "the DXF to DWG conversion failed on this file".to_string())?;
std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DwgWriter::write_to_vec(&doc)))
let dwg = std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DwgWriter::write_to_vec(&doc)))
.map_err(|_| "the DWG writer failed on this drawing".to_string())?
.map_err(|e| format!("DWG write: {e}"))
.map_err(|e| format!("DWG write: {e}"))?;
Ok((dwg, unwritten))
}
/// AutoCAD's entry name for the drawing variables dictionary (the DXF Reference calls it the
@@ -903,7 +920,11 @@ mod native {
}
#[cfg(not(no_dwg_bridge))]
pub use native::{dwg_to_dxf, dwg_to_dxf_noted, dwg_to_dxf_with, dxf_to_dwg, dxf_to_dwg_as};
pub use native::{dwg_to_dxf, dwg_to_dxf_noted, dwg_to_dxf_with, dxf_to_dwg, dxf_to_dwg_as, dxf_to_dwg_noted};
/// What saving as DWG left out: how many records of each DXF type (upper case) the DWG writer
/// can't write.
pub type Unwritten = std::collections::BTreeMap<String, usize>;
// Test-only: the stage ("post_process" or "convert") whose guard the current test checks. The
// stage then panics on entry, so a test through the public API shows whether it is guarded.
@@ -950,6 +971,10 @@ pub fn decode(bytes: &[u8], limits: Limits) -> Result<(Vec<u8>, Vec<String>), St
pub fn encode_as(dxf: &[u8], version: &str) -> Result<Vec<u8>, String> {
dxf_to_dwg_as(dxf, version)
}
#[cfg(no_dwg_bridge)]
pub fn encode_noted(dxf: &[u8], version: &str) -> Result<(Vec<u8>, Unwritten), String> {
dxf_to_dwg_noted(dxf, version)
}
#[cfg(no_dwg_bridge)]
pub fn dwg_to_dxf(_bytes: &[u8]) -> Result<Vec<u8>, String> {
@@ -971,6 +996,10 @@ pub fn dxf_to_dwg(_dxf: &[u8]) -> Result<Vec<u8>, String> {
pub fn dxf_to_dwg_as(_dxf: &[u8], _version: &str) -> Result<Vec<u8>, String> {
Err(format!("DWG files can't be written in the {NO_BRIDGE_BUILD} build yet; save as DXF"))
}
#[cfg(no_dwg_bridge)]
pub fn dxf_to_dwg_noted(_dxf: &[u8], _version: &str) -> Result<(Vec<u8>, Unwritten), String> {
Err(format!("DWG files can't be written in the {NO_BRIDGE_BUILD} build yet; save as DXF"))
}
#[cfg(test)]
mod tests {
@@ -1302,4 +1331,47 @@ mod tests {
assert_eq!(read.dwg_tail_byte, 0, "{version}");
}
}
/// Saving says what the DWG writer left out, per type: entities of types acadrust's DXF
/// reader doesn't model (another program's custom entity) can't be written; a drawing the DWG
/// keeps whole reports nothing.
#[cfg(not(no_dwg_bridge))]
#[test]
fn saving_lists_the_types_the_dwg_writer_leaves_out() {
let line = " 0\nLINE\n 8\n0\n100\nAcDbEntity\n100\nAcDbLine\n 10\n0.0\n 20\n0.0\n 30\n0.0\n 11\n10.0\n 21\n0.0\n 31\n0.0\n";
let widget = " 0\nACME_WIDGET\n 8\n0\n100\nAcDbEntity\n100\nAcmeWidget\n 10\n1.0\n 20\n2.0\n 30\n0.0\n";
let dxf = |body: &str| {
format!(" 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 0\nENDSEC\n 0\nSECTION\n 2\nENTITIES\n{body} 0\nENDSEC\n 0\nEOF\n")
};
for version in ["AC1015", "AC1018", "AC1032"] {
let (dwg, unwritten) = dxf_to_dwg_noted(dxf(&format!("{line}{widget}{widget}")).as_bytes(), version).unwrap();
assert!(is_dwg(&dwg), "{version}");
assert_eq!(unwritten, [("ACME_WIDGET".to_string(), 2)].into_iter().collect::<Unwritten>(), "{version}");
}
let (_, unwritten) = dxf_to_dwg_noted(dxf(line).as_bytes(), "AC1032").unwrap();
assert!(unwritten.is_empty(), "{unwritten:?}");
}
/// An OLE2FRAME, which CADCraft keeps as its DXF groups without modelling it, is written to
/// the DWG with its data: it isn't in the list of what the writer left out.
#[cfg(not(no_dwg_bridge))]
#[test]
fn ole_frames_are_written_with_their_data() {
let ole = " 0\nOLE2FRAME\n 8\n0\n100\nAcDbEntity\n100\nAcDbOle2Frame\n 70\n2\n 10\n1.0\n 20\n9.0\n 30\n0.0\n 11\n8.0\n 21\n2.0\n 31\n0.0\n 71\n2\n 72\n0\n 73\n1\n 90\n4\n310\nDEADBEEF\n 1\nOLE\n";
let dxf =
format!(" 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 0\nENDSEC\n 0\nSECTION\n 2\nENTITIES\n{ole} 0\nENDSEC\n 0\nEOF\n");
for version in ["AC1015", "AC1018", "AC1032"] {
let (dwg, unwritten) = dxf_to_dwg_noted(dxf.as_bytes(), version).unwrap();
assert!(unwritten.is_empty(), "{version} {unwritten:?}");
let back = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg)).read().unwrap();
let frames: Vec<Vec<u8>> = back
.entities()
.filter_map(|e| match e {
acadrust::entities::EntityType::Ole2Frame(o) => Some(o.encoded_payload()),
_ => None,
})
.collect();
assert_eq!(frames, [vec![0xDE, 0xAD, 0xBE, 0xEF]], "{version}");
}
}
}
+25 -3
View File
@@ -82,15 +82,31 @@ pub fn decode(bytes: &[u8], limits: Limits) -> Result<(Vec<u8>, Vec<String>), St
/// [`super::dxf_to_dwg_as`] in the worker, when there is one.
pub fn encode_as(dxf: &[u8], version: &str) -> Result<Vec<u8>, String> {
encode_noted(dxf, version).map(|(dwg, _)| dwg)
}
/// [`super::dxf_to_dwg_noted`] in the worker, when there is one. What the writer left out comes
/// back as notes of `TYPE<tab>count`.
pub fn encode_noted(dxf: &[u8], version: &str) -> Result<(Vec<u8>, super::Unwritten), String> {
if u8::try_from(version.len()).is_err() {
return Err(super::unsupported(version));
}
match call(&request(ENCODE, Limits::DEFAULT, version), dxf, MAX_DWG_REPLY, "writing this file") {
Some(reply) => reply?.map(|(dwg, _)| dwg),
None => super::dxf_to_dwg_as(dxf, version),
Some(reply) => reply?.map(|(dwg, notes)| (dwg, unwritten_from(&notes))),
None => super::dxf_to_dwg_noted(dxf, version),
}
}
fn unwritten_from(notes: &[String]) -> super::Unwritten {
notes.iter().filter_map(|n| n.split_once('\t')).filter_map(|(kind, n)| Some((kind.to_string(), n.parse::<usize>().ok()?))).fold(
super::Unwritten::new(),
|mut m, (kind, n)| {
*m.entry(kind).or_insert(0) = n;
m
},
)
}
/// Make the worker abort, as the third-party code can: what the tests prove isolation with.
#[doc(hidden)]
pub fn crash_worker_for_test() -> Result<(), String> {
@@ -241,7 +257,7 @@ fn handle(mut b: &[u8]) -> Reply {
let version = take(&mut b, usize::from(version_len)).and_then(|v| std::str::from_utf8(v).ok()).ok_or_else(bad)?;
match op {
DECODE => super::dwg_to_dxf_noted(b, Limits { max_dwg_bytes, max_dxf_bytes }),
ENCODE => super::dxf_to_dwg_as(b, version).map(|dwg| (dwg, Vec::new())),
ENCODE => super::dxf_to_dwg_noted(b, version).map(|(dwg, left_out)| (dwg, left_out.iter().map(|(kind, n)| format!("{kind}\t{n}")).collect())),
CRASH => std::process::abort(),
_ => Err(bad()),
}
@@ -267,6 +283,12 @@ mod tests {
out
}
#[test]
fn what_the_writer_left_out_survives_the_reply() {
let notes = vec!["ACME_WIDGET\t2".to_string(), "not a count".to_string(), "X\tmany".to_string()];
assert_eq!(unwritten_from(&notes), [("ACME_WIDGET".to_string(), 2)].into_iter().collect());
}
#[test]
fn replies_round_trip() {
let ok: Reply = Ok((b"bytes".to_vec(), vec!["one".into(), "two é".into()]));
+30 -8
View File
@@ -14,6 +14,8 @@ mod dxf_write;
mod dxf_xref;
pub mod pdf;
pub mod plotter;
/// Open → save → reopen comparison and the real-file corpus harness (gaps FF1, FF5).
pub mod roundtrip;
pub mod svg;
mod svg_read;
@@ -69,19 +71,25 @@ fn ext(name: impl AsRef<Path>) -> String {
/// Read a drawing; the format comes from the content (DXF ASCII/binary, DWG) or the name
/// (SVG and gzip-compressed SVGZ).
pub fn read(bytes: &[u8], name: impl AsRef<Path>) -> Result<Drawing> {
let name = name.as_ref();
read_source(bytes, name.as_ref()).map(|(d, _)| d)
}
/// [`read`], also returning the DXF rendition a DWG was read through (`None` for other files),
/// which the round-trip harness counts.
pub(crate) fn read_source(bytes: &[u8], name: &Path) -> Result<(Drawing, Option<Vec<u8>>)> {
if cadcraft_dwg::is_dwg(bytes) {
let (dxf, notes) = cadcraft_dwg::decode(bytes, cadcraft_dwg::Limits::DEFAULT).map_err(IoError::Format)?;
let mut d = dxf_read::read(&dxf).map_err(|e| IoError::Format(format!("{e} (in the DXF converted from this DWG)")))?;
d.read_notes = notes;
return Ok(d);
return Ok((d, Some(dxf)));
}
match (Format::from_path(name), ext(name).as_str()) {
let d = match (Format::from_path(name), ext(name).as_str()) {
(Some(Format::Svg | Format::Svgz), _) => svg_read::read(bytes),
(Some(Format::Dxf), _) => dxf_read::read(bytes),
(_, e) if bytes.len() > 4 => dxf_read::read(bytes).map_err(|_| IoError::Unsupported(e.to_string())),
(_, e) => Err(IoError::Unsupported(e.to_string())),
}
}?;
Ok((d, None))
}
/// Write a drawing in the format chosen by the name's extension. Images (PNG, SVG, PDF) show
@@ -111,15 +119,29 @@ pub fn write_with(d: &Drawing, name: impl AsRef<Path>, opts: &SaveOptions) -> Re
// The DWG bridge reads our R2000 DXF and writes the DWG at the version asked for. Without
// one, at least 2004: R2000 DWG has no true colours, transparency, table styles or
// gradients, so a drawing from an R2000 DXF doesn't lose them by default.
Some(Format::Dwg) if !opts.binary => {
let version = opts.version.unwrap_or_else(|| version.max(Version::R2004));
cadcraft_dwg::encode_as(dxf_write::dwg_bridge(d, version).as_bytes(), version.code()).map_err(IoError::Format)
}
Some(Format::Dwg) if !opts.binary => write_dwg(d, opts.version.unwrap_or_else(|| version.max(Version::R2004))).map(|(dwg, _)| dwg),
_ if opts.binary => Err(IoError::Format(format!("binary output is for DXF files, not `{}`", ext(name)))),
_ => write_framed(d, name, None),
}
}
/// [`write_with`], also returning what the file leaves out of the drawing: for a DWG, how many
/// records of each DXF type the DWG writer can't write (other programs' custom entities and
/// objects); for other formats, nothing.
pub fn write_noted(d: &Drawing, name: impl AsRef<Path>, opts: &SaveOptions) -> Result<(Vec<u8>, cadcraft_dwg::Unwritten)> {
let name = name.as_ref();
if matches!(Format::from_path(name), Some(Format::Dwg)) && !opts.binary {
return write_dwg(d, opts.version.unwrap_or_else(|| save_version(d).max(Version::R2004)));
}
write_with(d, name, opts).map(|b| (b, cadcraft_dwg::Unwritten::new()))
}
/// The DWG bridge reads our R2000 DXF and writes the DWG at `version`, in the DWG worker when
/// there is one.
fn write_dwg(d: &Drawing, version: Version) -> Result<(Vec<u8>, cadcraft_dwg::Unwritten)> {
cadcraft_dwg::encode_noted(dxf_write::dwg_bridge(d, version).as_bytes(), version.code()).map_err(IoError::Format)
}
/// Like [`write`], but image formats (PNG, SVG, PDF) show `window` (a model-space rectangle in
/// drawing units, centred and scaled to fit the image without distortion) instead of the
/// extents. Formats that are not images (DXF, DWG) ignore the window. The window is checked with
File diff suppressed because it is too large Load Diff
+212
View File
@@ -0,0 +1,212 @@
//! What a DXF file holds, counted from its groups, and the same counts of a drawing read from it:
//! together they show what opening a file leaves out (the DXF rendition of a DWG for DWG files).
use std::collections::{BTreeMap, HashSet};
use cadcraft_doc::Drawing;
use serde::Serialize;
use super::{Category, Change, Comparison, Options, Out};
/// Counts of what a file (or a drawing) holds.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Census {
/// Entities of model space and every layout, per DXF type.
pub entities: BTreeMap<String, usize>,
/// Entities of block definitions (other than the model and paper space blocks), per type.
pub block_entities: BTreeMap<String, usize>,
/// Block definitions other than the model and paper space blocks and the generated ones.
pub blocks: usize,
/// Blocks CADCraft generates and so doesn't read (tables' blocks, standard arrowheads
/// nothing inserts); their entities are not counted.
pub generated_blocks: usize,
/// Table records per table type (LAYER, LTYPE, STYLE, DIMSTYLE, VIEW, UCS…).
pub tables: BTreeMap<String, usize>,
/// Non-graphical objects per type. For a drawing: only those kept without being modelled.
pub objects: BTreeMap<String, usize>,
/// CLASS records. For a drawing: only those kept for unmodelled objects.
pub classes: usize,
}
/// Records that belong to the record before them (or delimit blocks), not entities of their own.
const SUB_RECORDS: &[&str] = &["VERTEX", "SEQEND", "ATTRIB", "BLOCK", "ENDBLK"];
fn bump(m: &mut BTreeMap<String, usize>, k: &str) {
let n = m.entry(k.to_string()).or_insert(0);
*n = n.saturating_add(1);
}
/// A layout's own paper-space viewport (VIEWPORT with ID 1, group 69): part of the layout, not
/// an entity of its own for CADCraft.
fn is_overall_viewport(t: &str, g: &[cadcraft_dxf::Tag]) -> bool {
t == "VIEWPORT" && g.iter().any(|x| x.code == 69 && x.i64() == 1)
}
fn is_space_block(name: &str) -> bool {
let up = name.trim().to_ascii_uppercase();
up.starts_with("*MODEL_SPACE") || up.starts_with("*PAPER_SPACE")
}
/// Count what a DXF file (ASCII or binary) holds; `None` when it can't be parsed.
pub fn census(dxf: &[u8]) -> Option<Census> {
let tags = cadcraft_dxf::parse(dxf).ok()?;
let mut c = Census::default();
// Blocks CADCraft generates (tables' blocks, standard arrowheads nothing inserts) are left out
// on read and rebuilt on save: counted apart.
let mut table_blocks: HashSet<String> = HashSet::new();
let mut inserted: HashSet<String> = HashSet::new();
let mut blocks: Vec<(String, BTreeMap<String, usize>)> = Vec::new();
let mut note_refs = |t: &str, g: &[cadcraft_dxf::Tag]| {
let name = || g.iter().find(|x| x.code == 2).map(|x| x.str().trim().to_ascii_uppercase());
match t {
"ACAD_TABLE" => table_blocks.extend(name()),
"INSERT" => inserted.extend(name()),
_ => {}
}
};
for s in cadcraft_dxf::sections(&tags) {
let records = cadcraft_dxf::records(&s.tags);
match s.name.trim().to_ascii_uppercase().as_str() {
"ENTITIES" => {
for (t, g) in &records {
note_refs(t, g);
if !SUB_RECORDS.contains(&t.as_str()) && !is_overall_viewport(t, g) {
bump(&mut c.entities, t);
}
}
}
"BLOCKS" => {
let mut space = false;
for (t, g) in &records {
note_refs(t, g);
match t.as_str() {
"BLOCK" => {
let name = g.iter().find(|x| x.code == 2).map(cadcraft_dxf::Tag::str).unwrap_or_default();
space = is_space_block(&name);
if !space {
blocks.push((name.trim().to_ascii_uppercase(), BTreeMap::new()));
}
}
t if SUB_RECORDS.contains(&t) || is_overall_viewport(t, g) => {}
t if space => bump(&mut c.entities, t),
t => {
if let Some((_, m)) = blocks.last_mut() {
bump(m, t);
}
}
}
}
}
"TABLES" => {
for (t, _) in &records {
if t != "TABLE" && t != "ENDTAB" {
bump(&mut c.tables, t);
}
}
}
"OBJECTS" => {
for (t, _) in &records {
bump(&mut c.objects, t);
}
}
"CLASSES" => c.classes = c.classes.saturating_add(records.iter().filter(|(t, _)| t == "CLASS").count()),
_ => {}
}
}
for (name, m) in blocks {
if table_blocks.contains(&name) || (crate::dxf_ext::is_standard_arrow(&name) && !inserted.contains(&name)) {
c.generated_blocks = c.generated_blocks.saturating_add(1);
continue;
}
c.blocks = c.blocks.saturating_add(1);
for (t, n) in m {
let slot = c.block_entities.entry(t).or_insert(0);
*slot = slot.saturating_add(n);
}
}
Some(c)
}
/// The same counts of a drawing: its entities, blocks and table records, and the objects and
/// classes it keeps without modelling them.
pub fn census_of(d: &Drawing) -> Census {
let mut c = Census::default();
for e in d.model.iter().chain(d.layouts.iter().flat_map(|l| l.entities.iter())) {
bump(&mut c.entities, e.kind.dxf_name());
}
for b in d.blocks.values() {
for e in b.entities.iter() {
bump(&mut c.block_entities, e.kind.dxf_name());
}
}
c.blocks = d.blocks.len();
for (t, n) in [
("LAYER", d.layers.len()),
("LTYPE", d.linetypes.len()),
("STYLE", d.text_styles.len()),
("DIMSTYLE", d.dim_styles.len()),
("VIEW", d.views.len()),
("UCS", d.ucss.len()),
] {
c.tables.insert(t.to_string(), n);
}
for o in &d.foreign.objects {
bump(&mut c.objects, &o.kind);
}
c.classes = d.foreign.classes.len();
c
}
/// Polylines may change between the old-style POLYLINE and LWPOLYLINE when read: counted
/// together.
fn merged(m: &BTreeMap<String, usize>) -> BTreeMap<String, usize> {
let mut out = BTreeMap::new();
for (k, n) in m {
let key = if k == "POLYLINE" || k == "LWPOLYLINE" { "POLYLINE+LWPOLYLINE" } else { k.as_str() };
let slot = out.entry(key.to_string()).or_insert(0usize);
*slot = slot.saturating_add(*n);
}
out
}
fn compare_counts(out: &mut Out, place: &str, file: &BTreeMap<String, usize>, read: &BTreeMap<String, usize>) {
let (file, read) = (merged(file), merged(read));
let keys: std::collections::BTreeSet<&String> = file.keys().chain(read.keys()).collect();
for k in keys {
let (f, r) = (file.get(k).copied().unwrap_or(0), read.get(k).copied().unwrap_or(0));
if r < f {
out.push_n(Category::Read, Change::Lost, f - r, format!("{place} {k}"), format!("{k}: {f} in the file, {r} read"));
} else if r > f {
out.push_n(Category::Read, Change::Added, r - f, format!("{place} {k}"), format!("{k}: {f} in the file, {r} read"));
}
}
}
/// What a drawing read from a file holds less (or more) than the file: entities per type in the
/// spaces and in blocks, blocks, and the table records CADCraft models. Blocks CADCraft generates
/// (tables' blocks, unused standard arrowheads) are left out on both sides.
pub fn compare_census(file: &Census, d: &Drawing, opts: &Options) -> Comparison {
let read = census_of(d);
let mut out = Out::new(opts);
compare_counts(&mut out, "spaces", &file.entities, &read.entities);
compare_counts(&mut out, "blocks", &file.block_entities, &read.block_entities);
if file.blocks != read.blocks {
let change = if read.blocks < file.blocks { Change::Lost } else { Change::Added };
out.push_n(
Category::Read,
change,
file.blocks.abs_diff(read.blocks),
"BLOCK definitions",
format!("{} in the file, {} read", file.blocks, read.blocks),
);
}
for t in ["LAYER", "LTYPE", "STYLE", "DIMSTYLE", "VIEW", "UCS"] {
let (f, r) = (file.tables.get(t).copied().unwrap_or(0), read.tables.get(t).copied().unwrap_or(0));
if f != r {
let change = if r < f { Change::Lost } else { Change::Added };
out.push_n(Category::Read, change, f.abs_diff(r), format!("{t} table"), format!("{f} records in the file, {r} read"));
}
}
out.cmp
}
+695
View File
@@ -0,0 +1,695 @@
//! The corpus harness: every DXF and DWG file of a folder is opened, saved as DXF and as DWG,
//! reopened and compared with the drawing first opened; the findings go to a Markdown and a JSON
//! report. A file that fails, or even makes CADCraft panic, is a reported failure: each stage runs
//! under `catch_unwind` here, as a last resort of the harness only.
use std::collections::BTreeMap;
use std::panic::AssertUnwindSafe;
use std::path::{Path, PathBuf};
use std::time::Instant;
use cadcraft_doc::Drawing;
use serde::Serialize;
use super::{Category, Census, Change, Comparison, Options, Tally, census, census_of, clip, compare, compare_census};
/// A drawing file format the harness checks.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Format {
Dxf,
Dwg,
}
impl Format {
pub fn ext(self) -> &'static str {
match self {
Format::Dxf => "dxf",
Format::Dwg => "dwg",
}
}
pub fn label(self) -> &'static str {
match self {
Format::Dxf => "DXF",
Format::Dwg => "DWG",
}
}
/// The format of a file's bytes: DWG by its magic, otherwise DXF.
pub fn of(bytes: &[u8]) -> Format {
if cadcraft_dwg::is_dwg(bytes) { Format::Dwg } else { Format::Dxf }
}
fn other(self) -> Format {
match self {
Format::Dxf => Format::Dwg,
Format::Dwg => Format::Dxf,
}
}
}
/// How the harness runs.
#[derive(Clone, Debug, PartialEq)]
pub struct CorpusOptions {
pub compare: Options,
/// Also save in the other format (DWG → DXF, DXF → DWG).
pub cross_format: bool,
/// Count what each file holds, to report what opening it leaves out.
pub census: bool,
/// Keep the saved files in this folder (and reopen them from there).
pub keep_saved: Option<PathBuf>,
}
impl Default for CorpusOptions {
fn default() -> Self {
CorpusOptions { compare: Options::default(), cross_format: true, census: true, keep_saved: None }
}
}
/// Largest file the harness opens.
pub const MAX_FILE_BYTES: u64 = 1 << 30;
/// Deepest folder nesting walked.
const MAX_DEPTH: usize = 32;
/// Most files taken from one corpus.
const MAX_FILES: usize = 100_000;
/// One save → reopen of a drawing.
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RouteReport {
/// `dwg → dxf`.
pub route: String,
pub saved_bytes: usize,
/// Where the saved file was kept, when it was.
#[serde(skip_serializing_if = "Option::is_none")]
pub saved_path: Option<String>,
pub save_error: Option<String>,
pub reopen_error: Option<String>,
/// What reopening reported (records the reader couldn't place or decode).
pub reopen_notes: Vec<String>,
/// What the writer itself left out of the saved file: records per DXF type (upper case) it
/// can't write in that format (for DWG: other programs' custom entities and objects). Each
/// is lost; the reopened drawing's entity counts show the same entities missing, and a strict
/// corpus run fails on any.
pub not_written: BTreeMap<String, usize>,
/// The reopened drawing against the drawing first opened.
pub comparison: Comparison,
pub seconds: f64,
}
impl RouteReport {
pub fn ok(&self) -> bool {
self.save_error.is_none() && self.reopen_error.is_none()
}
/// Saved and reopened with no difference at all.
pub fn lossless(&self) -> bool {
self.ok() && self.not_written.is_empty() && self.comparison.is_empty()
}
/// How many records the writer left out ([`Self::not_written`]).
pub fn not_written_total(&self) -> usize {
self.not_written.values().fold(0usize, |a, n| a.saturating_add(*n))
}
}
/// `2 ACME_WIDGET, 1 OLE2FRAME`.
fn per_type(m: &BTreeMap<String, usize>) -> String {
m.iter().map(|(t, n)| format!("{n} {t}")).collect::<Vec<_>>().join(", ")
}
/// Everything found for one file.
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FileReport {
/// The path relative to the corpus folder.
pub path: String,
pub format: Option<Format>,
/// `AC1032` for a DWG, the `$ACADVER` of a DXF.
pub version: String,
pub bytes: u64,
pub open_error: Option<String>,
/// What opening reported (a DWG's records the reader skipped, damaged records).
pub open_notes: Vec<String>,
pub open_seconds: f64,
/// What the file holds (for a DWG: its DXF rendition), when counted.
pub file_census: Option<Census>,
/// What the drawing read from it holds.
pub drawing_census: Option<Census>,
/// What the drawing holds less or more than the file.
pub read: Comparison,
pub routes: Vec<RouteReport>,
}
impl FileReport {
pub fn opened(&self) -> bool {
self.open_error.is_none()
}
}
impl CorpusReport {
/// What makes a strict corpus run fail, one line each: a file that didn't open, a round trip
/// that didn't save or reopen, and records a writer left out ([`RouteReport::not_written`]).
/// Other differences are reported, not failed on.
pub fn strict_failures(&self) -> Vec<String> {
let mut out = Vec::new();
for f in &self.files {
if !f.opened() {
out.push(format!("{}: failed to open", f.path));
}
for r in &f.routes {
if !r.ok() {
out.push(format!("{} ({}): failed to save or reopen", f.path, r.route));
}
if !r.not_written.is_empty() {
out.push(format!("{} ({}): lost on save, not written: {}", f.path, r.route, per_type(&r.not_written)));
}
}
}
out
}
}
/// The whole corpus run.
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CorpusReport {
pub corpus: String,
/// UTC, `2026-10-21 09:30`.
pub generated: String,
pub cadcraft_version: String,
pub abs_tol: f64,
pub rel_tol: f64,
pub files: Vec<FileReport>,
}
/// Run `f`, turning a panic into an error message.
fn guard<T>(f: impl FnOnce() -> T) -> Result<T, String> {
std::panic::catch_unwind(AssertUnwindSafe(f)).map_err(|p| {
let msg =
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "no message".into());
format!("CRASH (panic): {msg}")
})
}
/// A drawing's notes on what reading it left out.
fn notes(d: &Drawing) -> Vec<String> {
let mut v = d.read_notes.clone();
for (section, n) in &d.skipped_records {
v.push(format!("{n} record(s) of the {section} section couldn't be placed and were skipped"));
}
v
}
/// Save `d` as `to`, reopen it and compare. `keep` is the file to keep the saved bytes in (and
/// reopen them from).
pub fn round_trip(d: &Drawing, from: Format, to: Format, opts: &Options, keep: Option<&Path>) -> RouteReport {
let start = Instant::now();
let mut r = RouteReport { route: format!("{} → {}", from.ext(), to.ext()), ..RouteReport::default() };
let name = format!("roundtrip.{}", to.ext());
let saved = match guard(|| crate::write_noted(d, &name, &crate::SaveOptions::default())) {
Ok(Ok((b, not_written))) => {
r.not_written = not_written;
b
}
Ok(Err(e)) => {
r.save_error = Some(e.to_string());
r.seconds = start.elapsed().as_secs_f64();
return r;
}
Err(e) => {
r.save_error = Some(e);
r.seconds = start.elapsed().as_secs_f64();
return r;
}
};
r.saved_bytes = saved.len();
let mut bytes = saved;
if let Some(path) = keep {
let kept = path.parent().map_or(Ok(()), std::fs::create_dir_all).and_then(|_| std::fs::write(path, &bytes)).and_then(|_| std::fs::read(path));
match kept {
Ok(b) => {
bytes = b;
r.saved_path = Some(path.display().to_string());
}
Err(e) => r.reopen_notes.push(format!("couldn't keep the saved file at {}: {e}", path.display())),
}
}
let reopened = match guard(|| crate::read(&bytes, &name)) {
Ok(Ok(d2)) => d2,
Ok(Err(e)) => {
r.reopen_error = Some(e.to_string());
r.seconds = start.elapsed().as_secs_f64();
return r;
}
Err(e) => {
r.reopen_error = Some(e);
r.seconds = start.elapsed().as_secs_f64();
return r;
}
};
r.reopen_notes.extend(notes(&reopened));
match guard(|| compare(d, &reopened, opts)) {
Ok(c) => r.comparison = c,
Err(e) => r.reopen_error = Some(format!("comparing: {e}")),
}
attribute_unwritten(&mut r, to);
r.seconds = start.elapsed().as_secs_f64();
r
}
/// Say on the lost entity rows of a type the writer couldn't write that this is why they are
/// lost, so a writer's gap reads as one and isn't taken for a reader's or a comparison's.
fn attribute_unwritten(r: &mut RouteReport, to: Format) {
if r.not_written.is_empty() {
return;
}
for d in r.comparison.differences.iter_mut().filter(|d| d.category == Category::Count && d.change == Change::Lost) {
if let Some(t) = r.not_written.keys().find(|t| d.detail.starts_with(&format!("{t}: "))) {
d.detail.push_str(&format!(" (not written: the {} writer can't write {t})", to.label()));
}
}
}
/// The kept saved file of `rel` saved as `to`, in `dir`.
fn kept_path(dir: &Path, rel: &str, to: Format) -> PathBuf {
let flat: String = rel.chars().map(|c| if matches!(c, '/' | '\\' | ':') { '_' } else { c }).collect();
dir.join(format!("{flat}.roundtrip.{}", to.ext()))
}
/// Check one file's bytes: open, count, save in each format, reopen, compare. `rel` names it in
/// the report.
pub fn check_bytes(bytes: &[u8], rel: &str, opts: &CorpusOptions) -> FileReport {
let format = Format::of(bytes);
let mut f = FileReport { path: rel.to_string(), format: Some(format), bytes: bytes.len() as u64, ..FileReport::default() };
if format == Format::Dwg {
f.version = cadcraft_dwg::version(bytes).unwrap_or_default();
}
let start = Instant::now();
let name = format!("source.{}", format.ext());
let opened = guard(|| crate::read_source(bytes, std::path::Path::new(&name)));
f.open_seconds = start.elapsed().as_secs_f64();
let (d, dxf) = match opened {
Ok(Ok(x)) => x,
Ok(Err(e)) => {
f.open_error = Some(e.to_string());
return f;
}
Err(e) => {
f.open_error = Some(e);
return f;
}
};
if format == Format::Dxf {
f.version = d.header.str("ACADVER", "");
}
f.open_notes = notes(&d);
f.drawing_census = guard(|| census_of(&d)).ok();
if opts.census {
let source = dxf.as_deref().unwrap_or(bytes);
match guard(|| census(source)) {
Ok(Some(c)) => {
if let Ok(read) = guard(|| compare_census(&c, &d, &opts.compare)) {
f.read = read;
}
f.file_census = Some(c);
}
Ok(None) => f.open_notes.push("the file's groups couldn't be counted".into()),
Err(e) => f.open_notes.push(format!("counting the file's groups: {e}")),
}
}
let mut targets = vec![format];
if opts.cross_format {
targets.push(format.other());
}
for to in targets {
let keep = opts.keep_saved.as_deref().map(|dir| kept_path(dir, rel, to));
f.routes.push(round_trip(&d, format, to, &opts.compare, keep.as_deref()));
}
f
}
/// Check one file on disk; `rel` names it in the report.
pub fn check_file(path: &Path, rel: &str, opts: &CorpusOptions) -> FileReport {
let fail = |e: String| FileReport { path: rel.to_string(), open_error: Some(e), ..FileReport::default() };
match std::fs::metadata(path) {
Ok(m) if m.len() > MAX_FILE_BYTES => return fail(format!("the file is {} bytes, more than the {MAX_FILE_BYTES} the harness opens", m.len())),
Ok(_) => {}
Err(e) => return fail(format!("can't read the file: {e}")),
}
match std::fs::read(path) {
Ok(bytes) => check_bytes(&bytes, rel, opts),
Err(e) => fail(format!("can't read the file: {e}")),
}
}
/// Whether a path names a DXF or DWG file (by extension).
pub fn is_drawing_file(path: &Path) -> bool {
path.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).is_some_and(|e| e == "dxf" || e == "dwg")
}
/// Every DXF and DWG file under `dir`, sorted, leaving out hidden folders and the folders in
/// `skip` (the report folder). Symbolic links are not followed.
pub fn corpus_files(dir: &Path, skip: &[PathBuf]) -> Vec<PathBuf> {
walk(dir, skip).files
}
/// What [`walk`] found, and what it couldn't look at, so a report never passes for a whole one.
#[derive(Debug, Default)]
pub struct Walk {
pub files: Vec<PathBuf>,
/// Folders and entries that couldn't be read, with why.
pub unreadable: Vec<String>,
/// Files past [`MAX_FILES`] or folders past the depth limit were left out.
pub truncated: bool,
}
/// [`corpus_files`], also saying what it had to leave out.
pub fn walk(dir: &Path, skip: &[PathBuf]) -> Walk {
let skip: Vec<PathBuf> = skip.iter().map(|p| p.canonicalize().unwrap_or_else(|_| p.clone())).collect();
let mut w = Walk::default();
let mut stack = vec![(dir.to_path_buf(), 0usize)];
while let Some((d, depth)) = stack.pop() {
let entries = match std::fs::read_dir(&d) {
Ok(entries) => entries,
Err(e) => {
w.unreadable.push(format!("{}: {e}", d.display()));
continue;
}
};
for entry in entries {
let entry = match entry {
Ok(entry) => entry,
Err(e) => {
w.unreadable.push(format!("{}: {e}", d.display()));
continue;
}
};
let path = entry.path();
let ft = match entry.file_type() {
Ok(ft) => ft,
Err(e) => {
w.unreadable.push(format!("{}: {e}", path.display()));
continue;
}
};
if ft.is_dir() {
let hidden = entry.file_name().to_string_lossy().starts_with('.');
let canon = path.canonicalize().unwrap_or_else(|_| path.clone());
if hidden || skip.contains(&canon) {
continue;
}
if depth < MAX_DEPTH {
stack.push((path, depth + 1));
} else {
w.truncated = true;
}
} else if ft.is_file() && is_drawing_file(&path) {
if w.files.len() < MAX_FILES {
w.files.push(path);
} else {
w.truncated = true;
}
}
}
}
w.files.sort();
w.unreadable.sort();
w
}
/// `path` relative to `root`, with forward slashes.
pub fn relative(path: &Path, root: &Path) -> String {
let rel = path.strip_prefix(root).unwrap_or(path);
rel.components().map(|c| c.as_os_str().to_string_lossy().into_owned()).collect::<Vec<_>>().join("/")
}
/// Check `files` (paths under `root`, which names the corpus in the report), calling `progress`
/// with (index, count, relative path) before each.
pub fn run(root: &Path, files: &[PathBuf], opts: &CorpusOptions, progress: &mut dyn FnMut(usize, usize, &str)) -> CorpusReport {
let mut report = CorpusReport {
corpus: root.display().to_string(),
generated: now_utc(),
cadcraft_version: env!("CARGO_PKG_VERSION").to_string(),
abs_tol: opts.compare.abs_tol,
rel_tol: opts.compare.rel_tol,
files: Vec::new(),
};
for (i, path) in files.iter().enumerate() {
let rel = relative(path, root);
progress(i, files.len(), &rel);
let f = match guard(|| check_file(path, &rel, opts)) {
Ok(f) => f,
Err(e) => FileReport { path: rel, open_error: Some(e), ..FileReport::default() },
};
report.files.push(f);
}
report
}
/// The current time, UTC, as `2026-10-21 09:30`.
fn now_utc() -> String {
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
let days = i64::try_from(secs / 86_400).unwrap_or(0);
let (h, m) = ((secs % 86_400) / 3600, (secs % 3600) / 60);
// Days to a civil date (proleptic Gregorian), after Howard Hinnant's public-domain algorithm.
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = yoe + era * 400 + i64::from(month <= 2);
format!("{year:04}-{month:02}-{day:02} {h:02}:{m:02} UTC")
}
// ---------------------------------------------------------------------------------------------
// Rendering
/// Most differences listed per round trip in the Markdown report (all are in the JSON).
const MD_ROWS: usize = 300;
/// Text for a Markdown table cell.
fn cell(s: &str) -> String {
s.replace('|', "\\|").replace(['\r', '\n'], " ")
}
fn size(n: u64) -> String {
if n >= 1 << 20 {
format!("{:.1} MB", n as f64 / f64::from(1u32 << 20))
} else if n >= 1 << 10 {
format!("{:.0} KB", n as f64 / 1024.0)
} else {
format!("{n} B")
}
}
fn status(err: &Option<String>) -> String {
match err {
None => "ok".into(),
Some(e) if e.starts_with("CRASH") => "**CRASH**".into(),
Some(_) => "**failed**".into(),
}
}
fn tally_cells(t: Tally) -> String {
format!("{} | {} | {}", t.lost, t.added, t.changed)
}
impl CorpusReport {
pub fn to_json(&self) -> String {
serde_json::to_string_pretty(self).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
}
/// Totals per category over every round trip.
pub fn totals(&self) -> std::collections::BTreeMap<Category, Tally> {
let mut all: std::collections::BTreeMap<Category, Tally> = std::collections::BTreeMap::new();
for r in self.files.iter().flat_map(|f| f.routes.iter()) {
for (c, t) in &r.comparison.totals {
let slot = all.entry(*c).or_default();
slot.add(Change::Lost, t.lost);
slot.add(Change::Added, t.added);
slot.add(Change::Changed, t.changed);
}
}
all
}
pub fn to_markdown(&self) -> String {
let mut s = String::new();
let opened = self.files.iter().filter(|f| f.opened()).count();
let routes: Vec<&RouteReport> = self.files.iter().flat_map(|f| f.routes.iter()).collect();
let save_failed = routes.iter().filter(|r| r.save_error.is_some()).count();
let reopen_failed = routes.iter().filter(|r| r.reopen_error.is_some()).count();
let lossless = routes.iter().filter(|r| r.lossless()).count();
let crashes = self.files.iter().filter(|f| f.open_error.as_deref().is_some_and(|e| e.starts_with("CRASH"))).count()
+ routes.iter().filter(|r| [&r.save_error, &r.reopen_error].iter().any(|e| e.as_deref().is_some_and(|e| e.contains("CRASH")))).count();
s.push_str("# CADCraft round-trip report\n\n");
s.push_str(&format!("- Corpus: `{}`\n", self.corpus));
s.push_str(&format!("- Generated: {} by CADCraft {}\n", self.generated, self.cadcraft_version));
s.push_str(&format!("- Tolerance for numbers: {:e} absolute + {:e} relative\n", self.abs_tol, self.rel_tol));
s.push_str(&format!(
"- Files: {} ({opened} opened, {} failed to open); round trips: {} ({save_failed} failed to save, {reopen_failed} failed to reopen, {lossless} without any difference); crashes: {crashes}\n",
self.files.len(),
self.files.len() - opened,
routes.len()
));
let mut unwritten: BTreeMap<String, usize> = BTreeMap::new();
for r in &routes {
for (t, n) in &r.not_written {
let slot = unwritten.entry(format!("{t} ({})", r.route)).or_insert(0);
*slot = slot.saturating_add(*n);
}
}
if unwritten.is_empty() {
s.push_str("- Lost on save (records a writer can't write): none\n\n");
} else {
s.push_str(&format!("- **Lost on save** (records a writer can't write): {}\n\n", per_type(&unwritten)));
}
s.push_str(
"Each file is opened, saved in its own format and in the other one, and the saved file is reopened and compared with \
the drawing first opened. *Read* counts what the file holds that the drawing read from it doesn't (for a DWG: its \
DXF rendition). Lost/added/changed count items (entities, records, values).\n\n",
);
s.push_str("## Summary\n\n");
s.push_str("| File | Format | Size | Open | Read lost | Round trip | Save | Reopen | Not written | Lost | Added | Changed |\n");
s.push_str("|---|---|---|---|---|---|---|---|---|---|---|---|\n");
for f in &self.files {
let fmt = f.format.map_or("?".to_string(), |x| format!("{} {}", x.label(), f.version).trim().to_string());
let open = if f.opened() { format!("ok ({:.1} s)", f.open_seconds) } else { status(&f.open_error) };
let lead = format!("| `{}` | {} | {} | {} | {} ", cell(&f.path), cell(&fmt), size(f.bytes), open, f.read.tally(Category::Read).lost);
if f.routes.is_empty() {
s.push_str(&format!("{lead}| — | — | — | — | — | — | — |\n"));
}
for (i, r) in f.routes.iter().enumerate() {
let head = if i == 0 { lead.clone() } else { "| | | | | ".to_string() };
let t = r.comparison.total();
let reopen = if r.save_error.is_some() { "—".to_string() } else { status(&r.reopen_error) };
let unwritten = if r.not_written.is_empty() { "0".to_string() } else { format!("**{}**", r.not_written_total()) };
s.push_str(&format!("{head}| {} | {} | {} | {unwritten} | {} |\n", r.route, status(&r.save_error), reopen, tally_cells(t)));
}
}
s.push_str("\n## By category (all round trips)\n\n| Category | Lost | Added | Changed |\n|---|---|---|---|\n");
let totals = self.totals();
for c in Category::ALL {
if let Some(t) = totals.get(&c).filter(|t| t.total() > 0) {
s.push_str(&format!("| {} | {} |\n", c.label(), tally_cells(*t)));
}
}
if totals.values().all(|t| t.total() == 0) {
s.push_str("| (no differences) | 0 | 0 | 0 |\n");
}
s.push_str("\n## Files\n");
for f in &self.files {
self.file_markdown(f, &mut s);
}
s
}
fn file_markdown(&self, f: &FileReport, s: &mut String) {
s.push_str(&format!("\n### `{}`\n\n", f.path));
let fmt = f.format.map_or("unknown format".to_string(), |x| format!("{} {}", x.label(), f.version).trim().to_string());
s.push_str(&format!("- {fmt}, {}\n", size(f.bytes)));
match &f.open_error {
Some(e) => {
s.push_str(&format!("- Open: {}: {}\n", status(&f.open_error), cell(e)));
return;
}
None => {
let what = f.drawing_census.as_ref().map_or(String::new(), |c| {
let n: usize = c.entities.values().sum();
let nb: usize = c.block_entities.values().sum();
format!(
": {n} entities in model and paper space, {} blocks holding {nb} entities, {} layers, {} unmodelled objects kept",
c.blocks,
c.tables.get("LAYER").copied().unwrap_or(0),
c.objects.values().sum::<usize>()
)
});
s.push_str(&format!("- Open: ok in {:.1} s{what}\n", f.open_seconds));
}
}
for n in &f.open_notes {
s.push_str(&format!("- Note on opening: {}\n", cell(n)));
}
if let Some(c) = &f.file_census
&& !c.objects.is_empty()
{
let kept = f.drawing_census.as_ref().map(|d| &d.objects);
let list: Vec<String> = c
.objects
.iter()
.map(|(k, n)| match kept.and_then(|m| m.get(k)) {
Some(m) => format!("{k} {n} ({m} kept as unmodelled)"),
None => format!("{k} {n}"),
})
.collect();
s.push_str(&format!("- Objects in the file: {}\n", cell(&list.join(", "))));
}
if !f.read.is_empty() {
s.push_str("\n#### Read (file → drawing)\n\n");
diff_table(&f.read, s);
}
for r in &f.routes {
s.push_str(&format!("\n#### {}\n\n", r.route));
match (&r.save_error, &r.reopen_error) {
(Some(e), _) => s.push_str(&format!("- Save: {}: {}\n", status(&r.save_error), cell(e))),
(None, Some(e)) => {
s.push_str(&format!("- Save: ok ({})\n", size(r.saved_bytes as u64)));
s.push_str(&format!("- Reopen: {}: {}\n", status(&r.reopen_error), cell(e)));
}
(None, None) => {
let t = r.comparison.total();
s.push_str(&format!(
"- Saved {} and reopened in {:.1} s: {} lost, {} added, {} changed\n",
size(r.saved_bytes as u64),
r.seconds,
t.lost,
t.added,
t.changed
));
}
}
if !r.not_written.is_empty() {
s.push_str(&format!("- **Lost on save**, not written (the writer can't write these types): {}\n", per_type(&r.not_written)));
}
if let Some(p) = &r.saved_path {
s.push_str(&format!("- Saved file: `{p}`\n"));
}
for n in &r.reopen_notes {
s.push_str(&format!("- Note on reopening: {}\n", cell(n)));
}
if !r.comparison.is_empty() {
s.push('\n');
diff_table(&r.comparison, s);
}
}
}
/// Write `report.md` and `report.json` into `dir` (created if needed); returns their paths.
pub fn write(&self, dir: &Path) -> std::io::Result<(PathBuf, PathBuf)> {
std::fs::create_dir_all(dir)?;
let (md, json) = (dir.join("report.md"), dir.join("report.json"));
std::fs::write(&md, self.to_markdown())?;
std::fs::write(&json, self.to_json())?;
Ok((md, json))
}
}
/// A comparison's differences as a Markdown table (the first [`MD_ROWS`]).
fn diff_table(c: &Comparison, s: &mut String) {
let tallies: Vec<String> = Category::ALL
.iter()
.filter_map(|cat| {
let t = c.tally(*cat);
(t.total() > 0).then(|| format!("{} {}/{}/{}", cat.label(), t.lost, t.added, t.changed))
})
.collect();
s.push_str(&format!("By category (lost/added/changed): {}\n\n", tallies.join(" · ")));
s.push_str("| Category | Change | Where | Detail |\n|---|---|---|---|\n");
for d in c.differences.iter().take(MD_ROWS) {
s.push_str(&format!("| {} | {} | {} | {} |\n", d.category.label(), d.change.label(), cell(&clip(&d.place, 80)), cell(&clip(&d.detail, 400))));
}
let more = c.differences.len().saturating_sub(MD_ROWS).saturating_add(c.omitted);
if more > 0 {
s.push_str(&format!("\n… and {more} more differences (see report.json; differences beyond the listing limit are only counted).\n"));
}
}
+566
View File
@@ -0,0 +1,566 @@
use std::sync::Arc;
use cadcraft_color::Color;
use cadcraft_doc::foreign::{ForeignExtra, ForeignObject};
use cadcraft_doc::*;
use cadcraft_geom::{PolyVertex, Vec2, Vec3};
use super::*;
fn tag(code: i32, value: &str) -> RawTag {
RawTag { code, value: value.into() }
}
fn line(a: (f64, f64), b: (f64, f64)) -> EntityKind {
EntityKind::Line(Line { a: Vec3::new(a.0, a.1, 0.0), b: Vec3::new(b.0, b.1, 0.0) })
}
/// A small drawing touching every part the comparator looks at.
fn drawing() -> Drawing {
let mut d = Drawing::new_metric();
d.layers.push(Layer { name: "Walls".into(), color: Color::Index(1), ..Layer::default() });
let walls = Common { layer: "Walls".into(), ..Common::default() };
d.add(&Space::Model, walls.clone(), line((0.0, 0.0), (10.0, 0.0))).unwrap();
d.add(&Space::Model, walls, line((0.0, 5.0), (10.0, 5.0))).unwrap();
d.add(&Space::Model, Common::default(), EntityKind::Circle(Circle { center: Vec3::new(3.0, 4.0, 0.0), radius: 2.5 })).unwrap();
let tagged = Common { xdata: vec![tag(1001, "ACME"), tag(1000, "door"), tag(1040, "2.5")], ..Common::default() };
d.add(
&Space::Model,
tagged,
EntityKind::LwPolyline(LwPolyline {
vertices: vec![PolyVertex::new(Vec2::ZERO), PolyVertex::with_bulge(Vec2::new(4.0, 0.0), 0.5), PolyVertex::new(Vec2::new(4.0, 3.0))],
closed: true,
const_width: 0.0,
elevation: 0.0,
plinegen: false,
}),
)
.unwrap();
d.add(
&Space::Model,
Common::default(),
EntityKind::Unknown(Unknown { dxf_type: "ACME_WIDGET".into(), tags: vec![tag(100, "AcDbEntity"), tag(10, "1.0"), tag(20, "2.0")] }),
)
.unwrap();
d.add(&Space::Paper("Layout1".into()), Common::default(), line((1.0, 1.0), (2.0, 2.0))).unwrap();
let mut b = Block::new("DOOR");
let h = d.new_handle();
b.entities.push(Entity::new(h, line((0.0, 0.0), (0.0, 1.0))));
d.blocks.insert("DOOR".into(), Arc::new(b));
let f = Arc::make_mut(&mut d.foreign);
f.objects.push(ForeignObject { handle: Handle(0x500), kind: "XRECORD".into(), tags: vec![tag(100, "AcDbXrecord"), tag(1, "kept")] });
f.dictionary.push(("ACME_DATA".into(), Handle(0x500)));
f.classes.push(vec![tag(1, "ACME_WIDGET"), tag(2, "AcmeWidget"), tag(3, "Acme")]);
f.records.insert(("LAYER".into(), "WALLS".into()), ForeignExtra { xdata: vec![tag(1001, "ACME"), tag(1070, "3")], ..ForeignExtra::default() });
d
}
fn cmp(a: &Drawing, b: &Drawing) -> Comparison {
compare(a, b, &Options::default())
}
fn only(c: &Comparison, cat: Category, change: Change) -> Vec<&Difference> {
c.differences.iter().filter(|d| d.category == cat && d.change == change).collect()
}
fn first_handle(d: &Drawing, kind: &str) -> Handle {
d.model.iter().find(|e| e.kind.dxf_name() == kind).map(|e| e.handle).unwrap()
}
#[test]
fn identical_drawings_have_no_differences() {
let d = drawing();
let c = cmp(&d, &d.clone());
assert!(c.is_empty(), "{:#?}", c.differences);
assert_eq!(c.total(), Tally::default());
}
#[test]
fn geometry_is_compared_by_handle_within_the_tolerance() {
let a = drawing();
let h = first_handle(&a, "LINE");
let mut b = a.clone();
b.modify_entity(h, |e| {
if let EntityKind::Line(l) = &mut e.kind {
l.b.x += 1e-9;
}
})
.unwrap();
assert!(cmp(&a, &b).is_empty(), "a change within the tolerance is no difference");
b.modify_entity(h, |e| {
if let EntityKind::Line(l) = &mut e.kind {
l.b.x = 11.0;
}
})
.unwrap();
let c = cmp(&a, &b);
let g = only(&c, Category::Geometry, Change::Changed);
assert_eq!(g.len(), 1, "{:#?}", c.differences);
assert!(g[0].place.contains(&h.hex()) && g[0].place.starts_with("Model LINE"), "{:?}", g[0]);
assert!(g[0].detail.contains("b.x: 10") && g[0].detail.contains("11"), "{:?}", g[0]);
assert_eq!(c.tally(Category::Geometry).changed, 1);
}
#[test]
fn angles_a_whole_turn_apart_are_the_same() {
let mut a = drawing();
let h = a
.add(&Space::Model, Common::default(), EntityKind::Arc(cadcraft_doc::Arc { center: Vec3::ZERO, radius: 2.0, start: -0.5, end: 1.0 }))
.unwrap();
let mut b = a.clone();
b.modify_entity(h, |e| {
if let EntityKind::Arc(x) = &mut e.kind {
x.start += std::f64::consts::TAU;
x.end -= std::f64::consts::TAU;
}
})
.unwrap();
assert!(cmp(&a, &b).is_empty(), "{:#?}", cmp(&a, &b).differences);
// A radius is no angle.
b.modify_entity(h, |e| {
if let EntityKind::Arc(x) = &mut e.kind {
x.radius += std::f64::consts::TAU;
}
})
.unwrap();
assert_eq!(cmp(&a, &b).tally(Category::Geometry).changed, 1);
}
#[test]
fn properties_and_type_changes_are_reported() {
let a = drawing();
let h = first_handle(&a, "CIRCLE");
let mut b = a.clone();
b.modify_entity(h, |e| e.common.color = Color::Index(3)).unwrap();
let c = cmp(&a, &b);
assert_eq!(only(&c, Category::Properties, Change::Changed).len(), 1, "{:#?}", c.differences);
b.modify_entity(h, |e| e.kind = line((0.0, 0.0), (1.0, 1.0))).unwrap();
let c = cmp(&a, &b);
let g = only(&c, Category::Geometry, Change::Changed);
assert!(g.iter().any(|d| d.detail == "type CIRCLE → LINE"), "{:#?}", c.differences);
}
#[test]
fn lost_and_added_entities_are_counted_per_type_and_space() {
let a = drawing();
let h = first_handle(&a, "CIRCLE");
let mut b = a.clone();
b.remove_entity(h);
let mut paper = b.clone();
paper.add(&Space::Paper("Layout1".into()), Common::default(), EntityKind::Point(Point { p: Vec3::ZERO, angle: 0.0 })).unwrap();
let c = cmp(&a, &paper);
let lost = only(&c, Category::Count, Change::Lost);
assert_eq!(lost.len(), 1, "{:#?}", c.differences);
assert_eq!(lost[0].place, "Model CIRCLE");
assert!(lost[0].detail.contains("1 → 0") && lost[0].detail.contains(&h.hex()), "{:?}", lost[0]);
let added = only(&c, Category::Count, Change::Added);
assert_eq!(added.len(), 1);
assert_eq!(added[0].place, "Layout Layout1 POINT");
assert_eq!(c.tally(Category::Count), Tally { lost: 1, added: 1, changed: 0 });
}
#[test]
fn renumbered_entities_are_matched_by_content() {
let a = drawing();
let mut b = a.clone();
// Every line gets a new handle, in reverse order, and one of them moves.
let lines: Vec<Arc<Entity>> = b.model.iter().filter(|e| e.kind.dxf_name() == "LINE").cloned().collect();
for (i, e) in lines.iter().rev().enumerate() {
b.model.remove(e.handle);
let mut e = Entity::clone(e);
e.handle = Handle(0x9000 + i as u64);
b.model.push(e);
}
let c = cmp(&a, &b);
assert_eq!(c.tally(Category::Count), Tally::default(), "{:#?}", c.differences);
assert_eq!(c.tally(Category::Geometry), Tally::default(), "pairs by content, not by order: {:#?}", c.differences);
assert_eq!(c.tally(Category::Handle).changed, 2);
}
#[test]
fn entities_moving_between_spaces_are_reported() {
let a = drawing();
let h = first_handle(&a, "CIRCLE");
let mut b = a.clone();
let e = b.remove_entity(h).unwrap();
if let Some(l) = b.layouts.first_mut() {
l.entities.push(Entity::clone(&e));
}
let c = cmp(&a, &b);
let moved = only(&c, Category::Handle, Change::Changed);
assert_eq!(moved.len(), 1, "{:#?}", c.differences);
assert_eq!(moved[0].detail, format!("1 entities moved from Model to Layout Layout1 ({})", h.hex()));
assert_eq!(c.tally(Category::Count), Tally::default());
}
#[test]
fn unreferenced_anonymous_blocks_dropped_on_save_are_one_row() {
let mut a = drawing();
for name in ["*D5", "*D6"] {
let mut blk = Block::new(name);
for _ in 0..2 {
let h = a.new_handle();
blk.entities.push(Entity::new(h, line((0.0, 0.0), (1.0, 0.0))));
}
a.blocks.insert(name.into(), Arc::new(blk));
}
let mut b = a.clone();
b.blocks.remove("*D5");
b.blocks.remove("*D6");
let c = cmp(&a, &b);
let lost = only(&c, Category::Count, Change::Lost);
assert_eq!(lost.len(), 1, "{:#?}", c.differences);
assert_eq!(lost[0].detail, "4 entities of 2 anonymous blocks nothing refers to (e.g. *D5) were not saved");
assert_eq!(c.tally(Category::Count).lost, 4);
assert_eq!(c.tally(Category::Block), Tally { lost: 2, added: 0, changed: 0 }, "{:#?}", c.differences);
assert_eq!(only(&c, Category::Block, Change::Lost).len(), 1);
}
#[test]
fn renamed_anonymous_blocks_are_matched_by_their_entities() {
let mut a = drawing();
let mut blk = Block::new("*D1");
let h = a.new_handle();
blk.entities.push(Entity::new(h, line((0.0, 0.0), (5.0, 0.0))));
a.blocks.insert("*D1".into(), Arc::new(blk.clone()));
let key = ("BLOCK_RECORD".to_string(), "*D1".to_string());
Arc::make_mut(&mut a.foreign).records.insert(key, ForeignExtra { xdata: vec![tag(1001, "ACME"), tag(1070, "1")], ..ForeignExtra::default() });
let mut b = a.clone();
b.blocks.remove("*D1");
blk.name = "*D7".into();
b.blocks.insert("*D7".into(), Arc::new(blk));
let f = Arc::make_mut(&mut b.foreign);
let extra = f.records.remove(&("BLOCK_RECORD".to_string(), "*D1".to_string())).unwrap();
f.records.insert(("BLOCK_RECORD".into(), "*D7".into()), extra);
let c = cmp(&a, &b);
assert_eq!(c.tally(Category::Count), Tally::default(), "{:#?}", c.differences);
assert_eq!(c.tally(Category::Xdata), Tally::default(), "{:#?}", c.differences);
assert_eq!(c.tally(Category::Block), Tally { lost: 0, added: 0, changed: 1 }, "{:#?}", c.differences);
assert!(c.differences.iter().any(|d| d.detail == "1 anonymous blocks saved under new names (e.g. *D1 → *D7)"), "{:#?}", c.differences);
}
#[test]
fn xdata_extension_dictionaries_and_opaque_entities_are_compared() {
let a = drawing();
let pl = first_handle(&a, "LWPOLYLINE");
let unknown = first_handle(&a, "ACME_WIDGET");
let mut b = a.clone();
b.modify_entity(pl, |e| e.common.xdata.clear()).unwrap();
b.modify_entity(unknown, |e| {
if let EntityKind::Unknown(u) = &mut e.kind {
u.tags.pop();
}
})
.unwrap();
Arc::make_mut(&mut b.foreign).records.clear();
let mut a2 = a.clone();
Arc::make_mut(&mut a2.foreign)
.entities
.insert(pl, ForeignExtra { xdict: Some(Handle(0x600)), reactors: vec![Handle(0x601)], ..ForeignExtra::default() });
let c = cmp(&a2, &b);
let x = only(&c, Category::Xdata, Change::Lost);
assert_eq!(x.len(), 2, "the entity's and the layer's: {:#?}", c.differences);
assert!(x.iter().any(|d| d.place == "LAYER WALLS" && d.detail.starts_with("application ACME")), "{x:#?}");
assert!(x.iter().any(|d| d.place.contains("LWPOLYLINE")), "{x:#?}");
assert_eq!(c.tally(Category::ExtDict).lost, 2, "the dictionary and a reactor: {:#?}", c.differences);
let o = only(&c, Category::Opaque, Change::Changed);
assert_eq!(o.len(), 1, "{:#?}", c.differences);
assert!(o[0].detail.contains("3 groups → 2"), "{:?}", o[0]);
// Numbers in kept groups compare within the tolerance.
let mut b2 = a.clone();
b2.modify_entity(unknown, |e| {
if let EntityKind::Unknown(u) = &mut e.kind {
u.tags[1].value = "1.0000000000001".into();
}
})
.unwrap();
assert!(cmp(&a, &b2).is_empty());
}
#[test]
fn tables_blocks_and_layouts_are_compared_by_name() {
let a = drawing();
let mut b = a.clone();
b.layers.retain(|l| l.name != "Walls");
b.linetypes.push(Linetype { name: "Dashed".into(), description: String::new(), pattern: Vec::new() });
if let Some(s) = b.text_styles.first_mut() {
s.height = 7.0;
}
b.blocks.remove("DOOR");
b.layouts.retain(|l| l.name != "Layout2");
let c = cmp(&a, &b);
let t = |cat, ch, place: &str| c.differences.iter().any(|d| d.category == cat && d.change == ch && d.place == place);
assert!(t(Category::Table, Change::Lost, "LAYER Walls"), "{:#?}", c.differences);
assert!(t(Category::Table, Change::Added, "LTYPE Dashed"));
assert!(t(Category::Table, Change::Changed, "STYLE Standard"));
assert!(t(Category::Block, Change::Lost, "BLOCK DOOR"));
assert!(t(Category::Layout, Change::Lost, "LAYOUT Layout2"));
// The block's entity is lost with it, counted where it was.
assert!(t(Category::Count, Change::Lost, "Block DOOR LINE"), "{:#?}", c.differences);
// Names match without case.
let mut upper = a.clone();
if let Some(l) = upper.layers.iter_mut().find(|l| l.name == "Walls") {
l.name = "WALLS".into();
}
let c = cmp(&a, &upper);
assert_eq!(c.tally(Category::Table).lost, 0, "{:#?}", c.differences);
}
#[test]
fn unmodelled_objects_dictionary_entries_and_classes_are_compared() {
let a = drawing();
let mut b = a.clone();
let f = Arc::make_mut(&mut b.foreign);
f.objects.clear();
f.dictionary.clear();
f.classes.clear();
let c = cmp(&a, &b);
assert_eq!(c.tally(Category::Object).lost, 1, "{:#?}", c.differences);
assert_eq!(c.tally(Category::Dictionary).lost, 1);
assert_eq!(c.tally(Category::Class).lost, 1);
// A renumbered object is matched by its groups.
let mut b = a.clone();
Arc::make_mut(&mut b.foreign).objects[0].handle = Handle(0x700);
let c = cmp(&a, &b);
assert_eq!(c.tally(Category::Object), Tally::default(), "{:#?}", c.differences);
assert_eq!(c.tally(Category::Handle).changed, 1);
}
#[test]
fn header_variables_are_compared_without_volatile_ones() {
let a = drawing();
let mut b = a.clone();
b.header.set_f64("LTSCALE", 2.0);
b.header.vars.remove("TEXTSIZE");
b.header.set_str("HANDSEED", "FFFF");
b.header.set_str("TDUPDATE", "2461000.5");
// 1 and 1.0 are the same value.
b.header.set_i64("DIMSCALE", 1);
let c = cmp(&a, &b);
assert!(only(&c, Category::Header, Change::Changed).iter().any(|d| d.place == "$LTSCALE"), "{:#?}", c.differences);
assert!(only(&c, Category::Header, Change::Lost).iter().any(|d| d.place == "$TEXTSIZE"));
assert_eq!(c.tally(Category::Header).total(), 2, "{:#?}", c.differences);
}
#[test]
fn the_listing_limit_keeps_counting() {
let a = drawing();
let mut b = a.clone();
for i in 0..20 {
b.header.set_i64(&format!("USERI{i}"), i);
}
let c = compare(&a, &b, &Options { max_differences: 5, ..Options::default() });
assert_eq!(c.differences.len(), 5);
assert_eq!(c.omitted, 15);
assert_eq!(c.tally(Category::Header).added, 20);
}
#[test]
fn census_counts_what_a_dxf_holds() {
let dxf = " 0\nSECTION\n 2\nTABLES\n 0\nTABLE\n 2\nLAYER\n 0\nLAYER\n 2\n0\n 0\nLAYER\n 2\nA\n 0\nENDTAB\n 0\nENDSEC\n\
0\nSECTION\n 2\nBLOCKS\n 0\nBLOCK\n 2\n*Model_Space\n 0\nENDBLK\n 0\nBLOCK\n 2\n*Paper_Space\n 0\nLINE\n 0\nENDBLK\n\
0\nBLOCK\n 2\nB1\n 0\nCIRCLE\n 0\nPOLYLINE\n 0\nVERTEX\n 0\nSEQEND\n 0\nENDBLK\n 0\nENDSEC\n\
0\nSECTION\n 2\nENTITIES\n 0\nLINE\n 0\nINSERT\n 0\nATTRIB\n 0\nSEQEND\n 0\nENDSEC\n\
0\nSECTION\n 2\nOBJECTS\n 0\nDICTIONARY\n 0\nXRECORD\n 0\nXRECORD\n 0\nENDSEC\n 0\nEOF\n";
let c = census(dxf.as_bytes()).unwrap();
assert_eq!(c.entities.get("LINE"), Some(&2));
assert_eq!(c.entities.get("INSERT"), Some(&1));
assert!(!c.entities.contains_key("ATTRIB") && !c.entities.contains_key("SEQEND"));
assert_eq!(c.block_entities.get("CIRCLE"), Some(&1));
assert_eq!(c.block_entities.get("POLYLINE"), Some(&1));
assert_eq!(c.blocks, 1);
assert_eq!(c.tables.get("LAYER"), Some(&2));
assert_eq!(c.objects.get("XRECORD"), Some(&2));
let _ = census(b"");
}
#[test]
fn read_census_reports_entities_the_reader_left_out() {
let d = drawing();
let mut file = census_of(&d);
*file.entities.entry("LINE".into()).or_default() += 2;
*file.entities.entry("WEIRD".into()).or_default() += 1;
let c = compare_census(&file, &d, &Options::default());
let lost = only(&c, Category::Read, Change::Lost);
assert_eq!(c.tally(Category::Read).lost, 3, "{:#?}", c.differences);
assert!(lost.iter().any(|x| x.place == "spaces LINE" && x.detail == "LINE: 5 in the file, 3 read"), "{lost:#?}");
assert!(compare_census(&census_of(&d), &d, &Options::default()).is_empty());
}
/// The round trips of a synthetic drawing through CADCraft's own DXF and DWG writers: whatever
/// they lose shows in the comparison. Plain geometry, layers, xdata and unmodelled objects must
/// come back from DXF.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn dxf_round_trip_of_plain_geometry_is_lossless() {
use corpus::{Format, round_trip};
let mut d = drawing();
// What the comparator should see in a round trip: the drawing as read from its own DXF.
d = crate::read(crate::write(&d, "a.dxf").unwrap().as_slice(), "a.dxf").unwrap();
let r = round_trip(&d, Format::Dxf, Format::Dxf, &Options::default(), None);
assert!(r.ok(), "{r:#?}");
let core = [Category::Count, Category::Geometry, Category::Table, Category::Xdata, Category::Object, Category::Opaque];
for cat in core {
assert_eq!(r.comparison.tally(cat), Tally::default(), "{cat:?}: {:#?}", r.comparison.differences);
}
let dwg = round_trip(&d, Format::Dxf, Format::Dwg, &Options::default(), None);
assert!(dwg.save_error.is_none() && dwg.reopen_error.is_none(), "{dwg:#?}");
assert!(dwg.saved_bytes > 0);
// Through DWG, only the custom-class entity is lost (gap FF1: acadrust drops other programs'
// entities without DWG data of their own) — and the comparator says so.
let lost: Vec<&str> = dwg.comparison.differences.iter().filter(|d| d.category == Category::Count).map(|d| d.place.as_str()).collect();
assert_eq!(lost, ["Model ACME_WIDGET"], "{:#?}", dwg.comparison.differences);
assert_eq!(dwg.comparison.tally(Category::Geometry), Tally::default(), "{:#?}", dwg.comparison.differences);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn hostile_files_are_reported_not_crashed() {
use corpus::{CorpusOptions, check_bytes};
let opts = CorpusOptions::default();
for (bytes, name) in [
(&b""[..], "empty.dxf"),
(b"AC1032 not really a dwg file at all", "fake.dwg"),
(b" 0\nSECTION\n 2\nENTITIES\n 0\nLINE\n 10\nnan\n 20\n1e400\n 0\nENDSEC\n 0\nEOF\n", "odd.dxf"),
(&[0xFFu8; 64][..], "noise.dxf"),
] {
let f = check_bytes(bytes, name, &opts);
assert_eq!(f.path, name);
if f.opened() {
assert!(!f.routes.is_empty());
} else {
assert!(f.routes.is_empty() && f.open_error.as_deref().is_some_and(|e| !e.is_empty()), "{f:#?}");
}
}
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn report_renders_as_markdown_and_json() {
use corpus::{CorpusOptions, CorpusReport, check_bytes};
let d = drawing();
let dxf = crate::write(&d, "a.dxf").unwrap();
let opts = CorpusOptions::default();
let report = CorpusReport {
corpus: "corpus".into(),
generated: "2026-10-21 09:30 UTC".into(),
cadcraft_version: "0".into(),
abs_tol: 1e-6,
rel_tol: 1e-9,
files: vec![check_bytes(&dxf, "synthetic/a.dxf", &opts), check_bytes(b"AC1032 broken", "dwg/broken.dwg", &opts)],
};
let md = report.to_markdown();
assert!(md.starts_with("# CADCraft round-trip report"), "{md}");
assert!(md.contains("| `synthetic/a.dxf` | DXF AC1032 |"), "{md}");
assert!(md.contains("dxf → dxf") && md.contains("dxf → dwg"), "{md}");
assert!(md.contains("| `dwg/broken.dwg` | DWG AC1032 |") && md.contains("**failed**"), "{md}");
assert!(md.contains("## By category"));
let json: serde_json::Value = serde_json::from_str(&report.to_json()).unwrap();
assert_eq!(json["files"][0]["path"], "synthetic/a.dxf");
assert_eq!(json["files"][0]["routes"][0]["route"], "dxf → dxf");
assert!(json["files"][1]["openError"].as_str().is_some_and(|e| !e.is_empty()), "{json}");
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_folder_it_cannot_read_is_reported_not_skipped() {
let w = corpus::walk(std::path::Path::new("no/such/corpus/folder"), &[]);
assert!(w.files.is_empty());
assert_eq!(w.unreadable.len(), 1, "{:?}", w.unreadable);
}
#[test]
fn corpus_files_walks_folders_and_skips_the_report() {
use corpus::corpus_files;
let dir = std::env::temp_dir().join(format!("cadcraft-corpus-walk-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
for p in ["dxf/a.DXF", "dwg/b.dwg", "dwg/sub/c.dwg", "notes.txt", "report/a.roundtrip.dxf", ".git/x.dxf"] {
let path = dir.join(p);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, b"x").unwrap();
}
let files = corpus_files(&dir, &[dir.join("report")]);
let rel: Vec<String> = files.iter().map(|f| corpus::relative(f, &dir)).collect();
assert_eq!(rel, ["dwg/b.dwg", "dwg/sub/c.dwg", "dxf/a.DXF"]);
let _ = std::fs::remove_dir_all(&dir);
}
/// The real-file corpus: set `CADCRAFT_CORPUS_DIR` to a folder of DXF/DWG files (a checkout of
/// `storytold/cadcraft-corpus`) to open, save and reopen each one and write `report.md` and
/// `report.json` to `CADCRAFT_CORPUS_REPORT` (default: the corpus folder's `report`). Skipped
/// when the variable is unset, so CI passes without the corpus. It reports, it doesn't fail on
/// differences; set `CADCRAFT_CORPUS_STRICT=1` to fail when a file doesn't open, save or reopen,
/// or when a writer leaves records out of a saved file ([`corpus::CorpusReport::strict_failures`]).
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn corpus_round_trip_report() {
let Some(dir) = std::env::var_os("CADCRAFT_CORPUS_DIR").map(std::path::PathBuf::from) else {
eprintln!("CADCRAFT_CORPUS_DIR is not set: corpus round trip skipped");
return;
};
let out = std::env::var_os("CADCRAFT_CORPUS_REPORT").map_or_else(|| dir.join("report"), std::path::PathBuf::from);
let files = corpus::corpus_files(&dir, std::slice::from_ref(&out));
let report = corpus::run(&dir, &files, &corpus::CorpusOptions::default(), &mut |i, n, rel| eprintln!("[{}/{n}] {rel}", i + 1));
let (md, json) = report.write(&out).unwrap();
eprintln!("corpus report: {} and {}", md.display(), json.display());
if std::env::var("CADCRAFT_CORPUS_STRICT").is_ok_and(|v| v == "1") {
let failed = report.strict_failures();
assert!(failed.is_empty(), "corpus failures:\n{}", failed.join("\n"));
}
}
/// A type the DWG writer can't write is reported as lost on save, per type: in the route's
/// `not_written`, on the lost entity rows, in the Markdown and as a strict failure. A type it
/// writes (OLE2FRAME, kept by CADCraft without modelling it) is none of these.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn types_the_dwg_writer_cannot_write_are_reported_lost() {
use corpus::{CorpusReport, FileReport, Format, round_trip};
let unknown = |t: &str, tags: Vec<RawTag>| EntityKind::Unknown(Unknown { dxf_type: t.into(), tags });
let mut d = Drawing::new_metric();
let widget = vec![tag(100, "AcDbEntity"), tag(100, "AcmeWidget"), tag(10, "1.0"), tag(20, "2.0"), tag(30, "0.0")];
d.add(&Space::Model, Common::default(), unknown("ACME_WIDGET", widget.clone())).unwrap();
d.add(&Space::Model, Common::default(), unknown("ACME_WIDGET", widget)).unwrap();
// Its data has no DWG wrapper, which is where a DWG keeps the corners: the reader's defaults.
let ole = ["100", "AcDbEntity", "8", "0", "100", "AcDbOle2Frame", "70", "2", "10", "1.0", "20", "1.0", "30", "0.0", "11", "0.0", "21", "0.0"]
.chunks(2)
.map(|g| tag(g[0].parse().unwrap(), g[1]))
.chain(
["31", "0.0", "71", "2", "72", "0", "73", "0", "90", "4", "310", "C0FFEE00", "1", "OLE"]
.chunks(2)
.map(|g| tag(g[0].parse().unwrap(), g[1])),
)
.collect();
d.add(&Space::Model, Common::default(), unknown("OLE2FRAME", ole)).unwrap();
let d = crate::read(&crate::write(&d, "a.dxf").unwrap(), "a.dxf").unwrap();
let r = round_trip(&d, Format::Dxf, Format::Dwg, &Options::default(), None);
assert!(r.ok(), "{r:#?}");
assert_eq!(r.not_written, [("ACME_WIDGET".to_string(), 2)].into_iter().collect());
let lost: Vec<&Difference> = r.comparison.differences.iter().filter(|x| x.category == Category::Count && x.change == Change::Lost).collect();
assert_eq!(lost.len(), 1, "{lost:#?}");
assert!(lost[0].place.ends_with("ACME_WIDGET") && lost[0].detail.contains("not written: the DWG writer can't write ACME_WIDGET"), "{lost:#?}");
// The OLE2FRAME is written (only the CLASS record AutoCAD's files have is added).
assert!(
!r.comparison.differences.iter().any(|x| x.category != Category::Class && x.place.contains("OLE2FRAME")),
"{:#?}",
r.comparison.differences
);
assert!(!r.lossless());
let report =
CorpusReport { files: vec![FileReport { path: "a.dxf".into(), routes: vec![r], ..FileReport::default() }], ..CorpusReport::default() };
assert_eq!(report.strict_failures(), ["a.dxf (dxf → dwg): lost on save, not written: 2 ACME_WIDGET"]);
let md = report.to_markdown();
assert!(md.contains("- **Lost on save** (records a writer can't write): 2 ACME_WIDGET (dxf → dwg)"), "{md}");
assert!(md.contains("| dxf → dwg | ok | ok | **2** |"), "{md}");
assert!(md.contains("- **Lost on save**, not written (the writer can't write these types): 2 ACME_WIDGET"), "{md}");
// DXF keeps everything: nothing lost on save, nothing to fail on.
let r = round_trip(&d, Format::Dxf, Format::Dxf, &Options::default(), None);
assert!(r.not_written.is_empty());
let report =
CorpusReport { files: vec![FileReport { path: "a.dxf".into(), routes: vec![r], ..FileReport::default() }], ..CorpusReport::default() };
assert!(report.strict_failures().is_empty());
assert!(report.to_markdown().contains("- Lost on save (records a writer can't write): none"));
}
+48 -6
View File
@@ -23,8 +23,10 @@ commands:
test cargo nextest run (cargo test without nextest) plus the doctests
i18n print the strings each interface language shows in English (never fails)
ci fmt --check, clippy -D warnings, test, i18n, assets, layers, wasm (stops at first failure)
corpus [--download]
show where test corpora live; --download fetches PngSuite into corpus/pngsuite
corpus [DIR] [--out DIR] [--keep-saved] [--same-format]
open -> save -> reopen every DXF/DWG of a real-file corpus (DIR, default
$CADCRAFT_CORPUS_DIR or ../cadcraft-corpus) and write report.md + report.json
(default DIR/report); runs `cadcraft-cli roundtrip` in release mode
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
parity recompute the AutoCAD menu-breadth checklist in docs/parity-checklist.md
ico <out.ico> <in.png>...
@@ -41,7 +43,7 @@ fn main() -> ExitCode {
Some("test") => cmd_test(),
Some("i18n") => cmd_i18n(),
Some("ci") => cmd_ci(),
Some("corpus") => cmd_corpus(rest.contains(&"--download")),
Some("corpus") => cmd_corpus(&rest),
Some("stats") => stats::run(&root(), rest.contains(&"--exact")),
Some("parity") => parity::run(&root()),
Some("ico") => ico::run(&rest),
@@ -226,7 +228,47 @@ fn cmd_ci() -> Result<(), String> {
Ok(())
}
fn cmd_corpus(_download: bool) -> Result<(), String> {
println!("Test corpora live in storytold/cadcraft-corpus (planned); never commit large binaries here.");
Ok(())
/// `corpus [DIR] [options]`: the round-trip report over a real-file corpus (gaps FF1, FF5). The
/// corpus lives outside this repo (storytold/cadcraft-corpus); its files are never committed here.
fn cmd_corpus(args: &[&str]) -> Result<(), String> {
// `cadcraft-cli` runs from the repository root: relative paths are made absolute against the
// folder `cargo xtask` was run from first, so they mean what the user typed.
let here = std::env::current_dir().map_err(|e| format!("current folder: {e}"))?;
let mut pass: Vec<String> = Vec::new();
let mut dir: Option<String> = None;
let mut it = args.iter();
while let Some(a) = it.next() {
match *a {
"--out" => {
let v = it.next().ok_or_else(|| format!("{a} needs a value"))?;
pass.extend([a.to_string(), here.join(v).display().to_string()]);
}
"--tolerance" => {
let v = it.next().ok_or_else(|| format!("{a} needs a value"))?;
pass.extend([a.to_string(), v.to_string()]);
}
s if s.starts_with("--out=") => {
let v = s.trim_start_matches("--out=");
pass.extend(["--out".to_string(), here.join(v).display().to_string()]);
}
s if s.starts_with("--") => pass.push(s.to_string()),
s if dir.is_none() => dir = Some(s.to_string()),
_ => return Err("corpus takes one DIR".into()),
}
}
let dir = dir
.or_else(|| std::env::var("CADCRAFT_CORPUS_DIR").ok())
.unwrap_or_else(|| root().parent().map(|p| p.join("cadcraft-corpus").display().to_string()).unwrap_or_else(|| "../cadcraft-corpus".into()));
let dir = here.join(&dir);
if !dir.is_dir() {
return Err(format!(
"no corpus at `{}`: clone storytold/cadcraft-corpus next to this repo, set CADCRAFT_CORPUS_DIR or pass DIR \
(never commit corpus files to this repo)",
dir.display()
));
}
let dir = dir.display().to_string();
let mut c = cargo();
c.args(["run", "--release", "-q", "-p", "cadcraft-cli", "--", "roundtrip", &dir]).args(&pass);
run(c, &format!("cadcraft-cli roundtrip {dir} {}", pass.join(" ")))
}