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
13fn 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
39fn format_rules_section(rules: &str) -> String {
41 format!(
42 "═══ PROJECT RULES ═══════════════════════════════════════════\n\
43 {}\n\
44 ═════════════════════════════════════════════════════════════\n\n",
45 rules.trim_end()
46 )
47}
48
49fn format_attempt_notes_section(bean: &Bean) -> Option<String> {
54 let mut parts: Vec<String> = Vec::new();
55
56 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 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
106fn 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 if trimmed.starts_with("use ") {
128 result.push(trimmed.to_string());
129 continue;
130 }
131
132 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 let sig = trimmed.trim_end_matches('{').trim_end();
160 result.push(sig.to_string());
161 }
162 }
163
164 result
165}
166
167fn 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 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
216fn 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 if line.starts_with("import ") || line.starts_with("from ") {
232 result.push(trimmed.to_string());
233 continue;
234 }
235
236 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
249pub 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
271fn 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
292fn 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
331struct DepProvider {
335 artifact: String,
336 bean_id: String,
337 bean_title: String,
338 status: String,
339 description: Option<String>,
340}
341
342fn 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
381fn 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
409fn 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
434pub 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 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 let paths = merge_paths(&bean);
483
484 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 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 output.push_str(&format_bean_spec_section(&bean));
596
597 if let Some(ref notes) = attempt_notes {
599 output.push_str(notes);
600 }
601
602 if let Some(ref rules_content) = rules {
604 output.push_str(&format_rules_section(rules_content));
605 }
606
607 if let Some(dep_section) = format_dependency_section(&dep_providers) {
609 output.push_str(&dep_section);
610 }
611
612 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 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 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 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 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 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 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 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 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()); bean.attempt_log = vec![AttemptRecord {
838 num: 1,
839 outcome: AttemptOutcome::Abandoned,
840 notes: Some(" ".to_string()), 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 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 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 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}