Files
md-manuscript/src/app/story_bible_editor.rs
T
landon 8b352d25ed Let the Story Bible Editor rename/move files
Right-clicking a file row now offers "Rename / Move…", mirroring the
manuscript file panel's combined rename-and-move pattern: editing the
name renames, editing the folder part moves it across the tree.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-03 14:08:40 -05:00

822 lines
33 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Story Bible breakout editor: a dedicated window for editing Story Bible items.
//!
//! Provides a tree view of Story Bible items on the left and an editor on the right.
use super::*;
use std::fs;
use std::path::{Path, PathBuf};
/// A tree node for the Story Bible tree view.
#[derive(Debug, Clone)]
pub enum BibleTreeNode {
/// A directory/folder node with children
Directory {
name: String,
path: String,
children: Vec<BibleTreeNode>,
},
/// A file/node (leaf)
File {
name: String,
path: String,
},
}
impl BibleTreeNode {
/// Create a directory node.
fn dir(name: String, path: String, children: Vec<BibleTreeNode>) -> Self {
Self::Directory { name, path, children }
}
/// Create a file node.
fn file(name: String, path: String) -> Self {
Self::File { name, path }
}
}
impl App {
/// Build the Story Bible tree structure from the Story Bible directory.
pub(super) fn build_story_bible_tree(&self) -> Vec<BibleTreeNode> {
let story_bible_dir = self.find_story_bible_directory();
let Some(base_dir) = story_bible_dir else {
return Vec::new();
};
let mut root_nodes = Vec::new();
// Read the directory structure
if let Ok(entries) = fs::read_dir(&base_dir) {
let mut entries: Vec<_> = entries.filter_map(Result::ok).collect();
// Sort entries: directories first, then files, both alphabetically
entries.sort_by(|a, b| {
let a_is_dir = a.file_type().map_or(false, |ft| ft.is_dir());
let b_is_dir = b.file_type().map_or(false, |ft| ft.is_dir());
if a_is_dir != b_is_dir {
b_is_dir.cmp(&a_is_dir) // dirs first
} else {
a.file_name().cmp(&b.file_name())
}
});
for entry in entries {
let path = entry.path();
let file_name = entry.file_name().to_string_lossy().to_string();
// Skip hidden files/directories
if file_name.starts_with('.') {
continue;
}
let is_dir = entry.file_type().map_or(false, |ft| ft.is_dir());
if is_dir {
// Recursively build subtree
let rel_path = path.strip_prefix(&base_dir).unwrap_or(&path).to_string_lossy().to_string();
let display_name = if rel_path.is_empty() {
base_dir.file_name().unwrap_or_else(|| "Story Bible".as_ref()).to_string_lossy().to_string()
} else {
path.file_name().unwrap_or_else(|| "".as_ref()).to_string_lossy().to_string()
};
let children = Self::build_tree_recursive(&path, &base_dir);
root_nodes.push(BibleTreeNode::dir(display_name, rel_path, children));
} else if path.extension().and_then(|s| s.to_str()) == Some("md") {
// It's a markdown file
let rel_path = path.strip_prefix(&base_dir).unwrap_or(&path).to_string_lossy().to_string();
let display_name = path.file_stem().unwrap_or_else(|| "".as_ref()).to_string_lossy().to_string();
root_nodes.push(BibleTreeNode::file(display_name, rel_path));
}
}
}
root_nodes
}
/// Recursively build tree from a directory.
fn build_tree_recursive(dir_path: &PathBuf, base_dir: &PathBuf) -> Vec<BibleTreeNode> {
let mut children = Vec::new();
if let Ok(entries) = fs::read_dir(dir_path) {
let mut entries: Vec<_> = entries.filter_map(Result::ok).collect();
// Sort entries: directories first, then files, both alphabetically
entries.sort_by(|a, b| {
let a_is_dir = a.file_type().map_or(false, |ft| ft.is_dir());
let b_is_dir = b.file_type().map_or(false, |ft| ft.is_dir());
if a_is_dir != b_is_dir {
b_is_dir.cmp(&a_is_dir) // dirs first
} else {
a.file_name().cmp(&b.file_name())
}
});
for entry in entries {
let path = entry.path();
let file_name = entry.file_name().to_string_lossy().to_string();
// Skip hidden files/directories
if file_name.starts_with('.') {
continue;
}
let is_dir = entry.file_type().map_or(false, |ft| ft.is_dir());
if is_dir {
let rel_path = path.strip_prefix(base_dir).unwrap_or(&path).to_string_lossy().to_string();
let display_name = path.file_name().unwrap_or_else(|| "".as_ref()).to_string_lossy().to_string();
let grandchildren = Self::build_tree_recursive(&path, base_dir);
children.push(BibleTreeNode::dir(display_name, rel_path, grandchildren));
} else if path.extension().and_then(|s| s.to_str()) == Some("md") {
let rel_path = path.strip_prefix(base_dir).unwrap_or(&path).to_string_lossy().to_string();
let display_name = path.file_stem().unwrap_or_else(|| "".as_ref()).to_string_lossy().to_string();
children.push(BibleTreeNode::file(display_name, rel_path));
}
}
}
children
}
/// Find the Story Bible directory path.
pub(super) fn find_story_bible_directory(&self) -> Option<PathBuf> {
use std::path::Path;
let ws = self.config.workspace.clone();
// Look for Story Bible directory - common locations
let story_bible_dirs = [
"03-Story-Bible",
"03-Characters",
"02-Story Bible",
"Story Bible",
"Characters",
];
for dir_name in story_bible_dirs.iter() {
let dir_path = Path::new(&ws).join(dir_name);
if dir_path.exists() && dir_path.is_dir() {
return Some(dir_path.to_path_buf());
}
}
None
}
/// Render the Story Bible tree recursively. `add_requested` collects the
/// directory path a right-click menu's "Add … entry…" was clicked for,
/// and `rename_requested` the file path a "Rename / Move…" was clicked
/// for, so the caller can open the relevant dialog once rendering is
/// done rather than mutating `self` from inside this `&self` walk.
fn render_bible_tree(
&self,
ui: &mut egui::Ui,
nodes: &[BibleTreeNode],
selected_path: &Option<String>,
add_requested: &mut Option<String>,
rename_requested: &mut Option<String>,
) -> Option<String> {
let mut clicked_path: Option<String> = None;
for node in nodes {
match node {
BibleTreeNode::Directory { name, path, children } => {
let _is_selected = selected_path.as_ref().map_or(false, |p| p == path);
let header = egui::CollapsingHeader::new(name)
.default_open(true)
.show(ui, |ui| {
if let Some(child_clicked) = self.render_bible_tree(
ui,
children,
selected_path,
add_requested,
rename_requested,
) {
clicked_path = Some(child_clicked);
}
});
header.header_response.context_menu(|ui| {
let entry_type = infer_entry_type(path);
let label = format!("➕ Add {entry_type} entry…");
if ui.button(label).clicked() {
*add_requested = Some(path.clone());
ui.close_menu();
}
});
}
BibleTreeNode::File { name, path } => {
let is_selected = selected_path.as_ref().map_or(false, |p| p == path);
let response = ui.selectable_label(is_selected, name);
if response.clicked() {
clicked_path = Some(path.clone());
}
response.context_menu(|ui| {
if ui.button("📝 Rename / Move…").clicked() {
*rename_requested = Some(path.clone());
ui.close_menu();
}
});
}
}
}
clicked_path
}
/// Show the Story Bible breakout editor window.
pub(super) fn story_bible_editor_window(&mut self, ctx: &egui::Context) {
// Build tree structure
let tree = self.build_story_bible_tree();
// Create a window with custom sizing - we want tree on left 1/3, editor on right 2/3
let mut open = self.show_story_bible_editor;
egui::Window::new("Story Bible Editor")
.open(&mut open)
.resizable(true)
.collapsible(false)
.default_width(1000.0)
.default_height(700.0)
// A window only paints as large as its content actually
// measures (`default_width`/`default_height` are no more than
// hints, silently ignored the moment content isn't a single
// top-level ScrollArea), so what actually renders is whatever
// size the tree/editor panes below claim for themselves —
// that's why they're given explicit heights rather than left to
// `auto_shrink(false)` alone, which only stops a pane from
// shrinking *below* whatever height it's handed, and doesn't
// make it claim more. This is also why the resize corner used
// to appear to do nothing: every frame's content re-measure
// snapped the drag straight back to that same small size.
.min_width(1000.0)
.min_height(700.0)
.show(ctx, |ui| {
self.story_bible_editor_contents(ui, &tree);
});
self.show_story_bible_editor = open;
// Clear editor state when window is closed
if !open {
self.story_bible_editor_content.clear();
self.story_bible_editor_path = None;
self.story_bible_editor_focused = false;
}
}
/// Contents of the Story Bible editor window.
fn story_bible_editor_contents(&mut self, ui: &mut egui::Ui, tree: &[BibleTreeNode]) {
// Set by the editor's text area below, read back out once the closures
// that borrow `self` have finished.
let mut focused = false;
// Directory a right-click menu's "Add … entry…" was clicked for, if
// any — applied after the tree finishes rendering, same reason.
let mut add_requested: Option<String> = None;
// File path a right-click menu's "Rename / Move…" was clicked for,
// if any — same deferred-handling reason as `add_requested`.
let mut rename_requested: Option<String> = None;
ui.horizontal(|ui| {
// Left panel: Tree view (1/3 width). The width must be set on
// this panel's own child `Ui`, not the shared horizontal one —
// that had been shrinking the whole row (tree, separator, and
// the editor panel with it) down to a third of the window,
// which is why the editor's heading used to wrap one letter at
// a time.
let tree_width = ui.available_width() / 3.0;
ui.vertical(|ui| {
ui.set_width(tree_width);
ui.heading("Story Bible");
ui.separator();
// Scroll area for tree. `auto_shrink(false)` only stops
// this from shrinking *below* whatever height the
// surrounding layout hands it — it doesn't claim more on
// its own, and `max_height` is a ceiling, not a floor (it
// can only shrink, never grow, what the ambient layout
// already offered). `min_scrolled_height` is the one
// that's actually enforced unconditionally, which is what
// establishes — and drives the window's own measured size
// up to — a real, spacious height.
egui::ScrollArea::vertical()
.id_salt("story_bible_editor_tree")
.auto_shrink(false)
.min_scrolled_height(600.0)
.show(ui, |ui| {
if tree.is_empty() {
ui.label("No Story Bible items found");
} else {
// Render tree
if let Some(clicked_path) = self.render_bible_tree(
ui,
tree,
&self.story_bible_editor_path,
&mut add_requested,
&mut rename_requested,
) {
// Load the clicked file
self.load_story_bible_item(&clicked_path);
}
}
});
});
ui.separator();
// Right panel: Editor (2/3 width)
ui.vertical(|ui| {
// Show current file info
if let Some(ref path) = self.story_bible_editor_path {
ui.heading(path);
} else {
ui.heading("Select an item to edit");
}
ui.separator();
// The Save/Close row lives in its own panel, claimed *before*
// the editor's scroll area: a resizable window fights the
// user's own resize drag when a ScrollArea isn't the last
// thing measured in its content (the cursor shows the resize
// affordance but dragging does nothing) — reserving this
// panel's height up front, the same way file_list.rs's
// `left_pane` does for its own action row, avoids that.
egui::TopBottomPanel::bottom("story_bible_editor_actions")
.frame(egui::Frame::none().inner_margin(egui::Margin::symmetric(0.0, 4.0)))
.show_inside(ui, |ui| {
ui.separator();
ui.horizontal(|ui| {
if ui.button("Save (Ctrl+S)").clicked() {
if let Some(ref path) = self.story_bible_editor_path {
self.save_story_bible_item(path.clone());
}
}
if ui.button("Close").clicked() {
self.show_story_bible_editor = false;
}
});
});
// Editor area — see the tree pane's comment above on why
// `min_scrolled_height` (not `max_height`) is needed.
egui::ScrollArea::vertical()
.id_salt("story_bible_editor_content")
.auto_shrink(false)
.min_scrolled_height(520.0)
.show(ui, |ui| {
let text_edit = egui::TextEdit::multiline(&mut self.story_bible_editor_content)
.id_salt("story_bible_editor_textedit")
.code_editor()
.desired_width(f32::INFINITY)
.desired_rows(20);
let response = ui.add(text_edit);
// Recorded rather than acted on: the save needs `&mut
// self`, which this closure is already borrowing to
// draw with. `App::update` reads the flag next frame and
// routes Ctrl+S here instead of to the manuscript.
focused = response.has_focus();
});
});
});
self.story_bible_editor_focused = focused;
if let Some(dir) = add_requested {
self.add_story_bible_type = infer_entry_type(&dir);
self.add_story_bible_dir = dir;
self.add_story_bible_name.clear();
self.show_add_story_bible_item = true;
self.add_story_bible_focus = true;
}
if let Some(path) = rename_requested {
self.rename_story_bible_input = strip_md(&path).to_string();
self.rename_story_bible_target = Some(path);
self.show_rename_story_bible = true;
self.rename_story_bible_focus = true;
}
}
/// Load a Story Bible item from the workspace.
pub(super) fn load_story_bible_item(&mut self, workspace_rel_path: &str) {
let story_bible_dir = self.find_story_bible_directory();
let Some(base_dir) = story_bible_dir else {
self.status = "Story Bible directory not found".to_string();
return;
};
let full_path = base_dir.join(workspace_rel_path);
match fs::read_to_string(&full_path) {
Ok(content) => {
self.story_bible_editor_content = content;
self.story_bible_editor_path = Some(workspace_rel_path.to_string());
}
Err(e) => {
self.status = format!("Could not read {}: {}", workspace_rel_path, e);
}
}
}
/// Save the current Story Bible item.
pub(super) fn save_story_bible_item(&mut self, workspace_rel_path: String) {
let story_bible_dir = self.find_story_bible_directory();
let Some(base_dir) = story_bible_dir else {
self.status = "Story Bible directory not found".to_string();
return;
};
let full_path = base_dir.join(&workspace_rel_path);
match fs::write(&full_path, &self.story_bible_editor_content) {
Ok(_) => {
self.status = format!("Saved: {}", workspace_rel_path);
// Rebuild the Story Bible index to pick up any changes
self.build_story_bible_index();
}
Err(e) => {
self.status = format!("Could not save {}: {}", workspace_rel_path, e);
}
}
}
/// The "Add … entry…" dialog opened from a directory's right-click menu.
/// Mirrors `rename_window`'s shape (`src/app/workspace.rs`): a small
/// modal with a focused name field, Enter-to-submit, Create/Cancel.
pub(super) fn add_story_bible_item_window(&mut self, ctx: &egui::Context) {
let mut open = self.show_add_story_bible_item;
let mut submit = false;
let mut cancel = false;
let title = format!("Add {} entry", capitalize(&self.add_story_bible_type));
egui::Window::new(title)
.open(&mut open)
.resizable(false)
.collapsible(false)
.default_width(320.0)
.show(ctx, |ui| {
ui.label(
egui::RichText::new(format!("in {}/", self.add_story_bible_dir))
.small()
.weak(),
);
ui.add_space(4.0);
let r = ui.add(
egui::TextEdit::singleline(&mut self.add_story_bible_name)
.desired_width(f32::INFINITY)
.hint_text("e.g. Captain Reyes"),
);
if self.add_story_bible_focus {
r.request_focus();
self.add_story_bible_focus = false;
}
submit |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
ui.label(
egui::RichText::new(format!(
"Starts with id/name/aliases/summary/type: {} frontmatter — \
edit any of it once it's created.",
self.add_story_bible_type
))
.small()
.weak(),
);
ui.separator();
ui.horizontal(|ui| {
submit |= ui.button("Create").clicked();
cancel |= ui.button("Cancel").clicked();
});
});
if cancel || !open {
self.show_add_story_bible_item = false;
return;
}
if submit {
if self.create_story_bible_item() {
self.show_add_story_bible_item = false;
} else {
// Rejected (empty name): keep the dialog up, same as Rename.
self.add_story_bible_focus = true;
}
}
}
/// Create the new Story Bible entry `add_story_bible_name` names, inside
/// `add_story_bible_dir`, and load it into the editor. Returns `false`
/// (leaving the dialog open) when there's no usable name yet.
fn create_story_bible_item(&mut self) -> bool {
let name = self.add_story_bible_name.trim();
if name.is_empty() {
self.status = "Name needed".to_string();
return false;
}
let Some(base_dir) = self.find_story_bible_directory() else {
self.status = "Story Bible directory not found".to_string();
return false;
};
let dir = base_dir.join(&self.add_story_bible_dir);
if let Err(e) = fs::create_dir_all(&dir) {
self.status = format!("Could not create {}: {e}", dir.display());
return false;
}
let stem = unique_slug(&dir, &slugify(name));
let rel_path = format!("{}/{stem}.md", self.add_story_bible_dir);
let full_path = base_dir.join(&rel_path);
let content = format!(
"---\n\
id: {stem}\n\
name: \"{name}\"\n\
aliases: []\n\
summary: \"\"\n\
type: {}\n\
status: active\n\
---\n\n\
# {name}\n",
self.add_story_bible_type,
);
match fs::write(&full_path, &content) {
Ok(()) => {
self.status = format!("Created {rel_path}");
self.story_bible_editor_content = content;
self.story_bible_editor_path = Some(rel_path);
self.build_story_bible_index();
true
}
Err(e) => {
self.status = format!("Could not create {rel_path}: {e}");
false
}
}
}
/// The rename/move dialog opened from a file row's context menu. Renaming
/// and moving are the same operation here, same as the manuscript file
/// panel's `rename_window`: editing the leaf renames, editing the folder
/// part moves.
pub(super) fn rename_story_bible_item_window(&mut self, ctx: &egui::Context) {
if self.rename_story_bible_target.is_none() {
// The tree changed underneath us; nothing left to rename.
self.show_rename_story_bible = false;
return;
}
let mut open = self.show_rename_story_bible;
let mut submit = false;
let mut cancel = false;
egui::Window::new("Rename / Move Story Bible item")
.open(&mut open)
.resizable(false)
.collapsible(false)
.default_width(320.0)
.show(ctx, |ui| {
if let Some(ref current) = self.rename_story_bible_target {
ui.label(egui::RichText::new(current.as_str()).small().weak());
}
ui.add_space(4.0);
let r = ui.add(
egui::TextEdit::singleline(&mut self.rename_story_bible_input)
.desired_width(f32::INFINITY)
.hint_text("name, or a path to move it"),
);
if self.rename_story_bible_focus {
r.request_focus();
self.rename_story_bible_focus = false;
}
submit |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
ui.label(
egui::RichText::new(
"The path within the Story Bible folder — edit the folder part \
to move it. The .md extension is added for you.",
)
.small()
.weak(),
);
ui.separator();
ui.horizontal(|ui| {
submit |= ui.button("Rename").clicked();
cancel |= ui.button("Cancel").clicked();
});
});
if cancel || !open {
self.show_rename_story_bible = false;
return;
}
if submit {
if self.rename_selected_story_bible_item() {
self.show_rename_story_bible = false;
} else {
self.rename_story_bible_focus = true;
}
}
}
/// Move/rename the Story Bible item named by `rename_story_bible_target`
/// to `rename_story_bible_input`. Returns `false` (leaving the dialog
/// open) when the input isn't usable or the destination is taken.
fn rename_selected_story_bible_item(&mut self) -> bool {
let Some(old_path) = self.rename_story_bible_target.clone() else {
return false;
};
let Some(new_path) = sanitize_rel_path(&self.rename_story_bible_input) else {
self.status = "Enter a new name".to_string();
return false;
};
if new_path == old_path {
// Already what was asked for.
self.rename_story_bible_target = None;
return true;
}
let Some(base_dir) = self.find_story_bible_directory() else {
self.status = "Story Bible directory not found".to_string();
return false;
};
if base_dir.join(&new_path).exists() {
self.status = format!("{new_path} already exists");
return false;
}
match workspace::move_manuscript_file(&base_dir, &old_path, &new_path) {
Ok(()) => {
if self.story_bible_editor_path.as_deref() == Some(old_path.as_str()) {
self.story_bible_editor_path = Some(new_path.clone());
}
self.build_story_bible_index();
self.status = format!("Renamed to {new_path}");
self.rename_story_bible_target = None;
true
}
Err(e) => {
self.status = format!("Rename failed: {e}");
false
}
}
}
/// Open the Story Bible editor.
pub(super) fn open_story_bible_editor(&mut self) {
self.show_story_bible_editor = true;
// If we have a tree, try to select the first item
let tree = self.build_story_bible_tree();
if !tree.is_empty() {
if let Some(first_file) = Self::find_first_file(&tree) {
self.load_story_bible_item(&first_file);
}
}
}
/// Find the first file in the tree (depth-first).
fn find_first_file(nodes: &[BibleTreeNode]) -> Option<String> {
for node in nodes {
match node {
BibleTreeNode::Directory { children, .. } => {
if let Some(file) = Self::find_first_file(children) {
return Some(file);
}
}
BibleTreeNode::File { path, .. } => {
return Some(path.clone());
}
}
}
None
}
}
/// Best-guess entry `type:` for a new Story Bible item, from the directory
/// it's being added to: the last path component, a leading `NN-`/`NN_`/`NN `
/// ordering prefix dropped, underscores turned to spaces, lowercased, and
/// singularized by a simple suffix heuristic (`cities` -> `city`, `Objects`
/// -> `object`). Only a default — nothing stops the file's own `type:`
/// field being edited afterward if the guess is off.
fn infer_entry_type(dir_path: &str) -> String {
let last = dir_path.rsplit('/').next().unwrap_or(dir_path).trim();
let rest = last.trim_start_matches(|c: char| c.is_ascii_digit());
let stripped = match rest.strip_prefix(['-', '_', ' ']) {
Some(after) if !after.trim().is_empty() => after.trim(),
_ => last,
};
let lower = stripped.replace(['_', '-'], " ").to_lowercase();
match lower.strip_suffix("ies") {
Some(stem) => format!("{stem}y"),
None => match lower.strip_suffix('s') {
Some(stem) => stem.to_string(),
None => lower,
},
}
}
/// Capitalize the first character, leaving the rest alone.
fn capitalize(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
/// Turn a typed display name into a filename stem: lowercase, runs of
/// anything that isn't a letter or digit collapsed to a single `_`, trimmed
/// of leading/trailing `_`. Falls back to `entry` if nothing alphanumeric
/// survives (an emoji-only name, say), so a file always gets created.
fn slugify(name: &str) -> String {
let mut out = String::with_capacity(name.len());
let mut last_was_sep = false;
for c in name.chars() {
if c.is_alphanumeric() {
out.extend(c.to_lowercase());
last_was_sep = false;
} else if !last_was_sep && !out.is_empty() {
out.push('_');
last_was_sep = true;
}
}
while out.ends_with('_') {
out.pop();
}
if out.is_empty() {
"entry".to_string()
} else {
out
}
}
/// `slug`, or `slug_2`/`slug_3`/… if a file by that name already exists in
/// `dir` — the same "find the first free name" shape as the main file
/// panel's `+ New from template`.
fn unique_slug(dir: &Path, slug: &str) -> String {
if !dir.join(format!("{slug}.md")).exists() {
return slug.to_string();
}
let mut n = 2;
loop {
let candidate = format!("{slug}_{n}");
if !dir.join(format!("{candidate}.md")).exists() {
return candidate;
}
n += 1;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn infers_type_from_a_top_level_category() {
assert_eq!(infer_entry_type("01-Characters"), "character");
assert_eq!(infer_entry_type("04-Objects"), "object");
assert_eq!(infer_entry_type("05-Relationships"), "relationship");
}
#[test]
fn infers_type_from_the_closest_subfolder() {
assert_eq!(infer_entry_type("01-Characters/main_characters"), "main character");
assert_eq!(infer_entry_type("02-Locations/cities"), "city");
assert_eq!(infer_entry_type("03-Lore/factions"), "faction");
}
#[test]
fn a_name_with_no_trailing_s_is_left_alone() {
assert_eq!(infer_entry_type("06-Timeline"), "timeline");
assert_eq!(infer_entry_type("03-Lore/history"), "history");
}
#[test]
fn capitalize_only_touches_the_first_character() {
assert_eq!(capitalize("character"), "Character");
assert_eq!(capitalize(""), "");
assert_eq!(capitalize("main character"), "Main character");
}
#[test]
fn slugify_collapses_punctuation_and_spaces() {
assert_eq!(slugify("Captain Reyes"), "captain_reyes");
assert_eq!(slugify(" Mira Voss "), "mira_voss");
assert_eq!(slugify("O'Brien's Tavern!"), "o_brien_s_tavern");
}
#[test]
fn slugify_falls_back_rather_than_producing_an_empty_name() {
assert_eq!(slugify("***"), "entry");
assert_eq!(slugify(""), "entry");
}
#[test]
fn unique_slug_finds_the_first_free_name() {
let dir = std::env::temp_dir().join(format!(
"md-manuscript-sbe-slug-test-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
assert_eq!(unique_slug(&dir, "mira"), "mira");
fs::write(dir.join("mira.md"), "").unwrap();
assert_eq!(unique_slug(&dir, "mira"), "mira_2");
fs::write(dir.join("mira_2.md"), "").unwrap();
assert_eq!(unique_slug(&dir, "mira"), "mira_3");
let _ = fs::remove_dir_all(&dir);
}
}