Files
md-manuscript/src/preprocess.rs
T
landon a0d1c3c206 feat: Remove ### Rough Draft: marker, use YAML frontmatter only for draft files
- 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>
2026-09-16 13:33:23 -05:00

799 lines
28 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.
//! 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");
}
}