//! TOML content-file loading with `file:line` errors (docs/04 §4.4). //! //! Designers edit `assets/data/*.toml`, not Rust. [`load_file`] is the single //! choke point that reads those files: I/O failures and TOML parse failures //! both come back as [`LoadError`], which always names the file and — for //! parse errors the `toml` crate can place — the 1-based line, so a content //! failure points at the offending data line instead of a Rust backtrace. //! //! Workstream split note: the canonical schema structs live in workstream 1 //! (`schema.rs`). The test-only mirrors in this file (`ItemsFile` etc.) //! deliberately duplicate their TOML field names exactly, so unifying the //! types later is mechanical (same keys, same field names, delete the mirror). use std::fmt; use std::fs; use serde::de::DeserializeOwned; /// Failure to load or parse one content file (docs/04 §4.4: validation errors /// carry `file:line`). #[derive(Clone, Debug, PartialEq, Eq)] pub struct LoadError { /// File path as passed to [`load_file`] (or the joined path for /// [`crate::pack::load_all`]). pub file: String, /// 1-based TOML line for parse errors. `None` for I/O errors and for /// parse errors the `toml` crate cannot place (span-less serde /// data-model errors). pub line: Option, /// Human-readable cause: I/O description or `toml` error message. pub message: String, } impl fmt::Display for LoadError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self.line { Some(line) => write!(f, "{}:{}: {}", self.file, line, self.message), None => write!(f, "{}: {}", self.file, self.message), } } } impl std::error::Error for LoadError {} /// Read `path` to a string, mapping I/O failures to [`LoadError`]. /// /// Shared by [`load_file`] and /// [`crate::pack::load_all`]: raw text loading must /// NOT go through `toml::from_str::`, which rejects TOML documents /// (`invalid type: map, expected a string`). TOML parsing only happens when /// deserialising into a structured `T`. pub fn load_text(path: &str) -> Result { fs::read_to_string(path).map_err(|err| LoadError { file: path.to_owned(), line: None, message: format!("cannot read file: {err}"), }) } /// Load `path` and deserialise its TOML into `T`. /// /// `path` is used verbatim for both reading and error attribution, so callers /// should pass the stable canonical form (see /// [`crate::pack::manifest`]). I/O errors return a /// [`LoadError`] with [`LoadError::line`] `None`; TOML errors carry the /// 1-based line derived from the `toml` error span. pub fn load_file(path: &str) -> Result where T: DeserializeOwned, { let text = load_text(path)?; parse_text(&text, path) } /// Parse TOML `text` as if read from `file`. /// /// Pure helper behind [`load_file`]'s error mapping; lets tests feed inline /// TOML without touching the filesystem. fn parse_text(text: &str, file: &str) -> Result where T: DeserializeOwned, { toml::from_str(text).map_err(|err| LoadError { file: file.to_owned(), line: err.span().map(|span| { // Byte offset -> 1-based line: count newlines before the span start. // `get` guards against a mid-character boundary; on failure we // fall back to line 1 rather than panicking in error handling. let head = text.get(..span.start.min(text.len())).unwrap_or(""); head.matches('\n').count() + 1 }), message: err.message().to_owned(), }) } #[cfg(test)] mod tests { use super::*; // Typed loading tests parse through the canonical schema wrappers // (`crate::{ItemsFile, …}`) — no local mirrors. Field names are identical // to assets/data/*.toml, so a parse failure here means data drifted from // the schema and the DATA file must be fixed (sim + schema beat content). use crate::{ ContractsFile, FloorsFile, GodsFile, HymnsFile, ItemsFile, MonstersFile, TownsFile, }; #[test] fn loads_items_file() { let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/data/items.toml"); let data: ItemsFile = load_file(path).expect("items.toml must parse"); assert_eq!(data.item.len(), 40, "canonical pack ships 40 items so far"); for item in &data.item { assert!( item.uses.len() >= 2, "two-uses law: '{}' has {} uses", item.id, item.uses.len() ); assert!(!item.intent.is_empty(), "'{}' needs an intent", item.id); assert!(item.weight_kg >= 0.0, "'{}' has negative weight", item.id); } let ids: Vec<&str> = data.item.iter().map(|i| i.id.as_str()).collect(); for expected in [ "lamp_oil", "drowned_bell_captive", "audit_lien_chit", "tin_lamp", ] { assert!(ids.contains(&expected), "missing item '{expected}'"); } } #[test] fn loads_monsters_file() { let path = concat!( env!("CARGO_MANIFEST_DIR"), "/../../assets/data/monsters.toml" ); let data: MonstersFile = load_file(path).expect("monsters.toml must parse"); assert_eq!( data.monster.len(), 42, "EA ships 42 monsters (docs/03 §3.11 slice floor)" ); for monster in &data.monster { assert!(!monster.uses.is_empty(), "'{}' needs a use", monster.id); assert!( monster.depth_min <= monster.depth_max, "'{}' has an inverted depth band", monster.id ); assert!( !monster.intent.is_empty(), "'{}' needs an intent", monster.id ); } assert_eq!( data.monster.iter().filter(|m| m.boss).count(), 5, "rat-king + Saint of Rust + Matriarch + Seraph + Drowned Saint are bosses" ); } #[test] fn loads_hymns_file() { let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/data/hymns.toml"); let data: HymnsFile = load_file(path).expect("hymns.toml must parse"); assert_eq!( data.hymn.len(), 7, "canonical pack ships 7 runtime-backed hymns" ); for hymn in &data.hymn { assert!( hymn.miscast_table.len() >= 2, "'{}' needs 2+ miscasts", hymn.id ); assert!( (0..=100).contains(&hymn.conduct_base), "'{}' conduct_base out of range", hymn.id ); assert!(!hymn.intent.is_empty(), "'{}' needs an intent", hymn.id); } } #[test] fn loads_gods_file() { let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/data/gods.toml"); let data: GodsFile = load_file(path).expect("gods.toml must parse"); assert_eq!(data.god.len(), 5, "EA ships 5 gods (docs/02 §2)"); for god in &data.god { assert!(god.boons.len() >= 2, "'{}' needs 2+ boons", god.name); for boon in &god.boons { assert!(!boon.small_print.is_empty(), "boon needs small print"); } assert!(!god.blasphemy.is_empty(), "'{}' needs blasphemy", god.name); } } #[test] fn loads_contracts_file() { let path = concat!( env!("CARGO_MANIFEST_DIR"), "/../../assets/data/contracts.toml" ); let data: ContractsFile = load_file(path).expect("contracts.toml must parse"); assert_eq!( data.contract.len(), 62, "EA ships 62 contracts (docs/03 §3.11 floor)" ); for contract in &data.contract { assert!( (1..=5).contains(&contract.tier), "'{}' tier out of range", contract.id ); assert!( !contract.unknown_hint.is_empty(), "'{}' unknown_hint must never be empty (schema law)", contract.id ); assert!( !contract.intent.is_empty(), "'{}' needs an intent", contract.id ); } } #[test] fn loads_floors_file() { let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/data/floors.toml"); let data: FloorsFile = load_file(path).expect("floors.toml must parse"); assert_eq!( data.floor.len(), 12, "EA ships the 12-floor Deep (docs/03 §3.8)" ); for floor in &data.floor { assert!( floor.connectors.len() >= 3, "floor {} needs 3+ connectors", floor.depth ); assert!( ["Shallows", "Works", "Deep"].contains(&floor.stratum.as_str()), "stratum must be canonical" ); assert!( !floor.intent.is_empty(), "floor {} needs intent", floor.depth ); } } #[test] fn loads_towns_file() { let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/data/towns.toml"); let data: TownsFile = load_file(path).expect("towns.toml must parse"); assert_eq!(data.town.len(), 5, "EA ships 5 towns (docs/02 §3 county)"); let town = &data.town[0]; assert_eq!(town.pop, 312, "Milthollow pop is 312 (docs/02 §3)"); assert_eq!( town.gossip_days, 3, "gossip latency is a sim rule (docs/02 §4)" ); assert!(!town.contracts.is_empty(), "town must reference contracts"); } #[test] fn missing_file_error_carries_path() { let path = concat!( env!("CARGO_MANIFEST_DIR"), "/../../assets/data/no_such_file.toml" ); let err = load_file::(path).expect_err("missing file must fail"); assert_eq!(err.file, path, "error must carry the path"); assert_eq!(err.line, None, "I/O errors have no line"); assert!(err.to_string().contains(path), "Display must name the file"); } #[test] fn display_shape_with_and_without_line() { let with_line = LoadError { file: "assets/data/items.toml".to_owned(), line: Some(12), message: "expected a value".to_owned(), }; assert_eq!( with_line.to_string(), "assets/data/items.toml:12: expected a value" ); let io_err = LoadError { file: "assets/data/items.toml".to_owned(), line: None, message: "cannot read file: nope".to_owned(), }; assert_eq!( io_err.to_string(), "assets/data/items.toml: cannot read file: nope" ); } #[test] fn malformed_toml_reports_line() { #[derive(Debug, serde::Deserialize)] struct Tiny { #[allow(dead_code)] id: String, } let bad = "id = \"x\"\n= bad\n"; let err = parse_text::(bad, "bad-file.toml").expect_err("bad TOML must fail"); assert_eq!(err.file, "bad-file.toml"); assert_eq!(err.line, Some(2), "error must point at line 2"); assert!(err.to_string().starts_with("bad-file.toml:2: ")); } #[test] fn canonical_items_parser_fixed_toml_diagnostics_preserve_literal_lines() { struct DiagnosticCase { name: &'static str, input: &'static str, expected_line: usize, } let cases = [ DiagnosticCase { name: "unfinished_array_with_terminal_lf", input: "# fixture\n[[item]]\nid = [\n", expected_line: 4, }, DiagnosticCase { name: "unfinished_array_with_extra_blank_lines", input: "# fixture\n[[item]]\nid = [\n\n\n", expected_line: 6, }, DiagnosticCase { name: "unfinished_array_without_terminal_lf", input: "# fixture\n[[item]]\nid = [", expected_line: 3, }, DiagnosticCase { name: "incomplete_assignment", input: "x =\n", expected_line: 1, }, DiagnosticCase { name: "utf8_incomplete_array", input: "x = [\"é\"", expected_line: 1, }, DiagnosticCase { name: "unfinished_array_crlf", input: "# fixture\r\n[[item]]\r\nid = [\r\n", expected_line: 4, }, DiagnosticCase { name: "invalid_token_before_newline", input: "x = ?\ny = 1\n", expected_line: 1, }, DiagnosticCase { name: "trailing_comment_and_blank_line", input: "x = [\n# trailing comment\n\n", expected_line: 4, }, DiagnosticCase { name: "trailing_horizontal_layout", input: "x = [\n \t\r\n", expected_line: 3, }, DiagnosticCase { name: "unterminated_multiline_basic_string", input: "x = \"\"\"\n# apparent comment\n", expected_line: 3, }, DiagnosticCase { name: "unterminated_multiline_literal_string", input: "x = '''\n# apparent comment\n", expected_line: 3, }, DiagnosticCase { name: "array_after_valid_multiline_string", input: "x = [\"\"\"alpha\n# content\n\"\"\"", expected_line: 3, }, DiagnosticCase { name: "array_with_trailing_comment", input: "x = [\n 1,\n # trailing\n", expected_line: 4, }, ]; for case in cases { let err = parse_text::(case.input, "items-fixture.toml") .expect_err("every fixed diagnostic input must be malformed TOML"); assert_eq!(err.file, "items-fixture.toml", "case {}", case.name); assert_eq!(err.line, Some(case.expected_line), "case {}", case.name); assert!( err.to_string() .starts_with(&format!("items-fixture.toml:{}: ", case.expected_line)), "case {}", case.name ); } } }