diff --git a/src/knowledge/address.rs b/src/knowledge/address.rs new file mode 100644 index 0000000..c9cb165 --- /dev/null +++ b/src/knowledge/address.rs @@ -0,0 +1,21 @@ +//! Normalizes address input a model might paste into a knowledge lookup: +//! a wikilink's brackets, a trailing `.md` a model added out of habit, or +//! stray whitespace around either. + +/// Normalizes `input` into a lookup-ready address: trims surrounding +/// whitespace, strips one layer of surrounding `[[...]]`, strips one +/// trailing `.md`, then lowercases the result. Every mount lookup is +/// case-insensitive, so this is the one place that casing gets settled. +pub fn normalize_address(input: &str) -> String { + let trimmed = input.trim(); + let unbracketed = trimmed + .strip_prefix("[[") + .and_then(|rest| rest.strip_suffix("]]")) + .unwrap_or(trimmed); + let unextended = unbracketed.strip_suffix(".md").unwrap_or(unbracketed); + unextended.to_lowercase() +} + +#[cfg(test)] +#[path = "address_tests.rs"] +mod tests; diff --git a/src/knowledge/address_tests.rs b/src/knowledge/address_tests.rs new file mode 100644 index 0000000..d26b582 --- /dev/null +++ b/src/knowledge/address_tests.rs @@ -0,0 +1,65 @@ +use super::*; + +#[test] +fn trims_surrounding_whitespace() { + assert_eq!( + normalize_address(" rules/spells/fireball "), + "rules/spells/fireball" + ); +} + +#[test] +fn strips_one_layer_of_surrounding_brackets() { + assert_eq!( + normalize_address("[[rules/spells/fireball]]"), + "rules/spells/fireball" + ); +} + +#[test] +fn strips_only_the_outer_layer_of_nested_brackets() { + assert_eq!( + normalize_address("[[[[rules/spells/fireball]]]]"), + "[[rules/spells/fireball]]" + ); +} + +#[test] +fn leaves_a_one_sided_bracket_untouched() { + assert_eq!( + normalize_address("[[rules/spells/fireball"), + "[[rules/spells/fireball" + ); +} + +#[test] +fn strips_one_trailing_md_extension() { + assert_eq!( + normalize_address("rules/spells/fireball.md"), + "rules/spells/fireball" + ); +} + +#[test] +fn strips_only_one_trailing_md_extension() { + assert_eq!( + normalize_address("rules/spells/fireball.md.md"), + "rules/spells/fireball.md" + ); +} + +#[test] +fn lowercases_the_result() { + assert_eq!( + normalize_address("Rules/Monsters/Goblin"), + "rules/monsters/goblin" + ); +} + +#[test] +fn combines_whitespace_brackets_extension_and_case() { + assert_eq!( + normalize_address(" [[Rules/Spells/Fireball.md]] "), + "rules/spells/fireball" + ); +} diff --git a/src/knowledge/entry.rs b/src/knowledge/entry.rs new file mode 100644 index 0000000..d213499 --- /dev/null +++ b/src/knowledge/entry.rs @@ -0,0 +1,26 @@ +//! One knowledge entry: what the mount indexes at each address. + +use std::path::PathBuf; + +/// One entry in the knowledge tree: an entry file's parsed frontmatter +/// and body, plus where it lives on disk. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Entry { + /// The entry's address, as walked from its layer's `rules/` or + /// `lore/` root, without the `.md` extension: `rules/monsters/goblin`. + /// Keeps the on-disk casing; the mount's own lookup key is + /// lowercased separately. + pub address: String, + /// Where the entry file lives on disk. + pub path: PathBuf, + /// The layer directory that supplied this entry. + pub layer: PathBuf, + /// The frontmatter `name` field. + pub name: String, + /// The frontmatter `type` field, when the entry has one. + pub kind: Option, + /// The markdown body, after the frontmatter block. + pub body: String, + /// The file's full text, frontmatter and body together, verbatim. + pub text: String, +} diff --git a/src/knowledge/fixtures.rs b/src/knowledge/fixtures.rs new file mode 100644 index 0000000..0bd085b --- /dev/null +++ b/src/knowledge/fixtures.rs @@ -0,0 +1,64 @@ +//! Shared test fixtures for the knowledge module: a fresh temp directory, +//! a small file-writing helper, and a builder for a fixture layer +//! directory. + +use std::fs; +use std::path::{Path, PathBuf}; + +use tempfile::TempDir; + +/// A fresh, empty directory that no other test is using. The caller must +/// hold the returned guard for as long as the directory needs to exist; +/// it removes the directory when it drops. +pub fn unique_temp_dir() -> TempDir { + TempDir::new().expect("failed to create a temp directory") +} + +/// Writes `contents` to `path`, creating its parent directories first. +pub fn write_file(path: &Path, contents: &str) { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, contents).unwrap(); +} + +/// A fixture layer directory: a temp directory a test writes entry files +/// and other layer-root files into, at whatever address and depth the +/// test needs. +pub struct Layer { + pub root: TempDir, +} + +impl Layer { + /// A fresh layer directory with nothing in it yet. + pub fn empty() -> Self { + Self { + root: unique_temp_dir(), + } + } + + pub fn path(&self) -> PathBuf { + self.root.path().to_path_buf() + } + + /// Writes `contents` to `relative`, inside this layer. + pub fn write(&self, relative: &str, contents: &str) { + write_file(&self.root.path().join(relative), contents); + } + + /// Writes an entry file at `address` (`rules/monsters/goblin`) with + /// `name`, an optional `type`, and `body`. + pub fn write_entry(&self, address: &str, name: &str, kind: Option<&str>, body: &str) { + let type_line = kind + .map(|kind| format!("type: {kind}\n")) + .unwrap_or_default(); + self.write( + &format!("{address}.md"), + &format!("---\nname: {name}\n{type_line}---\n{body}"), + ); + } + + /// Writes a tombstone at `address`: frontmatter with `deleted: true` + /// and no other field. + pub fn write_tombstone(&self, address: &str) { + self.write(&format!("{address}.md"), "---\ndeleted: true\n---\n"); + } +} diff --git a/src/knowledge/frontmatter.rs b/src/knowledge/frontmatter.rs new file mode 100644 index 0000000..3a897da --- /dev/null +++ b/src/knowledge/frontmatter.rs @@ -0,0 +1,46 @@ +//! Splits an entry file into its frontmatter YAML and markdown body, and +//! parses the frontmatter into a YAML value. +//! +//! An entry file opens with a `---` delimiter line, holds YAML +//! frontmatter until the next `---` delimiter line, and treats +//! everything after that as its body. + +use std::fs; +use std::path::Path; + +use serde_yaml_ng::Value; + +/// Splits `contents` into its frontmatter YAML text and body markdown. +pub(crate) fn split_frontmatter(contents: &str) -> Result<(String, String), String> { + let mut lines = contents.lines(); + if lines.next() != Some("---") { + return Err("file must start with a `---` frontmatter delimiter".to_string()); + } + let mut frontmatter_lines = Vec::new(); + for line in lines.by_ref() { + if line == "---" { + let body: String = lines.collect::>().join("\n"); + return Ok((frontmatter_lines.join("\n"), body)); + } + frontmatter_lines.push(line); + } + Err("frontmatter is missing its closing `---` delimiter".to_string()) +} + +/// Parses `text`, a frontmatter block's raw YAML, into a `Value`. +pub(crate) fn parse_frontmatter_yaml(text: &str) -> Result { + serde_yaml_ng::from_str(text).map_err(|error| format!("invalid frontmatter YAML: {error}")) +} + +/// Reads and parses one entry file's frontmatter directly from `path`, +/// without its body. +pub fn read_frontmatter(path: &Path) -> Result { + let contents = + fs::read_to_string(path).map_err(|error| format!("cannot read file: {error}"))?; + let (frontmatter_text, _body) = split_frontmatter(&contents)?; + parse_frontmatter_yaml(&frontmatter_text) +} + +#[cfg(test)] +#[path = "frontmatter_tests.rs"] +mod tests; diff --git a/src/knowledge/frontmatter_tests.rs b/src/knowledge/frontmatter_tests.rs new file mode 100644 index 0000000..f6eddeb --- /dev/null +++ b/src/knowledge/frontmatter_tests.rs @@ -0,0 +1,56 @@ +use super::*; +use crate::knowledge::fixtures::unique_temp_dir; + +#[test] +fn splits_frontmatter_and_body() { + let contents = "---\nname: Fireball\ntype: spell\n---\n# Fireball\n\nBody text.\n"; + + let (frontmatter, body) = split_frontmatter(contents).unwrap(); + + assert_eq!(frontmatter, "name: Fireball\ntype: spell"); + assert_eq!(body, "# Fireball\n\nBody text."); +} + +#[test] +fn rejects_a_file_missing_the_opening_delimiter() { + let error = split_frontmatter("name: Fireball\n---\nbody").unwrap_err(); + assert!(error.contains("must start with")); +} + +#[test] +fn rejects_a_file_missing_the_closing_delimiter() { + let error = split_frontmatter("---\nname: Fireball\nbody").unwrap_err(); + assert!(error.contains("closing")); +} + +#[test] +fn parse_frontmatter_yaml_reports_invalid_yaml() { + let error = parse_frontmatter_yaml("name: [unterminated").unwrap_err(); + assert!(error.contains("invalid frontmatter YAML")); +} + +#[test] +fn read_frontmatter_parses_a_single_entry_by_path() { + let temp_dir = unique_temp_dir(); + let path = temp_dir.path().join("spells/fireball.md"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, "---\nname: Fireball\ntype: spell\n---\nbody\n").unwrap(); + + let frontmatter = read_frontmatter(&path).unwrap(); + + let mapping = frontmatter.as_mapping().unwrap(); + assert_eq!( + mapping.get("name").and_then(Value::as_str), + Some("Fireball") + ); +} + +#[test] +fn read_frontmatter_reports_a_file_it_cannot_read() { + let temp_dir = unique_temp_dir(); + let missing = temp_dir.path().join("does-not-exist.md"); + + let error = read_frontmatter(&missing).unwrap_err(); + + assert!(error.contains("cannot read file")); +} diff --git a/src/knowledge/mod.rs b/src/knowledge/mod.rs new file mode 100644 index 0000000..92f1324 --- /dev/null +++ b/src/knowledge/mod.rs @@ -0,0 +1,25 @@ +//! The DM's knowledge tree: an ordered union of layered directories, +//! indexed in memory by address. +//! +//! A layer is a directory; the mount reads only its `rules/` and `lore/` +//! subtrees, recursively, and ignores everything else at the layer's +//! root. Layers mount lowest first. When two layers hold the same +//! address, the higher layer's file becomes the entry, whole file; a +//! tombstone, an entry whose frontmatter sets `deleted: true`, removes +//! its address, and a later layer can resurrect it with a normal entry +//! at the same address. [`Mount::open`] builds the index; [`Entry`] is +//! what it holds at each address; [`normalize_address`] cleans up +//! address input a model might paste into a lookup. + +mod address; +mod entry; +pub(crate) mod frontmatter; +mod mount; + +#[cfg(test)] +mod fixtures; + +pub use address::normalize_address; +pub use entry::Entry; +pub use frontmatter::read_frontmatter; +pub use mount::Mount; diff --git a/src/knowledge/mount.rs b/src/knowledge/mount.rs new file mode 100644 index 0000000..455ebe6 --- /dev/null +++ b/src/knowledge/mount.rs @@ -0,0 +1,164 @@ +//! Builds the ordered union of knowledge layers into one in-memory index. + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use serde_yaml_ng::Value; + +use super::entry::Entry; +use super::frontmatter::{parse_frontmatter_yaml, split_frontmatter}; + +/// The two subtrees a layer directory mounts; everything else at a +/// layer's root is invisible to the mount. +const ROOTS: [&str; 2] = ["rules", "lore"]; + +/// The DM's knowledge tree: the ordered union of every mounted layer, +/// indexed by lowercased address. +/// +/// Layers mount lowest first. When two layers hold the same address, +/// the higher layer's entry replaces the lower one whole file. A +/// tombstone, an entry whose frontmatter sets `deleted: true`, removes +/// its address; a later layer can resurrect it with a normal entry at +/// the same address. +#[derive(Debug)] +pub struct Mount { + entries: BTreeMap, +} + +impl Mount { + /// Builds the index from `layers`, lowest first. + /// + /// Walks each layer's `rules/` and `lore/` subtrees recursively, at + /// any depth; a layer missing one or both subtrees contributes + /// nothing from the missing side. Fails on the first file that + /// cannot be read, whose frontmatter is malformed or missing, or + /// whose non-tombstone entry has no `name`, naming the offending + /// file. + pub fn open(layers: &[PathBuf]) -> Result { + let mut entries: BTreeMap = BTreeMap::new(); + for layer_root in layers { + for root_name in ROOTS { + let root_dir = layer_root.join(root_name); + for file_path in find_md_files(&root_dir) { + let address = address_for(root_name, &root_dir, &file_path); + let key = address.to_lowercase(); + match parse_entry(&file_path, &address, layer_root)? { + Parsed::Tombstone => { + entries.remove(&key); + } + Parsed::Live(entry) => { + entries.insert(key, entry); + } + } + } + } + } + Ok(Self { entries }) + } + + /// The entry mounted at `address`, case-insensitive, or `None` when + /// nothing is mounted there, whether because no layer ever supplied + /// it or because a tombstone removed it. + pub fn get(&self, address: &str) -> Option<&Entry> { + self.entries.get(&address.to_lowercase()) + } + + /// Every mounted entry, in no particular order. + pub fn entries(&self) -> impl Iterator { + self.entries.values() + } +} + +/// What one entry file parsed to: a tombstone that removes its address +/// from the index, or a live entry that fills it. +enum Parsed { + Tombstone, + Live(Entry), +} + +/// Reads and parses the entry file at `path`, whose address is `address` +/// and whose layer root is `layer_root`. +fn parse_entry(path: &Path, address: &str, layer_root: &Path) -> Result { + let text = fs::read_to_string(path) + .map_err(|error| format!("{}: cannot read file: {error}", path.display()))?; + let (frontmatter_text, body) = + split_frontmatter(&text).map_err(|message| format!("{}: {message}", path.display()))?; + let frontmatter = parse_frontmatter_yaml(&frontmatter_text) + .map_err(|message| format!("{}: {message}", path.display()))?; + let mapping = frontmatter + .as_mapping() + .ok_or_else(|| format!("{}: frontmatter must be a YAML mapping", path.display()))?; + + let deleted = mapping + .get("deleted") + .and_then(Value::as_bool) + .unwrap_or(false); + if deleted { + return Ok(Parsed::Tombstone); + } + + let name = mapping + .get("name") + .and_then(Value::as_str) + .ok_or_else(|| { + format!( + "{}: frontmatter is missing required field 'name'", + path.display() + ) + })? + .to_string(); + let kind = mapping + .get("type") + .and_then(Value::as_str) + .map(str::to_string); + + Ok(Parsed::Live(Entry { + address: address.to_string(), + path: path.to_path_buf(), + layer: layer_root.to_path_buf(), + name, + kind, + body, + text, + })) +} + +/// Every `.md` file under `dir`, at any depth. A missing or unreadable +/// directory yields no files, since a layer missing a `rules/` or +/// `lore/` subtree is not an error. +fn find_md_files(dir: &Path) -> Vec { + let mut found = Vec::new(); + let Ok(read_dir) = fs::read_dir(dir) else { + return found; + }; + for entry in read_dir.flatten() { + let path = entry.path(); + if path.is_dir() { + found.extend(find_md_files(&path)); + } else if path.extension().and_then(|ext| ext.to_str()) == Some("md") { + found.push(path); + } + } + found +} + +/// The address a file at `file_path`, found under `root_dir` (a layer's +/// `rules/` or `lore/` directory), walks to: `root_name` joined with the +/// file's path relative to `root_dir`, `/`-separated, extension stripped. +fn address_for(root_name: &str, root_dir: &Path, file_path: &Path) -> String { + let relative = file_path + .strip_prefix(root_dir) + .expect("find_md_files only returns paths under root_dir") + .with_extension(""); + let mut address = root_name.to_string(); + for component in relative.components() { + address.push('/'); + address.push_str(&component.as_os_str().to_string_lossy()); + } + address +} + +#[cfg(test)] +#[path = "mount_tests.rs"] +mod tests; diff --git a/src/knowledge/mount_tests.rs b/src/knowledge/mount_tests.rs new file mode 100644 index 0000000..8516136 --- /dev/null +++ b/src/knowledge/mount_tests.rs @@ -0,0 +1,267 @@ +use std::fs; + +use super::*; +use crate::knowledge::fixtures::Layer; + +/// Opens a mount over `layers`, lowest first. +fn open(layers: &[&Layer]) -> Result { + let roots: Vec = layers.iter().map(|layer| layer.path()).collect(); + Mount::open(&roots) +} + +#[test] +fn walks_both_roots_of_a_single_layer() { + let layer = Layer::empty(); + layer.write_entry( + "rules/spells/fireball", + "Fireball", + Some("spell"), + "A bright streak flashes.\n", + ); + layer.write_entry( + "lore/people/martha", + "Martha", + None, + "The innkeep at the Silver Stag.\n", + ); + + let mount = open(&[&layer]).unwrap(); + + let fireball = mount.get("rules/spells/fireball").unwrap(); + assert_eq!(fireball.address, "rules/spells/fireball"); + assert_eq!(fireball.name, "Fireball"); + assert_eq!(fireball.kind.as_deref(), Some("spell")); + assert_eq!(fireball.body, "A bright streak flashes."); + assert_eq!(fireball.layer, layer.path()); + assert!(fireball.text.starts_with("---\n")); + + let martha = mount.get("lore/people/martha").unwrap(); + assert_eq!(martha.name, "Martha"); + assert_eq!(martha.kind, None); +} + +#[test] +fn a_higher_layer_shadows_a_lower_layer_whole_file() { + let low = Layer::empty(); + low.write_entry( + "rules/monsters/goblin", + "Goblin (low)", + Some("monster"), + "low body\n", + ); + let mid = Layer::empty(); + mid.write_entry( + "rules/monsters/goblin", + "Goblin (mid)", + Some("monster"), + "mid body\n", + ); + let high = Layer::empty(); + high.write_entry( + "rules/monsters/goblin", + "Goblin (high)", + Some("monster"), + "high body\n", + ); + + let mount = open(&[&low, &mid, &high]).unwrap(); + + let goblin = mount.get("rules/monsters/goblin").unwrap(); + assert_eq!(goblin.name, "Goblin (high)"); + assert_eq!(goblin.layer, high.path()); +} + +#[test] +fn a_tombstone_hides_its_address() { + let low = Layer::empty(); + low.write_entry("rules/monsters/goblin", "Goblin", Some("monster"), "body\n"); + let high = Layer::empty(); + high.write_tombstone("rules/monsters/goblin"); + + let mount = open(&[&low, &high]).unwrap(); + + assert!(mount.get("rules/monsters/goblin").is_none()); +} + +#[test] +fn a_higher_layer_resurrects_a_tombstoned_address() { + let low = Layer::empty(); + low.write_entry( + "rules/monsters/goblin", + "Goblin (low)", + Some("monster"), + "low body\n", + ); + let mid = Layer::empty(); + mid.write_tombstone("rules/monsters/goblin"); + let high = Layer::empty(); + high.write_entry( + "rules/monsters/goblin", + "Goblin (resurrected)", + Some("monster"), + "high body\n", + ); + + let mount = open(&[&low, &mid, &high]).unwrap(); + + let goblin = mount.get("rules/monsters/goblin").unwrap(); + assert_eq!(goblin.name, "Goblin (resurrected)"); +} + +#[test] +fn a_tombstone_needs_no_name() { + let layer = Layer::empty(); + layer.write_tombstone("rules/monsters/goblin"); + + let mount = open(&[&layer]).unwrap(); + + assert!(mount.get("rules/monsters/goblin").is_none()); +} + +#[test] +fn get_is_case_insensitive() { + let layer = Layer::empty(); + layer.write_entry("rules/spells/fireball", "Fireball", Some("spell"), "body\n"); + + let mount = open(&[&layer]).unwrap(); + + assert!(mount.get("Rules/Spells/FIREBALL").is_some()); +} + +#[test] +fn an_entrys_address_keeps_its_original_casing() { + let layer = Layer::empty(); + layer.write_entry("rules/Monsters/Goblin", "Goblin", Some("monster"), "body\n"); + + let mount = open(&[&layer]).unwrap(); + + let goblin = mount.get("rules/monsters/goblin").unwrap(); + assert_eq!(goblin.address, "rules/Monsters/Goblin"); +} + +#[test] +fn metadata_files_at_a_layers_root_are_invisible() { + let layer = Layer::empty(); + layer.write_entry("rules/spells/fireball", "Fireball", Some("spell"), "body\n"); + layer.write("meta.yaml", "version: 1\n"); + layer.write("README.md", "# Layer\n"); + layer.write("sources/original.md", "vendored text\n"); + + let mount = open(&[&layer]).unwrap(); + + assert_eq!(mount.entries().count(), 1); +} + +#[test] +fn non_md_files_inside_the_mounted_subtrees_are_ignored() { + let layer = Layer::empty(); + layer.write_entry("rules/spells/fireball", "Fireball", Some("spell"), "body\n"); + layer.write("rules/spells/notes.txt", "not an entry\n"); + + let mount = open(&[&layer]).unwrap(); + + assert_eq!(mount.entries().count(), 1); +} + +#[test] +fn nested_category_depth_is_walked() { + let layer = Layer::empty(); + layer.write_entry( + "rules/monsters/undead/skeleton", + "Skeleton", + Some("monster"), + "body\n", + ); + + let mount = open(&[&layer]).unwrap(); + + assert!(mount.get("rules/monsters/undead/skeleton").is_some()); +} + +#[test] +fn an_empty_layer_yields_no_entries() { + let layer = Layer::empty(); + + let mount = open(&[&layer]).unwrap(); + + assert_eq!(mount.entries().count(), 0); +} + +#[test] +fn a_layer_missing_a_subtree_is_fine() { + let layer = Layer::empty(); + layer.write_entry("lore/people/martha", "Martha", None, "body\n"); + + let mount = open(&[&layer]).unwrap(); + + assert_eq!(mount.entries().count(), 1); + assert!(mount.get("lore/people/martha").is_some()); +} + +#[test] +fn an_unreadable_file_fails_open_and_names_it() { + let layer = Layer::empty(); + let path = layer.path().join("rules/spells/fireball.md"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, [0xFF, 0xFE, 0x00]).unwrap(); + + let error = open(&[&layer]).unwrap_err(); + + assert!(error.contains("fireball.md")); + assert!(error.contains("cannot read file")); +} + +#[test] +fn frontmatter_missing_the_opening_delimiter_fails_open() { + let layer = Layer::empty(); + layer.write("rules/spells/fireball.md", "name: Fireball\n---\nbody\n"); + + let error = open(&[&layer]).unwrap_err(); + + assert!(error.contains("fireball.md")); + assert!(error.contains("must start with")); +} + +#[test] +fn frontmatter_missing_the_closing_delimiter_fails_open() { + let layer = Layer::empty(); + layer.write("rules/spells/fireball.md", "---\nname: Fireball\nbody\n"); + + let error = open(&[&layer]).unwrap_err(); + + assert!(error.contains("closing")); +} + +#[test] +fn invalid_frontmatter_yaml_fails_open() { + let layer = Layer::empty(); + layer.write( + "rules/spells/fireball.md", + "---\nname: [unterminated\n---\nbody\n", + ); + + let error = open(&[&layer]).unwrap_err(); + + assert!(error.contains("invalid frontmatter YAML")); +} + +#[test] +fn frontmatter_that_is_not_a_mapping_fails_open() { + let layer = Layer::empty(); + layer.write("rules/spells/fireball.md", "---\n- one\n- two\n---\nbody\n"); + + let error = open(&[&layer]).unwrap_err(); + + assert!(error.contains("must be a YAML mapping")); +} + +#[test] +fn a_missing_name_on_a_non_tombstone_entry_fails_open() { + let layer = Layer::empty(); + layer.write("rules/spells/fireball.md", "---\ntype: spell\n---\nbody\n"); + + let error = open(&[&layer]).unwrap_err(); + + assert!(error.contains("fireball.md")); + assert!(error.contains("'name'")); +} diff --git a/src/lib.rs b/src/lib.rs index 7452e6c..239592c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ pub mod cli; pub mod config; pub mod dice; pub mod dm; +pub mod knowledge; pub mod play; pub mod srd; diff --git a/src/srd/verify/layer.rs b/src/srd/verify/layer.rs index da69f75..dde09bb 100644 --- a/src/srd/verify/layer.rs +++ b/src/srd/verify/layer.rs @@ -6,6 +6,8 @@ use std::path::{Path, PathBuf}; use serde_yaml_ng::Value; +use crate::knowledge::frontmatter::{parse_frontmatter_yaml, split_frontmatter}; + use super::Failure; use super::kind::Kind; @@ -87,65 +89,10 @@ fn parse_file(path: &Path, kind: Kind) -> Result { }) } -fn parse_frontmatter_yaml(text: &str) -> Result { - serde_yaml_ng::from_str(text).map_err(|error| format!("invalid frontmatter YAML: {error}")) -} - -/// Reads and parses one corpus entry's frontmatter directly from `path`, -/// for a caller that wants a single known entry's fields rather than a -/// whole layer's worth (`discover` walks every kind directory at once). -pub fn read_frontmatter(path: &Path) -> Result { - let contents = - fs::read_to_string(path).map_err(|error| format!("cannot read file: {error}"))?; - let (frontmatter_text, _body) = split_frontmatter(&contents)?; - parse_frontmatter_yaml(&frontmatter_text) -} - -/// Splits `contents` into its frontmatter YAML and body markdown. A corpus -/// file opens with a `---` delimiter line, holds YAML until the next `---` -/// delimiter line, and treats everything after that as the body. -fn split_frontmatter(contents: &str) -> Result<(String, String), String> { - let mut lines = contents.lines(); - if lines.next() != Some("---") { - return Err("file must start with a `---` frontmatter delimiter".to_string()); - } - let mut frontmatter_lines = Vec::new(); - for line in lines.by_ref() { - if line == "---" { - let body: String = lines.collect::>().join("\n"); - return Ok((frontmatter_lines.join("\n"), body)); - } - frontmatter_lines.push(line); - } - Err("frontmatter is missing its closing `---` delimiter".to_string()) -} - #[cfg(test)] mod tests { use super::*; - use crate::srd::verify::fixtures::{unique_temp_dir, write_file}; - - #[test] - fn splits_frontmatter_and_body() { - let contents = "---\nname: Fireball\ntype: spell\n---\n# Fireball\n\nBody text.\n"; - - let (frontmatter, body) = split_frontmatter(contents).unwrap(); - - assert_eq!(frontmatter, "name: Fireball\ntype: spell"); - assert_eq!(body, "# Fireball\n\nBody text."); - } - - #[test] - fn rejects_a_file_missing_the_opening_delimiter() { - let error = split_frontmatter("name: Fireball\n---\nbody").unwrap_err(); - assert!(error.contains("must start with")); - } - - #[test] - fn rejects_a_file_missing_the_closing_delimiter() { - let error = split_frontmatter("---\nname: Fireball\nbody").unwrap_err(); - assert!(error.contains("closing")); - } + use crate::srd::verify::fixtures::unique_temp_dir; #[test] fn discover_finds_md_files_in_each_kind_directory() { @@ -276,29 +223,4 @@ mod tests { assert!(files.is_empty()); assert_eq!(failures, vec![]); } - - #[test] - fn read_frontmatter_parses_a_single_entry_by_path() { - let layer_root = unique_temp_dir(); - let path = layer_root.path().join("spells/fireball.md"); - write_file(&path, "---\nname: Fireball\ntype: spell\n---\nbody\n"); - - let frontmatter = read_frontmatter(&path).unwrap(); - - let mapping = frontmatter.as_mapping().unwrap(); - assert_eq!( - mapping.get("name").and_then(Value::as_str), - Some("Fireball") - ); - } - - #[test] - fn read_frontmatter_reports_a_file_it_cannot_read() { - let temp_dir = unique_temp_dir(); - let missing = temp_dir.path().join("does-not-exist.md"); - - let error = read_frontmatter(&missing).unwrap_err(); - - assert!(error.contains("cannot read file")); - } } diff --git a/src/srd/verify/mod.rs b/src/srd/verify/mod.rs index 9382a63..6704c67 100644 --- a/src/srd/verify/mod.rs +++ b/src/srd/verify/mod.rs @@ -36,8 +36,8 @@ use std::collections::HashMap; use std::fmt; use std::path::{Path, PathBuf}; +pub use crate::knowledge::read_frontmatter; pub use kind::Kind; -pub use layer::read_frontmatter; /// One thing wrong with the corpus, tied to the file that has the problem. #[derive(Debug, Clone, PartialEq, Eq)]