1use std::path::{Path, PathBuf};
23
24use anyhow::Result;
25use regex::Regex;
26use std::sync::LazyLock;
27
28use crate::config::Config;
29use crate::ctx_assembler::{extract_paths, read_file};
30use crate::discovery::find_unit_file;
31use crate::index::Index;
32use crate::unit::{AttemptOutcome, Status, Unit};
33
34pub struct PromptResult {
40 pub system_prompt: String,
42 pub user_message: String,
44 pub file_ref: String,
46}
47
48pub struct PromptOptions {
50 pub mana_dir: PathBuf,
52 pub instructions: Option<String>,
54 pub concurrent_overlaps: Option<Vec<FileOverlap>>,
56}
57
58pub struct FileOverlap {
60 pub unit_id: String,
62 pub title: String,
64 pub shared_files: Vec<String>,
66}
67
68const PARENT_CHAR_CAP: usize = 2000;
74
75const TOTAL_ANCESTOR_CHAR_CAP: usize = 3000;
77
78const DISCOVERY_CHAR_CAP: usize = 1500;
80
81const FILE_CONTENT_CHAR_CAP: usize = 8000;
83
84static DISCOVERY_PATTERN: LazyLock<Regex> =
86 LazyLock::new(|| Regex::new(r"(?i)discover").expect("Invalid discovery regex"));
87
88static PRIORITY_KEYWORDS: LazyLock<Regex> = LazyLock::new(|| {
90 Regex::new(r"(?i)\b(modify|create|add|edit|update|change|implement|write)\b")
91 .expect("Invalid priority keywords regex")
92});
93
94pub fn build_agent_prompt(unit: &Unit, options: &PromptOptions) -> Result<PromptResult> {
104 let mana_dir = &options.mana_dir;
105 let mut sections: Vec<String> = Vec::new();
106
107 if let Some(rules) = load_rules(mana_dir) {
109 sections.push(format!("# Project Rules\n\n{}", rules));
110 }
111
112 let parent_sections = collect_parent_context(unit, mana_dir);
114 for section in parent_sections {
115 sections.push(section);
116 }
117
118 if let Some(discoveries) = collect_sibling_discoveries(unit, mana_dir) {
120 sections.push(discoveries);
121 }
122
123 sections.push(format!(
125 "# Unit Assignment\n\nYou are implementing unit {}: {}",
126 unit.id, unit.title
127 ));
128
129 if let Some(ref overlaps) = options.concurrent_overlaps {
131 if !overlaps.is_empty() {
132 sections.push(format_concurrent_warning(overlaps));
133 }
134 }
135
136 let project_dir = mana_dir.parent().unwrap_or(Path::new("."));
138 let description = unit.description.as_deref().unwrap_or("");
139 if let Some(file_context) = assemble_file_context(description, project_dir) {
140 sections.push(file_context);
141 }
142
143 if let Some(ref acceptance) = unit.acceptance {
145 sections.push(format!(
146 "# Acceptance Criteria (must ALL be true)\n\n{}",
147 acceptance
148 ));
149 }
150
151 if let Some(ref verify) = unit.verify {
153 sections.push(format!(
154 "# Pre-flight Check\n\n\
155 Before implementing, run the verify command to confirm it currently FAILS:\n\
156 ```\n{}\n```\n\
157 If it errors for infrastructure reasons (missing deps, wrong path), fix that first.",
158 verify
159 ));
160 }
161
162 if unit.attempts > 0 {
164 sections.push(format_previous_attempts(unit));
165 }
166
167 sections.push(format_approach(&unit.id));
169
170 sections.push(format_verify_gate(unit));
172
173 sections.push(format_constraints(&unit.id));
175
176 sections.push(format_tool_strategy());
178
179 let system_prompt = sections.join("\n\n---\n\n");
181
182 let mut user_message = String::new();
184 if let Some(ref instructions) = options.instructions {
185 user_message.push_str(instructions);
186 user_message.push_str("\n\n");
187 }
188 user_message.push_str(&format!(
189 "implement this unit and run mana close {} when done",
190 unit.id
191 ));
192
193 let file_ref = find_unit_file(mana_dir, &unit.id)
195 .map(|p| format!("@{}", p.display()))
196 .unwrap_or_default();
197
198 Ok(PromptResult {
199 system_prompt,
200 user_message,
201 file_ref,
202 })
203}
204
205fn load_rules(mana_dir: &Path) -> Option<String> {
211 let config = Config::load(mana_dir).ok()?;
212 let rules_path = config.rules_path(mana_dir);
213 let content = std::fs::read_to_string(&rules_path).ok()?;
214 let trimmed = content.trim();
215 if trimmed.is_empty() {
216 return None;
217 }
218 Some(content)
219}
220
221fn collect_parent_context(unit: &Unit, mana_dir: &Path) -> Vec<String> {
227 let Some(ref first_parent) = unit.parent else {
228 return Vec::new();
229 };
230
231 let mut sections = Vec::new();
232 let mut total_chars: usize = 0;
233 let mut current_id = Some(first_parent.clone());
234
235 while let Some(id) = current_id {
236 if total_chars >= TOTAL_ANCESTOR_CHAR_CAP {
237 break;
238 }
239
240 let parent = match load_unit(mana_dir, &id) {
241 Some(b) => b,
242 None => break,
243 };
244
245 let body = match parent.description {
246 Some(ref d) if !d.trim().is_empty() => d.clone(),
247 _ => break,
248 };
249
250 let remaining = TOTAL_ANCESTOR_CHAR_CAP - total_chars;
251 let char_limit = PARENT_CHAR_CAP.min(remaining);
252 let trimmed = truncate_text(&body, char_limit);
253
254 sections.push(format!(
255 "# Parent Context (unit {}: {})\n\n{}",
256 parent.id, parent.title, trimmed
257 ));
258
259 total_chars += trimmed.len();
260 current_id = parent.parent.clone();
261 }
262
263 sections.reverse();
265 sections
266}
267
268fn collect_sibling_discoveries(unit: &Unit, mana_dir: &Path) -> Option<String> {
274 let parent_id = unit.parent.as_ref()?;
275
276 let index = Index::load_or_rebuild(mana_dir).ok()?;
277
278 let closed_siblings: Vec<_> = index
280 .units
281 .iter()
282 .filter(|e| {
283 e.id != unit.id && e.parent.as_deref() == Some(parent_id) && e.status == Status::Closed
284 })
285 .collect();
286
287 if closed_siblings.is_empty() {
288 return None;
289 }
290
291 let mut parts = Vec::new();
292 let mut total_chars: usize = 0;
293
294 for sibling in &closed_siblings {
295 if total_chars >= DISCOVERY_CHAR_CAP {
296 break;
297 }
298
299 let sibling_unit = match load_unit(mana_dir, &sibling.id) {
300 Some(b) => b,
301 None => continue,
302 };
303
304 let notes = match sibling_unit.notes {
305 Some(ref n) if !n.trim().is_empty() => n.clone(),
306 _ => continue,
307 };
308
309 if !DISCOVERY_PATTERN.is_match(¬es) {
310 continue;
311 }
312
313 let remaining = DISCOVERY_CHAR_CAP - total_chars;
314 let trimmed = truncate_text(¬es, remaining);
315
316 parts.push(format!(
317 "## From unit {} ({}):\n{}",
318 sibling.id, sibling.title, trimmed
319 ));
320 total_chars += trimmed.len();
321 }
322
323 if parts.is_empty() {
324 return None;
325 }
326
327 Some(format!(
328 "# Discoveries from completed siblings\n\n{}",
329 parts.join("\n\n")
330 ))
331}
332
333fn format_concurrent_warning(overlaps: &[FileOverlap]) -> String {
335 let mut lines = Vec::new();
336 for overlap in overlaps {
337 let files = overlap.shared_files.join(", ");
338 lines.push(format!(
339 "- Unit {} ({}) may also be modifying: {}",
340 overlap.unit_id, overlap.title, files
341 ));
342 }
343
344 format!(
345 "# Concurrent Modification Warning\n\n\
346 The following units are running in parallel and share files with your unit:\n\n\
347 {}\n\n\
348 Be careful with overwrites. Prefer surgical Edit operations over full Write.\n\
349 If you must rewrite a file, read it immediately before writing to avoid clobbering concurrent changes.",
350 lines.join("\n")
351 )
352}
353
354fn assemble_file_context(description: &str, project_dir: &Path) -> Option<String> {
361 let paths = extract_prioritized_paths(description);
362 if paths.is_empty() {
363 return None;
364 }
365
366 let canonical_base = project_dir.canonicalize().ok()?;
367 let mut file_sections = Vec::new();
368 let mut total_chars: usize = 0;
369
370 for file_path in &paths {
371 if total_chars >= FILE_CONTENT_CHAR_CAP {
372 break;
373 }
374
375 let full_path = project_dir.join(file_path);
376 let canonical = match full_path.canonicalize() {
377 Ok(c) => c,
378 Err(_) => continue, };
380
381 if !canonical.starts_with(&canonical_base) {
383 continue;
384 }
385
386 if canonical.is_dir() {
388 continue;
389 }
390
391 let content = match read_file(&canonical) {
392 Ok(c) => c,
393 Err(_) => continue,
394 };
395
396 let remaining = FILE_CONTENT_CHAR_CAP - total_chars;
397 let content = if content.len() > remaining {
398 let mut truncated = content[..remaining].to_string();
399 truncated.push_str("\n\n[…truncated]");
400 truncated
401 } else {
402 content
403 };
404
405 let lang = detect_language(file_path);
406 file_sections.push(format!("## {}\n```{}\n{}\n```", file_path, lang, content));
407 total_chars += content.len();
408 }
409
410 if file_sections.is_empty() {
411 return None;
412 }
413
414 Some(format!(
415 "# Referenced Files\n\n{}",
416 file_sections.join("\n\n")
417 ))
418}
419
420fn format_previous_attempts(unit: &Unit) -> String {
422 let mut section = format!("# Previous Attempts ({} so far)", unit.attempts);
423
424 if let Some(ref notes) = unit.notes {
426 let trimmed = notes.trim();
427 if !trimmed.is_empty() {
428 section.push_str(&format!("\n\n{}", trimmed));
429 }
430 }
431
432 for attempt in &unit.attempt_log {
434 if let Some(ref notes) = attempt.notes {
435 let trimmed = notes.trim();
436 if !trimmed.is_empty() {
437 let outcome = match attempt.outcome {
438 AttemptOutcome::Success => "success",
439 AttemptOutcome::Failed => "failed",
440 AttemptOutcome::Abandoned => "abandoned",
441 };
442 let agent_str = attempt
443 .agent
444 .as_deref()
445 .map(|a| format!(" ({})", a))
446 .unwrap_or_default();
447 section.push_str(&format!(
448 "\n\nAttempt #{}{} [{}]: {}",
449 attempt.num, agent_str, outcome, trimmed
450 ));
451 }
452 }
453 }
454
455 section.push_str(
456 "\n\nIMPORTANT: Do NOT repeat the same approach. \
457 The notes above explain what was tried.\n\
458 Read them carefully before starting.",
459 );
460
461 section
462}
463
464fn format_approach(unit_id: &str) -> String {
466 format!(
467 "# Approach\n\n\
468 1. Read the unit description carefully — it IS your spec\n\
469 2. Understand the acceptance criteria before writing code\n\
470 3. Read referenced files to understand existing patterns\n\
471 4. Implement changes file by file\n\
472 5. Run the verify command to check your work\n\
473 6. If verify passes, run: mana close {id}\n\
474 7. After closing, share what you learned:\n \
475 mana update {id} --note \"Discoveries: <brief notes about patterns, conventions, \
476 or gotchas you found that might help sibling units>\"\n\
477 8. If verify fails, fix and retry\n\
478 9. If stuck after 3 attempts, run: mana update {id} --note \"Stuck: <explanation>\"",
479 id = unit_id
480 )
481}
482
483fn format_verify_gate(unit: &Unit) -> String {
485 let batch_mode = std::env::var("MANA_BATCH_VERIFY").is_ok();
486
487 if let Some(ref verify) = unit.verify {
488 if batch_mode {
489 format!(
490 "# Verify Gate\n\n\
491 Your verify command is:\n\
492 ```\n{verify}\n```\n\
493 Batch verify mode: the orchestrator runs this command after you exit — \
494 you do not need to run it yourself.\n\
495 Use scoped checks (e.g. `cargo check -p <crate>`) for fast feedback during work.\n\
496 Signal completion with: mana close {id}",
497 verify = verify,
498 id = unit.id
499 )
500 } else {
501 format!(
502 "# Verify Gate\n\n\
503 Your verify command is:\n\
504 ```\n{}\n```\n\
505 This MUST exit 0 for the unit to close. Test it before declaring done.",
506 verify
507 )
508 }
509 } else {
510 format!(
511 "# Verify Gate\n\n\
512 No verify command is set for this unit.\n\
513 When all acceptance criteria are met, run: mana close {}",
514 unit.id
515 )
516 }
517}
518
519fn format_constraints(unit_id: &str) -> String {
521 format!(
522 "# Constraints\n\n\
523 - Only modify files mentioned in the description unless clearly necessary\n\
524 - Don't add dependencies without justification\n\
525 - Preserve existing tests\n\
526 - Run the project's test/build commands before closing\n\
527 - When complete, run: mana close {}",
528 unit_id
529 )
530}
531
532fn format_tool_strategy() -> String {
534 "# Tool Strategy\n\n\
535 - Use probe_search for semantic code search, rg for exact text matching\n\
536 - Read files before editing — never edit blind\n\
537 - Use Edit for surgical changes, Write for new files\n\
538 - Use Bash to run tests and verify commands"
539 .to_string()
540}
541
542fn truncate_text(text: &str, limit: usize) -> String {
548 if text.len() <= limit {
549 return text.to_string();
550 }
551 let mut result = text[..limit].to_string();
552 result.push_str("\n\n[…truncated]");
553 result
554}
555
556fn extract_prioritized_paths(description: &str) -> Vec<String> {
561 let mut seen = std::collections::HashSet::new();
562 let mut prioritized = Vec::new();
563 let mut normal = Vec::new();
564
565 for line in description.lines() {
566 let line_paths = extract_paths(line);
567 let is_priority = PRIORITY_KEYWORDS.is_match(line);
568
569 for p in line_paths {
570 if seen.insert(p.clone()) {
571 if is_priority {
572 prioritized.push(p);
573 } else {
574 normal.push(p);
575 }
576 }
577 }
578 }
579
580 prioritized.extend(normal);
581 prioritized
582}
583
584fn detect_language(path: &str) -> &'static str {
586 match Path::new(path).extension().and_then(|e| e.to_str()) {
587 Some("rs") => "rust",
588 Some("ts") => "typescript",
589 Some("tsx") => "typescript",
590 Some("js") => "javascript",
591 Some("jsx") => "javascript",
592 Some("py") => "python",
593 Some("md") => "markdown",
594 Some("json") => "json",
595 Some("toml") => "toml",
596 Some("yaml") | Some("yml") => "yaml",
597 Some("sh") => "bash",
598 Some("go") => "go",
599 Some("java") => "java",
600 Some("css") => "css",
601 Some("html") => "html",
602 Some("sql") => "sql",
603 Some("c") => "c",
604 Some("cpp") => "cpp",
605 Some("h") => "c",
606 Some("hpp") => "cpp",
607 Some("rb") => "ruby",
608 Some("php") => "php",
609 Some("swift") => "swift",
610 Some("kt") => "kotlin",
611 _ => "",
612 }
613}
614
615fn load_unit(mana_dir: &Path, id: &str) -> Option<Unit> {
617 let path = find_unit_file(mana_dir, id).ok()?;
618 Unit::from_file(&path).ok()
619}
620
621#[cfg(test)]
626mod tests {
627 use super::*;
628 use crate::unit::{AttemptOutcome, AttemptRecord, Unit};
629 use std::fs;
630 use tempfile::TempDir;
631
632 fn setup_test_env() -> (TempDir, PathBuf) {
634 let dir = TempDir::new().unwrap();
635 let mana_dir = dir.path().join(".mana");
636 fs::create_dir(&mana_dir).unwrap();
637 fs::write(
638 mana_dir.join("config.yaml"),
639 "project: test\nnext_id: 100\n",
640 )
641 .unwrap();
642 (dir, mana_dir)
643 }
644
645 fn write_test_unit(mana_dir: &Path, unit: &Unit) {
647 let slug = crate::util::title_to_slug(&unit.title);
648 let path = mana_dir.join(format!("{}-{}.md", unit.id, slug));
649 unit.to_file(&path).unwrap();
650 }
651
652 #[test]
655 fn truncate_text_short() {
656 assert_eq!(truncate_text("hello", 100), "hello");
657 }
658
659 #[test]
660 fn truncate_text_at_limit() {
661 assert_eq!(truncate_text("hello", 5), "hello");
662 }
663
664 #[test]
665 fn truncate_text_over_limit() {
666 let result = truncate_text("hello world", 5);
667 assert!(result.starts_with("hello"));
668 assert!(result.contains("[…truncated]"));
669 }
670
671 #[test]
674 fn detect_language_known_extensions() {
675 assert_eq!(detect_language("src/main.rs"), "rust");
676 assert_eq!(detect_language("index.ts"), "typescript");
677 assert_eq!(detect_language("app.tsx"), "typescript");
678 assert_eq!(detect_language("script.py"), "python");
679 assert_eq!(detect_language("config.json"), "json");
680 assert_eq!(detect_language("Cargo.toml"), "toml");
681 assert_eq!(detect_language("config.yaml"), "yaml");
682 assert_eq!(detect_language("config.yml"), "yaml");
683 assert_eq!(detect_language("deploy.sh"), "bash");
684 assert_eq!(detect_language("main.go"), "go");
685 assert_eq!(detect_language("Main.java"), "java");
686 assert_eq!(detect_language("style.css"), "css");
687 assert_eq!(detect_language("page.html"), "html");
688 assert_eq!(detect_language("query.sql"), "sql");
689 }
690
691 #[test]
692 fn detect_language_unknown_extension() {
693 assert_eq!(detect_language("file.xyz"), "");
694 assert_eq!(detect_language("Makefile"), "");
695 }
696
697 #[test]
700 fn prioritized_paths_modify_first() {
701 let desc = "Read src/lib.rs for context\nModify src/main.rs to add feature";
702 let paths = extract_prioritized_paths(desc);
703 assert_eq!(paths, vec!["src/main.rs", "src/lib.rs"]);
704 }
705
706 #[test]
707 fn prioritized_paths_create_first() {
708 let desc = "Check src/old.rs\nCreate src/new.rs with the new module";
709 let paths = extract_prioritized_paths(desc);
710 assert_eq!(paths, vec!["src/new.rs", "src/old.rs"]);
711 }
712
713 #[test]
714 fn prioritized_paths_deduplicates() {
715 let desc = "Modify src/main.rs\nAlso read src/main.rs for context";
716 let paths = extract_prioritized_paths(desc);
717 assert_eq!(paths, vec!["src/main.rs"]);
718 }
719
720 #[test]
721 fn prioritized_paths_no_keywords() {
722 let desc = "See src/foo.rs and src/bar.rs";
723 let paths = extract_prioritized_paths(desc);
724 assert_eq!(paths, vec!["src/foo.rs", "src/bar.rs"]);
725 }
726
727 #[test]
728 fn prioritized_paths_empty() {
729 let paths = extract_prioritized_paths("No files here");
730 assert!(paths.is_empty());
731 }
732
733 #[test]
736 fn load_rules_returns_none_when_missing() {
737 let (_dir, mana_dir) = setup_test_env();
738 let result = load_rules(&mana_dir);
739 assert!(result.is_none());
740 }
741
742 #[test]
743 fn load_rules_returns_none_when_empty() {
744 let (_dir, mana_dir) = setup_test_env();
745 fs::write(mana_dir.join("RULES.md"), " \n ").unwrap();
746 let result = load_rules(&mana_dir);
747 assert!(result.is_none());
748 }
749
750 #[test]
751 fn load_rules_returns_content() {
752 let (_dir, mana_dir) = setup_test_env();
753 fs::write(mana_dir.join("RULES.md"), "# Rules\nNo unwrap.\n").unwrap();
754 let result = load_rules(&mana_dir);
755 assert!(result.is_some());
756 assert!(result.unwrap().contains("No unwrap."));
757 }
758
759 #[test]
762 fn parent_context_no_parent() {
763 let (_dir, mana_dir) = setup_test_env();
764 let unit = Unit::new("1", "No parent");
765 let sections = collect_parent_context(&unit, &mana_dir);
766 assert!(sections.is_empty());
767 }
768
769 #[test]
770 fn parent_context_single_parent() {
771 let (_dir, mana_dir) = setup_test_env();
772
773 let mut parent = Unit::new("1", "Parent Task");
775 parent.description = Some("This is the parent goal.".to_string());
776 write_test_unit(&mana_dir, &parent);
777
778 let mut child = Unit::new("1.1", "Child Task");
780 child.parent = Some("1".to_string());
781 write_test_unit(&mana_dir, &child);
782
783 let sections = collect_parent_context(&child, &mana_dir);
784 assert_eq!(sections.len(), 1);
785 assert!(sections[0].contains("Parent Context"));
786 assert!(sections[0].contains("unit 1: Parent Task"));
787 assert!(sections[0].contains("parent goal"));
788 }
789
790 #[test]
791 fn parent_context_grandparent_appears_first() {
792 let (_dir, mana_dir) = setup_test_env();
793
794 let mut grandparent = Unit::new("1", "Grandparent");
796 grandparent.description = Some("Grand context.".to_string());
797 write_test_unit(&mana_dir, &grandparent);
798
799 let mut parent = Unit::new("1.1", "Parent");
801 parent.parent = Some("1".to_string());
802 parent.description = Some("Parent context.".to_string());
803 write_test_unit(&mana_dir, &parent);
804
805 let mut child = Unit::new("1.1.1", "Child");
807 child.parent = Some("1.1".to_string());
808
809 let sections = collect_parent_context(&child, &mana_dir);
810 assert_eq!(sections.len(), 2);
811 assert!(sections[0].contains("Grandparent"));
813 assert!(sections[1].contains("Parent"));
814 }
815
816 #[test]
817 fn parent_context_caps_total_chars() {
818 let (_dir, mana_dir) = setup_test_env();
819
820 let mut parent = Unit::new("1", "Verbose Parent");
822 parent.description = Some("x".repeat(5000));
823 write_test_unit(&mana_dir, &parent);
824
825 let mut child = Unit::new("1.1", "Child");
826 child.parent = Some("1".to_string());
827
828 let sections = collect_parent_context(&child, &mana_dir);
829 assert_eq!(sections.len(), 1);
830 assert!(sections[0].contains("[…truncated]"));
832 let body_start = sections[0].find("\n\n").unwrap() + 2;
834 let body = §ions[0][body_start..];
835 assert!(body.len() < PARENT_CHAR_CAP + 50);
837 }
838
839 #[test]
842 fn sibling_discoveries_no_parent() {
843 let (_dir, mana_dir) = setup_test_env();
844 let unit = Unit::new("1", "No parent");
845 let result = collect_sibling_discoveries(&unit, &mana_dir);
846 assert!(result.is_none());
847 }
848
849 #[test]
850 fn sibling_discoveries_finds_closed_with_discover() {
851 let (_dir, mana_dir) = setup_test_env();
852
853 let parent = Unit::new("1", "Parent");
855 write_test_unit(&mana_dir, &parent);
856
857 let mut sibling = Unit::new("1.1", "Sibling A");
859 sibling.parent = Some("1".to_string());
860 sibling.status = Status::Closed;
861 sibling.notes = Some("Discoveries: the API uses snake_case".to_string());
862 write_test_unit(&mana_dir, &sibling);
863
864 let mut unit = Unit::new("1.2", "Current Unit");
866 unit.parent = Some("1".to_string());
867 write_test_unit(&mana_dir, &unit);
868
869 let _ = Index::build(&mana_dir).unwrap().save(&mana_dir);
871
872 let result = collect_sibling_discoveries(&unit, &mana_dir);
873 assert!(result.is_some());
874 let text = result.unwrap();
875 assert!(text.contains("Discoveries from completed siblings"));
876 assert!(text.contains("snake_case"));
877 }
878
879 #[test]
880 fn sibling_discoveries_skips_non_discover_notes() {
881 let (_dir, mana_dir) = setup_test_env();
882
883 let parent = Unit::new("1", "Parent");
884 write_test_unit(&mana_dir, &parent);
885
886 let mut sibling = Unit::new("1.1", "Sibling");
888 sibling.parent = Some("1".to_string());
889 sibling.status = Status::Closed;
890 sibling.notes = Some("Just regular notes about the task".to_string());
891 write_test_unit(&mana_dir, &sibling);
892
893 let mut unit = Unit::new("1.2", "Current");
894 unit.parent = Some("1".to_string());
895 write_test_unit(&mana_dir, &unit);
896
897 let _ = Index::build(&mana_dir).unwrap().save(&mana_dir);
898
899 let result = collect_sibling_discoveries(&unit, &mana_dir);
900 assert!(result.is_none());
901 }
902
903 #[test]
904 fn sibling_discoveries_skips_open_siblings() {
905 let (_dir, mana_dir) = setup_test_env();
906
907 let parent = Unit::new("1", "Parent");
908 write_test_unit(&mana_dir, &parent);
909
910 let mut sibling = Unit::new("1.1", "Open Sibling");
912 sibling.parent = Some("1".to_string());
913 sibling.status = Status::Open;
914 sibling.notes = Some("Discoveries: something useful".to_string());
915 write_test_unit(&mana_dir, &sibling);
916
917 let mut unit = Unit::new("1.2", "Current");
918 unit.parent = Some("1".to_string());
919 write_test_unit(&mana_dir, &unit);
920
921 let _ = Index::build(&mana_dir).unwrap().save(&mana_dir);
922
923 let result = collect_sibling_discoveries(&unit, &mana_dir);
924 assert!(result.is_none());
925 }
926
927 #[test]
930 fn concurrent_warning_single_overlap() {
931 let overlaps = vec![FileOverlap {
932 unit_id: "5".to_string(),
933 title: "Other Task".to_string(),
934 shared_files: vec!["src/main.rs".to_string()],
935 }];
936 let result = format_concurrent_warning(&overlaps);
937 assert!(result.contains("Concurrent Modification Warning"));
938 assert!(result.contains("Unit 5 (Other Task)"));
939 assert!(result.contains("src/main.rs"));
940 }
941
942 #[test]
943 fn concurrent_warning_multiple_overlaps() {
944 let overlaps = vec![
945 FileOverlap {
946 unit_id: "5".to_string(),
947 title: "Task A".to_string(),
948 shared_files: vec!["src/a.rs".to_string(), "src/b.rs".to_string()],
949 },
950 FileOverlap {
951 unit_id: "6".to_string(),
952 title: "Task B".to_string(),
953 shared_files: vec!["src/c.rs".to_string()],
954 },
955 ];
956 let result = format_concurrent_warning(&overlaps);
957 assert!(result.contains("Unit 5"));
958 assert!(result.contains("Unit 6"));
959 assert!(result.contains("src/a.rs, src/b.rs"));
960 }
961
962 #[test]
965 fn file_context_reads_existing_files() {
966 let dir = TempDir::new().unwrap();
967 let project_dir = dir.path();
968
969 let src = project_dir.join("src");
971 fs::create_dir(&src).unwrap();
972 fs::write(src.join("main.rs"), "fn main() {}").unwrap();
973
974 let desc = "Modify src/main.rs to add feature";
975 let result = assemble_file_context(desc, project_dir);
976 assert!(result.is_some());
977 let text = result.unwrap();
978 assert!(text.contains("# Referenced Files"));
979 assert!(text.contains("## src/main.rs"));
980 assert!(text.contains("```rust"));
981 assert!(text.contains("fn main() {}"));
982 }
983
984 #[test]
985 fn file_context_skips_missing_files() {
986 let dir = TempDir::new().unwrap();
987 let desc = "Read src/nonexistent.rs";
988 let result = assemble_file_context(desc, dir.path());
989 assert!(result.is_none());
990 }
991
992 #[test]
993 fn file_context_caps_total_chars() {
994 let dir = TempDir::new().unwrap();
995 let project_dir = dir.path();
996 let src = project_dir.join("src");
997 fs::create_dir(&src).unwrap();
998
999 fs::write(src.join("big.rs"), "x".repeat(20000)).unwrap();
1001
1002 let desc = "Read src/big.rs";
1003 let result = assemble_file_context(desc, project_dir);
1004 assert!(result.is_some());
1005 let text = result.unwrap();
1006 assert!(text.contains("[…truncated]"));
1007 assert!(text.len() < FILE_CONTENT_CHAR_CAP + 500);
1009 }
1010
1011 #[test]
1012 fn file_context_no_paths() {
1013 let dir = TempDir::new().unwrap();
1014 let result = assemble_file_context("No file paths here", dir.path());
1015 assert!(result.is_none());
1016 }
1017
1018 #[test]
1021 fn previous_attempts_with_notes() {
1022 let mut unit = Unit::new("1", "Test");
1023 unit.attempts = 2;
1024 unit.notes = Some("Tried approach X, it broke Y.".to_string());
1025 unit.attempt_log = vec![AttemptRecord {
1026 num: 1,
1027 outcome: AttemptOutcome::Failed,
1028 notes: Some("First try failed due to Z".to_string()),
1029 agent: Some("agent-1".to_string()),
1030 started_at: None,
1031 finished_at: None,
1032 }];
1033
1034 let result = format_previous_attempts(&unit);
1035 assert!(result.contains("Previous Attempts (2 so far)"));
1036 assert!(result.contains("Tried approach X"));
1037 assert!(result.contains("Attempt #1 (agent-1) [failed]"));
1038 assert!(result.contains("First try failed"));
1039 assert!(result.contains("Do NOT repeat"));
1040 }
1041
1042 #[test]
1043 fn previous_attempts_no_notes() {
1044 let mut unit = Unit::new("1", "Test");
1045 unit.attempts = 1;
1046
1047 let result = format_previous_attempts(&unit);
1048 assert!(result.contains("Previous Attempts (1 so far)"));
1049 assert!(result.contains("Do NOT repeat"));
1050 }
1051
1052 #[test]
1055 fn approach_contains_unit_id() {
1056 let result = format_approach("42");
1057 assert!(result.contains("mana close 42"));
1058 assert!(result.contains("mana update 42"));
1059 }
1060
1061 #[test]
1064 fn verify_gate_with_command() {
1065 let mut unit = Unit::new("1", "Test");
1066 unit.verify = Some("cargo test".to_string());
1067 let result = format_verify_gate(&unit);
1068 assert!(result.contains("cargo test"));
1069 assert!(result.contains("MUST exit 0"));
1070 }
1071
1072 #[test]
1073 fn verify_gate_without_command() {
1074 let unit = Unit::new("1", "Test");
1075 let result = format_verify_gate(&unit);
1076 assert!(result.contains("No verify command"));
1077 assert!(result.contains("mana close 1"));
1078 }
1079
1080 #[test]
1083 fn constraints_contains_unit_id() {
1084 let result = format_constraints("7");
1085 assert!(result.contains("mana close 7"));
1086 assert!(result.contains("Don't add dependencies"));
1087 }
1088
1089 #[test]
1092 fn tool_strategy_mentions_key_tools() {
1093 let result = format_tool_strategy();
1094 assert!(result.contains("probe_search"));
1095 assert!(result.contains("rg"));
1096 assert!(result.contains("Edit"));
1097 assert!(result.contains("Write"));
1098 }
1099
1100 #[test]
1103 fn build_prompt_minimal_unit() {
1104 let (_dir, mana_dir) = setup_test_env();
1105
1106 let mut unit = Unit::new("1", "Simple Task");
1107 unit.description = Some("Just do the thing.".to_string());
1108 unit.verify = Some("cargo test".to_string());
1109 write_test_unit(&mana_dir, &unit);
1110
1111 let options = PromptOptions {
1112 mana_dir: mana_dir.clone(),
1113 instructions: None,
1114 concurrent_overlaps: None,
1115 };
1116
1117 let result = build_agent_prompt(&unit, &options).unwrap();
1118
1119 assert!(result.system_prompt.contains("Unit Assignment"));
1121 assert!(result.system_prompt.contains("unit 1: Simple Task"));
1122 assert!(result.system_prompt.contains("Pre-flight Check"));
1123 assert!(result.system_prompt.contains("cargo test"));
1124 assert!(result.system_prompt.contains("Verify Gate"));
1125 assert!(result.system_prompt.contains("Approach"));
1126 assert!(result.system_prompt.contains("Constraints"));
1127 assert!(result.system_prompt.contains("Tool Strategy"));
1128
1129 assert!(result.system_prompt.contains("---"));
1131
1132 assert!(result.user_message.contains("mana close 1"));
1134
1135 assert!(result.file_ref.contains("1-simple-task.md"));
1137 }
1138
1139 #[test]
1140 fn build_prompt_with_instructions() {
1141 let (_dir, mana_dir) = setup_test_env();
1142
1143 let unit = Unit::new("1", "Task");
1144 write_test_unit(&mana_dir, &unit);
1145
1146 let options = PromptOptions {
1147 mana_dir: mana_dir.clone(),
1148 instructions: Some("Focus on performance".to_string()),
1149 concurrent_overlaps: None,
1150 };
1151
1152 let result = build_agent_prompt(&unit, &options).unwrap();
1153 assert!(result.user_message.starts_with("Focus on performance"));
1154 assert!(result.user_message.contains("mana close 1"));
1155 }
1156
1157 #[test]
1158 fn build_prompt_with_rules() {
1159 let (_dir, mana_dir) = setup_test_env();
1160 fs::write(mana_dir.join("RULES.md"), "# Style\nUse snake_case.\n").unwrap();
1161
1162 let unit = Unit::new("1", "Task");
1163 write_test_unit(&mana_dir, &unit);
1164
1165 let options = PromptOptions {
1166 mana_dir: mana_dir.clone(),
1167 instructions: None,
1168 concurrent_overlaps: None,
1169 };
1170
1171 let result = build_agent_prompt(&unit, &options).unwrap();
1172 assert!(result.system_prompt.contains("Project Rules"));
1173 assert!(result.system_prompt.contains("snake_case"));
1174 }
1175
1176 #[test]
1177 fn build_prompt_with_acceptance_criteria() {
1178 let (_dir, mana_dir) = setup_test_env();
1179
1180 let mut unit = Unit::new("1", "Task");
1181 unit.acceptance = Some("All tests pass\nNo warnings".to_string());
1182 write_test_unit(&mana_dir, &unit);
1183
1184 let options = PromptOptions {
1185 mana_dir: mana_dir.clone(),
1186 instructions: None,
1187 concurrent_overlaps: None,
1188 };
1189
1190 let result = build_agent_prompt(&unit, &options).unwrap();
1191 assert!(result.system_prompt.contains("Acceptance Criteria"));
1192 assert!(result.system_prompt.contains("All tests pass"));
1193 assert!(result.system_prompt.contains("No warnings"));
1194 }
1195
1196 #[test]
1197 fn build_prompt_with_concurrent_overlaps() {
1198 let (_dir, mana_dir) = setup_test_env();
1199
1200 let unit = Unit::new("1", "Task");
1201 write_test_unit(&mana_dir, &unit);
1202
1203 let options = PromptOptions {
1204 mana_dir: mana_dir.clone(),
1205 instructions: None,
1206 concurrent_overlaps: Some(vec![FileOverlap {
1207 unit_id: "2".to_string(),
1208 title: "Other".to_string(),
1209 shared_files: vec!["src/shared.rs".to_string()],
1210 }]),
1211 };
1212
1213 let result = build_agent_prompt(&unit, &options).unwrap();
1214 assert!(result
1215 .system_prompt
1216 .contains("Concurrent Modification Warning"));
1217 assert!(result.system_prompt.contains("Unit 2 (Other)"));
1218 }
1219
1220 #[test]
1221 fn build_prompt_with_previous_attempts() {
1222 let (_dir, mana_dir) = setup_test_env();
1223
1224 let mut unit = Unit::new("1", "Retry Task");
1225 unit.attempts = 2;
1226 unit.notes = Some("Tried X, failed due to Y.".to_string());
1227 write_test_unit(&mana_dir, &unit);
1228
1229 let options = PromptOptions {
1230 mana_dir: mana_dir.clone(),
1231 instructions: None,
1232 concurrent_overlaps: None,
1233 };
1234
1235 let result = build_agent_prompt(&unit, &options).unwrap();
1236 assert!(result.system_prompt.contains("Previous Attempts"));
1237 assert!(result.system_prompt.contains("Tried X"));
1238 assert!(result.system_prompt.contains("Do NOT repeat"));
1239 }
1240
1241 #[test]
1242 fn build_prompt_no_verify() {
1243 let (_dir, mana_dir) = setup_test_env();
1244
1245 let unit = Unit::new("1", "No Verify");
1246 write_test_unit(&mana_dir, &unit);
1247
1248 let options = PromptOptions {
1249 mana_dir: mana_dir.clone(),
1250 instructions: None,
1251 concurrent_overlaps: None,
1252 };
1253
1254 let result = build_agent_prompt(&unit, &options).unwrap();
1255 assert!(!result.system_prompt.contains("Pre-flight Check"));
1257 assert!(result.system_prompt.contains("No verify command"));
1259 }
1260
1261 #[test]
1262 fn build_prompt_with_file_references() {
1263 let (dir, mana_dir) = setup_test_env();
1264 let project_dir = dir.path();
1265
1266 let src = project_dir.join("src");
1268 fs::create_dir(&src).unwrap();
1269 fs::write(src.join("lib.rs"), "pub mod utils;").unwrap();
1270 fs::write(src.join("utils.rs"), "pub fn helper() {}").unwrap();
1271
1272 let mut unit = Unit::new("1", "Task");
1273 unit.description =
1274 Some("Modify src/lib.rs to export new module\nRead src/utils.rs".to_string());
1275 write_test_unit(&mana_dir, &unit);
1276
1277 let options = PromptOptions {
1278 mana_dir: mana_dir.clone(),
1279 instructions: None,
1280 concurrent_overlaps: None,
1281 };
1282
1283 let result = build_agent_prompt(&unit, &options).unwrap();
1284 assert!(result.system_prompt.contains("Referenced Files"));
1285 assert!(result.system_prompt.contains("src/lib.rs"));
1286 assert!(result.system_prompt.contains("pub mod utils;"));
1287 }
1288
1289 #[test]
1290 fn build_prompt_section_order() {
1291 let (dir, mana_dir) = setup_test_env();
1292 let project_dir = dir.path();
1293
1294 fs::write(mana_dir.join("RULES.md"), "# Rules\nBe nice.").unwrap();
1296
1297 let mut parent = Unit::new("1", "Parent");
1299 parent.description = Some("Parent goal.".to_string());
1300 write_test_unit(&mana_dir, &parent);
1301
1302 let src = project_dir.join("src");
1304 fs::create_dir(&src).unwrap();
1305 fs::write(src.join("main.rs"), "fn main() {}").unwrap();
1306
1307 let mut unit = Unit::new("1.1", "Child Task");
1309 unit.parent = Some("1".to_string());
1310 unit.description = Some("Modify src/main.rs".to_string());
1311 unit.acceptance = Some("Tests pass".to_string());
1312 unit.verify = Some("cargo test".to_string());
1313 unit.attempts = 1;
1314 unit.notes = Some("Tried something".to_string());
1315 write_test_unit(&mana_dir, &unit);
1316
1317 let _ = Index::build(&mana_dir).unwrap().save(&mana_dir);
1318
1319 let options = PromptOptions {
1320 mana_dir: mana_dir.clone(),
1321 instructions: None,
1322 concurrent_overlaps: None,
1323 };
1324
1325 let result = build_agent_prompt(&unit, &options).unwrap();
1326 let prompt = &result.system_prompt;
1327
1328 let rules_pos = prompt.find("# Project Rules").unwrap();
1330 let parent_pos = prompt.find("# Parent Context").unwrap();
1331 let assignment_pos = prompt.find("# Unit Assignment").unwrap();
1332 let files_pos = prompt.find("# Referenced Files").unwrap();
1333 let acceptance_pos = prompt.find("# Acceptance Criteria").unwrap();
1334 let preflight_pos = prompt.find("# Pre-flight Check").unwrap();
1335 let attempts_pos = prompt.find("# Previous Attempts").unwrap();
1336 let approach_pos = prompt.find("# Approach").unwrap();
1337 let verify_pos = prompt.find("# Verify Gate").unwrap();
1338 let constraints_pos = prompt.find("# Constraints").unwrap();
1339 let tools_pos = prompt.find("# Tool Strategy").unwrap();
1340
1341 assert!(rules_pos < parent_pos, "Rules before Parent");
1342 assert!(parent_pos < assignment_pos, "Parent before Assignment");
1343 assert!(assignment_pos < files_pos, "Assignment before Files");
1344 assert!(files_pos < acceptance_pos, "Files before Acceptance");
1345 assert!(
1346 acceptance_pos < preflight_pos,
1347 "Acceptance before Preflight"
1348 );
1349 assert!(preflight_pos < attempts_pos, "Preflight before Attempts");
1350 assert!(attempts_pos < approach_pos, "Attempts before Approach");
1351 assert!(approach_pos < verify_pos, "Approach before Verify");
1352 assert!(verify_pos < constraints_pos, "Verify before Constraints");
1353 assert!(constraints_pos < tools_pos, "Constraints before Tools");
1354 }
1355}