chdlady-disc 0.2.2

Disc geometries and physical media formats for chdlady
//! Core type definitions for disc geometry, track layout, and MSF addressing.
use crate::error::DiscError;
use std::fmt;

/// Bytes per raw CD sector frame data.
pub const CD_FRAME_SIZE: usize = 2352;

/// Bytes per CD subcode channel frame.
pub const CD_SUBCODE_SIZE: usize = 96;

/// Total bytes per complete CHD optical frame (data + subcode).
pub const CD_TOTAL_FRAME_SIZE: usize = CD_FRAME_SIZE + CD_SUBCODE_SIZE;

/// CD frame playback rate per second (75 Hz).
pub const CD_FRAMES_PER_SECOND: u32 = 75;

/// CD-ROM track metadata tag v2 (`b"CHT2"`).
pub const CDROM_TRACK_METADATA2_TAG: u32 = u32::from_be_bytes(*b"CHT2");

/// CD-ROM track metadata tag v1 (`b"CHTR"`).
pub const CDROM_TRACK_METADATA_TAG: u32 = u32::from_be_bytes(*b"CHTR");

/// Old CD-ROM track metadata tag (`b"CHCD"`).
pub const CDROM_OLD_METADATA_TAG: u32 = u32::from_be_bytes(*b"CHCD");

/// GD-ROM track metadata tag (`b"CHGT"`).
pub const GDROM_TRACK_METADATA_TAG: u32 = u32::from_be_bytes(*b"CHGT");

/// Old GD-ROM track metadata tag (`b"CHGD"`).
pub const GDROM_OLD_METADATA_TAG: u32 = u32::from_be_bytes(*b"CHGD");

/// Hard disk metadata tag (`b"GDDD"`).
pub const HARD_DISK_METADATA_TAG: u32 = u32::from_be_bytes(*b"GDDD");

/// Hard disk identify metadata tag (`b"IDNT"`).
pub const HARD_DISK_IDENT_METADATA_TAG: u32 = u32::from_be_bytes(*b"IDNT");

/// DVD media metadata tag (`b"DVD "`).
pub const DVD_METADATA_TAG: u32 = u32::from_be_bytes(*b"DVD ");

/// The optical disc physical type.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DiscType {
    /// Standard Compact Disc (CD-ROM / CD-DA).
    #[default]
    Cd,
    /// Sega Dreamcast Gigabyte Disc (GD-ROM).
    GdRom,
}

/// Optical disc track format type.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CdTrackType {
    /// Mode 1 computer data (2048 bytes user data).
    Mode1,
    /// Mode 1 raw sector (2352 bytes).
    Mode1Raw,
    /// Mode 2 computer data (2336 bytes user data).
    Mode2,
    /// Mode 2 Form 1 CD-ROM XA (2048 bytes user data).
    Mode2Form1,
    /// Mode 2 Form 2 CD-ROM XA (2324 bytes user data).
    Mode2Form2,
    /// Mode 2 mixed form data (2336 bytes).
    Mode2FormMix,
    /// Mode 2 raw sector (2352 bytes).
    Mode2Raw,
    /// 16-bit 44.1 kHz stereo audio (2352 bytes).
    Audio,
}

impl CdTrackType {
    /// Returns the data payload size in bytes per sector for this track type.
    pub fn sector_size(&self) -> usize {
        match self {
            Self::Mode1 => 2048,
            Self::Mode1Raw => 2352,
            Self::Mode2 => 2336,
            Self::Mode2Form1 => 2048,
            Self::Mode2Form2 => 2324,
            Self::Mode2FormMix => 2336,
            Self::Mode2Raw => 2352,
            Self::Audio => 2352,
        }
    }

