1use std::path::Path;
12
13use anyhow::Result;
14
15use crate::config::Config;
16use crate::discovery::find_unit_file;
17use crate::index::Index;
18use crate::spawner::substitute_template_with_model;
19use crate::unit::Unit;
20use mana_core::ops::plan::{
21 build_decomposition_prompt, build_research_prompt, is_oversized, shell_escape,
22};
23
24pub struct PlanArgs {
26 pub id: Option<String>,
27 pub strategy: Option<String>,
28 pub auto: bool,
29 pub force: bool,
30 pub dry_run: bool,
31}
32
33pub fn cmd_plan(mana_dir: &Path, args: PlanArgs) -> Result<()> {
35 let config = Config::load_with_extends(mana_dir)?;
36
37 let _index = Index::load_or_rebuild(mana_dir)?;
38
39 match args.id {
40 Some(ref id) => plan_specific(mana_dir, &config, id, &args),
41 None => plan_research(mana_dir, &config, &args),
42 }
43}
44
45fn plan_specific(mana_dir: &Path, config: &Config, id: &str, args: &PlanArgs) -> Result<()> {
47 let unit_path = find_unit_file(mana_dir, id)?;
48 let unit = Unit::from_file(&unit_path)?;
49
50 if !is_oversized(&unit) && !args.force {
51 eprintln!("Unit {} is small enough to run directly.", id);
52 eprintln!(" Use mana run {} to dispatch it.", id);
53 eprintln!(" Use mana plan {} --force to plan anyway.", id);
54 return Ok(());
55 }
56
57 spawn_plan(mana_dir, config, id, &unit, args)
58}
59
60fn plan_research(mana_dir: &Path, config: &Config, args: &PlanArgs) -> Result<()> {
62 let project_root = mana_dir.parent().unwrap_or(Path::new("."));
63 let mana_cmd = std::env::args()
64 .next()
65 .unwrap_or_else(|| "mana".to_string());
66
67 eprintln!("🔍 Project research mode");
68 eprintln!();
69
70 let stack = mana_core::ops::plan::detect_project_stack(project_root);
72 if stack.is_empty() {
73 eprintln!(" Could not detect project language/stack.");
74 } else {
75 eprintln!(" Detected stack:");
76 for (lang, file) in &stack {
77 eprintln!(" {} ({})", lang, file);
78 }
79 }
80 eprintln!();
81
82 let date = chrono::Utc::now().format("%Y-%m-%d");
84 let parent_title = format!("Project research — {}", date);
85
86 if args.dry_run {
87 eprintln!("Would create parent unit: {}", parent_title);
88 eprintln!();
89
90 eprintln!("Running static analysis...");
92 let static_output = mana_core::ops::plan::run_static_checks(project_root);
93 if static_output.is_empty() {
94 eprintln!(" No issues found (or tools not installed).");
95 } else {
96 eprintln!("{}", static_output);
97 }
98
99 let prompt = build_research_prompt(project_root, "PARENT_ID", &mana_cmd);
100 eprintln!("--- Research prompt ---");
101 eprintln!("{}", prompt);
102 return Ok(());
103 }
104
105 let mut cfg = crate::config::Config::load(mana_dir)?;
107 let parent_id = cfg.increment_id().to_string();
108 cfg.save(mana_dir)?;
109
110 let mut parent_unit = Unit::new(&parent_id, &parent_title);
111 parent_unit.labels = vec!["research".to_string()];
112 parent_unit.verify = Some(format!("{} tree {}", mana_cmd, parent_id));
113 parent_unit.description = Some(format!(
114 "Parent unit grouping project research findings from {}.",
115 date
116 ));
117 let slug = crate::util::title_to_slug(&parent_title);
118 let filename = format!("{}-{}.md", parent_id, slug);
119 parent_unit.to_file(mana_dir.join(&filename))?;
120
121 let _ = Index::build(mana_dir);
123
124 eprintln!("Created parent unit {} — {}", parent_id, parent_title);
125 eprintln!();
126
127 spawn_research(mana_dir, config, &parent_id, &mana_cmd, args)
129}
130
131fn spawn_research(
133 mana_dir: &Path,
134 config: &Config,
135 parent_id: &str,
136 mana_cmd: &str,
137 args: &PlanArgs,
138) -> Result<()> {
139 if let Some(ref template) = config.research {
141 let cmd =
142 build_research_template_command(template, parent_id, config.research_model.as_deref());
143 eprintln!("Spawning research: {}", cmd);
144 return run_shell_command(&cmd, parent_id, args.auto);
145 }
146
147 if let Some(ref template) = config.plan {
148 let cmd =
151 substitute_template_with_model(template, parent_id, config.research_model.as_deref());
152 eprintln!("Spawning research (via plan template): {}", cmd);
153 return run_shell_command(&cmd, parent_id, args.auto);
154 }
155
156 let project_root = mana_dir.parent().unwrap_or(Path::new("."));
158
159 eprintln!("Running static analysis...");
160 let prompt = build_research_prompt(project_root, parent_id, mana_cmd);
161
162 let cmd = build_builtin_research_command(&prompt, config.research_model.as_deref());
163
164 eprintln!("Spawning built-in research agent...");
165 run_shell_command(&cmd, parent_id, args.auto)
166}
167
168fn spawn_plan(
170 mana_dir: &Path,
171 config: &Config,
172 id: &str,
173 unit: &Unit,
174 args: &PlanArgs,
175) -> Result<()> {
176 let effective_model = unit.model.as_deref().or(config.plan_model.as_deref());
177
178 if let Some(ref template) = config.plan {
179 return spawn_template(template, id, args, effective_model);
180 }
181
182 spawn_builtin(mana_dir, id, unit, args, effective_model)
183}
184
185#[must_use]
186fn build_plan_template_command(
187 template: &str,
188 id: &str,
189 strategy: Option<&str>,
190 model: Option<&str>,
191) -> String {
192 let mut cmd = substitute_template_with_model(template, id, model);
193
194 if let Some(strategy) = strategy {
195 cmd = format!("{} --strategy {}", cmd, strategy);
196 }
197
198 cmd
199}
200
201fn spawn_template(template: &str, id: &str, args: &PlanArgs, model: Option<&str>) -> Result<()> {
203 let cmd = build_plan_template_command(template, id, args.strategy.as_deref(), model);
204
205 if args.dry_run {
206 eprintln!("Would spawn: {}", cmd);
207 return Ok(());
208 }
209
210 eprintln!("Spawning: {}", cmd);
211 run_shell_command(&cmd, id, args.auto)
212}
213
214#[must_use]
215fn build_research_template_command(template: &str, parent_id: &str, model: Option<&str>) -> String {
216 let cmd = template
217 .replace("{parent_id}", parent_id)
218 .replace("{id}", parent_id);
219 match model {
220 Some(model) => cmd.replace("{model}", model),
221 None => cmd,
222 }
223}
224
225#[must_use]
226fn build_builtin_plan_command(unit_path: &str, prompt: &str, model: Option<&str>) -> String {
227 let escaped_prompt = shell_escape(prompt);
228 match model {
229 Some(model) => format!(
230 "pi --model {} @{} {}",
231 shell_escape(model),
232 unit_path,
233 escaped_prompt
234 ),
235 None => format!("pi @{} {}", unit_path, escaped_prompt),
236 }
237}
238
239#[must_use]
240fn build_builtin_research_command(prompt: &str, model: Option<&str>) -> String {
241 let escaped_prompt = shell_escape(prompt);
242 match model {
243 Some(model) => format!("pi --model {} {}", shell_escape(model), escaped_prompt),
244 None => format!("pi {}", escaped_prompt),
245 }
246}
247
248fn spawn_builtin(
250 mana_dir: &Path,
251 id: &str,
252 unit: &Unit,
253 args: &PlanArgs,
254 model: Option<&str>,
255) -> Result<()> {
256 let prompt = build_decomposition_prompt(id, unit, args.strategy.as_deref());
257
258 let unit_path = find_unit_file(mana_dir, id)?;
259 let unit_path_str = unit_path.display().to_string();
260
261 let cmd = build_builtin_plan_command(&unit_path_str, &prompt, model);
262
263 if args.dry_run {
264 eprintln!("Would spawn: {}", cmd);
265 eprintln!("\n--- Built-in decomposition prompt ---");
266 eprintln!("{}", prompt);
267 return Ok(());
268 }
269
270 eprintln!("Spawning built-in decomposition for unit {}...", id);
271 run_shell_command(&cmd, id, args.auto)
272}
273
274fn run_shell_command(cmd: &str, id: &str, auto: bool) -> Result<()> {
276 if auto {
277 let status = std::process::Command::new("sh").args(["-c", cmd]).status();
278 match status {
279 Ok(s) if s.success() => {
280 eprintln!("Planning complete. Use mana tree {} to see children.", id);
281 }
282 Ok(s) => {
283 anyhow::bail!("Plan command exited with code {}", s.code().unwrap_or(-1));
284 }
285 Err(e) => {
286 anyhow::bail!("Failed to run plan command: {}", e);
287 }
288 }
289 } else {
290 let status = std::process::Command::new("sh")
291 .args(["-c", cmd])
292 .stdin(std::process::Stdio::inherit())
293 .stdout(std::process::Stdio::inherit())
294 .stderr(std::process::Stdio::inherit())
295 .status();
296 match status {
297 Ok(s) if s.success() => {
298 eprintln!("Planning complete. Use mana tree {} to see children.", id);
299 }
300 Ok(s) => {
301 let code = s.code().unwrap_or(-1);
302 if code != 0 {
303 anyhow::bail!("Plan command exited with code {}", code);
304 }
305 }
306 Err(e) => {
307 anyhow::bail!("Failed to run plan command: {}", e);
308 }
309 }
310 }
311 Ok(())
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317 use std::fs;
318 use tempfile::TempDir;
319
320 fn setup_mana_dir() -> (TempDir, std::path::PathBuf) {
321 let dir = TempDir::new().unwrap();
322 let mana_dir = dir.path().join(".mana");
323 fs::create_dir(&mana_dir).unwrap();
324 fs::write(mana_dir.join("config.yaml"), "project: test\nnext_id: 10\n").unwrap();
325 (dir, mana_dir)
326 }
327
328 #[test]
329 fn plan_help_contains_plan() {
330 }
332
333 #[test]
334 fn plan_no_template_without_auto_errors() {
335 let (dir, mana_dir) = setup_mana_dir();
336
337 let mut unit = Unit::new("1", "Big unit");
338 unit.produces = vec!["a".into(), "b".into(), "c".into(), "d".into()];
339 unit.to_file(mana_dir.join("1-big-unit.md")).unwrap();
340
341 let _ = Index::build(&mana_dir);
342
343 let result = cmd_plan(
344 &mana_dir,
345 PlanArgs {
346 id: Some("1".to_string()),
347 strategy: None,
348 auto: false,
349 force: true,
350 dry_run: true,
351 },
352 );
353
354 assert!(result.is_ok());
355
356 drop(dir);
357 }
358
359 #[test]
360 fn plan_small_unit_suggests_run() {
361 let (dir, mana_dir) = setup_mana_dir();
362
363 let unit = Unit::new("1", "Small unit");
364 unit.to_file(mana_dir.join("1-small-unit.md")).unwrap();
365
366 let _ = Index::build(&mana_dir);
367
368 let result = cmd_plan(
369 &mana_dir,
370 PlanArgs {
371 id: Some("1".to_string()),
372 strategy: None,
373 auto: false,
374 force: false,
375 dry_run: false,
376 },
377 );
378
379 assert!(result.is_ok());
380
381 drop(dir);
382 }
383
384 #[test]
385 fn plan_force_overrides_size_check() {
386 let (dir, mana_dir) = setup_mana_dir();
387
388 fs::write(
389 mana_dir.join("config.yaml"),
390 "project: test\nnext_id: 10\nplan: \"true\"\n",
391 )
392 .unwrap();
393
394 let unit = Unit::new("1", "Small unit");
395 unit.to_file(mana_dir.join("1-small-unit.md")).unwrap();
396
397 let _ = Index::build(&mana_dir);
398
399 let result = cmd_plan(
400 &mana_dir,
401 PlanArgs {
402 id: Some("1".to_string()),
403 strategy: None,
404 auto: false,
405 force: true,
406 dry_run: false,
407 },
408 );
409
410 assert!(result.is_ok());
411
412 drop(dir);
413 }
414
415 #[test]
416 fn plan_dry_run_does_not_spawn() {
417 let (dir, mana_dir) = setup_mana_dir();
418
419 fs::write(
420 mana_dir.join("config.yaml"),
421 "project: test\nnext_id: 10\nplan: \"echo planning {id}\"\n",
422 )
423 .unwrap();
424
425 let mut unit = Unit::new("1", "Big unit");
426 unit.produces = vec!["a".into(), "b".into(), "c".into(), "d".into()];
427 unit.to_file(mana_dir.join("1-big-unit.md")).unwrap();
428
429 let _ = Index::build(&mana_dir);
430
431 let result = cmd_plan(
432 &mana_dir,
433 PlanArgs {
434 id: Some("1".to_string()),
435 strategy: None,
436 auto: false,
437 force: false,
438 dry_run: true,
439 },
440 );
441
442 assert!(result.is_ok());
443
444 drop(dir);
445 }
446
447 #[test]
448 fn plan_research_dry_run_shows_prompt() {
449 let (dir, mana_dir) = setup_mana_dir();
450
451 let _ = Index::build(&mana_dir);
452
453 let result = cmd_plan(
454 &mana_dir,
455 PlanArgs {
456 id: None,
457 strategy: None,
458 auto: false,
459 force: false,
460 dry_run: true,
461 },
462 );
463
464 assert!(result.is_ok());
465
466 drop(dir);
467 }
468
469 #[test]
470 fn plan_research_creates_parent_unit() {
471 let (dir, mana_dir) = setup_mana_dir();
472
473 fs::write(
475 mana_dir.join("config.yaml"),
476 "project: test\nnext_id: 10\nresearch: \"true\"\n",
477 )
478 .unwrap();
479
480 let _ = Index::build(&mana_dir);
481
482 let result = cmd_plan(
483 &mana_dir,
484 PlanArgs {
485 id: None,
486 strategy: None,
487 auto: true,
488 force: false,
489 dry_run: false,
490 },
491 );
492
493 assert!(result.is_ok());
494
495 let index = Index::build(&mana_dir).unwrap();
497 let research_units: Vec<_> = index
498 .units
499 .iter()
500 .filter(|u| u.title.contains("Project research"))
501 .collect();
502 assert_eq!(research_units.len(), 1);
503
504 drop(dir);
505 }
506
507 #[test]
508 fn plan_research_falls_back_to_plan_template() {
509 let (dir, mana_dir) = setup_mana_dir();
510
511 fs::write(
513 mana_dir.join("config.yaml"),
514 "project: test\nnext_id: 10\nplan: \"true\"\n",
515 )
516 .unwrap();
517
518 let _ = Index::build(&mana_dir);
519
520 let result = cmd_plan(
521 &mana_dir,
522 PlanArgs {
523 id: None,
524 strategy: None,
525 auto: true,
526 force: false,
527 dry_run: false,
528 },
529 );
530
531 assert!(result.is_ok());
532
533 drop(dir);
534 }
535
536 #[test]
537 fn research_template_command_replaces_parent_id_and_model() {
538 let cmd = build_research_template_command(
539 "claude --model {model} -p 'research {parent_id} {id}'",
540 "42",
541 Some("sonnet"),
542 );
543
544 assert_eq!(cmd, "claude --model sonnet -p 'research 42 42'");
545 }
546
547 #[test]
548 fn research_template_without_model_keeps_placeholder() {
549 let cmd = build_research_template_command(
550 "claude --model {model} -p 'research {parent_id}'",
551 "42",
552 None,
553 );
554
555 assert_eq!(cmd, "claude --model {model} -p 'research 42'");
556 }
557
558 #[test]
559 fn plan_template_substitutes_model_and_strategy() {
560 let cmd = build_plan_template_command(
561 "claude --model {model} -p 'plan {id}'",
562 "7",
563 Some("by-layer"),
564 Some("haiku"),
565 );
566
567 assert_eq!(cmd, "claude --model haiku -p 'plan 7' --strategy by-layer");
568 }
569
570 #[test]
571 fn plan_template_prefers_unit_model_override() {
572 let config_model = Some("haiku");
573 let unit_model = Some("opus");
574 let cmd = build_plan_template_command(
575 "claude --model {model} -p 'plan {id}'",
576 "7",
577 None,
578 unit_model.or(config_model),
579 );
580
581 assert_eq!(cmd, "claude --model opus -p 'plan 7'");
582 }
583
584 #[test]
585 fn builtin_plan_command_includes_model_when_set() {
586 let cmd = build_builtin_plan_command(
587 ".mana/7-plan.md",
588 "plan this unit carefully",
589 Some("sonnet"),
590 );
591
592 assert_eq!(
593 cmd,
594 "pi --model 'sonnet' @.mana/7-plan.md 'plan this unit carefully'"
595 );
596 }
597
598 #[test]
599 fn builtin_research_command_includes_model_when_set() {
600 let cmd = build_builtin_research_command("research the project", Some("opus"));
601
602 assert_eq!(cmd, "pi --model 'opus' 'research the project'");
603 }
604
605 #[test]
606 fn build_prompt_includes_decomposition_rules() {
607 let unit = Unit::new("42", "Implement auth system");
608 let prompt = build_decomposition_prompt("42", &unit, None);
609
610 assert!(prompt.contains("Decompose unit 42"), "missing header");
611 assert!(prompt.contains("Implement auth system"), "missing title");
612 assert!(prompt.contains("≤5 functions"), "missing sizing rules");
613 assert!(
614 prompt.contains("Maximize parallelism"),
615 "missing parallelism rule"
616 );
617 assert!(
618 prompt.contains("Embed context"),
619 "missing context embedding rule"
620 );
621 assert!(
622 prompt.contains("verify command"),
623 "missing verify requirement"
624 );
625 assert!(prompt.contains("mana create"), "missing create syntax");
626 assert!(prompt.contains("--parent 42"), "missing parent flag");
627 assert!(prompt.contains("--produces"), "missing produces flag");
628 assert!(prompt.contains("--requires"), "missing requires flag");
629 }
630
631 #[test]
632 fn build_prompt_with_strategy() {
633 let unit = Unit::new("1", "Big task");
634 let prompt = build_decomposition_prompt("1", &unit, Some("by-feature"));
635
636 assert!(
637 prompt.contains("vertical slice"),
638 "missing feature strategy guidance"
639 );
640 }
641
642 #[test]
643 fn build_prompt_includes_produces_requires() {
644 let mut unit = Unit::new("5", "Task with deps");
645 unit.produces = vec!["auth_types".to_string(), "auth_middleware".to_string()];
646 unit.requires = vec!["db_connection".to_string()];
647
648 let prompt = build_decomposition_prompt("5", &unit, None);
649
650 assert!(prompt.contains("auth_types"), "missing produces");
651 assert!(prompt.contains("db_connection"), "missing requires");
652 }
653
654 #[test]
655 fn shell_escape_simple() {
656 assert_eq!(shell_escape("hello world"), "'hello world'");
657 }
658
659 #[test]
660 fn shell_escape_with_quotes() {
661 assert_eq!(shell_escape("it's here"), "'it'\\''s here'");
662 }
663
664 #[test]
665 fn plan_builtin_dry_run_shows_prompt() {
666 let (dir, mana_dir) = setup_mana_dir();
667
668 let mut unit = Unit::new("1", "Big unit");
669 unit.description = Some("x".repeat(2000));
670 unit.to_file(mana_dir.join("1-big-unit.md")).unwrap();
671
672 let _ = Index::build(&mana_dir);
673
674 let result = cmd_plan(
675 &mana_dir,
676 PlanArgs {
677 id: Some("1".to_string()),
678 strategy: None,
679 auto: false,
680 force: true,
681 dry_run: true,
682 },
683 );
684
685 assert!(result.is_ok());
686
687 drop(dir);
688 }
689}