//! A heuristic, nudge-only progress strip for the Snowflake-staged project //! template (`00-Scratch Pad` … `09-Archive`): how much each stage folder //! actually holds, so the planning → draft handoff is visible at a glance. //! Tolerant of custom/non-snowflake templates — a missing stage folder is //! simply left out, never an error. use super::*; /// The bundled snowflake template's stage folders, in order. A project built /// from a custom template may not have all — or any — of these; each is /// only shown if it actually exists. const STAGES: &[&str] = &[ "00-Scratch Pad", "01-Premise", "02-Synopsis", "03-Story-Bible", "04-Outline", "05-First Draft", "06-Revisions", "07-Final", "08-Covers", "09-Archive", ]; /// Word count under which a stage counts as merely "started" rather than /// "solid" — past template boilerplate, short of a full stage's worth. const SOLID_THRESHOLD: usize = 200; /// Total prose words under each stage folder that actually exists in /// `workspace`, in template order. A missing folder is left out entirely. pub(super) fn stage_word_counts(workspace: &Path) -> Vec<(String, usize)> { STAGES .iter() .filter_map(|stage| { let dir = workspace.join(stage); if !dir.is_dir() { return None; } let words: usize = markdown_files_under(&dir) .iter() .map(|path| count_words(&std::fs::read_to_string(path).unwrap_or_default())) .sum(); Some((stage.to_string(), words)) }) .collect() } impl App { /// A compact "○/◐/● stage name" line per snowflake stage folder found in /// the workspace. Collapsed by default and draws nothing at all when no /// stage folder is present, so a custom-template project isn't left with /// an empty header. pub(super) fn snowflake_progress_section(&self, ui: &mut egui::Ui) { let counts = stage_word_counts(self.workspace()); if counts.is_empty() { return; } egui::CollapsingHeader::new("Snowflake progress") .default_open(false) .show(ui, |ui| { for (stage, words) in &counts { let icon = if *words == 0 { "○" } else if *words < SOLID_THRESHOLD { "◐" } else { "●" }; ui.label( egui::RichText::new(format!("{icon} {stage}")) .small() .weak(), ) .on_hover_text(format!("{words} words")); } }); ui.separator(); } } #[cfg(test)] mod tests { use super::*; use std::fs; fn temp_dir(name: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!( "md-manuscript-snowflake-progress-test-{name}-{}", std::process::id() )); let _ = fs::remove_dir_all(&dir); fs::create_dir_all(&dir).unwrap(); dir } #[test] fn a_missing_stage_folder_is_left_out() { let ws = temp_dir("missing"); assert!(stage_word_counts(&ws).is_empty()); } #[test] fn an_empty_stage_folder_counts_zero_words() { let ws = temp_dir("empty"); fs::create_dir_all(ws.join("01-Premise")).unwrap(); let counts = stage_word_counts(&ws); assert_eq!(counts, vec![("01-Premise".to_string(), 0)]); } #[test] fn a_populated_stage_folder_sums_words_across_its_files() { let ws = temp_dir("populated"); let stage = ws.join("05-First Draft"); fs::create_dir_all(stage.join("Act 1")).unwrap(); fs::write(stage.join("Act 1").join("ch01.md"), "one two three").unwrap(); fs::write(stage.join("ch02.md"), "four five").unwrap(); let counts = stage_word_counts(&ws); assert_eq!(counts, vec![("05-First Draft".to_string(), 5)]); } #[test] fn stages_are_reported_in_template_order_not_filesystem_order() { let ws = temp_dir("order"); fs::create_dir_all(ws.join("09-Archive")).unwrap(); fs::create_dir_all(ws.join("01-Premise")).unwrap(); let counts = stage_word_counts(&ws); assert_eq!( counts.iter().map(|(s, _)| s.as_str()).collect::>(), vec!["01-Premise", "09-Archive"] ); } }