cadmpeg 0.6.2

Convert and inspect native CAD files.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
// SPDX-License-Identifier: Apache-2.0
//! Typed prepare/write conversion workflow.

use std::io::{self, BufWriter, Write};
use std::path::{Path, PathBuf};

use anyhow::{anyhow, Result as AnyResult};
use cadmpeg_ir::codec::write::{
    target::TargetRequest, DepthNeed, EncodeInput, Encoder, ExportPlan,
};
use cadmpeg_ir::codec::DecodeOptions;
use cadmpeg_ir::report::{check::ValidationReport, decode::DecodeReport, export::ExportReport};
use clap::ValueEnum;

use cadmpeg_registry::{ForcedInput, Format, InputCatalog};

use crate::application::artifact_store::{self, FileDestination, SidecarPersistOutcome};
use crate::application::document::{LoadOrigin, LoadedDocument};
use crate::application::refusal::{ApplicationError, ConversionRefusal, DepthReport, RefusalStage};
use crate::application::validators::validate_loaded;
use crate::loader;

/// Input path and decode options for one conversion.
pub(crate) struct SourceRequest<'a> {
    /// Path to the CADIR or native CAD file.
    pub(crate) path: &'a Path,
    /// Explicit input format selection.
    pub(crate) forced: Option<ForcedInput>,
    /// Decode options.
    pub(crate) options: DecodeOptions,
}

/// Output format with an encoder already constructed at the CLI boundary.
pub(crate) struct ExportTarget {
    encoder: Box<dyn Encoder>,
    selection: TargetSelection,
}

impl ExportTarget {
    /// The decode depth the selected encoder plans from.
    #[must_use]
    pub(crate) fn needs(&self) -> DepthNeed {
        self.encoder.needs()
    }
}

/// The output format and target token selected by the command line.
///
/// `--to` names a dialect outright. A `--to` that names only a format, or no
/// `--to` at all, leaves `request` unstated so the encoder inherits from the
/// source. The encoder resolves explicit tokens, source-dependent
/// preservation, and delivery during planning.
#[derive(Debug, Clone)]
pub(crate) struct TargetSelection {
    /// Selected output format.
    format: Format,
    /// Dialect token from `--to`, as a local id or catalog alias.
    request: Option<String>,
}

impl TargetSelection {
    /// Creates an owned output selection at the command-line boundary.
    #[must_use]
    fn new(format: Format, request: Option<String>) -> Self {
        Self { format, request }
    }

    /// Resolves the format half of the `--to` grammar against the output path.
    /// The encoder admits the dialect half during planning.
    pub(crate) fn resolve(to: Option<&str>, out: Option<&Path>) -> Result<Self, ApplicationError> {
        let inferred = format_from_path(out);
        Ok(match to {
            None => Self::new(
                inferred.ok_or_else(|| {
                    anyhow!("cannot infer format from the output path; pass --to FORMAT")
                })?,
                None,
            ),
            Some(value) => Self::resolve_value(value, inferred)?,
        })
    }

    fn resolve_value(value: &str, inferred: Option<Format>) -> Result<Self, ApplicationError> {
        if let Some((left, right)) = value.split_once(':') {
            let format = Format::from_name(left)?.ok_or_else(|| {
                ConversionRefusal::UnsupportedOutputFormat {
                    message: format!(
                        "--to {value}: {left} is not an output format of this build; available: {}",
                        Format::vocabulary()
                    ),
                }
            })?;
            if right.is_empty() {
                return Err(anyhow!(
                    "--to {value}: nothing after the colon; write --to {left} for the format alone"
                )
                .into());
            }
            warn_on_extension_disagreement(format, inferred);
            return Ok(Self::new(format, Some(right.to_owned())));
        }
        if let Some(format) = Format::from_name(value)? {
            warn_on_extension_disagreement(format, inferred);
            return Ok(Self::new(format, None));
        }
        if Format::is_known_name(value)? {
            return Err(ConversionRefusal::UnsupportedOutputFormat {
                message: format!(
                    "--to {value}: {value} is not an output format of this build; available: {}",
                    Format::vocabulary()
                ),
            }
            .into());
        }
        let format = inferred.ok_or_else(|| {
            anyhow!(
                "--to {value}: not an output format of this build ({}), and no output path to read a format from; write --to FORMAT:{value}",
                Format::vocabulary()
            )
        })?;
        Ok(Self::new(format, Some(value.to_owned())))
    }

