//! Reading the state of a project's outline. //! //! The snowflake template's outline files are scaffolds made entirely of HTML //! comments, one per story beat: //! //! ```text //! ## Act I – Setup (≈ 25 % of the story) //! //! //! //! //! ``` //! //! The app strips comments everywhere else, which means these files render and //! export as blank — there is nothing to see and no way to tell how much of the //! outline is actually written. This module reads the scaffold back: the beats //! it names, and which of them have prose under them yet. /// One beat of an outline, and whether it has been written. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Beat { /// The heading this beat sits under, e.g. `Act I – Setup`. pub section: String, /// The beat's name, taken from the start of its prompt comment. pub name: String, /// Whether any prose has been written for it. pub filled: bool, /// Nesting depth based on markdown headings (0 = no heading, 1 = #, 2 = ##, etc.) pub depth: usize, /// Full path of parent headings, e.g., ["Act 1", "Chapter 1"] for a scene in Act 1, Chapter 1 pub path: Vec, } /// Read an outline file's beats and their state. /// /// A beat is "filled" when there is ordinary text between its prompt comment /// and the next prompt or heading — which is exactly where you would type it. /// /// Heading hierarchy is tracked to support nesting. Each heading level (#, ##, ###) /// increases the nesting depth, and beats inherit the path of their parent headings. pub fn beats(markdown: &str) -> Vec { let mut out: Vec = Vec::new(); let mut rest = markdown; // Track heading hierarchy: stack of (heading_text, depth) pairs let mut heading_stack: Vec<(String, usize)> = Vec::new(); // Text seen since the last prompt, which decides whether it is filled. let mut pending_prose = String::new(); while let Some(start) = rest.find("") else { break; }; let body = &rest[start + 4..start + 4 + end]; if let Some(name) = prompt_name(body) { // Build path from heading stack let path: Vec = heading_stack.iter().map(|(h, _)| h.clone()).collect(); let depth = heading_stack.len(); let section = if !heading_stack.is_empty() { heading_stack.last().unwrap().0.clone() } else { String::new() }; out.push(Beat { section: section.clone(), name, filled: false, depth, path, }); } rest = &rest[start + 4 + end + 3..]; } pending_prose.push_str(rest); // Process any remaining headings process_headings(rest, &mut heading_stack); settle(&mut out, &mut pending_prose); out } /// Process headings in text and update the heading stack. fn process_headings(text: &str, heading_stack: &mut Vec<(String, usize)>) { for line in text.lines() { let trimmed = line.trim(); if !trimmed.starts_with('#') { continue; } let hashes = trimmed.len() - trimmed.trim_start_matches('#').len(); if hashes == 0 || hashes > 6 { continue; } let name = trimmed[hashes..].trim(); if name.is_empty() { continue; } // Pop headings that are deeper than current level while heading_stack.len() >= hashes { heading_stack.pop(); } // Push current heading heading_stack.push((name.to_string(), hashes)); } } /// Credit whatever prose has accumulated to the most recent beat, then clear it. fn settle(beats: &mut [Beat], pending: &mut String) { if has_prose(pending) { if let Some(last) = beats.last_mut() { last.filled = true; } } pending.clear(); } /// Whether a run of text contains anything a writer would call content — /// headings and blank lines do not count. fn has_prose(text: &str) -> bool { text.lines() .map(str::trim) .any(|line| !line.is_empty() && !line.starts_with('#')) } /// The last markdown heading in a run of text, if any. fn last_heading(text: &str) -> Option { text.lines().rev().find_map(|line| { let trimmed = line.trim(); let hashes = trimmed.len() - trimmed.trim_start_matches('#').len(); if hashes == 0 { return None; } let name = trimmed[hashes..].trim(); (!name.is_empty()).then(|| name.to_string()) }) } /// The beat name at the start of a prompt comment: everything before its first /// colon, which is how the template writes them (`Midpoint: A major turning…`). fn prompt_name(body: &str) -> Option { let text = body.trim(); if text.is_empty() { return None; } let name = match text.split_once(':') { Some((name, _)) => name.trim(), // A comment with no colon is a note, not a beat prompt. None => return None, }; // Prompt names are labels; a long one is a sentence that happened to have // a colon in it. if name.is_empty() || name.split_whitespace().count() > 8 { return None; } Some(name.to_string()) } /// How many of `beats` are written, and how many there are. pub fn progress(beats: &[Beat]) -> (usize, usize) { (beats.iter().filter(|b| b.filled).count(), beats.len()) } #[cfg(test)] mod tests { use super::*; const SCAFFOLD: &str = "\ ## Act I – Setup (≈ 25 % of the story) ## Act II – Confrontation "; #[test] fn an_untouched_scaffold_has_no_beats_written() { let beats = beats(SCAFFOLD); assert_eq!(beats.len(), 3); assert!(beats.iter().all(|b| !b.filled)); assert_eq!(progress(&beats), (0, 3)); } #[test] fn beat_names_and_their_sections_are_read() { let beats = beats(SCAFFOLD); assert_eq!(beats[0].name, "Opening Image / Hook"); assert_eq!(beats[0].section, "Act I – Setup (≈ 25 % of the story)"); assert_eq!(beats[1].name, "Inciting Incident"); assert_eq!(beats[2].name, "Midpoint"); assert_eq!(beats[2].section, "Act II – Confrontation"); } #[test] fn prose_after_a_prompt_marks_it_written() { let written = "\ ## Act I Ada stands on the ice, counting bolts. "; let beats = beats(written); assert_eq!(progress(&beats), (1, 2)); assert!(beats[0].filled, "the beat with prose under it"); assert!(!beats[1].filled, "the one still empty"); } #[test] fn a_heading_does_not_count_as_prose() { let text = "\n\n## Act II\n\n\n"; let beats = beats(text); assert_eq!(progress(&beats), (0, 2)); } #[test] fn prose_after_the_last_prompt_still_counts() { let text = "\n\nShe opens the gate.\n"; let beats = beats(text); assert_eq!(progress(&beats), (1, 1)); } #[test] fn a_comment_without_a_colon_is_a_note_not_a_beat() { let text = "\n\n\n"; let beats = beats(text); assert_eq!(beats.len(), 1); assert_eq!(beats[0].name, "Climax"); } #[test] fn an_unterminated_comment_does_not_hang_or_panic() { let beats = beats("\n\n\n"; assert_eq!(progress(&beats(text)), (0, 1)); } }