use std::{collections::HashMap, path::Path, io}; /// Reads and parses a `CHECKLIST.md` file, extracting both *values* (from /// fenced code blocks) and *toggles* (from checkbox lines). /// /// # Format /// /// - **Values**: fenced code blocks where the info string is the key and /// the first non-empty line is the value. Example: /// \`\`\`name
my-cli
\`\`\`
/// - **Toggles**: checkbox lines like `- [x] \`ns:key\`` (checked) or /// `- [ ] \`ns:key\`` (unchecked). /// /// Checked items are stored as `"namespace:key"`. /// /// # Usage /// /// ```rust,ignore /// let reader = CheckListReader::from(&Path::new("CHECKLIST.md")).unwrap(); /// assert_eq!(reader.read_value("name"), Some("my-cli".into())); /// assert!(reader.read_toggle("ver:0.2")); /// assert!(!reader.read_toggle("feat:comp")); /// assert_eq!(reader.read_toggles("feat"), vec!["feat:async"]); /// ``` pub struct CheckListReader { /// Values extracted from fenced code blocks: `key → value`. values: HashMap, /// Checked toggles: `"namespace:key" → true` (only checked items are stored). toggles: HashMap, /// All toggles (checked or not): `"namespace:key" → checked`. all_toggles: HashMap, } impl CheckListReader { /// Parse a CHECKLIST.md from a file path in a single left-to-right pass pub fn from(path: &Path) -> Result { let content = std::fs::read_to_string(path)?; let mut reader = Self { values: HashMap::new(), toggles: HashMap::new(), all_toggles: HashMap::new(), }; reader.parse(&content); Ok(reader) } /// Parse CHECKLIST.md content in a single pass. fn parse(&mut self, content: &str) { let lines: Vec<&str> = content.lines().collect(); let mut i = 0; while i < lines.len() { let line = lines[i]; if let Some(key) = line.strip_prefix("```").map(|s| s.trim()) && !key.is_empty() && !key.starts_with(' ') { let mut block_lines = Vec::new(); i += 1; while i < lines.len() && !lines[i].trim_start().starts_with("```") { block_lines.push(lines[i]); i += 1; } let value = block_lines .into_iter() .find(|l| !l.trim().is_empty()) .map(|l| l.trim().to_string()); if let Some(val) = value { self.values.insert(key.to_string(), val); } continue; } if let Some(toggle_key) = Self::parse_toggle_line(line) { let is_checked = line.contains("[x]") || line.contains("[X]"); self.all_toggles .insert(toggle_key.clone(), is_checked); if is_checked { self.toggles.insert(toggle_key, true); } } i += 1; } } /// Extract the `namespace:key` from a toggle line. /// /// Matches: `- [x] `namespace:key`` or `- [ ] `namespace:key`` fn parse_toggle_line(line: &str) -> Option { let line = line.trim(); if !line.starts_with("- [") { return None; } // Find the backtick-enclosed key let start = line.find('`')?; let rest = &line[start + 1..]; let end = rest.find('`')?; let key = rest[..end].trim(); if key.is_empty() { return None; } Some(key.to_string()) } /// Read a value by its key. /// /// Returns `Some(value)` if a fenced code block with that key was found, /// or `None` if the key does not exist. #[must_use] pub fn read_value(&self, key: &str) -> Option { self.values.get(key).cloned() } /// Check if a toggle (`namespace:key`) is checked. /// /// Returns `true` if the checkbox line was `- [x]`, `false` if it was /// `- [ ]` or the key does not exist in the file. #[must_use] pub fn read_toggle(&self, key: &str) -> bool { self.toggles.contains_key(key) } /// Get all toggles under a given `namespace` that are checked. /// /// For example, `read_toggles("feat")` returns all checked keys starting /// with `"feat:"`, such as `["feat:comp", "feat:parser"]`. #[must_use] pub fn read_toggles(&self, namespace: &str) -> Vec { let prefix = format!("{namespace}:"); let mut keys: Vec = self .toggles .keys() .filter(|k| k.starts_with(&prefix)) .cloned() .collect(); keys.sort(); keys } /// Get ALL toggles (checked or not) under a namespace. /// /// Useful for iterating all available options. #[must_use] pub fn all_toggles(&self, namespace: &str) -> Vec { let prefix = format!("{namespace}:"); let mut keys: Vec = self .all_toggles .keys() .filter(|k| k.starts_with(&prefix)) .cloned() .collect(); keys.sort(); keys } } #[cfg(test)] mod tests { use super::*; fn sample_md() -> &'static str { r#"> Some intro ## Question 1: What is your project name? ```name my-cli ``` ## Question 2: Which version? - [x] `ver:0.2` ## Question 3: Features? - [ ] `feat:structural_renderer` - [x] `feat:comp` - [x] `feat:parser` - [ ] `feat:async` ```other some value ``` "# } #[test] fn test_read_value() { let mut reader = CheckListReader { values: HashMap::new(), toggles: HashMap::new(), all_toggles: HashMap::new(), }; reader.parse(sample_md()); assert_eq!(reader.read_value("name"), Some("my-cli".into())); assert_eq!(reader.read_value("other"), Some("some value".into())); assert_eq!(reader.read_value("nonexistent"), None); } #[test] fn test_read_toggle() { let mut reader = CheckListReader { values: HashMap::new(), toggles: HashMap::new(), all_toggles: HashMap::new(), }; reader.parse(sample_md()); assert!(reader.read_toggle("ver:0.2")); assert!(reader.read_toggle("feat:comp")); assert!(reader.read_toggle("feat:parser")); assert!(!reader.read_toggle("feat:structural_renderer")); assert!(!reader.read_toggle("feat:async")); assert!(!reader.read_toggle("nonexistent:key")); } #[test] fn test_read_toggles() { let mut reader = CheckListReader { values: HashMap::new(), toggles: HashMap::new(), all_toggles: HashMap::new(), }; reader.parse(sample_md()); let feat_toggles = reader.read_toggles("feat"); assert_eq!(feat_toggles, vec!["feat:comp", "feat:parser"]); } #[test] fn test_all_toggles() { let mut reader = CheckListReader { values: HashMap::new(), toggles: HashMap::new(), all_toggles: HashMap::new(), }; reader.parse(sample_md()); let all = reader.all_toggles("feat"); assert_eq!( all, vec![ "feat:async", "feat:comp", "feat:parser", "feat:structural_renderer", ] ); } #[test] fn test_from_path() { // Write a temp CHECKLIST.md, read it, then clean up let dir = std::env::temp_dir().join("mling_checklist_test"); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("CHECKLIST.md"); std::fs::write(&path, sample_md()).unwrap(); let reader = CheckListReader::from(&path).unwrap(); assert_eq!(reader.read_value("name"), Some("my-cli".into())); assert!(reader.read_toggle("ver:0.2")); let _ = std::fs::remove_dir_all(&dir); } #[test] fn test_empty_file() { let mut reader = CheckListReader { values: HashMap::new(), toggles: HashMap::new(), all_toggles: HashMap::new(), }; reader.parse(""); assert_eq!(reader.read_value("anything"), None); assert!(!reader.read_toggle("ns:key")); assert!(reader.read_toggles("ns").is_empty()); } #[test] fn test_no_toggle_or_value_lines() { let md = "# Just a heading\n\nSome text\n\n```code\nstill not a value\n```\n"; let mut reader = CheckListReader { values: HashMap::new(), toggles: HashMap::new(), all_toggles: HashMap::new(), }; reader.parse(md); // "code" isn't a valid value key (it's used as a language tag, not a name/value key) // but our parser treats ANY ```key as a value block. This is correct behavior — // malformed CHECKLIST.md may produce unexpected values. assert_eq!(reader.read_value("code"), Some("still not a value".into())); } }