Skip to main content

mana/commands/
next.rs

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/// A scored unit with metadata for display.
13#[derive(Debug, Serialize)]
14pub struct ScoredUnit {
15    pub id: String,
16    pub title: String,
17    pub priority: u8,
18    pub score: f64,
19    /// IDs of units this one unblocks (directly depends-on this unit).
20    pub unblocks: Vec<String>,
21    /// Age in days since creation.
22    pub age_days: u64,
23    /// Number of verify attempts so far.
24    pub attempts: u32,
25}
26
27/// Score a ready unit based on the ranking criteria.
28///
29/// Higher score = should be worked on first.
30///
31/// Components:
32/// 1. Priority weight (P0 = 50, P1 = 40, P2 = 30, P3 = 20, P4 = 10)
33/// 2. Dependency depth — how many other units does this unblock (transitive)
34/// 3. Age in days (capped at 30 to prevent runaway scores)
35/// 4. Fewer attempts = higher score (fresh units preferred)
36fn score_unit(entry: &IndexEntry, unblock_count: usize) -> f64 {
37    // Priority: P0=50, P1=40, P2=30, P3=20, P4=10
38    let priority_score = (5u8.saturating_sub(entry.priority)) as f64 * 10.0;
39
40    // Dependency depth: each unit unblocked adds 5 points (capped at 50)
41    let unblock_score = (unblock_count as f64 * 5.0).min(50.0);
42
43    // Age: 1 point per day, capped at 30
44    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    // Attempts: penalize 3 points per attempt (capped at 15)
51    let attempt_penalty = (entry.attempts as f64 * 3.0).min(15.0);
52
53    priority_score + unblock_score + age_score - attempt_penalty
54}
55
56/// Count how many units a given unit transitively unblocks.
57///
58/// Walks the reverse dependency graph from `unit_id` counting all
59/// units that are (transitively) waiting on this unit.
60fn 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(&current) {
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
77/// Get direct unblock IDs (units that directly depend on this one).
78fn 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
82/// Pick the top N recommended units to work on next.
83pub fn cmd_next(n: usize, json: bool, mana_dir: &Path) -> Result<()> {
84    let index = Index::load_or_rebuild(mana_dir)?;
85
86    // Find ready units: open, has verify, not blocked, not a feature
87    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    // Build reverse dependency map: unit_id -> list of units that depend on it
108    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    // Score and sort
119    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    // Sort by score descending
143    scored.sort_by(|a, b| {
144        b.score
145            .partial_cmp(&a.score)
146            .unwrap_or(std::cmp::Ordering::Equal)
147    });
148
149    // Take top N
150    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        // A -> B -> C (A unblocks B, B unblocks C, so A transitively unblocks B and C)
286        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}