Skip to main content

bn/commands/
context.rs

1use std::collections::HashSet;
2use std::path::Path;
3
4use anyhow::{Context, Result};
5
6use crate::bean::{AttemptOutcome, Bean};
7use crate::config::Config;
8use crate::ctx_assembler::{assemble_context, extract_paths, read_file};
9use crate::discovery::find_bean_file;
10use crate::index::Index;
11use crate::prompt::{build_agent_prompt, PromptOptions};
12
13/// Load project rules from the configured rules file.
14///
15/// Returns `None` if the file doesn't exist or is empty.
16/// Warns to stderr if the file is very large (>1000 lines).
17fn load_rules(beans_dir: &Path) -> Option<String> {
18    let config = Config::load(beans_dir).ok()?;
19    let rules_path = config.rules_path(beans_dir);
20
21    let content = std::fs::read_to_string(&rules_path).ok()?;
22    let trimmed = content.trim();
23
24    if trimmed.is_empty() {
25        return None;
26    }
27
28    let line_count = content.lines().count();
29    if line_count > 1000 {
30        eprintln!(
31            "Warning: RULES.md is very large ({} lines). Consider trimming it.",
32            line_count
33        );
34    }
35
36    Some(content)
37}
38
39/// Format rules content with delimiters for agent context injection.
40fn format_rules_section(rules: &str) -> String {
41    format!(
42        "═══ PROJECT RULES ═══════════════════════════════════════════\n\
43         {}\n\
44         ═════════════════════════════════════════════════════════════\n\n",
45        rules.trim_end()
46    )
47}
48
49/// Format the attempt_log and notes field into a "Previous Attempts" section.
50///
51/// Returns `None` if there are no attempt notes and no bean notes — callers
52/// should skip output entirely in that case (no noise).
53fn format_attempt_notes_section(bean: &Bean) -> Option<String> {
54    let mut parts: Vec<String> = Vec::new();
55
56    // Accumulated notes written via `bn update --note`
57    if let Some(ref notes) = bean.notes {
58        let trimmed = notes.trim();
59        if !trimmed.is_empty() {
60            parts.push(format!("Bean notes:\n{}", trimmed));
61        }
62    }
63
64    // Per-attempt notes from the attempt_log
65    let attempt_entries: Vec<String> = bean
66        .attempt_log
67        .iter()
68        .filter_map(|a| {
69            let notes = a.notes.as_deref()?.trim();
70            if notes.is_empty() {
71                return None;
72            }
73            let outcome = match a.outcome {
74                AttemptOutcome::Success => "success",
75                AttemptOutcome::Failed => "failed",
76                AttemptOutcome::Abandoned => "abandoned",
77            };
78            let agent_str = a
79                .agent
80                .as_deref()
81                .map(|ag| format!(" ({})", ag))
82                .unwrap_or_default();
83            Some(format!(
84                "Attempt #{}{} [{}]: {}",
85                a.num, agent_str, outcome, notes
86            ))
87        })
88        .collect();
89
90    if !attempt_entries.is_empty() {
91        parts.push(attempt_entries.join("\n"));
92    }
93
94    if parts.is_empty() {
95        return None;
96    }
97
98    Some(format!(
99        "═══ Previous Attempts ════════════════════════════════════════\n\
100         {}\n\
101         ══════════════════════════════════════════════════════════════\n\n",
102        parts.join("\n\n").trim_end()
103    ))
104}
105
106// ─── Structure extraction ────────────────────────────────────────────────────
107
108/// Extract function/type signatures and imports from Rust source.
109///
110/// Matches top-level declarations: `use`, `fn`, `struct`, `enum`, `trait`,
111/// `impl`, `type`, and `const`. Strips trailing `{` from signature lines.
112fn extract_rust_structure(content: &str) -> Vec<String> {
113    let mut result = Vec::new();
114
115    for line in content.lines() {
116        let trimmed = line.trim();
117
118        if trimmed.is_empty()
119            || trimmed.starts_with("//")
120            || trimmed.starts_with("/*")
121            || trimmed.starts_with('*')
122        {
123            continue;
124        }
125
126        // Imports
127        if trimmed.starts_with("use ") {
128            result.push(trimmed.to_string());
129            continue;
130        }
131
132        // Declarations: check common prefixes
133        let is_decl = trimmed.starts_with("pub fn ")
134            || trimmed.starts_with("pub async fn ")
135            || trimmed.starts_with("pub(crate) fn ")
136            || trimmed.starts_with("pub(crate) async fn ")
137            || trimmed.starts_with("fn ")
138            || trimmed.starts_with("async fn ")
139            || trimmed.starts_with("pub struct ")
140            || trimmed.starts_with("pub(crate) struct ")
141            || trimmed.starts_with("struct ")
142            || trimmed.starts_with("pub enum ")
143            || trimmed.starts_with("pub(crate) enum ")
144            || trimmed.starts_with("enum ")
145            || trimmed.starts_with("pub trait ")
146            || trimmed.starts_with("pub(crate) trait ")
147            || trimmed.starts_with("trait ")
148            || trimmed.starts_with("pub type ")
149            || trimmed.starts_with("type ")
150            || trimmed.starts_with("impl ")
151            || trimmed.starts_with("pub const ")
152            || trimmed.starts_with("pub(crate) const ")
153            || trimmed.starts_with("const ")
154            || trimmed.starts_with("pub static ")
155            || trimmed.starts_with("static ");
156
157        if is_decl {
158            // Take the signature line; strip trailing `{` and whitespace
159            let sig = trimmed.trim_end_matches('{').trim_end();
160            result.push(sig.to_string());
161        }
162    }
163
164    result
165}
166
167/// Extract function/type signatures and imports from TypeScript/TSX source.
168///
169/// Matches: `import`, `export function`, `function`, `class`, `interface`,
170/// `export type`, `export const`, `export enum`, and their async variants.
171fn extract_ts_structure(content: &str) -> Vec<String> {
172    let mut result = Vec::new();
173
174    for line in content.lines() {
175        let trimmed = line.trim();
176
177        if trimmed.is_empty()
178            || trimmed.starts_with("//")
179            || trimmed.starts_with("/*")
180            || trimmed.starts_with('*')
181        {
182            continue;
183        }
184
185        // Imports
186        if trimmed.starts_with("import ") {
187            result.push(trimmed.to_string());
188            continue;
189        }
190
191        let is_decl = trimmed.starts_with("export function ")
192            || trimmed.starts_with("export async function ")
193            || trimmed.starts_with("export default function ")
194            || trimmed.starts_with("function ")
195            || trimmed.starts_with("async function ")
196            || trimmed.starts_with("export class ")
197            || trimmed.starts_with("export abstract class ")
198            || trimmed.starts_with("class ")
199            || trimmed.starts_with("export interface ")
200            || trimmed.starts_with("interface ")
201            || trimmed.starts_with("export type ")
202            || trimmed.starts_with("export enum ")
203            || trimmed.starts_with("export const ")
204            || trimmed.starts_with("export default class ")
205            || trimmed.starts_with("export default async function ");
206
207        if is_decl {
208            let sig = trimmed.trim_end_matches('{').trim_end();
209            result.push(sig.to_string());
210        }
211    }
212
213    result
214}
215
216/// Extract function/class definitions and imports from Python source.
217///
218/// Matches top-level `def`, `async def`, `class`, `import`, and `from` lines.
219/// Strips trailing `:` from definition signatures.
220fn extract_python_structure(content: &str) -> Vec<String> {
221    let mut result = Vec::new();
222
223    for line in content.lines() {
224        let trimmed = line.trim();
225
226        if trimmed.is_empty() || trimmed.starts_with('#') {
227            continue;
228        }
229
230        // Top-level imports (no indentation)
231        if line.starts_with("import ") || line.starts_with("from ") {
232            result.push(trimmed.to_string());
233            continue;
234        }
235
236        // Top-level and nested defs/classes — capture the signature line
237        if trimmed.starts_with("def ")
238            || trimmed.starts_with("async def ")
239            || trimmed.starts_with("class ")
240        {
241            let sig = trimmed.trim_end_matches(':').trim_end();
242            result.push(sig.to_string());
243        }
244    }
245
246    result
247}
248
249/// Extract a structural summary (signatures, imports) from file content.
250///
251/// Dispatches to language-specific extractors based on file extension.
252/// Returns `None` for unrecognized file types or when no structure is found.
253/// Silently skips unrecognized types — no error is returned.
254pub fn extract_file_structure(path: &str, content: &str) -> Option<String> {
255    let ext = Path::new(path).extension()?.to_str()?;
256
257    let lines: Vec<String> = match ext {
258        "rs" => extract_rust_structure(content),
259        "ts" | "tsx" => extract_ts_structure(content),
260        "py" => extract_python_structure(content),
261        _ => return None,
262    };
263
264    if lines.is_empty() {
265        return None;
266    }
267
268    Some(lines.join("\n"))
269}
270
271/// Format multiple file structures into a single "File Structure" section.
272///
273/// Each entry is `(path, structure_text)`. Returns `None` if the input is empty.
274fn format_structure_block(structures: &[(&str, String)]) -> Option<String> {
275    if structures.is_empty() {
276        return None;
277    }
278
279    let mut body = String::new();
280    for (path, structure) in structures {
281        body.push_str(&format!("### {}\n```\n{}\n```\n\n", path, structure));
282    }
283
284    Some(format!(
285        "═══ File Structure ═══════════════════════════════════════════\n\
286         {}\
287         ══════════════════════════════════════════════════════════════\n\n",
288        body
289    ))
290}
291
292// ─── Bean spec formatting ────────────────────────────────────────────────────
293
294/// Format the bean's core spec as the first section of the context output.
295fn format_bean_spec_section(bean: &Bean) -> String {
296    let mut s = String::new();
297    s.push_str("═══ BEAN ════════════════════════════════════════════════════\n");
298    s.push_str(&format!("ID: {}\n", bean.id));
299    s.push_str(&format!("Title: {}\n", bean.title));
300    s.push_str(&format!("Priority: P{}\n", bean.priority));
301    s.push_str(&format!("Status: {}\n", bean.status));
302
303    if let Some(ref verify) = bean.verify {
304        s.push_str(&format!("Verify: {}\n", verify));
305    }
306
307    if !bean.produces.is_empty() {
308        s.push_str(&format!("Produces: {}\n", bean.produces.join(", ")));
309    }
310    if !bean.requires.is_empty() {
311        s.push_str(&format!("Requires: {}\n", bean.requires.join(", ")));
312    }
313    if !bean.dependencies.is_empty() {
314        s.push_str(&format!("Dependencies: {}\n", bean.dependencies.join(", ")));
315    }
316    if let Some(ref parent) = bean.parent {
317        s.push_str(&format!("Parent: {}\n", parent));
318    }
319
320    if let Some(ref desc) = bean.description {
321        s.push_str(&format!("\n## Description\n{}\n", desc));
322    }
323    if let Some(ref acceptance) = bean.acceptance {
324        s.push_str(&format!("\n## Acceptance Criteria\n{}\n", acceptance));
325    }
326
327    s.push_str("═════════════════════════════════════════════════════════════\n\n");
328    s
329}
330
331// ─── Dependency context ──────────────────────────────────────────────────────
332
333/// Information about a sibling bean that produces an artifact this bean requires.
334struct DepProvider {
335    artifact: String,
336    bean_id: String,
337    bean_title: String,
338    status: String,
339    description: Option<String>,
340}
341
342/// Resolve dependency context: find sibling beans that produce artifacts
343/// this bean requires, and load their descriptions.
344fn resolve_dependency_context(beans_dir: &Path, bean: &Bean) -> Vec<DepProvider> {
345    if bean.requires.is_empty() {
346        return Vec::new();
347    }
348
349    let index = match Index::load_or_rebuild(beans_dir) {
350        Ok(idx) => idx,
351        Err(_) => return Vec::new(),
352    };
353
354    let mut providers = Vec::new();
355
356    for required in &bean.requires {
357        let producer = index
358            .beans
359            .iter()
360            .find(|e| e.id != bean.id && e.parent == bean.parent && e.produces.contains(required));
361
362        if let Some(entry) = producer {
363            let desc = find_bean_file(beans_dir, &entry.id)
364                .ok()
365                .and_then(|p| Bean::from_file(&p).ok())
366                .and_then(|b| b.description.clone());
367
368            providers.push(DepProvider {
369                artifact: required.clone(),
370                bean_id: entry.id.clone(),
371                bean_title: entry.title.clone(),
372                status: format!("{}", entry.status),
373                description: desc,
374            });
375        }
376    }
377
378    providers
379}
380
381/// Format dependency providers into a section for the context output.
382fn format_dependency_section(providers: &[DepProvider]) -> Option<String> {
383    if providers.is_empty() {
384        return None;
385    }
386
387    let mut s = String::new();
388    s.push_str("═══ DEPENDENCY CONTEXT ══════════════════════════════════════\n");
389
390    for p in providers {
391        s.push_str(&format!(
392            "Bean {} ({}) produces `{}` [{}]\n",
393            p.bean_id, p.bean_title, p.artifact, p.status
394        ));
395        if let Some(ref desc) = p.description {
396            let preview: String = desc.chars().take(500).collect();
397            s.push_str(&format!("{}\n", preview));
398            if desc.len() > 500 {
399                s.push_str("...\n");
400            }
401        }
402        s.push('\n');
403    }
404
405    s.push_str("═════════════════════════════════════════════════════════════\n\n");
406    Some(s)
407}
408
409// ─── Path merging ────────────────────────────────────────────────────────────
410
411/// Merge explicit `bean.paths` with paths regex-extracted from the description.
412/// Explicit paths come first, then regex-extracted paths fill gaps.
413/// Deduplicates by path string.
414fn merge_paths(bean: &Bean) -> Vec<String> {
415    let mut seen = HashSet::new();
416    let mut result = Vec::new();
417
418    for p in &bean.paths {
419        if seen.insert(p.clone()) {
420            result.push(p.clone());
421        }
422    }
423
424    let description = bean.description.as_deref().unwrap_or("");
425    for p in extract_paths(description) {
426        if seen.insert(p.clone()) {
427            result.push(p);
428        }
429    }
430
431    result
432}
433
434// ─── Command ─────────────────────────────────────────────────────────────────
435
436/// Assemble complete agent context for a bean — the single source of truth.
437///
438/// Outputs everything an agent needs to implement this bean:
439/// 1. Bean spec — ID, title, verify, priority, status, description, acceptance
440/// 2. Previous attempts — what was tried and failed
441/// 3. Project rules — conventions from RULES.md
442/// 4. Dependency context — sibling beans that produce required artifacts
443/// 5. File structure — function signatures and imports
444/// 6. File contents — full source of referenced files
445///
446/// File paths are merged from explicit `bean.paths` field (priority) and
447/// regex-extracted paths from the description (fills gaps).
448///
449/// When `structure_only` is true, only structural summaries are emitted.
450pub fn cmd_context(
451    beans_dir: &Path,
452    id: &str,
453    json: bool,
454    structure_only: bool,
455    agent_prompt: bool,
456) -> Result<()> {
457    let bean_path =
458        find_bean_file(beans_dir, id).context(format!("Could not find bean with ID: {}", id))?;
459
460    let bean = Bean::from_file(&bean_path).context(format!(
461        "Failed to parse bean from: {}",
462        bean_path.display()
463    ))?;
464
465    // --agent-prompt: output the full structured prompt that an agent sees during bn run
466    if agent_prompt {
467        let options = PromptOptions {
468            beans_dir: beans_dir.to_path_buf(),
469            instructions: None,
470            concurrent_overlaps: None,
471        };
472        let result = build_agent_prompt(&bean, &options)?;
473        println!("{}", result.system_prompt);
474        return Ok(());
475    }
476
477    let project_dir = beans_dir
478        .parent()
479        .ok_or_else(|| anyhow::anyhow!("Invalid .beans/ path: {}", beans_dir.display()))?;
480
481    // Merge explicit paths with regex-extracted paths from description
482    let paths = merge_paths(&bean);
483
484    // Load supplementary context
485    let rules = load_rules(beans_dir);
486    let attempt_notes = format_attempt_notes_section(&bean);
487    let dep_providers = resolve_dependency_context(beans_dir, &bean);
488
489    // Read file contents and extract structure
490    struct FileEntry {
491        path: String,
492        content: Option<String>,
493        structure: Option<String>,
494    }
495
496    let canonical_base = project_dir
497        .canonicalize()
498        .context("Cannot canonicalize project dir")?;
499
500    let mut entries: Vec<FileEntry> = Vec::new();
501    for path_str in &paths {
502        let full_path = project_dir.join(path_str);
503        let canonical = full_path.canonicalize().ok();
504
505        let in_bounds = canonical
506            .as_ref()
507            .map(|c| c.starts_with(&canonical_base))
508            .unwrap_or(false);
509
510        let content = if let Some(ref c) = canonical {
511            if in_bounds {
512                read_file(c).ok()
513            } else {
514                None
515            }
516        } else {
517            None
518        };
519
520        let structure = content
521            .as_deref()
522            .and_then(|c| extract_file_structure(path_str, c));
523
524        entries.push(FileEntry {
525            path: path_str.clone(),
526            content,
527            structure,
528        });
529    }
530
531    if json {
532        let files: Vec<serde_json::Value> = entries
533            .iter()
534            .map(|entry| {
535                let exists = entry.content.is_some();
536                let mut file_obj = serde_json::json!({
537                    "path": entry.path,
538                    "exists": exists,
539                });
540                if !structure_only {
541                    file_obj["content"] = serde_json::Value::String(
542                        entry
543                            .content
544                            .as_deref()
545                            .unwrap_or("(not found)")
546                            .to_string(),
547                    );
548                }
549                if let Some(ref s) = entry.structure {
550                    file_obj["structure"] = serde_json::Value::String(s.clone());
551                }
552                file_obj
553            })
554            .collect();
555
556        let dep_json: Vec<serde_json::Value> = dep_providers
557            .iter()
558            .map(|p| {
559                serde_json::json!({
560                    "artifact": p.artifact,
561                    "bean_id": p.bean_id,
562                    "title": p.bean_title,
563                    "status": p.status,
564                    "description": p.description,
565                })
566            })
567            .collect();
568
569        let mut obj = serde_json::json!({
570            "id": bean.id,
571            "title": bean.title,
572            "priority": bean.priority,
573            "status": format!("{}", bean.status),
574            "verify": bean.verify,
575            "description": bean.description,
576            "acceptance": bean.acceptance,
577            "produces": bean.produces,
578            "requires": bean.requires,
579            "dependencies": bean.dependencies,
580            "parent": bean.parent,
581            "files": files,
582            "dependency_context": dep_json,
583        });
584        if let Some(ref rules_content) = rules {
585            obj["rules"] = serde_json::Value::String(rules_content.clone());
586        }
587        if let Some(ref notes) = attempt_notes {
588            obj["attempt_notes"] = serde_json::Value::String(notes.clone());
589        }
590        println!("{}", serde_json::to_string_pretty(&obj)?);
591    } else {
592        let mut output = String::new();
593
594        // 1. Bean spec — the most important section
595        output.push_str(&format_bean_spec_section(&bean));
596
597        // 2. Previous attempts — what was tried and failed
598        if let Some(ref notes) = attempt_notes {
599            output.push_str(notes);
600        }
601
602        // 3. Project rules
603        if let Some(ref rules_content) = rules {
604            output.push_str(&format_rules_section(rules_content));
605        }
606
607        // 4. Dependency context
608        if let Some(dep_section) = format_dependency_section(&dep_providers) {
609            output.push_str(&dep_section);
610        }
611
612        // 5. Structural summaries
613        let structure_pairs: Vec<(&str, String)> = entries
614            .iter()
615            .filter_map(|e| e.structure.as_ref().map(|s| (e.path.as_str(), s.clone())))
616            .collect();
617
618        if let Some(structure_block) = format_structure_block(&structure_pairs) {
619            output.push_str(&structure_block);
620        }
621
622        // 6. Full file contents (unless --structure-only)
623        if !structure_only {
624            let file_paths: Vec<String> = paths.clone();
625            if !file_paths.is_empty() {
626                let context = assemble_context(file_paths, project_dir)
627                    .context("Failed to assemble context")?;
628                output.push_str(&context);
629            }
630        }
631
632        print!("{}", output);
633    }
634
635    Ok(())
636}
637#[cfg(test)]
638mod tests {
639    use super::*;
640    use std::fs;
641    use tempfile::TempDir;
642
643    fn setup_test_env() -> (TempDir, std::path::PathBuf) {
644        let dir = TempDir::new().unwrap();
645        let beans_dir = dir.path().join(".beans");
646        fs::create_dir(&beans_dir).unwrap();
647        (dir, beans_dir)
648    }
649
650    #[test]
651    fn context_with_no_paths_in_description() {
652        let (_dir, beans_dir) = setup_test_env();
653
654        // Create a bean with no file paths in description
655        let mut bean = crate::bean::Bean::new("1", "Test bean");
656        bean.description = Some("A description with no file paths".to_string());
657        let slug = crate::util::title_to_slug(&bean.title);
658        let bean_path = beans_dir.join(format!("1-{}.md", slug));
659        bean.to_file(&bean_path).unwrap();
660
661        // Should succeed but print a tip
662        let result = cmd_context(&beans_dir, "1", false, false, false);
663        assert!(result.is_ok());
664    }
665
666    #[test]
667    fn context_with_paths_in_description() {
668        let (dir, beans_dir) = setup_test_env();
669        let project_dir = dir.path();
670
671        // Create a source file
672        let src_dir = project_dir.join("src");
673        fs::create_dir(&src_dir).unwrap();
674        fs::write(src_dir.join("foo.rs"), "fn main() {}").unwrap();
675
676        // Create a bean referencing the file
677        let mut bean = crate::bean::Bean::new("1", "Test bean");
678        bean.description = Some("Check src/foo.rs for implementation".to_string());
679        let slug = crate::util::title_to_slug(&bean.title);
680        let bean_path = beans_dir.join(format!("1-{}.md", slug));
681        bean.to_file(&bean_path).unwrap();
682
683        let result = cmd_context(&beans_dir, "1", false, false, false);
684        assert!(result.is_ok());
685    }
686
687    #[test]
688    fn context_bean_not_found() {
689        let (_dir, beans_dir) = setup_test_env();
690
691        let result = cmd_context(&beans_dir, "999", false, false, false);
692        assert!(result.is_err());
693    }
694
695    #[test]
696    fn load_rules_returns_none_when_file_missing() {
697        let (_dir, beans_dir) = setup_test_env();
698        // Write a minimal config so Config::load succeeds
699        fs::write(beans_dir.join("config.yaml"), "project: test\nnext_id: 1\n").unwrap();
700
701        let result = load_rules(&beans_dir);
702        assert!(result.is_none());
703    }
704
705    #[test]
706    fn load_rules_returns_none_when_file_empty() {
707        let (_dir, beans_dir) = setup_test_env();
708        fs::write(beans_dir.join("config.yaml"), "project: test\nnext_id: 1\n").unwrap();
709        fs::write(beans_dir.join("RULES.md"), "   \n\n  ").unwrap();
710
711        let result = load_rules(&beans_dir);
712        assert!(result.is_none());
713    }
714
715    #[test]
716    fn load_rules_returns_content_when_present() {
717        let (_dir, beans_dir) = setup_test_env();
718        fs::write(beans_dir.join("config.yaml"), "project: test\nnext_id: 1\n").unwrap();
719        fs::write(beans_dir.join("RULES.md"), "# My Rules\nNo unwrap.\n").unwrap();
720
721        let result = load_rules(&beans_dir);
722        assert!(result.is_some());
723        assert!(result.unwrap().contains("No unwrap."));
724    }
725
726    #[test]
727    fn load_rules_uses_custom_rules_file_path() {
728        let (_dir, beans_dir) = setup_test_env();
729        fs::write(
730            beans_dir.join("config.yaml"),
731            "project: test\nnext_id: 1\nrules_file: custom-rules.md\n",
732        )
733        .unwrap();
734        fs::write(beans_dir.join("custom-rules.md"), "Custom rules here").unwrap();
735
736        let result = load_rules(&beans_dir);
737        assert!(result.is_some());
738        assert!(result.unwrap().contains("Custom rules here"));
739    }
740
741    #[test]
742    fn format_rules_section_wraps_with_delimiters() {
743        let output = format_rules_section("# Rules\nBe nice.\n");
744        assert!(output.starts_with("═══ PROJECT RULES"));
745        assert!(output.contains("# Rules\nBe nice."));
746        assert!(
747            output.ends_with("═════════════════════════════════════════════════════════════\n\n")
748        );
749    }
750
751    // --- attempt notes tests ---
752
753    fn make_bean_with_attempts() -> crate::bean::Bean {
754        use crate::bean::{AttemptOutcome, AttemptRecord};
755        let mut bean = crate::bean::Bean::new("1", "Test bean");
756        bean.attempt_log = vec![
757            AttemptRecord {
758                num: 1,
759                outcome: AttemptOutcome::Abandoned,
760                notes: Some("Tried X, hit bug Y".to_string()),
761                agent: Some("pi-agent".to_string()),
762                started_at: None,
763                finished_at: None,
764            },
765            AttemptRecord {
766                num: 2,
767                outcome: AttemptOutcome::Failed,
768                notes: Some("Fixed Y, now Z fails".to_string()),
769                agent: None,
770                started_at: None,
771                finished_at: None,
772            },
773        ];
774        bean
775    }
776
777    #[test]
778    fn format_attempt_notes_returns_none_when_no_notes() {
779        let bean = crate::bean::Bean::new("1", "Empty bean");
780        // No attempt_log, no notes
781        let result = format_attempt_notes_section(&bean);
782        assert!(result.is_none());
783    }
784
785    #[test]
786    fn format_attempt_notes_returns_none_when_attempts_have_no_notes() {
787        use crate::bean::{AttemptOutcome, AttemptRecord};
788        let mut bean = crate::bean::Bean::new("1", "Empty bean");
789        bean.attempt_log = vec![AttemptRecord {
790            num: 1,
791            outcome: AttemptOutcome::Abandoned,
792            notes: None,
793            agent: None,
794            started_at: None,
795            finished_at: None,
796        }];
797        let result = format_attempt_notes_section(&bean);
798        assert!(result.is_none());
799    }
800
801    #[test]
802    fn format_attempt_notes_includes_attempt_log_notes() {
803        let bean = make_bean_with_attempts();
804        let result = format_attempt_notes_section(&bean).expect("should produce output");
805        assert!(
806            result.contains("Previous Attempts"),
807            "should have section header"
808        );
809        assert!(result.contains("Attempt #1"), "should include attempt 1");
810        assert!(result.contains("pi-agent"), "should include agent name");
811        assert!(result.contains("abandoned"), "should include outcome");
812        assert!(
813            result.contains("Tried X, hit bug Y"),
814            "should include notes text"
815        );
816        assert!(result.contains("Attempt #2"), "should include attempt 2");
817        assert!(
818            result.contains("Fixed Y, now Z fails"),
819            "should include attempt 2 notes"
820        );
821    }
822
823    #[test]
824    fn format_attempt_notes_includes_bean_notes() {
825        let mut bean = crate::bean::Bean::new("1", "Test bean");
826        bean.notes = Some("Watch out for edge cases".to_string());
827        let result = format_attempt_notes_section(&bean).expect("should produce output");
828        assert!(result.contains("Watch out for edge cases"));
829        assert!(result.contains("Bean notes:"));
830    }
831
832    #[test]
833    fn format_attempt_notes_skips_empty_notes_strings() {
834        use crate::bean::{AttemptOutcome, AttemptRecord};
835        let mut bean = crate::bean::Bean::new("1", "Test bean");
836        bean.notes = Some("   ".to_string()); // whitespace only
837        bean.attempt_log = vec![AttemptRecord {
838            num: 1,
839            outcome: AttemptOutcome::Abandoned,
840            notes: Some("  ".to_string()), // whitespace only
841            agent: None,
842            started_at: None,
843            finished_at: None,
844        }];
845        let result = format_attempt_notes_section(&bean);
846        assert!(
847            result.is_none(),
848            "whitespace-only notes should produce no output"
849        );
850    }
851
852    #[test]
853    fn context_includes_attempt_notes_in_text_output() {
854        let (dir, beans_dir) = setup_test_env();
855        let project_dir = dir.path();
856
857        // Create a source file
858        let src_dir = project_dir.join("src");
859        fs::create_dir(&src_dir).unwrap();
860        fs::write(src_dir.join("foo.rs"), "fn main() {}").unwrap();
861
862        // Create a bean with attempt notes
863        let mut bean = make_bean_with_attempts();
864        bean.id = "1".to_string();
865        bean.description = Some("Check src/foo.rs for implementation".to_string());
866        let slug = crate::util::title_to_slug(&bean.title);
867        let bean_path = beans_dir.join(format!("1-{}.md", slug));
868        bean.to_file(&bean_path).unwrap();
869
870        // The function prints to stdout — just verify it runs without error
871        let result = cmd_context(&beans_dir, "1", false, false, false);
872        assert!(result.is_ok());
873    }
874
875    #[test]
876    fn context_includes_attempt_notes_in_json_output() {
877        let (dir, beans_dir) = setup_test_env();
878        let project_dir = dir.path();
879
880        let src_dir = project_dir.join("src");
881        fs::create_dir(&src_dir).unwrap();
882        fs::write(src_dir.join("foo.rs"), "fn main() {}").unwrap();
883
884        let mut bean = make_bean_with_attempts();
885        bean.id = "1".to_string();
886        bean.description = Some("Check src/foo.rs for implementation".to_string());
887        let slug = crate::util::title_to_slug(&bean.title);
888        let bean_path = beans_dir.join(format!("1-{}.md", slug));
889        bean.to_file(&bean_path).unwrap();
890
891        let result = cmd_context(&beans_dir, "1", true, false, false);
892        assert!(result.is_ok());
893    }
894}