car-parser 0.1.0

Rust parser for Apple .car asset catalog archives
Documentation
use std::path::{Component, Path, PathBuf};

use crate::car::{CSIItem, Car};
use crate::model::Encoding;
use crate::model::rendition::Rendition;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum OutputIdentityKind {
    CanonicalSourceOutput,
    VariantOutput,
}

#[derive(Debug, Clone)]
pub struct OutputIdentity<'a> {
    kind: OutputIdentityKind,
    facet_name: String,
    item: &'a CSIItem,
}

impl<'a> OutputIdentity<'a> {
    pub fn canonical_source(facet_name: impl Into<String>, item: &'a CSIItem) -> Self {
        Self {
            kind: OutputIdentityKind::CanonicalSourceOutput,
            facet_name: facet_name.into(),
            item,
        }
    }

    pub fn variant(facet_name: impl Into<String>, item: &'a CSIItem) -> Self {
        Self {
            kind: OutputIdentityKind::VariantOutput,
            facet_name: facet_name.into(),
            item,
        }
    }

    pub fn kind(&self) -> OutputIdentityKind {
        self.kind
    }

    pub fn facet_name(&self) -> &str {
        &self.facet_name
    }

    pub fn item(&self) -> &'a CSIItem {
        self.item
    }

    pub fn include_scale_suffix(&self) -> bool {
        matches!(self.kind, OutputIdentityKind::VariantOutput)
    }

    pub fn canonical_identity_key(&self) -> Option<Vec<u16>> {
        matches!(self.kind, OutputIdentityKind::CanonicalSourceOutput)
            .then(|| self.item.key_values().to_vec())
    }
}

impl Car {
    pub fn output_identity_for_payload<'a>(
        &'a self,
        logical_facet_name: &str,
        logical_item: &'a CSIItem,
        payload_item: &'a CSIItem,
    ) -> Option<OutputIdentity<'a>> {
        match supported_output_identity(payload_item)? {
            OutputIdentityKind::CanonicalSourceOutput => {
                let (facet_name, item) = self
                    .find_canonical_named_item(payload_item)
                    .unwrap_or((logical_facet_name.to_string(), payload_item));
                Some(OutputIdentity::canonical_source(facet_name, item))
            }
            OutputIdentityKind::VariantOutput => {
                Some(OutputIdentity::variant(logical_facet_name, logical_item))
            }
        }
    }

    fn find_canonical_named_item<'a>(
        &'a self,
        payload_item: &'a CSIItem,
    ) -> Option<(String, &'a CSIItem)> {
        let key_values = payload_item.key_values();
        for (facet_name, facet) in self.named_facets() {
            let Some(items) = self.items_for_facet(facet) else {
                continue;
            };
            if let Some(item) = items.iter().find(|item| {
                item.key_values() == key_values && supported_output_identity(item).is_some()
            }) {
                return Some((facet_name.to_string(), item));
            }
        }
        None
    }
}

pub fn supported_output_identity(item: &CSIItem) -> Option<OutputIdentityKind> {
    match &item.header().rendition {
        Some(Rendition::RawData(_))
            if matches!(
                item.header().encoding,
                Encoding::Data | Encoding::PDF | Encoding::SVG
            ) =>
        {
            Some(OutputIdentityKind::CanonicalSourceOutput)
        }
        Some(Rendition::Color(_))
        | Some(Rendition::ThemeCBCK(_))
        | Some(Rendition::MultisizeImageSet(_))
        | Some(Rendition::RawData(_)) => Some(OutputIdentityKind::VariantOutput),
        _ => None,
    }
}

pub fn rendition_scale(item: &CSIItem) -> u32 {
    item.attributes()
        .iter()
        .find(|attr| attr.name == crate::rendition::AttributeType::Scale && attr.val > 0)
        .map(|attr| u32::from(attr.val))
        .unwrap_or_else(|| item.scale())
}

pub fn suggested_file_name(identity: &OutputIdentity<'_>, extension: &str) -> Option<String> {
    let mut file_name = sanitize_file_name(identity.item().name())?;

    let scale = rendition_scale(identity.item());
    if identity.include_scale_suffix() && scale > 1 {
        let stem = file_name
            .file_stem()
            .unwrap_or_default()
            .to_string_lossy()
            .to_string();
        if !stem.ends_with(&format!("@{}x", scale)) {
            let current_ext = file_name
                .extension()
                .map(|ext| ext.to_string_lossy().to_string());
            let renamed = match current_ext {
                Some(current_ext) => format!("{stem}@{scale}x.{current_ext}"),
                None => format!("{stem}@{scale}x"),
            };
            file_name = PathBuf::from(renamed);
        }
    }

    if !extension.is_empty() {
        let has_expected_extension = file_name
            .extension()
            .and_then(|ext| ext.to_str())
            .is_some_and(|ext| ext.eq_ignore_ascii_case(extension));
        if !has_expected_extension {
            file_name.set_extension(extension);
        }
    }

    file_name
        .file_name()
        .map(|name| name.to_string_lossy().into_owned())
}

pub fn default_raw_extension(encoding: Encoding) -> &'static str {
    match encoding {
        Encoding::HEIF => "heic",
        Encoding::PDF => "pdf",
        Encoding::SVG => "svg",
        Encoding::JPEG => "jpg",
        Encoding::WEBP => "webp",
        Encoding::Data | Encoding::None => "bin",
        _ => "bin",
    }
}

pub fn default_raw_extension_for_item(item: &CSIItem) -> &'static str {
    if crate::image_conv::is_hevc_compressed(item) {
        "heic"
    } else {
        default_raw_extension(item.header().encoding)
    }
}

pub(crate) fn sanitize_file_name(name: &str) -> Option<PathBuf> {
    let mut file_name = None;

    for component in Path::new(name).components() {
        match component {
            Component::Normal(part) => file_name = Some(PathBuf::from(part)),
            Component::CurDir => {}
            Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
        }
    }

    file_name
}