From ef5ce208ba3a72228c92cccd3ddd36a2aa9f096f Mon Sep 17 00:00:00 2001
From: 魏曹先生 <1992414357@qq.com>
Date: Fri, 26 Jun 2026 07:59:34 +0800
Subject: feat(proj_mgr): add CHECKLIST.md reader for values and toggles
Parse fenced code blocks as key-value pairs and checkbox lines as
namespace toggles in a single pass
---
mling/src/proj_mgr/checklist_reader.rs | 293 +++++++++++++++++++++++++++++++++
mling/src/proj_mgr/mod.rs | 3 +
2 files changed, 296 insertions(+)
create mode 100644 mling/src/proj_mgr/checklist_reader.rs
(limited to 'mling')
diff --git a/mling/src/proj_mgr/checklist_reader.rs b/mling/src/proj_mgr/checklist_reader.rs
new file mode 100644
index 0000000..6d115bb
--- /dev/null
+++ b/mling/src/proj_mgr/checklist_reader.rs
@@ -0,0 +1,293 @@
+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()));
+ }
+}
diff --git a/mling/src/proj_mgr/mod.rs b/mling/src/proj_mgr/mod.rs
index 04353b7..d0c4cbf 100644
--- a/mling/src/proj_mgr/mod.rs
+++ b/mling/src/proj_mgr/mod.rs
@@ -9,6 +9,9 @@ pub use generator::*;
pub mod metadata;
+mod checklist_reader;
+pub use checklist_reader::*;
+
mod show_binaries;
pub use show_binaries::*;
--
cgit