    /// Builds the encoder request.
    ///
    /// Flag absence is [`TargetRequest::Inherit`] unconditionally. What that
    /// resolves to is the encoder's answer, not the command line's:
    /// [`Encoder::plan`](cadmpeg_ir::codec::write::Encoder::plan) preserves the source
    /// dialect within one format and selects the catalog default when there is
    /// nothing to inherit: no source or a source of another format. Deciding
    /// the cross-format default here as well would write the same rule twice.
    fn request(&self) -> TargetRequest<'_> {
        match self.request.as_deref() {
            Some(id) => TargetRequest::Explicit(id),
            None => TargetRequest::Inherit,
        }
    }
}

fn format_from_path(path: Option<&Path>) -> Option<Format> {
    path.and_then(Path::extension)
        .and_then(|extension| extension.to_str())
        .and_then(Format::from_extension)
}

fn warn_on_extension_disagreement(named: Format, inferred: Option<Format>) {
    if let Some(inferred) = inferred.filter(|inferred| *inferred != named) {
        eprintln!(
            "warning: explicit format {} disagrees with output extension format {}; using {}",
            named.name(),
            inferred.name(),
            named.name()
        );
    }
}

/// Which conversion losses refuse the conversion.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)]
pub(crate) enum LossPolicy {
    /// Permit losses at both phases.
    #[default]
    #[value(skip)]
    Allow,
    /// Refuse decode losses only.
    #[value(name = "decode")]
    RejectDecode,
    /// Refuse export losses only.
    #[value(name = "export")]
    RejectExport,
    /// Refuse losses at either phase.
    #[value(name = "any")]
    RejectAny,
}

impl LossPolicy {
    /// Whether a decode loss refuses the conversion.
    #[must_use]
    const fn rejects_decode(self) -> bool {
        matches!(self, Self::RejectDecode | Self::RejectAny)
    }

    /// Whether an export loss refuses the conversion.
    #[must_use]
    const fn rejects_export(self) -> bool {
        matches!(self, Self::RejectExport | Self::RejectAny)
    }

    /// Whether the conversion decodes the whole source. A refusing policy
    /// judges the source's design intent too, and a decode cut to the depth
    /// the output needs reports no loss for the design records it skips.
    #[must_use]
    pub(crate) const fn needs_full_decode(self) -> bool {
        self.rejects_decode() || self.rejects_export()
    }
}

/// Destination and overwrite rules for a conversion.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum DestinationPolicy {
    /// Write to standard output.
    Stdout {
        /// Permit a binary format on standard output.
        allow_binary: bool,
    },
    /// Write to a file.
    File(FileDestination),
}

/// Conversion output and report destinations admitted from command arguments.
#[derive(Debug)]
pub(crate) struct ConversionDestinations {
    /// Primary output destination.
    pub(crate) destination: DestinationPolicy,
    /// Optional command report output.
    pub(crate) report: Option<FileDestination>,
}

impl clap::Args for ConversionDestinations {
    fn augment_args(command: clap::Command) -> clap::Command {
        artifact_store::OutputDestinations::augment_args(command).arg(
            clap::Arg::new("binary_stdout")
                .long("binary-stdout")
                .hide(true)
                .help("Stream a binary output format to standard output anyway")
                .action(clap::ArgAction::SetTrue),
        )
    }

    fn augment_args_for_update(command: clap::Command) -> clap::Command {
        Self::augment_args(command)
    }
}

impl clap::FromArgMatches for ConversionDestinations {
    fn from_arg_matches(matches: &clap::ArgMatches) -> Result<Self, clap::Error> {
        let outputs = artifact_store::OutputDestinations::from_arg_matches(matches)?;
        Ok(Self {
            destination: match outputs.output.0 {
                Some(file) => DestinationPolicy::File(file),
                None => DestinationPolicy::Stdout {
                    allow_binary: matches.get_flag("binary_stdout"),
                },
            },
            report: outputs.report,
        })
    }

