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#[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#[derive(Debug, Serialize)]
23pub struct BlockedEntry {
24 #[serde(flatten)]
25 pub entry: IndexEntry,
26 pub block_reason: String,
27}
28
29pub 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 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 let goal_unit = Unit::new("2", "Goal task");
124 write_unit(&mana_dir, &goal_unit);
125
126 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 let mut dep = Unit::new("1", "Dependency");
151 dep.verify = Some("true".to_string());
152 write_unit(&mana_dir, &dep);
153
154 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 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 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}