use std::path::Path;
use super::Section;
const SCORE_BLOCK_SIZE: usize = 5;
const MAX_INITIALS_LEN: usize = 12;
#[derive(Debug, PartialEq, Eq)]
pub enum LookupError {
PatternNotFound,
ReadFailed(String),
}
pub fn read_sections(path: &Path) -> Result<Vec<Section>, LookupError> {
let bytes = std::fs::read(path).map_err(|e| LookupError::ReadFailed(e.to_string()))?;
let raw = String::from_utf8_lossy(&bytes);
extract_sections_from_text(&raw)
}
const MIN_SINGLE_HISC_LINES: usize = 4;
fn extract_sections_from_text(text: &str) -> Result<Vec<Section>, LookupError> {
let lines: Vec<&str> = text
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.collect();
if let Some(sections) = try_score_block(&lines) {
return Ok(sections);
}
if let Some(sections) = try_single_hisc(&lines) {
return Ok(sections);
}
Err(LookupError::PatternNotFound)
}
fn try_score_block(lines: &[&str]) -> Option<Vec<Section>> {
let (scores, names) = find_score_block(lines)?;
let rows: Vec<Vec<String>> = scores
.iter()
.zip(names.iter())
.enumerate()
.filter(|(_, (score, _))| **score != 0)
.map(|(i, (score, name))| {
vec![
format!("#{}", i + 1),
(*name).to_string(),
score.to_string(),
String::new(),
]
})
.collect();
if rows.is_empty() {
return None;
}
let ranked = rows.len() > 1;
Some(vec![Section {
header: "HIGH SCORES".to_string(),
rows,
ranked,
}])
}
fn try_single_hisc(lines: &[&str]) -> Option<Vec<Section>> {
if lines.len() < MIN_SINGLE_HISC_LINES {
return None;
}
let ints: Vec<u64> = lines
.iter()
.map(|l| l.parse::<u64>().ok())
.collect::<Option<Vec<_>>>()?;
let max = *ints.iter().max()?;
if max == 0 {
return None;
}
Some(vec![Section {
header: "HIGH SCORE".to_string(),
rows: vec![vec![
"HIGH SCORE".to_string(),
String::new(),
max.to_string(),
String::new(),
]],
ranked: false,
}])
}
fn find_score_block<'a>(lines: &[&'a str]) -> Option<(Vec<u64>, Vec<&'a str>)> {
if lines.len() < SCORE_BLOCK_SIZE * 2 {
return None;
}
let max_start = lines.len() - SCORE_BLOCK_SIZE * 2;
for start in 0..=max_start {
let score_window = &lines[start..start + SCORE_BLOCK_SIZE];
let names_window = &lines[start + SCORE_BLOCK_SIZE..start + SCORE_BLOCK_SIZE * 2];
let Some(scores) = score_window
.iter()
.map(|l| l.parse::<u64>().ok())
.collect::<Option<Vec<_>>>()
else {
continue;
};
if !names_window.iter().all(|n| looks_like_initials(n)) {
continue;
}
let names: Vec<&str> = names_window.to_vec();
return Some((scores, names));
}
None
}
fn looks_like_initials(s: &str) -> bool {
if s.is_empty() || s.len() > MAX_INITIALS_LEN {
return false;
}
if s.parse::<u64>().is_ok() {
return false;
}
s.chars().all(|c| c.is_ascii_graphic() || c == ' ')
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn drops_zero_scored_slots() {
let text = "100\n90\n80\n0\n0\nAAA\nBBB\nCCC\nDDD\nEEE\n";
let sections = extract_sections_from_text(text).expect("section");
assert_eq!(sections[0].rows.len(), 3);
let names: Vec<&str> = sections[0].rows.iter().map(|r| r[1].as_str()).collect();
assert_eq!(names, vec!["AAA", "BBB", "CCC"]);
}
#[test]
fn read_sections_treats_non_utf8_readme_as_pattern_not_found() {
let dir = std::env::temp_dir().join(format!("vpxtool-emhs-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("mkdir tmp");
let path = dir.join("readme_cp1252.txt");
let bytes: &[u8] = b"Welcome to the world\x92s most famous table.\nInstructions follow.\n";
std::fs::write(&path, bytes).expect("write fixture");
let err = read_sections(&path).expect_err("non-utf8 readme should not parse");
assert_eq!(err, LookupError::PatternNotFound);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_dir(&dir);
}
#[test]
fn returns_pattern_not_found_when_no_block() {
let text =
"This is a readme file.\nNothing here looks like a score.\nLine three.\nLine four.\n";
let err = extract_sections_from_text(text).expect_err("should not match");
assert_eq!(err, LookupError::PatternNotFound);
}
#[test]
fn returns_pattern_not_found_when_all_zero_scored() {
let text = "0\n0\n0\n0\n0\nAAA\nBBB\nCCC\nDDD\nEEE\n";
let err = extract_sections_from_text(text).expect_err("should not match");
assert_eq!(err, LookupError::PatternNotFound);
}
#[test]
fn finds_first_matching_window_when_multiple_could_overlap() {
let text = "10\n20\n30\n40\n50\n60\n70\n80\n90\n100\nAAA\nBBB\nCCC\nDDD\nEEE\n";
let sections = extract_sections_from_text(text).expect("section");
assert_eq!(sections[0].rows[0][2], "60");
assert_eq!(sections[0].rows[4][2], "100");
}
#[test]
fn rejects_window_where_names_look_too_long() {
let text = "100\n90\n80\n70\n60\n\
OneSentenceLongerThanInitials\n\
TwoSentenceLongerThanInitials\n\
ThreeSentenceLongerThanInitials\n\
FourSentenceLongerThanInitials\n\
FiveSentenceLongerThanInitials\n";
let err = extract_sections_from_text(text).expect_err("should not match");
assert_eq!(err, LookupError::PatternNotFound);
}
#[test]
fn single_hisc_rejects_short_files() {
let text = "12\n0\n1\n";
let err = extract_sections_from_text(text).expect_err("too short");
assert_eq!(err, LookupError::PatternNotFound);
}
#[test]
fn single_hisc_rejects_all_zero_files() {
let text = "0\n0\n0\n0\n0\n";
let err = extract_sections_from_text(text).expect_err("all zero");
assert_eq!(err, LookupError::PatternNotFound);
}
#[test]
fn single_hisc_rejects_files_with_any_string_line() {
let text = "12\n0\nSomeReadme\n1000\n";
let err = extract_sections_from_text(text).expect_err("has prose");
assert_eq!(err, LookupError::PatternNotFound);
}
#[test]
fn rejects_window_where_names_are_all_digits() {
let text = "100\n90\n80\n70\n60\n1\n2\n3\n4\n5\nGARBAGE\n";
let err = extract_sections_from_text(text).expect_err("should not match");
assert_eq!(err, LookupError::PatternNotFound);
}
}