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
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum Status {
16 Open,
17 InProgress,
18 Closed,
19}
20
21impl std::fmt::Display for Status {
22 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23 match self {
24 Status::Open => write!(f, "open"),
25 Status::InProgress => write!(f, "in_progress"),
26 Status::Closed => write!(f, "closed"),
27 }
28 }
29}
30
31pub fn validate_priority(priority: u8) -> Result<()> {
37 if priority > 4 {
38 return Err(anyhow::anyhow!(
39 "Invalid priority: {}. Priority must be in range 0-4 (P0-P4)",
40 priority
41 ));
42 }
43 Ok(())
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case")]
53pub enum RunResult {
54 Pass,
55 Fail,
56 Timeout,
57 Cancelled,
58}
59
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
62pub struct RunRecord {
63 pub attempt: u32,
64 pub started_at: DateTime<Utc>,
65 #[serde(skip_serializing_if = "Option::is_none")]
66 pub finished_at: Option<DateTime<Utc>>,
67 #[serde(skip_serializing_if = "Option::is_none")]
68 pub duration_secs: Option<f64>,
69 #[serde(skip_serializing_if = "Option::is_none")]
70 pub agent: Option<String>,
71 pub result: RunResult,
72 #[serde(skip_serializing_if = "Option::is_none")]
73 pub exit_code: Option<i32>,
74 #[serde(skip_serializing_if = "Option::is_none")]
75 pub tokens: Option<u64>,
76 #[serde(skip_serializing_if = "Option::is_none")]
77 pub cost: Option<f64>,
78 #[serde(skip_serializing_if = "Option::is_none")]
79 pub output_snippet: Option<String>,
80}
81
82#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
88#[serde(tag = "action", rename_all = "snake_case")]
89pub enum OnFailAction {
90 Retry {
92 #[serde(skip_serializing_if = "Option::is_none")]
93 max: Option<u32>,
94 #[serde(skip_serializing_if = "Option::is_none")]
95 delay_secs: Option<u64>,
96 },
97 Escalate {
99 #[serde(skip_serializing_if = "Option::is_none")]
100 priority: Option<u8>,
101 #[serde(skip_serializing_if = "Option::is_none")]
102 message: Option<String>,
103 },
104}
105
106#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
109#[serde(tag = "action", rename_all = "snake_case")]
110pub enum OnCloseAction {
111 Run { command: String },
113 Notify { message: String },
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(rename_all = "snake_case")]
124pub enum AttemptOutcome {
125 Success,
126 Failed,
127 Abandoned,
128}
129
130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132pub struct AttemptRecord {
133 pub num: u32,
134 pub outcome: AttemptOutcome,
135 #[serde(skip_serializing_if = "Option::is_none")]
136 pub notes: Option<String>,
137 #[serde(skip_serializing_if = "Option::is_none")]
138 pub agent: Option<String>,
139 #[serde(skip_serializing_if = "Option::is_none")]
140 pub started_at: Option<DateTime<Utc>>,
141 #[serde(skip_serializing_if = "Option::is_none")]
142 pub finished_at: Option<DateTime<Utc>>,
143}
144
145#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
150pub struct Bean {
151 pub id: String,
152 pub title: String,
153 #[serde(skip_serializing_if = "Option::is_none")]
154 pub slug: Option<String>,
155 pub status: Status,
156 #[serde(default = "default_priority")]
157 pub priority: u8,
158 pub created_at: DateTime<Utc>,
159 pub updated_at: DateTime<Utc>,
160
161 #[serde(skip_serializing_if = "Option::is_none")]
162 pub description: Option<String>,
163 #[serde(skip_serializing_if = "Option::is_none")]
164 pub acceptance: Option<String>,
165 #[serde(skip_serializing_if = "Option::is_none")]
166 pub notes: Option<String>,
167 #[serde(skip_serializing_if = "Option::is_none")]
168 pub design: Option<String>,
169
170 #[serde(default, skip_serializing_if = "Vec::is_empty")]
171 pub labels: Vec<String>,
172 #[serde(skip_serializing_if = "Option::is_none")]
173 pub assignee: Option<String>,
174
175 #[serde(skip_serializing_if = "Option::is_none")]
176 pub closed_at: Option<DateTime<Utc>>,
177 #[serde(skip_serializing_if = "Option::is_none")]
178 pub close_reason: Option<String>,
179
180 #[serde(skip_serializing_if = "Option::is_none")]
181 pub parent: Option<String>,
182 #[serde(default, skip_serializing_if = "Vec::is_empty")]
183 pub dependencies: Vec<String>,
184
185 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub verify: Option<String>,
189 #[serde(default, skip_serializing_if = "is_false")]
192 pub fail_first: bool,
193 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub checkpoint: Option<String>,
197 #[serde(default, skip_serializing_if = "is_zero")]
199 pub attempts: u32,
200 #[serde(
202 default = "default_max_attempts",
203 skip_serializing_if = "is_default_max_attempts"
204 )]
205 pub max_attempts: u32,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub claimed_by: Option<String>,
209 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub claimed_at: Option<DateTime<Utc>>,
212
213 #[serde(default, skip_serializing_if = "is_false")]
215 pub is_archived: bool,
216
217 #[serde(default, skip_serializing_if = "Vec::is_empty")]
220 pub produces: Vec<String>,
221
222 #[serde(default, skip_serializing_if = "Vec::is_empty")]
225 pub requires: Vec<String>,
226
227 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub tokens: Option<u64>,
231
232 #[serde(default, skip_serializing_if = "Option::is_none")]
234 pub tokens_updated: Option<DateTime<Utc>>,
235
236 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub on_fail: Option<OnFailAction>,
239
240 #[serde(default, skip_serializing_if = "Vec::is_empty")]
243 pub on_close: Vec<OnCloseAction>,
244
245 #[serde(default, skip_serializing_if = "Vec::is_empty")]
247 pub history: Vec<RunRecord>,
248
249 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub outputs: Option<serde_json::Value>,
252
253 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub max_loops: Option<u32>,
256
257 #[serde(default, skip_serializing_if = "Option::is_none")]
260 pub verify_timeout: Option<u64>,
261
262 #[serde(
265 default = "default_bean_type",
266 skip_serializing_if = "is_default_bean_type"
267 )]
268 pub bean_type: String,
269
270 #[serde(default, skip_serializing_if = "Option::is_none")]
272 pub last_verified: Option<DateTime<Utc>>,
273
274 #[serde(default, skip_serializing_if = "Option::is_none")]
276 pub stale_after: Option<DateTime<Utc>>,
277
278 #[serde(default, skip_serializing_if = "Vec::is_empty")]
280 pub paths: Vec<String>,
281
282 #[serde(default, skip_serializing_if = "Vec::is_empty")]
285 pub attempt_log: Vec<AttemptRecord>,
286}
287
288fn default_priority() -> u8 {
289 2
290}
291
292fn default_max_attempts() -> u32 {
293 3
294}
295
296fn is_zero(v: &u32) -> bool {
297 *v == 0
298}
299
300fn is_default_max_attempts(v: &u32) -> bool {
301 *v == 3
302}
303
304fn is_false(v: &bool) -> bool {
305 !*v
306}
307
308fn default_bean_type() -> String {
309 "task".to_string()
310}
311
312fn is_default_bean_type(v: &str) -> bool {
313 v == "task"
314}
315
316impl Bean {
317 pub fn try_new(id: impl Into<String>, title: impl Into<String>) -> Result<Self> {
320 let id_str = id.into();
321 validate_bean_id(&id_str)?;
322
323 let now = Utc::now();
324 Ok(Self {
325 id: id_str,
326 title: title.into(),
327 slug: None,
328 status: Status::Open,
329 priority: 2,
330 created_at: now,
331 updated_at: now,
332 description: None,
333 acceptance: None,
334 notes: None,
335 design: None,
336 labels: Vec::new(),
337 assignee: None,
338 closed_at: None,
339 close_reason: None,
340 parent: None,
341 dependencies: Vec::new(),
342 verify: None,
343 fail_first: false,
344 checkpoint: None,
345 attempts: 0,
346 max_attempts: 3,
347 claimed_by: None,
348 claimed_at: None,
349 is_archived: false,
350 produces: Vec::new(),
351 requires: Vec::new(),
352 tokens: None,
353 tokens_updated: None,
354 on_fail: None,
355 on_close: Vec::new(),
356 history: Vec::new(),
357 outputs: None,
358 max_loops: None,
359 verify_timeout: None,
360 bean_type: "task".to_string(),
361 last_verified: None,
362 stale_after: None,
363 paths: Vec::new(),
364 attempt_log: Vec::new(),
365 })
366 }
367
368 pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
371 Self::try_new(id, title).expect("Invalid bean ID")
372 }
373
374 pub fn effective_max_loops(&self, config_max: u32) -> u32 {
377 self.max_loops.unwrap_or(config_max)
378 }
379
380 pub fn effective_verify_timeout(&self, config_timeout: Option<u64>) -> Option<u64> {
382 self.verify_timeout.or(config_timeout)
383 }
384
385 fn parse_frontmatter(content: &str) -> Result<(String, Option<String>)> {
397 if !content.starts_with("---\n") && !content.starts_with("---\r\n") {
399 return Err(anyhow::anyhow!("Not markdown frontmatter format"));
401 }
402
403 let after_first_delimiter = if let Some(stripped) = content.strip_prefix("---\r\n") {
405 stripped
406 } else if let Some(stripped) = content.strip_prefix("---\n") {
407 stripped
408 } else {
409 return Err(anyhow::anyhow!("Not markdown frontmatter format"));
410 };
411
412 let second_delimiter_pos = after_first_delimiter.find("---").ok_or_else(|| {
413 anyhow::anyhow!("Markdown frontmatter is missing closing delimiter (---)")
414 })?;
415 let frontmatter = &after_first_delimiter[..second_delimiter_pos];
416
417 let body_start = second_delimiter_pos + 3;
419 let body_raw = &after_first_delimiter[body_start..];
420
421 let body = body_raw.trim();
423 let body = (!body.is_empty()).then(|| body.to_string());
424
425 Ok((frontmatter.to_string(), body))
426 }
427
428 pub fn from_string(content: &str) -> Result<Self> {
430 match Self::parse_frontmatter(content) {
432 Ok((frontmatter, body)) => {
433 let mut bean: Bean = serde_yml::from_str(&frontmatter)?;
435
436 if let Some(markdown_body) = body {
438 if bean.description.is_none() {
439 bean.description = Some(markdown_body);
440 }
441 }
442
443 Ok(bean)
444 }
445 Err(_) => {
446 let bean: Bean = serde_yml::from_str(content)?;
448 Ok(bean)
449 }
450 }
451 }
452
453 pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
455 let contents = std::fs::read_to_string(path.as_ref())?;
456 Self::from_string(&contents)
457 }
458
459 pub fn to_file(&self, path: impl AsRef<Path>) -> Result<()> {
463 let path = path.as_ref();
464 let is_md = path.extension().and_then(|e| e.to_str()) == Some("md");
465
466 if is_md && self.description.is_some() {
467 let mut frontmatter_bean = self.clone();
469 let description = frontmatter_bean.description.take(); let yaml = serde_yml::to_string(&frontmatter_bean)?;
471 let mut content = String::from("---\n");
472 content.push_str(yaml.trim_start_matches("---\n").trim_end());
473 content.push_str("\n---\n");
474 if let Some(desc) = description {
475 content.push('\n');
476 content.push_str(&desc);
477 if !desc.ends_with('\n') {
478 content.push('\n');
479 }
480 }
481 atomic_write(path, &content)?;
482 } else {
483 let yaml = serde_yml::to_string(self)?;
484 atomic_write(path, &yaml)?;
485 }
486 Ok(())
487 }
488
489 pub fn hash(&self) -> String {
494 use sha2::{Digest, Sha256};
495 let canonical = self.clone();
496
497 let json = serde_json::to_string(&canonical).expect("Bean serialization to JSON cannot fail");
499 let mut hasher = Sha256::new();
500 hasher.update(json.as_bytes());
501 format!("{:x}", hasher.finalize())
502 }
503
504 pub fn from_file_with_hash(path: impl AsRef<Path>) -> Result<(Self, String)> {
509 let bean = Self::from_file(path)?;
510 let hash = bean.hash();
511 Ok((bean, hash))
512 }
513
514 pub fn apply_value(&mut self, field: &str, json_value: &str) -> Result<()> {
527 match field {
528 "title" => self.title = serde_json::from_str(json_value)?,
529 "status" => self.status = serde_json::from_str(json_value)?,
530 "priority" => self.priority = serde_json::from_str(json_value)?,
531 "description" => self.description = serde_json::from_str(json_value)?,
532 "acceptance" => self.acceptance = serde_json::from_str(json_value)?,
533 "notes" => self.notes = serde_json::from_str(json_value)?,
534 "design" => self.design = serde_json::from_str(json_value)?,
535 "assignee" => self.assignee = serde_json::from_str(json_value)?,
536 "labels" => self.labels = serde_json::from_str(json_value)?,
537 "dependencies" => self.dependencies = serde_json::from_str(json_value)?,
538 "parent" => self.parent = serde_json::from_str(json_value)?,
539 "verify" => self.verify = serde_json::from_str(json_value)?,
540 "produces" => self.produces = serde_json::from_str(json_value)?,
541 "requires" => self.requires = serde_json::from_str(json_value)?,
542 "claimed_by" => self.claimed_by = serde_json::from_str(json_value)?,
543 "close_reason" => self.close_reason = serde_json::from_str(json_value)?,
544 "on_fail" => self.on_fail = serde_json::from_str(json_value)?,
545 "tokens" => self.tokens = serde_json::from_str(json_value)?,
546 "tokens_updated" => self.tokens_updated = serde_json::from_str(json_value)?,
547 "outputs" => self.outputs = serde_json::from_str(json_value)?,
548 "max_loops" => self.max_loops = serde_json::from_str(json_value)?,
549 "bean_type" => self.bean_type = serde_json::from_str(json_value)?,
550 "last_verified" => self.last_verified = serde_json::from_str(json_value)?,
551 "stale_after" => self.stale_after = serde_json::from_str(json_value)?,
552 "paths" => self.paths = serde_json::from_str(json_value)?,
553 _ => return Err(anyhow::anyhow!("Unknown field: {}", field)),
554 }
555 self.updated_at = Utc::now();
556 Ok(())
557 }
558}
559
560#[cfg(test)]
565mod tests {
566 use super::*;
567 use tempfile::NamedTempFile;
568
569 #[test]
570 fn round_trip_minimal_bean() {
571 let bean = Bean::new("1", "My first bean");
572
573 let yaml = serde_yml::to_string(&bean).unwrap();
575
576 let restored: Bean = serde_yml::from_str(&yaml).unwrap();
578
579 assert_eq!(bean, restored);
580 }
581
582 #[test]
583 fn round_trip_full_bean() {
584 let now = Utc::now();
585 let bean = Bean {
586 id: "3.2.1".to_string(),
587 title: "Implement parser".to_string(),
588 slug: None,
589 status: Status::InProgress,
590 priority: 1,
591 created_at: now,
592 updated_at: now,
593 description: Some("Build a robust YAML parser".to_string()),
594 acceptance: Some("All tests pass".to_string()),
595 notes: Some("Watch out for edge cases".to_string()),
596 design: Some("Use serde_yaml".to_string()),
597 labels: vec!["backend".to_string(), "core".to_string()],
598 assignee: Some("alice".to_string()),
599 closed_at: Some(now),
600 close_reason: Some("Done".to_string()),
601 parent: Some("3.2".to_string()),
602 dependencies: vec!["3.1".to_string()],
603 verify: Some("cargo test".to_string()),
604 fail_first: false,
605 checkpoint: None,
606 attempts: 1,
607 max_attempts: 5,
608 claimed_by: Some("agent-7".to_string()),
609 claimed_at: Some(now),
610 is_archived: false,
611 produces: vec!["Parser".to_string()],
612 requires: vec!["Lexer".to_string()],
613 tokens: Some(15000),
614 tokens_updated: Some(now),
615 on_fail: Some(OnFailAction::Retry {
616 max: Some(5),
617 delay_secs: None,
618 }),
619 on_close: vec![
620 OnCloseAction::Run {
621 command: "echo done".to_string(),
622 },
623 OnCloseAction::Notify {
624 message: "Task complete".to_string(),
625 },
626 ],
627 verify_timeout: None,
628 history: Vec::new(),
629 outputs: Some(serde_json::json!({"key": "value"})),
630 max_loops: None,
631 bean_type: "task".to_string(),
632 last_verified: None,
633 stale_after: None,
634 paths: Vec::new(),
635 attempt_log: Vec::new(),
636 };
637
638 let yaml = serde_yml::to_string(&bean).unwrap();
639 let restored: Bean = serde_yml::from_str(&yaml).unwrap();
640
641 assert_eq!(bean, restored);
642 }
643
644 #[test]
645 fn status_serializes_as_lowercase() {
646 let open = serde_yml::to_string(&Status::Open).unwrap();
647 let in_progress = serde_yml::to_string(&Status::InProgress).unwrap();
648 let closed = serde_yml::to_string(&Status::Closed).unwrap();
649
650 assert_eq!(open.trim(), "open");
651 assert_eq!(in_progress.trim(), "in_progress");
652 assert_eq!(closed.trim(), "closed");
653 }
654
655 #[test]
656 fn optional_fields_omitted_when_none() {
657 let bean = Bean::new("1", "Minimal");
658 let yaml = serde_yml::to_string(&bean).unwrap();
659
660 assert!(!yaml.contains("description:"));
661 assert!(!yaml.contains("acceptance:"));
662 assert!(!yaml.contains("notes:"));
663 assert!(!yaml.contains("design:"));
664 assert!(!yaml.contains("assignee:"));
665 assert!(!yaml.contains("closed_at:"));
666 assert!(!yaml.contains("close_reason:"));
667 assert!(!yaml.contains("parent:"));
668 assert!(!yaml.contains("labels:"));
669 assert!(!yaml.contains("dependencies:"));
670 assert!(!yaml.contains("verify:"));
671 assert!(!yaml.contains("attempts:"));
672 assert!(!yaml.contains("max_attempts:"));
673 assert!(!yaml.contains("claimed_by:"));
674 assert!(!yaml.contains("claimed_at:"));
675 assert!(!yaml.contains("is_archived:"));
676 assert!(!yaml.contains("tokens:"));
677 assert!(!yaml.contains("tokens_updated:"));
678 assert!(!yaml.contains("on_fail:"));
679 assert!(!yaml.contains("on_close:"));
680 assert!(!yaml.contains("history:"));
681 assert!(!yaml.contains("outputs:"));
682 }
683
684 #[test]
685 fn timestamps_serialize_as_iso8601() {
686 let bean = Bean::new("1", "Check timestamps");
687 let yaml = serde_yml::to_string(&bean).unwrap();
688
689 for line in yaml.lines() {
691 if line.starts_with("created_at:") || line.starts_with("updated_at:") {
692 let value = line.split_once(':').unwrap().1.trim();
693 assert!(value.contains('T'), "timestamp should be ISO 8601: {value}");
694 }
695 }
696 }
697
698 #[test]
699 fn file_round_trip() {
700 let bean = Bean::new("42", "File I/O test");
701
702 let tmp = NamedTempFile::new().unwrap();
703 let path = tmp.path().to_path_buf();
704
705 bean.to_file(&path).unwrap();
707
708 let restored = Bean::from_file(&path).unwrap();
710 assert_eq!(bean, restored);
711
712 let raw = std::fs::read_to_string(&path).unwrap();
714 assert!(raw.contains("id: '42'") || raw.contains("id: \"42\""));
715 assert!(raw.contains("title: File I/O test") || raw.contains("title: 'File I/O test'"));
716 drop(tmp);
717 }
718
719 #[test]
720 fn defaults_are_correct() {
721 let bean = Bean::new("1", "Defaults");
722 assert_eq!(bean.status, Status::Open);
723 assert_eq!(bean.priority, 2);
724 assert!(bean.labels.is_empty());
725 assert!(bean.dependencies.is_empty());
726 assert!(bean.description.is_none());
727 }
728
729 #[test]
730 fn deserialize_with_missing_optional_fields() {
731 let yaml = r#"
732id: "5"
733title: Sparse bean
734status: open
735priority: 3
736created_at: "2025-01-01T00:00:00Z"
737updated_at: "2025-01-01T00:00:00Z"
738"#;
739 let bean: Bean = serde_yml::from_str(yaml).unwrap();
740 assert_eq!(bean.id, "5");
741 assert_eq!(bean.priority, 3);
742 assert!(bean.description.is_none());
743 assert!(bean.labels.is_empty());
744 }
745
746 #[test]
747 fn validate_priority_accepts_valid_range() {
748 for priority in 0..=4 {
749 assert!(
750 validate_priority(priority).is_ok(),
751 "Priority {} should be valid",
752 priority
753 );
754 }
755 }
756
757 #[test]
758 fn validate_priority_rejects_out_of_range() {
759 assert!(validate_priority(5).is_err());
760 assert!(validate_priority(10).is_err());
761 assert!(validate_priority(255).is_err());
762 }
763
764 #[test]
769 fn test_parse_md_frontmatter() {
770 let content = r#"---
771id: 11.1
772title: Test Bean
773status: open
774priority: 2
775created_at: "2026-01-26T15:00:00Z"
776updated_at: "2026-01-26T15:00:00Z"
777---
778
779# Description
780
781Test markdown body.
782"#;
783 let bean = Bean::from_string(content).unwrap();
784 assert_eq!(bean.id, "11.1");
785 assert_eq!(bean.title, "Test Bean");
786 assert_eq!(bean.status, Status::Open);
787 assert!(bean.description.is_some());
788 assert!(bean.description.as_ref().unwrap().contains("# Description"));
789 assert!(bean
790 .description
791 .as_ref()
792 .unwrap()
793 .contains("Test markdown body"));
794 }
795
796 #[test]
797 fn test_parse_md_frontmatter_preserves_metadata_fields() {
798 let content = r#"---
799id: "2.5"
800title: Complex Bean
801status: in_progress
802priority: 1
803created_at: "2026-01-01T10:00:00Z"
804updated_at: "2026-01-26T15:00:00Z"
805parent: "2"
806labels:
807 - backend
808 - urgent
809dependencies:
810 - "2.1"
811 - "2.2"
812---
813
814## Implementation Notes
815
816This is a complex bean with multiple metadata fields.
817"#;
818 let bean = Bean::from_string(content).unwrap();
819 assert_eq!(bean.id, "2.5");
820 assert_eq!(bean.title, "Complex Bean");
821 assert_eq!(bean.status, Status::InProgress);
822 assert_eq!(bean.priority, 1);
823 assert_eq!(bean.parent, Some("2".to_string()));
824 assert_eq!(
825 bean.labels,
826 vec!["backend".to_string(), "urgent".to_string()]
827 );
828 assert_eq!(
829 bean.dependencies,
830 vec!["2.1".to_string(), "2.2".to_string()]
831 );
832 assert!(bean.description.is_some());
833 }
834
835 #[test]
836 fn test_parse_md_frontmatter_empty_body() {
837 let content = r#"---
838id: "3"
839title: No Body Bean
840status: open
841priority: 2
842created_at: "2026-01-01T00:00:00Z"
843updated_at: "2026-01-01T00:00:00Z"
844---
845"#;
846 let bean = Bean::from_string(content).unwrap();
847 assert_eq!(bean.id, "3");
848 assert_eq!(bean.title, "No Body Bean");
849 assert!(bean.description.is_none());
850 }
851
852 #[test]
853 fn test_parse_md_frontmatter_with_body_containing_dashes() {
854 let content = r#"---
855id: "4"
856title: Dashes in Body
857status: open
858priority: 2
859created_at: "2026-01-01T00:00:00Z"
860updated_at: "2026-01-01T00:00:00Z"
861---
862
863# Section 1
864
865This has --- inside the body, which should not break parsing.
866
867---
868
869More content after a horizontal rule.
870"#;
871 let bean = Bean::from_string(content).unwrap();
872 assert_eq!(bean.id, "4");
873 assert!(bean.description.is_some());
874 let body = bean.description.as_ref().unwrap();
875 assert!(body.contains("---"));
876 assert!(body.contains("horizontal rule"));
877 }
878
879 #[test]
880 fn test_parse_md_frontmatter_with_whitespace_in_body() {
881 let content = r#"---
882id: "5"
883title: Whitespace Test
884status: open
885priority: 2
886created_at: "2026-01-01T00:00:00Z"
887updated_at: "2026-01-01T00:00:00Z"
888---
889
890
891 Leading whitespace preserved after trimming newlines.
892
893"#;
894 let bean = Bean::from_string(content).unwrap();
895 assert_eq!(bean.id, "5");
896 assert!(bean.description.is_some());
897 let body = bean.description.as_ref().unwrap();
898 assert!(body.contains("Leading whitespace"));
900 }
901
902 #[test]
903 fn test_fallback_to_yaml_parsing() {
904 let yaml_content = r#"
905id: "6"
906title: Pure YAML Bean
907status: open
908priority: 3
909created_at: "2026-01-01T00:00:00Z"
910updated_at: "2026-01-01T00:00:00Z"
911description: "This is YAML, not markdown"
912"#;
913 let bean = Bean::from_string(yaml_content).unwrap();
914 assert_eq!(bean.id, "6");
915 assert_eq!(bean.title, "Pure YAML Bean");
916 assert_eq!(
917 bean.description,
918 Some("This is YAML, not markdown".to_string())
919 );
920 }
921
922 #[test]
923 fn test_file_round_trip_with_markdown() {
924 let content = r#"---
925id: "7"
926title: File Markdown Test
927status: open
928priority: 2
929created_at: "2026-01-01T00:00:00Z"
930updated_at: "2026-01-01T00:00:00Z"
931---
932
933# Markdown Body
934
935This is a test of reading markdown from a file.
936"#;
937
938 let dir = tempfile::tempdir().unwrap();
940 let path = dir.path().join("7-test.md");
941
942 std::fs::write(&path, content).unwrap();
944
945 let bean = Bean::from_file(&path).unwrap();
947 assert_eq!(bean.id, "7");
948 assert_eq!(bean.title, "File Markdown Test");
949 assert!(bean.description.is_some());
950 assert!(bean
951 .description
952 .as_ref()
953 .unwrap()
954 .contains("# Markdown Body"));
955
956 bean.to_file(&path).unwrap();
958
959 let written = std::fs::read_to_string(&path).unwrap();
961 assert!(
962 written.starts_with("---\n"),
963 "Should start with frontmatter delimiter, got: {}",
964 &written[..50.min(written.len())]
965 );
966 assert!(
967 written.contains("# Markdown Body"),
968 "Should contain markdown body"
969 );
970 let parts: Vec<&str> = written.splitn(3, "---").collect();
972 assert!(parts.len() >= 3, "Should have frontmatter delimiters");
973 let frontmatter_section = parts[1];
974 assert!(
975 !frontmatter_section.contains("# Markdown Body"),
976 "Description should be in body, not frontmatter"
977 );
978
979 let bean2 = Bean::from_file(&path).unwrap();
981 assert_eq!(bean2.id, bean.id);
982 assert_eq!(bean2.title, bean.title);
983 assert_eq!(bean2.description, bean.description);
984 }
985
986 #[test]
987 fn test_parse_md_frontmatter_missing_closing_delimiter() {
988 let bad_content = r#"---
989id: "8"
990title: Missing Delimiter
991status: open
992"#;
993 let result = Bean::from_string(bad_content);
994 assert!(result.is_err());
996 }
997
998 #[test]
999 fn test_parse_md_frontmatter_multiline_fields() {
1000 let content = r#"---
1001id: "9"
1002title: Multiline Test
1003status: open
1004priority: 2
1005created_at: "2026-01-01T00:00:00Z"
1006updated_at: "2026-01-01T00:00:00Z"
1007acceptance: |
1008 - Criterion 1
1009 - Criterion 2
1010 - Criterion 3
1011---
1012
1013# Implementation
1014
1015Start implementing...
1016"#;
1017 let bean = Bean::from_string(content).unwrap();
1018 assert_eq!(bean.id, "9");
1019 assert!(bean.acceptance.is_some());
1020 let acceptance = bean.acceptance.as_ref().unwrap();
1021 assert!(acceptance.contains("Criterion 1"));
1022 assert!(acceptance.contains("Criterion 2"));
1023 assert!(bean.description.is_some());
1024 }
1025
1026 #[test]
1027 fn test_parse_md_with_crlf_line_endings() {
1028 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.";
1029 let bean = Bean::from_string(content).unwrap();
1030 assert_eq!(bean.id, "10");
1031 assert_eq!(bean.title, "CRLF Test");
1032 assert!(bean.description.is_some());
1033 }
1034
1035 #[test]
1036 fn test_parse_md_description_does_not_override_yaml_description() {
1037 let content = r#"---
1038id: "11"
1039title: Override Test
1040status: open
1041priority: 2
1042created_at: "2026-01-01T00:00:00Z"
1043updated_at: "2026-01-01T00:00:00Z"
1044description: "From YAML metadata"
1045---
1046
1047# From Markdown Body
1048
1049This should not override.
1050"#;
1051 let bean = Bean::from_string(content).unwrap();
1052 assert_eq!(bean.description, Some("From YAML metadata".to_string()));
1054 }
1055
1056 #[test]
1061 fn test_hash_consistency() {
1062 let bean1 = Bean::new("1", "Test bean");
1063 let bean2 = bean1.clone();
1064 assert_eq!(bean1.hash(), bean2.hash());
1066 assert_eq!(bean1.hash(), bean1.hash());
1068 }
1069
1070 #[test]
1071 fn test_hash_changes_with_content() {
1072 let bean1 = Bean::new("1", "Test bean");
1073 let bean2 = Bean::new("1", "Different title");
1074 assert_ne!(bean1.hash(), bean2.hash());
1075 }
1076
1077 #[test]
1078 fn test_from_file_with_hash() {
1079 let bean = Bean::new("42", "Hash file test");
1080 let expected_hash = bean.hash();
1081
1082 let tmp = NamedTempFile::new().unwrap();
1083 bean.to_file(tmp.path()).unwrap();
1084
1085 let (loaded, hash) = Bean::from_file_with_hash(tmp.path()).unwrap();
1086 assert_eq!(loaded, bean);
1087 assert_eq!(hash, expected_hash);
1088 }
1089
1090 #[test]
1095 fn on_close_empty_vec_not_serialized() {
1096 let bean = Bean::new("1", "No actions");
1097 let yaml = serde_yml::to_string(&bean).unwrap();
1098 assert!(!yaml.contains("on_close"));
1099 }
1100
1101 #[test]
1102 fn on_close_round_trip_run_action() {
1103 let mut bean = Bean::new("1", "With run");
1104 bean.on_close = vec![OnCloseAction::Run {
1105 command: "echo hi".to_string(),
1106 }];
1107
1108 let yaml = serde_yml::to_string(&bean).unwrap();
1109 assert!(yaml.contains("on_close"));
1110 assert!(yaml.contains("action: run"));
1111 assert!(yaml.contains("echo hi"));
1112
1113 let restored: Bean = serde_yml::from_str(&yaml).unwrap();
1114 assert_eq!(restored.on_close, bean.on_close);
1115 }
1116
1117 #[test]
1118 fn on_close_round_trip_notify_action() {
1119 let mut bean = Bean::new("1", "With notify");
1120 bean.on_close = vec![OnCloseAction::Notify {
1121 message: "Done!".to_string(),
1122 }];
1123
1124 let yaml = serde_yml::to_string(&bean).unwrap();
1125 assert!(yaml.contains("action: notify"));
1126 assert!(yaml.contains("Done!"));
1127
1128 let restored: Bean = serde_yml::from_str(&yaml).unwrap();
1129 assert_eq!(restored.on_close, bean.on_close);
1130 }
1131
1132 #[test]
1133 fn on_close_round_trip_multiple_actions() {
1134 let mut bean = Bean::new("1", "Multiple actions");
1135 bean.on_close = vec![
1136 OnCloseAction::Run {
1137 command: "make deploy".to_string(),
1138 },
1139 OnCloseAction::Notify {
1140 message: "Deployed".to_string(),
1141 },
1142 OnCloseAction::Run {
1143 command: "echo cleanup".to_string(),
1144 },
1145 ];
1146
1147 let yaml = serde_yml::to_string(&bean).unwrap();
1148 let restored: Bean = serde_yml::from_str(&yaml).unwrap();
1149 assert_eq!(restored.on_close.len(), 3);
1150 assert_eq!(restored.on_close, bean.on_close);
1151 }
1152
1153 #[test]
1154 fn on_close_deserialized_from_yaml() {
1155 let yaml = r#"
1156id: "1"
1157title: From YAML
1158status: open
1159priority: 2
1160created_at: "2026-01-01T00:00:00Z"
1161updated_at: "2026-01-01T00:00:00Z"
1162on_close:
1163 - action: run
1164 command: "cargo test"
1165 - action: notify
1166 message: "Tests passed"
1167"#;
1168 let bean: Bean = serde_yml::from_str(yaml).unwrap();
1169 assert_eq!(bean.on_close.len(), 2);
1170 assert_eq!(
1171 bean.on_close[0],
1172 OnCloseAction::Run {
1173 command: "cargo test".to_string()
1174 }
1175 );
1176 assert_eq!(
1177 bean.on_close[1],
1178 OnCloseAction::Notify {
1179 message: "Tests passed".to_string()
1180 }
1181 );
1182 }
1183
1184 #[test]
1189 fn run_result_serializes_as_snake_case() {
1190 assert_eq!(
1191 serde_yml::to_string(&RunResult::Pass).unwrap().trim(),
1192 "pass"
1193 );
1194 assert_eq!(
1195 serde_yml::to_string(&RunResult::Fail).unwrap().trim(),
1196 "fail"
1197 );
1198 assert_eq!(
1199 serde_yml::to_string(&RunResult::Timeout).unwrap().trim(),
1200 "timeout"
1201 );
1202 assert_eq!(
1203 serde_yml::to_string(&RunResult::Cancelled).unwrap().trim(),
1204 "cancelled"
1205 );
1206 }
1207
1208 #[test]
1209 fn run_record_minimal_round_trip() {
1210 let now = Utc::now();
1211 let record = RunRecord {
1212 attempt: 1,
1213 started_at: now,
1214 finished_at: None,
1215 duration_secs: None,
1216 agent: None,
1217 result: RunResult::Pass,
1218 exit_code: None,
1219 tokens: None,
1220 cost: None,
1221 output_snippet: None,
1222 };
1223
1224 let yaml = serde_yml::to_string(&record).unwrap();
1225 let restored: RunRecord = serde_yml::from_str(&yaml).unwrap();
1226 assert_eq!(record, restored);
1227
1228 assert!(!yaml.contains("finished_at:"));
1230 assert!(!yaml.contains("duration_secs:"));
1231 assert!(!yaml.contains("agent:"));
1232 assert!(!yaml.contains("exit_code:"));
1233 assert!(!yaml.contains("tokens:"));
1234 assert!(!yaml.contains("cost:"));
1235 assert!(!yaml.contains("output_snippet:"));
1236 }
1237
1238 #[test]
1239 fn run_record_full_round_trip() {
1240 let now = Utc::now();
1241 let record = RunRecord {
1242 attempt: 3,
1243 started_at: now,
1244 finished_at: Some(now),
1245 duration_secs: Some(12.5),
1246 agent: Some("agent-42".to_string()),
1247 result: RunResult::Fail,
1248 exit_code: Some(1),
1249 tokens: Some(5000),
1250 cost: Some(0.03),
1251 output_snippet: Some("FAILED: assertion error".to_string()),
1252 };
1253
1254 let yaml = serde_yml::to_string(&record).unwrap();
1255 let restored: RunRecord = serde_yml::from_str(&yaml).unwrap();
1256 assert_eq!(record, restored);
1257 }
1258
1259 #[test]
1260 fn history_empty_not_serialized() {
1261 let bean = Bean::new("1", "No history");
1262 let yaml = serde_yml::to_string(&bean).unwrap();
1263 assert!(!yaml.contains("history:"));
1264 }
1265
1266 #[test]
1267 fn history_round_trip_yaml() {
1268 let now = Utc::now();
1269 let mut bean = Bean::new("1", "With history");
1270 bean.history = vec![
1271 RunRecord {
1272 attempt: 1,
1273 started_at: now,
1274 finished_at: Some(now),
1275 duration_secs: Some(5.2),
1276 agent: Some("agent-1".to_string()),
1277 result: RunResult::Fail,
1278 exit_code: Some(1),
1279 tokens: None,
1280 cost: None,
1281 output_snippet: Some("error: test failed".to_string()),
1282 },
1283 RunRecord {
1284 attempt: 2,
1285 started_at: now,
1286 finished_at: Some(now),
1287 duration_secs: Some(3.1),
1288 agent: Some("agent-1".to_string()),
1289 result: RunResult::Pass,
1290 exit_code: Some(0),
1291 tokens: Some(12000),
1292 cost: Some(0.05),
1293 output_snippet: None,
1294 },
1295 ];
1296
1297 let yaml = serde_yml::to_string(&bean).unwrap();
1298 assert!(yaml.contains("history:"));
1299
1300 let restored: Bean = serde_yml::from_str(&yaml).unwrap();
1301 assert_eq!(restored.history.len(), 2);
1302 assert_eq!(restored.history[0].result, RunResult::Fail);
1303 assert_eq!(restored.history[1].result, RunResult::Pass);
1304 assert_eq!(restored.history[0].attempt, 1);
1305 assert_eq!(restored.history[1].attempt, 2);
1306 assert_eq!(restored.history, bean.history);
1307 }
1308
1309 #[test]
1310 fn history_deserialized_from_yaml() {
1311 let yaml = r#"
1312id: "1"
1313title: From YAML
1314status: open
1315priority: 2
1316created_at: "2026-01-01T00:00:00Z"
1317updated_at: "2026-01-01T00:00:00Z"
1318history:
1319 - attempt: 1
1320 started_at: "2026-01-01T00:01:00Z"
1321 duration_secs: 10.0
1322 result: timeout
1323 exit_code: 124
1324 - attempt: 2
1325 started_at: "2026-01-01T00:05:00Z"
1326 finished_at: "2026-01-01T00:05:03Z"
1327 duration_secs: 3.0
1328 agent: agent-7
1329 result: pass
1330 exit_code: 0
1331"#;
1332 let bean: Bean = serde_yml::from_str(yaml).unwrap();
1333 assert_eq!(bean.history.len(), 2);
1334 assert_eq!(bean.history[0].result, RunResult::Timeout);
1335 assert_eq!(bean.history[0].exit_code, Some(124));
1336 assert_eq!(bean.history[1].result, RunResult::Pass);
1337 assert_eq!(bean.history[1].agent, Some("agent-7".to_string()));
1338 }
1339
1340 #[test]
1345 fn on_fail_none_not_serialized() {
1346 let bean = Bean::new("1", "No fail action");
1347 let yaml = serde_yml::to_string(&bean).unwrap();
1348 assert!(!yaml.contains("on_fail"));
1349 }
1350
1351 #[test]
1352 fn on_fail_retry_round_trip() {
1353 let mut bean = Bean::new("1", "With retry");
1354 bean.on_fail = Some(OnFailAction::Retry {
1355 max: Some(5),
1356 delay_secs: Some(10),
1357 });
1358
1359 let yaml = serde_yml::to_string(&bean).unwrap();
1360 assert!(yaml.contains("on_fail"));
1361 assert!(yaml.contains("action: retry"));
1362 assert!(yaml.contains("max: 5"));
1363 assert!(yaml.contains("delay_secs: 10"));
1364
1365 let restored: Bean = serde_yml::from_str(&yaml).unwrap();
1366 assert_eq!(restored.on_fail, bean.on_fail);
1367 }
1368
1369 #[test]
1370 fn on_fail_retry_minimal_round_trip() {
1371 let mut bean = Bean::new("1", "Retry minimal");
1372 bean.on_fail = Some(OnFailAction::Retry {
1373 max: None,
1374 delay_secs: None,
1375 });
1376
1377 let yaml = serde_yml::to_string(&bean).unwrap();
1378 assert!(yaml.contains("action: retry"));
1379 assert!(!yaml.contains("max:"));
1381 assert!(!yaml.contains("delay_secs:"));
1382
1383 let restored: Bean = serde_yml::from_str(&yaml).unwrap();
1384 assert_eq!(restored.on_fail, bean.on_fail);
1385 }
1386
1387 #[test]
1388 fn on_fail_escalate_round_trip() {
1389 let mut bean = Bean::new("1", "With escalate");
1390 bean.on_fail = Some(OnFailAction::Escalate {
1391 priority: Some(0),
1392 message: Some("Needs attention".to_string()),
1393 });
1394
1395 let yaml = serde_yml::to_string(&bean).unwrap();
1396 assert!(yaml.contains("action: escalate"));
1397 assert!(yaml.contains("priority: 0"));
1398 assert!(yaml.contains("Needs attention"));
1399
1400 let restored: Bean = serde_yml::from_str(&yaml).unwrap();
1401 assert_eq!(restored.on_fail, bean.on_fail);
1402 }
1403
1404 #[test]
1405 fn on_fail_escalate_minimal_round_trip() {
1406 let mut bean = Bean::new("1", "Escalate minimal");
1407 bean.on_fail = Some(OnFailAction::Escalate {
1408 priority: None,
1409 message: None,
1410 });
1411
1412 let yaml = serde_yml::to_string(&bean).unwrap();
1413 assert!(yaml.contains("action: escalate"));
1414 let on_fail_section = yaml.split("on_fail:").nth(1).unwrap();
1417 let on_fail_end = on_fail_section
1418 .find("\non_close:")
1419 .or_else(|| on_fail_section.find("\nhistory:"))
1420 .unwrap_or(on_fail_section.len());
1421 let on_fail_block = &on_fail_section[..on_fail_end];
1422 assert!(
1423 !on_fail_block.contains("priority:"),
1424 "on_fail block should not contain priority"
1425 );
1426 assert!(
1427 !on_fail_block.contains("message:"),
1428 "on_fail block should not contain message"
1429 );
1430
1431 let restored: Bean = serde_yml::from_str(&yaml).unwrap();
1432 assert_eq!(restored.on_fail, bean.on_fail);
1433 }
1434
1435 #[test]
1436 fn on_fail_deserialized_from_yaml() {
1437 let yaml = r#"
1438id: "1"
1439title: From YAML
1440status: open
1441priority: 2
1442created_at: "2026-01-01T00:00:00Z"
1443updated_at: "2026-01-01T00:00:00Z"
1444on_fail:
1445 action: retry
1446 max: 3
1447 delay_secs: 30
1448"#;
1449 let bean: Bean = serde_yml::from_str(yaml).unwrap();
1450 assert_eq!(
1451 bean.on_fail,
1452 Some(OnFailAction::Retry {
1453 max: Some(3),
1454 delay_secs: Some(30),
1455 })
1456 );
1457 }
1458
1459 #[test]
1460 fn on_fail_escalate_deserialized_from_yaml() {
1461 let yaml = r#"
1462id: "1"
1463title: Escalate YAML
1464status: open
1465priority: 2
1466created_at: "2026-01-01T00:00:00Z"
1467updated_at: "2026-01-01T00:00:00Z"
1468on_fail:
1469 action: escalate
1470 priority: 0
1471 message: "Critical failure"
1472"#;
1473 let bean: Bean = serde_yml::from_str(yaml).unwrap();
1474 assert_eq!(
1475 bean.on_fail,
1476 Some(OnFailAction::Escalate {
1477 priority: Some(0),
1478 message: Some("Critical failure".to_string()),
1479 })
1480 );
1481 }
1482
1483 #[test]
1484 fn history_with_cancelled_result() {
1485 let now = Utc::now();
1486 let record = RunRecord {
1487 attempt: 1,
1488 started_at: now,
1489 finished_at: None,
1490 duration_secs: None,
1491 agent: None,
1492 result: RunResult::Cancelled,
1493 exit_code: None,
1494 tokens: None,
1495 cost: None,
1496 output_snippet: None,
1497 };
1498
1499 let yaml = serde_yml::to_string(&record).unwrap();
1500 assert!(yaml.contains("cancelled"));
1501 let restored: RunRecord = serde_yml::from_str(&yaml).unwrap();
1502 assert_eq!(restored.result, RunResult::Cancelled);
1503 }
1504
1505 #[test]
1510 fn outputs_none_not_serialized() {
1511 let bean = Bean::new("1", "No outputs");
1512 let yaml = serde_yml::to_string(&bean).unwrap();
1513 assert!(
1514 !yaml.contains("outputs:"),
1515 "outputs field should be omitted when None, got:\n{yaml}"
1516 );
1517 }
1518
1519 #[test]
1520 fn outputs_round_trip_nested_object() {
1521 let mut bean = Bean::new("1", "With outputs");
1522 bean.outputs = Some(serde_json::json!({
1523 "test_results": {
1524 "passed": 42,
1525 "failed": 0,
1526 "skipped": 3
1527 },
1528 "coverage": 87.5
1529 }));
1530
1531 let yaml = serde_yml::to_string(&bean).unwrap();
1532 assert!(yaml.contains("outputs"));
1533
1534 let restored: Bean = serde_yml::from_str(&yaml).unwrap();
1535 assert_eq!(restored.outputs, bean.outputs);
1536 let out = restored.outputs.unwrap();
1537 assert_eq!(out["test_results"]["passed"], 42);
1538 assert_eq!(out["coverage"], 87.5);
1539 }
1540
1541 #[test]
1542 fn outputs_round_trip_array() {
1543 let mut bean = Bean::new("1", "Array outputs");
1544 bean.outputs = Some(serde_json::json!(["artifact1.tar.gz", "artifact2.zip"]));
1545
1546 let yaml = serde_yml::to_string(&bean).unwrap();
1547 let restored: Bean = serde_yml::from_str(&yaml).unwrap();
1548 assert_eq!(restored.outputs, bean.outputs);
1549 let arr = restored.outputs.unwrap();
1550 assert_eq!(arr.as_array().unwrap().len(), 2);
1551 assert_eq!(arr[0], "artifact1.tar.gz");
1552 }
1553
1554 #[test]
1555 fn outputs_round_trip_simple_values() {
1556 let mut bean = Bean::new("1", "String output");
1558 bean.outputs = Some(serde_json::json!("just a string"));
1559 let yaml = serde_yml::to_string(&bean).unwrap();
1560 let restored: Bean = serde_yml::from_str(&yaml).unwrap();
1561 assert_eq!(restored.outputs, bean.outputs);
1562
1563 bean.outputs = Some(serde_json::json!(42));
1565 let yaml = serde_yml::to_string(&bean).unwrap();
1566 let restored: Bean = serde_yml::from_str(&yaml).unwrap();
1567 assert_eq!(restored.outputs, bean.outputs);
1568
1569 bean.outputs = Some(serde_json::json!(true));
1571 let yaml = serde_yml::to_string(&bean).unwrap();
1572 let restored: Bean = serde_yml::from_str(&yaml).unwrap();
1573 assert_eq!(restored.outputs, bean.outputs);
1574 }
1575
1576 #[test]
1577 fn max_loops_defaults_to_none() {
1578 let bean = Bean::new("1", "No max_loops");
1579 assert_eq!(bean.max_loops, None);
1580 let yaml = serde_yml::to_string(&bean).unwrap();
1581 assert!(!yaml.contains("max_loops:"));
1582 }
1583
1584 #[test]
1585 fn max_loops_overrides_config_when_set() {
1586 let mut bean = Bean::new("1", "With max_loops");
1587 bean.max_loops = Some(5);
1588
1589 let yaml = serde_yml::to_string(&bean).unwrap();
1590 assert!(yaml.contains("max_loops: 5"));
1591
1592 let restored: Bean = serde_yml::from_str(&yaml).unwrap();
1593 assert_eq!(restored.max_loops, Some(5));
1594 }
1595
1596 #[test]
1597 fn max_loops_effective_returns_bean_value_when_set() {
1598 let mut bean = Bean::new("1", "Override");
1599 bean.max_loops = Some(20);
1600 assert_eq!(bean.effective_max_loops(10), 20);
1601 }
1602
1603 #[test]
1604 fn max_loops_effective_returns_config_value_when_none() {
1605 let bean = Bean::new("1", "Default");
1606 assert_eq!(bean.effective_max_loops(10), 10);
1607 assert_eq!(bean.effective_max_loops(42), 42);
1608 }
1609
1610 #[test]
1611 fn max_loops_zero_means_unlimited() {
1612 let mut bean = Bean::new("1", "Unlimited");
1613 bean.max_loops = Some(0);
1614 assert_eq!(bean.effective_max_loops(10), 0);
1615
1616 let bean2 = Bean::new("2", "Config unlimited");
1618 assert_eq!(bean2.effective_max_loops(0), 0);
1619 }
1620
1621 #[test]
1622 fn outputs_deserialized_from_yaml() {
1623 let yaml = r#"
1624id: "1"
1625title: Outputs YAML
1626status: open
1627priority: 2
1628created_at: "2026-01-01T00:00:00Z"
1629updated_at: "2026-01-01T00:00:00Z"
1630outputs:
1631 binary: /tmp/build/app
1632 size_bytes: 1048576
1633 checksums:
1634 sha256: abc123
1635"#;
1636 let bean: Bean = serde_yml::from_str(yaml).unwrap();
1637 assert!(bean.outputs.is_some());
1638 let out = bean.outputs.unwrap();
1639 assert_eq!(out["binary"], "/tmp/build/app");
1640 assert_eq!(out["size_bytes"], 1048576);
1641 assert_eq!(out["checksums"]["sha256"], "abc123");
1642 }
1643}