Skip to main content

mana/commands/run/
plan.rs

1use std::path::Path;
2
3use anyhow::Result;
4
5use crate::blocking::{check_blocked_with_archive, check_scope_warning, BlockReason, ScopeWarning};
6use crate::config::Config;
7use crate::index::{ArchiveIndex, Index, IndexEntry};
8use crate::stream::{self, StreamEvent};
9use crate::unit::Status;
10
11use super::ready_queue::all_deps_closed;
12use super::wave::{
13    compute_critical_path, compute_downstream_weights, compute_effective_parallelism,
14    compute_file_conflicts, compute_waves, Wave,
15};
16use super::UnitAction;
17
18/// A unit ready for dispatch.
19#[derive(Debug, Clone)]
20pub struct SizedUnit {
21    pub id: String,
22    pub title: String,
23    pub action: UnitAction,
24    pub priority: u8,
25    pub dependencies: Vec<String>,
26    pub parent: Option<String>,
27    pub produces: Vec<String>,
28    pub requires: Vec<String>,
29    pub paths: Vec<String>,
30    /// Per-unit model override from frontmatter.
31    pub model: Option<String>,
32}
33
34/// A unit that was excluded from dispatch due to scope issues.
35#[derive(Debug, Clone)]
36pub struct BlockedUnit {
37    pub id: String,
38    pub title: String,
39    pub reason: BlockReason,
40}
41
42/// Result from planning dispatch.
43pub struct DispatchPlan {
44    pub waves: Vec<Wave>,
45    pub skipped: Vec<BlockedUnit>,
46    /// Scope warnings for units that will dispatch but have large scope.
47    pub warnings: Vec<(String, ScopeWarning)>,
48    /// Flat list of all units to dispatch (for ready-queue mode).
49    pub all_units: Vec<SizedUnit>,
50    /// The index snapshot used for planning.
51    pub index: Index,
52}
53
54/// Plan dispatch: get ready units, filter by scope, compute waves.
55pub(super) fn plan_dispatch(
56    mana_dir: &Path,
57    _config: &Config,
58    filter_id: Option<&str>,
59    _auto_plan: bool,
60    simulate: bool,
61) -> Result<DispatchPlan> {
62    let index = Index::load_or_rebuild(mana_dir)?;
63    let archive = ArchiveIndex::load_or_rebuild(mana_dir)
64        .unwrap_or_else(|_| ArchiveIndex { units: Vec::new() });
65
66    // Get candidate units: open with verify.
67    // In simulate mode (dry-run), include all open units with verify — even those
68    // whose deps aren't met yet — so compute_waves can show the full execution plan.
69    // In normal mode, only include units whose deps are already closed.
70    let mut candidate_entries: Vec<&IndexEntry> = index
71        .units
72        .iter()
73        .filter(|e| {
74            e.has_verify
75                && e.status == Status::Open
76                && (simulate || all_deps_closed(e, &index, &archive))
77        })
78        .collect();
79
80    // Filter by ID if provided
81    if let Some(filter_id) = filter_id {
82        // Check if it's a parent — if so, get its ready children
83        let is_parent = index
84            .units
85            .iter()
86            .any(|e| e.parent.as_deref() == Some(filter_id));
87        if is_parent {
88            candidate_entries.retain(|e| e.parent.as_deref() == Some(filter_id));
89        } else {
90            candidate_entries.retain(|e| e.id == filter_id);
91        }
92    }
93
94    // Partition into dispatchable vs blocked.
95    // In simulate mode, skip blocking checks — we want to show the full plan.
96    // In normal mode, dependency blocking is already handled by all_deps_closed above,
97    // but check_blocked catches edge cases (e.g., missing deps not in index).
98    // Scope warnings (oversized) are non-blocking — units dispatch with a warning.
99    let mut dispatch_units: Vec<SizedUnit> = Vec::new();
100    let mut skipped: Vec<BlockedUnit> = Vec::new();
101    let mut warnings: Vec<(String, ScopeWarning)> = Vec::new();
102
103    for entry in &candidate_entries {
104        if !simulate {
105            if let Some(reason) = check_blocked_with_archive(entry, &index, Some(&archive)) {
106                skipped.push(BlockedUnit {
107                    id: entry.id.clone(),
108                    title: entry.title.clone(),
109                    reason,
110                });
111                continue;
112            }
113        }
114        // Check for scope warnings (non-blocking)
115        if let Some(warning) = check_scope_warning(entry) {
116            warnings.push((entry.id.clone(), warning));
117        }
118        let unit_path = crate::discovery::find_unit_file(mana_dir, &entry.id)?;
119        let unit = crate::unit::Unit::from_file(&unit_path)?;
120
121        dispatch_units.push(SizedUnit {
122            id: entry.id.clone(),
123            title: entry.title.clone(),
124            action: UnitAction::Implement,
125            priority: entry.priority,
126            dependencies: entry.dependencies.clone(),
127            parent: entry.parent.clone(),
128            produces: entry.produces.clone(),
129            requires: entry.requires.clone(),
130            paths: entry.paths.clone(),
131            model: unit.model.clone(),
132        });
133    }
134
135    let waves = compute_waves(&dispatch_units, &index);
136
137    Ok(DispatchPlan {
138        waves,
139        skipped,
140        warnings,
141        all_units: dispatch_units,
142        index,
143    })
144}
145
146/// Print the dispatch plan without executing.
147pub(super) fn print_plan(plan: &DispatchPlan) {
148    let weights = compute_downstream_weights(&plan.all_units);
149    let critical_path = compute_critical_path(&plan.all_units);
150    let critical_set: std::collections::HashSet<&str> =
151        critical_path.iter().map(|s| s.as_str()).collect();
152
153    // Critical path summary at the top
154    if critical_path.len() > 1 {
155        println!(
156            "Critical path: {} ({} steps)",
157            critical_path.join(" → "),
158            critical_path.len()
159        );
160        println!();
161    }
162
163    for (wave_idx, wave) in plan.waves.iter().enumerate() {
164        let eff_par = compute_effective_parallelism(&wave.units);
165        let par_note = if eff_par < wave.units.len() {
166            format!(", effective concurrency: {}/{}", eff_par, wave.units.len())
167        } else {
168            String::new()
169        };
170        println!(
171            "Wave {}: {} unit(s){}",
172            wave_idx + 1,
173            wave.units.len(),
174            par_note
175        );
176
177        // Precompute file conflicts for this wave so we can annotate per-unit
178        let wave_conflicts = compute_file_conflicts(&wave.units);
179
180        for sb in &wave.units {
181            let weight = weights.get(&sb.id).copied().unwrap_or(1);
182            let weight_note = if weight > 1 {
183                format!("  [weight: {}]", weight)
184            } else {
185                String::new()
186            };
187            let critical_note = if critical_set.contains(sb.id.as_str()) && critical_path.len() > 1
188            {
189                "  ⚡ critical"
190            } else {
191                ""
192            };
193            // Collect conflicts for this unit: other units sharing a file in this wave
194            let mut conflict_parts: Vec<String> = Vec::new();
195            for (file, ids) in &wave_conflicts {
196                if ids.contains(&sb.id) {
197                    for other_id in ids {
198                        if other_id != &sb.id {
199                            conflict_parts.push(format!("{} ({})", other_id, file));
200                        }
201                    }
202                }
203            }
204            let conflict_str = if conflict_parts.is_empty() {
205                String::new()
206            } else {
207                format!("  ⊘ conflicts: {}", conflict_parts.join(", "))
208            };
209            let warning = plan
210                .warnings
211                .iter()
212                .find(|(id, _)| id == &sb.id)
213                .map(|(_, w)| format!("  ⚠ {}", w))
214                .unwrap_or_default();
215            println!(
216                "  {}  {}  {}{}{}{}{}",
217                sb.id, sb.title, sb.action, weight_note, critical_note, conflict_str, warning
218            );
219        }
220    }
221
222    if !plan.skipped.is_empty() {
223        println!();
224        println!("Blocked ({}):", plan.skipped.len());
225        for bb in &plan.skipped {
226            println!("  ⚠ {}  {}  ({})", bb.id, bb.title, bb.reason);
227        }
228    }
229}
230
231/// Print the dispatch plan as JSON stream events.
232pub(super) fn print_plan_json(plan: &DispatchPlan, parent_id: Option<&str>) {
233    let parent_id = parent_id.unwrap_or("all").to_string();
234    let critical_path = compute_critical_path(&plan.all_units);
235    let rounds: Vec<stream::RoundPlan> = plan
236        .waves
237        .iter()
238        .enumerate()
239        .map(|(i, wave)| {
240            let eff_par = compute_effective_parallelism(&wave.units);
241            let conflicts = compute_file_conflicts(&wave.units);
242            let effective_concurrency = if eff_par < wave.units.len() {
243                Some(eff_par)
244            } else {
245                None
246            };
247            stream::RoundPlan {
248                round: i + 1,
249                units: wave
250                    .units
251                    .iter()
252                    .map(|b| stream::UnitInfo {
253                        id: b.id.clone(),
254                        title: b.title.clone(),
255                        round: i + 1,
256                    })
257                    .collect(),
258                effective_concurrency,
259                conflicts,
260            }
261        })
262        .collect();
263
264    stream::emit(&StreamEvent::DryRun {
265        parent_id,
266        rounds,
267        critical_path,
268    });
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use crate::config::Config;
275    use std::fs;
276    use std::path::Path;
277    use tempfile::TempDir;
278
279    fn make_mana_dir() -> (TempDir, std::path::PathBuf) {
280        let dir = TempDir::new().unwrap();
281        let mana_dir = dir.path().join(".mana");
282        fs::create_dir(&mana_dir).unwrap();
283        (dir, mana_dir)
284    }
285
286    fn write_config(mana_dir: &Path, run: Option<&str>) {
287        let run_line = match run {
288            Some(r) => format!("run: \"{}\"\n", r),
289            None => String::new(),
290        };
291        fs::write(
292            mana_dir.join("config.yaml"),
293            format!("project: test\nnext_id: 1\n{}", run_line),
294        )
295        .unwrap();
296    }
297
298    #[test]
299    fn plan_dispatch_no_ready_units() {
300        let (_dir, mana_dir) = make_mana_dir();
301        write_config(&mana_dir, Some("echo {id}"));
302
303        let config = Config::load_with_extends(&mana_dir).unwrap();
304        let plan = plan_dispatch(&mana_dir, &config, None, false, false).unwrap();
305
306        assert!(plan.waves.is_empty());
307        assert!(plan.skipped.is_empty());
308    }
309
310    #[test]
311    fn plan_dispatch_returns_ready_units() {
312        let (_dir, mana_dir) = make_mana_dir();
313        write_config(&mana_dir, Some("echo {id}"));
314
315        let mut unit = crate::unit::Unit::new("1", "Task one");
316        unit.verify = Some("echo ok".to_string());
317        unit.produces = vec!["X".to_string()];
318        unit.paths = vec!["src/x.rs".to_string()];
319        unit.to_file(mana_dir.join("1-task-one.md")).unwrap();
320
321        let mut unit2 = crate::unit::Unit::new("2", "Task two");
322        unit2.verify = Some("echo ok".to_string());
323        unit2.produces = vec!["Y".to_string()];
324        unit2.paths = vec!["src/y.rs".to_string()];
325        unit2.to_file(mana_dir.join("2-task-two.md")).unwrap();
326
327        let config = Config::load_with_extends(&mana_dir).unwrap();
328        let plan = plan_dispatch(&mana_dir, &config, None, false, false).unwrap();
329
330        assert_eq!(plan.waves.len(), 1);
331        assert_eq!(plan.waves[0].units.len(), 2);
332    }
333
334    #[test]
335    fn plan_dispatch_filters_by_id() {
336        let (_dir, mana_dir) = make_mana_dir();
337        write_config(&mana_dir, Some("echo {id}"));
338
339        let mut unit = crate::unit::Unit::new("1", "Task one");
340        unit.verify = Some("echo ok".to_string());
341        unit.produces = vec!["X".to_string()];
342        unit.paths = vec!["src/x.rs".to_string()];
343        unit.to_file(mana_dir.join("1-task-one.md")).unwrap();
344
345        let mut unit2 = crate::unit::Unit::new("2", "Task two");
346        unit2.verify = Some("echo ok".to_string());
347        unit2.produces = vec!["Y".to_string()];
348        unit2.paths = vec!["src/y.rs".to_string()];
349        unit2.to_file(mana_dir.join("2-task-two.md")).unwrap();
350
351        let config = Config::load_with_extends(&mana_dir).unwrap();
352        let plan = plan_dispatch(&mana_dir, &config, Some("1"), false, false).unwrap();
353
354        assert_eq!(plan.waves.len(), 1);
355        assert_eq!(plan.waves[0].units.len(), 1);
356        assert_eq!(plan.waves[0].units[0].id, "1");
357    }
358
359    #[test]
360    fn plan_dispatch_includes_unit_model_override() {
361        let (_dir, mana_dir) = make_mana_dir();
362        write_config(&mana_dir, Some("echo {id}"));
363
364        let mut unit = crate::unit::Unit::new("1", "Task one");
365        unit.verify = Some("echo ok".to_string());
366        unit.model = Some("opus".to_string());
367        unit.to_file(mana_dir.join("1-task-one.md")).unwrap();
368
369        let config = Config::load_with_extends(&mana_dir).unwrap();
370        let plan = plan_dispatch(&mana_dir, &config, Some("1"), false, false).unwrap();
371
372        assert_eq!(plan.waves.len(), 1);
373        assert_eq!(plan.waves[0].units[0].model.as_deref(), Some("opus"));
374    }
375
376    #[test]
377    fn plan_dispatch_parent_id_gets_children() {
378        let (_dir, mana_dir) = make_mana_dir();
379        write_config(&mana_dir, Some("echo {id}"));
380
381        let parent = crate::unit::Unit::new("1", "Parent");
382        parent.to_file(mana_dir.join("1-parent.md")).unwrap();
383
384        let mut child1 = crate::unit::Unit::new("1.1", "Child one");
385        child1.parent = Some("1".to_string());
386        child1.verify = Some("echo ok".to_string());
387        child1.produces = vec!["A".to_string()];
388        child1.paths = vec!["src/a.rs".to_string()];
389        child1.to_file(mana_dir.join("1.1-child-one.md")).unwrap();
390
391        let mut child2 = crate::unit::Unit::new("1.2", "Child two");
392        child2.parent = Some("1".to_string());
393        child2.verify = Some("echo ok".to_string());
394        child2.produces = vec!["B".to_string()];
395        child2.paths = vec!["src/b.rs".to_string()];
396        child2.to_file(mana_dir.join("1.2-child-two.md")).unwrap();
397
398        let config = Config::load_with_extends(&mana_dir).unwrap();
399        let plan = plan_dispatch(&mana_dir, &config, Some("1"), false, false).unwrap();
400
401        assert_eq!(plan.waves.len(), 1);
402        assert_eq!(plan.waves[0].units.len(), 2);
403    }
404
405    #[test]
406    fn oversized_unit_dispatched_with_warning() {
407        let (_dir, mana_dir) = make_mana_dir();
408        write_config(&mana_dir, Some("echo {id}"));
409
410        let mut unit = crate::unit::Unit::new("1", "Oversized unit");
411        unit.verify = Some("echo ok".to_string());
412        // 4 produces exceeds MAX_PRODUCES (3) — warning but not blocked
413        unit.produces = vec![
414            "A".to_string(),
415            "B".to_string(),
416            "C".to_string(),
417            "D".to_string(),
418        ];
419        unit.paths = vec!["src/a.rs".to_string()];
420        unit.to_file(mana_dir.join("1-oversized.md")).unwrap();
421
422        let config = Config::load_with_extends(&mana_dir).unwrap();
423        let plan = plan_dispatch(&mana_dir, &config, None, false, false).unwrap();
424
425        assert_eq!(plan.waves.len(), 1);
426        assert_eq!(plan.waves[0].units.len(), 1);
427        assert!(plan.skipped.is_empty());
428        assert_eq!(plan.warnings.len(), 1);
429        assert_eq!(plan.warnings[0].0, "1");
430    }
431
432    #[test]
433    fn unscoped_unit_dispatched_normally() {
434        let (_dir, mana_dir) = make_mana_dir();
435        write_config(&mana_dir, Some("echo {id}"));
436
437        let mut unit = crate::unit::Unit::new("1", "Unscoped unit");
438        unit.verify = Some("echo ok".to_string());
439        // No produces, no paths — dispatched normally
440        unit.to_file(mana_dir.join("1-unscoped.md")).unwrap();
441
442        let config = Config::load_with_extends(&mana_dir).unwrap();
443        let plan = plan_dispatch(&mana_dir, &config, None, false, false).unwrap();
444
445        assert_eq!(plan.waves.len(), 1);
446        assert_eq!(plan.waves[0].units.len(), 1);
447        assert!(plan.skipped.is_empty());
448        assert!(plan.warnings.is_empty());
449    }
450
451    #[test]
452    fn well_scoped_unit_dispatched() {
453        let (_dir, mana_dir) = make_mana_dir();
454        write_config(&mana_dir, Some("echo {id}"));
455
456        let mut unit = crate::unit::Unit::new("1", "Well scoped");
457        unit.verify = Some("echo ok".to_string());
458        unit.produces = vec!["Widget".to_string()];
459        unit.paths = vec!["src/widget.rs".to_string()];
460        unit.to_file(mana_dir.join("1-well-scoped.md")).unwrap();
461
462        let config = Config::load_with_extends(&mana_dir).unwrap();
463        let plan = plan_dispatch(&mana_dir, &config, None, false, false).unwrap();
464
465        assert_eq!(plan.waves.len(), 1);
466        assert_eq!(plan.waves[0].units.len(), 1);
467        assert!(plan.skipped.is_empty());
468    }
469
470    #[test]
471    fn dry_run_simulate_shows_all_waves() {
472        let (_dir, mana_dir) = make_mana_dir();
473        write_config(&mana_dir, Some("echo {id}"));
474
475        // Create a chain: 1.1 → 1.2 → 1.3 (parent=1)
476        let parent = crate::unit::Unit::new("1", "Parent");
477        parent.to_file(mana_dir.join("1-parent.md")).unwrap();
478
479        let mut a = crate::unit::Unit::new("1.1", "Step A");
480        a.parent = Some("1".to_string());
481        a.verify = Some("echo ok".to_string());
482        a.produces = vec!["A".to_string()];
483        a.paths = vec!["src/a.rs".to_string()];
484        a.to_file(mana_dir.join("1.1-step-a.md")).unwrap();
485
486        let mut b = crate::unit::Unit::new("1.2", "Step B");
487        b.parent = Some("1".to_string());
488        b.verify = Some("echo ok".to_string());
489        b.dependencies = vec!["1.1".to_string()];
490        b.produces = vec!["B".to_string()];
491        b.paths = vec!["src/b.rs".to_string()];
492        b.to_file(mana_dir.join("1.2-step-b.md")).unwrap();
493
494        let mut c = crate::unit::Unit::new("1.3", "Step C");
495        c.parent = Some("1".to_string());
496        c.verify = Some("echo ok".to_string());
497        c.dependencies = vec!["1.2".to_string()];
498        c.produces = vec!["C".to_string()];
499        c.paths = vec!["src/c.rs".to_string()];
500        c.to_file(mana_dir.join("1.3-step-c.md")).unwrap();
501
502        // Without simulate: only wave 1 (1.1) is ready
503        let config = Config::load_with_extends(&mana_dir).unwrap();
504        let plan = plan_dispatch(&mana_dir, &config, Some("1"), false, false).unwrap();
505        assert_eq!(plan.waves.len(), 1);
506        assert_eq!(plan.waves[0].units.len(), 1);
507        assert_eq!(plan.waves[0].units[0].id, "1.1");
508
509        // With simulate: all 3 waves shown
510        let plan = plan_dispatch(&mana_dir, &config, Some("1"), false, true).unwrap();
511        assert_eq!(plan.waves.len(), 3);
512        assert_eq!(plan.waves[0].units[0].id, "1.1");
513        assert_eq!(plan.waves[1].units[0].id, "1.2");
514        assert_eq!(plan.waves[2].units[0].id, "1.3");
515    }
516
517    #[test]
518    fn dry_run_simulate_respects_produces_requires() {
519        let (_dir, mana_dir) = make_mana_dir();
520        write_config(&mana_dir, Some("echo {id}"));
521
522        let parent = crate::unit::Unit::new("1", "Parent");
523        parent.to_file(mana_dir.join("1-parent.md")).unwrap();
524
525        let mut a = crate::unit::Unit::new("1.1", "Types");
526        a.parent = Some("1".to_string());
527        a.verify = Some("echo ok".to_string());
528        a.produces = vec!["types".to_string()];
529        a.paths = vec!["src/types.rs".to_string()];
530        a.to_file(mana_dir.join("1.1-types.md")).unwrap();
531
532        let mut b = crate::unit::Unit::new("1.2", "Impl");
533        b.parent = Some("1".to_string());
534        b.verify = Some("echo ok".to_string());
535        b.requires = vec!["types".to_string()];
536        b.produces = vec!["impl".to_string()];
537        b.paths = vec!["src/impl.rs".to_string()];
538        b.to_file(mana_dir.join("1.2-impl.md")).unwrap();
539
540        // Without simulate: only 1.1 is ready (1.2 blocked on requires)
541        let config = Config::load_with_extends(&mana_dir).unwrap();
542        let plan = plan_dispatch(&mana_dir, &config, Some("1"), false, false).unwrap();
543        assert_eq!(plan.waves.len(), 1);
544        assert_eq!(plan.waves[0].units[0].id, "1.1");
545
546        // With simulate: both shown in correct wave order
547        let plan = plan_dispatch(&mana_dir, &config, Some("1"), false, true).unwrap();
548        assert_eq!(plan.waves.len(), 2);
549        assert_eq!(plan.waves[0].units[0].id, "1.1");
550        assert_eq!(plan.waves[1].units[0].id, "1.2");
551    }
552
553    #[test]
554    fn plan_dispatch_sorts_wave_by_downstream_weight() {
555        let (_dir, mana_dir) = make_mana_dir();
556        write_config(&mana_dir, Some("echo {id}"));
557
558        let parent = crate::unit::Unit::new("1", "Parent");
559        parent.to_file(mana_dir.join("1-parent.md")).unwrap();
560
561        // A has no dependents (weight 1)
562        let mut a = crate::unit::Unit::new("1.1", "A leaf");
563        a.parent = Some("1".to_string());
564        a.verify = Some("echo ok".to_string());
565        a.paths = vec!["src/a.rs".to_string()];
566        a.to_file(mana_dir.join("1.1-a-leaf.md")).unwrap();
567
568        // B has two dependents D, E (weight 3)
569        let mut b = crate::unit::Unit::new("1.2", "B root");
570        b.parent = Some("1".to_string());
571        b.verify = Some("echo ok".to_string());
572        b.paths = vec!["src/b.rs".to_string()];
573        b.to_file(mana_dir.join("1.2-b-root.md")).unwrap();
574
575        // C has one dependent F (weight 2)
576        let mut c = crate::unit::Unit::new("1.3", "C mid");
577        c.parent = Some("1".to_string());
578        c.verify = Some("echo ok".to_string());
579        c.paths = vec!["src/c.rs".to_string()];
580        c.to_file(mana_dir.join("1.3-c-mid.md")).unwrap();
581
582        // D depends on B
583        let mut d = crate::unit::Unit::new("1.4", "D dep B");
584        d.parent = Some("1".to_string());
585        d.verify = Some("echo ok".to_string());
586        d.dependencies = vec!["1.2".to_string()];
587        d.paths = vec!["src/d.rs".to_string()];
588        d.to_file(mana_dir.join("1.4-d.md")).unwrap();
589
590        // E depends on B
591        let mut e = crate::unit::Unit::new("1.5", "E dep B");
592        e.parent = Some("1".to_string());
593        e.verify = Some("echo ok".to_string());
594        e.dependencies = vec!["1.2".to_string()];
595        e.paths = vec!["src/e.rs".to_string()];
596        e.to_file(mana_dir.join("1.5-e.md")).unwrap();
597
598        // F depends on C
599        let mut f = crate::unit::Unit::new("1.6", "F dep C");
600        f.parent = Some("1".to_string());
601        f.verify = Some("echo ok".to_string());
602        f.dependencies = vec!["1.3".to_string()];
603        f.paths = vec!["src/f.rs".to_string()];
604        f.to_file(mana_dir.join("1.6-f.md")).unwrap();
605
606        // Simulate dry-run: shows all waves
607        let config = Config::load_with_extends(&mana_dir).unwrap();
608        let plan = plan_dispatch(&mana_dir, &config, Some("1"), false, true).unwrap();
609
610        // Wave 1 should be: B(weight 3), C(weight 2), A(weight 1)
611        assert_eq!(plan.waves[0].units.len(), 3);
612        assert_eq!(plan.waves[0].units[0].id, "1.2"); // B — weight 3
613        assert_eq!(plan.waves[0].units[1].id, "1.3"); // C — weight 2
614        assert_eq!(plan.waves[0].units[2].id, "1.1"); // A — weight 1
615    }
616
617    #[test]
618    fn plan_dispatch_file_conflict_in_wave() {
619        let (_dir, mana_dir) = make_mana_dir();
620        write_config(&mana_dir, Some("echo {id}"));
621
622        // Two units in the same wave that share a file
623        let mut a = crate::unit::Unit::new("1", "Touches lib");
624        a.verify = Some("echo ok".to_string());
625        a.paths = vec!["src/lib.rs".to_string(), "src/a.rs".to_string()];
626        a.to_file(mana_dir.join("1-touches-lib.md")).unwrap();
627
628        let mut b = crate::unit::Unit::new("2", "Also lib");
629        b.verify = Some("echo ok".to_string());
630        b.paths = vec!["src/lib.rs".to_string(), "src/b.rs".to_string()];
631        b.to_file(mana_dir.join("2-also-lib.md")).unwrap();
632
633        let mut c = crate::unit::Unit::new("3", "Independent");
634        c.verify = Some("echo ok".to_string());
635        c.paths = vec!["src/c.rs".to_string()];
636        c.to_file(mana_dir.join("3-independent.md")).unwrap();
637
638        let config = Config::load_with_extends(&mana_dir).unwrap();
639        let plan = plan_dispatch(&mana_dir, &config, None, false, false).unwrap();
640
641        // All 3 in wave 1 (no deps)
642        assert_eq!(plan.waves.len(), 1);
643        assert_eq!(plan.waves[0].units.len(), 3);
644
645        // Verify file conflict detection works on the wave
646        let conflicts = super::super::wave::compute_file_conflicts(&plan.waves[0].units);
647        assert_eq!(conflicts.len(), 1);
648        assert_eq!(conflicts[0].0, "src/lib.rs");
649
650        // Effective parallelism: 2 (A+C or B+C, not A+B)
651        let eff = super::super::wave::compute_effective_parallelism(&plan.waves[0].units);
652        assert_eq!(eff, 2);
653    }
654
655    #[test]
656    fn print_plan_shows_critical_path() {
657        let (_dir, mana_dir) = make_mana_dir();
658        write_config(&mana_dir, Some("echo {id}"));
659
660        let parent = crate::unit::Unit::new("1", "Parent");
661        parent.to_file(mana_dir.join("1-parent.md")).unwrap();
662
663        // Chain: 1.1 → 1.2 (critical path has len 2)
664        let mut a = crate::unit::Unit::new("1.1", "Step A");
665        a.parent = Some("1".to_string());
666        a.verify = Some("echo ok".to_string());
667        a.paths = vec!["src/a.rs".to_string()];
668        a.to_file(mana_dir.join("1.1-step-a.md")).unwrap();
669
670        let mut b = crate::unit::Unit::new("1.2", "Step B");
671        b.parent = Some("1".to_string());
672        b.verify = Some("echo ok".to_string());
673        b.dependencies = vec!["1.1".to_string()];
674        b.paths = vec!["src/b.rs".to_string()];
675        b.to_file(mana_dir.join("1.2-step-b.md")).unwrap();
676
677        let config = Config::load_with_extends(&mana_dir).unwrap();
678        let plan = plan_dispatch(&mana_dir, &config, Some("1"), false, true).unwrap();
679
680        // The critical path computed from the plan must include both 1.1 and 1.2
681        let critical_path = compute_critical_path(&plan.all_units);
682        assert!(
683            critical_path.len() >= 2,
684            "expected critical path of length >= 2, got {:?}",
685            critical_path
686        );
687        assert!(
688            critical_path.contains(&"1.1".to_string()),
689            "expected 1.1 in critical path"
690        );
691        assert!(
692            critical_path.contains(&"1.2".to_string()),
693            "expected 1.2 in critical path"
694        );
695    }
696
697    #[test]
698    fn print_plan_shows_file_conflicts() {
699        let (_dir, mana_dir) = make_mana_dir();
700        write_config(&mana_dir, Some("echo {id}"));
701
702        // Two units sharing src/lib.rs
703        let mut a = crate::unit::Unit::new("1", "Alpha");
704        a.verify = Some("echo ok".to_string());
705        a.paths = vec!["src/lib.rs".to_string()];
706        a.to_file(mana_dir.join("1-alpha.md")).unwrap();
707
708        let mut b = crate::unit::Unit::new("2", "Beta");
709        b.verify = Some("echo ok".to_string());
710        b.paths = vec!["src/lib.rs".to_string()];
711        b.to_file(mana_dir.join("2-beta.md")).unwrap();
712
713        let config = Config::load_with_extends(&mana_dir).unwrap();
714        let plan = plan_dispatch(&mana_dir, &config, None, false, false).unwrap();
715
716        // Both in wave 1; confirm conflict is detected
717        assert_eq!(plan.waves.len(), 1);
718        let conflicts = compute_file_conflicts(&plan.waves[0].units);
719        assert_eq!(conflicts.len(), 1, "expected one conflict group");
720        assert_eq!(conflicts[0].0, "src/lib.rs");
721        assert!(conflicts[0].1.contains(&"1".to_string()));
722        assert!(conflicts[0].1.contains(&"2".to_string()));
723    }
724
725    #[test]
726    fn print_plan_shows_effective_concurrency() {
727        let (_dir, mana_dir) = make_mana_dir();
728        write_config(&mana_dir, Some("echo {id}"));
729
730        // Three units: 1 and 2 share a file, 3 is independent
731        let mut a = crate::unit::Unit::new("1", "Conflict A");
732        a.verify = Some("echo ok".to_string());
733        a.paths = vec!["src/shared.rs".to_string()];
734        a.to_file(mana_dir.join("1-conflict-a.md")).unwrap();
735
736        let mut b = crate::unit::Unit::new("2", "Conflict B");
737        b.verify = Some("echo ok".to_string());
738        b.paths = vec!["src/shared.rs".to_string()];
739        b.to_file(mana_dir.join("2-conflict-b.md")).unwrap();
740
741        let mut c = crate::unit::Unit::new("3", "Independent");
742        c.verify = Some("echo ok".to_string());
743        c.paths = vec!["src/other.rs".to_string()];
744        c.to_file(mana_dir.join("3-independent.md")).unwrap();
745
746        let config = Config::load_with_extends(&mana_dir).unwrap();
747        let plan = plan_dispatch(&mana_dir, &config, None, false, false).unwrap();
748
749        assert_eq!(plan.waves.len(), 1);
750        assert_eq!(plan.waves[0].units.len(), 3);
751
752        // Effective concurrency must be less than 3 due to the file conflict
753        let eff = compute_effective_parallelism(&plan.waves[0].units);
754        assert!(eff < 3, "expected effective concurrency < 3, got {}", eff);
755        assert!(eff >= 2, "expected effective concurrency >= 2, got {}", eff);
756    }
757
758    #[test]
759    fn print_plan_no_conflicts_shows_full_concurrency() {
760        let (_dir, mana_dir) = make_mana_dir();
761        write_config(&mana_dir, Some("echo {id}"));
762
763        // Three units with no shared files — full concurrency
764        let mut a = crate::unit::Unit::new("1", "A");
765        a.verify = Some("echo ok".to_string());
766        a.paths = vec!["src/a.rs".to_string()];
767        a.to_file(mana_dir.join("1-a.md")).unwrap();
768
769        let mut b = crate::unit::Unit::new("2", "B");
770        b.verify = Some("echo ok".to_string());
771        b.paths = vec!["src/b.rs".to_string()];
772        b.to_file(mana_dir.join("2-b.md")).unwrap();
773
774        let mut c = crate::unit::Unit::new("3", "C");
775        c.verify = Some("echo ok".to_string());
776        c.paths = vec!["src/c.rs".to_string()];
777        c.to_file(mana_dir.join("3-c.md")).unwrap();
778
779        let config = Config::load_with_extends(&mana_dir).unwrap();
780        let plan = plan_dispatch(&mana_dir, &config, None, false, false).unwrap();
781
782        assert_eq!(plan.waves.len(), 1);
783        assert_eq!(plan.waves[0].units.len(), 3);
784
785        // No conflicts — effective concurrency equals unit count
786        let eff = compute_effective_parallelism(&plan.waves[0].units);
787        assert_eq!(eff, 3, "expected full concurrency of 3, got {}", eff);
788    }
789}