    fn update_from_arg_matches(&mut self, matches: &clap::ArgMatches) -> Result<(), clap::Error> {
        *self = Self::from_arg_matches(matches)?;
        Ok(())
    }
}

impl DestinationPolicy {
    /// Returns the output path used for format inference, if any.
    #[must_use]
    pub(crate) fn path(&self) -> Option<&Path> {
        match self {
            Self::Stdout { .. } => None,
            Self::File(destination) => Some(&destination.path),
        }
    }

    /// Applies format-only destination admission before input decode.
    fn admit_format(&self, format: Format) -> Result<(), ApplicationError> {
        match self {
            Self::Stdout {
                allow_binary: false,
            } if format.is_binary() => Err(ConversionRefusal::BinaryStdoutRejected {
                message: format!(
                    "refusing to write binary {name} to standard output; pass -o FILE.{name}, or \
                     --input-format {name} (alias --from) if you meant to force how the INPUT is \
                     read; pass --binary-stdout to stream the bytes anyway",
                    name = format.name()
                ),
            }
            .into()),
            Self::Stdout { .. } | Self::File(..) => Ok(()),
        }
    }

    /// Resolves filesystem-dependent destination rules. A missing source is
    /// left for the loader; an existing destination still refuses before the
    /// potentially expensive decode.
    pub(crate) fn resolve(&self, source: &Path) -> AnyResult<ResolvedDestination> {
        match self {
            Self::Stdout { .. } => Ok(ResolvedDestination::Stdout),
            Self::File(destination) => {
                destination.check(source)?;
                Ok(ResolvedDestination::File(destination.path.clone()))
            }
        }
    }
}

/// Policy controlling the independent conversion phases.
#[derive(Debug, Clone)]
pub(crate) struct ConversionPolicy {
    /// Decode and export loss refusal.
    pub(crate) losses: LossPolicy,
    /// Permit export when validation reports errors.
    pub(crate) allow_errors: bool,
    /// Permit a geometry export when decode transferred no geometry.
    pub(crate) allow_empty: bool,
    /// Output destination rules.
    pub(crate) destination: DestinationPolicy,
}

#[derive(Debug, Clone)]
pub(crate) enum ResolvedDestination {
    Stdout,
    File(PathBuf),
}

impl ResolvedDestination {
    pub(crate) fn path(&self) -> Option<&Path> {
        match self {
            Self::Stdout => None,
            Self::File(path) => Some(path),
        }
    }
}

/// A loaded and validated conversion ready for export planning.
/// Decode-loss refusal is complete. Check and empty-geometry refusals run after planning.
pub(crate) struct PreparedConversion {
    /// Loaded source document.
    pub(crate) document: LoadedDocument,
    /// Validation report.
    pub(crate) validation: ValidationReport,
    encoder: Box<dyn Encoder>,
    selection: TargetSelection,
    destination: ResolvedDestination,
    loss_policy: LossPolicy,
    allow_errors: bool,
    allow_empty: bool,
}

/// Loads and validates a conversion without planning or writing it.
///
/// Applies destination admission and decode-loss refusal. Runs validation.
/// Check, empty-geometry, plan, and export-loss refusals run in `plan`.
/// Does not print reports or write destination artifacts.
pub(crate) fn prepare(
    inputs: &InputCatalog,
    source: &SourceRequest<'_>,
    target: ExportTarget,
    policy: &ConversionPolicy,
) -> Result<PreparedConversion, ApplicationError> {
    let format = target.selection.format;
    policy.destination.admit_format(format)?;
    let destination = policy
        .destination
        .resolve(source.path)
        .map_err(ApplicationError::from)?;

    let loaded = loader::load_artifact(inputs, source.path, source.options, source.forced)?;
    let decode_report = loaded.decode_report().cloned();

    if let Some(refusal) = decode_lossy_refusal(policy.losses, decode_report.as_ref(), format) {
        return Err(refusal.into());
    }

    let validation = validate_loaded(inputs, &loaded, &source.options.policy)?;
    Ok(PreparedConversion {
        document: loaded,
        validation,
        encoder: target.encoder,
        selection: target.selection,
        destination,
        loss_policy: policy.losses,
        allow_errors: policy.allow_errors,
        allow_empty: policy.allow_empty,
    })
}

