Files
md-manuscript/src/story_bible.rs
T
landon 229e2413c4 Add summary: to character templates; fix two bugs that were hiding it
Add a summary: field (with a short explanatory comment) to
01_character_template.md, and fill the field in on the two shipped
examples, 01_protagonist.md and 02_antagonist.md. Documented in the
Story Bible README's "Adding a New Character" example and its new
"Automatic Linking in the Draft" section, and in the main README's
Story Bible docs.

While wiring this up against the real bundled template rather than a
hand-written test fixture, found two bugs that were silently defeating
last session's auto-linking feature for every project made from that
template:

- extract_from_frontmatter() only read a title: field for an item's
  name, but every bundled Story Bible template — character, location,
  lore, object, all of them — writes name:, never title:. Every real
  item's display name (and therefore everything matched against it in
  prose) was falling back to a garbled, uncapitalized filename guess
  instead. Now name: is read (title: still works too, for a sheet that
  uses that word instead).

- build_editor_job()'s fast path skipped straight to an unformatted
  run whenever there were no grammar/spelling issues and no active
  search — without checking whether there was a Story Bible reference
  to highlight. A name with no spelling issue of its own (which is
  most of them) never got its purple background, and with it no visible
  way to find the hover card that depends on it.

Both were invisible in the unit tests added last session because they
exercised find_references() directly against a hand-built index, never
through StoryBibleIndex::build() against real files, and the manual
smoke test happened to use a name ("Bixby") that the spell checker
flagged anyway — which incidentally kept the fast path from short-
circuiting. Added a disk-built-index test and a fast-path regression
test so both stay caught.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 11:32:56 -05:00

868 lines
32 KiB
Rust

