cadmpeg 0.6.8

Convert and inspect native CAD files.
// SPDX-License-Identifier: Apache-2.0
//! Input detection and loading into CADIR.

use std::fs::File;
use std::path::Path;

use anyhow::{anyhow, Context};
use cadmpeg_core::decode::{DecodeArena, DecodeContext, View};
use cadmpeg_ir::codec::DecodeOptions;
use cadmpeg_ir::CadIr;

use cadmpeg_registry::{
    ForcedInput, InputCatalog, ResolveSourceError, ResolvedSource, DETECTION_PREFIX_LEN,
};

use crate::application::artifact_store;
use crate::application::document::LoadedDocument;
use crate::application::refusal::ApplicationError;

pub(crate) mod companions;

/// Restates a detection failure with the flag that overrides it.
///
/// The registry states the fact; naming `--input-format` is this crate's job,
/// because the flag is this crate's.
pub(crate) fn detection_failure(error: &ResolveSourceError) -> anyhow::Error {
    anyhow!("{error}; pass --input-format")
}

/// Load CADIR from a native CAD file or CADIR JSON.
///
/// A native decode failure is classified into an [`ApplicationError`] here,
/// at the only site that knows the path and the selected codec.
///
/// An explicit input format bypasses detection. Without one, the registered
/// codec with the strongest match decodes the file. An input beginning with a
/// JSON object is parsed as CADIR when no native codec recognizes it.
pub(crate) fn load_artifact(
    catalog: &InputCatalog,
    path: &Path,
    options: DecodeOptions,
    forced: Option<ForcedInput>,
) -> Result<LoadedDocument, ApplicationError> {
    let companions = companions::Directory::beside(path);
    let arena = DecodeArena::new();
    let ctx =
        DecodeContext::new(&arena, &options.policy, options.depth).with_companions(&companions);
    let mut file = File::open(path).with_context(|| format!("opening {}", path.display()))?;
    let mut prefix =
        cadmpeg_container::compound::read_detection_prefix(&ctx, &mut file, DETECTION_PREFIX_LEN)?;
    let resolved = catalog
        .resolve_seekable_source(&ctx, View::over_retained(&prefix), &mut file, forced)
        .map_err(|error| match error {
            ResolveSourceError::Codec(error) => ApplicationError::from(error),
            error => ApplicationError::from(detection_failure(&error)),
        })?;
    match resolved {
        ResolvedSource::Native { codec, selection } => {
            let format_id = codec.id();
            ctx.complete_input(&mut file, &mut prefix)?;
            let result = codec.decode_with_context(&ctx, View::over_retained(&prefix));
            ctx.finish_session()?;
            let result = result.map_err(|failure| {
                ApplicationError::from_decode_failure(path, format_id, failure)
            })?;
            return Ok(LoadedDocument::decoded(
                result,
                selection,
                cadmpeg_core::decode::u64_from_index(prefix.len()),
            ));
        }
        ResolvedSource::Cadir => {}
        ResolvedSource::Unrecognized => {
            return Err(anyhow!(
                "unrecognized format for {}; supported: FCStd, f3d, Inventor IPT/IAM, sldprt, CATPart, NX/Creo prt, Rhino 3DM, IGES, STEP, ASM sat/smt/smb/sab, Creo View pvz, OpenUSD usda/usdc/usd/usdz, .cadir.json; use --input-format to override detection",
                path.display()
            )
            .into());
        }
    }

    let max_bytes = options.policy.limits.max_input_bytes;
    ctx.complete_input(&mut file, &mut prefix)?;
    let text =
        String::from_utf8(prefix).map_err(|error| anyhow!("invalid CADIR UTF-8: {error}"))?;
    ctx.finish_session()?;
    let ir = CadIr::from_json(&text).map_err(|e| {
        if names_decode_sidecar(path) {
            anyhow!(
                "{} is a decode sidecar; pass the CADIR document beside it",
                path.display()
            )
        } else {
            anyhow!(
                "{} is neither a recognized CAD file nor a valid .cadir.json document: {e}",
                path.display()
            )
        }
    })?;
    let Some(sidecar) = artifact_store::load_matching_sidecar(path, text.as_bytes(), max_bytes)?
    else {
        return Ok(LoadedDocument::neutral(
            ir,
            cadmpeg_core::decode::u64_from_index(text.len()),
        ));
    };
    Ok(LoadedDocument::restored(
        ir,
        sidecar.report,
        sidecar.fidelity,
        cadmpeg_core::decode::u64_from_index(text.len()),
    ))
}

