Skip to main content

wowlab_parsers/parsers/dbc/
gametable.rs

1// #t(file: rust_alloc_in_loop) game table parsing builds per-row value vectors and column names
2
3//! `WoW` client `GameTable` loader (tab-delimited scaling-curve `*.txt` files).
4
5use wowlab_fs::{directory, file, path::Path};
6
7use super::super::errors::DbcError;
8
9/// A parsed `GameTable` as a raw string grid.
10#[derive(Clone, Debug)]
11pub struct GameTable {
12    pub name: String,
13    pub columns: Vec<String>,
14    pub rows: Vec<Vec<String>>,
15}
16
17pub(super) fn parse_game_table(name: &str, text: &str) -> Result<GameTable, DbcError> {
18    let mut reader = csv::ReaderBuilder::new()
19        .delimiter(b'\t')
20        .flexible(true)
21        .trim(csv::Trim::All)
22        .from_reader(text.as_bytes());
23
24    let columns: Vec<String> = reader
25        .headers()
26        .map_err(|error| DbcError::csv_parse(name.to_string(), error))?
27        .iter()
28        .map(String::from)
29        .collect();
30
31    let mut rows = Vec::new();
32
33    for record in reader.records() {
34        let record = record.map_err(|error| DbcError::csv_parse(name.to_string(), error))?;
35
36        rows.push(record.iter().map(String::from).collect());
37    }
38
39    Ok(GameTable {
40        name: name.to_string(),
41        columns,
42        rows,
43    })
44}
45
46// Empty files are skipped: deprecated tables ship as zero-byte CASC entries.
47pub(crate) fn load_game_tables(tables_dir: &Path) -> Result<Vec<GameTable>, DbcError> {
48    let Ok(entries) = directory::entries(tables_dir) else {
49        return Ok(Vec::new());
50    };
51
52    let mut tables = Vec::new();
53
54    for entry in entries {
55        let path = entry.path();
56
57        if path.extension().and_then(|ext| ext.to_str()) != Some("txt") {
58            continue;
59        }
60
61        let Some(name) = path.file_stem().and_then(|stem| stem.to_str()) else {
62            continue;
63        };
64
65        let text = file::read_text(path)?;
66
67        if text.trim().is_empty() {
68            continue;
69        }
70
71        tables.push(parse_game_table(name, &text)?);
72    }
73
74    tables.sort_by(|a, b| a.name.cmp(&b.name));
75
76    Ok(tables)
77}
78
79#[cfg(test)]
80mod tests {
81    use googletest::prelude::*;
82    use insta::assert_debug_snapshot;
83
84    use super::*;
85
86    #[gtest]
87    fn parses_header_and_two_rows() -> Result<()> {
88        let table = parse_game_table("HpPerSta", "Level\tValue\n1\t2.5\n7\t9.0\n").or_fail()?;
89
90        verify_that!(table.name, eq("HpPerSta"))?;
91        verify_that!(
92            table.columns,
93            eq(&vec!["Level".to_string(), "Value".to_string()])
94        )?;
95        verify_that!(table.rows.len(), eq(2))?;
96        verify_that!(table.rows[0], eq(&vec!["1".to_string(), "2.5".to_string()]))?;
97
98        verify_that!(table.rows[1], eq(&vec!["7".to_string(), "9.0".to_string()]))
99    }
100
101    #[gtest]
102    fn trims_surrounding_whitespace() -> Result<()> {
103        let table = parse_game_table("Trimmed", "  Level \t Value \n  1 \t 2.5 \n").or_fail()?;
104
105        verify_that!(
106            table.columns,
107            eq(&vec!["Level".to_string(), "Value".to_string()])
108        )?;
109
110        verify_that!(table.rows[0], eq(&vec!["1".to_string(), "2.5".to_string()]))
111    }
112
113    #[gtest]
114    fn flexible_width_allows_short_rows() -> Result<()> {
115        let result = parse_game_table("Flex", "A\tB\tC\n1\t2\n");
116
117        verify_that!(result.is_ok(), eq(true))?;
118        let table = result.or_fail()?;
119
120        verify_that!(table.rows[0].len(), eq(2))
121    }
122
123    #[gtest]
124    fn header_only_has_no_rows() -> Result<()> {
125        let table = parse_game_table("Empty", "Level\tValue\n").or_fail()?;
126
127        verify_that!(table.columns.len(), eq(2))?;
128
129        verify_that!(table.rows.is_empty(), eq(true))
130    }
131
132    #[gtest]
133    fn parsed_game_table_snapshot() -> Result<()> {
134        let table = parse_game_table("HpPerSta", "Level\tValue\n1\t2.5\n2\t3.0\n").or_fail()?;
135
136        assert_debug_snapshot!(table);
137
138        Ok(())
139    }
140}