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
11pub struct PlanCandidate {
13 pub id: String,
14 pub title: String,
15 pub priority: u8,
16}
17
18pub fn is_oversized(unit: &Unit) -> bool {
20 unit.produces.len() > MAX_PRODUCES || unit.paths.len() > MAX_PATHS
21}
22
23pub 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
65pub 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
173pub 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
199pub 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 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 }
258 }
259 }
260 }
261
262 output
263}
264
265pub 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
332pub 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}