Skip to main content

speccoder_speckit/
checklists.rs

1//! Spec Kit checklists: `<feature>/checklists/*.md`, whose unchecked items
2//! `/speckit-implement` treats as a stop-and-ask.
3
4use std::path::Path;
5
6use speccoder_core::ChecklistStatus;
7
8/// Each checklist of the feature directory `feature_dir` (relative paths
9/// from `root`), sorted by file; empty when there is no `checklists/`.
10#[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}