/// Whether the file name has the decode-sidecar suffix that `dump` writes
/// beside a CADIR document.
fn names_decode_sidecar(path: &Path) -> bool {
    path.file_name()
        .and_then(std::ffi::OsStr::to_str)
        .is_some_and(|name| name.ends_with(".fidelity.json"))
}

#[cfg(test)]
#[allow(clippy::default_trait_access, clippy::unwrap_used)]
mod tests {
    use super::load_artifact;
    use crate::application::document::LoadOrigin;
    use cadmpeg_ir::codec::DecodeOptions;
    use cadmpeg_ir::CadIr;
    use cadmpeg_ir::{report::decode::DecodeReport, DecodeSidecar, SourceFidelity};
    use cadmpeg_registry::{ForcedInput, InputCatalog};

    #[test]
    fn input_acquisition_resource_refusal_stays_typed() {
        let directory = tempfile::tempdir().expect("directory");
        let path = directory.path().join("input.json");
        std::fs::write(&path, b"12345").expect("input");
        let mut options = DecodeOptions::default();
        options.policy.limits.max_input_bytes = 4;
        let error = load_artifact(
            &InputCatalog::with_builtins(),
            &path,
            options,
            Some(ForcedInput::Cadir),
        )
        .expect_err("input limit");
        assert!(
            matches!(error.refusal(), Some(crate::application::refusal::ConversionRefusal::ResourceLimit { limit, .. }) if limit.dimension == cadmpeg_core::decode::ResourceDimension::InputBytes && limit.limit == 4)
        );
    }

    #[test]
    fn matching_sidecar_restores_decoded_origin_and_mismatch_is_hard_error() {
        let directory = tempfile::tempdir().unwrap();
        let path = directory.path().join("part.cadir.json");
        let text = CadIr::empty().to_canonical_json().unwrap();
        std::fs::write(&path, &text).unwrap();
        let report: DecodeReport = serde_json::from_value(serde_json::json!({
            "identity": {"classification": "unclassified", "format": "test"},
            "transfer": {"transfer": "full", "geometry_transferred": false},
            "losses": [],
            "notes": [],
        }))
        .unwrap();
        let sidecar = DecodeSidecar::bind_sha256(
            cadmpeg_ir::hash::digest::Sha256Digest::digest(text.as_bytes()),
            report,
            SourceFidelity::default(),
        );
        std::fs::write(
            cadmpeg_ir::decode_sidecar_path(&path).unwrap(),
            sidecar.to_canonical_json().unwrap(),
        )
        .unwrap();

        let outcome = load_artifact(
            &InputCatalog::with_builtins(),
            &path,
            DecodeOptions::default(),
            Some(ForcedInput::Cadir),
        )
        .unwrap();
        assert!(matches!(outcome.origin, LoadOrigin::Restored { .. }));
        assert_eq!(outcome.input_bytes, u64::try_from(text.len()).unwrap());

        std::fs::write(&path, format!("{text}\n")).unwrap();
        let error = load_artifact(
            &InputCatalog::with_builtins(),
            &path,
            DecodeOptions::default(),
            Some(ForcedInput::Cadir),
        )
        .unwrap_err();
        assert!(error.to_string().contains("does not match"));
    }
}