    /// Returns the type identifier string stored in CHD metadata.
    pub fn chd_metadata_type_str(&self) -> &'static str {
        match self {
            Self::Mode1 => "MODE1",
            Self::Mode1Raw => "MODE1_RAW",
            Self::Mode2 => "MODE2",
            Self::Mode2Form1 => "MODE2_FORM1",
            Self::Mode2Form2 => "MODE2_FORM2",
            Self::Mode2FormMix => "MODE2_FORM_MIX",
            Self::Mode2Raw => "MODE2_RAW",
            Self::Audio => "AUDIO",
        }
    }

    /// Returns the CUE sheet track mode string representation.
    pub fn cue_mode_str(&self) -> &'static str {
        match self {
            Self::Mode1 => "MODE1/2048",
            Self::Mode1Raw => "MODE1/2352",
            Self::Mode2 => "MODE2/2336",
            Self::Mode2Form1 => "MODE2/2048",
            Self::Mode2Form2 => "MODE2/2324",
            Self::Mode2FormMix => "MODE2/2336",
            Self::Mode2Raw => "MODE2/2352",
            Self::Audio => "AUDIO",
        }
    }

    /// Returns true if this track contains digital audio samples.
    pub fn is_audio(&self) -> bool {
        matches!(self, Self::Audio)
    }

    /// Parses a track mode string from a CUE sheet, GDI file, or CHD metadata.
    pub fn parse_cue_or_metadata(s: &str) -> Result<Self, DiscError> {
        let trimmed = s.trim();
        match trimmed {
            "MODE1" | "VMODE1" | "MODE1/2048" => Ok(Self::Mode1),
            "MODE1_RAW" | "VMODE1_RAW" | "MODE1/2352" => Ok(Self::Mode1Raw),
            "MODE2" | "VMODE2" | "MODE2/2336" => Ok(Self::Mode2),
            "MODE2_FORM1" | "VMODE2_FORM1" | "MODE2/2048" => Ok(Self::Mode2Form1),
            "MODE2_FORM2" | "VMODE2_FORM2" | "/2324" | "MODE2/2324" => Ok(Self::Mode2Form2),
            "MODE2_FORM_MIX" | "VMODE2_FORM_MIX" => Ok(Self::Mode2FormMix),
            "MODE2_RAW" | "VMODE2_RAW" | "MODE2/2352" | "CDI/2352" => Ok(Self::Mode2Raw),
            "AUDIO" | "VAUDIO" => Ok(Self::Audio),
            other => Err(DiscError::UnsupportedTrackType(other.to_string())),
        }
    }
}

/// Optical disc subcode encoding format.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CdSubcodeType {
    /// No separate subcode stream stored.
    #[default]
    None,
    /// Packed 96-byte R-W subcode.
    Rw,
    /// Raw 96-byte R-W subcode.
    RwRaw,
}

impl CdSubcodeType {
    /// Returns the metadata string representation for this subcode format.
    pub fn as_str(&self) -> &'static str {
        match self {
            Self::None => "NONE",
            Self::Rw => "RW",
            Self::RwRaw => "RW_RAW",
        }
    }

    /// Parses a subcode format string.
    pub fn parse(s: &str) -> Result<Self, DiscError> {
        match s.trim() {
            "NONE" => Ok(Self::None),
            "RW" => Ok(Self::Rw),
            "RW_RAW" => Ok(Self::RwRaw),
            other => Err(DiscError::InvalidMetadata(format!(
                "unknown subcode type: {}",
                other
            ))),
        }
    }
}

/// Minutes, Seconds, and Frames (MSF) optical disc timestamp.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
pub struct Msf {
    /// Minutes component (0..=99).
    pub m: u8,
    /// Seconds component (0..=59).
    pub s: u8,
    /// Frames component (0..=74).
    pub f: u8,
}

impl Msf {
    /// Creates a new MSF timestamp.
    pub fn new(m: u8, s: u8, f: u8) -> Self {
        Self { m, s, f }
    }

    /// Converts a linear block address (LBA) to an MSF timestamp.
    pub fn from_lba(lba: u32) -> Self {
        let f = (lba % CD_FRAMES_PER_SECOND) as u8;
        let total_s = lba / CD_FRAMES_PER_SECOND;
        let s = (total_s % 60) as u8;
        let m = (total_s / 60) as u8;
        Self { m, s, f }
    }

    /// Converts an MSF timestamp to a linear block address (LBA).
    pub fn to_lba(&self) -> u32 {
        ((self.m as u32 * 60) + self.s as u32) * CD_FRAMES_PER_SECOND + self.f as u32
    }

    /// Parses an MSF string formatted as `"MM:SS:FF"`.
    pub fn parse(s: &str) -> Result<Self, DiscError> {
        let parts: Vec<&str> = s.trim().split(':').collect();
        if parts.len() != 3 {
            return Err(DiscError::InvalidCue(format!(
                "invalid MSF format: '{}'",
                s
            )));
        }
        let m = parts[0]
            .parse::<u8>()
            .map_err(|_| DiscError::InvalidCue(format!("invalid minute in MSF: '{}'", s)))?;
        let s_val = parts[1]
            .parse::<u8>()
            .map_err(|_| DiscError::InvalidCue(format!("invalid second in MSF: '{}'", s)))?;
        let f = parts[2]
            .parse::<u8>()
            .map_err(|_| DiscError::InvalidCue(format!("invalid frame in MSF: '{}'", s)))?;
        Ok(Self { m, s: s_val, f })
    }
}

impl fmt::Display for Msf {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{:02}:{:02}:{:02}", self.m, self.s, self.f)
    }
}

