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#[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#[derive(Debug, Serialize)]
24pub struct BlockedEntry {
25 #[serde(flatten)]
26 pub entry: IndexEntry,
27 pub block_reason: String,
28}
29
30pub 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 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 let goal_unit = Unit::new("2", "Goal task");
159 write_unit(&mana_dir, &goal_unit);
160
161 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 let mut dep = Unit::new("1", "Dependency");
186 dep.verify = Some("true".to_string());
187 write_unit(&mana_dir, &dep);
188
189 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 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 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}