impl PreparedConversion {
    /// Plans the export before applying check and empty-geometry refusals.
    /// A failed plan counts every error as carried, then applies plan refusal.
    /// A completed plan checks carried errors, then applies export-loss refusal.
    pub(crate) fn plan(self) -> Result<PlannedConversion, ApplicationError> {
        // Resolution is the encoder's, and so is its refusal: the message
        // already names the requested id and the whole catalog, and it
        // reflects this build's feature set. Restating it here would be a
        // second vocabulary to keep in step with the first.
        let plan = match self.encoder.plan(
            EncodeInput::new(&self.document.ir, self.document.fidelity()),
            self.selection.request(),
        ) {
            Ok(plan) => plan,
            Err(error) => {
                let carried = self.validation.error_count();
                if carried > 0 && !self.allow_errors {
                    return Err(self.check_refusal(carried));
                }
                let shallow = matches!(error, cadmpeg_core::CodecError::InsufficientDepth { .. });
                if !shallow && self.refuses_empty_geometry() {
                    return Err(self.empty_geometry_refusal());
                }
                return Err(plan_refusal(
                    error,
                    self.document.decode_report().cloned(),
                    self.validation,
                ));
            }
        };
        let carried = plan
            .carried_errors(&self.document.ir, &self.validation)
            .len();
        if carried > 0 && !self.allow_errors {
            return Err(self.check_refusal(carried));
        }
        if self.refuses_empty_geometry() {
            return Err(self.empty_geometry_refusal());
        }
        if self.loss_policy.rejects_export() && !plan.report().losses.is_empty() {
            return Err(ConversionRefusal::ExportLossRejected {
                decode_report: self.document.decode_report().cloned(),
                validation: self.validation,
                export_report: plan.report().clone(),
            }
            .into());
        }
        Ok(PlannedConversion {
            plan,
            prepared: self,
            carried_errors: carried,
        })
    }

    fn check_refusal(self, carried: usize) -> ApplicationError {
        ConversionRefusal::CheckFailed {
            operation: super::refusal::CheckOperation::Export { carried },
            decode_report: self.document.decode_report().cloned(),
            validation: self.validation,
        }
        .into()
    }

    fn empty_geometry_refusal(self) -> ApplicationError {
        ConversionRefusal::EmptyGeometry {
            format: self.selection.format,
            decode_report: self.document.decode_report().cloned(),
            validation: self.validation,
        }
        .into()
    }

    fn refuses_empty_geometry(&self) -> bool {
        self.selection.format.transfers_geometry()
            && self
                .document
                .decode_report()
                .is_some_and(|report| !report.geometry_transferred())
            && !self.allow_empty
    }

    /// Returns the selected output format.
    pub(crate) const fn format(&self) -> Format {
        self.selection.format
    }
}

/// One planned export and the source state from which it was produced.
pub(crate) struct PlannedConversion {
    plan: ExportPlan,
    prepared: PreparedConversion,
    carried_errors: usize,
}

impl PlannedConversion {
    /// Returns the number of error findings carried by the output.
    pub(crate) const fn carried_errors(&self) -> usize {
        self.carried_errors
    }

    /// Returns the loaded source state retained for reporting.
    #[must_use]
    pub(crate) const fn prepared(&self) -> &PreparedConversion {
        &self.prepared
    }

    /// Writes the destination artifact and optional CADIR sidecar.
    pub(crate) fn write(self) -> AnyResult<ExportEmission> {
        emit_export_plan(
            self.plan,
            self.prepared.selection.format,
            &self.prepared.destination,
            &self.prepared.document.origin,
        )
    }
}

