Skip to main content

mana_core/
prompt.rs

1//! Structured task-packet and compatibility prompt builder.
2//!
3//! Constructs a multi-section task packet that gives runtimes/agents the context
4//! they need to implement a unit successfully. Historically this module also
5//! acted like a final agent-prompt authority; the rebuild is narrowing it toward
6//! durable task/input preparation, with final runtime/system prompt assembly
7//! living in imp.
8//!
9//! The current API still returns a prompt-shaped bundle because legacy
10//! `mana run`/`mana context --agent-prompt` compatibility flows consume it.
11//! Long-term, treat the material here as durable task-packet source content
12//! rather than the final runtime prompt center.
13//!
14//! Sections (in order):
15//! 1. Project Rules
16//! 2. Parent Context
17//! 3. Sibling Discoveries
18//! 4. Unit Assignment
19//! 5. Concurrent Modification Warning
20//! 6. Referenced Files
21//! 7. Acceptance Criteria
22//! 8. Pre-flight Check
23//! 9. Previous Attempts
24//! 10. Approach
25//! 11. Verify Gate
26//! 12. Constraints
27//! 13. Tool Strategy
28
29use 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
41// ---------------------------------------------------------------------------
42// Public types
43// ---------------------------------------------------------------------------
44
45/// Result of building durable task-packet material for compatibility flows.
46pub struct PromptResult {
47    /// Compatibility system-prompt content assembled from durable task/input sections.
48    pub system_prompt: String,
49    /// Compatibility user message for legacy consumers.
50    pub user_message: String,
51    /// Path to the unit file, for @file injection by the caller.
52    pub file_ref: String,
53}
54
55/// Options for prompt construction.
56pub struct PromptOptions {
57    /// Path to the `.mana/` directory.
58    pub mana_dir: PathBuf,
59    /// Optional instructions to prepend to the user message.
60    pub instructions: Option<String>,
61    /// Units running concurrently that share files with this unit.
62    pub concurrent_overlaps: Option<Vec<FileOverlap>>,
63}
64
65/// Describes a concurrent unit that overlaps on files.
66pub struct FileOverlap {
67    /// ID of the overlapping unit.
68    pub unit_id: String,
69    /// Title of the overlapping unit.
70    pub title: String,
71    /// File paths shared between the two units.
72    pub shared_files: Vec<String>,
73}
74
75// ---------------------------------------------------------------------------
76// Constants
77// ---------------------------------------------------------------------------
78
79/// Max characters per parent body.
80const PARENT_CHAR_CAP: usize = 2000;
81
82/// Max total characters across all ancestors.
83const TOTAL_ANCESTOR_CHAR_CAP: usize = 3000;
84
85/// Max total characters from sibling discovery notes.
86const DISCOVERY_CHAR_CAP: usize = 1500;
87
88/// Max total characters of file content to embed in the prompt.
89const FILE_CONTENT_CHAR_CAP: usize = 8000;
90
91/// Pattern to detect discovery notes in unit notes.
92static DISCOVERY_PATTERN: LazyLock<Regex> =
93    LazyLock::new(|| Regex::new(r"(?i)discover").expect("Invalid discovery regex"));
94
95/// Keywords near a path that hint the file is a modify/create target.
96static 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
101// ---------------------------------------------------------------------------
102// Public API
103// ---------------------------------------------------------------------------
104
105/// Build the full structured task-packet material for a unit.
106///
107/// Returns a [`PromptResult`] containing compatibility prompt-shaped fields
108/// for current legacy consumers. The content is assembled from up to 13
109/// sections that capture durable task/input material another runtime can use.
110pub 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    // 1. Project Rules
115    if let Some(rules) = load_rules(mana_dir) {
116        sections.push(format!("# Project Rules\n\n{}", rules));
117    }
118
119    // 2. Parent Context
120    let parent_sections = collect_parent_context(unit, mana_dir);
121    for section in parent_sections {
122        sections.push(section);
123    }
124
125    // 3. Sibling Discoveries
126    if let Some(discoveries) = collect_sibling_discoveries(unit, mana_dir) {
127        sections.push(discoveries);
128    }
129
130    // 4. Unit Assignment
131    sections.push(format!(
132        "# Unit Assignment\n\nYou are implementing unit {}: {}",
133        unit.id, unit.title
134    ));
135
136    // 5. Concurrent Modification Warning
137    if let Some(ref overlaps) = options.concurrent_overlaps {
138        if !overlaps.is_empty() {
139            sections.push(format_concurrent_warning(overlaps));
140        }
141    }
142
143    // 6. Referenced Files
144    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    // 7. Acceptance Criteria
151    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    // 8. Pre-flight Check
159    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    // 9. Previous Attempts
170    if unit.attempts > 0 {
171        sections.push(format_previous_attempts(unit));
172    }
173
174    // 10. Approach
175    sections.push(format_approach(&unit.id));
176
177    // 11. Verify Gate
178    sections.push(format_verify_gate(unit));
179
180    // 12. Constraints
181    sections.push(format_constraints(&unit.id));
182
183    // 13. Tool Strategy
184    sections.push(format_tool_strategy());
185
186    // Assemble system prompt
187    let system_prompt = sections.join("\n\n---\n\n");
188
189    // User message (legacy compatibility for current mana-side prompt consumers)
190    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    // File reference
201    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
212// ---------------------------------------------------------------------------
213// Section builders
214// ---------------------------------------------------------------------------
215
216/// Load project rules from `.mana/RULES.md` (or configured path).
217fn 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
228/// Walk up the parent chain and collect context sections.
229///
230/// Returns sections in outermost-first order (grandparent before parent).
231/// Each parent body is capped at [`PARENT_CHAR_CAP`]; total ancestor
232/// context is capped at [`TOTAL_ANCESTOR_CHAR_CAP`].
233fn 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    // Reverse so grandparent appears before parent (outermost context first)
271    sections.reverse();
272    sections
273}
274
275/// Collect discovery notes from closed sibling units.
276///
277/// Reads siblings (children of the same parent) and extracts notes
278/// containing "discover" from closed siblings. Caps total context
279/// at [`DISCOVERY_CHAR_CAP`].
280fn 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    // Find closed siblings (same parent, not self)
286    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(&notes) {
317            continue;
318        }
319
320        let remaining = DISCOVERY_CHAR_CAP - total_chars;
321        let trimmed = truncate_text(&notes, 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
340/// Format the concurrent modification warning section.
341fn 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
361/// Assemble referenced file contents from the unit description.
362///
363/// Extracts file paths from the description text, reads their contents
364/// from the project directory, and assembles them into a markdown section.
365/// Files near priority keywords (modify, create, etc.) are listed first.
366/// Total content is capped at [`FILE_CONTENT_CHAR_CAP`].
367fn 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, // file doesn't exist
386        };
387
388        // Stay within project directory
389        if !canonical.starts_with(&canonical_base) {
390            continue;
391        }
392
393        // Skip directories
394        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
427/// Format the previous attempts section.
428fn format_previous_attempts(unit: &Unit) -> String {
429    let mut section = format!("# Previous Attempts ({} so far)", unit.attempts);
430
431    // Include unit notes
432    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    // Include per-attempt notes from attempt_log
440    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
471/// Format the approach section with numbered workflow.
472fn 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
490/// Format the verify gate section.
491fn 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
526/// Format the constraints section.
527fn 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
539/// Format the tool strategy section.
540fn 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
549// ---------------------------------------------------------------------------
550// Helpers
551// ---------------------------------------------------------------------------
552
553/// Truncate text to a character limit, appending an ellipsis if trimmed.
554fn 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
563/// Extract file paths from description text, prioritized by action keywords.
564///
565/// Paths on lines containing words like "modify", "create", "add" come first,
566/// followed by other referenced paths.
567fn 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
591/// Detect programming language from file extension for code fence tagging.
592fn 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
622/// Load a unit by ID, returning None on any error.
623fn 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// ---------------------------------------------------------------------------
629// Tests
630// ---------------------------------------------------------------------------
631
632#[cfg(test)]
633mod tests {
634    use super::*;
635    use crate::unit::{AttemptOutcome, AttemptRecord, Unit};
636    use std::fs;
637    use tempfile::TempDir;
638
639    /// Create a test environment with .mana/ directory and minimal config.
640    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    /// Write a unit to the .mana/ directory with standard naming.
653    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    // -- truncate_text --
660
661    #[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    // -- detect_language --
679
680    #[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    // -- extract_prioritized_paths --
705
706    #[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    // -- load_rules --
741
742    #[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    // -- collect_parent_context --
767
768    #[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        // Create parent unit
781        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        // Create child referencing parent
786        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        // Grandparent
802        let mut grandparent = Unit::new("1", "Grandparent");
803        grandparent.description = Some("Grand context.".to_string());
804        write_test_unit(&mana_dir, &grandparent);
805
806        // Parent
807        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        // Child
813        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        // Grandparent should appear first (reversed order)
819        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        // Create a parent with a very long description
828        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        // Body should be truncated
838        assert!(sections[0].contains("[…truncated]"));
839        // Total chars should respect PARENT_CHAR_CAP
840        let body_start = sections[0].find("\n\n").unwrap() + 2;
841        let body = &sections[0][body_start..];
842        // Truncated body should be roughly PARENT_CHAR_CAP + truncation marker
843        assert!(body.len() < PARENT_CHAR_CAP + 50);
844    }
845
846    // -- collect_sibling_discoveries --
847
848    #[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        // Create parent
861        let parent = Unit::new("1", "Parent");
862        write_test_unit(&mana_dir, &parent);
863
864        // Create closed sibling with discovery notes
865        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        // The unit under test
872        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        // Need to rebuild index
877        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        // Closed sibling without "discover" in notes
894        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        // Open sibling with discovery notes — should be skipped
918        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    // -- format_concurrent_warning --
935
936    #[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    // -- assemble_file_context --
970
971    #[test]
972    fn file_context_reads_existing_files() {
973        let dir = TempDir::new().unwrap();
974        let project_dir = dir.path();
975
976        // Create a source file
977        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        // Create a large file
1007        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        // Total content should be around FILE_CONTENT_CHAR_CAP
1015        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    // -- format_previous_attempts --
1026
1027    #[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    // -- format_approach --
1061
1062    #[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    // -- format_verify_gate --
1070
1071    #[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    // -- format_constraints --
1089
1090    #[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    // -- format_tool_strategy --
1098
1099    #[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    // -- build_agent_prompt integration --
1109
1110    #[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        // System prompt should contain key sections
1128        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        // Sections should be separated by ---
1138        assert!(result.system_prompt.contains("---"));
1139
1140        // User message should contain close instruction
1141        assert!(result
1142            .user_message
1143            .contains("configured runtime/close path for unit 1"));
1144
1145        // File ref should point to the unit file
1146        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        // Should not have pre-flight check
1268        assert!(!result.system_prompt.contains("Pre-flight Check"));
1269        // Verify gate should say no command
1270        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        // Create source files
1279        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        // Write rules
1307        fs::write(mana_dir.join("RULES.md"), "# Rules\nBe nice.").unwrap();
1308
1309        // Create parent
1310        let mut parent = Unit::new("1", "Parent");
1311        parent.description = Some("Parent goal.".to_string());
1312        write_test_unit(&mana_dir, &parent);
1313
1314        // Create source file
1315        let src = project_dir.join("src");
1316        fs::create_dir(&src).unwrap();
1317        fs::write(src.join("main.rs"), "fn main() {}").unwrap();
1318
1319        // Create child unit with all features
1320        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        // Verify section ordering by finding positions
1341        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}