//! 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, } /// 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. pub fn beats(markdown: &str) -> Vec { let mut out: Vec = Vec::new(); let mut section = String::new(); let mut rest = markdown; // 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) { out.push(Beat { section: section.clone(), name, filled: false, }); } rest = &rest[start + 4 + end + 3..]; } pending_prose.push_str(rest); if let Some(heading) = last_heading(rest) { settle(&mut out, &mut pending_prose); let _ = heading; // a trailing heading opens nothing } settle(&mut out, &mut pending_prose); out } /// 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)); } }