Skip to main content

bn/bean/
mod.rs

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
12// ---------------------------------------------------------------------------
13// Priority Validation
14// ---------------------------------------------------------------------------
15
16/// Validate that priority is in the valid range (0-4, P0-P4).
17pub 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// ---------------------------------------------------------------------------
28// Bean
29// ---------------------------------------------------------------------------
30
31#[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    // -- verification & claim fields --
68    /// Shell command that must exit 0 to close the bean.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub verify: Option<String>,
71    /// Whether this bean was created with --fail-first (enforced TDD).
72    /// Records that the verify command was proven to fail before creation.
73    #[serde(default, skip_serializing_if = "is_false")]
74    pub fail_first: bool,
75    /// Git commit SHA recorded when verify was proven to fail at claim time.
76    /// Proves the test was meaningful at the point work began.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub checkpoint: Option<String>,
79    /// How many times the verify command has been run.
80    #[serde(default, skip_serializing_if = "is_zero")]
81    pub attempts: u32,
82    /// Maximum verify attempts before escalation (default 3).
83    #[serde(
84        default = "default_max_attempts",
85        skip_serializing_if = "is_default_max_attempts"
86    )]
87    pub max_attempts: u32,
88    /// Agent or user currently holding a claim on this bean.
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub claimed_by: Option<String>,
91    /// When the claim was acquired.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub claimed_at: Option<DateTime<Utc>>,
94
95    /// Whether this bean has been moved to the archive.
96    #[serde(default, skip_serializing_if = "is_false")]
97    pub is_archived: bool,
98
99    /// Artifacts this bean produces (types, functions, files).
100    /// Used by decompose skill for dependency inference.
101    #[serde(default, skip_serializing_if = "Vec::is_empty")]
102    pub produces: Vec<String>,
103
104    /// Artifacts this bean requires from other beans.
105    /// Maps to dependencies via sibling produces.
106    #[serde(default, skip_serializing_if = "Vec::is_empty")]
107    pub requires: Vec<String>,
108
109    /// Declarative action to execute when verify fails.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub on_fail: Option<OnFailAction>,
112
113    /// Declarative actions to execute when this bean is closed.
114    /// Runs after archive and post-close hook. Failures warn but don't revert.
115    #[serde(default, skip_serializing_if = "Vec::is_empty")]
116    pub on_close: Vec<OnCloseAction>,
117
118    /// Structured history of verification runs.
119    #[serde(default, skip_serializing_if = "Vec::is_empty")]
120    pub history: Vec<RunRecord>,
121
122    /// Structured output from verify commands (arbitrary JSON).
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub outputs: Option<serde_json::Value>,
125
126    /// Maximum agent loops for this bean (overrides config default, 0 = unlimited).
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub max_loops: Option<u32>,
129
130    /// Timeout in seconds for the verify command (overrides config default).
131    /// If the verify command exceeds this limit, it is killed and treated as failure.
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub verify_timeout: Option<u64>,
134
135    // -- Memory system fields --
136    /// Bean type: 'task' (default) or 'fact' (verified knowledge).
137    #[serde(
138        default = "default_bean_type",
139        skip_serializing_if = "is_default_bean_type"
140    )]
141    pub bean_type: String,
142
143    /// Unix timestamp of last successful verify (for staleness detection).
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub last_verified: Option<DateTime<Utc>>,
146
147    /// When this fact becomes stale (created_at + TTL). Only meaningful for facts.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub stale_after: Option<DateTime<Utc>>,
150
151    /// File paths this bean is relevant to (for context relevance scoring).
152    #[serde(default, skip_serializing_if = "Vec::is_empty")]
153    pub paths: Vec<String>,
154
155    /// Structured attempt tracking: [{num, outcome, notes}].
156    /// Tracks claim→close cycles for episodic memory.
157    #[serde(default, skip_serializing_if = "Vec::is_empty")]
158    pub attempt_log: Vec<AttemptRecord>,
159
160    /// Identity of who created this bean (resolved from config/git/env).
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub created_by: Option<String>,
163
164    /// Whether this bean is a feature (product-level goal, human-only close).
165    #[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    /// Create a new bean with sensible defaults.
199    /// Returns an error if the ID is invalid.
200    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    /// Create a new bean with sensible defaults.
250    /// Panics if the ID is invalid. Prefer `try_new` for fallible construction.
251    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    /// Get effective max_loops (per-bean override or config default).
256    /// A value of 0 means unlimited.
257    pub fn effective_max_loops(&self, config_max: u32) -> u32 {
258        self.max_loops.unwrap_or(config_max)
259    }
260
261    /// Get effective verify_timeout: bean-level override, then config default, then None.
262    pub fn effective_verify_timeout(&self, config_timeout: Option<u64>) -> Option<u64> {
263        self.verify_timeout.or(config_timeout)
264    }
265
266    /// Parse YAML frontmatter and markdown body.
267    /// Expects format:
268    /// ```text
269    /// ---
270    /// id: 1
271    /// title: Example
272    /// status: open
273    /// ...
274    /// ---
275    /// # Markdown body here
276    /// ```
277    fn parse_frontmatter(content: &str) -> Result<(String, Option<String>)> {
278        // Check if content starts with ---
279        if !content.starts_with("---\n") && !content.starts_with("---\r\n") {
280            // Not frontmatter format, try pure YAML
281            return Err(anyhow::anyhow!("Not markdown frontmatter format"));
282        }
283
284        // Find the second --- delimiter
285        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        // Skip the closing --- and any whitespace to get the body
299        let body_start = second_delimiter_pos + 3;
300        let body_raw = &after_first_delimiter[body_start..];
301
302        // Trim leading/trailing whitespace from body
303        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    /// Parse a bean from a string (either YAML or Markdown with YAML frontmatter).
310    pub fn from_string(content: &str) -> Result<Self> {
311        // Try frontmatter format first
312        match Self::parse_frontmatter(content) {
313            Ok((frontmatter, body)) => {
314                // Parse frontmatter as YAML
315                let mut bean: Bean = serde_yml::from_str(&frontmatter)?;
316
317                // If there's a body and no description yet, set it
318                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                // Fallback: treat entire content as YAML
328                let bean: Bean = serde_yml::from_str(content)?;
329                Ok(bean)
330            }
331        }
332    }
333
334    /// Read a bean from a file (supports both YAML and Markdown with YAML frontmatter).
335    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    /// Write this bean to a file.
341    /// For `.md` files, writes markdown frontmatter format (YAML between `---` delimiters
342    /// with description as the markdown body). For other extensions, writes pure YAML.
343    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            // Write frontmatter format: YAML metadata + markdown body
349            let mut frontmatter_bean = self.clone();
350            let description = frontmatter_bean.description.take(); // Remove from YAML
351            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    /// Calculate SHA256 hash of canonical form.
371    ///
372    /// Used for optimistic locking. The hash is calculated from a canonical
373    /// JSON representation with transient fields cleared.
374    pub fn hash(&self) -> String {
375        use sha2::{Digest, Sha256};
376        let canonical = self.clone();
377
378        // Serialize to JSON (deterministic)
379        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    /// Load bean with version hash for optimistic locking.
387    ///
388    /// Returns the bean and its content hash as a tuple. The hash can be
389    /// compared before saving to detect concurrent modifications.
390    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    /// Apply a JSON-serialized value to a field by name.
397    ///
398    /// Used by conflict resolution to set a field to a chosen value.
399    /// The value should be JSON-serialized (e.g., `"\"hello\""` for a string).
400    ///
401    /// # Arguments
402    /// * `field` - The field name to update
403    /// * `json_value` - JSON-serialized value to apply
404    ///
405    /// # Returns
406    /// * `Ok(())` on success
407    /// * `Err` if field is unknown or value cannot be deserialized
408    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// ---------------------------------------------------------------------------
441// Tests
442// ---------------------------------------------------------------------------
443
444#[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        // Serialize
454        let yaml = serde_yml::to_string(&bean).unwrap();
455
456        // Deserialize
457        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        // ISO 8601 timestamps contain 'T' between date and time
557        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        // Write
573        bean.to_file(&path).unwrap();
574
575        // Read back
576        let restored = Bean::from_file(&path).unwrap();
577        assert_eq!(bean, restored);
578
579        // Verify the file is valid YAML we can also read raw
580        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    // =====================================================================
632    // Tests for Markdown Frontmatter Parsing
633    // =====================================================================
634
635    #[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        // Leading newlines trimmed, but content preserved
766        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        // Use a .md extension to trigger frontmatter write
806        let dir = tempfile::tempdir().unwrap();
807        let path = dir.path().join("7-test.md");
808
809        // Write markdown content
810        std::fs::write(&path, content).unwrap();
811
812        // Read back as bean
813        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        // Write it back — should preserve frontmatter format for .md files
824        bean.to_file(&path).unwrap();
825
826        // Verify the file still has frontmatter format
827        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        // Description should NOT be in the YAML frontmatter section
838        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        // Read back one more time to verify full round-trip
847        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        // Should fail because no closing ---
862        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        // Description from YAML should take precedence
920        assert_eq!(bean.description, Some("From YAML metadata".to_string()));
921    }
922
923    // =====================================================================
924    // Tests for Bean hash methods
925    // =====================================================================
926
927    #[test]
928    fn test_hash_consistency() {
929        let bean1 = Bean::new("1", "Test bean");
930        let bean2 = bean1.clone();
931        // Same content produces same hash
932        assert_eq!(bean1.hash(), bean2.hash());
933        // Hash is deterministic
934        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    // =====================================================================
958    // on_close serialization tests
959    // =====================================================================
960
961    #[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    // =====================================================================
1052    // RunResult / RunRecord / history tests
1053    // =====================================================================
1054
1055    #[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    // =====================================================================
1137    // on_fail serialization tests
1138    // =====================================================================
1139
1140    #[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        // Optional fields should be omitted
1176        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        // The on_fail block should not contain priority or message
1211        // (the bean itself has a top-level priority field, so check within on_fail)
1212        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    // =====================================================================
1280    // outputs field tests
1281    // =====================================================================
1282
1283    #[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        // String value
1331        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        // Number value
1338        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        // Boolean value
1344        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        // Config-level zero also works
1391        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}