a0d1c3c206
- Everything after YAML frontmatter closing --- is now considered the draft content - No ### Rough Draft: marker needed - Chapter titles are handled by the program on export based on file order - Draft content should not include chapter titles - Updated default_new_file_template to use YAML frontmatter - Updated preprocess.rs to parse YAML frontmatter for metadata - Updated all relevant tests - Updated documentation in help.rs - Updated template files Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
799 lines
28 KiB
Rust
799 lines
28 KiB
Rust
//! Markdown pre-processing applied at export time.
|
||
//!
|
||
//! Manuscript source files may carry an editorial "header" above the prose and
|
||
//! HTML comments inline. Neither belongs in the exported document, so before a
|
||
//! file is converted to ODT we:
|
||
//!
|
||
//! * strip HTML comments (`<!-- ... -->`, including multi-line ones), and
|
||
//! * split off the header: everything above a marker line (default
|
||
//! `### Rough Draft:`). From the header we pull out `# Title:` and
|
||
//! `# Slug:` metadata; the title becomes the chapter heading and the slug a
|
||
//! caption beneath it. All other header text is discarded.
|
||
//!
|
||
//! If a file has no marker line, nothing is treated as a header block, but any
|
||
//! stray `# Title:` / `# Slug:` lines are still lifted out and removed.
|
||
|
||
/// A per-file word-count target parsed from a `Word Count Target:` header line.
|
||
/// A single number gives `min == max`; a range like `1500 - 2000` gives both.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub struct WordGoal {
|
||
/// Lower bound of the target (the point "in range" begins).
|
||
pub min: usize,
|
||
/// Upper bound of the target (for a single number, equal to `min`).
|
||
pub max: usize,
|
||
}
|
||
|
||
/// The parsed result of pre-processing one markdown document.
|
||
pub struct Header {
|
||
/// Value of the `# Title:` line, if present and non-empty.
|
||
pub title: Option<String>,
|
||
/// Value of the `# Slug:` line, if present and non-empty.
|
||
pub slug: Option<String>,
|
||
/// Value of the `POV:` line, if present and non-empty.
|
||
pub pov: Option<String>,
|
||
/// Word-count target from a `Word Count Target:` line, if present and parsable.
|
||
pub goal: Option<WordGoal>,
|
||
/// The prose body, with comments, the header block, and metadata lines removed.
|
||
pub body: String,
|
||
}
|
||
|
||
#[derive(Clone, Copy)]
|
||
enum Meta {
|
||
Title,
|
||
Slug,
|
||
Pov,
|
||
WordCount,
|
||
}
|
||
|
||
/// Parse a word-count target value such as `1500 - 2000`, `1500-2000`,
|
||
/// `1,500 to 2,000`, or a single `2000`. Any two numbers are taken as the
|
||
/// bounds (ordered); a lone number becomes both bounds. Returns `None` if the
|
||
/// value contains no digits.
|
||
fn parse_goal(value: &str) -> Option<WordGoal> {
|
||
// Remove digit-grouping characters so "1,500" stays one number, then split
|
||
// the remainder on any non-digit (dashes, "to", spaces) into number tokens.
|
||
let ungrouped: String = value.chars().filter(|c| *c != ',' && *c != '_').collect();
|
||
let nums: Vec<usize> = ungrouped
|
||
.split(|c: char| !c.is_ascii_digit())
|
||
.filter(|tok| !tok.is_empty())
|
||
.filter_map(|tok| tok.parse::<usize>().ok())
|
||
.collect();
|
||
match nums.as_slice() {
|
||
[] => None,
|
||
[n] => Some(WordGoal { min: *n, max: *n }),
|
||
[a, b, ..] => Some(WordGoal {
|
||
min: (*a).min(*b),
|
||
max: (*a).max(*b),
|
||
}),
|
||
}
|
||
}
|
||
|
||
/// Remove `<!-- ... -->` comments (matching the non-greedy `<!--[\s\S]*?-->`),
|
||
/// including comments that span multiple lines. An unterminated comment drops
|
||
/// everything from `<!--` onward.
|
||
pub fn strip_comments(s: &str) -> String {
|
||
let mut out = String::with_capacity(s.len());
|
||
let mut rest = s;
|
||
while let Some(start) = rest.find("<!--") {
|
||
out.push_str(&rest[..start]);
|
||
match rest[start + 4..].find("-->") {
|
||
Some(end) => rest = &rest[start + 4 + end + 3..],
|
||
None => {
|
||
rest = "";
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
out.push_str(rest);
|
||
out
|
||
}
|
||
|
||
/// Split a document at its draft marker into `(header, body)`, where the header
|
||
/// includes the marker line itself and its newline.
|
||
///
|
||
/// The split is exact: `header + body` is the original text, byte for byte, so
|
||
/// a caller can lift the header out and put it back without touching what the
|
||
/// author wrote. `None` when the document has no marker to split at.
|
||
pub fn split_at_marker<'a>(text: &'a str, marker: &str) -> Option<(&'a str, &'a str)> {
|
||
let marker_norm = marker.trim().to_ascii_lowercase();
|
||
if marker_norm.is_empty() {
|
||
return None;
|
||
}
|
||
let mut offset = 0usize;
|
||
for line in text.split_inclusive('\n') {
|
||
if line.trim().to_ascii_lowercase().starts_with(&marker_norm) {
|
||
let end = offset + line.len();
|
||
return Some((&text[..end], &text[end..]));
|
||
}
|
||
offset += line.len();
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Split a document at its YAML frontmatter boundaries (`---` delimiters).
|
||
/// Returns `(frontmatter, body)` if frontmatter exists, or `None` if no frontmatter.
|
||
/// The frontmatter includes the opening and closing `---` lines.
|
||
pub fn split_yaml_frontmatter<'a>(text: &'a str) -> Option<(&'a str, &'a str)> {
|
||
let mut lines = text.lines();
|
||
|
||
// Check for opening ---
|
||
let first_line = lines.next()?.trim();
|
||
if first_line != "---" {
|
||
return None;
|
||
}
|
||
|
||
// Find closing ---
|
||
let mut frontmatter_end = first_line.len() + 1; // +1 for newline
|
||
let mut found_closing = false;
|
||
|
||
for line in lines {
|
||
frontmatter_end += line.len() + 1; // +1 for newline
|
||
if line.trim() == "---" {
|
||
found_closing = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if !found_closing {
|
||
return None;
|
||
}
|
||
|
||
// Return frontmatter (including delimiters) and body
|
||
let frontmatter = &text[..frontmatter_end];
|
||
let body = &text[frontmatter_end..];
|
||
Some((frontmatter, body))
|
||
}
|
||
|
||
/// One `#* Name: value` line from a document's editorial header.
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct Field {
|
||
/// The field name as written, e.g. `Word Count Target`.
|
||
pub name: String,
|
||
/// Everything after the colon, trimmed. May be empty for an unfilled field.
|
||
pub value: String,
|
||
}
|
||
|
||
/// Every `#* Name: value` line in a document's header, in the order written.
|
||
///
|
||
/// This is the generic form of what [`parse`] pulls out for the few fields the
|
||
/// exporter cares about: the scene card, the status column and the character
|
||
/// index all read fields the exporter has no opinion about, so they read them
|
||
/// from here rather than each re-implementing the line format.
|
||
///
|
||
/// Only the header is considered — lines above `marker`, or the whole document
|
||
/// when there is none — so a `## Setting:` written inside the prose is not
|
||
/// mistaken for card metadata.
|
||
pub fn fields(markdown: &str, marker: &str) -> Vec<Field> {
|
||
let text = strip_comments(markdown);
|
||
let marker_norm = marker.trim().to_ascii_lowercase();
|
||
let mut out = Vec::new();
|
||
for line in text.lines() {
|
||
if !marker_norm.is_empty()
|
||
&& line.trim().to_ascii_lowercase().starts_with(&marker_norm)
|
||
{
|
||
break;
|
||
}
|
||
if let Some(field) = field_line(line) {
|
||
out.push(field);
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Look up one field by name, case-insensitively. Empty values give `None`, so
|
||
/// an unfilled `## POV:` reads the same as an absent one.
|
||
pub fn field(markdown: &str, marker: &str, name: &str) -> Option<String> {
|
||
fields(markdown, marker)
|
||
.into_iter()
|
||
.find(|f| f.name.eq_ignore_ascii_case(name.trim()))
|
||
.map(|f| f.value)
|
||
.filter(|v| !v.is_empty())
|
||
}
|
||
|
||
/// Split a comma-separated field value into its items, dropping blanks — the
|
||
/// form `## Characters: Bixby, Mom, Kayla` uses.
|
||
pub fn value_items(value: &str) -> Vec<String> {
|
||
value
|
||
.split(',')
|
||
.map(str::trim)
|
||
.filter(|item| !item.is_empty())
|
||
.map(str::to_string)
|
||
.collect()
|
||
}
|
||
|
||
/// If `line` is a header field, split it into name and value.
|
||
///
|
||
/// Two forms are in use and both are accepted, because real files mix them:
|
||
/// scene cards write `## POV: Bixby`, while character sheets often write a bare
|
||
/// `Age: 37`. The bare form is the looser one, so it carries a stricter test —
|
||
/// its name may only be letters, spaces, hyphens and apostrophes — which keeps
|
||
/// ordinary prose out. A line of dialogue like `Bixby: run` would still be read
|
||
/// as a field; only the header is scanned, so that matters just for documents
|
||
/// with no draft marker, where the header is the whole file.
|
||
fn field_line(line: &str) -> Option<Field> {
|
||
let trimmed = line.trim_start();
|
||
let hashes = trimmed.len() - trimmed.trim_start_matches('#').len();
|
||
let (rest, headed) = if hashes > 0 {
|
||
let after_hash = &trimmed[hashes..];
|
||
if !after_hash.starts_with(char::is_whitespace) {
|
||
return None;
|
||
}
|
||
(after_hash.trim(), true)
|
||
} else {
|
||
// A bare field must start the line, so an indented or wrapped line of
|
||
// prose is never mistaken for one.
|
||
if line.starts_with(char::is_whitespace) {
|
||
return None;
|
||
}
|
||
(trimmed, false)
|
||
};
|
||
let (name, value) = rest.split_once(':')?;
|
||
let name = name.trim();
|
||
// A field name is a short label, not a sentence. The longest in real use is
|
||
// three words ("Word Count Target"), so four leaves headroom while rejecting
|
||
// most prose headings that happen to contain a colon.
|
||
if name.is_empty() || name.split_whitespace().count() > 4 || name.ends_with('.') {
|
||
return None;
|
||
}
|
||
if !headed
|
||
&& !name
|
||
.chars()
|
||
.all(|c| c.is_alphabetic() || c == ' ' || c == '-' || c == '\'')
|
||
{
|
||
return None;
|
||
}
|
||
Some(Field {
|
||
name: name.to_string(),
|
||
value: value.trim().to_string(),
|
||
})
|
||
}
|
||
|
||
/// If `line` is a recognised metadata line — `# Title:`, `# Slug:`, or
|
||
/// `## Word Count Target:` — return which one and its value. Matches one or more
|
||
/// leading `#` followed by whitespace, case-insensitively, so the field can sit
|
||
/// at any heading level (`#`, `##`, …).
|
||
fn meta_line(line: &str) -> Option<(Meta, String)> {
|
||
let trimmed = line.trim_start();
|
||
let hashes = trimmed.len() - trimmed.trim_start_matches('#').len();
|
||
if hashes == 0 {
|
||
return None;
|
||
}
|
||
let after_hash = &trimmed[hashes..];
|
||
if !after_hash.starts_with(char::is_whitespace) {
|
||
return None;
|
||
}
|
||
let rest = after_hash.trim_start();
|
||
let lower = rest.to_ascii_lowercase();
|
||
// Longer keys first so "word count target:" wins over "word count:".
|
||
for (key, meta) in [
|
||
("title:", Meta::Title),
|
||
("slug:", Meta::Slug),
|
||
("pov:", Meta::Pov),
|
||
("word count target:", Meta::WordCount),
|
||
("word count goal:", Meta::WordCount),
|
||
("word count:", Meta::WordCount),
|
||
] {
|
||
if lower.starts_with(key) {
|
||
return Some((meta, rest[key.len()..].trim().to_string()));
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Pre-process one markdown document, splitting header metadata from the body.
|
||
/// Tries YAML frontmatter first, then falls back to the legacy marker-based header.
|
||
/// `marker` is the legacy header/body separator line (e.g. `### Rough Draft:`); an empty
|
||
/// marker disables marker-based splitting, but YAML frontmatter is still parsed.
|
||
pub fn parse(markdown: &str, marker: &str) -> Header {
|
||
let cleaned = strip_comments(markdown);
|
||
let lines: Vec<&str> = cleaned.lines().collect();
|
||
let marker_norm = marker.trim().to_ascii_lowercase();
|
||
|
||
// Try YAML frontmatter first
|
||
if let Some(body_start_line) = find_yaml_frontmatter_end(&lines) {
|
||
// Parse metadata from YAML frontmatter
|
||
let mut title = None;
|
||
let mut slug = None;
|
||
let mut pov = None;
|
||
let mut goal = None;
|
||
|
||
// Extract YAML fields from the frontmatter lines
|
||
for line in &lines[..body_start_line] {
|
||
// Skip the --- delimiters
|
||
if line.trim() == "---" {
|
||
continue;
|
||
}
|
||
// Parse YAML-style key: value lines
|
||
if let Some((key, value)) = parse_yaml_field(line) {
|
||
match key.to_ascii_lowercase().as_str() {
|
||
"title" => title = Some(value),
|
||
"slug" => slug = Some(value),
|
||
"pov" => pov = Some(value),
|
||
"word_count_target" | "word count target" | "wordcounttarget" => {
|
||
goal = parse_goal(&value);
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Body is everything after the closing ---
|
||
let body_lines: Vec<&str> = lines[body_start_line..].to_vec();
|
||
|
||
return Header {
|
||
title: title.filter(|s| !s.is_empty()),
|
||
slug: slug.filter(|s| !s.is_empty()),
|
||
pov: pov.filter(|s| !s.is_empty()),
|
||
goal,
|
||
body: body_lines.join("\n").trim().to_string(),
|
||
};
|
||
}
|
||
|
||
// Fall back to legacy marker-based parsing
|
||
let marker_idx = if marker_norm.is_empty() {
|
||
None
|
||
} else {
|
||
lines
|
||
.iter()
|
||
.position(|l| l.trim().to_ascii_lowercase().starts_with(&marker_norm))
|
||
};
|
||
|
||
let mut title = None;
|
||
let mut slug = None;
|
||
let mut pov = None;
|
||
let mut goal = None;
|
||
let mut set = |meta: Meta, value: String| match meta {
|
||
Meta::Title => title = Some(value),
|
||
Meta::Slug => slug = Some(value),
|
||
Meta::Pov => pov = Some(value),
|
||
Meta::WordCount => goal = parse_goal(&value),
|
||
};
|
||
|
||
let body_lines: Vec<&str> = match marker_idx {
|
||
Some(idx) => {
|
||
// Header is everything before the marker; parse metadata from it.
|
||
for l in &lines[..idx] {
|
||
if let Some((m, v)) = meta_line(l) {
|
||
set(m, v);
|
||
}
|
||
}
|
||
// Body is everything after the marker, minus any stray metadata lines.
|
||
lines[idx + 1..]
|
||
.iter()
|
||
.copied()
|
||
.filter(|l| meta_line(l).is_none())
|
||
.collect()
|
||
}
|
||
None => {
|
||
// No marker: keep all prose, but lift out metadata lines anywhere.
|
||
let mut kept = Vec::new();
|
||
for l in &lines {
|
||
if let Some((m, v)) = meta_line(l) {
|
||
set(m, v);
|
||
} else {
|
||
kept.push(*l);
|
||
}
|
||
}
|
||
kept
|
||
}
|
||
};
|
||
|
||
Header {
|
||
title: title.filter(|s| !s.is_empty()),
|
||
slug: slug.filter(|s| !s.is_empty()),
|
||
pov: pov.filter(|s| !s.is_empty()),
|
||
goal,
|
||
body: body_lines.join("\n").trim().to_string(),
|
||
}
|
||
}
|
||
|
||
/// Find the line index where YAML frontmatter ends (after the closing `---`).
|
||
/// Returns Some(body_start_line) where body_start_line is the index after the closing ---.
|
||
fn find_yaml_frontmatter_end(lines: &[&str]) -> Option<usize> {
|
||
if lines.is_empty() || lines[0].trim() != "---" {
|
||
return None;
|
||
}
|
||
|
||
for (i, line) in lines.iter().enumerate().skip(1) {
|
||
if line.trim() == "---" {
|
||
return Some(i + 1);
|
||
}
|
||
}
|
||
|
||
None
|
||
}
|
||
|
||
/// Parse a YAML field line like `key: value` or `key: "value"`.
|
||
/// Returns (key, value) if the line looks like a YAML field.
|
||
/// Strips inline comments (everything after # that's not in quotes).
|
||
fn parse_yaml_field(line: &str) -> Option<(String, String)> {
|
||
let trimmed = line.trim();
|
||
|
||
// Skip empty lines and full-line comments
|
||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||
return None;
|
||
}
|
||
|
||
// Strip inline comments first (everything after # that's not in quotes)
|
||
let mut in_quote = false;
|
||
let mut quote_char = '\0';
|
||
let mut comment_pos = None;
|
||
|
||
for (i, c) in trimmed.char_indices() {
|
||
match c {
|
||
'"' | '\'' => {
|
||
if in_quote && c == quote_char {
|
||
in_quote = false;
|
||
quote_char = '\0';
|
||
} else if !in_quote {
|
||
in_quote = true;
|
||
quote_char = c;
|
||
}
|
||
}
|
||
'#' => {
|
||
if !in_quote && comment_pos.is_none() {
|
||
comment_pos = Some(i);
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
// Trim at the comment position if found
|
||
let trimmed_no_comment = if let Some(pos) = comment_pos {
|
||
trimmed[..pos].trim()
|
||
} else {
|
||
trimmed
|
||
};
|
||
|
||
if trimmed_no_comment.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
// Find the first colon that's not part of a quoted string
|
||
let mut in_quote = false;
|
||
let mut quote_char = '\0';
|
||
let mut colon_pos = None;
|
||
|
||
for (i, c) in trimmed_no_comment.char_indices() {
|
||
match c {
|
||
'"' | '\'' => {
|
||
if in_quote && c == quote_char {
|
||
in_quote = false;
|
||
quote_char = '\0';
|
||
} else if !in_quote {
|
||
in_quote = true;
|
||
quote_char = c;
|
||
}
|
||
}
|
||
':' => {
|
||
if !in_quote && colon_pos.is_none() {
|
||
colon_pos = Some(i);
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
let colon_pos = colon_pos?;
|
||
|
||
let key = trimmed_no_comment[..colon_pos].trim().to_string();
|
||
let value = trimmed_no_comment[colon_pos + 1..].trim();
|
||
|
||
// Remove surrounding quotes if present
|
||
let value = if value.starts_with('"') && value.ends_with('"') {
|
||
value[1..value.len() - 1].to_string()
|
||
} else if value.starts_with('\'') && value.ends_with('\'') {
|
||
value[1..value.len() - 1].to_string()
|
||
} else {
|
||
value.to_string()
|
||
};
|
||
|
||
if key.is_empty() {
|
||
None
|
||
} else {
|
||
Some((key, value))
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
const MARKER: &str = "### Rough Draft:";
|
||
|
||
#[test]
|
||
fn strips_inline_and_multiline_comments() {
|
||
let s = "before <!-- note --> after\n<!--\nblock\ncomment\n-->\nend";
|
||
let out = strip_comments(s);
|
||
assert!(!out.contains("note"));
|
||
assert!(!out.contains("block"));
|
||
assert!(out.contains("before after"));
|
||
assert!(out.contains("end"));
|
||
}
|
||
|
||
#[test]
|
||
fn unterminated_comment_drops_tail() {
|
||
let out = strip_comments("keep <!-- oops never closed");
|
||
assert_eq!(out, "keep ");
|
||
}
|
||
|
||
#[test]
|
||
fn splits_header_at_marker() {
|
||
let md = "# Title: The Gate\n# Slug: an opening\n<!-- outline -->\nnotes to self\n### Rough Draft:\n\nThe prose begins here.\n\n## A scene";
|
||
let h = parse(md, MARKER);
|
||
assert_eq!(h.title.as_deref(), Some("The Gate"));
|
||
assert_eq!(h.slug.as_deref(), Some("an opening"));
|
||
assert!(h.body.starts_with("The prose begins here."));
|
||
assert!(h.body.contains("## A scene"));
|
||
assert!(!h.body.contains("notes to self"));
|
||
assert!(!h.body.contains("outline"));
|
||
assert!(!h.body.contains("Title:"));
|
||
}
|
||
|
||
#[test]
|
||
fn without_marker_keeps_body_but_lifts_metadata() {
|
||
let md = "# Title: Solo\n\nJust prose, no marker.";
|
||
let h = parse(md, MARKER);
|
||
assert_eq!(h.title.as_deref(), Some("Solo"));
|
||
assert_eq!(h.slug, None);
|
||
assert_eq!(h.body, "Just prose, no marker.");
|
||
}
|
||
|
||
#[test]
|
||
fn real_heading_is_not_mistaken_for_metadata() {
|
||
let md = "### Rough Draft:\n\n# Chapter One\n\nText.";
|
||
let h = parse(md, MARKER);
|
||
assert!(h.body.contains("# Chapter One"));
|
||
}
|
||
|
||
#[test]
|
||
fn parses_word_count_target_range_at_heading_level_two() {
|
||
let md = "# Title: Bixby\n## Word Count Target: 1500 - 2000\n\
|
||
## POV: Bixby\n### Rough Draft:\n\nThe prose.";
|
||
let h = parse(md, MARKER);
|
||
assert_eq!(h.goal, Some(WordGoal { min: 1500, max: 2000 }));
|
||
// The metadata line is lifted out of the body; POV (unrecognised) is
|
||
// above the marker so it's dropped too.
|
||
assert_eq!(h.body, "The prose.");
|
||
}
|
||
|
||
#[test]
|
||
fn word_count_target_accepts_various_forms() {
|
||
assert_eq!(parse_goal("1500 - 2000"), Some(WordGoal { min: 1500, max: 2000 }));
|
||
assert_eq!(parse_goal("1500-2000"), Some(WordGoal { min: 1500, max: 2000 }));
|
||
assert_eq!(parse_goal("1,500 to 2,000"), Some(WordGoal { min: 1500, max: 2000 }));
|
||
assert_eq!(parse_goal("2000 – 1500"), Some(WordGoal { min: 1500, max: 2000 }));
|
||
assert_eq!(parse_goal("about 2000"), Some(WordGoal { min: 2000, max: 2000 }));
|
||
assert_eq!(parse_goal(" "), None);
|
||
assert_eq!(parse_goal("none"), None);
|
||
}
|
||
|
||
#[test]
|
||
fn word_count_goal_alias_and_no_marker() {
|
||
// The alias spelling and no draft marker still yield a goal.
|
||
let md = "## Word Count Goal: 800\n\nJust prose.";
|
||
let h = parse(md, MARKER);
|
||
assert_eq!(h.goal, Some(WordGoal { min: 800, max: 800 }));
|
||
assert_eq!(h.body, "Just prose.");
|
||
}
|
||
|
||
#[test]
|
||
fn no_goal_when_absent() {
|
||
let md = "# Title: Plain\n### Rough Draft:\n\nText.";
|
||
assert_eq!(parse(md, MARKER).goal, None);
|
||
}
|
||
|
||
#[test]
|
||
fn parses_pov_at_heading_level_two() {
|
||
let md = "# Title: Bixby\n## POV: Bixby\n### Rough Draft:\n\nThe prose.";
|
||
let h = parse(md, MARKER);
|
||
assert_eq!(h.pov.as_deref(), Some("Bixby"));
|
||
assert_eq!(h.body, "The prose.");
|
||
// A prose line that merely starts with "POV" is not metadata.
|
||
let md2 = "### Rough Draft:\n\nPOV of the crowd was tense.";
|
||
assert_eq!(parse(md2, MARKER).pov, None);
|
||
}
|
||
|
||
#[test]
|
||
fn parses_yaml_frontmatter() {
|
||
let md = "---\ntitle: The Gate\nslug: an opening\npov: Bixby\nword_count_target: 1500 - 2000\n---\n\nThe prose begins here.\n\n## A scene";
|
||
let h = parse(md, "");
|
||
assert_eq!(h.title.as_deref(), Some("The Gate"));
|
||
assert_eq!(h.slug.as_deref(), Some("an opening"));
|
||
assert_eq!(h.pov.as_deref(), Some("Bixby"));
|
||
assert_eq!(h.goal, Some(WordGoal { min: 1500, max: 2000 }));
|
||
assert!(h.body.starts_with("The prose begins here."));
|
||
assert!(h.body.contains("## A scene"));
|
||
}
|
||
|
||
#[test]
|
||
fn parses_yaml_frontmatter_with_quoted_values() {
|
||
let md = "---\ntitle: \"The Gate\"\nslug: \"an opening\"\n---\n\nProse here.";
|
||
let h = parse(md, "");
|
||
assert_eq!(h.title.as_deref(), Some("The Gate"));
|
||
assert_eq!(h.slug.as_deref(), Some("an opening"));
|
||
}
|
||
|
||
#[test]
|
||
fn parses_yaml_frontmatter_with_comments() {
|
||
let md = "---\n# This is a comment
|
||
title: The Gate # inline comment\nslug: an opening\n---\n\nProse here.";
|
||
let h = parse(md, "");
|
||
assert_eq!(h.title.as_deref(), Some("The Gate"));
|
||
assert_eq!(h.slug.as_deref(), Some("an opening"));
|
||
}
|
||
|
||
#[test]
|
||
fn falls_back_to_marker_if_no_yaml_frontmatter() {
|
||
let md = "# Title: The Gate\n# Slug: an opening\n### Rough Draft:\n\nThe prose begins here.";
|
||
let h = parse(md, "### Rough Draft:");
|
||
assert_eq!(h.title.as_deref(), Some("The Gate"));
|
||
assert_eq!(h.slug.as_deref(), Some("an opening"));
|
||
assert!(h.body.starts_with("The prose begins here."));
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod field_tests {
|
||
use super::*;
|
||
|
||
/// A real scene card from the snowflake plot folder.
|
||
const CARD: &str = "\
|
||
# Title:
|
||
|
||
# Slug: 2026 - Bixby
|
||
|
||
## POV: Bixby
|
||
|
||
## Characters: Bixby, Mom, Kayla
|
||
|
||
## Setting: The Old Reynold's House
|
||
|
||
## Word Count Target: 1500 - 2000
|
||
|
||
### Rough Draft:
|
||
|
||
She stood there. The setting: a house. Not a field: prose.
|
||
";
|
||
|
||
#[test]
|
||
fn every_header_field_is_read_in_order() {
|
||
let names: Vec<String> = fields(CARD, "### Rough Draft:")
|
||
.into_iter()
|
||
.map(|f| f.name)
|
||
.collect();
|
||
assert_eq!(
|
||
names,
|
||
["Title", "Slug", "POV", "Characters", "Setting", "Word Count Target"]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_field_is_found_by_name_whatever_its_case() {
|
||
assert_eq!(field(CARD, "### Rough Draft:", "POV").as_deref(), Some("Bixby"));
|
||
assert_eq!(field(CARD, "### Rough Draft:", "pov").as_deref(), Some("Bixby"));
|
||
assert_eq!(
|
||
field(CARD, "### Rough Draft:", "Setting").as_deref(),
|
||
Some("The Old Reynold's House")
|
||
);
|
||
// An unfilled field reads as absent rather than as an empty string.
|
||
assert_eq!(field(CARD, "### Rough Draft:", "Title"), None);
|
||
assert_eq!(field(CARD, "### Rough Draft:", "Nope"), None);
|
||
}
|
||
|
||
/// The body is where prose lives; a colon in it is not card metadata.
|
||
#[test]
|
||
fn prose_below_the_marker_is_not_scanned() {
|
||
assert_eq!(field(CARD, "### Rough Draft:", "The setting"), None);
|
||
let names: Vec<String> = fields(CARD, "### Rough Draft:")
|
||
.into_iter()
|
||
.map(|f| f.name)
|
||
.collect();
|
||
assert!(!names.iter().any(|n| n.eq_ignore_ascii_case("Not a field")));
|
||
}
|
||
|
||
#[test]
|
||
fn a_comma_list_splits_into_items() {
|
||
assert_eq!(value_items("Bixby, Mom, Kayla"), ["Bixby", "Mom", "Kayla"]);
|
||
assert_eq!(value_items(" Bixby ,, Kayla "), ["Bixby", "Kayla"]);
|
||
assert!(value_items("").is_empty());
|
||
assert_eq!(value_items("Bixby"), ["Bixby"]);
|
||
}
|
||
|
||
#[test]
|
||
fn a_prose_heading_is_not_a_field() {
|
||
// A heading long enough to be a sentence is a heading, not metadata.
|
||
assert!(field_line("## And then she said to her: run").is_none());
|
||
assert!(field_line("## A very long heading indeed that goes on: yes").is_none());
|
||
// The longest field name actually in use still parses.
|
||
assert_eq!(
|
||
field_line("## Word Count Target: 1500").map(|f| f.name),
|
||
Some("Word Count Target".to_string())
|
||
);
|
||
assert!(field_line("## No colon here").is_none());
|
||
// A bare field line is accepted — character sheets are written that way.
|
||
assert_eq!(
|
||
field_line("Age: 37"),
|
||
Some(Field { name: "Age".into(), value: "37".into() })
|
||
);
|
||
// But not one whose label could not be a label.
|
||
assert!(field_line("At 17, Bixby was hurt: badly").is_none());
|
||
assert!(field_line(" indented: value").is_none());
|
||
assert!(field_line("2026 - Bixby: something").is_none());
|
||
assert!(field_line("#NoSpace: value").is_none());
|
||
// But a short label is.
|
||
assert_eq!(
|
||
field_line("## POV: Bixby"),
|
||
Some(Field { name: "POV".into(), value: "Bixby".into() })
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn comments_are_stripped_before_fields_are_read() {
|
||
let text = "<!-- ## POV: Ghost -->\n## POV: Bixby\n";
|
||
assert_eq!(field(text, "", "POV").as_deref(), Some("Bixby"));
|
||
}
|
||
|
||
#[test]
|
||
fn without_a_marker_the_whole_document_is_scanned() {
|
||
assert_eq!(field("## POV: Ada\n\nprose\n", "", "POV").as_deref(), Some("Ada"));
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod split_tests {
|
||
use super::*;
|
||
|
||
const DOC: &str = "# Title: A\n\n## POV: Ada\n\n### Rough Draft:\n\nShe waited.\n";
|
||
|
||
#[test]
|
||
fn the_split_is_lossless() {
|
||
let (header, body) = split_at_marker(DOC, "### Rough Draft:").unwrap();
|
||
assert_eq!(format!("{header}{body}"), DOC, "rejoining must be exact");
|
||
assert!(header.ends_with("### Rough Draft:\n"));
|
||
assert_eq!(body, "\nShe waited.\n");
|
||
}
|
||
|
||
#[test]
|
||
fn the_marker_is_matched_loosely_like_everywhere_else() {
|
||
// Case and trailing text on the marker line are tolerated.
|
||
let doc = "# A\n### rough draft: \n\nprose\n";
|
||
let (header, body) = split_at_marker(doc, "### Rough Draft:").unwrap();
|
||
assert!(header.ends_with("### rough draft: \n"));
|
||
assert_eq!(body, "\nprose\n");
|
||
}
|
||
|
||
#[test]
|
||
fn a_document_without_a_marker_does_not_split() {
|
||
assert!(split_at_marker("# A\n\nprose\n", "### Rough Draft:").is_none());
|
||
// An empty marker disables splitting, as it disables header parsing.
|
||
assert!(split_at_marker(DOC, "").is_none());
|
||
assert!(split_at_marker(DOC, " ").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn only_the_first_marker_splits() {
|
||
let doc = "# A\n### Rough Draft:\nfirst\n### Rough Draft:\nsecond\n";
|
||
let (header, body) = split_at_marker(doc, "### Rough Draft:").unwrap();
|
||
assert_eq!(header, "# A\n### Rough Draft:\n");
|
||
assert!(body.starts_with("first\n"));
|
||
}
|
||
|
||
#[test]
|
||
fn a_marker_on_the_last_line_leaves_an_empty_body() {
|
||
let doc = "# A\n### Rough Draft:";
|
||
let (header, body) = split_at_marker(doc, "### Rough Draft:").unwrap();
|
||
assert_eq!(header, doc);
|
||
assert_eq!(body, "");
|
||
}
|
||
|
||
#[test]
|
||
fn multibyte_text_splits_on_a_character_boundary() {
|
||
let doc = "# Título: Café\n### Rough Draft:\nprosa — sí\n";
|
||
let (header, body) = split_at_marker(doc, "### Rough Draft:").unwrap();
|
||
assert_eq!(format!("{header}{body}"), doc);
|
||
assert_eq!(body, "prosa — sí\n");
|
||
}
|
||
}
|