chdlady-disc 0.2.2

Disc geometries and physical media formats for chdlady
//! GDI (GD-ROM) disc descriptor parser and formatter.
use crate::error::DiscError;
use crate::types::{CdToc, CdTrack, CdTrackType, DiscType};
use std::path::Path;

/// Parser and generator for Sega Dreamcast GDI disc descriptors.
#[derive(Debug, Default)]
pub struct GdiSheet;

impl GdiSheet {
    /// Parses a GDI descriptor string.
    pub fn parse(content: &str, parent_dir: Option<&Path>) -> Result<CdToc, DiscError> {
        let lines: Vec<&str> = content
            .lines()
            .map(|l| l.trim())
            .filter(|l| !l.is_empty())
            .collect();

        if lines.is_empty() {
            return Err(DiscError::InvalidGdi("empty GDI file".to_string()));
        }

        let track_count: usize = lines[0].parse().map_err(|_| {
            DiscError::InvalidGdi(format!("invalid track count header: '{}'", lines[0]))
        })?;

        if lines.len() - 1 < track_count {
            return Err(DiscError::InvalidGdi(format!(
                "expected {} tracks in GDI, found {}",
                track_count,
                lines.len() - 1
            )));
        }

        struct RawGdiEntry {
            track_num: u32,
            start_lba: u32,
            track_type_code: u32,
            sector_size: usize,
            filename: String,
            offset: u64,
        }

        let mut raw_entries = Vec::new();
        for &line in &lines[1..=track_count] {
            let parts = tokenize_gdi_line(line)?;
            if parts.len() < 6 {
                return Err(DiscError::InvalidGdi(format!(
                    "invalid GDI track line: '{}'",
                    line
                )));
            }

            let track_num = parts[0]
                .parse::<u32>()
                .map_err(|_| DiscError::InvalidGdi(format!("invalid track num: '{}'", parts[0])))?;
            let start_lba = parts[1]
                .parse::<u32>()
                .map_err(|_| DiscError::InvalidGdi(format!("invalid start LBA: '{}'", parts[1])))?;
            let track_type_code = parts[2].parse::<u32>().map_err(|_| {
                DiscError::InvalidGdi(format!("invalid track type code: '{}'", parts[2]))
            })?;
            let sector_size = parts[3].parse::<usize>().map_err(|_| {
                DiscError::InvalidGdi(format!("invalid sector size: '{}'", parts[3]))
            })?;
            if sector_size == 0 {
                return Err(DiscError::InvalidGdi(
                    "sector size must be non-zero".to_string(),
                ));
            }
            let filename = parts[4].clone();
            crate::types::validate_descriptor_path(&filename)?;
            let offset = parts[5].parse::<u64>().map_err(|_| {
                DiscError::InvalidGdi(format!("invalid byte offset: '{}'", parts[5]))
            })?;

            raw_entries.push(RawGdiEntry {
                track_num,
                start_lba,
                track_type_code,
                sector_size,
                filename,
                offset,
            });
        }

        let mut tracks = Vec::new();
        for i in 0..raw_entries.len() {
            let entry = &raw_entries[i];
            let t_type = match (entry.track_type_code, entry.sector_size) {
                (0, _) => CdTrackType::Audio,
                (4, 2048) => CdTrackType::Mode1,
                (4, _) => CdTrackType::Mode1Raw,
                (code, _) => {
                    return Err(DiscError::UnsupportedTrackType(format!(
                        "unsupported GDI track type code: {}",
                        code
                    )));
                }
            };

            // Measure actual file frame count
            let file_frames = if let Some(parent) = parent_dir {
                let p = parent.join(&entry.filename);
                let bytes = std::fs::metadata(&p)?.len();
                let available = bytes.saturating_sub(entry.offset);
                (available / entry.sector_size as u64) as u32
            } else {
                0
            };

            // Calculate track span (including padding if followed by a gap to next track)
            let total_frames = if i + 1 < raw_entries.len() {
                raw_entries[i + 1].start_lba.saturating_sub(entry.start_lba)
            } else {
                file_frames
            };

            let pad_frames = total_frames.saturating_sub(file_frames);

            let mut track = CdTrack::new(entry.track_num, t_type, total_frames);
            track.pad_frames = pad_frames;
            track.file_name = Some(entry.filename.clone());
            track.file_offset = entry.offset;
            tracks.push(track);
        }

        Ok(CdToc {
            disc_type: DiscType::GdRom,
            tracks,
        })
    }

