use std::borrow::Cow;
use std::fmt;
use std::path::Path;
use cadmpeg_core::decode::{DecodeDepth, ResourceDimension, ResourceFailure, ResourceLimit};
use cadmpeg_core::dialect::DialectLayers;
use cadmpeg_core::target::TargetRefusal;
use cadmpeg_ir::codec::DecodeFailure;
use cadmpeg_ir::report::{check::ValidationReport, decode::DecodeReport, export::ExportReport};
use cadmpeg_registry::Format;
use serde::Serialize;
#[derive(Debug)]
pub(crate) enum ApplicationError {
Refusal(Box<ConversionRefusal>),
Operational(anyhow::Error),
}
impl ApplicationError {
#[must_use]
pub(crate) fn refusal(&self) -> Option<&ConversionRefusal> {
match self {
Self::Refusal(refusal) => Some(refusal.as_ref()),
Self::Operational(_) => None,
}
}
#[must_use]
pub(crate) fn at_stage(mut self, stage: RefusalStage) -> Self {
if let Self::Refusal(ref mut refusal) = self {
if let ConversionRefusal::ResourceLimit { stage: current, .. } = refusal.as_mut() {
*current = stage;
}
}
self
}
#[must_use]
pub(crate) fn exit_code(&self) -> u8 {
self.refusal().map_or(2, ConversionRefusal::exit_code)
}
}
impl From<ConversionRefusal> for ApplicationError {
fn from(refusal: ConversionRefusal) -> Self {
Self::Refusal(Box::new(refusal))
}
}
impl From<anyhow::Error> for ApplicationError {
fn from(error: anyhow::Error) -> Self {
if let Some(cadmpeg_core::CodecError::ResourceLimit(limit)) =
error.downcast_ref::<cadmpeg_core::CodecError>()
{
return cadmpeg_core::CodecError::ResourceLimit(*limit).into();
}
Self::Operational(error)
}
}
impl From<std::io::Error> for ApplicationError {
fn from(error: std::io::Error) -> Self {
Self::Operational(error.into())
}
}
impl From<cadmpeg_registry::RegistryLoadError> for ApplicationError {
fn from(error: cadmpeg_registry::RegistryLoadError) -> Self {
Self::Operational(error.into())
}
}
impl From<serde_json::Error> for ApplicationError {
fn from(error: serde_json::Error) -> Self {
Self::Operational(error.into())
}
}
impl From<cadmpeg_core::CodecError> for ApplicationError {
fn from(error: cadmpeg_core::CodecError) -> Self {
match error {
cadmpeg_core::CodecError::ResourceLimit(limit) => ConversionRefusal::ResourceLimit {
limit,
stage: RefusalStage::Decode,
}
.into(),
error => Self::Operational(error.into()),
}
}
}
impl fmt::Display for ApplicationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Refusal(refusal) => fmt::Display::fmt(refusal.as_ref(), f),
Self::Operational(error) if f.alternate() => write!(f, "{error:#}"),
Self::Operational(error) => fmt::Display::fmt(error, f),
}
}
}
impl std::error::Error for ApplicationError {}
impl ApplicationError {
#[must_use]
pub(crate) fn from_decode_failure(
path: &Path,
format_id: cadmpeg_ir::codec::FormatId,
failure: DecodeFailure,
) -> Self {
match failure {
DecodeFailure::StrictRejected { rejection } => {
ConversionRefusal::StrictDecodeRejected { rejection }.into()
}
DecodeFailure::Codec(error) => {
Self::from_codec_failure(path, format_id, "decoding", error)
}
failure => ConversionRefusal::DecodeFailed {
message: format!(
"decode failed: decoding {} as {format_id}: {failure}",
path.display()
),
}
.into(),
}
}
#[must_use]
pub(crate) fn from_codec_failure(
path: &Path,
format_id: cadmpeg_ir::codec::FormatId,
operation: &'static str,
error: cadmpeg_core::CodecError,
) -> Self {
match error {
cadmpeg_core::CodecError::ResourceLimit(limit) => {
cadmpeg_core::CodecError::ResourceLimit(limit).into()
}
cadmpeg_core::CodecError::Io(error) => Self::Operational(
anyhow::Error::new(cadmpeg_core::CodecError::Io(error))
.context(format!("{operation} {} as {format_id}", path.display())),
),
cadmpeg_core::CodecError::UnsupportedDialect { dialects, message } => {
ConversionRefusal::unsupported_dialect(dialects, message).into()
}
error => ConversionRefusal::DecodeFailed {
message: format!(
"decode failed: {operation} {} as {format_id}: {error}",
path.display()
),
}
.into(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RefusalCode {
ResourceLimit,
DecodeFailed,
UnsupportedDialect,
StrictDecodeRejected,
CheckFailed,
DecodeLossRejected,
ExportLossRejected,
EmptyGeometry,
UnsupportedTarget,
InsufficientDepth,
UnsupportedOutputFormat,
BinaryStdoutRejected,
}
impl RefusalCode {
const fn disposition(self) -> RefusalDisposition {
let (stage, exit_code) = match self {
Self::ResourceLimit | Self::DecodeFailed => (RefusalStage::Decode, 2),
Self::UnsupportedDialect | Self::StrictDecodeRejected | Self::DecodeLossRejected => {
(RefusalStage::Decode, 1)
}
Self::CheckFailed => (RefusalStage::Check, 1),
Self::ExportLossRejected => (RefusalStage::Export, 1),
Self::EmptyGeometry
| Self::UnsupportedTarget
| Self::InsufficientDepth
| Self::UnsupportedOutputFormat => (RefusalStage::Plan, 1),
Self::BinaryStdoutRejected => (RefusalStage::Plan, 2),
};
RefusalDisposition { stage, exit_code }
}
}
impl fmt::Display for RefusalCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::ResourceLimit => "resource_limit",
Self::DecodeFailed => "decode_failed",
Self::UnsupportedDialect => "unsupported_dialect",
Self::StrictDecodeRejected => "strict_decode_rejected",
Self::CheckFailed => "check_failed",
Self::DecodeLossRejected => "decode_loss_rejected",
Self::ExportLossRejected => "export_loss_rejected",
Self::EmptyGeometry => "empty_geometry",
Self::UnsupportedTarget => "unsupported_target",
Self::InsufficientDepth => "insufficient_depth",
Self::UnsupportedOutputFormat => "unsupported_output_format",
Self::BinaryStdoutRejected => "binary_stdout_rejected",
})
}
}
impl Serialize for RefusalCode {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(self)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RefusalStage {
Plan,
Decode,
Check,
Export,
Measure,
}
impl fmt::Display for RefusalStage {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Plan => "plan",
Self::Decode => "decode",
Self::Check => "check",
Self::Export => "export",
Self::Measure => "measure",
})
}
}
impl Serialize for RefusalStage {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(self)
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) enum CheckOperation {
Check,
Export {
carried: usize,
},
}
#[derive(Debug)]
pub(crate) enum ConversionRefusal {
ResourceLimit {
limit: ResourceLimit,
stage: RefusalStage,
},
DecodeFailed {
message: String,
},
UnsupportedDialect {
dialects: Box<DialectLayers>,
reason: String,
},
StrictDecodeRejected {
rejection: cadmpeg_ir::codec::StrictDecodeRejection,
},
CheckFailed {
operation: CheckOperation,
decode_report: Option<DecodeReport>,
validation: ValidationReport,
},
DecodeLossRejected {
format: Format,
decode_report: DecodeReport,
},
ExportLossRejected {
decode_report: Option<DecodeReport>,
validation: ValidationReport,
export_report: ExportReport,
},
EmptyGeometry {
format: Format,
decode_report: Option<DecodeReport>,
validation: ValidationReport,
},
UnsupportedOutputFormat {
message: String,
},
UnsupportedTarget {
refusal: Box<TargetRefusal>,
decode_report: Option<DecodeReport>,
validation: ValidationReport,
},
InsufficientDepth {
format: &'static str,
depth: DepthReport,
decode_report: Option<DecodeReport>,
validation: ValidationReport,
},
BinaryStdoutRejected {
message: String,
},
InsufficientMeasureDepth {
depth: DepthReport,
decode_report: Option<DecodeReport>,
},
}
#[derive(Debug, Serialize)]
pub(crate) struct RefusalReport<'a> {
stage: RefusalStage,
code: RefusalCode,
message: Cow<'a, str>,
#[serde(flatten)]
detail: Option<RefusalDetail<'a>>,
}
#[derive(Debug)]
pub(crate) struct RefusalEvidence<'a> {
code: RefusalCode,
pub(super) message: Cow<'a, str>,
detail: Option<RefusalDetail<'a>>,
pub(crate) reports: RefusalReports<'a>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "snake_case")]
enum RefusalDetail<'a> {
Resource(ResourceReport),
Dialects(&'a DialectLayers),
Target(&'a TargetRefusal),
Depth(DepthReport),
}
#[derive(Debug, Clone, Copy, Serialize)]
pub(crate) struct DepthReport {
pub(crate) needed: DecodeDepth,
pub(crate) recorded: DecodeDepth,
}
#[derive(Debug, Serialize)]
struct ResourceReport {
dimension: &'static str,
reason: &'static str,
limit: u64,
used: u64,
requested: u64,
operation: &'static str,
}
impl From<&ResourceLimit> for ResourceReport {
fn from(limit: &ResourceLimit) -> Self {
let dimension = match limit.dimension {
ResourceDimension::InputBytes => "input_bytes",
ResourceDimension::DecompressedBytes => "decompressed_bytes",
ResourceDimension::RetainedBytes => "retained_bytes",
ResourceDimension::Entities => "entities",
ResourceDimension::RecursionDepth => "recursion_depth",
};
let reason = match limit.reason {
ResourceFailure::BudgetExceeded => "budget_exceeded",
ResourceFailure::AllocationFailed => "allocation_failed",
};
Self {
dimension,
reason,
limit: limit.limit,
used: limit.used,
requested: limit.additional,
operation: limit.operation,
}
}
}
#[derive(Debug, Default, Clone, Copy)]
pub(crate) struct RefusalReports<'a> {
pub(crate) decode: Option<&'a DecodeReport>,
pub(crate) check: Option<&'a ValidationReport>,
pub(crate) export: Option<&'a ExportReport>,
}
#[derive(Debug, Clone, Copy)]
struct RefusalDisposition {
stage: RefusalStage,
exit_code: u8,
}
impl ConversionRefusal {
#[must_use]
pub(crate) fn unsupported_dialect(
dialects: Box<DialectLayers>,
reason: impl Into<String>,
) -> Self {
Self::UnsupportedDialect {
dialects,
reason: reason.into(),
}
}
#[must_use]
pub(crate) fn evidence(&self) -> RefusalEvidence<'_> {
match self {
Self::ResourceLimit { limit, .. } => RefusalEvidence {
code: RefusalCode::ResourceLimit,
message: Cow::Owned(format!(
"{} during {}",
cadmpeg_core::CodecError::ResourceLimit(*limit),
limit.operation
)),
detail: Some(RefusalDetail::Resource(limit.into())),
reports: RefusalReports::default(),
},
Self::DecodeFailed { message } => RefusalEvidence {
code: RefusalCode::DecodeFailed,
message: Cow::Borrowed(message),
detail: None,
reports: RefusalReports::default(),
},
Self::UnsupportedDialect { dialects, reason } => RefusalEvidence {
code: RefusalCode::UnsupportedDialect,
message: Cow::Owned(unsupported_dialect_message(dialects, reason)),
detail: Some(RefusalDetail::Dialects(dialects)),
reports: RefusalReports::default(),
},
Self::StrictDecodeRejected { rejection } => {
let loss = rejection.loss();
RefusalEvidence {
code: RefusalCode::StrictDecodeRejected,
message: Cow::Owned(format!(
"strict mode rejects {}: {}",
loss.code, loss.message
)),
detail: None,
reports: RefusalReports {
decode: Some(rejection.report()),
check: None,
export: None,
},
}
}
Self::CheckFailed {
operation,
decode_report,
validation,
} => RefusalEvidence {
code: RefusalCode::CheckFailed,
message: Cow::Owned(match operation {
CheckOperation::Check => {
format!("check found {} error(s)", validation.error_count())
}
CheckOperation::Export { carried } if *carried == validation.error_count() => format!(
"check found {} error(s); refusing to export (use --allow-errors to override)",
validation.error_count()
),
CheckOperation::Export { carried } => format!(
"check found {} error(s), {carried} in what the export writes; refusing to export (use --allow-errors to override)",
validation.error_count()
),
}),
detail: None,
reports: RefusalReports {
decode: decode_report.as_ref(),
check: Some(validation),
export: None,
},
},
Self::DecodeLossRejected {
format,
decode_report,
} => RefusalEvidence {
code: RefusalCode::DecodeLossRejected,
message: Cow::Owned(format!(
"decode reported {} loss(es); refusing to write a lossy {} (omit --reject-lossy to allow)",
decode_report.losses.len(),
format.name()
)),
detail: None,
reports: RefusalReports {
decode: Some(decode_report),
check: None,
export: None,
},
},
Self::ExportLossRejected {
decode_report,
validation,
export_report,
} => RefusalEvidence {
code: RefusalCode::ExportLossRejected,
message: Cow::Owned(format!(
"export planning reported {} loss(es): {}; refusing to write a lossy {} (omit --reject-lossy to allow)",
export_report.losses.len(),
export_report
.losses
.iter()
.map(|loss| loss.message.as_str())
.collect::<Vec<_>>()
.join("; "),
export_report.format()
)),
detail: None,
reports: RefusalReports {
decode: decode_report.as_ref(),
check: Some(validation),
export: Some(export_report),
},
},
Self::EmptyGeometry {
format,
decode_report,
validation,
} => RefusalEvidence {
code: RefusalCode::EmptyGeometry,
message: Cow::Owned(format!(
"decode transferred no geometry; refusing to write an empty {} (use --allow-empty to override)",
format.name()
)),
detail: None,
reports: RefusalReports {
decode: decode_report.as_ref(),
check: Some(validation),
export: None,
},
},
Self::UnsupportedOutputFormat { message } => RefusalEvidence {
code: RefusalCode::UnsupportedOutputFormat,
message: Cow::Borrowed(message),
detail: None,
reports: RefusalReports::default(),
},
Self::UnsupportedTarget {
refusal,
decode_report,
validation,
} => RefusalEvidence {
code: RefusalCode::UnsupportedTarget,
message: Cow::Owned(refusal.to_string()),
detail: Some(RefusalDetail::Target(refusal)),
reports: RefusalReports {
decode: decode_report.as_ref(),
check: Some(validation),
export: None,
},
},
Self::InsufficientDepth {
format,
depth,
decode_report,
validation,
} => RefusalEvidence {
code: RefusalCode::InsufficientDepth,
message: Cow::Owned(format!(
"the {format} encoder needs a {}-depth decode, but the document records {} depth",
depth.needed, depth.recorded
)),
detail: Some(RefusalDetail::Depth(*depth)),
reports: RefusalReports {
decode: decode_report.as_ref(),
check: Some(validation),
export: None,
},
},
Self::BinaryStdoutRejected { message } => RefusalEvidence {
code: RefusalCode::BinaryStdoutRejected,
message: Cow::Borrowed(message),
detail: None,
reports: RefusalReports::default(),
},
Self::InsufficientMeasureDepth {
depth,
decode_report,
} => RefusalEvidence {
code: RefusalCode::InsufficientDepth,
message: Cow::Owned(format!(
"measurement needs a {}-depth decode, but the document records {} depth",
depth.needed, depth.recorded
)),
detail: Some(RefusalDetail::Depth(*depth)),
reports: RefusalReports {
decode: decode_report.as_ref(),
check: None,
export: None,
},
},
}
}
#[must_use]
pub(crate) fn code(&self) -> RefusalCode {
self.evidence().code
}
#[must_use]
pub(crate) fn report(&self) -> RefusalReport<'_> {
let evidence = self.evidence();
RefusalReport {
stage: match self {
Self::ResourceLimit { stage, .. } => *stage,
_ => evidence.code.disposition().stage,
},
code: evidence.code,
message: evidence.message,
detail: evidence.detail,
}
}
#[must_use]
fn exit_code(&self) -> u8 {
self.code().disposition().exit_code
}
}
fn unsupported_dialect_message(dialects: &DialectLayers, reason: &str) -> String {
let primary = dialects.primary();
let carried = dialects
.iter()
.skip(1)
.map(|layer| layer.dialect().as_str())
.collect::<Vec<_>>();
if carried.is_empty() {
format!(
"unsupported {} dialect {}: {reason}",
primary.format(),
primary.dialect()
)
} else {
format!(
"unsupported {} dialect {}; carried layers: {}; {reason}",
primary.format(),
primary.dialect(),
carried.join(", ")
)
}
}
impl fmt::Display for ConversionRefusal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.evidence().message.as_ref())
}
}
impl std::error::Error for ConversionRefusal {}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::path::PathBuf;
use cadmpeg_core::decode::DecodeDepth;
use cadmpeg_core::dialect::{DialectLayers, DialectMatch};
use cadmpeg_core::target::{TargetCatalog, TargetDescriptor};
use super::{ApplicationError, CheckOperation, ConversionRefusal, DepthReport, RefusalCode};
use crate::application::transcoder::TargetSelection;
use cadmpeg_core::target::TargetRefusal;
use cadmpeg_ir::codec::DecodeFailure;
use cadmpeg_ir::report::check::ValidationReport;
const IGES_TARGETS: &[TargetDescriptor] = &[TargetDescriptor {
id: cadmpeg_core::dialect_id!("iges:5.3-fixed-ascii"),
aliases: &[],
}];
const IGES_CATALOG: TargetCatalog = TargetCatalog::new(IGES_TARGETS, Some(0));
fn report_value(refusal: &ConversionRefusal) -> serde_json::Value {
serde_json::to_value(refusal.report()).expect("serialize refusal report")
}
fn classify(failure: DecodeFailure) -> ApplicationError {
ApplicationError::from_decode_failure(
&PathBuf::from("part.step"),
cadmpeg_ir::codec::FormatId::new("step"),
failure,
)
}
#[test]
fn refusal_codes_are_stable_for_tests_and_absent_from_display() {
let refusal = ConversionRefusal::UnsupportedTarget {
refusal: Box::new(TargetRefusal::unknown_explicit("iges:9.9", IGES_CATALOG)),
decode_report: None,
validation: ValidationReport {
entity_counts: BTreeMap::new(),
findings: Vec::new(),
losses: Vec::new(),
},
};
assert_eq!(refusal.code(), RefusalCode::UnsupportedTarget);
assert_eq!(report_value(&refusal)["stage"], "plan");
assert_eq!(refusal.exit_code(), 1);
assert_eq!(
refusal.to_string(),
"iges cannot write iges:9.9: not a target this encoder can synthesize; available targets: iges:5.3-fixed-ascii"
);
let report = report_value(&refusal);
assert_eq!(report["code"], "unsupported_target");
assert_eq!(report["stage"], "plan");
assert_eq!(report["target"]["refusal"]["kind"], "unknown_explicit");
assert_eq!(report["target"]["format"], "iges");
assert_eq!(report["target"]["refusal"]["requested"], "iges:9.9");
assert_eq!(
report["target"]["available"][0]["id"],
"iges:5.3-fixed-ascii"
);
}
#[test]
fn an_insufficient_depth_refusal_reports_both_depths_at_plan() {
let refusal = ConversionRefusal::InsufficientDepth {
format: "f3d",
depth: DepthReport {
needed: DecodeDepth::Full,
recorded: DecodeDepth::Geometry,
},
decode_report: None,
validation: ValidationReport {
entity_counts: BTreeMap::new(),
findings: Vec::new(),
losses: Vec::new(),
},
};
assert_eq!(refusal.code(), RefusalCode::InsufficientDepth);
assert_eq!(refusal.exit_code(), 1);
assert_eq!(
refusal.to_string(),
"the f3d encoder needs a full-depth decode, but the document records geometry depth"
);
let report = report_value(&refusal);
assert_eq!(report["code"], "insufficient_depth");
assert_eq!(report["stage"], "plan");
assert_eq!(report["depth"]["needed"], "full");
assert_eq!(report["depth"]["recorded"], "geometry");
}
#[test]
fn a_measure_depth_refusal_reports_both_depths_at_plan() {
let refusal = ConversionRefusal::InsufficientMeasureDepth {
depth: DepthReport {
needed: DecodeDepth::Geometry,
recorded: DecodeDepth::Container,
},
decode_report: None,
};
assert_eq!(refusal.code(), RefusalCode::InsufficientDepth);
assert_eq!(refusal.exit_code(), 1);
assert_eq!(
refusal.to_string(),
"measurement needs a geometry-depth decode, but the document records container depth"
);
let report = report_value(&refusal);
assert_eq!(report["code"], "insufficient_depth");
assert_eq!(report["stage"], "plan");
assert_eq!(report["depth"]["needed"], "geometry");
assert_eq!(report["depth"]["recorded"], "container");
}
#[test]
fn unsupported_decode_keeps_the_identification() {
let matched = DialectMatch::refused(cadmpeg_core::dialect_id!("step:part-28-xml"));
let refusal = ConversionRefusal::UnsupportedDialect {
dialects: Box::new(DialectLayers::of(matched.clone())),
reason: "the XML encoding has no decode grammar".into(),
};
let ConversionRefusal::UnsupportedDialect { dialects, .. } = &refusal else {
panic!("the variant just constructed is preserved");
};
assert_eq!(dialects.primary(), &matched);
assert_eq!(refusal.code(), RefusalCode::UnsupportedDialect);
assert_eq!(report_value(&refusal)["stage"], "decode");
assert_eq!(refusal.exit_code(), 1);
assert_eq!(report_value(&refusal)["code"], "unsupported_dialect");
}
#[test]
fn unsupported_decode_serializes_every_identified_layer() {
let layers = DialectLayers::of(DialectMatch::refused(cadmpeg_core::dialect_id!(
"sldprt:sw-2024"
)))
.with(DialectMatch::residual(cadmpeg_core::dialect_id!(
"parasolid:unknown"
)))
.expect("distinct dialect layer keys");
let refusal = ConversionRefusal::UnsupportedDialect {
dialects: Box::new(layers),
reason: "no decoder admits the host dialect".into(),
};
let report = report_value(&refusal);
assert_eq!(report["dialects"]["primary"]["dialect"], "sldprt:sw-2024");
assert_eq!(
report["dialects"]["extra"][0]["dialect"],
"parasolid:unknown"
);
assert!(refusal.to_string().contains("parasolid:unknown"));
}
#[test]
fn unwritable_format_has_its_own_wire_code() {
let refusal = ConversionRefusal::UnsupportedOutputFormat {
message: "catia is not writable".into(),
};
assert_eq!(refusal.code(), RefusalCode::UnsupportedOutputFormat);
assert_eq!(report_value(&refusal)["code"], "unsupported_output_format");
}
#[test]
fn decode_classifier_preserves_an_unsupported_dialect_variant() {
let matched = DialectMatch::refused(cadmpeg_core::dialect_id!("step:part-28-xml"));
let classified = classify(DecodeFailure::Codec(
cadmpeg_core::CodecError::UnsupportedDialect {
dialects: Box::new(DialectLayers::of(matched.clone())),
message: "the XML encoding has no decode grammar".into(),
},
));
let refusal = classified
.refusal()
.expect("codec refusal becomes an application refusal");
let ConversionRefusal::UnsupportedDialect { dialects, .. } = refusal else {
panic!("unsupported identity must not be flattened to DecodeFailed");
};
assert_eq!(dialects.primary(), &matched);
}
#[test]
fn decode_classifier_keeps_io_operational() {
let classified = classify(DecodeFailure::Codec(cadmpeg_core::CodecError::Io(
std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "short read"),
)));
assert!(classified.refusal().is_none());
assert_eq!(classified.exit_code(), 2);
assert_eq!(classified.to_string(), "decoding part.step as step");
assert!(format!("{classified:#}").contains("short read"));
}
#[test]
fn binary_stdout_keeps_operational_exit_two() {
let refusal = ConversionRefusal::BinaryStdoutRejected {
message: "refusing to write binary sldprt to standard output".into(),
};
assert_eq!(refusal.code(), RefusalCode::BinaryStdoutRejected);
assert_eq!(report_value(&refusal)["stage"], "plan");
assert_eq!(refusal.exit_code(), 2);
}
#[test]
fn decode_failure_is_a_structured_decode_refusal() {
let refusal = ConversionRefusal::DecodeFailed {
message: "decode failed: malformed container: test".into(),
};
assert_eq!(refusal.code(), RefusalCode::DecodeFailed);
assert_eq!(report_value(&refusal)["stage"], "decode");
assert_eq!(refusal.exit_code(), 2);
let evidence = refusal.evidence();
assert!(evidence.reports.decode.is_none());
assert!(evidence.reports.check.is_none());
assert!(evidence.detail.is_none());
let report = report_value(&refusal);
assert_eq!(report["code"], "decode_failed");
assert_eq!(report["stage"], "decode");
}
#[test]
fn check_refusal_maps_to_check_stage() {
let refusal = ConversionRefusal::CheckFailed {
operation: CheckOperation::Check,
decode_report: None,
validation: ValidationReport {
entity_counts: BTreeMap::new(),
findings: Vec::new(),
losses: Vec::new(),
},
};
assert_eq!(report_value(&refusal)["stage"], "check");
assert_eq!(refusal.code().to_string(), "check_failed");
}
#[test]
fn an_unwritable_format_is_a_typed_plan_refusal() {
let error = TargetSelection::resolve(Some("catia:v5"), None).unwrap_err();
let refusal = error
.refusal()
.expect("unsupported output formats are semantic plan refusals");
assert!(matches!(
refusal,
ConversionRefusal::UnsupportedOutputFormat { .. }
));
assert_eq!(
refusal.code(),
crate::application::refusal::RefusalCode::UnsupportedOutputFormat
);
assert_eq!(
serde_json::to_value(refusal.report()).unwrap()["stage"],
"plan"
);
assert_eq!(refusal.exit_code(), 1);
}
#[test]
fn resource_refusals_preserve_each_dimension_reason_and_request() {
use cadmpeg_core::decode::{ResourceDimension, ResourceFailure, ResourceLimit};
let dimensions = [
(ResourceDimension::InputBytes, "input_bytes"),
(ResourceDimension::DecompressedBytes, "decompressed_bytes"),
(ResourceDimension::RetainedBytes, "retained_bytes"),
(ResourceDimension::Entities, "entities"),
(ResourceDimension::RecursionDepth, "recursion_depth"),
];
for (dimension, name) in dimensions {
for (reason, reason_name) in [
(ResourceFailure::BudgetExceeded, "budget_exceeded"),
(ResourceFailure::AllocationFailed, "allocation_failed"),
] {
let limit = ResourceLimit {
dimension,
reason,
limit: u64::MAX,
used: u64::MAX - 1,
additional: 2,
operation: "synthetic resource request",
};
let error = classify(DecodeFailure::Codec(
cadmpeg_core::CodecError::ResourceLimit(limit),
));
let refusal = error.refusal().expect("resources are reportable refusals");
assert_eq!(error.exit_code(), 2);
assert_eq!(refusal.code(), RefusalCode::ResourceLimit);
let report = report_value(refusal);
assert_eq!(report["stage"], "decode");
assert_eq!(
report["resource"],
serde_json::json!({
"dimension": name, "reason": reason_name, "limit": u64::MAX,
"used": u64::MAX - 1, "requested": 2, "operation": limit.operation,
})
);
assert!(error
.to_string()
.contains("during synthetic resource request"));
}
}
}
#[test]
fn contextual_resource_errors_retain_evidence_and_the_requesting_stage() {
use cadmpeg_core::decode::{ResourceDimension, ResourceLimit};
let limit = ResourceLimit::allocation_failed(
ResourceDimension::RetainedBytes,
8,
4,
"synthetic allocation",
);
for (stage, label) in [
(super::RefusalStage::Check, "check"),
(super::RefusalStage::Plan, "plan"),
(super::RefusalStage::Export, "export"),
(super::RefusalStage::Measure, "measure"),
] {
let error = anyhow::Error::new(cadmpeg_core::CodecError::ResourceLimit(limit))
.context("writing an artifact");
let error = ApplicationError::from(error).at_stage(stage);
assert_eq!(error.exit_code(), 2);
let report = report_value(
error
.refusal()
.expect("resource evidence survives anyhow context"),
);
assert_eq!(report["stage"], label);
assert_eq!(report["code"], "resource_limit");
assert_eq!(report["resource"]["reason"], "allocation_failed");
assert_eq!(report["resource"]["limit"], 8);
assert_eq!(report["resource"]["used"], 0);
assert_eq!(report["resource"]["requested"], 4);
assert_eq!(report["resource"]["operation"], "synthetic allocation");
}
}
}