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