speccoder_speckit/
checklists.rs1use std::path::Path;
5
6use speccoder_core::ChecklistStatus;
7
8#[must_use]
11pub fn status(root: &Path, feature_dir: &str) -> Vec<ChecklistStatus> {
12 let dir = root.join(feature_dir).join("checklists");
13 let Ok(entries) = std::fs::read_dir(&dir) else {
14 return Vec::new();
15 };
16 let mut files: Vec<_> = entries
17 .flatten()
18 .map(|e| e.path())
19 .filter(|p| p.extension().is_some_and(|x| x == "md") && p.is_file())
20 .collect();
21 files.sort();
22 files
23 .into_iter()
24 .filter_map(|p| {
25 let text = std::fs::read_to_string(&p).ok()?;
26 let (mut done, mut open) = (0, 0);
27 for line in text.lines() {
28 let t = line.trim_start();
29 let t = t
30 .strip_prefix("- ")
31 .or_else(|| t.strip_prefix("* "))
32 .unwrap_or("");
33 if t.starts_with("[ ]") {
34 open += 1;
35 } else if t.starts_with("[x]") || t.starts_with("[X]") {
36 done += 1;
37 }
38 }
39 let name = p.file_name()?.to_string_lossy().into_owned();
40 Some(ChecklistStatus {
41 file: format!("{feature_dir}/checklists/{name}"),
42 total: done + open,
43 done,
44 open,
45 })
46 })
47 .collect()
48}
49
50#[cfg(test)]
51mod tests {
52 use super::*;
53
54 #[test]
55 fn counts_checked_and_open_items() {
56 let dir = tempfile::tempdir().unwrap();
57 assert!(status(dir.path(), "specs/001-x").is_empty());
58 let c = dir.path().join("specs/001-x/checklists");
59 std::fs::create_dir_all(&c).unwrap();
60 std::fs::write(
61 c.join("ux.md"),
62 "# UX\n- [x] a\n- [X] b\n - [ ] c\n* [ ] d\ntext [ ] e\n",
63 )
64 .unwrap();
65 std::fs::write(c.join("api.md"), "- [x] a\n").unwrap();
66 std::fs::write(c.join("notes.txt"), "- [ ] ignored\n").unwrap();
67 let s = status(dir.path(), "specs/001-x");
68 assert_eq!(s.len(), 2);
69 assert_eq!(
70 (s[0].file.as_str(), s[0].open),
71 ("specs/001-x/checklists/api.md", 0)
72 );
73 assert_eq!((s[1].total, s[1].done, s[1].open), (4, 2, 2));
74 }
75}