cd8663349a
- Add depth and path fields to Beat struct to track heading hierarchy - Rewrite beats() parser to use heading stack and process_headings() helper - Add BeatTree enum with Heading and Beat variants for tree structure - Add build_beat_tree() to convert flat beats to nested tree - Add render_beat_tree() for recursive tree rendering with collapsible headers - Add count_beat_tree() helper to count beats in subtree - Replace flat list rendering with tree-based rendering in outline_window() The implementation currently groups beats by section, with infrastructure in place for full hierarchical nesting based on beat.path. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
266 lines
8.7 KiB
Rust
266 lines
8.7 KiB
Rust
//! 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)
|
||
//!
|
||
//! <!-- Opening Image / Hook: A vivid snapshot of the ordinary world. -->
|
||
//!
|
||
//! <!-- Inciting Incident: An event that disrupts the status-quo. -->
|
||
//! ```
|
||
//!
|
||
//! 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<String>,
|
||
}
|
||
|
||
/// 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<Beat> {
|
||
let mut out: Vec<Beat> = 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("<!--") {
|
||
let before = &rest[..start];
|
||
// Process any headings in the text before this comment
|
||
process_headings(before, &mut heading_stack);
|
||
|
||
// Anything before this comment belongs to the previous beat.
|
||
pending_prose.push_str(before);
|
||
settle(&mut out, &mut pending_prose);
|
||
|
||
let Some(end) = rest[start + 4..].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<String> = 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<String> {
|
||
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<String> {
|
||
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)
|
||
|
||
<!-- Opening Image / Hook: A vivid snapshot of the ordinary world. -->
|
||
|
||
<!-- Inciting Incident: An event that disrupts the status-quo. -->
|
||
|
||
## Act II – Confrontation
|
||
|
||
<!-- Midpoint: A major turning point. -->
|
||
";
|
||
|
||
#[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
|
||
|
||
<!-- Opening Image: the prompt. -->
|
||
|
||
Ada stands on the ice, counting bolts.
|
||
|
||
<!-- Inciting Incident: the prompt. -->
|
||
";
|
||
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 = "<!-- A: prompt -->\n\n## Act II\n\n<!-- B: prompt -->\n";
|
||
let beats = beats(text);
|
||
assert_eq!(progress(&beats), (0, 2));
|
||
}
|
||
|
||
#[test]
|
||
fn prose_after_the_last_prompt_still_counts() {
|
||
let text = "<!-- Climax: prompt -->\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 = "<!-- just a note to self -->\n\n<!-- Climax: prompt -->\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("<!-- Climax: prompt -->\n\n<!-- broken");
|
||
assert_eq!(beats.len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn a_file_with_no_comments_yields_nothing() {
|
||
assert!(beats("# Just prose\n\nWords.\n").is_empty());
|
||
assert!(beats("").is_empty());
|
||
}
|
||
|
||
/// Prose written *before* the first prompt belongs to no beat and must not
|
||
/// be credited to one that comes later.
|
||
#[test]
|
||
fn prose_before_the_first_prompt_credits_nothing() {
|
||
let text = "Some preamble.\n\n<!-- Climax: prompt -->\n";
|
||
assert_eq!(progress(&beats(text)), (0, 1));
|
||
}
|
||
}
|