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