Skip to main content

bn/
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 bean 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. Bean 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::bean::{AttemptOutcome, Bean, Status};
29use crate::config::Config;
30use crate::ctx_assembler::{extract_paths, read_file};
31use crate::discovery::find_bean_file;
32use crate::index::Index;
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 bean 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 `.beans/` directory.
51    pub beans_dir: PathBuf,
52    /// Optional instructions to prepend to the user message.
53    pub instructions: Option<String>,
54    /// Beans running concurrently that share files with this bean.
55    pub concurrent_overlaps: Option<Vec<FileOverlap>>,
56}
57
58/// Describes a concurrent bean that overlaps on files.
59pub struct FileOverlap {
60    /// ID of the overlapping bean.
61    pub bean_id: String,
62    /// Title of the overlapping bean.
63    pub title: String,
64    /// File paths shared between the two beans.
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 bean 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 bean.
99///
100/// Returns a [`PromptResult`] containing the system prompt, user message,
101/// and bean file path. The system prompt is assembled from up to 13 sections
102/// that give the agent everything it needs to implement the bean.
103pub fn build_agent_prompt(bean: &Bean, options: &PromptOptions) -> Result<PromptResult> {
104    let beans_dir = &options.beans_dir;
105    let mut sections: Vec<String> = Vec::new();
106
107    // 1. Project Rules
108    if let Some(rules) = load_rules(beans_dir) {
109        sections.push(format!("# Project Rules\n\n{}", rules));
110    }
111
112    // 2. Parent Context
113    let parent_sections = collect_parent_context(bean, beans_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(bean, beans_dir) {
120        sections.push(discoveries);
121    }
122
123    // 4. Bean Assignment
124    sections.push(format!(
125        "# Bean Assignment\n\nYou are implementing bean {}: {}",
126        bean.id, bean.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 = beans_dir.parent().unwrap_or(Path::new("."));
138    let description = bean.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) = bean.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) = bean.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 bean.attempts > 0 {
164        sections.push(format_previous_attempts(bean));
165    }
166
167    // 10. Approach
168    sections.push(format_approach(&bean.id));
169
170    // 11. Verify Gate
171    sections.push(format_verify_gate(bean));
172
173    // 12. Constraints
174    sections.push(format_constraints(&bean.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 bean and run bn close {} when done",
190        bean.id
191    ));
192
193    // File reference
194    let file_ref = find_bean_file(beans_dir, &bean.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 `.beans/RULES.md` (or configured path).
210fn load_rules(beans_dir: &Path) -> Option<String> {
211    let config = Config::load(beans_dir).ok()?;
212    let rules_path = config.rules_path(beans_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(bean: &Bean, beans_dir: &Path) -> Vec<String> {
227    let Some(ref first_parent) = bean.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_bean(beans_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 (bean {}: {})\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 beans.
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(bean: &Bean, beans_dir: &Path) -> Option<String> {
274    let parent_id = bean.parent.as_ref()?;
275
276    let index = Index::load_or_rebuild(beans_dir).ok()?;
277
278    // Find closed siblings (same parent, not self)
279    let closed_siblings: Vec<_> = index
280        .beans
281        .iter()
282        .filter(|e| {
283            e.id != bean.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_bean = match load_bean(beans_dir, &sibling.id) {
300            Some(b) => b,
301            None => continue,
302        };
303
304        let notes = match sibling_bean.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 bean {} ({}):\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            "- Bean {} ({}) may also be modifying: {}",
340            overlap.bean_id, overlap.title, files
341        ));
342    }
343
344    format!(
345        "# Concurrent Modification Warning\n\n\
346         The following beans are running in parallel and share files with your bean:\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 bean 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(bean: &Bean) -> String {
422    let mut section = format!("# Previous Attempts ({} so far)", bean.attempts);
423
424    // Include bean notes
425    if let Some(ref notes) = bean.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 &bean.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(bean_id: &str) -> String {
466    format!(
467        "# Approach\n\n\
468         1. Read the bean 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: bn close {id}\n\
474         7. After closing, share what you learned:\n   \
475            bn update {id} --note \"Discoveries: <brief notes about patterns, conventions, \
476            or gotchas you found that might help sibling beans>\"\n\
477         8. If verify fails, fix and retry\n\
478         9. If stuck after 3 attempts, run: bn update {id} --note \"Stuck: <explanation>\"",
479        id = bean_id
480    )
481}
482
483/// Format the verify gate section.
484fn format_verify_gate(bean: &Bean) -> String {
485    if let Some(ref verify) = bean.verify {
486        format!(
487            "# Verify Gate\n\n\
488             Your verify command is:\n\
489             ```\n{}\n```\n\
490             This MUST exit 0 for the bean to close. Test it before declaring done.",
491            verify
492        )
493    } else {
494        format!(
495            "# Verify Gate\n\n\
496             No verify command is set for this bean.\n\
497             When all acceptance criteria are met, run: bn close {}",
498            bean.id
499        )
500    }
501}
502
503/// Format the constraints section.
504fn format_constraints(bean_id: &str) -> String {
505    format!(
506        "# Constraints\n\n\
507         - Only modify files mentioned in the description unless clearly necessary\n\
508         - Don't add dependencies without justification\n\
509         - Preserve existing tests\n\
510         - Run the project's test/build commands before closing\n\
511         - When complete, run: bn close {}",
512        bean_id
513    )
514}
515
516/// Format the tool strategy section.
517fn format_tool_strategy() -> String {
518    "# Tool Strategy\n\n\
519     - Use probe_search for semantic code search, rg for exact text matching\n\
520     - Read files before editing — never edit blind\n\
521     - Use Edit for surgical changes, Write for new files\n\
522     - Use Bash to run tests and verify commands"
523        .to_string()
524}
525
526// ---------------------------------------------------------------------------
527// Helpers
528// ---------------------------------------------------------------------------
529
530/// Truncate text to a character limit, appending an ellipsis if trimmed.
531fn truncate_text(text: &str, limit: usize) -> String {
532    if text.len() <= limit {
533        return text.to_string();
534    }
535    let mut result = text[..limit].to_string();
536    result.push_str("\n\n[…truncated]");
537    result
538}
539
540/// Extract file paths from description text, prioritized by action keywords.
541///
542/// Paths on lines containing words like "modify", "create", "add" come first,
543/// followed by other referenced paths.
544fn extract_prioritized_paths(description: &str) -> Vec<String> {
545    let mut seen = std::collections::HashSet::new();
546    let mut prioritized = Vec::new();
547    let mut normal = Vec::new();
548
549    for line in description.lines() {
550        let line_paths = extract_paths(line);
551        let is_priority = PRIORITY_KEYWORDS.is_match(line);
552
553        for p in line_paths {
554            if seen.insert(p.clone()) {
555                if is_priority {
556                    prioritized.push(p);
557                } else {
558                    normal.push(p);
559                }
560            }
561        }
562    }
563
564    prioritized.extend(normal);
565    prioritized
566}
567
568/// Detect programming language from file extension for code fence tagging.
569fn detect_language(path: &str) -> &'static str {
570    match Path::new(path).extension().and_then(|e| e.to_str()) {
571        Some("rs") => "rust",
572        Some("ts") => "typescript",
573        Some("tsx") => "typescript",
574        Some("js") => "javascript",
575        Some("jsx") => "javascript",
576        Some("py") => "python",
577        Some("md") => "markdown",
578        Some("json") => "json",
579        Some("toml") => "toml",
580        Some("yaml") | Some("yml") => "yaml",
581        Some("sh") => "bash",
582        Some("go") => "go",
583        Some("java") => "java",
584        Some("css") => "css",
585        Some("html") => "html",
586        Some("sql") => "sql",
587        Some("c") => "c",
588        Some("cpp") => "cpp",
589        Some("h") => "c",
590        Some("hpp") => "cpp",
591        Some("rb") => "ruby",
592        Some("php") => "php",
593        Some("swift") => "swift",
594        Some("kt") => "kotlin",
595        _ => "",
596    }
597}
598
599/// Load a bean by ID, returning None on any error.
600fn load_bean(beans_dir: &Path, id: &str) -> Option<Bean> {
601    let path = find_bean_file(beans_dir, id).ok()?;
602    Bean::from_file(&path).ok()
603}
604
605// ---------------------------------------------------------------------------
606// Tests
607// ---------------------------------------------------------------------------
608
609#[cfg(test)]
610mod tests {
611    use super::*;
612    use crate::bean::{AttemptOutcome, AttemptRecord, Bean};
613    use std::fs;
614    use tempfile::TempDir;
615
616    /// Create a test environment with .beans/ directory and minimal config.
617    fn setup_test_env() -> (TempDir, PathBuf) {
618        let dir = TempDir::new().unwrap();
619        let beans_dir = dir.path().join(".beans");
620        fs::create_dir(&beans_dir).unwrap();
621        fs::write(
622            beans_dir.join("config.yaml"),
623            "project: test\nnext_id: 100\n",
624        )
625        .unwrap();
626        (dir, beans_dir)
627    }
628
629    /// Write a bean to the .beans/ directory with standard naming.
630    fn write_test_bean(beans_dir: &Path, bean: &Bean) {
631        let slug = crate::util::title_to_slug(&bean.title);
632        let path = beans_dir.join(format!("{}-{}.md", bean.id, slug));
633        bean.to_file(&path).unwrap();
634    }
635
636    // -- truncate_text --
637
638    #[test]
639    fn truncate_text_short() {
640        assert_eq!(truncate_text("hello", 100), "hello");
641    }
642
643    #[test]
644    fn truncate_text_at_limit() {
645        assert_eq!(truncate_text("hello", 5), "hello");
646    }
647
648    #[test]
649    fn truncate_text_over_limit() {
650        let result = truncate_text("hello world", 5);
651        assert!(result.starts_with("hello"));
652        assert!(result.contains("[…truncated]"));
653    }
654
655    // -- detect_language --
656
657    #[test]
658    fn detect_language_known_extensions() {
659        assert_eq!(detect_language("src/main.rs"), "rust");
660        assert_eq!(detect_language("index.ts"), "typescript");
661        assert_eq!(detect_language("app.tsx"), "typescript");
662        assert_eq!(detect_language("script.py"), "python");
663        assert_eq!(detect_language("config.json"), "json");
664        assert_eq!(detect_language("Cargo.toml"), "toml");
665        assert_eq!(detect_language("config.yaml"), "yaml");
666        assert_eq!(detect_language("config.yml"), "yaml");
667        assert_eq!(detect_language("deploy.sh"), "bash");
668        assert_eq!(detect_language("main.go"), "go");
669        assert_eq!(detect_language("Main.java"), "java");
670        assert_eq!(detect_language("style.css"), "css");
671        assert_eq!(detect_language("page.html"), "html");
672        assert_eq!(detect_language("query.sql"), "sql");
673    }
674
675    #[test]
676    fn detect_language_unknown_extension() {
677        assert_eq!(detect_language("file.xyz"), "");
678        assert_eq!(detect_language("Makefile"), "");
679    }
680
681    // -- extract_prioritized_paths --
682
683    #[test]
684    fn prioritized_paths_modify_first() {
685        let desc = "Read src/lib.rs for context\nModify src/main.rs to add feature";
686        let paths = extract_prioritized_paths(desc);
687        assert_eq!(paths, vec!["src/main.rs", "src/lib.rs"]);
688    }
689
690    #[test]
691    fn prioritized_paths_create_first() {
692        let desc = "Check src/old.rs\nCreate src/new.rs with the new module";
693        let paths = extract_prioritized_paths(desc);
694        assert_eq!(paths, vec!["src/new.rs", "src/old.rs"]);
695    }
696
697    #[test]
698    fn prioritized_paths_deduplicates() {
699        let desc = "Modify src/main.rs\nAlso read src/main.rs for context";
700        let paths = extract_prioritized_paths(desc);
701        assert_eq!(paths, vec!["src/main.rs"]);
702    }
703
704    #[test]
705    fn prioritized_paths_no_keywords() {
706        let desc = "See src/foo.rs and src/bar.rs";
707        let paths = extract_prioritized_paths(desc);
708        assert_eq!(paths, vec!["src/foo.rs", "src/bar.rs"]);
709    }
710
711    #[test]
712    fn prioritized_paths_empty() {
713        let paths = extract_prioritized_paths("No files here");
714        assert!(paths.is_empty());
715    }
716
717    // -- load_rules --
718
719    #[test]
720    fn load_rules_returns_none_when_missing() {
721        let (_dir, beans_dir) = setup_test_env();
722        let result = load_rules(&beans_dir);
723        assert!(result.is_none());
724    }
725
726    #[test]
727    fn load_rules_returns_none_when_empty() {
728        let (_dir, beans_dir) = setup_test_env();
729        fs::write(beans_dir.join("RULES.md"), "   \n  ").unwrap();
730        let result = load_rules(&beans_dir);
731        assert!(result.is_none());
732    }
733
734    #[test]
735    fn load_rules_returns_content() {
736        let (_dir, beans_dir) = setup_test_env();
737        fs::write(beans_dir.join("RULES.md"), "# Rules\nNo unwrap.\n").unwrap();
738        let result = load_rules(&beans_dir);
739        assert!(result.is_some());
740        assert!(result.unwrap().contains("No unwrap."));
741    }
742
743    // -- collect_parent_context --
744
745    #[test]
746    fn parent_context_no_parent() {
747        let (_dir, beans_dir) = setup_test_env();
748        let bean = Bean::new("1", "No parent");
749        let sections = collect_parent_context(&bean, &beans_dir);
750        assert!(sections.is_empty());
751    }
752
753    #[test]
754    fn parent_context_single_parent() {
755        let (_dir, beans_dir) = setup_test_env();
756
757        // Create parent bean
758        let mut parent = Bean::new("1", "Parent Task");
759        parent.description = Some("This is the parent goal.".to_string());
760        write_test_bean(&beans_dir, &parent);
761
762        // Create child referencing parent
763        let mut child = Bean::new("1.1", "Child Task");
764        child.parent = Some("1".to_string());
765        write_test_bean(&beans_dir, &child);
766
767        let sections = collect_parent_context(&child, &beans_dir);
768        assert_eq!(sections.len(), 1);
769        assert!(sections[0].contains("Parent Context"));
770        assert!(sections[0].contains("bean 1: Parent Task"));
771        assert!(sections[0].contains("parent goal"));
772    }
773
774    #[test]
775    fn parent_context_grandparent_appears_first() {
776        let (_dir, beans_dir) = setup_test_env();
777
778        // Grandparent
779        let mut grandparent = Bean::new("1", "Grandparent");
780        grandparent.description = Some("Grand context.".to_string());
781        write_test_bean(&beans_dir, &grandparent);
782
783        // Parent
784        let mut parent = Bean::new("1.1", "Parent");
785        parent.parent = Some("1".to_string());
786        parent.description = Some("Parent context.".to_string());
787        write_test_bean(&beans_dir, &parent);
788
789        // Child
790        let mut child = Bean::new("1.1.1", "Child");
791        child.parent = Some("1.1".to_string());
792
793        let sections = collect_parent_context(&child, &beans_dir);
794        assert_eq!(sections.len(), 2);
795        // Grandparent should appear first (reversed order)
796        assert!(sections[0].contains("Grandparent"));
797        assert!(sections[1].contains("Parent"));
798    }
799
800    #[test]
801    fn parent_context_caps_total_chars() {
802        let (_dir, beans_dir) = setup_test_env();
803
804        // Create a parent with a very long description
805        let mut parent = Bean::new("1", "Verbose Parent");
806        parent.description = Some("x".repeat(5000));
807        write_test_bean(&beans_dir, &parent);
808
809        let mut child = Bean::new("1.1", "Child");
810        child.parent = Some("1".to_string());
811
812        let sections = collect_parent_context(&child, &beans_dir);
813        assert_eq!(sections.len(), 1);
814        // Body should be truncated
815        assert!(sections[0].contains("[…truncated]"));
816        // Total chars should respect PARENT_CHAR_CAP
817        let body_start = sections[0].find("\n\n").unwrap() + 2;
818        let body = &sections[0][body_start..];
819        // Truncated body should be roughly PARENT_CHAR_CAP + truncation marker
820        assert!(body.len() < PARENT_CHAR_CAP + 50);
821    }
822
823    // -- collect_sibling_discoveries --
824
825    #[test]
826    fn sibling_discoveries_no_parent() {
827        let (_dir, beans_dir) = setup_test_env();
828        let bean = Bean::new("1", "No parent");
829        let result = collect_sibling_discoveries(&bean, &beans_dir);
830        assert!(result.is_none());
831    }
832
833    #[test]
834    fn sibling_discoveries_finds_closed_with_discover() {
835        let (_dir, beans_dir) = setup_test_env();
836
837        // Create parent
838        let parent = Bean::new("1", "Parent");
839        write_test_bean(&beans_dir, &parent);
840
841        // Create closed sibling with discovery notes
842        let mut sibling = Bean::new("1.1", "Sibling A");
843        sibling.parent = Some("1".to_string());
844        sibling.status = Status::Closed;
845        sibling.notes = Some("Discoveries: the API uses snake_case".to_string());
846        write_test_bean(&beans_dir, &sibling);
847
848        // The bean under test
849        let mut bean = Bean::new("1.2", "Current Bean");
850        bean.parent = Some("1".to_string());
851        write_test_bean(&beans_dir, &bean);
852
853        // Need to rebuild index
854        let _ = Index::build(&beans_dir).unwrap().save(&beans_dir);
855
856        let result = collect_sibling_discoveries(&bean, &beans_dir);
857        assert!(result.is_some());
858        let text = result.unwrap();
859        assert!(text.contains("Discoveries from completed siblings"));
860        assert!(text.contains("snake_case"));
861    }
862
863    #[test]
864    fn sibling_discoveries_skips_non_discover_notes() {
865        let (_dir, beans_dir) = setup_test_env();
866
867        let parent = Bean::new("1", "Parent");
868        write_test_bean(&beans_dir, &parent);
869
870        // Closed sibling without "discover" in notes
871        let mut sibling = Bean::new("1.1", "Sibling");
872        sibling.parent = Some("1".to_string());
873        sibling.status = Status::Closed;
874        sibling.notes = Some("Just regular notes about the task".to_string());
875        write_test_bean(&beans_dir, &sibling);
876
877        let mut bean = Bean::new("1.2", "Current");
878        bean.parent = Some("1".to_string());
879        write_test_bean(&beans_dir, &bean);
880
881        let _ = Index::build(&beans_dir).unwrap().save(&beans_dir);
882
883        let result = collect_sibling_discoveries(&bean, &beans_dir);
884        assert!(result.is_none());
885    }
886
887    #[test]
888    fn sibling_discoveries_skips_open_siblings() {
889        let (_dir, beans_dir) = setup_test_env();
890
891        let parent = Bean::new("1", "Parent");
892        write_test_bean(&beans_dir, &parent);
893
894        // Open sibling with discovery notes — should be skipped
895        let mut sibling = Bean::new("1.1", "Open Sibling");
896        sibling.parent = Some("1".to_string());
897        sibling.status = Status::Open;
898        sibling.notes = Some("Discoveries: something useful".to_string());
899        write_test_bean(&beans_dir, &sibling);
900
901        let mut bean = Bean::new("1.2", "Current");
902        bean.parent = Some("1".to_string());
903        write_test_bean(&beans_dir, &bean);
904
905        let _ = Index::build(&beans_dir).unwrap().save(&beans_dir);
906
907        let result = collect_sibling_discoveries(&bean, &beans_dir);
908        assert!(result.is_none());
909    }
910
911    // -- format_concurrent_warning --
912
913    #[test]
914    fn concurrent_warning_single_overlap() {
915        let overlaps = vec![FileOverlap {
916            bean_id: "5".to_string(),
917            title: "Other Task".to_string(),
918            shared_files: vec!["src/main.rs".to_string()],
919        }];
920        let result = format_concurrent_warning(&overlaps);
921        assert!(result.contains("Concurrent Modification Warning"));
922        assert!(result.contains("Bean 5 (Other Task)"));
923        assert!(result.contains("src/main.rs"));
924    }
925
926    #[test]
927    fn concurrent_warning_multiple_overlaps() {
928        let overlaps = vec![
929            FileOverlap {
930                bean_id: "5".to_string(),
931                title: "Task A".to_string(),
932                shared_files: vec!["src/a.rs".to_string(), "src/b.rs".to_string()],
933            },
934            FileOverlap {
935                bean_id: "6".to_string(),
936                title: "Task B".to_string(),
937                shared_files: vec!["src/c.rs".to_string()],
938            },
939        ];
940        let result = format_concurrent_warning(&overlaps);
941        assert!(result.contains("Bean 5"));
942        assert!(result.contains("Bean 6"));
943        assert!(result.contains("src/a.rs, src/b.rs"));
944    }
945
946    // -- assemble_file_context --
947
948    #[test]
949    fn file_context_reads_existing_files() {
950        let dir = TempDir::new().unwrap();
951        let project_dir = dir.path();
952
953        // Create a source file
954        let src = project_dir.join("src");
955        fs::create_dir(&src).unwrap();
956        fs::write(src.join("main.rs"), "fn main() {}").unwrap();
957
958        let desc = "Modify src/main.rs to add feature";
959        let result = assemble_file_context(desc, project_dir);
960        assert!(result.is_some());
961        let text = result.unwrap();
962        assert!(text.contains("# Referenced Files"));
963        assert!(text.contains("## src/main.rs"));
964        assert!(text.contains("```rust"));
965        assert!(text.contains("fn main() {}"));
966    }
967
968    #[test]
969    fn file_context_skips_missing_files() {
970        let dir = TempDir::new().unwrap();
971        let desc = "Read src/nonexistent.rs";
972        let result = assemble_file_context(desc, dir.path());
973        assert!(result.is_none());
974    }
975
976    #[test]
977    fn file_context_caps_total_chars() {
978        let dir = TempDir::new().unwrap();
979        let project_dir = dir.path();
980        let src = project_dir.join("src");
981        fs::create_dir(&src).unwrap();
982
983        // Create a large file
984        fs::write(src.join("big.rs"), "x".repeat(20000)).unwrap();
985
986        let desc = "Read src/big.rs";
987        let result = assemble_file_context(desc, project_dir);
988        assert!(result.is_some());
989        let text = result.unwrap();
990        assert!(text.contains("[…truncated]"));
991        // Total content should be around FILE_CONTENT_CHAR_CAP
992        assert!(text.len() < FILE_CONTENT_CHAR_CAP + 500);
993    }
994
995    #[test]
996    fn file_context_no_paths() {
997        let dir = TempDir::new().unwrap();
998        let result = assemble_file_context("No file paths here", dir.path());
999        assert!(result.is_none());
1000    }
1001
1002    // -- format_previous_attempts --
1003
1004    #[test]
1005    fn previous_attempts_with_notes() {
1006        let mut bean = Bean::new("1", "Test");
1007        bean.attempts = 2;
1008        bean.notes = Some("Tried approach X, it broke Y.".to_string());
1009        bean.attempt_log = vec![AttemptRecord {
1010            num: 1,
1011            outcome: AttemptOutcome::Failed,
1012            notes: Some("First try failed due to Z".to_string()),
1013            agent: Some("agent-1".to_string()),
1014            started_at: None,
1015            finished_at: None,
1016        }];
1017
1018        let result = format_previous_attempts(&bean);
1019        assert!(result.contains("Previous Attempts (2 so far)"));
1020        assert!(result.contains("Tried approach X"));
1021        assert!(result.contains("Attempt #1 (agent-1) [failed]"));
1022        assert!(result.contains("First try failed"));
1023        assert!(result.contains("Do NOT repeat"));
1024    }
1025
1026    #[test]
1027    fn previous_attempts_no_notes() {
1028        let mut bean = Bean::new("1", "Test");
1029        bean.attempts = 1;
1030
1031        let result = format_previous_attempts(&bean);
1032        assert!(result.contains("Previous Attempts (1 so far)"));
1033        assert!(result.contains("Do NOT repeat"));
1034    }
1035
1036    // -- format_approach --
1037
1038    #[test]
1039    fn approach_contains_bean_id() {
1040        let result = format_approach("42");
1041        assert!(result.contains("bn close 42"));
1042        assert!(result.contains("bn update 42"));
1043    }
1044
1045    // -- format_verify_gate --
1046
1047    #[test]
1048    fn verify_gate_with_command() {
1049        let mut bean = Bean::new("1", "Test");
1050        bean.verify = Some("cargo test".to_string());
1051        let result = format_verify_gate(&bean);
1052        assert!(result.contains("cargo test"));
1053        assert!(result.contains("MUST exit 0"));
1054    }
1055
1056    #[test]
1057    fn verify_gate_without_command() {
1058        let bean = Bean::new("1", "Test");
1059        let result = format_verify_gate(&bean);
1060        assert!(result.contains("No verify command"));
1061        assert!(result.contains("bn close 1"));
1062    }
1063
1064    // -- format_constraints --
1065
1066    #[test]
1067    fn constraints_contains_bean_id() {
1068        let result = format_constraints("7");
1069        assert!(result.contains("bn close 7"));
1070        assert!(result.contains("Don't add dependencies"));
1071    }
1072
1073    // -- format_tool_strategy --
1074
1075    #[test]
1076    fn tool_strategy_mentions_key_tools() {
1077        let result = format_tool_strategy();
1078        assert!(result.contains("probe_search"));
1079        assert!(result.contains("rg"));
1080        assert!(result.contains("Edit"));
1081        assert!(result.contains("Write"));
1082    }
1083
1084    // -- build_agent_prompt integration --
1085
1086    #[test]
1087    fn build_prompt_minimal_bean() {
1088        let (_dir, beans_dir) = setup_test_env();
1089
1090        let mut bean = Bean::new("1", "Simple Task");
1091        bean.description = Some("Just do the thing.".to_string());
1092        bean.verify = Some("cargo test".to_string());
1093        write_test_bean(&beans_dir, &bean);
1094
1095        let options = PromptOptions {
1096            beans_dir: beans_dir.clone(),
1097            instructions: None,
1098            concurrent_overlaps: None,
1099        };
1100
1101        let result = build_agent_prompt(&bean, &options).unwrap();
1102
1103        // System prompt should contain key sections
1104        assert!(result.system_prompt.contains("Bean Assignment"));
1105        assert!(result.system_prompt.contains("bean 1: Simple Task"));
1106        assert!(result.system_prompt.contains("Pre-flight Check"));
1107        assert!(result.system_prompt.contains("cargo test"));
1108        assert!(result.system_prompt.contains("Verify Gate"));
1109        assert!(result.system_prompt.contains("Approach"));
1110        assert!(result.system_prompt.contains("Constraints"));
1111        assert!(result.system_prompt.contains("Tool Strategy"));
1112
1113        // Sections should be separated by ---
1114        assert!(result.system_prompt.contains("---"));
1115
1116        // User message should contain close instruction
1117        assert!(result.user_message.contains("bn close 1"));
1118
1119        // File ref should point to the bean file
1120        assert!(result.file_ref.contains("1-simple-task.md"));
1121    }
1122
1123    #[test]
1124    fn build_prompt_with_instructions() {
1125        let (_dir, beans_dir) = setup_test_env();
1126
1127        let bean = Bean::new("1", "Task");
1128        write_test_bean(&beans_dir, &bean);
1129
1130        let options = PromptOptions {
1131            beans_dir: beans_dir.clone(),
1132            instructions: Some("Focus on performance".to_string()),
1133            concurrent_overlaps: None,
1134        };
1135
1136        let result = build_agent_prompt(&bean, &options).unwrap();
1137        assert!(result.user_message.starts_with("Focus on performance"));
1138        assert!(result.user_message.contains("bn close 1"));
1139    }
1140
1141    #[test]
1142    fn build_prompt_with_rules() {
1143        let (_dir, beans_dir) = setup_test_env();
1144        fs::write(beans_dir.join("RULES.md"), "# Style\nUse snake_case.\n").unwrap();
1145
1146        let bean = Bean::new("1", "Task");
1147        write_test_bean(&beans_dir, &bean);
1148
1149        let options = PromptOptions {
1150            beans_dir: beans_dir.clone(),
1151            instructions: None,
1152            concurrent_overlaps: None,
1153        };
1154
1155        let result = build_agent_prompt(&bean, &options).unwrap();
1156        assert!(result.system_prompt.contains("Project Rules"));
1157        assert!(result.system_prompt.contains("snake_case"));
1158    }
1159
1160    #[test]
1161    fn build_prompt_with_acceptance_criteria() {
1162        let (_dir, beans_dir) = setup_test_env();
1163
1164        let mut bean = Bean::new("1", "Task");
1165        bean.acceptance = Some("All tests pass\nNo warnings".to_string());
1166        write_test_bean(&beans_dir, &bean);
1167
1168        let options = PromptOptions {
1169            beans_dir: beans_dir.clone(),
1170            instructions: None,
1171            concurrent_overlaps: None,
1172        };
1173
1174        let result = build_agent_prompt(&bean, &options).unwrap();
1175        assert!(result.system_prompt.contains("Acceptance Criteria"));
1176        assert!(result.system_prompt.contains("All tests pass"));
1177        assert!(result.system_prompt.contains("No warnings"));
1178    }
1179
1180    #[test]
1181    fn build_prompt_with_concurrent_overlaps() {
1182        let (_dir, beans_dir) = setup_test_env();
1183
1184        let bean = Bean::new("1", "Task");
1185        write_test_bean(&beans_dir, &bean);
1186
1187        let options = PromptOptions {
1188            beans_dir: beans_dir.clone(),
1189            instructions: None,
1190            concurrent_overlaps: Some(vec![FileOverlap {
1191                bean_id: "2".to_string(),
1192                title: "Other".to_string(),
1193                shared_files: vec!["src/shared.rs".to_string()],
1194            }]),
1195        };
1196
1197        let result = build_agent_prompt(&bean, &options).unwrap();
1198        assert!(result
1199            .system_prompt
1200            .contains("Concurrent Modification Warning"));
1201        assert!(result.system_prompt.contains("Bean 2 (Other)"));
1202    }
1203
1204    #[test]
1205    fn build_prompt_with_previous_attempts() {
1206        let (_dir, beans_dir) = setup_test_env();
1207
1208        let mut bean = Bean::new("1", "Retry Task");
1209        bean.attempts = 2;
1210        bean.notes = Some("Tried X, failed due to Y.".to_string());
1211        write_test_bean(&beans_dir, &bean);
1212
1213        let options = PromptOptions {
1214            beans_dir: beans_dir.clone(),
1215            instructions: None,
1216            concurrent_overlaps: None,
1217        };
1218
1219        let result = build_agent_prompt(&bean, &options).unwrap();
1220        assert!(result.system_prompt.contains("Previous Attempts"));
1221        assert!(result.system_prompt.contains("Tried X"));
1222        assert!(result.system_prompt.contains("Do NOT repeat"));
1223    }
1224
1225    #[test]
1226    fn build_prompt_no_verify() {
1227        let (_dir, beans_dir) = setup_test_env();
1228
1229        let bean = Bean::new("1", "No Verify");
1230        write_test_bean(&beans_dir, &bean);
1231
1232        let options = PromptOptions {
1233            beans_dir: beans_dir.clone(),
1234            instructions: None,
1235            concurrent_overlaps: None,
1236        };
1237
1238        let result = build_agent_prompt(&bean, &options).unwrap();
1239        // Should not have pre-flight check
1240        assert!(!result.system_prompt.contains("Pre-flight Check"));
1241        // Verify gate should say no command
1242        assert!(result.system_prompt.contains("No verify command"));
1243    }
1244
1245    #[test]
1246    fn build_prompt_with_file_references() {
1247        let (dir, beans_dir) = setup_test_env();
1248        let project_dir = dir.path();
1249
1250        // Create source files
1251        let src = project_dir.join("src");
1252        fs::create_dir(&src).unwrap();
1253        fs::write(src.join("lib.rs"), "pub mod utils;").unwrap();
1254        fs::write(src.join("utils.rs"), "pub fn helper() {}").unwrap();
1255
1256        let mut bean = Bean::new("1", "Task");
1257        bean.description =
1258            Some("Modify src/lib.rs to export new module\nRead src/utils.rs".to_string());
1259        write_test_bean(&beans_dir, &bean);
1260
1261        let options = PromptOptions {
1262            beans_dir: beans_dir.clone(),
1263            instructions: None,
1264            concurrent_overlaps: None,
1265        };
1266
1267        let result = build_agent_prompt(&bean, &options).unwrap();
1268        assert!(result.system_prompt.contains("Referenced Files"));
1269        assert!(result.system_prompt.contains("src/lib.rs"));
1270        assert!(result.system_prompt.contains("pub mod utils;"));
1271    }
1272
1273    #[test]
1274    fn build_prompt_section_order() {
1275        let (dir, beans_dir) = setup_test_env();
1276        let project_dir = dir.path();
1277
1278        // Write rules
1279        fs::write(beans_dir.join("RULES.md"), "# Rules\nBe nice.").unwrap();
1280
1281        // Create parent
1282        let mut parent = Bean::new("1", "Parent");
1283        parent.description = Some("Parent goal.".to_string());
1284        write_test_bean(&beans_dir, &parent);
1285
1286        // Create source file
1287        let src = project_dir.join("src");
1288        fs::create_dir(&src).unwrap();
1289        fs::write(src.join("main.rs"), "fn main() {}").unwrap();
1290
1291        // Create child bean with all features
1292        let mut bean = Bean::new("1.1", "Child Task");
1293        bean.parent = Some("1".to_string());
1294        bean.description = Some("Modify src/main.rs".to_string());
1295        bean.acceptance = Some("Tests pass".to_string());
1296        bean.verify = Some("cargo test".to_string());
1297        bean.attempts = 1;
1298        bean.notes = Some("Tried something".to_string());
1299        write_test_bean(&beans_dir, &bean);
1300
1301        let _ = Index::build(&beans_dir).unwrap().save(&beans_dir);
1302
1303        let options = PromptOptions {
1304            beans_dir: beans_dir.clone(),
1305            instructions: None,
1306            concurrent_overlaps: None,
1307        };
1308
1309        let result = build_agent_prompt(&bean, &options).unwrap();
1310        let prompt = &result.system_prompt;
1311
1312        // Verify section ordering by finding positions
1313        let rules_pos = prompt.find("# Project Rules").unwrap();
1314        let parent_pos = prompt.find("# Parent Context").unwrap();
1315        let assignment_pos = prompt.find("# Bean Assignment").unwrap();
1316        let files_pos = prompt.find("# Referenced Files").unwrap();
1317        let acceptance_pos = prompt.find("# Acceptance Criteria").unwrap();
1318        let preflight_pos = prompt.find("# Pre-flight Check").unwrap();
1319        let attempts_pos = prompt.find("# Previous Attempts").unwrap();
1320        let approach_pos = prompt.find("# Approach").unwrap();
1321        let verify_pos = prompt.find("# Verify Gate").unwrap();
1322        let constraints_pos = prompt.find("# Constraints").unwrap();
1323        let tools_pos = prompt.find("# Tool Strategy").unwrap();
1324
1325        assert!(rules_pos < parent_pos, "Rules before Parent");
1326        assert!(parent_pos < assignment_pos, "Parent before Assignment");
1327        assert!(assignment_pos < files_pos, "Assignment before Files");
1328        assert!(files_pos < acceptance_pos, "Files before Acceptance");
1329        assert!(
1330            acceptance_pos < preflight_pos,
1331            "Acceptance before Preflight"
1332        );
1333        assert!(preflight_pos < attempts_pos, "Preflight before Attempts");
1334        assert!(attempts_pos < approach_pos, "Attempts before Approach");
1335        assert!(approach_pos < verify_pos, "Approach before Verify");
1336        assert!(verify_pos < constraints_pos, "Verify before Constraints");
1337        assert!(constraints_pos < tools_pos, "Constraints before Tools");
1338    }
1339}