use std::path::{Path, PathBuf};
use crate::bincue::{BinTrack, TrackType};
use crate::error::{OpticaldiscsError, Result};
const RAW_SECTOR: u64 = 2352;
pub fn parse_ccd(ccd_path: &Path) -> Result<Vec<BinTrack>> {
let text = std::fs::read_to_string(ccd_path).map_err(OpticaldiscsError::Io)?;
let img_path = resolve_img_path(ccd_path)?;
let sections = parse_ini_sections(&text);
let mut raw: Vec<(u32, u32, u64)> = Vec::new();
let mut leadout: Option<u64> = None;
for (header, kv) in §ions {
let mut parts = header.split_whitespace();
match parts.next() {
Some("TRACK") => {
let Some(num) = parts.next().and_then(|n| n.parse::<u32>().ok()) else {
continue;
};
let mode = get(kv, "MODE")
.and_then(|v| v.parse::<u32>().ok())
.unwrap_or(1);
let index1 = get(kv, "INDEX 1")
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(0);
raw.push((num, mode, index1));
}
Some("Entry") => {
if let Some(point) = get(kv, "Point").and_then(parse_int) {
if point == 0xA2 {
if let Some(plba) = get(kv, "PLBA").and_then(|v| v.parse::<u64>().ok()) {
leadout = Some(plba);
}
}
}
}
_ => {}
}
}
if raw.is_empty() {
return Err(OpticaldiscsError::Parse(
"no tracks in .ccd descriptor".into(),
));
}
raw.sort_by_key(|t| t.0);
let mut tracks = Vec::with_capacity(raw.len());
for (i, &(num, mode, index1)) in raw.iter().enumerate() {
let next_start = raw.get(i + 1).map(|t| t.2).or(leadout);
let frame_count = next_start.map(|n| n.saturating_sub(index1)).unwrap_or(0);
tracks.push(BinTrack {
track_no: num,
track_type: mode_to_track_type(mode),
bin_path: img_path.clone(),
file_byte_offset: index1 * RAW_SECTOR,
frame_count,
});
}
Ok(tracks)
}
fn mode_to_track_type(mode: u32) -> TrackType {
match mode {
1 => TrackType::Mode1Raw,
2 => TrackType::Mode2Form1,
_ => TrackType::Audio,
}
}
fn resolve_img_path(ccd_path: &Path) -> Result<PathBuf> {
for ext in ["img", "IMG", "Img"] {
let cand = ccd_path.with_extension(ext);
if cand.exists() {
return Ok(cand);
}
}
let stem = ccd_path.file_stem().unwrap_or_default();
if let Some(dir) = ccd_path.parent() {
if let Ok(rd) = std::fs::read_dir(dir) {
for entry in rd.flatten() {
let p = entry.path();
let ext_ok = p
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("img"));
if ext_ok && p.file_stem() == Some(stem) {
return Ok(p);
}
}
}
}
Err(OpticaldiscsError::NotFound(format!(
"no matching .img found for {}",
ccd_path.display()
)))
}
fn parse_int(s: &str) -> Option<u32> {
let s = s.trim();
if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) {
u32::from_str_radix(hex, 16).ok()
} else {
s.parse().ok()
}
}
fn get<'a>(kv: &'a [(String, String)], key: &str) -> Option<&'a str> {
kv.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(key))
.map(|(_, v)| v.as_str())
}
fn parse_ini_sections(text: &str) -> Vec<(String, Vec<(String, String)>)> {
let mut out: Vec<(String, Vec<(String, String)>)> = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Some(header) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
out.push((header.trim().to_string(), Vec::new()));
} else if let Some((k, v)) = line.split_once('=') {
if let Some((_, kv)) = out.last_mut() {
kv.push((k.trim().to_string(), v.trim().to_string()));
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn write_pair(dir: &Path, stem: &str, ccd: &str, img_sectors: usize) -> PathBuf {
let ccd_path = dir.join(format!("{stem}.ccd"));
std::fs::write(&ccd_path, ccd).unwrap();
std::fs::write(
dir.join(format!("{stem}.img")),
vec![0u8; img_sectors * RAW_SECTOR as usize],
)
.unwrap();
ccd_path
}
#[test]
fn parses_single_data_track() {
let ccd = "[CloneCD]\nVersion=3\n[Disc]\nTocEntries=4\nSessions=1\n\
[Entry 2]\nPoint=0xa2\nPLBA=309313\n\
[TRACK 1]\nMODE=1\nINDEX 1=0\n";
let dir = tempfile::tempdir().unwrap();
let ccd_path = write_pair(dir.path(), "GAME", ccd, 4);
let tracks = parse_ccd(&ccd_path).unwrap();
assert_eq!(tracks.len(), 1);
let t = &tracks[0];
assert_eq!(t.track_no, 1);
assert_eq!(t.track_type, TrackType::Mode1Raw);
assert_eq!(t.file_byte_offset, 0);
assert_eq!(t.frame_count, 309313);
assert!(t.is_data());
assert!(t.bin_path.ends_with("GAME.img"));
}
#[test]
fn mixed_mode_data_then_audio() {
let ccd = "[Entry 3]\nPoint=0xa2\nPLBA=10000\n\
[TRACK 1]\nMODE=2\nINDEX 1=0\n\
[TRACK 2]\nMODE=0\nINDEX 0=7613\nINDEX 1=7624\n";
let dir = tempfile::tempdir().unwrap();
let ccd_path = write_pair(dir.path(), "MIX", ccd, 10000);
let tracks = parse_ccd(&ccd_path).unwrap();
assert_eq!(tracks.len(), 2);
assert_eq!(tracks[0].track_type, TrackType::Mode2Form1);
assert_eq!(tracks[0].file_byte_offset, 0);
assert_eq!(tracks[0].frame_count, 7624);
assert!(!tracks[1].is_data());
assert_eq!(tracks[1].file_byte_offset, 7624 * RAW_SECTOR);
assert_eq!(tracks[1].frame_count, 10000 - 7624);
let data = tracks.iter().find(|t| t.is_data()).unwrap();
assert_eq!(data.track_no, 1);
}
#[test]
fn missing_img_errors() {
let dir = tempfile::tempdir().unwrap();
let ccd_path = dir.path().join("noimg.ccd");
std::fs::write(&ccd_path, "[TRACK 1]\nMODE=1\nINDEX 1=0\n").unwrap();
assert!(parse_ccd(&ccd_path).is_err());
}
}