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