sora-export 0.3.0

Simple and powerful configuration table compiler for games and data-heavy tools.
Documentation
use std::collections::BTreeMap;

use serde::Serialize;
use sora_diagnostics::{Result, SoraError};

use crate::{
    bundle::{FORMAT_VERSION, schema_fingerprint},
    exporter::{DataExporter, ExportOutput, ExportRequest, OutputKind},
    fs_util::{create_parent_dir, write_file},
};

const I18N_MAGIC: &[u8; 4] = b"SORI";
const I18N_VERSION: u32 = 1;

pub struct I18nJsonExporter;

impl DataExporter for I18nJsonExporter {
    fn format_name(&self) -> &'static str {
        "i18n-json"
    }

    fn output_kind(&self) -> OutputKind {
        OutputKind::File
    }

    fn export(&self, request: ExportRequest<'_>) -> Result<()> {
        let ExportOutput::File(ref path) = request.output else {
            return Err(SoraError::InvalidExportOutput {
                format: self.format_name().to_owned(),
                expected: "file",
            });
        };
        let view = locale_view(self.format_name(), &request)?;
        create_parent_dir(path)?;
        let content = serde_json::to_vec_pretty(&view).map_err(SoraError::SerializeData)?;
        write_file(path.clone(), content)
    }
}

pub struct I18nBinaryExporter;

impl DataExporter for I18nBinaryExporter {
    fn format_name(&self) -> &'static str {
        "i18n-binary"
    }

    fn output_kind(&self) -> OutputKind {
        OutputKind::File
    }

    fn export(&self, request: ExportRequest<'_>) -> Result<()> {
        let ExportOutput::File(ref path) = request.output else {
            return Err(SoraError::InvalidExportOutput {
                format: self.format_name().to_owned(),
                expected: "file",
            });
        };
        let view = locale_view(self.format_name(), &request)?;
        create_parent_dir(path)?;
        write_file(path.clone(), encode_i18n_binary(&view)?)
    }
}

#[derive(Serialize)]
struct I18nView {
    format: &'static str,
    format_version: u32,
    schema_fingerprint: String,
    locale: String,
    fallback_locale: Option<String>,
    translations: BTreeMap<String, String>,
}

fn locale_view(format: &'static str, request: &ExportRequest<'_>) -> Result<I18nView> {
    let locale = request.options.locale.as_deref().ok_or_else(|| {
        SoraError::InvalidSchema(format!("export format `{format}` requires `locale`"))
    })?;
    let catalog = request.locale_catalog.ok_or_else(|| {
        SoraError::InvalidSchema(format!("export format `{format}` requires [localization]"))
    })?;
    Ok(I18nView {
        format,
        format_version: FORMAT_VERSION,
        schema_fingerprint: schema_fingerprint(request.ir)?,
        locale: locale.to_owned(),
        fallback_locale: catalog.fallback_locale.clone(),
        translations: catalog.for_locale(locale)?,
    })
}

fn encode_i18n_binary(view: &I18nView) -> Result<Vec<u8>> {
    let mut bytes = Vec::new();
    bytes.extend_from_slice(I18N_MAGIC);
    write_u32(&mut bytes, I18N_VERSION);
    write_len_prefixed(&mut bytes, view.schema_fingerprint.as_bytes())?;
    write_len_prefixed(&mut bytes, view.locale.as_bytes())?;
    write_u32(
        &mut bytes,
        checked_u32(view.translations.len(), "translation count")?,
    );
    for (key, value) in &view.translations {
        write_len_prefixed(&mut bytes, key.as_bytes())?;
        write_len_prefixed(&mut bytes, value.as_bytes())?;
    }
    Ok(bytes)
}

fn write_len_prefixed(out: &mut Vec<u8>, bytes: &[u8]) -> Result<()> {
    write_u32(out, checked_u32(bytes.len(), "i18n string length")?);
    out.extend_from_slice(bytes);
    Ok(())
}

fn checked_u32(value: usize, label: &str) -> Result<u32> {
    u32::try_from(value).map_err(|_| SoraError::InvalidSchema(format!("{label} exceeds u32 range")))
}

fn write_u32(out: &mut Vec<u8>, value: u32) {
    out.extend_from_slice(&value.to_le_bytes());
}