Skip to main content

mana_core/ops/
plan.rs

1use std::path::Path;
2
3use anyhow::Result;
4
5use crate::blocking::{MAX_PATHS, MAX_PRODUCES};
6use crate::discovery::find_unit_file;
7use crate::index::Index;
8use crate::unit::{Status, Unit};
9use crate::util::natural_cmp;
10
11/// A candidate unit that needs planning (decomposition).
12pub struct PlanCandidate {
13    pub id: String,
14    pub title: String,
15    pub priority: u8,
16}
17
18/// Whether a unit is considered oversized and needs planning.
19pub fn is_oversized(unit: &Unit) -> bool {
20    unit.produces.len() > MAX_PRODUCES || unit.paths.len() > MAX_PATHS
21}
22
23/// Find all open, unclaimed units that are oversized.
24///
25/// Returns candidates sorted by priority (ascending P0 first), then by ID.
26pub fn find_plan_candidates(mana_dir: &Path) -> Result<Vec<PlanCandidate>> {
27    let index = Index::load_or_rebuild(mana_dir)?;
28    let mut candidates: Vec<PlanCandidate> = Vec::new();
29
30    for entry in &index.units {
31        if entry.status != Status::Open {
32            continue;
33        }
34        if entry.claimed_by.is_some() {
35            continue;
36        }
37
38        let unit_path = match find_unit_file(mana_dir, &entry.id) {
39            Ok(p) => p,
40            Err(_) => continue,
41        };
42        let unit = match Unit::from_file(&unit_path) {
43            Ok(b) => b,
44            Err(_) => continue,
45        };
46
47        if is_oversized(&unit) {
48            candidates.push(PlanCandidate {
49                id: entry.id.clone(),
50                title: entry.title.clone(),
51                priority: entry.priority,
52            });
53        }
54    }
55
56    candidates.sort_by(|a, b| {
57        a.priority
58            .cmp(&b.priority)
59            .then_with(|| natural_cmp(&a.id, &b.id))
60    });
61
62    Ok(candidates)
63}
64
65/// Build a rich decomposition prompt for a unit.
66///
67/// Embeds the unit's description, produces/requires, and decomposition rules
68/// so an agent can create well-scoped child units.
69pub fn build_decomposition_prompt(id: &str, unit: &Unit, strategy: Option<&str>) -> String {
70    let strategy_guidance = match strategy {
71        Some("feature") | Some("by-feature") => {
72            "Split by feature — each child is a vertical slice (types + impl + tests for one feature)."
73        }
74        Some("layer") | Some("by-layer") => {
75            "Split by layer — types/interfaces first, then implementation, then tests."
76        }
77        Some("file") | Some("by-file") => {
78            "Split by file — each child handles one file or closely related file group."
79        }
80        Some("phase") => {
81            "Split by phase — scaffold first, then core logic, then edge cases, then polish."
82        }
83        Some(other) => {
84            return build_prompt_text(id, unit, other);
85        }
86        None => "Choose the best strategy: by-feature (vertical slices), by-layer, or by-file.",
87    };
88
89    build_prompt_text(id, unit, strategy_guidance)
90}
91
92fn build_prompt_text(id: &str, unit: &Unit, strategy_guidance: &str) -> String {
93    let title = &unit.title;
94    let priority = unit.priority;
95    let description = unit.description.as_deref().unwrap_or("(no description)");
96
97    let mut dep_context = String::new();
98    if !unit.produces.is_empty() {
99        dep_context.push_str(&format!("\nProduces: {}\n", unit.produces.join(", ")));
100    }
101    if !unit.requires.is_empty() {
102        dep_context.push_str(&format!("Requires: {}\n", unit.requires.join(", ")));
103    }
104
105    format!(
106        r#"Decompose unit {id} into smaller child units.
107
108## Parent Unit
109- **ID:** {id}
110- **Title:** {title}
111- **Priority:** P{priority}
112{dep_context}
113## Strategy
114{strategy_guidance}
115
116## Sizing Rules
117- A unit is **atomic** if it requires ≤5 functions to write and ≤10 to read
118- Each child should have at most 3 `produces` artifacts and 5 `paths`
119- Count functions concretely by examining the code — don't estimate
120
121## Splitting Rules
122- Create **2-4 children** for medium units, **3-5** for large ones
123- **Maximize parallelism** — prefer independent units over sequential chains
124- Each child must have a **verify command** that exits 0 on success
125- Children should be independently testable where possible
126- Use `--produces` and `--requires` to express dependencies between siblings
127
128## Context Embedding Rules
129- **Embed context into descriptions** — don't reference files, include the relevant types/signatures
130- Include: concrete file paths, function signatures, type definitions
131- Include: specific steps, edge cases, error handling requirements
132- Be specific: "Add `fn validate_email(s: &str) -> bool` to `src/util.rs`" not "add validation"
133
134## How to Create Children
135Use `mana create` for each child unit:
136
137```
138mana create "child title" \
139  --parent {id} \
140  --priority {priority} \
141  --verify "test command that exits 0" \
142  --produces "artifact_name" \
143  --requires "artifact_from_sibling" \
144  --description "Full description with:
145- What to implement
146- Which files to modify (with paths)
147- Key types/signatures to use or create
148- Acceptance criteria
149- Edge cases to handle"
150```
151
152## Description Template
153A good child unit description includes:
1541. **What**: One clear sentence of what this child does
1552. **Files**: Specific file paths with what changes in each
1563. **Context**: Embedded type definitions, function signatures, patterns to follow
1574. **Acceptance**: Concrete criteria the verify command checks
1585. **Edge cases**: What could go wrong, what to handle
159
160## Your Task
1611. Read the parent unit's description below
1622. Examine referenced source files to count functions accurately
1633. Decide on a split strategy
1644. Create 2-5 child units using `mana create` commands
1655. Ensure every child has a verify command
1666. After creating children, run `mana tree {id}` to show the result
167
168## Parent Unit Description
169{description}"#,
170    )
171}
172
173/// Detect the project's language/stack by looking for marker files.
174///
175/// Returns a list of (language, config_file) pairs found in the project root.
176pub fn detect_project_stack(project_root: &Path) -> Vec<(&'static str, &'static str)> {
177    let markers: &[(&str, &str)] = &[
178        ("Rust", "Cargo.toml"),
179        ("JavaScript/TypeScript", "package.json"),
180        ("Python", "pyproject.toml"),
181        ("Python", "setup.py"),
182        ("Go", "go.mod"),
183        ("Ruby", "Gemfile"),
184        ("Java", "pom.xml"),
185        ("Java", "build.gradle"),
186        ("Elixir", "mix.exs"),
187        ("Swift", "Package.swift"),
188        ("C/C++", "CMakeLists.txt"),
189        ("Zig", "build.zig"),
190    ];
191
192    markers
193        .iter()
194        .filter(|(_, file)| project_root.join(file).exists())
195        .copied()
196        .collect()
197}
198
199/// Run static analysis commands for the detected stack.
200///
201/// Returns a string with the combined output of all checks (best-effort).
202/// Commands that aren't installed or fail are skipped gracefully.
203pub fn run_static_checks(project_root: &Path) -> String {
204    let stack = detect_project_stack(project_root);
205    let mut output = String::new();
206
207    for (lang, _) in &stack {
208        let checks: Vec<(&str, &[&str])> = match *lang {
209            "Rust" => vec![
210                ("cargo clippy", &["cargo", "clippy", "--", "-D", "warnings"]),
211                ("cargo test (check)", &["cargo", "test", "--no-run"]),
212            ],
213            "JavaScript/TypeScript" => vec![
214                ("npm run lint", &["npm", "run", "lint"]),
215                ("npx tsc --noEmit", &["npx", "tsc", "--noEmit"]),
216            ],
217            "Python" => vec![
218                ("ruff check .", &["ruff", "check", "."]),
219                ("mypy .", &["mypy", "."]),
220            ],
221            "Go" => vec![
222                ("go vet ./...", &["go", "vet", "./..."]),
223                ("golangci-lint run", &["golangci-lint", "run"]),
224            ],
225            _ => vec![],
226        };
227
228        for (name, args) in checks {
229            let result = std::process::Command::new(args[0])
230                .args(&args[1..])
231                .current_dir(project_root)
232                .output();
233
234            match result {
235                Ok(o) => {
236                    let stdout = String::from_utf8_lossy(&o.stdout);
237                    let stderr = String::from_utf8_lossy(&o.stderr);
238                    if !o.status.success() {
239                        output.push_str(&format!("### {} (exit {})\n", name, o.status));
240                        if !stdout.is_empty() {
241                            // Truncate to keep prompt reasonable
242                            let truncated: String = stdout.chars().take(2000).collect();
243                            output.push_str(&truncated);
244                            output.push('\n');
245                        }
246                        if !stderr.is_empty() {
247                            let truncated: String = stderr.chars().take(2000).collect();
248                            output.push_str(&truncated);
249                            output.push('\n');
250                        }
251                    } else {
252                        output.push_str(&format!("### {} — ✓ passed\n", name));
253                    }
254                }
255                Err(_) => {
256                    // Tool not installed, skip silently
257                }
258            }
259        }
260    }
261
262    output
263}
264
265/// Build a research prompt for project-level analysis.
266///
267/// Includes detected stack info, static check results, and instructions
268/// for the agent to create units from findings.
269pub fn build_research_prompt(project_root: &Path, parent_id: &str, mana_cmd: &str) -> String {
270    let stack = detect_project_stack(project_root);
271    let stack_info = if stack.is_empty() {
272        "Could not detect project stack.".to_string()
273    } else {
274        stack
275            .iter()
276            .map(|(lang, file)| format!("- {} ({})", lang, file))
277            .collect::<Vec<_>>()
278            .join("\n")
279    };
280
281    let static_output = run_static_checks(project_root);
282    let static_section = if static_output.is_empty() {
283        "No static analysis tools were available or all passed.".to_string()
284    } else {
285        static_output
286    };
287
288    format!(
289        r#"Analyze this project for improvements and create units for each finding.
290
291## Project Stack
292{stack_info}
293
294## Static Analysis Results
295{static_section}
296
297## Your Task
2981. Review the static analysis output above for errors, warnings, and issues
2992. Examine the codebase for:
300   - **Bugs**: Logic errors, edge cases, error handling gaps
301   - **Tests**: Missing test coverage, untested error paths
302   - **Refactors**: Code duplication, complexity, unclear naming
303   - **Security**: Input validation, auth issues, data exposure
304   - **Performance**: Unnecessary allocations, N+1 queries, blocking I/O
3053. For each finding, create a unit:
306
307```
308{mana_cmd} create "category: description" \
309  --parent {parent_id} \
310  --verify "test command" \
311  --description "What's wrong, where it is, how to fix it"
312```
313
314## Categories
315Use these prefixes for unit titles:
316- `bug:` for bugs and logic errors
317- `test:` for missing tests
318- `refactor:` for code quality improvements
319- `security:` for security issues
320- `perf:` for performance improvements
321
322## Rules
323- Focus on actionable, concrete findings (not style nits)
324- Every unit must have a verify command that proves the fix works
325- Include file paths and line numbers when possible
326- Prioritize: critical bugs > security > missing tests > refactors > perf
327- Create 3-10 units (don't overwhelm with trivial issues)
328- After creating units, run `{mana_cmd} tree {parent_id}` to show the result"#,
329    )
330}
331
332/// Escape a string for safe use as a single shell argument.
333pub fn shell_escape(s: &str) -> String {
334    let escaped = s.replace('\'', "'\\''");
335    format!("'{}'", escaped)
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use std::fs;
342    use tempfile::TempDir;
343
344    fn setup_mana_dir() -> (TempDir, std::path::PathBuf) {
345        let dir = TempDir::new().unwrap();
346        let mana_dir = dir.path().join(".mana");
347        fs::create_dir(&mana_dir).unwrap();
348        fs::write(mana_dir.join("config.yaml"), "project: test\nnext_id: 10\n").unwrap();
349        (dir, mana_dir)
350    }
351
352    #[test]
353    fn is_oversized_with_many_produces() {
354        let mut unit = Unit::new("1", "Big unit");
355        unit.produces = vec!["a".into(), "b".into(), "c".into(), "d".into()];
356        assert!(is_oversized(&unit));
357    }
358
359    #[test]
360    fn is_oversized_false_for_small() {
361        let unit = Unit::new("1", "Small unit");
362        assert!(!is_oversized(&unit));
363    }
364
365    #[test]
366    fn find_candidates_returns_oversized() {
367        let (_dir, mana_dir) = setup_mana_dir();
368
369        let mut big = Unit::new("1", "Big unit");
370        big.produces = vec!["a".into(), "b".into(), "c".into(), "d".into()];
371        big.to_file(mana_dir.join("1-big-unit.md")).unwrap();
372
373        let small = Unit::new("2", "Small unit");
374        small.to_file(mana_dir.join("2-small-unit.md")).unwrap();
375
376        let _ = Index::build(&mana_dir);
377
378        let candidates = find_plan_candidates(&mana_dir).unwrap();
379        assert_eq!(candidates.len(), 1);
380        assert_eq!(candidates[0].id, "1");
381    }
382
383    #[test]
384    fn find_candidates_empty_when_all_small() {
385        let (_dir, mana_dir) = setup_mana_dir();
386
387        let unit = Unit::new("1", "Small");
388        unit.to_file(mana_dir.join("1-small.md")).unwrap();
389
390        let _ = Index::build(&mana_dir);
391
392        let candidates = find_plan_candidates(&mana_dir).unwrap();
393        assert!(candidates.is_empty());
394    }
395
396    #[test]
397    fn build_prompt_includes_rules() {
398        let unit = Unit::new("42", "Implement auth system");
399        let prompt = build_decomposition_prompt("42", &unit, None);
400
401        assert!(prompt.contains("Decompose unit 42"));
402        assert!(prompt.contains("Implement auth system"));
403        assert!(prompt.contains("≤5 functions"));
404        assert!(prompt.contains("Maximize parallelism"));
405        assert!(prompt.contains("Embed context"));
406        assert!(prompt.contains("verify command"));
407        assert!(prompt.contains("mana create"));
408        assert!(prompt.contains("--parent 42"));
409        assert!(prompt.contains("--produces"));
410        assert!(prompt.contains("--requires"));
411    }
412
413    #[test]
414    fn build_prompt_with_strategy() {
415        let unit = Unit::new("1", "Big task");
416        let prompt = build_decomposition_prompt("1", &unit, Some("by-feature"));
417        assert!(prompt.contains("vertical slice"));
418    }
419
420    #[test]
421    fn build_prompt_includes_produces_requires() {
422        let mut unit = Unit::new("5", "Task with deps");
423        unit.produces = vec!["auth_types".to_string(), "auth_middleware".to_string()];
424        unit.requires = vec!["db_connection".to_string()];
425
426        let prompt = build_decomposition_prompt("5", &unit, None);
427        assert!(prompt.contains("auth_types"));
428        assert!(prompt.contains("db_connection"));
429    }
430
431    #[test]
432    fn shell_escape_simple() {
433        assert_eq!(shell_escape("hello world"), "'hello world'");
434    }
435
436    #[test]
437    fn shell_escape_with_quotes() {
438        assert_eq!(shell_escape("it's here"), "'it'\\''s here'");
439    }
440}