1use 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
34pub struct PromptResult {
40 pub system_prompt: String,
42 pub user_message: String,
44 pub file_ref: String,
46}
47
48pub struct PromptOptions {
50 pub beans_dir: PathBuf,
52 pub instructions: Option<String>,
54 pub concurrent_overlaps: Option<Vec<FileOverlap>>,
56}
57
58pub struct FileOverlap {
60 pub bean_id: String,
62 pub title: String,
64 pub shared_files: Vec<String>,
66}
67
68const PARENT_CHAR_CAP: usize = 2000;
74
75const TOTAL_ANCESTOR_CHAR_CAP: usize = 3000;
77
78const DISCOVERY_CHAR_CAP: usize = 1500;
80
81const FILE_CONTENT_CHAR_CAP: usize = 8000;
83
84static DISCOVERY_PATTERN: LazyLock<Regex> =
86 LazyLock::new(|| Regex::new(r"(?i)discover").expect("Invalid discovery regex"));
87
88static PRIORITY_KEYWORDS: LazyLock<Regex> = LazyLock::new(|| {
90 Regex::new(r"(?i)\b(modify|create|add|edit|update|change|implement|write)\b")
91 .expect("Invalid priority keywords regex")
92});
93
94pub fn build_agent_prompt(bean: &Bean, options: &PromptOptions) -> Result<PromptResult> {
104 let beans_dir = &options.beans_dir;
105 let mut sections: Vec<String> = Vec::new();
106
107 if let Some(rules) = load_rules(beans_dir) {
109 sections.push(format!("# Project Rules\n\n{}", rules));
110 }
111
112 let parent_sections = collect_parent_context(bean, beans_dir);
114 for section in parent_sections {
115 sections.push(section);
116 }
117
118 if let Some(discoveries) = collect_sibling_discoveries(bean, beans_dir) {
120 sections.push(discoveries);
121 }
122
123 sections.push(format!(
125 "# Bean Assignment\n\nYou are implementing bean {}: {}",
126 bean.id, bean.title
127 ));
128
129 if let Some(ref overlaps) = options.concurrent_overlaps {
131 if !overlaps.is_empty() {
132 sections.push(format_concurrent_warning(overlaps));
133 }
134 }
135
136 let project_dir = 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 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 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 if bean.attempts > 0 {
164 sections.push(format_previous_attempts(bean));
165 }
166
167 sections.push(format_approach(&bean.id));
169
170 sections.push(format_verify_gate(bean));
172
173 sections.push(format_constraints(&bean.id));
175
176 sections.push(format_tool_strategy());
178
179 let system_prompt = sections.join("\n\n---\n\n");
181
182 let mut user_message = String::new();
184 if let Some(ref instructions) = options.instructions {
185 user_message.push_str(instructions);
186 user_message.push_str("\n\n");
187 }
188 user_message.push_str(&format!(
189 "implement this bean and run bn close {} when done",
190 bean.id
191 ));
192
193 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
205fn 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
221fn 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 sections.reverse();
265 sections
266}
267
268fn 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 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(¬es) {
310 continue;
311 }
312
313 let remaining = DISCOVERY_CHAR_CAP - total_chars;
314 let trimmed = truncate_text(¬es, remaining);
315
316 parts.push(format!(
317 "## From 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
333fn format_concurrent_warning(overlaps: &[FileOverlap]) -> String {
335 let mut lines = Vec::new();
336 for overlap in overlaps {
337 let files = overlap.shared_files.join(", ");
338 lines.push(format!(
339 "- 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
354fn assemble_file_context(description: &str, project_dir: &Path) -> Option<String> {
361 let paths = extract_prioritized_paths(description);
362 if paths.is_empty() {
363 return None;
364 }
365
366 let canonical_base = project_dir.canonicalize().ok()?;
367 let mut file_sections = Vec::new();
368 let mut total_chars: usize = 0;
369
370 for file_path in &paths {
371 if total_chars >= FILE_CONTENT_CHAR_CAP {
372 break;
373 }
374
375 let full_path = project_dir.join(file_path);
376 let canonical = match full_path.canonicalize() {
377 Ok(c) => c,
378 Err(_) => continue, };
380
381 if !canonical.starts_with(&canonical_base) {
383 continue;
384 }
385
386 if canonical.is_dir() {
388 continue;
389 }
390
391 let content = match read_file(&canonical) {
392 Ok(c) => c,
393 Err(_) => continue,
394 };
395
396 let remaining = FILE_CONTENT_CHAR_CAP - total_chars;
397 let content = if content.len() > remaining {
398 let mut truncated = content[..remaining].to_string();
399 truncated.push_str("\n\n[…truncated]");
400 truncated
401 } else {
402 content
403 };
404
405 let lang = detect_language(file_path);
406 file_sections.push(format!("## {}\n```{}\n{}\n```", file_path, lang, content));
407 total_chars += content.len();
408 }
409
410 if file_sections.is_empty() {
411 return None;
412 }
413
414 Some(format!(
415 "# Referenced Files\n\n{}",
416 file_sections.join("\n\n")
417 ))
418}
419
420fn format_previous_attempts(bean: &Bean) -> String {
422 let mut section = format!("# Previous Attempts ({} so far)", bean.attempts);
423
424 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 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
464fn 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
483fn 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
503fn 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
516fn 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
526fn 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
540fn 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
568fn 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
599fn 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#[cfg(test)]
610mod tests {
611 use super::*;
612 use crate::bean::{AttemptOutcome, AttemptRecord, Bean};
613 use std::fs;
614 use tempfile::TempDir;
615
616 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 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 #[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 #[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 #[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 #[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 #[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 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 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 let mut grandparent = Bean::new("1", "Grandparent");
780 grandparent.description = Some("Grand context.".to_string());
781 write_test_bean(&beans_dir, &grandparent);
782
783 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 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 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 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 assert!(sections[0].contains("[…truncated]"));
816 let body_start = sections[0].find("\n\n").unwrap() + 2;
818 let body = §ions[0][body_start..];
819 assert!(body.len() < PARENT_CHAR_CAP + 50);
821 }
822
823 #[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 let parent = Bean::new("1", "Parent");
839 write_test_bean(&beans_dir, &parent);
840
841 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 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 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 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 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 #[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 #[test]
949 fn file_context_reads_existing_files() {
950 let dir = TempDir::new().unwrap();
951 let project_dir = dir.path();
952
953 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 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 assert!(result.system_prompt.contains("---"));
1115
1116 assert!(result.user_message.contains("bn close 1"));
1118
1119 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 assert!(!result.system_prompt.contains("Pre-flight Check"));
1241 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 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 fs::write(beans_dir.join("RULES.md"), "# Rules\nBe nice.").unwrap();
1280
1281 let mut parent = Bean::new("1", "Parent");
1283 parent.description = Some("Parent goal.".to_string());
1284 write_test_bean(&beans_dir, &parent);
1285
1286 let src = project_dir.join("src");
1288 fs::create_dir(&src).unwrap();
1289 fs::write(src.join("main.rs"), "fn main() {}").unwrap();
1290
1291 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 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}