rimloc-core 0.1.0

Core data types and utilities for RimLoc translation toolkit
Documentation
use color_eyre::eyre::eyre;
use std::path::PathBuf;

use serde::{Deserialize, Serialize};
use thiserror::Error;

/// Workspace-wide result alias.
pub type Result<T> = color_eyre::eyre::Result<T>;

/// Schema version for RimLoc data outputs (JSON/PO headers).
pub const RIMLOC_SCHEMA_VERSION: u32 = 1;

/// Minimal unit used across crates to represent a single translation entry
/// scanned from RimWorld XML (Keyed/DefInjected) or produced by tools.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransUnit {
    pub key: String,
    /// Source string (may be missing for keys detected without text)
    pub source: Option<String>,
    /// Absolute or relative path to the file where this unit comes from
    pub path: PathBuf,
    /// 1-based line number if available
    pub line: Option<usize>,
}

/// Simple PO entry used by import/export utilities and tests.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PoEntry {
    pub key: String,
    pub value: String,
    /// Optional reference like
    /// "…/Languages/English/Keyed/Some.xml:42" used to reconstruct paths.
    pub reference: Option<String>,
}

/// Keep a lightweight error type for crates that still import it.
#[derive(Debug, Error)]
pub enum RimLocError {
    #[error("{0}")]
    Other(String),
}

/// Parse a minimal subset of PO syntax used across the workspace.
/// Supports single-line `msgid`/`msgstr` pairs and optional reference lines (`#: ...`).
pub fn parse_simple_po(input: &str) -> Result<Vec<PoEntry>> {
    let mut entries = Vec::new();
    let mut cur_ref: Option<String> = None;
    let mut cur_id: Option<String> = None;

    fn unquote(raw: &str) -> String {
        let trimmed = raw.trim();
        if trimmed.starts_with('"') && trimmed.ends_with('"') && trimmed.len() >= 2 {
            trimmed[1..trimmed.len() - 1].to_string()
        } else {
            trimmed.to_string()
        }
    }

    for line in input.lines() {
        let trimmed = line.trim();
        if trimmed.is_empty() {
            continue;
        }
        if let Some(rest) = trimmed.strip_prefix("#:") {
            cur_ref = Some(rest.trim().to_string());
            continue;
        }
        if let Some(rest) = trimmed.strip_prefix("msgid") {
            let eq = rest
                .trim_start()
                .strip_prefix(' ')
                .unwrap_or(rest)
                .trim_start_matches('=');
            cur_id = Some(unquote(eq.trim()));
            continue;
        }
        if let Some(rest) = trimmed.strip_prefix("msgstr") {
            let eq = rest
                .trim_start()
                .strip_prefix(' ')
                .unwrap_or(rest)
                .trim_start_matches('=');
            let val = unquote(eq.trim());
            if let Some(id) = cur_id.take() {
                entries.push(PoEntry {
                    key: id,
                    value: val,
                    reference: cur_ref.take(),
                });
            } else {
                return Err(eyre!("Malformed PO entry: msgstr without msgid"));
            }
        }
    }

    Ok(entries)
}