use std::io::{self, BufWriter, Write};
use std::path::{Path, PathBuf};
use anyhow::{anyhow, Result as AnyResult};
use cadmpeg_ir::codec::write::{target::TargetRequest, 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, RefusalStage};
use crate::application::validators::validate_loaded;
use crate::loader;
pub(crate) struct SourceRequest<'a> {
pub(crate) path: &'a Path,
pub(crate) forced: Option<ForcedInput>,
pub(crate) options: DecodeOptions,
}
pub(crate) struct ExportTarget {
encoder: Box<dyn Encoder>,
selection: TargetSelection,
}
#[derive(Debug, Clone)]
pub(crate) struct TargetSelection {
format: Format,
request: Option<String>,
}
impl TargetSelection {
#[must_use]
fn new(format: Format, request: Option<String>) -> Self {
Self { format, request }
}
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())))
}
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()
);
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)]
pub(crate) enum LossPolicy {
#[default]
#[value(skip)]
Allow,
#[value(name = "decode")]
RejectDecode,
#[value(name = "export")]
RejectExport,
#[value(name = "any")]
RejectAny,
}
impl LossPolicy {
#[must_use]
const fn rejects_decode(self) -> bool {
matches!(self, Self::RejectDecode | Self::RejectAny)
}
#[must_use]
const fn rejects_export(self) -> bool {
matches!(self, Self::RejectExport | Self::RejectAny)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum DestinationPolicy {
Stdout {
allow_binary: bool,
},
File(FileDestination),
}
#[derive(Debug)]
pub(crate) struct ConversionDestinations {
pub(crate) destination: DestinationPolicy,
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 {
#[must_use]
pub(crate) fn path(&self) -> Option<&Path> {
match self {
Self::Stdout { .. } => None,
Self::File(destination) => Some(&destination.path),
}
}
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(()),
}
}
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()))
}
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct ConversionPolicy {
pub(crate) losses: LossPolicy,
pub(crate) allow_errors: bool,
pub(crate) allow_empty: bool,
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),
}
}
}
pub(crate) struct PreparedConversion {
pub(crate) document: LoadedDocument,
pub(crate) validation: ValidationReport,
encoder: Box<dyn Encoder>,
selection: TargetSelection,
destination: ResolvedDestination,
loss_policy: LossPolicy,
}
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)?;
if !validation.is_ok() && !policy.allow_errors {
return Err(ConversionRefusal::CheckFailed {
operation: super::refusal::CheckOperation::Export,
decode_report,
validation,
}
.into());
}
if format.transfers_geometry()
&& decode_report
.as_ref()
.is_some_and(|report| !report.geometry_transferred())
&& !policy.allow_empty
{
return Err(ConversionRefusal::EmptyGeometry {
format,
decode_report,
validation,
}
.into());
}
Ok(PreparedConversion {
document: loaded,
validation,
encoder: target.encoder,
selection: target.selection,
destination,
loss_policy: policy.losses,
})
}
impl PreparedConversion {
pub(crate) fn plan(self) -> Result<PlannedConversion, ApplicationError> {
let plan = match self.encoder.plan(
EncodeInput::new(&self.document.ir, self.document.fidelity()),
self.selection.request(),
) {
Ok(plan) => plan,
Err(error) => {
return Err(plan_refusal(
error,
self.document.decode_report().cloned(),
self.validation,
));
}
};
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,
})
}
}
pub(crate) struct PlannedConversion {
plan: ExportPlan,
prepared: PreparedConversion,
}
impl PlannedConversion {
#[must_use]
pub(crate) const fn prepared(&self) -> &PreparedConversion {
&self.prepared
}
pub(crate) fn write(self) -> AnyResult<ExportEmission> {
emit_export_plan(
self.plan,
self.prepared.selection.format,
&self.prepared.destination,
&self.prepared.document.origin,
)
}
}
pub(crate) struct ExportEmission {
pub(crate) report: ExportReport,
pub(crate) artifact: EmittedArtifact,
}
pub(crate) enum EmittedArtifact {
File {
path: PathBuf,
sidecar: SidecarPersistOutcome,
},
Stdout,
StdoutWithoutSidecar,
}
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()
}
_ => 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(),
})
}
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
},
})
}
}
pub(crate) fn export_target(selection: TargetSelection) -> ExportTarget {
let encoder = cadmpeg_registry::build_encoder(selection.format);
ExportTarget { encoder, selection }
}
#[cfg(test)]
mod tests;