/// Artifact state produced by one completed export emission.
pub(crate) struct ExportEmission {
    pub(crate) report: ExportReport,
    pub(crate) artifact: EmittedArtifact,
}

/// Destination-specific facts needed by the presentation layer.
pub(crate) enum EmittedArtifact {
    /// A file and its CADIR sidecar disposition.
    File {
        path: PathBuf,
        sidecar: SidecarPersistOutcome,
    },
    /// Standard output with no sidecar requirement.
    Stdout,
    /// CADIR on standard output, where the required sidecar cannot travel.
    StdoutWithoutSidecar,
}

/// Restates an unsupported target or an insufficient decode depth as a typed
/// refusal.
///
/// Resource failures retain their typed evidence; other plan failures stay
/// operational. Export-loss refusal needs a completed plan's typed loss rows.
fn plan_refusal(
    error: cadmpeg_core::CodecError,
    decode_report: Option<DecodeReport>,
    validation: ValidationReport,
) -> ApplicationError {
    match error {
        cadmpeg_core::CodecError::UnsupportedTarget(refusal) => {
            ConversionRefusal::UnsupportedTarget {
                refusal,
                decode_report,
                validation,
            }
            .into()
        }
        cadmpeg_core::CodecError::InsufficientDepth {
            format,
            needed,
            recorded,
        } => ConversionRefusal::InsufficientDepth {
            format,
            depth: DepthReport { needed, recorded },
            decode_report,
            validation,
        }
        .into(),
        _ => ApplicationError::from(error).at_stage(RefusalStage::Plan),
    }
}

fn decode_lossy_refusal(
    policy: LossPolicy,
    report: Option<&DecodeReport>,
    format: Format,
) -> Option<ConversionRefusal> {
    if !policy.rejects_decode() {
        return None;
    }
    let report = report?;
    let count = report.losses.len();
    (count > 0).then(|| ConversionRefusal::DecodeLossRejected {
        format,
        decode_report: report.clone(),
    })
}

/// Emits one built plan to its destination and maintains CADIR sidecars.
///
/// This operation performs artifact I/O only. The returned outcome carries
/// every fact the command layer needs for presentation.
pub(crate) fn emit_export_plan(
    plan: ExportPlan,
    format: Format,
    destination: &ResolvedDestination,
    origin: &LoadOrigin,
) -> AnyResult<ExportEmission> {
    let needs_sidecar = format == Format::Cadir
        && matches!(
            origin,
            LoadOrigin::Decoded { .. } | LoadOrigin::Restored { .. }
        );
    if let ResolvedDestination::File(path) = destination {
        let (report, cadir_sha256) = artifact_store::write_plan_atomic(path, plan)?;
        let sidecar = if format == Format::Cadir {
            artifact_store::persist_decode_sidecar(path, &cadir_sha256, origin)?
        } else {
            SidecarPersistOutcome::Absent
        };
        Ok(ExportEmission {
            report,
            artifact: EmittedArtifact::File {
                path: path.to_owned(),
                sidecar,
            },
        })
    } else {
        let stdout = io::stdout().lock();
        let mut writer = BufWriter::with_capacity(64 * 1024, stdout);
        let report = plan.write_to(&mut writer)?;
        writer.flush()?;
        Ok(ExportEmission {
            report,
            artifact: if needs_sidecar {
                EmittedArtifact::StdoutWithoutSidecar
            } else {
                EmittedArtifact::Stdout
            },
        })
    }
}

/// Builds an [`ExportTarget`] from the command-line selection.
///
/// This constructs the selected encoder without interpreting its dialect
/// token. The encoder's sealed `plan` resolves catalog membership,
/// source-dependent inheritance, and input-conditioned delivery once.
///
/// `losses` is a policy, never a target: reading it as one would turn
/// `convert a.step -o b.step --reject-lossy=export` into an explicit AP214
/// request, silently rewriting the schema of a file the caller only asked to
/// check for losses.
pub(crate) fn export_target(selection: TargetSelection) -> ExportTarget {
    let encoder = cadmpeg_registry::build_encoder(selection.format);
    ExportTarget { encoder, selection }
}

#[cfg(test)]
mod tests;