Skip to main content

mana_core/
prompt.rs

1//! Structured agent prompt builder.
2//!
3//! Constructs a multi-section system prompt that gives agents the context
4//! they need to implement a unit successfully. Ports the 11-section
5//! architecture from the pi extension `prompt.ts` into Rust.
6//!
7//! Sections (in order):
8//! 1. Project Rules
9//! 2. Parent Context
10//! 3. Sibling Discoveries
11//! 4. Unit Assignment
12//! 5. Concurrent Modification Warning
13//! 6. Referenced Files
14//! 7. Acceptance Criteria
15//! 8. Pre-flight Check
16//! 9. Previous Attempts
17//! 10. Approach
18//! 11. Verify Gate
19//! 12. Constraints
20//! 13. Tool Strategy
21
22use 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
34// ---------------------------------------------------------------------------
35// Public types
36// ---------------------------------------------------------------------------
37
38/// Result of building an agent prompt.
39pub struct PromptResult {
40    /// The full system prompt containing all context sections.
41    pub system_prompt: String,
42    /// The user message instructing the agent what to do.
43    pub user_message: String,
44    /// Path to the unit file, for @file injection by the caller.
45    pub file_ref: String,
46}
47
48/// Options for prompt construction.
49pub struct PromptOptions {
50    /// Path to the `.mana/` directory.
51    pub mana_dir: PathBuf,
52    /// Optional instructions to prepend to the user message.
53    pub instructions: Option<String>,
54    /// Units running concurrently that share files with this unit.
55    pub concurrent_overlaps: Option<Vec<FileOverlap>>,
56}
57
58/// Describes a concurrent unit that overlaps on files.
59pub struct FileOverlap {
60    /// ID of the overlapping unit.
61    pub unit_id: String,
62    /// Title of the overlapping unit.
63    pub title: String,
64    /// File paths shared between the two units.
65    pub shared_files: Vec<String>,
66}
67
68// ---------------------------------------------------------------------------
69// Constants
70// ---------------------------------------------------------------------------
71
72/// Max characters per parent body.
73const PARENT_CHAR_CAP: usize = 2000;
74
75/// Max total characters across all ancestors.
76const TOTAL_ANCESTOR_CHAR_CAP: usize = 3000;
77
78/// Max total characters from sibling discovery notes.
79const DISCOVERY_CHAR_CAP: usize = 1500;
80
81/// Max total characters of file content to embed in the prompt.
82const FILE_CONTENT_CHAR_CAP: usize = 8000;
83
84/// Pattern to detect discovery notes in unit notes.
85static DISCOVERY_PATTERN: LazyLock<Regex> =
86    LazyLock::new(|| Regex::new(r"(?i)discover").expect("Invalid discovery regex"));
87
88/// Keywords near a path that hint the file is a modify/create target.
89static 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
94// ---------------------------------------------------------------------------
95// Public API
96// ---------------------------------------------------------------------------
97
98/// Build the full structured agent prompt for a unit.
99///
100/// Returns a [`PromptResult`] containing the system prompt, user message,
101/// and unit file path. The system prompt is assembled from up to 13 sections
102/// that give the agent everything it needs to implement the unit.
103pub 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    // 1. Project Rules
108    if let Some(rules) = load_rules(mana_dir) {
109        sections.push(format!("# Project Rules\n\n{}", rules));
110    }
111
112    // 2. Parent Context
113    let parent_sections = collect_parent_context(unit, mana_dir);
114    for section in parent_sections {
115        sections.push(section);
116    }
117
118    // 3. Sibling Discoveries
119    if let Some(discoveries) = collect_sibling_discoveries(unit, mana_dir) {
120        sections.push(discoveries);
121    }
122
123    // 4. Unit Assignment
124    sections.push(format!(
125        "# Unit Assignment\n\nYou are implementing unit {}: {}",
126        unit.id, unit.title
127    ));
128
129    // 5. Concurrent Modification Warning
130    if let Some(ref overlaps) = options.concurrent_overlaps {
131        if !overlaps.is_empty() {
132            sections.push(format_concurrent_warning(overlaps));
133        }
134    }
135
136    // 6. Referenced Files
137    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    // 7. Acceptance Criteria
144    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    // 8. Pre-flight Check
152    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    // 9. Previous Attempts
163    if unit.attempts > 0 {
164        sections.push(format_previous_attempts(unit));
165    }
166
167    // 10. Approach
168    sections.push(format_approach(&unit.id));
169
170    // 11. Verify Gate
171    sections.push(format_verify_gate(unit));
172
173    // 12. Constraints
174    sections.push(format_constraints(&unit.id));
175
176    // 13. Tool Strategy
177    sections.push(format_tool_strategy());
178
179    // Assemble system prompt
180    let system_prompt = sections.join("\n\n---\n\n");
181
182    // User message
183    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    // File reference
194    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
205// ---------------------------------------------------------------------------
206// Section builders
207// ---------------------------------------------------------------------------
208
209/// Load project rules from `.mana/RULES.md` (or configured path).
210fn 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
221/// Walk up the parent chain and collect context sections.
222///
223/// Returns sections in outermost-first order (grandparent before parent).
224/// Each parent body is capped at [`PARENT_CHAR_CAP`]; total ancestor
225/// context is capped at [`TOTAL_ANCESTOR_CHAR_CAP`].
226fn 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    // Reverse so grandparent appears before parent (outermost context first)
264    sections.reverse();
265    sections
266}
267
268/// Collect discovery notes from closed sibling units.
269///
270/// Reads siblings (children of the same parent) and extracts notes
271/// containing "discover" from closed siblings. Caps total context
272/// at [`DISCOVERY_CHAR_CAP`].
273fn 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    // Find closed siblings (same parent, not self)
279    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(&notes) {
310            continue;
311        }
312
313        let remaining = DISCOVERY_CHAR_CAP - total_chars;
314        let trimmed = truncate_text(&notes, 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
333/// Format the concurrent modification warning section.
334fn 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
354/// Assemble referenced file contents from the unit description.
355///
356/// Extracts file paths from the description text, reads their contents
357/// from the project directory, and assembles them into a markdown section.
358/// Files near priority keywords (modify, create, etc.) are listed first.
359/// Total content is capped at [`FILE_CONTENT_CHAR_CAP`].
360fn 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, // file doesn't exist
379        };
380
381        // Stay within project directory
382        if !canonical.starts_with(&canonical_base) {
383            continue;
384        }
385
386        // Skip directories
387        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
420/// Format the previous attempts section.
421fn format_previous_attempts(unit: &Unit) -> String {
422    let mut section = format!("# Previous Attempts ({} so far)", unit.attempts);
423
424    // Include unit notes
425    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    // Include per-attempt notes from attempt_log
433    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
464/// Format the approach section with numbered workflow.
465fn 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
483/// Format the verify gate section.
484fn 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
519/// Format the constraints section.
520fn 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
532/// Format the tool strategy section.
533fn 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
542// ---------------------------------------------------------------------------
543// Helpers
544// ---------------------------------------------------------------------------
545
546/// Truncate text to a character limit, appending an ellipsis if trimmed.
547fn 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
556/// Extract file paths from description text, prioritized by action keywords.
557///
558/// Paths on lines containing words like "modify", "create", "add" come first,
559/// followed by other referenced paths.
560fn 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
584/// Detect programming language from file extension for code fence tagging.
585fn 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
615/// Load a unit by ID, returning None on any error.
616fn 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// ---------------------------------------------------------------------------
622// Tests
623// ---------------------------------------------------------------------------
624
625#[cfg(test)]
626mod tests {
627    use super::*;
628    use crate::unit::{AttemptOutcome, AttemptRecord, Unit};
629    use std::fs;
630    use tempfile::TempDir;
631
632    /// Create a test environment with .mana/ directory and minimal config.
633    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    /// Write a unit to the .mana/ directory with standard naming.
646    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    // -- truncate_text --
653
654    #[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    // -- detect_language --
672
673    #[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    // -- extract_prioritized_paths --
698
699    #[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    // -- load_rules --
734
735    #[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    // -- collect_parent_context --
760
761    #[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        // Create parent unit
774        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        // Create child referencing parent
779        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        // Grandparent
795        let mut grandparent = Unit::new("1", "Grandparent");
796        grandparent.description = Some("Grand context.".to_string());
797        write_test_unit(&mana_dir, &grandparent);
798
799        // Parent
800        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        // Child
806        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        // Grandparent should appear first (reversed order)
812        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        // Create a parent with a very long description
821        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        // Body should be truncated
831        assert!(sections[0].contains("[…truncated]"));
832        // Total chars should respect PARENT_CHAR_CAP
833        let body_start = sections[0].find("\n\n").unwrap() + 2;
834        let body = &sections[0][body_start..];
835        // Truncated body should be roughly PARENT_CHAR_CAP + truncation marker
836        assert!(body.len() < PARENT_CHAR_CAP + 50);
837    }
838
839    // -- collect_sibling_discoveries --
840
841    #[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        // Create parent
854        let parent = Unit::new("1", "Parent");
855        write_test_unit(&mana_dir, &parent);
856
857        // Create closed sibling with discovery notes
858        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        // The unit under test
865        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        // Need to rebuild index
870        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        // Closed sibling without "discover" in notes
887        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        // Open sibling with discovery notes — should be skipped
911        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    // -- format_concurrent_warning --
928
929    #[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    // -- assemble_file_context --
963
964    #[test]
965    fn file_context_reads_existing_files() {
966        let dir = TempDir::new().unwrap();
967        let project_dir = dir.path();
968
969        // Create a source file
970        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        // Create a large file
1000        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        // Total content should be around FILE_CONTENT_CHAR_CAP
1008        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    // -- format_previous_attempts --
1019
1020    #[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    // -- format_approach --
1053
1054    #[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    // -- format_verify_gate --
1062
1063    #[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    // -- format_constraints --
1081
1082    #[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    // -- format_tool_strategy --
1090
1091    #[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    // -- build_agent_prompt integration --
1101
1102    #[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        // System prompt should contain key sections
1120        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        // Sections should be separated by ---
1130        assert!(result.system_prompt.contains("---"));
1131
1132        // User message should contain close instruction
1133        assert!(result.user_message.contains("mana close 1"));
1134
1135        // File ref should point to the unit file
1136        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        // Should not have pre-flight check
1256        assert!(!result.system_prompt.contains("Pre-flight Check"));
1257        // Verify gate should say no command
1258        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        // Create source files
1267        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        // Write rules
1295        fs::write(mana_dir.join("RULES.md"), "# Rules\nBe nice.").unwrap();
1296
1297        // Create parent
1298        let mut parent = Unit::new("1", "Parent");
1299        parent.description = Some("Parent goal.".to_string());
1300        write_test_unit(&mana_dir, &parent);
1301
1302        // Create source file
1303        let src = project_dir.join("src");
1304        fs::create_dir(&src).unwrap();
1305        fs::write(src.join("main.rs"), "fn main() {}").unwrap();
1306
1307        // Create child unit with all features
1308        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        // Verify section ordering by finding positions
1329        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}