diff --git a/src/entities/entities_seeding_tests.rs b/src/entities/entities_seeding_tests.rs --- a/src/entities/entities_seeding_tests.rs +++ b/src/entities/entities_seeding_tests.rs @@ -88,6 +88,74 @@ ); } #[test] +fn an_entity_in_a_directory_registers_at_the_directorys_address() { + let world = TempDir::new().unwrap(); + let file = world.path().join("characters/sister-maren/entry.md"); + fs::create_dir_all(file.parent().unwrap()).unwrap(); + fs::write( + &file, + "---\nname: Sister Maren\naliases: [Maren]\n---\n\nA cleric.\n", + ) + .unwrap(); + + let mut state = ScanState::default(); + seed_resolver(world.path(), &mut state); + + assert_eq!( + state.resolve("characters/sister-maren"), + Resolution::Found("characters/sister-maren".to_string()) + ); + assert_eq!( + state.resolve("Maren"), + Resolution::Found("characters/sister-maren".to_string()) + ); + assert_eq!( + state.resolve("characters/sister-maren/entry"), + Resolution::None + ); +} + +#[test] +fn an_entity_directorys_log_does_not_register() { + let world = TempDir::new().unwrap(); + let entry = world.path().join("characters/sister-maren/entry.md"); + let day = world.path().join("characters/sister-maren/log/0001.md"); + fs::create_dir_all(day.parent().unwrap()).unwrap(); + fs::write(&entry, "---\nname: Sister Maren\n---\n\nA cleric.\n").unwrap(); + fs::write(&day, "hp: 22 -> 15\n").unwrap(); + + let mut state = ScanState::default(); + seed_resolver(world.path(), &mut state); + + assert_eq!( + state.resolve("characters/sister-maren/log/0001"), + Resolution::None + ); +} + +#[test] +fn a_sub_entity_beside_an_entry_file_registers_at_its_own_address() { + let world = TempDir::new().unwrap(); + let entry = world.path().join("places/weatherford/entry.md"); + let sub = world.path().join("places/weatherford/rusty-anchor.md"); + fs::create_dir_all(entry.parent().unwrap()).unwrap(); + fs::write(&entry, "---\nname: Weatherford\n---\n\nA port town.\n").unwrap(); + fs::write(&sub, "---\nname: The Rusty Anchor\n---\n\nA tavern.\n").unwrap(); + + let mut state = ScanState::default(); + seed_resolver(world.path(), &mut state); + + assert_eq!( + state.resolve("places/weatherford"), + Resolution::Found("places/weatherford".to_string()) + ); + assert_eq!( + state.resolve("places/weatherford/rusty-anchor"), + Resolution::Found("places/weatherford/rusty-anchor".to_string()) + ); +} + +#[test] fn a_world_root_that_does_not_exist_registers_nothing() { let world = TempDir::new().unwrap(); diff --git a/src/entities/entities_tests.rs b/src/entities/entities_tests.rs --- a/src/entities/entities_tests.rs +++ b/src/entities/entities_tests.rs @@ -338,6 +338,29 @@ assert_eq!(std::fs::read_to_string(&stub).unwrap(), before); } #[test] +fn a_directory_entity_gets_no_stub_beside_it() { + let world = TempDir::new().unwrap(); + // A character's sheet lives in a directory. The wikilink for it must + // never establish a second, plain-file entity at the same address. + let entry = world.path().join("characters/sister-maren/entry.md"); + std::fs::create_dir_all(entry.parent().unwrap()).unwrap(); + std::fs::write(&entry, "---\nname: Sister Maren\n---\n\nA cleric.\n").unwrap(); + + let state = Arc::new(Mutex::new(ScanState::default())); + let mut scanner = Scanner::new(Arc::clone(&state), Some(world.path().to_path_buf())); + + scanner.turn_start("I pray."); + scanner.delta("[[characters/sister-maren]] lifts her holy symbol."); + scanner.done("Sister Maren lifts her holy symbol."); + + assert!(!world.path().join("characters/sister-maren.md").exists()); + assert_eq!( + std::fs::read_to_string(&entry).unwrap(), + "---\nname: Sister Maren\n---\n\nA cleric.\n" + ); +} + +#[test] fn an_ambiguous_bare_name_injects_nothing_and_writes_no_stub() { let world = TempDir::new().unwrap(); let state = Arc::new(Mutex::new(ScanState::default())); @@ -439,6 +462,19 @@ assert_eq!( note_body(world.path(), "Vera Blackwater"), Some("Vera keeps the Rusty Anchor.".to_string()) + ); +} + +#[test] +fn note_body_reads_a_directory_entitys_entry_file() { + let world = TempDir::new().unwrap(); + let entry = world.path().join("characters/sister-maren/entry.md"); + std::fs::create_dir_all(entry.parent().unwrap()).unwrap(); + std::fs::write(&entry, "---\nname: Sister Maren\n---\n\nA cleric.\n").unwrap(); + + assert_eq!( + note_body(world.path(), "characters/sister-maren"), + Some("A cleric.".to_string()) ); } diff --git a/src/entities/mod.rs b/src/entities/mod.rs --- a/src/entities/mod.rs +++ b/src/entities/mod.rs @@ -13,10 +13,13 @@ //! matches by name across whatever scope holds it. Resolution also //! checks an entity's aliases, so a retired address or an alternate name //! still finds its way home. When a reference matches nothing, the //! engine auto-establishes a stub at that address, and the kind-unknown -//! home for a bare name is the `entities/` bucket. [`note_body`] reads -//! an entity's note fresh from disk, and [`brief_body`] reads the -//! session brief the same way, for the `Dm` to inject into the next -//! turn's prompt. +//! home for a bare name is the `entities/` bucket. An entity is one file, +//! `people/Joseph Black.md`, until it grows into a directory and keeps +//! its note in the `entry.md` inside it; both forms answer to the same +//! address, so an entity already on disk in either form never gets a +//! second stub. [`note_body`] reads an entity's note fresh from disk, and +//! [`brief_body`] reads the session brief the same way, for the `Dm` to +//! inject into the next turn's prompt. //! //! A session that opens on a world with a record starts with entities //! already in play. [`seed_resolver`] walks the world root and registers @@ -38,7 +41,7 @@ use crate::bus::{Flow, Listener}; use crate::campaign::{CampaignLog, GameTime, START}; use crate::knowledge::frontmatter::split_frontmatter; -use crate::knowledge::read_frontmatter; +use crate::knowledge::{ENTRY_FILE, address_of, is_history_directory, read_frontmatter}; /// The width of the narration window a stub's seed line carries around /// the entity's name. Big enough to read the scene, small enough to stay @@ -275,30 +278,52 @@ } } } -/// Writes one established stub for `address` under `root`, leaving a -/// stub that is already on disk alone. A write failure is ignored: a -/// stub that cannot be recorded is not worth failing a turn over, and the -/// entity is already registered in memory for this session. +/// Writes one established stub for `address` under `root`, leaving an +/// entity that is already on disk alone, in either form. A write failure +/// is ignored: a stub that cannot be recorded is not worth failing a turn +/// over, and the entity is already registered in memory for this session. fn write_stub(root: &Path, address: &str, time: GameTime, narration: &str) { - let file = stub_path(root, address); - if file.exists() { + if note_path(root, address).is_some() { return; } + let file = stub_path(root, address); let parent = file.parent().expect("a stub file has a parent directory"); let _ = fs::create_dir_all(parent); let _ = fs::write(file, stub_body(address, time, narration)); } -/// The file a stub for `address` lives at under `root`. A scoped address -/// keeps its scope, `people/`, while a bare one lands under `entities/`, -/// the home for things whose kind is not yet known. -fn stub_path(root: &Path, address: &str) -> PathBuf { +/// Where `address` sits under `root`, with no extension and no decision +/// yet about which form it takes. A scoped address keeps its scope, +/// `people/`, while a bare one lands under `entities/`, the home for +/// things whose kind is not yet known. +fn entity_base(root: &Path, address: &str) -> PathBuf { let relative = if address.contains('/') { String::from(address) } else { format!("entities/{address}") }; - root.join(relative).with_extension("md") + root.join(relative) +} + +/// The file a new stub for `address` lives at under `root`. A stub is +/// always born as a plain file; an entity grows into a directory only +/// when a tool makes it one. +fn stub_path(root: &Path, address: &str) -> PathBuf { + entity_base(root, address).with_extension("md") +} + +/// The file that holds `address`'s note today, or `None` when the address +/// has no note on disk yet. An entity that grew into a directory keeps +/// its note in the `entry.md` inside it, and that file answers for the +/// address; every other entity is a plain file. +fn note_path(root: &Path, address: &str) -> Option { + let base = entity_base(root, address); + let directory_form = base.join(ENTRY_FILE); + if directory_form.is_file() { + return Some(directory_form); + } + let file_form = base.with_extension("md"); + file_form.is_file().then_some(file_form) } /// The address's note body, read fresh from disk under `root`. `None` @@ -306,7 +331,7 @@ /// when the note file does not exist or its body is empty: an entity /// established with no world has no note, and its name already lives in /// the conversation history. pub(crate) fn note_body(root: &Path, address: &str) -> Option { - body_of(&stub_path(root, address)) + body_of(¬e_path(root, address)?) } /// The session brief's body, `session.md` at `root`, read fresh from @@ -461,8 +486,9 @@ /// Registers every note under `root` into `state`, so a bare wikilink /// resolves to the entity already on disk instead of establishing a /// second stub for it. An address is the note's path relative to `root`, -/// `/`-separated, with the `.md` extension stripped; its aliases come -/// from the note's frontmatter. +/// `/`-separated, with the `.md` extension stripped; an entity that lives +/// in a directory registers at the directory's address, not at its +/// `entry.md`. A note's aliases come from its frontmatter. /// /// Skips the record's own directories, [`RECORD_DIRS`], and the session /// brief, [`SESSION_BRIEF`]. A note whose frontmatter is missing or does @@ -472,13 +498,8 @@ pub(crate) fn seed_resolver(root: &Path, state: &mut ScanState) { for file in find_notes(root) { let relative = file .strip_prefix(root) - .expect("find_notes only returns paths under root") - .with_extension(""); - let address = relative - .components() - .map(|component| component.as_os_str().to_string_lossy()) - .collect::>() - .join("/"); + .expect("find_notes only returns paths under root"); + let address = address_of(relative); let aliases = read_frontmatter(&file) .ok() .map(|value| aliases_from_frontmatter(&value)) @@ -504,8 +525,8 @@ }) .unwrap_or_default() } -/// Every `.md` file under `root`, at any depth, except [`RECORD_DIRS`] -/// and [`SESSION_BRIEF`]. +/// Every `.md` file under `root`, at any depth, except [`RECORD_DIRS`], +/// [`SESSION_BRIEF`], and an entity's own history directory. fn find_notes(root: &Path) -> Vec { let mut found = Vec::new(); let Ok(read_dir) = fs::read_dir(root) else { @@ -516,7 +537,7 @@ let path = entry.path(); let name = entry.file_name(); let name = name.to_string_lossy(); if path.is_dir() { - if RECORD_DIRS.contains(&name.as_ref()) { + if RECORD_DIRS.contains(&name.as_ref()) || is_history_directory(&path) { continue; } found.extend(find_notes(&path)); diff --git a/src/knowledge/entity_directory.rs b/src/knowledge/entity_directory.rs new file mode 100644 --- /dev/null +++ b/src/knowledge/entity_directory.rs @@ -0,0 +1,65 @@ +//! The directory form of an entity: an address that names a directory +//! resolves to the `entry.md` inside it. +//! +//! An entity starts as one file, `lore/places/weatherford.md`, and can +//! grow into a directory, `lore/places/weatherford/entry.md`, with +//! sub-entries beside it. Both forms carry the same address, so a +//! wikilink written against the file still resolves after the entity +//! grows. A directory contributes exactly one entry at its own address, +//! its `entry.md`. A `log/` beside that file is the entity's history, and +//! history holds no entries. + +use std::ffi::OsStr; +use std::path::Path; + +/// The file inside an entity directory that holds the entity itself. +pub const ENTRY_FILE: &str = "entry.md"; + +/// The address segment [`ENTRY_FILE`] would otherwise contribute. No +/// address ends in it: the entry file speaks for its directory. +const ENTRY_SEGMENT: &str = "entry"; + +/// A directory inside an entity directory that holds the entity's +/// history, day file by day file. +const HISTORY_DIR: &str = "log"; + +/// The address of the note at `path`, a path relative to whatever root +/// the caller walks: the components joined with `/`, the extension +/// dropped, and a trailing `entry` dropped with it. So +/// `characters/sister-maren/entry.md` addresses `characters/sister-maren` +/// and `people/martha.md` addresses `people/martha`. +pub fn address_of(path: &Path) -> String { + let stripped = path.with_extension(""); + let mut segments: Vec = stripped + .components() + .map(|component| component.as_os_str().to_string_lossy().into_owned()) + .collect(); + if segments + .last() + .is_some_and(|last| last.as_str() == ENTRY_SEGMENT) + { + segments.pop(); + } + segments.join("/") +} + +/// True when `path` is an entity directory's entry file. +pub fn is_entry_file(path: &Path) -> bool { + path.file_name() == Some(OsStr::new(ENTRY_FILE)) +} + +/// True when `directory` holds an entity's history rather than entries: a +/// `log/` directory beside an [`ENTRY_FILE`]. A walk does not enter it, +/// so a character's day files never become entries of their own. A `log/` +/// with no entry file beside it is an ordinary scope, and a walk reads it +/// like any other. +pub fn is_history_directory(directory: &Path) -> bool { + directory.file_name() == Some(OsStr::new(HISTORY_DIR)) + && directory + .parent() + .is_some_and(|parent| parent.join(ENTRY_FILE).is_file()) +} + +#[cfg(test)] +#[path = "entity_directory_tests.rs"] +mod tests; diff --git a/src/knowledge/entity_directory_tests.rs b/src/knowledge/entity_directory_tests.rs new file mode 100644 --- /dev/null +++ b/src/knowledge/entity_directory_tests.rs @@ -0,0 +1,65 @@ +use std::fs; +use std::path::Path; + +use super::*; +use crate::knowledge::fixtures::{unique_temp_dir, write_file}; + +#[test] +fn a_plain_note_addresses_its_own_path() { + assert_eq!( + address_of(Path::new("lore/people/martha.md")), + "lore/people/martha" + ); +} + +#[test] +fn an_entry_file_addresses_its_directory() { + assert_eq!( + address_of(Path::new("characters/sister-maren/entry.md")), + "characters/sister-maren" + ); +} + +#[test] +fn a_note_named_entry_at_the_root_addresses_nothing() { + assert_eq!(address_of(Path::new("entry.md")), ""); +} + +#[test] +fn an_entry_file_is_recognized_by_its_name() { + assert!(is_entry_file(Path::new("characters/maren/entry.md"))); + assert!(!is_entry_file(Path::new("characters/maren.md"))); +} + +#[test] +fn a_log_beside_an_entry_file_is_history() { + let root = unique_temp_dir(); + write_file(&root.path().join("characters/maren/entry.md"), "---\n---\n"); + fs::create_dir_all(root.path().join("characters/maren/log")).unwrap(); + + assert!(is_history_directory( + &root.path().join("characters/maren/log") + )); +} + +#[test] +fn a_log_with_no_entry_file_beside_it_is_an_ordinary_scope() { + let root = unique_temp_dir(); + fs::create_dir_all(root.path().join("rules/log")).unwrap(); + + assert!(!is_history_directory(&root.path().join("rules/log"))); +} + +#[test] +fn a_directory_that_is_not_named_log_is_not_history() { + let root = unique_temp_dir(); + write_file( + &root.path().join("places/weatherford/entry.md"), + "---\n---\n", + ); + fs::create_dir_all(root.path().join("places/weatherford/shops")).unwrap(); + + assert!(!is_history_directory( + &root.path().join("places/weatherford/shops") + )); +} diff --git a/src/knowledge/mod.rs b/src/knowledge/mod.rs --- a/src/knowledge/mod.rs +++ b/src/knowledge/mod.rs @@ -11,8 +11,14 @@ //! 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; [`search`] runs a //! keyword query over a mount's entries. +//! +//! An address can name a directory instead of a file, and then it +//! resolves to the [`ENTRY_FILE`] inside it. [`entity_directory`] holds +//! that rule, for the mount and for anything else that walks notes on +//! disk. mod address; +mod entity_directory; mod entry; pub(crate) mod frontmatter; mod mount; @@ -22,6 +28,7 @@ #[cfg(test)] pub(crate) mod fixtures; pub use address::normalize_address; +pub use entity_directory::{ENTRY_FILE, address_of, is_history_directory}; pub use entry::{Entry, Visibility}; pub use frontmatter::read_frontmatter; pub use mount::Mount; diff --git a/src/knowledge/mount.rs b/src/knowledge/mount.rs --- a/src/knowledge/mount.rs +++ b/src/knowledge/mount.rs @@ -6,6 +6,7 @@ use std::path::{Path, PathBuf}; use serde_yaml_ng::Value; +use super::entity_directory::{address_of, is_entry_file, is_history_directory}; use super::entry::{Entry, Visibility}; use super::frontmatter::{parse_frontmatter_yaml, split_frontmatter}; @@ -32,17 +33,30 @@ /// /// Each layer root must exist and be a directory; walks each layer's /// `rules/` and `lore/` subtrees recursively, at any depth, and a /// layer missing one or both subtrees contributes nothing from the - /// missing side. Fails on the first layer root that does not exist - /// or is not a directory, the first file that cannot be read, whose - /// frontmatter is malformed or missing, or whose non-tombstone entry - /// has no `name`, naming the offending path. + /// missing side. A directory that holds an `entry.md` mounts that + /// file at the directory's own address, and the directory's `log/` + /// stays out of the union. Fails on the first layer root that does + /// not exist or is not a directory, the first file that cannot be + /// read, whose frontmatter is malformed or missing, or whose + /// non-tombstone entry has no `name`, naming the offending path. pub fn open(layers: &[PathBuf]) -> Result { let mut entries: BTreeMap = BTreeMap::new(); for layer_root in layers { check_layer_root(layer_root)?; for root_name in ROOTS { let root_dir = layer_root.join(root_name); - for file_path in find_md_files(&root_dir) { + let mut files = find_md_files(&root_dir); + // Entry files go in last, so within one layer a directory + // entity beats a plain file left behind at the same + // address. Path order settles the rest, which keeps a + // layer's index the same whatever order the walk found + // its files in. + files.sort_by(|left, right| { + is_entry_file(left) + .cmp(&is_entry_file(right)) + .then_with(|| left.cmp(right)) + }); + for file_path in files { let address = address_for(root_name, &root_dir, &file_path); let key = address.to_lowercase(); match parse_entry(&file_path, &address, layer_root)? { @@ -185,9 +199,10 @@ } Ok(()) } -/// 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. +/// Every `.md` file under `dir`, at any depth, except the files inside an +/// entity's history directory. 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 { @@ -196,6 +211,9 @@ }; for entry in read_dir.flatten() { let path = entry.path(); if path.is_dir() { + if is_history_directory(&path) { + continue; + } found.extend(find_md_files(&path)); } else if path.extension().and_then(|ext| ext.to_str()) == Some("md") { found.push(path); @@ -207,17 +225,12 @@ /// 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. +/// An `entry.md` addresses the directory that holds it. 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 + .expect("find_md_files only returns paths under root_dir"); + address_of(&Path::new(root_name).join(relative)) } #[cfg(test)] diff --git a/src/knowledge/mount_tests.rs b/src/knowledge/mount_tests.rs --- a/src/knowledge/mount_tests.rs +++ b/src/knowledge/mount_tests.rs @@ -386,6 +386,211 @@ assert!(error.contains("invisible")); } #[test] +fn a_directory_holding_an_entry_file_mounts_at_the_directorys_address() { + let layer = Layer::empty(); + layer.write_entry( + "lore/characters/sister-maren/entry", + "Sister Maren", + Some("character"), + "A cleric of the drowned coast.\n", + ); + + let mount = open(&[&layer]).unwrap(); + + let maren = mount.get("lore/characters/sister-maren").unwrap(); + assert_eq!(maren.address, "lore/characters/sister-maren"); + assert_eq!(maren.name, "Sister Maren"); +} + +#[test] +fn an_entry_file_never_mounts_under_its_own_name() { + let layer = Layer::empty(); + layer.write_entry( + "lore/characters/sister-maren/entry", + "Sister Maren", + None, + "body\n", + ); + + let mount = open(&[&layer]).unwrap(); + + assert!(mount.get("lore/characters/sister-maren/entry").is_none()); + assert_eq!(mount.entries().count(), 1); +} + +#[test] +fn an_entity_directorys_log_stays_out_of_the_mount() { + let layer = Layer::empty(); + layer.write_entry( + "lore/characters/sister-maren/entry", + "Sister Maren", + None, + "body\n", + ); + layer.write_entry( + "lore/characters/sister-maren/log/0001", + "Day one", + None, + "hp: 22 → 15\n", + ); + + let mount = open(&[&layer]).unwrap(); + + assert!(mount.get("lore/characters/sister-maren/log/0001").is_none()); + assert_eq!(mount.entries().count(), 1); +} + +#[test] +fn a_log_directory_with_no_entry_file_beside_it_mounts_normally() { + let layer = Layer::empty(); + layer.write_entry("lore/log/0001", "Day one", None, "body\n"); + + let mount = open(&[&layer]).unwrap(); + + assert!(mount.get("lore/log/0001").is_some()); +} + +#[test] +fn a_sub_entry_beside_an_entry_file_keeps_its_own_address() { + let layer = Layer::empty(); + layer.write_entry( + "lore/places/weatherford/entry", + "Weatherford", + Some("town"), + "A port town.\n", + ); + layer.write_entry( + "lore/places/weatherford/rusty-anchor", + "The Rusty Anchor", + Some("tavern"), + "The town's only tavern.\n", + ); + + let mount = open(&[&layer]).unwrap(); + + assert_eq!( + mount.get("lore/places/weatherford").unwrap().name, + "Weatherford" + ); + assert_eq!( + mount + .get("lore/places/weatherford/rusty-anchor") + .unwrap() + .name, + "The Rusty Anchor" + ); +} + +#[test] +fn a_higher_layers_directory_entity_shadows_a_lower_layers_file() { + let low = Layer::empty(); + low.write_entry( + "lore/places/weatherford", + "Weatherford (low)", + None, + "low\n", + ); + let high = Layer::empty(); + high.write_entry( + "lore/places/weatherford/entry", + "Weatherford (high)", + None, + "high\n", + ); + + let mount = open(&[&low, &high]).unwrap(); + + let weatherford = mount.get("lore/places/weatherford").unwrap(); + assert_eq!(weatherford.name, "Weatherford (high)"); + assert_eq!(weatherford.layer, high.path()); +} + +#[test] +fn a_higher_layers_file_shadows_a_lower_layers_directory_entity() { + let low = Layer::empty(); + low.write_entry( + "lore/places/weatherford/entry", + "Weatherford (low)", + None, + "low\n", + ); + let high = Layer::empty(); + high.write_entry( + "lore/places/weatherford", + "Weatherford (high)", + None, + "high\n", + ); + + let mount = open(&[&low, &high]).unwrap(); + + let weatherford = mount.get("lore/places/weatherford").unwrap(); + assert_eq!(weatherford.name, "Weatherford (high)"); + assert_eq!(weatherford.layer, high.path()); +} + +#[test] +fn a_higher_layers_directory_entity_shadows_a_lower_layers_directory_entity() { + let low = Layer::empty(); + low.write_entry( + "lore/places/weatherford/entry", + "Weatherford (low)", + None, + "low\n", + ); + let high = Layer::empty(); + high.write_entry( + "lore/places/weatherford/entry", + "Weatherford (high)", + None, + "high\n", + ); + + let mount = open(&[&low, &high]).unwrap(); + + assert_eq!( + mount.get("lore/places/weatherford").unwrap().name, + "Weatherford (high)" + ); +} + +#[test] +fn a_directory_entity_wins_over_a_leftover_file_in_the_same_layer() { + let layer = Layer::empty(); + layer.write_entry( + "lore/places/weatherford", + "Weatherford (file)", + None, + "old\n", + ); + layer.write_entry( + "lore/places/weatherford/entry", + "Weatherford (directory)", + None, + "new\n", + ); + + let mount = open(&[&layer]).unwrap(); + + assert_eq!( + mount.get("lore/places/weatherford").unwrap().name, + "Weatherford (directory)" + ); +} + +#[test] +fn a_directory_form_tombstone_hides_a_lower_layers_file() { + let low = Layer::empty(); + low.write_entry("lore/places/weatherford", "Weatherford", None, "body\n"); + let high = Layer::empty(); + high.write_tombstone("lore/places/weatherford/entry"); + + let mount = open(&[&low, &high]).unwrap(); + + assert!(mount.get("lore/places/weatherford").is_none()); +} + +#[test] fn context_and_visibility_on_a_tombstone_are_ignored() { let layer = Layer::empty(); layer.write(