pub(crate) mod reporting;
use reporting::{
command_body_json, command_report_json, fidelity_diff, fidelity_differs, print_check_report,
print_decode_report, print_export_emission, print_fidelity_summary, print_id_delta,
print_source_diff, write_command_report, write_json_report, CommandReportBody, Payload,
};
use cadmpeg_ir::codec::write::target::TargetRequest;
use cadmpeg_ir::codec::Confidence;
use std::fs::File;
use std::io::{self, Write};
use std::path::Path;
use std::process::ExitCode;
use anyhow::{anyhow, Context};
use cadmpeg_core::decode::{DecodeDepth, InspectOptions};
use serde::ser::SerializeStruct;
use serde::{Serialize, Serializer};
use cadmpeg_registry::{
build_encoder, resolve_and_inspect_with, ForcedInput, Format, InputCatalog, InspectError,
Inspected, Selection,
};
use crate::application::artifact_store::{self, FileDestination};
use crate::application::document::{LoadOrigin, LoadedDocument};
use crate::application::refusal::{ApplicationError, ConversionRefusal, RefusalStage};
use crate::application::transcoder::{
self, emit_export_plan, export_target, ConversionPolicy, DestinationPolicy, LossPolicy,
SourceRequest, TargetSelection,
};
use crate::application::validators::validate_loaded;
use crate::loader;
use crate::measure::geometry_diff::{GeometryArgs, GeometryBody, GeometryDiff};
use crate::DecodeArgs;
use cadmpeg_measure::compare::Verdict;
type CommandResult<T> = std::result::Result<T, ApplicationError>;
pub(crate) fn print_load_notice(document: &LoadedDocument) {
let LoadOrigin::Decoded {
selection: Selection::Detected { confidence },
report,
..
} = &document.origin
else {
return;
};
if *confidence < Confidence::High {
let format_id = report.format();
eprintln!(
"warning: detected {format_id} with {confidence} confidence; use --input-format to override"
);
}
}
pub(crate) struct ConversionArgs {
pub(crate) losses: LossPolicy,
pub(crate) allow_errors: bool,
pub(crate) full_decode: bool,
pub(crate) destination: DestinationPolicy,
pub(crate) report: Option<FileDestination>,
pub(crate) forced_input: Option<ForcedInput>,
}
fn write_refusal<P: reporting::ReportBody>(
input: &Path,
output: Option<&FileDestination>,
command: &'static str,
payload: Payload<'_, P>,
json: bool,
) {
if output.is_none() && !json {
return;
}
let report = match reporting::payload_report_json(command, payload) {
Ok(report) => report,
Err(error) => {
eprintln!("warning: could not serialize {command} refusal report: {error:#}; preserving the original refusal");
return;
}
};
if let Err(error) = reporting::write_report_text(input, output, &report) {
eprintln!("warning: could not write {command} refusal report: {error:#}; preserving the original refusal");
}
if json {
println!("{report}");
}
}
pub(crate) fn report_failure<T>(
result: CommandResult<T>,
input: &Path,
output: Option<&FileDestination>,
command: &'static str,
json: bool,
) -> CommandResult<T> {
result.inspect_err(|error| {
let Some(refusal) = error.refusal() else {
return;
};
let reports = refusal.evidence().reports;
if command == "convert" {
let mut stderr = io::stderr();
let diagnostics = (|| -> io::Result<()> {
if let Some(report) = reports.decode {
print_decode_report(&mut stderr, report)?;
writeln!(stderr)?;
}
if let Some(report) = reports.check {
print_check_report(&mut stderr, report)?;
}
Ok(())
})();
if let Err(error) = diagnostics {
eprintln!("warning: could not write convert diagnostics: {error}");
}
} else if command == "check" && !json {
if let Some(report) = reports.check {
if let Err(error) = print_check_report(&mut io::stdout(), report) {
eprintln!("warning: could not write check diagnostics: {error}");
}
}
}
write_refusal(
input,
output,
command,
CommandReportBody::Refused(refusal).payload(),
json,
);
})
}
#[derive(Clone, Copy)]
pub(crate) struct DiffInput<'a> {
pub(crate) path: &'a Path,
pub(crate) forced: Option<ForcedInput>,
}
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
enum InputSelection {
Forced,
Detected {
confidence: cadmpeg_ir::codec::Confidence,
},
}
impl From<cadmpeg_registry::Selection> for InputSelection {
fn from(selection: cadmpeg_registry::Selection) -> Self {
match selection {
cadmpeg_registry::Selection::Forced => Self::Forced,
cadmpeg_registry::Selection::Detected { confidence } => Self::Detected { confidence },
}
}
}
impl InputSelection {
fn label(self) -> String {
match self {
Self::Forced => " (forced)".to_string(),
Self::Detected { confidence } => format!(" (detected {confidence})"),
}
}
}
#[derive(Serialize)]
struct InspectPayload<'a> {
selection: InputSelection,
summary: Option<&'a cadmpeg_ir::ContainerSummary>,
}
struct DiffReportPayload<'a> {
diff: &'a cadmpeg_ir::IrDiff,
source_fidelity: &'a reporting::FidelitySummary,
geometry: Option<GeometryBody<'a>>,
verdict: Option<Verdict>,
}
impl DiffReportPayload<'_> {
fn different(&self) -> bool {
match self.verdict {
Some(verdict) => verdict != Verdict::Agree,
None => !self.diff.is_empty() || fidelity_differs(self.source_fidelity),
}
}
}
impl Serialize for DiffReportPayload<'_> {
fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
let fields = if self.geometry.is_some() { 4 } else { 3 };
let mut state = serializer.serialize_struct("DiffReportPayload", fields)?;
state.serialize_field("different", &self.different())?;
state.serialize_field("diff", self.diff)?;
state.serialize_field("source_fidelity", self.source_fidelity)?;
if let Some(geometry) = &self.geometry {
state.serialize_field("geometry", geometry)?;
}
state.end()
}
}
#[derive(Clone, Copy)]
pub(crate) struct DiffOutput {
pub(crate) json: bool,
pub(crate) geometry: Option<GeometryArgs>,
}
pub(crate) fn inspect(
inputs: &InputCatalog,
path: &Path,
forced: Option<&'static cadmpeg_registry::NativeDescriptor>,
json: bool,
report_path: Option<&FileDestination>,
limits: cadmpeg_core::decode::ResourceLimits,
) -> CommandResult<()> {
let mut refusal_selection = None;
let result: CommandResult<()> = (|| {
let mut file = File::open(path).with_context(|| format!("opening {}", path.display()))?;
let Inspected {
selection, summary, ..
} = match resolve_and_inspect_with(
inputs,
&mut file,
forced.map(ForcedInput::Codec),
&InspectOptions { limits },
) {
Ok(inspected) => inspected,
Err(InspectError::Io(error)) => {
return Err(inspect_io_error(path, limits.max_input_bytes, error).into());
}
Err(InspectError::Detection(error)) => return Err(error.into()),
Err(InspectError::Unresolved(cadmpeg_registry::ResolveSourceError::Codec(error))) => {
return Err(error.into())
}
Err(InspectError::Unresolved(error)) => {
return Err(loader::detection_failure(&error).into());
}
Err(InspectError::Cadir | InspectError::Unrecognized) => {
return Err(inspect_unrecognized(path).into());
}
Err(InspectError::Codec {
format,
selection: selected,
error,
}) => {
refusal_selection = Some(selected.into());
return Err(ApplicationError::from_codec_failure(
path,
format,
"inspecting",
error,
));
}
};
let selection = InputSelection::from(selection);
let payload = InspectPayload {
selection,
summary: Some(&summary),
};
write_json_report(path, report_path, "inspect", &payload)?;
if json {
println!("{}", command_report_json("inspect", &payload)?);
return Ok(());
}
println!(
"format: {}{}\ncontainer: {}\nentries: {}",
summary.format(),
selection.label(),
summary.container_kind,
summary.entries.len()
);
for line in crate::registry_view::dialect_lines(summary.dialects())? {
println!("{line}");
}
println!();
for entry in &summary.entries {
let size =
|size: Option<u64>| size.map_or_else(|| "-".to_string(), |size| size.to_string());
println!(
" {:<14} {:>10} → {:<10} {}",
entry.role,
size(entry.stored_size()),
size(entry.expanded_size()),
entry.name
);
for (key, value) in &entry.attributes {
println!(" {key} = {value}");
}
}
if !summary.notes.is_empty() {
println!("\nnotes:");
for note in &summary.notes {
println!(" - {note}");
}
}
Ok(())
})();
result.inspect_err(|error| {
if let Some(refusal) = error.refusal() {
if let Some(selection) = refusal_selection {
let payload = InspectPayload {
selection,
summary: None,
};
write_refusal(
path,
report_path,
"inspect",
Payload::Refused(&payload, refusal),
json,
);
} else {
write_refusal(
path,
report_path,
"inspect",
Payload::Refused(serde_json::json!({ "summary": null }), refusal),
json,
);
}
}
})
}
fn inspect_unrecognized(path: &Path) -> anyhow::Error {
anyhow!(
"no codec recognized {}; inspect supports container inputs only, not .cadir.json IR documents; supported: FCStd, f3d, Inventor IPT/IAM, sldprt, CATPart, NX/Creo prt, Rhino 3DM, IGES, STEP, Creo View pvz; use --input-format to override detection",
path.display()
)
}
fn inspect_io_error(path: &Path, max_input_bytes: u64, error: io::Error) -> anyhow::Error {
if error.kind() == io::ErrorKind::FileTooLarge {
anyhow!(
"{} exceeds the configured {}-byte input limit",
path.display(),
max_input_bytes
)
} else {
anyhow!(error).context(format!("inspecting {}", path.display()))
}
}
pub(crate) fn dump(
inputs: &InputCatalog,
path: &Path,
destination: &DestinationPolicy,
report_path: Option<&FileDestination>,
forced: Option<ForcedInput>,
args: &DecodeArgs,
) -> CommandResult<()> {
let result = (|| {
let destination = destination.resolve(path)?;
if let Some(report_path) = report_path {
report_path.check(path)?;
if let Some(out) = destination.path() {
artifact_store::check_distinct_output_paths(
out,
"CADIR output",
&report_path.path,
"command report",
)?;
}
}
let loaded = loader::load_artifact(inputs, path, args.options(), forced)?;
print_load_notice(&loaded);
let encoder = build_encoder(Format::Cadir);
let plan = encoder
.plan(
cadmpeg_ir::codec::write::EncodeInput::new(&loaded.ir, loaded.fidelity()),
TargetRequest::Inherit,
)
.map_err(|error| ApplicationError::from(error).at_stage(RefusalStage::Plan))?;
let emission = emit_export_plan(plan, Format::Cadir, &destination, &loaded.origin)
.map_err(|error| ApplicationError::from(error).at_stage(RefusalStage::Export))?;
print_export_emission(&mut io::stderr(), &emission)?;
if let Some(report) = loaded.decode_report() {
print_decode_report(&mut io::stderr(), report)?;
}
eprintln!("check: not run (a successful dump is not a checked model; run `cadmpeg check`)");
write_command_report(
path,
report_path,
"dump",
CommandReportBody::Ok {
decode_report: loaded.decode_report(),
check_report: None,
export: None,
},
)?;
Ok(())
})();
report_failure(result, path, report_path, "dump", false)
}
pub(crate) fn check_cmd(
inputs: &InputCatalog,
path: &Path,
forced: Option<ForcedInput>,
args: &DecodeArgs,
json: bool,
report_path: Option<&FileDestination>,
) -> CommandResult<()> {
let result = (|| {
let loaded = loader::load_artifact(inputs, path, args.options(), forced)?;
print_load_notice(&loaded);
let mut stdout = io::stdout();
if let Some(report) = loaded.decode_report() {
print_decode_report(&mut io::stderr(), report)?;
}
let report = validate_loaded(
inputs,
&loaded,
&args.options().policy,
args.policy.limits.work_profile(),
)?;
let check_refusal = (!report.is_ok()).then(|| ConversionRefusal::CheckFailed {
operation: crate::application::refusal::CheckOperation::Check,
decode_report: loaded.decode_report().cloned(),
validation: report.clone(),
});
if let Some(refusal) = check_refusal {
return Err(refusal.into());
}
let body = CommandReportBody::Ok {
decode_report: loaded.decode_report(),
check_report: Some(&report),
export: None,
};
write_command_report(path, report_path, "check", body)?;
if json {
writeln!(stdout, "{}", command_body_json("check", body)?)?;
} else {
print_check_report(&mut stdout, &report)?;
}
Ok(())
})();
report_failure(result, path, report_path, "check", json)
}
pub(crate) fn convert(
inputs: &InputCatalog,
path: &Path,
to: Option<&str>,
conversion: &ConversionArgs,
args: &DecodeArgs,
) -> CommandResult<()> {
let result = (|| {
let policy = ConversionPolicy {
losses: conversion.losses,
allow_errors: conversion.allow_errors,
destination: conversion.destination.clone(),
};
let selection = TargetSelection::resolve(to, policy.destination.path())?;
let target = export_target(selection);
if let Some(report_path) = conversion.report.as_ref() {
report_path.check(path)?;
if let Some(destination) = policy.destination.path() {
artifact_store::check_distinct_output_paths(
destination,
"CAD output",
&report_path.path,
"command report",
)?;
}
}
let depth = if conversion.full_decode || conversion.losses.needs_full_decode() {
DecodeDepth::Full
} else {
target.needs().request()
};
let source = SourceRequest {
path,
forced: conversion.forced_input,
options: args.options_at(depth),
work: args.policy.limits.work_profile(),
};
let prepared = transcoder::prepare(inputs, &source, target, &policy)?;
let planned = prepared.plan()?;
let (decode_report, validation) = {
let prepared = planned.prepared();
print_load_notice(&prepared.document);
let mut stderr = io::stderr();
if let Some(report) = prepared.document.decode_report() {
print_decode_report(&mut stderr, report)?;
writeln!(stderr)?;
}
print_check_report(&mut stderr, &prepared.validation)?;
let total = prepared.validation.error_count();
if total > 0 {
writeln!(
stderr,
"check: {} of {total} error(s) in what the {} output writes",
planned.carried_errors(),
prepared.format().name()
)?;
}
(
prepared.document.decode_report().cloned(),
prepared.validation.clone(),
)
};
let emission = planned
.write()
.map_err(|error| ApplicationError::from(error).at_stage(RefusalStage::Export))?;
print_export_emission(&mut io::stderr(), &emission)?;
if let Err(error) = write_command_report(
path,
conversion.report.as_ref(),
"convert",
CommandReportBody::Ok {
decode_report: decode_report.as_ref(),
check_report: Some(&validation),
export: Some(&emission.report),
},
) {
eprintln!(
"warning: CAD output was written, but the convert report could not be written: {error:#}"
);
}
Ok(())
})();
report_failure(result, path, conversion.report.as_ref(), "convert", false)
}
pub(crate) fn diff(
inputs: &InputCatalog,
a: DiffInput<'_>,
b: DiffInput<'_>,
args: &DecodeArgs,
output: DiffOutput,
report_path: Option<&FileDestination>,
) -> CommandResult<ExitCode> {
let DiffOutput { json, geometry } = output;
let result = (|| {
if let Some(report_path) = report_path {
report_path.check(a.path)?;
report_path.check(b.path)?;
}
let left = loader::load_artifact(inputs, a.path, args.options(), a.forced)?;
print_load_notice(&left);
let right = loader::load_artifact(inputs, b.path, args.options(), b.forced)?;
print_load_notice(&right);
let result = cadmpeg_ir::diff(&left.ir, &right.ir);
let fidelity = fidelity_diff(left.fidelity(), right.fidelity());
let measured = match &geometry {
Some(geometry) => Some(GeometryDiff::measure(
&left,
&right,
&args.options(),
geometry,
)?),
None => None,
};
let payload = DiffReportPayload {
diff: &result,
source_fidelity: &fidelity,
geometry: measured
.as_ref()
.map(|measured| measured.body(&left, &right)),
verdict: measured.as_ref().map(GeometryDiff::verdict),
};
write_json_report(a.path, report_path, "diff", &payload)?;
let status = if payload.different() {
ExitCode::from(1)
} else {
ExitCode::SUCCESS
};
if json {
println!("{}", command_report_json("diff", &payload)?);
return Ok(status);
}
if let (Some(measured), Some(geometry)) = (&measured, &geometry) {
let mut stdout = std::io::stdout();
if geometry.tsv {
write!(stdout, "{}", measured.tsv())?;
} else {
measured.text(&mut stdout, [a.path, b.path])?;
}
return Ok(status);
}
println!("diff {} vs {}", a.path.display(), b.path.display());
if let Some((before, after)) = &result.tolerance_change {
println!(" tolerances: {before:?} → {after:?}");
}
print_source_diff(&result.source)?;
for arena in &result.per_arena {
if arena.added.is_empty() && arena.removed.is_empty() && arena.modified.is_empty() {
continue;
}
println!(
" {}: +{} -{} ~{}",
arena.kind,
arena.added.len(),
arena.removed.len(),
arena.modified.len()
);
print_id_delta("removed", &arena.removed);
print_id_delta("added", &arena.added);
let modified: Vec<String> = arena
.modified
.iter()
.map(|item| format!("{} ({})", item.id, item.fields.join(", ")))
.collect();
print_id_delta("modified", &modified);
}
print_fidelity_summary(&fidelity);
if payload.different() {
Ok(ExitCode::from(1))
} else {
println!(" identical");
Ok(ExitCode::SUCCESS)
}
})();
report_failure(result, a.path, report_path, "diff", json)
}
#[cfg(test)]
mod tests {
use super::{inspect, inspect_io_error};
use cadmpeg_core::decode::ResourceLimits;
use cadmpeg_registry::InputCatalog;
use std::io;
use std::path::Path;
#[test]
fn inspect_open_errors_name_the_path() {
let path = Path::new("missing-inspect-input.3dm");
let error = inspect(
&InputCatalog::with_builtins(),
path,
None,
false,
None,
ResourceLimits::desktop(),
)
.unwrap_err();
assert_eq!(error.to_string(), "opening missing-inspect-input.3dm");
}
#[test]
fn inspect_input_limit_errors_name_the_path_and_limit() {
let path = Path::new("oversize.3dm");
let error = inspect_io_error(
path,
2,
io::Error::new(io::ErrorKind::FileTooLarge, "input limit exceeded"),
);
assert_eq!(
error.to_string(),
format!(
"{} exceeds the configured 2-byte input limit",
path.display()
)
);
}
}