1use std::path::Path;
2
3use anyhow::Result;
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7use crate::util::{atomic_write, validate_bean_id};
8
9pub mod types;
10pub use types::*;
11
12pub fn validate_priority(priority: u8) -> Result<()> {
18 if priority > 4 {
19 return Err(anyhow::anyhow!(
20 "Invalid priority: {}. Priority must be in range 0-4 (P0-P4)",
21 priority
22 ));
23 }
24 Ok(())
25}
26
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32pub struct Bean {
33 pub id: String,
34 pub title: String,
35 #[serde(skip_serializing_if = "Option::is_none")]
36 pub slug: Option<String>,
37 pub status: Status,
38 #[serde(default = "default_priority")]
39 pub priority: u8,
40 pub created_at: DateTime<Utc>,
41 pub updated_at: DateTime<Utc>,
42
43 #[serde(skip_serializing_if = "Option::is_none")]
44 pub description: Option<String>,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub acceptance: Option<String>,
47 #[serde(skip_serializing_if = "Option::is_none")]
48 pub notes: Option<String>,
49 #[serde(skip_serializing_if = "Option::is_none")]
50 pub design: Option<String>,
51
52 #[serde(default, skip_serializing_if = "Vec::is_empty")]
53 pub labels: Vec<String>,
54 #[serde(skip_serializing_if = "Option::is_none")]
55 pub assignee: Option<String>,
56
57 #[serde(skip_serializing_if = "Option::is_none")]
58 pub closed_at: Option<DateTime<Utc>>,
59 #[serde(skip_serializing_if = "Option::is_none")]
60 pub close_reason: Option<String>,
61
62 #[serde(skip_serializing_if = "Option::is_none")]
63 pub parent: Option<String>,
64 #[serde(default, skip_serializing_if = "Vec::is_empty")]
65 pub dependencies: Vec<String>,
66
67 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub verify: Option<String>,
71 #[serde(default, skip_serializing_if = "is_false")]
74 pub fail_first: bool,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub checkpoint: Option<String>,
79 #[serde(default, skip_serializing_if = "is_zero")]
81 pub attempts: u32,
82 #[serde(
84 default = "default_max_attempts",
85 skip_serializing_if = "is_default_max_attempts"
86 )]
87 pub max_attempts: u32,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub claimed_by: Option<String>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub claimed_at: Option<DateTime<Utc>>,
94
95 #[serde(default, skip_serializing_if = "is_false")]
97 pub is_archived: bool,
98
99 #[serde(default, skip_serializing_if = "Vec::is_empty")]
102 pub produces: Vec<String>,
103
104 #[serde(default, skip_serializing_if = "Vec::is_empty")]
107 pub requires: Vec<String>,
108
109 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub on_fail: Option<OnFailAction>,
112
113 #[serde(default, skip_serializing_if = "Vec::is_empty")]
116 pub on_close: Vec<OnCloseAction>,
117
118 #[serde(default, skip_serializing_if = "Vec::is_empty")]
120 pub history: Vec<RunRecord>,
121
122 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub outputs: Option<serde_json::Value>,
125
126 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub max_loops: Option<u32>,
129
130 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub verify_timeout: Option<u64>,
134
135 #[serde(
138 default = "default_bean_type",
139 skip_serializing_if = "is_default_bean_type"
140 )]
141 pub bean_type: String,
142
143 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub last_verified: Option<DateTime<Utc>>,
146
147 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub stale_after: Option<DateTime<Utc>>,
150
151 #[serde(default, skip_serializing_if = "Vec::is_empty")]
153 pub paths: Vec<String>,
154
155 #[serde(default, skip_serializing_if = "Vec::is_empty")]
158 pub attempt_log: Vec<AttemptRecord>,
159
160 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub created_by: Option<String>,
163
164 #[serde(default, skip_serializing_if = "is_false")]
166 pub feature: bool,
167}
168
169fn default_priority() -> u8 {
170 2
171}
172
173fn default_max_attempts() -> u32 {
174 3
175}
176
177fn is_zero(v: &u32) -> bool {
178 *v == 0
179}
180
181fn is_default_max_attempts(v: &u32) -> bool {
182 *v == 3
183}
184
185fn is_false(v: &bool) -> bool {
186 !*v
187}
188
189fn default_bean_type() -> String {
190 "task".to_string()
191}
192
193fn is_default_bean_type(v: &str) -> bool {
194 v == "task"
195}
196
197impl Bean {
198 pub fn try_new(id: impl Into<String>, title: impl Into<String>) -> Result<Self> {
201 let id_str = id.into();
202 validate_bean_id(&id_str)?;
203
204 let now = Utc::now();
205 Ok(Self {
206 id: id_str,
207 title: title.into(),
208 slug: None,
209 status: Status::Open,
210 priority: 2,
211 created_at: now,
212 updated_at: now,
213 description: None,
214 acceptance: None,
215 notes: None,
216 design: None,
217 labels: Vec::new(),
218 assignee: None,
219 closed_at: None,
220 close_reason: None,
221 parent: None,
222 dependencies: Vec::new(),
223 verify: None,
224 fail_first: false,
225 checkpoint: None,
226 attempts: 0,
227 max_attempts: 3,
228 claimed_by: None,
229 claimed_at: None,
230 is_archived: false,
231 feature: false,
232 produces: Vec::new(),
233 requires: Vec::new(),
234 on_fail: None,
235 on_close: Vec::new(),
236 history: Vec::new(),
237 outputs: None,
238 max_loops: None,
239 verify_timeout: None,
240 bean_type: "task".to_string(),
241 last_verified: None,
242 stale_after: None,
243 paths: Vec::new(),
244 attempt_log: Vec::new(),
245 created_by: None,
246 })
247 }
248
249 pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
252 Self::try_new(id, title).expect("Invalid bean ID")
253 }
254
255 pub fn effective_max_loops(&self, config_max: u32) -> u32 {
258 self.max_loops.unwrap_or(config_max)
259 }
260
261 pub fn effective_verify_timeout(&self, config_timeout: Option<u64>) -> Option<u64> {
263 self.verify_timeout.or(config_timeout)
264 }
265
266 fn parse_frontmatter(content: &str) -> Result<(String, Option<String>)> {
278 if !content.starts_with("---\n") && !content.starts_with("---\r\n") {
280 return Err(anyhow::anyhow!("Not markdown frontmatter format"));
282 }
283
284 let after_first_delimiter = if let Some(stripped) = content.strip_prefix("---\r\n") {
286 stripped
287 } else if let Some(stripped) = content.strip_prefix("---\n") {
288 stripped
289 } else {
290 return Err(anyhow::anyhow!("Not markdown frontmatter format"));
291 };
292
293 let second_delimiter_pos = after_first_delimiter.find("---").ok_or_else(|| {
294 anyhow::anyhow!("Markdown frontmatter is missing closing delimiter (---)")
295 })?;
296 let frontmatter = &after_first_delimiter[..second_delimiter_pos];
297
298 let body_start = second_delimiter_pos + 3;
300 let body_raw = &after_first_delimiter[body_start..];
301
302 let body = body_raw.trim();
304 let body = (!body.is_empty()).then(|| body.to_string());
305
306 Ok((frontmatter.to_string(), body))
307 }
308
309 pub fn from_string(content: &str) -> Result<Self> {
311 match Self::parse_frontmatter(content) {
313 Ok((frontmatter, body)) => {
314 let mut bean: Bean = serde_yml::from_str(&frontmatter)?;
316
317 if let Some(markdown_body) = body {
319 if bean.description.is_none() {
320 bean.description = Some(markdown_body);
321 }
322 }
323
324 Ok(bean)
325 }
326 Err(_) => {
327 let bean: Bean = serde_yml::from_str(content)?;
329 Ok(bean)
330 }
331 }
332 }
333
334 pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
336 let contents = std::fs::read_to_string(path.as_ref())?;
337 Self::from_string(&contents)
338 }
339
340 pub fn to_file(&self, path: impl AsRef<Path>) -> Result<()> {
344 let path = path.as_ref();
345 let is_md = path.extension().and_then(|e| e.to_str()) == Some("md");
346
347 if is_md && self.description.is_some() {
348 let mut frontmatter_bean = self.clone();
350 let description = frontmatter_bean.description.take(); let yaml = serde_yml::to_string(&frontmatter_bean)?;
352 let mut content = String::from("---\n");
353 content.push_str(yaml.trim_start_matches("---\n").trim_end());
354 content.push_str("\n---\n");
355 if let Some(desc) = description {
356 content.push('\n');
357 content.push_str(&desc);
358 if !desc.ends_with('\n') {
359 content.push('\n');
360 }
361 }
362 atomic_write(path, &content)?;
363 } else {
364 let yaml = serde_yml::to_string(self)?;
365 atomic_write(path, &yaml)?;
366 }
367 Ok(())
368 }
369
370 pub fn hash(&self) -> String {
375 use sha2::{Digest, Sha256};
376 let canonical = self.clone();
377
378 let json =
380 serde_json::to_string(&canonical).expect("Bean serialization to JSON cannot fail");
381 let mut hasher = Sha256::new();
382 hasher.update(json.as_bytes());
383 format!("{:x}", hasher.finalize())
384 }
385
386 pub fn from_file_with_hash(path: impl AsRef<Path>) -> Result<(Self, String)> {
391 let bean = Self::from_file(path)?;
392 let hash = bean.hash();
393 Ok((bean, hash))
394 }
395
396 pub fn apply_value(&mut self, field: &str, json_value: &str) -> Result<()> {
409 match field {
410 "title" => self.title = serde_json::from_str(json_value)?,
411 "status" => self.status = serde_json::from_str(json_value)?,
412 "priority" => self.priority = serde_json::from_str(json_value)?,
413 "description" => self.description = serde_json::from_str(json_value)?,
414 "acceptance" => self.acceptance = serde_json::from_str(json_value)?,
415 "notes" => self.notes = serde_json::from_str(json_value)?,
416 "design" => self.design = serde_json::from_str(json_value)?,
417 "assignee" => self.assignee = serde_json::from_str(json_value)?,
418 "labels" => self.labels = serde_json::from_str(json_value)?,
419 "dependencies" => self.dependencies = serde_json::from_str(json_value)?,
420 "parent" => self.parent = serde_json::from_str(json_value)?,
421 "verify" => self.verify = serde_json::from_str(json_value)?,
422 "produces" => self.produces = serde_json::from_str(json_value)?,
423 "requires" => self.requires = serde_json::from_str(json_value)?,
424 "claimed_by" => self.claimed_by = serde_json::from_str(json_value)?,
425 "close_reason" => self.close_reason = serde_json::from_str(json_value)?,
426 "on_fail" => self.on_fail = serde_json::from_str(json_value)?,
427 "outputs" => self.outputs = serde_json::from_str(json_value)?,
428 "max_loops" => self.max_loops = serde_json::from_str(json_value)?,
429 "bean_type" => self.bean_type = serde_json::from_str(json_value)?,
430 "last_verified" => self.last_verified = serde_json::from_str(json_value)?,
431 "stale_after" => self.stale_after = serde_json::from_str(json_value)?,
432 "paths" => self.paths = serde_json::from_str(json_value)?,
433 _ => return Err(anyhow::anyhow!("Unknown field: {}", field)),
434 }
435 self.updated_at = Utc::now();
436 Ok(())
437 }
438}
439
440#[cfg(test)]
445mod tests {
446 use super::*;
447 use tempfile::NamedTempFile;
448
449 #[test]
450 fn round_trip_minimal_bean() {
451 let bean = Bean::new("1", "My first bean");
452
453 let yaml = serde_yml::to_string(&bean).unwrap();
455
456 let restored: Bean = serde_yml::from_str(&yaml).unwrap();
458
459 assert_eq!(bean, restored);
460 }
461
462 #[test]
463 fn round_trip_full_bean() {
464 let now = Utc::now();
465 let bean = Bean {
466 id: "3.2.1".to_string(),
467 title: "Implement parser".to_string(),
468 slug: None,
469 status: Status::InProgress,
470 priority: 1,
471 created_at: now,
472 updated_at: now,
473 description: Some("Build a robust YAML parser".to_string()),
474 acceptance: Some("All tests pass".to_string()),
475 notes: Some("Watch out for edge cases".to_string()),
476 design: Some("Use serde_yaml".to_string()),
477 labels: vec!["backend".to_string(), "core".to_string()],
478 assignee: Some("alice".to_string()),
479 closed_at: Some(now),
480 close_reason: Some("Done".to_string()),
481 parent: Some("3.2".to_string()),
482 dependencies: vec!["3.1".to_string()],
483 verify: Some("cargo test".to_string()),
484 fail_first: false,
485 checkpoint: None,
486 attempts: 1,
487 max_attempts: 5,
488 claimed_by: Some("agent-7".to_string()),
489 claimed_at: Some(now),
490 is_archived: false,
491 feature: false,
492 produces: vec!["Parser".to_string()],
493 requires: vec!["Lexer".to_string()],
494 on_fail: Some(OnFailAction::Retry {
495 max: Some(5),
496 delay_secs: None,
497 }),
498 on_close: vec![
499 OnCloseAction::Run {
500 command: "echo done".to_string(),
501 },
502 OnCloseAction::Notify {
503 message: "Task complete".to_string(),
504 },
505 ],
506 verify_timeout: None,
507 history: Vec::new(),
508 outputs: Some(serde_json::json!({"key": "value"})),
509 max_loops: None,
510 bean_type: "task".to_string(),
511 last_verified: None,
512 stale_after: None,
513 paths: Vec::new(),
514 attempt_log: Vec::new(),
515 created_by: Some("alice".to_string()),
516 };
517
518 let yaml = serde_yml::to_string(&bean).unwrap();
519 let restored: Bean = serde_yml::from_str(&yaml).unwrap();
520
521 assert_eq!(bean, restored);
522 }
523
524 #[test]
525 fn optional_fields_omitted_when_none() {
526 let bean = Bean::new("1", "Minimal");
527 let yaml = serde_yml::to_string(&bean).unwrap();
528
529 assert!(!yaml.contains("description:"));
530 assert!(!yaml.contains("acceptance:"));
531 assert!(!yaml.contains("notes:"));
532 assert!(!yaml.contains("design:"));
533 assert!(!yaml.contains("assignee:"));
534 assert!(!yaml.contains("closed_at:"));
535 assert!(!yaml.contains("close_reason:"));
536 assert!(!yaml.contains("parent:"));
537 assert!(!yaml.contains("labels:"));
538 assert!(!yaml.contains("dependencies:"));
539 assert!(!yaml.contains("verify:"));
540 assert!(!yaml.contains("attempts:"));
541 assert!(!yaml.contains("max_attempts:"));
542 assert!(!yaml.contains("claimed_by:"));
543 assert!(!yaml.contains("claimed_at:"));
544 assert!(!yaml.contains("is_archived:"));
545 assert!(!yaml.contains("on_fail:"));
546 assert!(!yaml.contains("on_close:"));
547 assert!(!yaml.contains("history:"));
548 assert!(!yaml.contains("outputs:"));
549 }
550
551 #[test]
552 fn timestamps_serialize_as_iso8601() {
553 let bean = Bean::new("1", "Check timestamps");
554 let yaml = serde_yml::to_string(&bean).unwrap();
555
556 for line in yaml.lines() {
558 if line.starts_with("created_at:") || line.starts_with("updated_at:") {
559 let value = line.split_once(':').unwrap().1.trim();
560 assert!(value.contains('T'), "timestamp should be ISO 8601: {value}");
561 }
562 }
563 }
564
565 #[test]
566 fn file_round_trip() {
567 let bean = Bean::new("42", "File I/O test");
568
569 let tmp = NamedTempFile::new().unwrap();
570 let path = tmp.path().to_path_buf();
571
572 bean.to_file(&path).unwrap();
574
575 let restored = Bean::from_file(&path).unwrap();
577 assert_eq!(bean, restored);
578
579 let raw = std::fs::read_to_string(&path).unwrap();
581 assert!(raw.contains("id: '42'") || raw.contains("id: \"42\""));
582 assert!(raw.contains("title: File I/O test") || raw.contains("title: 'File I/O test'"));
583 drop(tmp);
584 }
585
586 #[test]
587 fn defaults_are_correct() {
588 let bean = Bean::new("1", "Defaults");
589 assert_eq!(bean.status, Status::Open);
590 assert_eq!(bean.priority, 2);
591 assert!(bean.labels.is_empty());
592 assert!(bean.dependencies.is_empty());
593 assert!(bean.description.is_none());
594 }
595
596 #[test]
597 fn deserialize_with_missing_optional_fields() {
598 let yaml = r#"
599id: "5"
600title: Sparse bean
601status: open
602priority: 3
603created_at: "2025-01-01T00:00:00Z"
604updated_at: "2025-01-01T00:00:00Z"
605"#;
606 let bean: Bean = serde_yml::from_str(yaml).unwrap();
607 assert_eq!(bean.id, "5");
608 assert_eq!(bean.priority, 3);
609 assert!(bean.description.is_none());
610 assert!(bean.labels.is_empty());
611 }
612
613 #[test]
614 fn validate_priority_accepts_valid_range() {
615 for priority in 0..=4 {
616 assert!(
617 validate_priority(priority).is_ok(),
618 "Priority {} should be valid",
619 priority
620 );
621 }
622 }
623
624 #[test]
625 fn validate_priority_rejects_out_of_range() {
626 assert!(validate_priority(5).is_err());
627 assert!(validate_priority(10).is_err());
628 assert!(validate_priority(255).is_err());
629 }
630
631 #[test]
636 fn test_parse_md_frontmatter() {
637 let content = r#"---
638id: 11.1
639title: Test Bean
640status: open
641priority: 2
642created_at: "2026-01-26T15:00:00Z"
643updated_at: "2026-01-26T15:00:00Z"
644---
645
646# Description
647
648Test markdown body.
649"#;
650 let bean = Bean::from_string(content).unwrap();
651 assert_eq!(bean.id, "11.1");
652 assert_eq!(bean.title, "Test Bean");
653 assert_eq!(bean.status, Status::Open);
654 assert!(bean.description.is_some());
655 assert!(bean.description.as_ref().unwrap().contains("# Description"));
656 assert!(bean
657 .description
658 .as_ref()
659 .unwrap()
660 .contains("Test markdown body"));
661 }
662
663 #[test]
664 fn test_parse_md_frontmatter_preserves_metadata_fields() {
665 let content = r#"---
666id: "2.5"
667title: Complex Bean
668status: in_progress
669priority: 1
670created_at: "2026-01-01T10:00:00Z"
671updated_at: "2026-01-26T15:00:00Z"
672parent: "2"
673labels:
674 - backend
675 - urgent
676dependencies:
677 - "2.1"
678 - "2.2"
679---
680
681## Implementation Notes
682
683This is a complex bean with multiple metadata fields.
684"#;
685 let bean = Bean::from_string(content).unwrap();
686 assert_eq!(bean.id, "2.5");
687 assert_eq!(bean.title, "Complex Bean");
688 assert_eq!(bean.status, Status::InProgress);
689 assert_eq!(bean.priority, 1);
690 assert_eq!(bean.parent, Some("2".to_string()));
691 assert_eq!(
692 bean.labels,
693 vec!["backend".to_string(), "urgent".to_string()]
694 );
695 assert_eq!(
696 bean.dependencies,
697 vec!["2.1".to_string(), "2.2".to_string()]
698 );
699 assert!(bean.description.is_some());
700 }
701
702 #[test]
703 fn test_parse_md_frontmatter_empty_body() {
704 let content = r#"---
705id: "3"
706title: No Body Bean
707status: open
708priority: 2
709created_at: "2026-01-01T00:00:00Z"
710updated_at: "2026-01-01T00:00:00Z"
711---
712"#;
713 let bean = Bean::from_string(content).unwrap();
714 assert_eq!(bean.id, "3");
715 assert_eq!(bean.title, "No Body Bean");
716 assert!(bean.description.is_none());
717 }
718
719 #[test]
720 fn test_parse_md_frontmatter_with_body_containing_dashes() {
721 let content = r#"---
722id: "4"
723title: Dashes in Body
724status: open
725priority: 2
726created_at: "2026-01-01T00:00:00Z"
727updated_at: "2026-01-01T00:00:00Z"
728---
729
730# Section 1
731
732This has --- inside the body, which should not break parsing.
733
734---
735
736More content after a horizontal rule.
737"#;
738 let bean = Bean::from_string(content).unwrap();
739 assert_eq!(bean.id, "4");
740 assert!(bean.description.is_some());
741 let body = bean.description.as_ref().unwrap();
742 assert!(body.contains("---"));
743 assert!(body.contains("horizontal rule"));
744 }
745
746 #[test]
747 fn test_parse_md_frontmatter_with_whitespace_in_body() {
748 let content = r#"---
749id: "5"
750title: Whitespace Test
751status: open
752priority: 2
753created_at: "2026-01-01T00:00:00Z"
754updated_at: "2026-01-01T00:00:00Z"
755---
756
757
758 Leading whitespace preserved after trimming newlines.
759
760"#;
761 let bean = Bean::from_string(content).unwrap();
762 assert_eq!(bean.id, "5");
763 assert!(bean.description.is_some());
764 let body = bean.description.as_ref().unwrap();
765 assert!(body.contains("Leading whitespace"));
767 }
768
769 #[test]
770 fn test_fallback_to_yaml_parsing() {
771 let yaml_content = r#"
772id: "6"
773title: Pure YAML Bean
774status: open
775priority: 3
776created_at: "2026-01-01T00:00:00Z"
777updated_at: "2026-01-01T00:00:00Z"
778description: "This is YAML, not markdown"
779"#;
780 let bean = Bean::from_string(yaml_content).unwrap();
781 assert_eq!(bean.id, "6");
782 assert_eq!(bean.title, "Pure YAML Bean");
783 assert_eq!(
784 bean.description,
785 Some("This is YAML, not markdown".to_string())
786 );
787 }
788
789 #[test]
790 fn test_file_round_trip_with_markdown() {
791 let content = r#"---
792id: "7"
793title: File Markdown Test
794status: open
795priority: 2
796created_at: "2026-01-01T00:00:00Z"
797updated_at: "2026-01-01T00:00:00Z"
798---
799
800# Markdown Body
801
802This is a test of reading markdown from a file.
803"#;
804
805 let dir = tempfile::tempdir().unwrap();
807 let path = dir.path().join("7-test.md");
808
809 std::fs::write(&path, content).unwrap();
811
812 let bean = Bean::from_file(&path).unwrap();
814 assert_eq!(bean.id, "7");
815 assert_eq!(bean.title, "File Markdown Test");
816 assert!(bean.description.is_some());
817 assert!(bean
818 .description
819 .as_ref()
820 .unwrap()
821 .contains("# Markdown Body"));
822
823 bean.to_file(&path).unwrap();
825
826 let written = std::fs::read_to_string(&path).unwrap();
828 assert!(
829 written.starts_with("---\n"),
830 "Should start with frontmatter delimiter, got: {}",
831 &written[..50.min(written.len())]
832 );
833 assert!(
834 written.contains("# Markdown Body"),
835 "Should contain markdown body"
836 );
837 let parts: Vec<&str> = written.splitn(3, "---").collect();
839 assert!(parts.len() >= 3, "Should have frontmatter delimiters");
840 let frontmatter_section = parts[1];
841 assert!(
842 !frontmatter_section.contains("# Markdown Body"),
843 "Description should be in body, not frontmatter"
844 );
845
846 let bean2 = Bean::from_file(&path).unwrap();
848 assert_eq!(bean2.id, bean.id);
849 assert_eq!(bean2.title, bean.title);
850 assert_eq!(bean2.description, bean.description);
851 }
852
853 #[test]
854 fn test_parse_md_frontmatter_missing_closing_delimiter() {
855 let bad_content = r#"---
856id: "8"
857title: Missing Delimiter
858status: open
859"#;
860 let result = Bean::from_string(bad_content);
861 assert!(result.is_err());
863 }
864
865 #[test]
866 fn test_parse_md_frontmatter_multiline_fields() {
867 let content = r#"---
868id: "9"
869title: Multiline Test
870status: open
871priority: 2
872created_at: "2026-01-01T00:00:00Z"
873updated_at: "2026-01-01T00:00:00Z"
874acceptance: |
875 - Criterion 1
876 - Criterion 2
877 - Criterion 3
878---
879
880# Implementation
881
882Start implementing...
883"#;
884 let bean = Bean::from_string(content).unwrap();
885 assert_eq!(bean.id, "9");
886 assert!(bean.acceptance.is_some());
887 let acceptance = bean.acceptance.as_ref().unwrap();
888 assert!(acceptance.contains("Criterion 1"));
889 assert!(acceptance.contains("Criterion 2"));
890 assert!(bean.description.is_some());
891 }
892
893 #[test]
894 fn test_parse_md_with_crlf_line_endings() {
895 let content = "---\r\nid: \"10\"\r\ntitle: CRLF Test\r\nstatus: open\r\npriority: 2\r\ncreated_at: \"2026-01-01T00:00:00Z\"\r\nupdated_at: \"2026-01-01T00:00:00Z\"\r\n---\r\n\r\n# Body\r\n\r\nCRLF line endings.";
896 let bean = Bean::from_string(content).unwrap();
897 assert_eq!(bean.id, "10");
898 assert_eq!(bean.title, "CRLF Test");
899 assert!(bean.description.is_some());
900 }
901
902 #[test]
903 fn test_parse_md_description_does_not_override_yaml_description() {
904 let content = r#"---
905id: "11"
906title: Override Test
907status: open
908priority: 2
909created_at: "2026-01-01T00:00:00Z"
910updated_at: "2026-01-01T00:00:00Z"
911description: "From YAML metadata"
912---
913
914# From Markdown Body
915
916This should not override.
917"#;
918 let bean = Bean::from_string(content).unwrap();
919 assert_eq!(bean.description, Some("From YAML metadata".to_string()));
921 }
922
923 #[test]
928 fn test_hash_consistency() {
929 let bean1 = Bean::new("1", "Test bean");
930 let bean2 = bean1.clone();
931 assert_eq!(bean1.hash(), bean2.hash());
933 assert_eq!(bean1.hash(), bean1.hash());
935 }
936
937 #[test]
938 fn test_hash_changes_with_content() {
939 let bean1 = Bean::new("1", "Test bean");
940 let bean2 = Bean::new("1", "Different title");
941 assert_ne!(bean1.hash(), bean2.hash());
942 }
943
944 #[test]
945 fn test_from_file_with_hash() {
946 let bean = Bean::new("42", "Hash file test");
947 let expected_hash = bean.hash();
948
949 let tmp = NamedTempFile::new().unwrap();
950 bean.to_file(tmp.path()).unwrap();
951
952 let (loaded, hash) = Bean::from_file_with_hash(tmp.path()).unwrap();
953 assert_eq!(loaded, bean);
954 assert_eq!(hash, expected_hash);
955 }
956
957 #[test]
962 fn on_close_empty_vec_not_serialized() {
963 let bean = Bean::new("1", "No actions");
964 let yaml = serde_yml::to_string(&bean).unwrap();
965 assert!(!yaml.contains("on_close"));
966 }
967
968 #[test]
969 fn on_close_round_trip_run_action() {
970 let mut bean = Bean::new("1", "With run");
971 bean.on_close = vec![OnCloseAction::Run {
972 command: "echo hi".to_string(),
973 }];
974
975 let yaml = serde_yml::to_string(&bean).unwrap();
976 assert!(yaml.contains("on_close"));
977 assert!(yaml.contains("action: run"));
978 assert!(yaml.contains("echo hi"));
979
980 let restored: Bean = serde_yml::from_str(&yaml).unwrap();
981 assert_eq!(restored.on_close, bean.on_close);
982 }
983
984 #[test]
985 fn on_close_round_trip_notify_action() {
986 let mut bean = Bean::new("1", "With notify");
987 bean.on_close = vec![OnCloseAction::Notify {
988 message: "Done!".to_string(),
989 }];
990
991 let yaml = serde_yml::to_string(&bean).unwrap();
992 assert!(yaml.contains("action: notify"));
993 assert!(yaml.contains("Done!"));
994
995 let restored: Bean = serde_yml::from_str(&yaml).unwrap();
996 assert_eq!(restored.on_close, bean.on_close);
997 }
998
999 #[test]
1000 fn on_close_round_trip_multiple_actions() {
1001 let mut bean = Bean::new("1", "Multiple actions");
1002 bean.on_close = vec![
1003 OnCloseAction::Run {
1004 command: "make deploy".to_string(),
1005 },
1006 OnCloseAction::Notify {
1007 message: "Deployed".to_string(),
1008 },
1009 OnCloseAction::Run {
1010 command: "echo cleanup".to_string(),
1011 },
1012 ];
1013
1014 let yaml = serde_yml::to_string(&bean).unwrap();
1015 let restored: Bean = serde_yml::from_str(&yaml).unwrap();
1016 assert_eq!(restored.on_close.len(), 3);
1017 assert_eq!(restored.on_close, bean.on_close);
1018 }
1019
1020 #[test]
1021 fn on_close_deserialized_from_yaml() {
1022 let yaml = r#"
1023id: "1"
1024title: From YAML
1025status: open
1026priority: 2
1027created_at: "2026-01-01T00:00:00Z"
1028updated_at: "2026-01-01T00:00:00Z"
1029on_close:
1030 - action: run
1031 command: "cargo test"
1032 - action: notify
1033 message: "Tests passed"
1034"#;
1035 let bean: Bean = serde_yml::from_str(yaml).unwrap();
1036 assert_eq!(bean.on_close.len(), 2);
1037 assert_eq!(
1038 bean.on_close[0],
1039 OnCloseAction::Run {
1040 command: "cargo test".to_string()
1041 }
1042 );
1043 assert_eq!(
1044 bean.on_close[1],
1045 OnCloseAction::Notify {
1046 message: "Tests passed".to_string()
1047 }
1048 );
1049 }
1050
1051 #[test]
1056 fn history_empty_not_serialized() {
1057 let bean = Bean::new("1", "No history");
1058 let yaml = serde_yml::to_string(&bean).unwrap();
1059 assert!(!yaml.contains("history:"));
1060 }
1061
1062 #[test]
1063 fn history_round_trip_yaml() {
1064 let now = Utc::now();
1065 let mut bean = Bean::new("1", "With history");
1066 bean.history = vec![
1067 RunRecord {
1068 attempt: 1,
1069 started_at: now,
1070 finished_at: Some(now),
1071 duration_secs: Some(5.2),
1072 agent: Some("agent-1".to_string()),
1073 result: RunResult::Fail,
1074 exit_code: Some(1),
1075 tokens: None,
1076 cost: None,
1077 output_snippet: Some("error: test failed".to_string()),
1078 },
1079 RunRecord {
1080 attempt: 2,
1081 started_at: now,
1082 finished_at: Some(now),
1083 duration_secs: Some(3.1),
1084 agent: Some("agent-1".to_string()),
1085 result: RunResult::Pass,
1086 exit_code: Some(0),
1087 tokens: Some(12000),
1088 cost: Some(0.05),
1089 output_snippet: None,
1090 },
1091 ];
1092
1093 let yaml = serde_yml::to_string(&bean).unwrap();
1094 assert!(yaml.contains("history:"));
1095
1096 let restored: Bean = serde_yml::from_str(&yaml).unwrap();
1097 assert_eq!(restored.history.len(), 2);
1098 assert_eq!(restored.history[0].result, RunResult::Fail);
1099 assert_eq!(restored.history[1].result, RunResult::Pass);
1100 assert_eq!(restored.history[0].attempt, 1);
1101 assert_eq!(restored.history[1].attempt, 2);
1102 assert_eq!(restored.history, bean.history);
1103 }
1104
1105 #[test]
1106 fn history_deserialized_from_yaml() {
1107 let yaml = r#"
1108id: "1"
1109title: From YAML
1110status: open
1111priority: 2
1112created_at: "2026-01-01T00:00:00Z"
1113updated_at: "2026-01-01T00:00:00Z"
1114history:
1115 - attempt: 1
1116 started_at: "2026-01-01T00:01:00Z"
1117 duration_secs: 10.0
1118 result: timeout
1119 exit_code: 124
1120 - attempt: 2
1121 started_at: "2026-01-01T00:05:00Z"
1122 finished_at: "2026-01-01T00:05:03Z"
1123 duration_secs: 3.0
1124 agent: agent-7
1125 result: pass
1126 exit_code: 0
1127"#;
1128 let bean: Bean = serde_yml::from_str(yaml).unwrap();
1129 assert_eq!(bean.history.len(), 2);
1130 assert_eq!(bean.history[0].result, RunResult::Timeout);
1131 assert_eq!(bean.history[0].exit_code, Some(124));
1132 assert_eq!(bean.history[1].result, RunResult::Pass);
1133 assert_eq!(bean.history[1].agent, Some("agent-7".to_string()));
1134 }
1135
1136 #[test]
1141 fn on_fail_none_not_serialized() {
1142 let bean = Bean::new("1", "No fail action");
1143 let yaml = serde_yml::to_string(&bean).unwrap();
1144 assert!(!yaml.contains("on_fail"));
1145 }
1146
1147 #[test]
1148 fn on_fail_retry_round_trip() {
1149 let mut bean = Bean::new("1", "With retry");
1150 bean.on_fail = Some(OnFailAction::Retry {
1151 max: Some(5),
1152 delay_secs: Some(10),
1153 });
1154
1155 let yaml = serde_yml::to_string(&bean).unwrap();
1156 assert!(yaml.contains("on_fail"));
1157 assert!(yaml.contains("action: retry"));
1158 assert!(yaml.contains("max: 5"));
1159 assert!(yaml.contains("delay_secs: 10"));
1160
1161 let restored: Bean = serde_yml::from_str(&yaml).unwrap();
1162 assert_eq!(restored.on_fail, bean.on_fail);
1163 }
1164
1165 #[test]
1166 fn on_fail_retry_minimal_round_trip() {
1167 let mut bean = Bean::new("1", "Retry minimal");
1168 bean.on_fail = Some(OnFailAction::Retry {
1169 max: None,
1170 delay_secs: None,
1171 });
1172
1173 let yaml = serde_yml::to_string(&bean).unwrap();
1174 assert!(yaml.contains("action: retry"));
1175 assert!(!yaml.contains("max:"));
1177 assert!(!yaml.contains("delay_secs:"));
1178
1179 let restored: Bean = serde_yml::from_str(&yaml).unwrap();
1180 assert_eq!(restored.on_fail, bean.on_fail);
1181 }
1182
1183 #[test]
1184 fn on_fail_escalate_round_trip() {
1185 let mut bean = Bean::new("1", "With escalate");
1186 bean.on_fail = Some(OnFailAction::Escalate {
1187 priority: Some(0),
1188 message: Some("Needs attention".to_string()),
1189 });
1190
1191 let yaml = serde_yml::to_string(&bean).unwrap();
1192 assert!(yaml.contains("action: escalate"));
1193 assert!(yaml.contains("priority: 0"));
1194 assert!(yaml.contains("Needs attention"));
1195
1196 let restored: Bean = serde_yml::from_str(&yaml).unwrap();
1197 assert_eq!(restored.on_fail, bean.on_fail);
1198 }
1199
1200 #[test]
1201 fn on_fail_escalate_minimal_round_trip() {
1202 let mut bean = Bean::new("1", "Escalate minimal");
1203 bean.on_fail = Some(OnFailAction::Escalate {
1204 priority: None,
1205 message: None,
1206 });
1207
1208 let yaml = serde_yml::to_string(&bean).unwrap();
1209 assert!(yaml.contains("action: escalate"));
1210 let on_fail_section = yaml.split("on_fail:").nth(1).unwrap();
1213 let on_fail_end = on_fail_section
1214 .find("\non_close:")
1215 .or_else(|| on_fail_section.find("\nhistory:"))
1216 .unwrap_or(on_fail_section.len());
1217 let on_fail_block = &on_fail_section[..on_fail_end];
1218 assert!(
1219 !on_fail_block.contains("priority:"),
1220 "on_fail block should not contain priority"
1221 );
1222 assert!(
1223 !on_fail_block.contains("message:"),
1224 "on_fail block should not contain message"
1225 );
1226
1227 let restored: Bean = serde_yml::from_str(&yaml).unwrap();
1228 assert_eq!(restored.on_fail, bean.on_fail);
1229 }
1230
1231 #[test]
1232 fn on_fail_deserialized_from_yaml() {
1233 let yaml = r#"
1234id: "1"
1235title: From YAML
1236status: open
1237priority: 2
1238created_at: "2026-01-01T00:00:00Z"
1239updated_at: "2026-01-01T00:00:00Z"
1240on_fail:
1241 action: retry
1242 max: 3
1243 delay_secs: 30
1244"#;
1245 let bean: Bean = serde_yml::from_str(yaml).unwrap();
1246 assert_eq!(
1247 bean.on_fail,
1248 Some(OnFailAction::Retry {
1249 max: Some(3),
1250 delay_secs: Some(30),
1251 })
1252 );
1253 }
1254
1255 #[test]
1256 fn on_fail_escalate_deserialized_from_yaml() {
1257 let yaml = r#"
1258id: "1"
1259title: Escalate YAML
1260status: open
1261priority: 2
1262created_at: "2026-01-01T00:00:00Z"
1263updated_at: "2026-01-01T00:00:00Z"
1264on_fail:
1265 action: escalate
1266 priority: 0
1267 message: "Critical failure"
1268"#;
1269 let bean: Bean = serde_yml::from_str(yaml).unwrap();
1270 assert_eq!(
1271 bean.on_fail,
1272 Some(OnFailAction::Escalate {
1273 priority: Some(0),
1274 message: Some("Critical failure".to_string()),
1275 })
1276 );
1277 }
1278
1279 #[test]
1284 fn outputs_none_not_serialized() {
1285 let bean = Bean::new("1", "No outputs");
1286 let yaml = serde_yml::to_string(&bean).unwrap();
1287 assert!(
1288 !yaml.contains("outputs:"),
1289 "outputs field should be omitted when None, got:\n{yaml}"
1290 );
1291 }
1292
1293 #[test]
1294 fn outputs_round_trip_nested_object() {
1295 let mut bean = Bean::new("1", "With outputs");
1296 bean.outputs = Some(serde_json::json!({
1297 "test_results": {
1298 "passed": 42,
1299 "failed": 0,
1300 "skipped": 3
1301 },
1302 "coverage": 87.5
1303 }));
1304
1305 let yaml = serde_yml::to_string(&bean).unwrap();
1306 assert!(yaml.contains("outputs"));
1307
1308 let restored: Bean = serde_yml::from_str(&yaml).unwrap();
1309 assert_eq!(restored.outputs, bean.outputs);
1310 let out = restored.outputs.unwrap();
1311 assert_eq!(out["test_results"]["passed"], 42);
1312 assert_eq!(out["coverage"], 87.5);
1313 }
1314
1315 #[test]
1316 fn outputs_round_trip_array() {
1317 let mut bean = Bean::new("1", "Array outputs");
1318 bean.outputs = Some(serde_json::json!(["artifact1.tar.gz", "artifact2.zip"]));
1319
1320 let yaml = serde_yml::to_string(&bean).unwrap();
1321 let restored: Bean = serde_yml::from_str(&yaml).unwrap();
1322 assert_eq!(restored.outputs, bean.outputs);
1323 let arr = restored.outputs.unwrap();
1324 assert_eq!(arr.as_array().unwrap().len(), 2);
1325 assert_eq!(arr[0], "artifact1.tar.gz");
1326 }
1327
1328 #[test]
1329 fn outputs_round_trip_simple_values() {
1330 let mut bean = Bean::new("1", "String output");
1332 bean.outputs = Some(serde_json::json!("just a string"));
1333 let yaml = serde_yml::to_string(&bean).unwrap();
1334 let restored: Bean = serde_yml::from_str(&yaml).unwrap();
1335 assert_eq!(restored.outputs, bean.outputs);
1336
1337 bean.outputs = Some(serde_json::json!(42));
1339 let yaml = serde_yml::to_string(&bean).unwrap();
1340 let restored: Bean = serde_yml::from_str(&yaml).unwrap();
1341 assert_eq!(restored.outputs, bean.outputs);
1342
1343 bean.outputs = Some(serde_json::json!(true));
1345 let yaml = serde_yml::to_string(&bean).unwrap();
1346 let restored: Bean = serde_yml::from_str(&yaml).unwrap();
1347 assert_eq!(restored.outputs, bean.outputs);
1348 }
1349
1350 #[test]
1351 fn max_loops_defaults_to_none() {
1352 let bean = Bean::new("1", "No max_loops");
1353 assert_eq!(bean.max_loops, None);
1354 let yaml = serde_yml::to_string(&bean).unwrap();
1355 assert!(!yaml.contains("max_loops:"));
1356 }
1357
1358 #[test]
1359 fn max_loops_overrides_config_when_set() {
1360 let mut bean = Bean::new("1", "With max_loops");
1361 bean.max_loops = Some(5);
1362
1363 let yaml = serde_yml::to_string(&bean).unwrap();
1364 assert!(yaml.contains("max_loops: 5"));
1365
1366 let restored: Bean = serde_yml::from_str(&yaml).unwrap();
1367 assert_eq!(restored.max_loops, Some(5));
1368 }
1369
1370 #[test]
1371 fn max_loops_effective_returns_bean_value_when_set() {
1372 let mut bean = Bean::new("1", "Override");
1373 bean.max_loops = Some(20);
1374 assert_eq!(bean.effective_max_loops(10), 20);
1375 }
1376
1377 #[test]
1378 fn max_loops_effective_returns_config_value_when_none() {
1379 let bean = Bean::new("1", "Default");
1380 assert_eq!(bean.effective_max_loops(10), 10);
1381 assert_eq!(bean.effective_max_loops(42), 42);
1382 }
1383
1384 #[test]
1385 fn max_loops_zero_means_unlimited() {
1386 let mut bean = Bean::new("1", "Unlimited");
1387 bean.max_loops = Some(0);
1388 assert_eq!(bean.effective_max_loops(10), 0);
1389
1390 let bean2 = Bean::new("2", "Config unlimited");
1392 assert_eq!(bean2.effective_max_loops(0), 0);
1393 }
1394
1395 #[test]
1396 fn outputs_deserialized_from_yaml() {
1397 let yaml = r#"
1398id: "1"
1399title: Outputs YAML
1400status: open
1401priority: 2
1402created_at: "2026-01-01T00:00:00Z"
1403updated_at: "2026-01-01T00:00:00Z"
1404outputs:
1405 binary: /tmp/build/app
1406 size_bytes: 1048576
1407 checksums:
1408 sha256: abc123
1409"#;
1410 let bean: Bean = serde_yml::from_str(yaml).unwrap();
1411 assert!(bean.outputs.is_some());
1412 let out = bean.outputs.unwrap();
1413 assert_eq!(out["binary"], "/tmp/build/app");
1414 assert_eq!(out["size_bytes"], 1048576);
1415 assert_eq!(out["checksums"]["sha256"], "abc123");
1416 }
1417}