    /// Formats a `CdToc` into GDI descriptor text.
    pub fn format(toc: &CdToc, base_name: &str) -> String {
        let mut out = String::new();
        out.push_str(&format!("{}\n", toc.tracks.len()));

        let mut current_lba: u32 = 0;
        for track in &toc.tracks {
            let is_audio = track.track_type.is_audio();
            let type_code = if is_audio { 0 } else { 4 };
            let sector_size = track.track_type.sector_size();
            let ext = if is_audio { "raw" } else { "bin" };
            let filename = format!("{}{:02}.{}", base_name, track.track_num, ext);
            let quoted_filename = if filename.contains(' ') {
                format!("\"{}\"", filename)
            } else {
                filename
            };

            out.push_str(&format!(
                "{} {} {} {} {} 0\n",
                track.track_num, current_lba, type_code, sector_size, quoted_filename
            ));

            current_lba += track.frames;
        }

        out
    }
}

/// Tokenizes a single line from a GDI file, handling quoted filenames with spaces.
fn tokenize_gdi_line(line: &str) -> Result<Vec<String>, DiscError> {
    let mut tokens = Vec::new();
    let mut current = String::new();
    let mut in_single_quote = false;
    let mut in_double_quote = false;

    for ch in line.chars() {
        if !in_single_quote && ch == '"' {
            in_double_quote = !in_double_quote;
        } else if !in_double_quote && ch == '\'' {
            in_single_quote = !in_single_quote;
        } else if !in_single_quote && !in_double_quote && ch.is_whitespace() {
            if !current.is_empty() {
                tokens.push(std::mem::take(&mut current));
            }
        } else {
            current.push(ch);
        }
    }

    if in_single_quote || in_double_quote {
        return Err(DiscError::InvalidGdi(format!(
            "unclosed quote in GDI track line: '{}'",
            line
        )));
    }

    if !current.is_empty() {
        tokens.push(current);
    }
    Ok(tokens)
}

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

    #[test]
    fn test_gdi_format() {
        let mut toc = CdToc::new(DiscType::GdRom);
        let t1 = CdTrack::new(1, CdTrackType::Mode1Raw, 4);
        let mut t2 = CdTrack::new(2, CdTrackType::Audio, 44996);
        t2.pad_frames = 44992;
        let t3 = CdTrack::new(3, CdTrackType::Mode1Raw, 4);
        toc.tracks.push(t1);
        toc.tracks.push(t2);
        toc.tracks.push(t3);

        let gdi = GdiSheet::format(&toc, "out");
        assert_eq!(
            gdi,
            "3\n1 0 4 2352 out01.bin 0\n2 4 0 2352 out02.raw 0\n3 45000 4 2352 out03.bin 0\n"
        );
    }

    #[test]
    fn test_gdi_tokenize_quoted() {
        let line = " 1      0 4 2352 \"4x4 Evo (USA) (Track 01).bin\" 0";
        let tokens = tokenize_gdi_line(line).unwrap();
        assert_eq!(
            tokens,
            vec!["1", "0", "4", "2352", "4x4 Evo (USA) (Track 01).bin", "0"]
        );
    }

    #[test]
    fn test_gdi_parse_quoted() {
        let gdi = "3\n1 0 4 2352 \"track 01.bin\" 0\n2 450 0 2352 'track 02.raw' 0\n3 45000 4 2352 track03.bin 0\n";
        let toc = GdiSheet::parse(gdi, None).unwrap();
        assert_eq!(toc.tracks.len(), 3);
        assert_eq!(toc.tracks[0].file_name.as_deref(), Some("track 01.bin"));
        assert_eq!(toc.tracks[1].file_name.as_deref(), Some("track 02.raw"));
        assert_eq!(toc.tracks[2].file_name.as_deref(), Some("track03.bin"));
    }
}