Skip to main content

mana_core/ops/
status.rs

1use std::path::Path;
2
3use anyhow::Result;
4use serde::Serialize;
5
6use crate::blocking::check_blocked_with_archive;
7use crate::index::{ArchiveIndex, Index, IndexEntry};
8use crate::unit::Status;
9use crate::util::natural_cmp;
10
11/// Categorized view of project status.
12#[derive(Debug, Serialize)]
13pub struct StatusSummary {
14    pub features: Vec<IndexEntry>,
15    pub claimed: Vec<IndexEntry>,
16    pub ready: Vec<IndexEntry>,
17    pub goals: Vec<IndexEntry>,
18    pub blocked: Vec<BlockedEntry>,
19}
20
21/// An entry that is blocked with its reason.
22#[derive(Debug, Serialize)]
23pub struct BlockedEntry {
24    #[serde(flatten)]
25    pub entry: IndexEntry,
26    pub block_reason: String,
27}
28
29/// Compute the project status summary: categorize units into claimed, ready,
30/// goals (need decomposition), and blocked.
31pub fn status(mana_dir: &Path) -> Result<StatusSummary> {
32    let index = Index::load_or_rebuild(mana_dir)?;
33    let archive = ArchiveIndex::load_or_rebuild(mana_dir)
34        .unwrap_or_else(|_| ArchiveIndex { units: Vec::new() });
35
36    let mut features: Vec<IndexEntry> = Vec::new();
37    let mut claimed: Vec<IndexEntry> = Vec::new();
38    let mut ready: Vec<IndexEntry> = Vec::new();
39    let mut goals: Vec<IndexEntry> = Vec::new();
40    let mut blocked: Vec<BlockedEntry> = Vec::new();
41
42    for entry in &index.units {
43        if entry.feature {
44            features.push(entry.clone());
45            continue;
46        }
47        match entry.status {
48            Status::InProgress | Status::AwaitingVerify => {
49                claimed.push(entry.clone());
50            }
51            Status::Open => {
52                if let Some(reason) = check_blocked_with_archive(entry, &index, Some(&archive)) {
53                    blocked.push(BlockedEntry {
54                        entry: entry.clone(),
55                        block_reason: reason.to_string(),
56                    });
57                } else if entry.has_verify {
58                    ready.push(entry.clone());
59                } else {
60                    goals.push(entry.clone());
61                }
62            }
63            Status::Closed => {}
64        }
65    }
66
67    sort_entries(&mut features);
68    sort_entries(&mut claimed);
69    sort_entries(&mut ready);
70    sort_entries(&mut goals);
71    blocked.sort_by(|a, b| match a.entry.priority.cmp(&b.entry.priority) {
72        std::cmp::Ordering::Equal => natural_cmp(&a.entry.id, &b.entry.id),
73        other => other,
74    });
75
76    Ok(StatusSummary {
77        features,
78        claimed,
79        ready,
80        goals,
81        blocked,
82    })
83}
84
85fn sort_entries(entries: &mut [IndexEntry]) {
86    entries.sort_by(|a, b| match a.priority.cmp(&b.priority) {
87        std::cmp::Ordering::Equal => natural_cmp(&a.id, &b.id),
88        other => other,
89    });
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use crate::unit::Unit;
96    use crate::util::title_to_slug;
97    use std::fs;
98    use tempfile::TempDir;
99
100    fn setup() -> (TempDir, std::path::PathBuf) {
101        let dir = TempDir::new().unwrap();
102        let mana_dir = dir.path().join(".mana");
103        fs::create_dir(&mana_dir).unwrap();
104        (dir, mana_dir)
105    }
106
107    fn write_unit(mana_dir: &Path, unit: &Unit) {
108        let slug = title_to_slug(&unit.title);
109        let path = mana_dir.join(format!("{}-{}.md", unit.id, slug));
110        unit.to_file(path).unwrap();
111    }
112
113    #[test]
114    fn status_categorizes_units() {
115        let (_dir, mana_dir) = setup();
116
117        // Open unit with verify -> ready
118        let mut ready_unit = Unit::new("1", "Ready task");
119        ready_unit.verify = Some("cargo test".to_string());
120        write_unit(&mana_dir, &ready_unit);
121
122        // Open unit without verify -> goals
123        let goal_unit = Unit::new("2", "Goal task");
124        write_unit(&mana_dir, &goal_unit);
125
126        // In progress -> claimed
127        let mut claimed_unit = Unit::new("3", "Claimed task");
128        claimed_unit.status = Status::InProgress;
129        write_unit(&mana_dir, &claimed_unit);
130
131        let result = status(&mana_dir).unwrap();
132
133        assert_eq!(result.ready.len(), 1);
134        assert_eq!(result.ready[0].id, "1");
135
136        assert_eq!(result.goals.len(), 1);
137        assert_eq!(result.goals[0].id, "2");
138
139        assert_eq!(result.claimed.len(), 1);
140        assert_eq!(result.claimed[0].id, "3");
141
142        assert!(result.blocked.is_empty());
143    }
144
145    #[test]
146    fn status_detects_blocked() {
147        let (_dir, mana_dir) = setup();
148
149        // Create a dependency that's still open
150        let mut dep = Unit::new("1", "Dependency");
151        dep.verify = Some("true".to_string());
152        write_unit(&mana_dir, &dep);
153
154        // Create unit depending on the open dep
155        let mut blocked_unit = Unit::new("2", "Blocked task");
156        blocked_unit.verify = Some("true".to_string());
157        blocked_unit.dependencies = vec!["1".to_string()];
158        write_unit(&mana_dir, &blocked_unit);
159
160        let result = status(&mana_dir).unwrap();
161
162        assert_eq!(result.blocked.len(), 1);
163        assert_eq!(result.blocked[0].entry.id, "2");
164    }
165
166    #[test]
167    fn status_empty_project() {
168        let (_dir, mana_dir) = setup();
169
170        let result = status(&mana_dir).unwrap();
171
172        assert!(result.features.is_empty());
173        assert!(result.claimed.is_empty());
174        assert!(result.ready.is_empty());
175        assert!(result.goals.is_empty());
176        assert!(result.blocked.is_empty());
177    }
178
179    #[test]
180    fn status_skips_closed() {
181        let (_dir, mana_dir) = setup();
182
183        let mut unit = Unit::new("1", "Closed task");
184        unit.status = Status::Closed;
185        write_unit(&mana_dir, &unit);
186
187        let result = status(&mana_dir).unwrap();
188
189        assert!(result.claimed.is_empty());
190        assert!(result.ready.is_empty());
191        assert!(result.goals.is_empty());
192        assert!(result.blocked.is_empty());
193    }
194
195    #[test]
196    fn awaiting_verify_appears_in_claimed() {
197        let (_dir, mana_dir) = setup();
198
199        let mut unit = Unit::new("1", "Awaiting verify task");
200        unit.verify = Some("cargo test".to_string());
201        unit.status = Status::AwaitingVerify;
202        write_unit(&mana_dir, &unit);
203
204        let result = status(&mana_dir).unwrap();
205
206        assert_eq!(result.claimed.len(), 1);
207        assert_eq!(result.claimed[0].id, "1");
208        assert!(result.ready.is_empty());
209        assert!(result.goals.is_empty());
210    }
211
212    #[test]
213    fn status_archived_dep_not_blocking() {
214        let (_dir, mana_dir) = setup();
215
216        // Write an archived dep into .mana/archive/
217        let archive_dir = mana_dir.join("archive");
218        fs::create_dir(&archive_dir).unwrap();
219        let mut archived_dep = Unit::new("1", "Archived dep");
220        archived_dep.status = Status::Closed;
221        archived_dep
222            .to_file(archive_dir.join("1-archived-dep.md"))
223            .unwrap();
224
225        // Unit depending on the archived dep should NOT be blocked
226        let mut unit = Unit::new("2", "Dependent task");
227        unit.verify = Some("true".to_string());
228        unit.dependencies = vec!["1".to_string()];
229        write_unit(&mana_dir, &unit);
230
231        let result = status(&mana_dir).unwrap();
232
233        assert!(
234            result.blocked.is_empty(),
235            "expected no blocked units, got: {:?}",
236            result
237                .blocked
238                .iter()
239                .map(|b| &b.entry.id)
240                .collect::<Vec<_>>()
241        );
242        assert_eq!(result.ready.len(), 1);
243        assert_eq!(result.ready[0].id, "2");
244    }
245}