//! 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, /// 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, } /// 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, /// Map from lowercase name to item info. pub items_by_name: HashMap, /// Map from lowercase alias to item info. pub items_by_alias: HashMap, } 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 { // 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 { 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 = None; let mut title: Option = None; let mut id: Option = None; let mut item_type: Option = None; let mut aliases: Vec = Vec::new(); let mut summary: Option = None; let mut description: Option = 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 { 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 { // (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 { 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::() + 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"]); } }