/// Metadata and physical layout for a single optical disc track.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CdTrack {
    /// Track sequence number (1-indexed, 1..=99).
    pub track_num: u32,
    /// Track data format.
    pub track_type: CdTrackType,
    /// Subcode format.
    pub subcode_type: CdSubcodeType,
    /// Total duration of the track in frames (sectors).
    pub frames: u32,
    /// Number of frames padded at the end of the track (used in GD-ROM).
    pub pad_frames: u32,
    /// Pregap frames before track data (INDEX 00 duration).
    pub pregap_frames: u32,
    /// Pregap track type if distinct from track data.
    pub pregap_type: Option<CdTrackType>,
    /// Pregap subcode type if distinct.
    pub pregap_subcode: Option<CdSubcodeType>,
    /// Postgap frames after track data.
    pub postgap_frames: u32,
    /// Name of external file holding track sectors, if any.
    pub file_name: Option<String>,
    /// Starting byte offset within the external file.
    pub file_offset: u64,
    /// Name of external file holding separate track subcode, if any.
    pub sub_file_name: Option<String>,
    /// Starting byte offset within the external subcode file.
    pub sub_file_offset: u64,
}

impl CdTrack {
    /// Creates a new track with default zero gaps and no padding.
    pub fn new(track_num: u32, track_type: CdTrackType, frames: u32) -> Self {
        Self {
            track_num,
            track_type,
            subcode_type: CdSubcodeType::None,
            frames,
            pad_frames: 0,
            pregap_frames: 0,
            pregap_type: None,
            pregap_subcode: None,
            postgap_frames: 0,
            file_name: None,
            file_offset: 0,
            sub_file_name: None,
            sub_file_offset: 0,
        }
    }
}

/// Table of Contents (TOC) representing an entire optical disc layout.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct CdToc {
    /// Disc media classification (CD or GD-ROM).
    pub disc_type: DiscType,
    /// Ordered list of tracks.
    pub tracks: Vec<CdTrack>,
}

impl CdToc {
    /// Creates a new empty TOC.
    pub fn new(disc_type: DiscType) -> Self {
        Self {
            disc_type,
            tracks: Vec::new(),
        }
    }

    /// Returns the total frame count across all tracks in the disc.
    pub fn total_frames(&self) -> u32 {
        self.tracks.iter().map(|t| t.frames).sum()
    }

    /// Returns the total logical byte size of the disc when stored in CHD container.
    pub fn total_logical_bytes(&self) -> u64 {
        self.total_frames() as u64 * CD_TOTAL_FRAME_SIZE as u64
    }

    /// Finds a track by its 1-indexed track number.
    pub fn find_track(&self, track_num: u32) -> Option<&CdTrack> {
        self.tracks.iter().find(|t| t.track_num == track_num)
    }
}

/// Validates that a filename from a descriptor does not attempt path traversal or specify an absolute path.
pub fn validate_descriptor_path(path_str: &str) -> Result<(), DiscError> {
    let p = std::path::Path::new(path_str);
    if p.is_absolute()
        || p.components()
            .any(|c| matches!(c, std::path::Component::ParentDir))
    {
        return Err(DiscError::InvalidMetadata(format!(
            "path traversal or absolute path not allowed in descriptor: '{}'",
            path_str
        )));
    }
    Ok(())
}

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

    #[test]
    fn test_parse_cue_or_metadata_standard_and_v_prefixed() {
        let cases = [
            ("MODE1", CdTrackType::Mode1),
            ("VMODE1", CdTrackType::Mode1),
            ("MODE1/2048", CdTrackType::Mode1),
            ("MODE1_RAW", CdTrackType::Mode1Raw),
            ("VMODE1_RAW", CdTrackType::Mode1Raw),
            ("MODE1/2352", CdTrackType::Mode1Raw),
            ("MODE2", CdTrackType::Mode2),
            ("VMODE2", CdTrackType::Mode2),
            ("MODE2/2336", CdTrackType::Mode2),
            ("MODE2_FORM1", CdTrackType::Mode2Form1),
            ("VMODE2_FORM1", CdTrackType::Mode2Form1),
            ("MODE2/2048", CdTrackType::Mode2Form1),
            ("MODE2_FORM2", CdTrackType::Mode2Form2),
            ("VMODE2_FORM2", CdTrackType::Mode2Form2),
            ("/2324", CdTrackType::Mode2Form2),
            ("MODE2/2324", CdTrackType::Mode2Form2),
            ("MODE2_FORM_MIX", CdTrackType::Mode2FormMix),
            ("VMODE2_FORM_MIX", CdTrackType::Mode2FormMix),
            ("MODE2_RAW", CdTrackType::Mode2Raw),
            ("VMODE2_RAW", CdTrackType::Mode2Raw),
            ("MODE2/2352", CdTrackType::Mode2Raw),
            ("CDI/2352", CdTrackType::Mode2Raw),
            ("AUDIO", CdTrackType::Audio),
            ("VAUDIO", CdTrackType::Audio),
        ];

        for (input, expected) in cases {
            assert_eq!(
                CdTrackType::parse_cue_or_metadata(input).expect("parse track type"),
                expected,
                "failed for input: {}",
                input
            );
        }

        assert!(matches!(
            CdTrackType::parse_cue_or_metadata("UNKNOWN_TYPE"),
            Err(DiscError::UnsupportedTrackType(_))
        ));
    }
}