//! Story Bible indexing and linking functionality.
//!
//! This module provides functionality to index Story Bible items (characters,
//! locations, lore, etc.) and create bidirectional links between Story Bible
//! entries and draft text.
use std::collections::HashMap;
use std::path::Path;
/// A reference to a Story Bible item found in draft text.
#[derive(Debug, Clone)]
pub struct StoryBibleReference {
/// The file path of the draft file containing this reference.
pub file_path: String,
/// The line number where the reference appears (1-indexed).
pub line_number: usize,
/// Byte offset of the reference's start in the whole buffer.
pub start: usize,
/// Byte offset just past the reference's end in the whole buffer.
pub end: usize,
/// The id of the Story Bible item this reference resolves to.
pub item_id: String,
/// The matched text, exactly as it appears in the draft.
pub identifier: String,
}
/// Information about a Story Bible item extracted from its file.
#[derive(Debug, Clone)]
pub struct StoryBibleItem {
/// Workspace-relative path to the item's file.
pub path: String,
/// The item's display name.
pub name: String,
/// The item's unique identifier (from ID field or filename).
pub id: String,
/// The item's type (character, location, lore, etc.).
pub item_type: String,
/// Alternative names for this item (from aliases field in YAML frontmatter).
pub aliases: Vec<String>,
/// Line in the file where the name/ID is defined.
pub definition_line: usize,
/// A short summary for the hover card, from a `summary:`/`description:`
/// frontmatter field. `None` when the sheet doesn't have one.
pub summary: Option<String>,
}
/// Index of all Story Bible items for quick lookup.
#[derive(Debug, Default)]
pub struct StoryBibleIndex {
/// Map from lowercase identifier to item info.
pub items_by_id: HashMap<String, StoryBibleItem>,
/// Map from lowercase name to item info.
pub items_by_name: HashMap<String, StoryBibleItem>,
/// Map from lowercase alias to item info.
pub items_by_alias: HashMap<String, StoryBibleItem>,
}
impl StoryBibleIndex {
/// Create a new empty index.
pub fn new() -> Self {
Self {
items_by_id: HashMap::new(),
items_by_name: HashMap::new(),
items_by_alias: HashMap::new(),
}
}
/// Build index from Story Bible directory.
///
/// Scans all markdown files in the Story Bible folder and extracts
/// item information from YAML frontmatter or filename.
pub fn build(story_bible_dir: &Path) -> Self {
let mut index = Self::new();
if !story_bible_dir.exists() {
return index;
}
// Walk through all markdown files in the Story Bible directory
for entry in walkdir::WalkDir::new(story_bible_dir)
.follow_links(true)
.into_iter()
.filter_map(Result::ok)
{
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("md") {
continue;
}
let relative_path = path
.strip_prefix(story_bible_dir)
.unwrap_or(path)
.to_string_lossy()
.to_string();
// Read the file and extract metadata
if let Ok(content) = std::fs::read_to_string(path) {
if let Some(item) = extract_story_bible_item(&relative_path, &content) {
// Index by ID (lowercase for case-insensitive lookup)
index
.items_by_id
.insert(item.id.to_lowercase(), item.clone());
// Index by name (lowercase for case-insensitive lookup)
index
.items_by_name
.insert(item.name.to_lowercase(), item.clone());
// Index by aliases (lowercase for case-insensitive lookup)
for alias in &item.aliases {
index
.items_by_alias
.insert(alias.to_lowercase(), item.clone());
}
}
}
}
index
}
/// Find an item by its identifier.
pub fn find_by_id(&self, id: &str) -> Option<&StoryBibleItem> {
self.items_by_id.get(&id.to_lowercase())
}
/// Find an item by its name.
pub fn find_by_name(&self, name: &str) -> Option<&StoryBibleItem> {
self.items_by_name.get(&name.to_lowercase())
}
/// Find an item by one of its aliases.
pub fn find_by_alias(&self, alias: &str) -> Option<&StoryBibleItem> {
self.items_by_alias.get(&alias.to_lowercase())
}
/// Find all items matching a partial name or alias.
pub fn find_by_partial_name(&self, query: &str) -> Vec<&StoryBibleItem> {
let query_lower = query.to_lowercase();
self.items_by_name
.values()
.filter(|item| {
item.name.to_lowercase().contains(&query_lower)
|| item
.aliases
.iter()
.any(|a| a.to_lowercase().contains(&query_lower))
})
.collect()
}
/// Get all items in the index.
pub fn all_items(&self) -> Vec<&StoryBibleItem> {
self.items_by_id.values().collect()
}
}
/// Extract Story Bible item information from a file.
fn extract_story_bible_item(relative_path: &str, content: &str) -> Option<StoryBibleItem> {
// Try to extract from YAML frontmatter first
if let Some(item) = extract_from_frontmatter(relative_path, content) {
return Some(item);
}
// Fallback: extract from filename
let path_obj = Path::new(relative_path);
let filename = path_obj.file_stem().and_then(|s| s.to_str())?;
// Determine type from directory structure
let item_type = path_obj
.parent()
.and_then(|p| p.to_str())
.map(|s| s.to_lowercase())
.unwrap_or_else(|| "unknown".to_string());
// Use filename as name
let name = filename.replace('_', " ").replace('-', " ");
Some(StoryBibleItem {
path: relative_path.to_string(),
name: name.clone(),
id: name.to_lowercase().replace(' ', "_"),
item_type,
aliases: Vec::new(),
definition_line: 0,
summary: None,
})
}
/// Extract item information from YAML frontmatter.
fn extract_from_frontmatter(relative_path: &str, content: &str) -> Option<StoryBibleItem> {
let lines: Vec<&str> = content.lines().collect();
// Find frontmatter boundaries
let start = lines.iter().position(|line| line.trim() == "---")?;
let end = lines[start + 1..]
.iter()
.position(|line| line.trim() == "---")
.map(|pos| start + 1 + pos)?;
if end <= start + 1 {
return None;
}
let mut name: Option<String> = None;
let mut title: Option<String> = None;
let mut id: Option<String> = None;
let mut item_type: Option<String> = None;
let mut aliases: Vec<String> = Vec::new();
let mut summary: Option<String> = None;
let mut description: Option<String> = None;
for line in &lines[start + 1..end] {
let trimmed = line.trim();
// `name:` is what every bundled Story Bible template actually
// writes; `title:` is accepted too since a scene/draft file's own
// frontmatter uses that word and a sheet may borrow it by habit.
if trimmed.starts_with("name:") {
name = Some(trimmed["name:".len()..].trim().trim_matches('"').to_string());
} else if trimmed.starts_with("title:") {
title = Some(trimmed["title:".len()..].trim().trim_matches('"').to_string());
} else if trimmed.starts_with("id:") {
id = Some(trimmed["id:".len()..].trim().trim_matches('"').to_string());
} else if trimmed.starts_with("type:") {
item_type = Some(trimmed["type:".len()..].trim().trim_matches('"').to_string());
} else if trimmed.starts_with("aliases:") {
// Parse aliases as a YAML list: aliases: ["Alias 1", "Alias 2"] or aliases: Alias1, Alias2
let value = trimmed["aliases:".len()..].trim();
aliases = parse_aliases(value);
} else if trimmed.starts_with("summary:") {
let value = trimmed["summary:".len()..].trim().trim_matches('"').trim();
if !value.is_empty() {
summary = Some(value.to_string());
}
} else if trimmed.starts_with("description:") {
let value = trimmed["description:".len()..].trim().trim_matches('"').trim();
if !value.is_empty() {
description = Some(value.to_string());
}
}
}
// `summary:` wins if a sheet somehow has both.
let summary = summary.or(description);
// Use name, then title, then the filename as fallback.
let name = name.or(title).unwrap_or_else(|| {
Path::new(relative_path)
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.replace('_', " ").replace('-', " "))
.unwrap_or_else(|| "Unknown".to_string())
});
// Use ID or sanitized name as fallback
let id = id.unwrap_or_else(|| {
name.to_lowercase().replace(' ', "_").replace('-', "_")
});
// Use type from frontmatter or directory
let item_type = item_type.unwrap_or_else(|| {
Path::new(relative_path)
.parent()
.and_then(|p| p.to_str())
.map(|s| {
let dir_name = s.to_lowercase();
if dir_name.contains("character") || dir_name.contains("cast") {
"character".to_string()
} else if dir_name.contains("location") || dir_name.contains("setting") {
"location".to_string()
} else if dir_name.contains("lore") || dir_name.contains("world") {
"lore".to_string()
} else {
dir_name
}
})
.unwrap_or_else(|| "unknown".to_string())
});
Some(StoryBibleItem {
path: relative_path.to_string(),
name,
id,
item_type,
aliases,
definition_line: start + 1,
summary,
})
}
/// Parse aliases from YAML frontmatter value.
/// Supports both list syntax: aliases: ["Alias 1", "Alias 2"]
/// and comma-separated: aliases: Alias1, Alias2
fn parse_aliases(value: &str) -> Vec<String> {
if value.is_empty() {
return Vec::new();
}
// Check if it's a YAML list: ["...", "..."]
if value.trim().starts_with('[') && value.trim().ends_with(']') {
let inner = value.trim()[1..value.trim().len() - 1].trim();
// Parse items from the list
let mut aliases = Vec::new();
let mut in_quote = false;
let mut quote_char = '\0';
let mut current = String::new();
for c in inner.chars() {
match c {
'"' | '\'' => {
if in_quote && c == quote_char {
in_quote = false;
quote_char = '\0';
if !current.is_empty() {
aliases.push(current.clone());
current.clear();
}
} else if !in_quote {
in_quote = true;
quote_char = c;
}
}
',' => {
if !in_quote {
if !current.is_empty() {
aliases.push(current.clone());
current.clear();
}
} else {
current.push(c);
}
}
_ => {
if in_quote {
current.push(c);
}
}
}
}
// Add the last item
if !current.is_empty() {
aliases.push(current);
}
return aliases;
}
// Comma-separated format
value
.split(',')
.map(|s| s.trim().trim_matches('"').trim_matches('\'').to_string())
.filter(|s| !s.is_empty())
.collect()
}
/// Find all Story Bible references in `text`, absolute byte offsets into the
/// whole buffer (not per-line), so callers can map them straight onto a galley.
///
/// Two kinds, in priority order — an overlap always keeps the higher one:
///
/// 1. **Explicit markup** — `[[Name]]` or `@Name`, resolved by ID, name or
/// alias. The author asked for this link by hand, so it always wins.
/// 2. **Bare mentions** — a known item's name or an alias, spelled exactly as
/// it appears in its Story Bible sheet, found in ordinary prose with no
/// markup at all. This is what makes the linking automatic: plan the cast
/// once in the Story Bible, then just write their names.
///
/// Bare matching is case-sensitive and requires the candidate to start with
/// an uppercase letter — names are written that way both in the sheet and as
/// proper nouns in prose, and that's what keeps a character named "Hope" or
/// "Will" from lighting up on every unrelated use of the word. A phrase under
/// three characters is skipped too, since a two-letter alias (initials, say)
/// matches too much of everyday prose to be worth auto-detecting; it still
/// works through the explicit `[[...]]`/`@` markup above.
pub fn find_references(text: &str, index: &StoryBibleIndex) -> Vec<StoryBibleReference> {
// (start, end, item_id, identifier) — accepted spans, explicit first.
let mut accepted: Vec<(usize, usize, String, String)> = Vec::new();
// 1. Explicit [[...]] and @Name markup. Walked one *char* at a time
// (manuscript prose is full of accented letters and em dashes, and a
// byte-at-a-time walk can land mid-character and panic on the next slice).
let mut pos = 0usize;
while pos < text.len() {
if text[pos..].starts_with("[[") {
// `]]` only counts on the same line — an unterminated `[[` should
// not swallow the rest of the document looking for a closer.
let line_end = text[pos..]
.find('\n')
.map(|o| pos + o)
.unwrap_or(text.len());
if let Some(rel_end) = text[pos + 2..line_end].find("]]") {
let end = pos + 2 + rel_end + 2;
let identifier = text[pos + 2..end - 2].trim().to_string();
if let Some(item) = resolve(index, &identifier) {
accepted.push((pos, end, item.id.clone(), identifier));
}
pos = end;
continue;
}
} else if text.as_bytes()[pos] == b'@' && pos + 1 < text.len() {
let rest = &text[pos + 1..];
let word_end = pos
+ 1
+ rest
.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '-')
.unwrap_or(rest.len());
let identifier = text[pos + 1..word_end].to_string();
if !identifier.is_empty() {
if let Some(item) = resolve(index, &identifier) {
accepted.push((pos, word_end, item.id.clone(), identifier));
}
}
pos = word_end;
continue;
}
pos += text[pos..].chars().next().map(char::len_utf8).unwrap_or(1);
}
// 2. Bare mentions. Every item's name and aliases, longest phrase first
// so "Bixby Reynolds" claims its span before "Bixby" alone gets a chance.
let mut candidates: Vec<(&str, &str)> = Vec::new(); // (phrase, item_id)
for item in index.items_by_id.values() {
candidates.push((item.name.as_str(), item.id.as_str()));
for alias in &item.aliases {
candidates.push((alias.as_str(), item.id.as_str()));
}
}
candidates.retain(|(phrase, _)| {
phrase.len() >= 3 && phrase.chars().next().is_some_and(char::is_uppercase)
});
candidates.sort_by_key(|(phrase, _)| std::cmp::Reverse(phrase.len()));
let is_word_char = |c: char| c.is_alphanumeric() || c == '_';
for (phrase, item_id) in candidates {
for (start, matched) in text.match_indices(phrase) {
let end = start + matched.len();
let before_ok = text[..start]
.chars()
.next_back()
.map_or(true, |c| !is_word_char(c));
let after_ok = text[end..].chars().next().map_or(true, |c| !is_word_char(c));
if !before_ok || !after_ok {
continue;
}
if accepted.iter().any(|&(s, e, ..)| start < e && end > s) {
continue; // overlaps an explicit link or a longer bare match
}
accepted.push((start, end, item_id.to_string(), phrase.to_string()));
}
}
accepted.sort_by_key(|&(start, ..)| start);
let line_starts = line_start_offsets(text);
accepted
.into_iter()
.map(|(start, end, item_id, identifier)| StoryBibleReference {
file_path: String::new(), // set by the caller
line_number: line_number_at(&line_starts, start),
start,
end,
item_id,
identifier,
})
.collect()
}
/// Look up an identifier by ID, then name, then alias — whichever matches
/// first, the same fallback order the old separate call sites each used.
fn resolve<'a>(index: &'a StoryBibleIndex, identifier: &str) -> Option<&'a StoryBibleItem> {
index
.find_by_id(identifier)
.or_else(|| index.find_by_name(identifier))
.or_else(|| index.find_by_alias(identifier))
}
/// Byte offset of the start of each line, `text[0]` included, in order.
fn line_start_offsets(text: &str) -> Vec<usize> {
std::iter::once(0)
.chain(text.match_indices('\n').map(|(i, _)| i + 1))
.collect()
}
/// 1-indexed line number containing `byte_offset`, from a table built by
/// [`line_start_offsets`].
fn line_number_at(line_starts: &[usize], byte_offset: usize) -> usize {
match line_starts.binary_search(&byte_offset) {
Ok(i) => i + 1,
Err(i) => i,
}
}
/// Generate a markdown link to a Story Bible item.
pub fn link_to_item(item: &StoryBibleItem, display_text: Option<&str>) -> String {
let text = display_text.unwrap_or(&item.name);
format!("[{}]({})", text, item.path)
}
/// A short, reader-friendly label for an item's type, for a hover card or
/// list row — "Person" for a character sheet, "Place" for a location, and
/// Title Case of anything else (lore, object, theme, …), since those don't
/// reduce to one generic word.
pub fn type_label(item_type: &str) -> String {
match item_type.to_lowercase().as_str() {
"character" | "characters" => "Person".to_string(),
"location" | "locations" => "Place".to_string(),
"object" | "objects" => "Thing".to_string(),
other => {
let mut chars = other.chars();
match chars.next() {
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
None => "Entry".to_string(),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_from_frontmatter() {
let content = r#"---
id: character_01
title: "John Doe"
type: character
---
Some content here."#;
let item = extract_from_frontmatter("test.md", content).unwrap();
assert_eq!(item.id, "character_01");
assert_eq!(item.name, "John Doe");
assert_eq!(item.item_type, "character");
}
#[test]
fn frontmatter_reads_name_the_way_every_bundled_template_actually_writes_it() {
// Every sheet the snowflake template ships — character, location,
// lore, object — writes `name:`, never `title:`. A regression check
// for the mismatch that made every item's name fall back to a
// filename-derived guess instead.
let content = r#"---
id: bixby_reynolds
name: Bixby Reynolds
type: protagonist
---
# Bixby Reynolds"#;
let item = extract_from_frontmatter("main_characters/01_bixby.md", content).unwrap();
assert_eq!(item.name, "Bixby Reynolds");
}
#[test]
fn frontmatter_prefers_name_over_title_when_a_sheet_somehow_has_both() {
let content = r#"---
name: Real Name
title: Old Spelling
---
"#;
let item = extract_from_frontmatter("test.md", content).unwrap();
assert_eq!(item.name, "Real Name");
}
#[test]
fn frontmatter_summary_feeds_the_hover_card() {
let content = r#"---
id: character_01
title: "John Doe"
type: character
summary: A retired locksmith with one unfinished job.
---
Some content here."#;
let item = extract_from_frontmatter("test.md", content).unwrap();
assert_eq!(
item.summary.as_deref(),
Some("A retired locksmith with one unfinished job.")
);
}
#[test]
fn frontmatter_falls_back_to_description_and_prefers_summary() {
let description_only = r#"---
title: "Jane"
description: Appears in Act 2.
---
"#;
let item = extract_from_frontmatter("test.md", description_only).unwrap();
assert_eq!(item.summary.as_deref(), Some("Appears in Act 2."));
let both = r#"---
title: "Jane"
summary: The short version.
description: The long version.
---
"#;
let item = extract_from_frontmatter("test.md", both).unwrap();
assert_eq!(item.summary.as_deref(), Some("The short version."));
}
#[test]
fn test_find_references_wiki_links() {
let index = {
let mut idx = StoryBibleIndex::new();
idx.items_by_id.insert(
"john_doe".to_string(),
StoryBibleItem {
path: "Characters/john_doe.md".to_string(),
name: "John Doe".to_string(),
id: "john_doe".to_string(),
item_type: "character".to_string(),
aliases: Vec::new(),
definition_line: 0,
summary: None,
},
);
idx.items_by_name.insert(
"john doe".to_string(),
StoryBibleItem {
path: "Characters/john_doe.md".to_string(),
name: "John Doe".to_string(),
id: "john_doe".to_string(),
item_type: "character".to_string(),
aliases: Vec::new(),
definition_line: 0,
summary: None,
},
);
idx
};
let refs = find_references("Some text with [[John Doe]] here.", &index);
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].identifier, "John Doe");
}
#[test]
fn test_find_references_mentions() {
let index = {
let mut idx = StoryBibleIndex::new();
let item = StoryBibleItem {
path: "Characters/jane.md".to_string(),
name: "Jane".to_string(),
id: "jane".to_string(),
item_type: "character".to_string(),
aliases: Vec::new(),
definition_line: 0,
summary: None,
};
idx.items_by_name.insert("jane".to_string(), item.clone());
idx.items_by_id.insert("jane".to_string(), item);
idx
};
let refs = find_references("Some text mentioning @Jane here.", &index);
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].identifier, "Jane");
}
#[test]
fn test_find_by_alias() {
let mut index = StoryBibleIndex::new();
let item = StoryBibleItem {
path: "Characters/john.md".to_string(),
name: "John Smith".to_string(),
id: "john_smith".to_string(),
item_type: "character".to_string(),
aliases: vec!["Johnny".to_string(), "JS".to_string()],
definition_line: 0,
summary: None,
};
index.items_by_id.insert("john_smith".to_string(), item.clone());
index.items_by_name.insert("john smith".to_string(), item.clone());
index.items_by_alias.insert("johnny".to_string(), item.clone());
index.items_by_alias.insert("js".to_string(), item);
assert!(index.find_by_alias("Johnny").is_some());
assert!(index.find_by_alias("JS").is_some());
assert!(index.find_by_alias("johnny").is_some());
assert!(index.find_by_alias("nonexistent").is_none());
}
#[test]
fn test_find_by_partial_name_with_aliases() {
let mut index = StoryBibleIndex::new();
let item = StoryBibleItem {
path: "Characters/john.md".to_string(),
name: "John Smith".to_string(),
id: "john_smith".to_string(),
item_type: "character".to_string(),
aliases: vec!["Johnny".to_string(), "JS".to_string()],
definition_line: 0,
summary: None,
};
index.items_by_name.insert("john smith".to_string(), item);
let results = index.find_by_partial_name("Johnny");
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "John Smith");
let results = index.find_by_partial_name("JS");
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "John Smith");
}
#[test]
fn test_find_references_with_aliases() {
let mut index = StoryBibleIndex::new();
let item = StoryBibleItem {
path: "Characters/john.md".to_string(),
name: "John Smith".to_string(),
id: "john_smith".to_string(),
item_type: "character".to_string(),
aliases: vec!["Johnny".to_string()],
definition_line: 0,
summary: None,
};
index.items_by_name.insert("john smith".to_string(), item.clone());
index.items_by_id.insert("john_smith".to_string(), item.clone());
index.items_by_alias.insert("johnny".to_string(), item);
let refs = find_references("Some text with [[Johnny]] here.", &index);
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].identifier, "Johnny");
let refs = find_references("Some text with @Johnny here.", &index);
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].identifier, "Johnny");
}
/// A one-item index for the bare-mention tests below, keyed by name and
/// (if given) aliases — the same shape a real `StoryBibleIndex::build`
/// produces for a single character sheet.
fn index_with(id: &str, name: &str, aliases: &[&str]) -> StoryBibleIndex {
let item = StoryBibleItem {
path: format!("Characters/{id}.md"),
name: name.to_string(),
id: id.to_string(),
item_type: "character".to_string(),
aliases: aliases.iter().map(|a| a.to_string()).collect(),
definition_line: 0,
summary: None,
};
let mut index = StoryBibleIndex::new();
index.items_by_id.insert(id.to_string(), item.clone());
index.items_by_name.insert(name.to_lowercase(), item.clone());
for alias in aliases {
index.items_by_alias.insert(alias.to_lowercase(), item.clone());
}
index
}
#[test]
fn a_bare_mention_is_found_without_any_markup() {
let index = index_with("bixby", "Bixby", &[]);
let refs = find_references("Bixby walked into the room.", &index);
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].identifier, "Bixby");
assert_eq!(refs[0].item_id, "bixby");
assert_eq!(&"Bixby walked into the room."[refs[0].start..refs[0].end], "Bixby");
}
#[test]
fn a_bare_mention_matches_against_a_real_disk_built_index() {
// The hand-built `index_with` above exercises the matching rules;
// this instead goes through `StoryBibleIndex::build`, the same path
// a real project's character sheet takes, as a check that nothing
// is lost crossing that boundary (frontmatter parsing, `id`
// lowercasing, and so on).
let dir = std::env::temp_dir().join(format!(
"md_manuscript_test_{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("protagonist.md"),
"---\nid: protagonist\nname: Protagonist\nsummary: \"hero\"\nslug: protagonist\ntype: protagonist\n---\n\n# Protagonist\n",
)
.unwrap();
let index = StoryBibleIndex::build(&dir);
let refs = find_references("Protagonist stood at the door.", &index);
std::fs::remove_dir_all(&dir).ok();
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].item_id, "protagonist");
}
#[test]
fn a_bare_mention_respects_word_boundaries() {
let index = index_with("bixby", "Bixby", &[]);
let refs = find_references("Bixbys and Bixbyish things aren't Bixby.", &index);
assert_eq!(refs.len(), 1, "only the standalone \"Bixby\" should match");
assert_eq!(refs[0].start, 34);
}
#[test]
fn a_bare_mention_is_case_sensitive_to_dodge_common_words() {
// A character named "Hope" must not light up every ordinary use of
// the word "hope" — only the capitalized, name-spelled uses.
let index = index_with("hope", "Hope", &[]);
let refs = find_references("She had no hope left, until Hope arrived.", &index);
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].start, 28);
}
#[test]
fn the_longest_bare_mention_wins_an_overlap() {
let index = index_with("bixby_reynolds", "Bixby Reynolds", &["Bixby"]);
let refs = find_references("Bixby Reynolds frowned.", &index);
assert_eq!(refs.len(), 1, "the full name should claim the span, not just \"Bixby\"");
assert_eq!(refs[0].identifier, "Bixby Reynolds");
}
#[test]
fn a_bare_mention_does_not_duplicate_an_explicit_link() {
let index = index_with("bixby", "Bixby", &[]);
let refs = find_references("Meet [[Bixby]] properly.", &index);
assert_eq!(refs.len(), 1, "the explicit [[...]] span should win, not add a second match");
}
#[test]
fn reference_offsets_are_absolute_into_the_whole_buffer() {
// A regression check for the bug where offsets were relative to each
// line, so anything past line 1 highlighted (and jumped to) the wrong
// place in the editor.
let index = index_with("bixby", "Bixby", &[]);
let text = "First line.\nSecond line has Bixby in it.";
let refs = find_references(text, &index);
assert_eq!(refs.len(), 1);
assert_eq!(&text[refs[0].start..refs[0].end], "Bixby");
assert_eq!(refs[0].line_number, 2);
}
#[test]
fn test_parse_aliases_yaml_list() {
let aliases = parse_aliases(r#"["Alias 1", "Alias 2", "Alias 3"]"#);
assert_eq!(aliases, vec!["Alias 1", "Alias 2", "Alias 3"]);
}
#[test]
fn test_parse_aliases_comma_separated() {
let aliases = parse_aliases("Alias1, Alias2, Alias3");
assert_eq!(aliases, vec!["Alias1", "Alias2", "Alias3"]);
}
#[test]
fn test_parse_aliases_empty() {
let aliases = parse_aliases("");
assert!(aliases.is_empty());
}
#[test]
fn test_extract_from_frontmatter_with_aliases() {
let content = r#"---
id: character_01
title: "John Doe"
type: character
aliases: ["Johnny", "JS"]
---
Some content here."#;
let item = extract_from_frontmatter("test.md", content).unwrap();
assert_eq!(item.id, "character_01");
assert_eq!(item.name, "John Doe");
assert_eq!(item.item_type, "character");
assert_eq!(item.aliases, vec!["Johnny", "JS"]);
}
}