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#[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 pub model: Option<String>,
32}
33
34#[derive(Debug, Clone)]
36pub struct BlockedUnit {
37 pub id: String,
38 pub title: String,
39 pub reason: BlockReason,
40}
41
42pub struct DispatchPlan {
44 pub waves: Vec<Wave>,
45 pub skipped: Vec<BlockedUnit>,
46 pub warnings: Vec<(String, ScopeWarning)>,
48 pub all_units: Vec<SizedUnit>,
50 pub index: Index,
52}
53
54pub(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 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 if let Some(filter_id) = filter_id {
82 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 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 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
146pub(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 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 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 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
231pub(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 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 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 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 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 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 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 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 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 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 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 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 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 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 let config = Config::load_with_extends(&mana_dir).unwrap();
608 let plan = plan_dispatch(&mana_dir, &config, Some("1"), false, true).unwrap();
609
610 assert_eq!(plan.waves[0].units.len(), 3);
612 assert_eq!(plan.waves[0].units[0].id, "1.2"); assert_eq!(plan.waves[0].units[1].id, "1.3"); assert_eq!(plan.waves[0].units[2].id, "1.1"); }
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 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 assert_eq!(plan.waves.len(), 1);
643 assert_eq!(plan.waves[0].units.len(), 3);
644
645 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 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 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 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 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 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 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 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 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 let eff = compute_effective_parallelism(&plan.waves[0].units);
787 assert_eq!(eff, 3, "expected full concurrency of 3, got {}", eff);
788 }
789}