Skip to main content

mana/commands/
plan.rs

1//! `mana plan` — interactively plan a large unit into children, or run project research.
2//!
3//! With an ID, decomposes a large unit into smaller children.
4//! Without an ID, enters project-level research mode: detects the project stack,
5//! runs static analysis, and spawns an agent to find improvements and create units.
6//!
7//! When `config.plan` is set, spawns that template command for decomposition.
8//! When `config.research` is set, spawns that for research mode.
9//! Otherwise, builds a rich prompt and spawns `pi` directly.
10
11use 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
24/// Arguments for the plan command.
25pub 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
33/// Execute the `mana plan` command.
34pub 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
45/// Plan a specific unit by ID.
46fn 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
60/// Project-level research mode: analyze codebase and create units from findings.
61fn 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    // Detect stack
71    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    // Create a parent unit to group findings
83    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        // Run static checks for preview
91        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    // Create the parent unit
106    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    // Rebuild index to include new parent
122    let _ = Index::build(mana_dir);
123
124    eprintln!("Created parent unit {} — {}", parent_id, parent_title);
125    eprintln!();
126
127    // Spawn research agent
128    spawn_research(mana_dir, config, &parent_id, &mana_cmd, args)
129}
130
131/// Spawn the research agent.
132fn spawn_research(
133    mana_dir: &Path,
134    config: &Config,
135    parent_id: &str,
136    mana_cmd: &str,
137    args: &PlanArgs,
138) -> Result<()> {
139    // Priority: config.research > config.plan > built-in
140    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        // Use plan template with a research-oriented invocation.
149        // Research uses its own model routing, even when falling back to the plan template.
150        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    // Built-in: construct prompt and spawn pi
157    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
168/// Spawn the plan command for a unit.
169fn 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
201/// Spawn the plan using a user-configured template command.
202fn 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
248/// Build a decomposition prompt and spawn `pi` with it directly.
249fn 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
274/// Execute a shell command, either interactively or non-interactively.
275fn 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        // Verified by the unit's verify command
331    }
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        // Use a research template that just succeeds
474        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        // Check parent unit was created
496        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        // No research template, but plan template is set
512        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}