cadmpeg 0.1.6

Inspect, decode, validate, compare, and convert CAD models.
// SPDX-License-Identifier: Apache-2.0
//! The `cadmpeg` command-line interface.
//!
//! The CLI detects supported native CAD containers, decodes model data through
//! CADIR, validates and compares CADIR models, and writes CADIR, STEP AP214,
//! `.f3d`, or `.sldprt` output. See the package README for workflows, format
//! limits, loss reporting, and exit-status semantics.

mod commands;
mod loader;
mod registry;

use std::path::PathBuf;
use std::process::ExitCode;

use clap::{Args, Parser, Subcommand, ValueEnum};

use crate::registry::Registry;

#[derive(Debug, Parser)]
#[command(
    name = "cadmpeg",
    version,
    about = "Inspect, decode, validate, compare, and convert CAD models",
    after_help = "Exit codes: 0 success, 1 semantic failure, 2 operational error."
)]
struct Cli {
    /// Operation to perform.
    #[command(subcommand)]
    command: Command,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum Format {
    /// Canonical CADIR JSON.
    #[value(alias = "json")]
    Cadir,
    /// ISO 10303-21 STEP AP214.
    Step,
    /// Autodesk Fusion `.f3d`.
    F3d,
    /// `SolidWorks` `.sldprt`.
    Sldprt,
}

impl Format {
    fn from_extension(extension: &str) -> Option<Self> {
        match extension.to_ascii_lowercase().as_str() {
            "cadir" | "json" => Some(Self::Cadir),
            "step" | "stp" => Some(Self::Step),
            "f3d" => Some(Self::F3d),
            "sldprt" => Some(Self::Sldprt),
            _ => None,
        }
    }

    fn is_geometry_export(self) -> bool {
        matches!(self, Self::Step | Self::F3d | Self::Sldprt)
    }

    fn from_path(path: Option<&std::path::Path>) -> Option<Self> {
        path.and_then(std::path::Path::extension)
            .and_then(|extension| extension.to_str())
            .and_then(Self::from_extension)
    }

    fn name(self) -> &'static str {
        match self {
            Self::Cadir => "cadir",
            Self::Step => "step",
            Self::F3d => "f3d",
            Self::Sldprt => "sldprt",
        }
    }
}

#[derive(Debug, Clone, Copy, ValueEnum)]
enum InputFormat {
    /// Autodesk Fusion `.f3d`.
    F3d,
    /// `SolidWorks` `.sldprt`.
    Sldprt,
    /// CATIA V5 `.CATPart`.
    #[value(alias = "catia")]
    Catpart,
    /// Siemens NX `.prt`.
    Nx,
    /// Creo Parametric `.prt`.
    Creo,
    /// Canonical CADIR JSON.
    Cadir,
}

