1use std::collections::{HashMap, HashSet};
2use std::path::Path;
3
4use anyhow::Result;
5use chrono::Utc;
6use serde::Serialize;
7
8use crate::blocking::check_blocked;
9use crate::index::{Index, IndexEntry};
10use crate::unit::Status;
11
12#[derive(Debug, Serialize)]
14pub struct ScoredUnit {
15 pub id: String,
16 pub title: String,
17 pub priority: u8,
18 pub score: f64,
19 pub unblocks: Vec<String>,
21 pub age_days: u64,
23 pub attempts: u32,
25}
26
27fn score_unit(entry: &IndexEntry, unblock_count: usize) -> f64 {
37 let priority_score = (5u8.saturating_sub(entry.priority)) as f64 * 10.0;
39
40 let unblock_score = (unblock_count as f64 * 5.0).min(50.0);
42
43 let age_days = Utc::now()
45 .signed_duration_since(entry.created_at)
46 .num_days()
47 .max(0) as f64;
48 let age_score = age_days.min(30.0);
49
50 let attempt_penalty = (entry.attempts as f64 * 3.0).min(15.0);
52
53 priority_score + unblock_score + age_score - attempt_penalty
54}
55
56fn count_transitive_unblocks(unit_id: &str, reverse_deps: &HashMap<String, Vec<String>>) -> usize {
61 let mut visited = HashSet::new();
62 let mut stack = vec![unit_id.to_string()];
63
64 while let Some(current) = stack.pop() {
65 if let Some(dependents) = reverse_deps.get(¤t) {
66 for dep in dependents {
67 if visited.insert(dep.clone()) {
68 stack.push(dep.clone());
69 }
70 }
71 }
72 }
73
74 visited.len()
75}
76
77fn direct_unblocks(unit_id: &str, reverse_deps: &HashMap<String, Vec<String>>) -> Vec<String> {
79 reverse_deps.get(unit_id).cloned().unwrap_or_default()
80}
81
82pub fn cmd_next(n: usize, json: bool, mana_dir: &Path) -> Result<()> {
84 let index = Index::load_or_rebuild(mana_dir)?;
85
86 let ready: Vec<&IndexEntry> = index
88 .units
89 .iter()
90 .filter(|e| {
91 e.status == Status::Open
92 && e.has_verify
93 && !e.feature
94 && check_blocked(e, &index).is_none()
95 })
96 .collect();
97
98 if ready.is_empty() {
99 if json {
100 println!("[]");
101 } else {
102 println!("No ready units. Create one with: mana create \"task\" --verify \"cmd\"");
103 }
104 return Ok(());
105 }
106
107 let mut reverse_deps: HashMap<String, Vec<String>> = HashMap::new();
109 for entry in &index.units {
110 for dep_id in &entry.dependencies {
111 reverse_deps
112 .entry(dep_id.clone())
113 .or_default()
114 .push(entry.id.clone());
115 }
116 }
117
118 let mut scored: Vec<ScoredUnit> = ready
120 .iter()
121 .map(|entry| {
122 let transitive_count = count_transitive_unblocks(&entry.id, &reverse_deps);
123 let unblocks = direct_unblocks(&entry.id, &reverse_deps);
124 let score = score_unit(entry, transitive_count);
125 let age_days = Utc::now()
126 .signed_duration_since(entry.created_at)
127 .num_days()
128 .max(0) as u64;
129
130 ScoredUnit {
131 id: entry.id.clone(),
132 title: entry.title.clone(),
133 priority: entry.priority,
134 score,
135 unblocks,
136 age_days,
137 attempts: entry.attempts,
138 }
139 })
140 .collect();
141
142 scored.sort_by(|a, b| {
144 b.score
145 .partial_cmp(&a.score)
146 .unwrap_or(std::cmp::Ordering::Equal)
147 });
148
149 scored.truncate(n);
151
152 if json {
153 let json_str = serde_json::to_string_pretty(&scored)?;
154 println!("{}", json_str);
155 } else {
156 for unit in &scored {
157 let priority_label = format!("P{}", unit.priority);
158 println!("{} {:.1} {}", priority_label, unit.score, unit.title);
159
160 if !unit.unblocks.is_empty() {
161 println!(" Unblocks: {}", unit.unblocks.join(", "));
162 }
163
164 let attempts_str = if unit.attempts > 0 {
165 format!(" | Attempts: {}", unit.attempts)
166 } else {
167 String::new()
168 };
169
170 println!(
171 " ID: {} | Age: {} days{}",
172 unit.id, unit.age_days, attempts_str
173 );
174 println!();
175 }
176 }
177
178 Ok(())
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184 use chrono::{Duration, Utc};
185
186 fn make_entry(id: &str, priority: u8) -> IndexEntry {
187 IndexEntry {
188 id: id.to_string(),
189 title: format!("Unit {}", id),
190 status: Status::Open,
191 priority,
192 parent: None,
193 dependencies: vec![],
194 labels: vec![],
195 assignee: None,
196 updated_at: Utc::now(),
197 produces: vec![],
198 requires: vec![],
199 has_verify: true,
200 verify: None,
201 created_at: Utc::now(),
202 claimed_by: None,
203 attempts: 0,
204 paths: vec![],
205 feature: false,
206 has_decisions: false,
207 }
208 }
209
210 #[test]
211 fn higher_priority_scores_higher() {
212 let p0 = make_entry("1", 0);
213 let p2 = make_entry("2", 2);
214 let p4 = make_entry("3", 4);
215
216 let reverse_deps = HashMap::new();
217
218 let s0 = score_unit(&p0, count_transitive_unblocks("1", &reverse_deps));
219 let s2 = score_unit(&p2, count_transitive_unblocks("2", &reverse_deps));
220 let s4 = score_unit(&p4, count_transitive_unblocks("3", &reverse_deps));
221
222 assert!(s0 > s2, "P0 ({}) should score higher than P2 ({})", s0, s2);
223 assert!(s2 > s4, "P2 ({}) should score higher than P4 ({})", s2, s4);
224 }
225
226 #[test]
227 fn more_unblocks_scores_higher() {
228 let entry = make_entry("1", 2);
229
230 let s_none = score_unit(&entry, 0);
231 let s_some = score_unit(&entry, 3);
232 let s_many = score_unit(&entry, 10);
233
234 assert!(
235 s_some > s_none,
236 "3 unblocks ({}) > 0 unblocks ({})",
237 s_some,
238 s_none
239 );
240 assert!(
241 s_many > s_some,
242 "10 unblocks ({}) > 3 unblocks ({})",
243 s_many,
244 s_some
245 );
246 }
247
248 #[test]
249 fn older_unit_scores_higher() {
250 let mut old = make_entry("1", 2);
251 old.created_at = Utc::now() - Duration::days(10);
252
253 let new = make_entry("2", 2);
254
255 let s_old = score_unit(&old, 0);
256 let s_new = score_unit(&new, 0);
257
258 assert!(
259 s_old > s_new,
260 "Old ({}) should score higher than new ({})",
261 s_old,
262 s_new
263 );
264 }
265
266 #[test]
267 fn more_attempts_scores_lower() {
268 let fresh = make_entry("1", 2);
269 let mut retried = make_entry("2", 2);
270 retried.attempts = 3;
271
272 let s_fresh = score_unit(&fresh, 0);
273 let s_retried = score_unit(&retried, 0);
274
275 assert!(
276 s_fresh > s_retried,
277 "Fresh ({}) should score higher than retried ({})",
278 s_fresh,
279 s_retried
280 );
281 }
282
283 #[test]
284 fn transitive_unblock_count() {
285 let mut reverse_deps = HashMap::new();
287 reverse_deps.insert("A".to_string(), vec!["B".to_string()]);
288 reverse_deps.insert("B".to_string(), vec!["C".to_string()]);
289
290 assert_eq!(count_transitive_unblocks("A", &reverse_deps), 2);
291 assert_eq!(count_transitive_unblocks("B", &reverse_deps), 1);
292 assert_eq!(count_transitive_unblocks("C", &reverse_deps), 0);
293 }
294
295 #[test]
296 fn direct_unblocks_returns_correct_ids() {
297 let mut reverse_deps = HashMap::new();
298 reverse_deps.insert("A".to_string(), vec!["B".to_string(), "C".to_string()]);
299
300 let unblocks = direct_unblocks("A", &reverse_deps);
301 assert_eq!(unblocks, vec!["B".to_string(), "C".to_string()]);
302
303 let empty = direct_unblocks("Z", &reverse_deps);
304 assert!(empty.is_empty());
305 }
306}