redguard-preservation 0.6.2

A CLI tool to preserve Redguard files
Documentation
use super::write_wav;
use crate::opts::RtxArgs;
use color_eyre::Result;
use log::info;
use rayon::prelude::*;
use rgpre::import::rtx::{self, RtxEntry};
use serde_json::json;
use std::collections::HashMap;
use std::path::Path;

const MAX_FILENAME_LEN: usize = 80;

fn sanitize_filename(name: &str) -> String {
    let mut result = String::with_capacity(name.len());
    let mut prev_underscore = false;

    for c in name.chars() {
        match c {
            'A'..='Z' => {
                result.push(c.to_ascii_lowercase());
                prev_underscore = false;
            }
            'a'..='z' | '0'..='9' => {
                result.push(c);
                prev_underscore = false;
            }
            ' ' | '_' | '-' if !prev_underscore && !result.is_empty() => {
                result.push('_');
                prev_underscore = true;
            }
            ' ' | '_' | '-' => {}
            _ => {}
        }
    }

    while result.ends_with('_') {
        result.pop();
    }

    if result.len() > MAX_FILENAME_LEN {
        if let Some(pos) = result[..MAX_FILENAME_LEN].rfind('_') {
            result.truncate(pos);
        } else {
            result.truncate(MAX_FILENAME_LEN);
        }
    }

    result
}

fn build_resolved_filenames(entries: &[RtxEntry]) -> Vec<String> {
    let mut counts: HashMap<String, u32> = HashMap::new();
    entries
        .iter()
        .map(|entry| {
            let raw = match entry {
                RtxEntry::Audio { label, .. } => label.as_str(),
                RtxEntry::Text { text, .. } => text.as_str(),
            };

            let base = sanitize_filename(raw);
            if base.is_empty() {
                return entry.tag_str();
            }

            let count = counts.entry(base.clone()).or_insert(0);
            *count += 1;
            if *count == 1 {
                base
            } else {
                format!("{base}_{count}")
            }
        })
        .collect()
}

pub(crate) fn handle_rtx_convert(args: &RtxArgs, output_path: &Path) -> Result<()> {
    let file_content = std::fs::read(&args.io.file)?;
    let rtx_file =
        rtx::parse_rtx_file(&file_content).map_err(|e| color_eyre::eyre::eyre!("{e}"))?;

    std::fs::create_dir_all(output_path)?;

    let resolved = if args.resolve_names {
        Some(build_resolved_filenames(&rtx_file.entries))
    } else {
        None
    };

    let metadata_entries: Vec<serde_json::Value> = rtx_file
        .entries
        .par_iter()
        .enumerate()
        .map(|(i, entry)| {
            let tag_str = entry.tag_str();

            match entry {
                RtxEntry::Text { text, .. } => Ok(json!({
                    "index": i,
                    "tag": tag_str,
                    "type": "text",
                    "text": text,
                })),
                RtxEntry::Audio {
                    label,
                    header,
                    pcm_data,
                    ..
                } => {
                    let wav_stem = resolved
                        .as_ref()
                        .map_or_else(|| tag_str.clone(), |names| names[i].clone());
                    let wav_filename = format!("{wav_stem}.wav");
                    let wav_path = output_path.join(&wav_filename);

                    write_wav(&wav_path, header.audio_type, header.sample_rate, pcm_data)?;

                    info!(
                        "  [{i:04}] '{}' {:?} {}Hz {:.3}s -> {}",
                        tag_str,
                        header.audio_type,
                        header.sample_rate,
                        header.duration_secs(),
                        wav_path.display(),
                    );

                    Ok(json!({
                        "index": i,
                        "tag": tag_str,
                        "type": "audio",
                        "label": label,
                        "audio_type": format!("{:?}", header.audio_type),
                        "sample_rate": header.sample_rate,
                        "duration_secs": header.duration_secs(),
                        "pcm_bytes": pcm_data.len(),
                        "wav_file": wav_filename,
                    }))
                }
            }
        })
        .collect::<Result<Vec<_>>>()?;

    let audio_written = metadata_entries
        .iter()
        .filter(|e| e.get("type").and_then(|t| t.as_str()) == Some("audio"))
        .count();

    let index_path = output_path.join("index.json");
    let index_json = json!({
        "entry_count": rtx_file.entries.len(),
        "audio_count": rtx_file.audio_count(),
        "text_count": rtx_file.text_count(),
        "entries": metadata_entries,
    });
    let json_text = serde_json::to_string_pretty(&index_json)?;
    std::fs::write(&index_path, json_text)?;
    info!("Metadata index written to: {}", index_path.display());

    info!(
        "Extracted {} audio clips + {} text entries to {}",
        audio_written,
        rtx_file.text_count(),
        output_path.display()
    );

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn sanitize_basic_name() {
        assert_eq!(sanitize_filename("SABRE"), "sabre");
    }

    #[test]
    fn sanitize_multi_word() {
        assert_eq!(sanitize_filename("SOUL SWORD"), "soul_sword");
    }

    #[test]
    fn sanitize_apostrophe() {
        assert_eq!(sanitize_filename("SPIDER'S MILK"), "spiders_milk");
    }

    #[test]
    fn sanitize_sentence() {
        assert_eq!(
            sanitize_filename("GET BACK IN YOUR JAR, YOU FILTHY LITTLE THING."),
            "get_back_in_your_jar_you_filthy_little_thing"
        );
    }

    #[test]
    fn sanitize_question_mark() {
        assert_eq!(
            sanitize_filename("WHICH DOOR IS CORRECT?"),
            "which_door_is_correct"
        );
    }

    #[test]
    fn sanitize_empty() {
        assert_eq!(sanitize_filename(""), "");
    }

    #[test]
    fn sanitize_truncates_long_name() {
        let long = "A ".repeat(100);
        let result = sanitize_filename(&long);
        assert!(result.len() <= MAX_FILENAME_LEN);
        assert!(!result.ends_with('_'));
    }

    #[test]
    fn dedup_identical_names() {
        use rgpre::import::rtx::RtxEntry;

        let entries = vec![
            RtxEntry::Text {
                tag: *b"xr01",
                text: "RUNE".to_string(),
            },
            RtxEntry::Text {
                tag: *b"xr02",
                text: "RUNE".to_string(),
            },
            RtxEntry::Text {
                tag: *b"xr03",
                text: "RUNE".to_string(),
            },
        ];
        let names = build_resolved_filenames(&entries);
        assert_eq!(names[0], "rune");
        assert_eq!(names[1], "rune_2");
        assert_eq!(names[2], "rune_3");
    }
}