#[derive(Debug, Clone, Copy)]
enum ForcedInput {
    Codec(&'static str),
    Cadir,
}

impl InputFormat {
    fn resolution(self) -> ForcedInput {
        match self {
            Self::F3d => ForcedInput::Codec("f3d"),
            Self::Sldprt => ForcedInput::Codec("sldprt"),
            Self::Catpart => ForcedInput::Codec("catia"),
            Self::Nx => ForcedInput::Codec("nx"),
            Self::Creo => ForcedInput::Codec("creo"),
            Self::Cadir => ForcedInput::Cadir,
        }
    }
}

#[derive(Debug, Clone, Args)]
struct InputArgs {
    /// Bypass content detection and read the input as this format.
    #[arg(long, value_enum)]
    input_format: Option<InputFormat>,
}

impl InputArgs {
    fn forced(&self) -> Option<ForcedInput> {
        self.input_format.map(InputFormat::resolution)
    }
}

#[derive(Debug, Clone, Args)]
struct DecodeArgs {
    /// Stop after the native container layer without transferring geometry.
    #[arg(long)]
    container_only: bool,
}

impl DecodeArgs {
    fn options(&self) -> cadmpeg_ir::DecodeOptions {
        cadmpeg_ir::DecodeOptions {
            container_only: self.container_only,
        }
    }
}

#[derive(Debug, Subcommand)]
enum Command {
    /// List a native container's entries without decoding its model.
    Inspect {
        /// Native CAD file to inspect.
        input: PathBuf,
        /// Write a versioned JSON summary to standard output.
        #[arg(long)]
        json: bool,
        #[command(flatten)]
        input_args: InputArgs,
    },
    /// Decode a native CAD file to canonical CADIR JSON.
    Decode {
        /// Native CAD file to decode.
        input: PathBuf,
        /// Output file; omit to write CADIR to standard output.
        #[arg(short, long)]
        output: Option<PathBuf>,
        /// Replace an existing output file.
        #[arg(long)]
        force: bool,
        /// Write a versioned JSON command report to this file.
        #[arg(long)]
        report: Option<PathBuf>,
        #[command(flatten)]
        input_args: InputArgs,
        #[command(flatten)]
        decode: DecodeArgs,
    },
    /// Validate a CADIR document or a decoded native CAD file.
    Validate {
        /// CADIR or supported native CAD file to validate.
        input: PathBuf,
        /// Write a versioned JSON result to standard output.
        #[arg(long)]
        json: bool,
        #[command(flatten)]
        input_args: InputArgs,
        #[command(flatten)]
        decode: DecodeArgs,
    },
    /// Decode if needed, then export without CADIR validation.
    Export {
        /// CADIR or supported native CAD file to export.
        input: PathBuf,
        /// Output format; inferred from the output extension when omitted.
        #[arg(short, long, value_enum)]
        format: Option<Format>,
        /// Output file; omit to write the artifact to standard output.
        #[arg(short, long)]
        output: Option<PathBuf>,
        /// Replace an existing output file.
        #[arg(long)]
        force: bool,
        /// Write a versioned JSON command report to this file.
        #[arg(long)]
        report: Option<PathBuf>,
        /// Write geometry output even when decoding transferred no geometry.
        #[arg(long)]
        allow_empty: bool,
        #[command(flatten)]
        input_args: InputArgs,
        #[command(flatten)]
        decode: DecodeArgs,
    },
    /// Structurally compare two CADIR or supported native CAD models.
    Diff {
        /// First model.
        a: PathBuf,
        /// Second model.
        b: PathBuf,
        /// Write a versioned JSON result to standard output.
        #[arg(long)]
        json: bool,
        #[command(flatten)]
        decode: DecodeArgs,
    },
    /// Decode if needed, validate CADIR, then export.
    Convert {
        /// CADIR or supported native CAD file to convert.
        input: PathBuf,
        /// Output format; inferred from the output extension when omitted.
        #[arg(short, long, value_enum)]
        format: Option<Format>,
        /// Output file; omit to write the artifact to standard output.
        #[arg(short, long)]
        output: Option<PathBuf>,
        /// Replace an existing output file.
        #[arg(long)]
        force: bool,
        /// Write a versioned JSON command report to this file.
        #[arg(long)]
        report: Option<PathBuf>,
        /// Export even when CADIR validation finds errors.
        #[arg(long)]
        allow_invalid: bool,
        /// Write geometry output even when decoding transferred no geometry.
        #[arg(long)]
        allow_empty: bool,
        #[command(flatten)]
        input_args: InputArgs,
        #[command(flatten)]
        decode: DecodeArgs,
    },
}

fn main() -> ExitCode {
    let registry = Registry::with_builtins();
    let result = match Cli::parse().command {
        Command::Inspect {
            input,
            json,
            input_args,
        } => commands::inspect(&registry, &input, input_args.forced(), json)
            .map(|()| ExitCode::SUCCESS),
        Command::Decode {
            input,
            output,
            force,
            report,
            input_args,
            decode,
        } => commands::decode(
            &registry,
            &input,
            output.as_deref(),
            force,
            report.as_deref(),
            input_args.forced(),
            &decode,
        )
        .map(|()| ExitCode::SUCCESS),
        Command::Validate {
            input,
            json,
            input_args,
            decode,
        } => commands::validate_cmd(&registry, &input, input_args.forced(), &decode, json)
            .map(|()| ExitCode::SUCCESS),
        Command::Export {
            input,
            format,
            output,
            force,
            report,
            allow_empty,
            input_args,
            decode,
        } => commands::export(
            &registry,
            &input,
            format,
            output.as_deref(),
            commands::ExportSettings {
                force,
                report,
                allow_empty,
                forced_input: input_args.forced(),
            },
            &decode,
        )
        .map(|()| ExitCode::SUCCESS),
        Command::Diff { a, b, json, decode } => commands::diff(&registry, &a, &b, &decode, json),
        Command::Convert {
            input,
            format,
            output,
            force,
            report,
            allow_invalid,
            allow_empty,
            input_args,
            decode,
        } => commands::convert(
            &registry,
            &input,
            format,
            output.as_deref(),
            &commands::ConvertSettings {
                force,
                report,
                allow_invalid,
                allow_empty,
                forced_input: input_args.forced(),
            },
            &decode,
        )
        .map(|()| ExitCode::SUCCESS),
    };
    result.unwrap_or_else(|err| {
        eprintln!("error: {err:#}");
        if err.downcast_ref::<commands::SemanticFailure>().is_some() {
            ExitCode::from(1)
        } else {
            ExitCode::from(2)
        }
    })
}