Skip to main content

bn/
bean.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
9// ---------------------------------------------------------------------------
10// Status
11// ---------------------------------------------------------------------------
12
13#[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
31// ---------------------------------------------------------------------------
32// Priority Validation
33// ---------------------------------------------------------------------------
34
35/// Validate that priority is in the valid range (0-4, P0-P4).
36pub 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// ---------------------------------------------------------------------------
47// RunResult / RunRecord (verification history)
48// ---------------------------------------------------------------------------
49
50/// Outcome of a verification run.
51#[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/// A single verification run record.
61#[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// ---------------------------------------------------------------------------
83// OnCloseAction
84// ---------------------------------------------------------------------------
85
86/// Declarative action to run when a bean's verify command fails.
87#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
88#[serde(tag = "action", rename_all = "snake_case")]
89pub enum OnFailAction {
90    /// Retry with optional max attempts and delay.
91    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    /// Bump priority and add message.
98    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/// Declarative actions to run when a bean is closed.
107/// Processed after the bean is archived and post-close hook fires.
108#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
109#[serde(tag = "action", rename_all = "snake_case")]
110pub enum OnCloseAction {
111    /// Run a shell command in the project root.
112    Run { command: String },
113    /// Print a notification message.
114    Notify { message: String },
115}
116
117// ---------------------------------------------------------------------------
118// AttemptRecord (for memory system attempt tracking)
119// ---------------------------------------------------------------------------
120
121/// Outcome of a claim→close cycle.
122#[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/// A single attempt record (claim→close cycle).
131#[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// ---------------------------------------------------------------------------
146// Bean
147// ---------------------------------------------------------------------------
148
149#[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    // -- verification & claim fields --
186    /// Shell command that must exit 0 to close the bean.
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub verify: Option<String>,
189    /// Whether this bean was created with --fail-first (enforced TDD).
190    /// Records that the verify command was proven to fail before creation.
191    #[serde(default, skip_serializing_if = "is_false")]
192    pub fail_first: bool,
193    /// Git commit SHA recorded when verify was proven to fail at claim time.
194    /// Proves the test was meaningful at the point work began.
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub checkpoint: Option<String>,
197    /// How many times the verify command has been run.
198    #[serde(default, skip_serializing_if = "is_zero")]
199    pub attempts: u32,
200    /// Maximum verify attempts before escalation (default 3).
201    #[serde(
202        default = "default_max_attempts",
203        skip_serializing_if = "is_default_max_attempts"
204    )]
205    pub max_attempts: u32,
206    /// Agent or user currently holding a claim on this bean.
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub claimed_by: Option<String>,
209    /// When the claim was acquired.
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub claimed_at: Option<DateTime<Utc>>,
212
213    /// Whether this bean has been moved to the archive.
214    #[serde(default, skip_serializing_if = "is_false")]
215    pub is_archived: bool,
216
217    /// Artifacts this bean produces (types, functions, files).
218    /// Used by decompose skill for dependency inference.
219    #[serde(default, skip_serializing_if = "Vec::is_empty")]
220    pub produces: Vec<String>,
221
222    /// Artifacts this bean requires from other beans.
223    /// Maps to dependencies via sibling produces.
224    #[serde(default, skip_serializing_if = "Vec::is_empty")]
225    pub requires: Vec<String>,
226
227    /// Estimated token count for this bean's context.
228    /// Used for sizing decisions (decomposition vs implementation).
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub tokens: Option<u64>,
231
232    /// When the token count was last calculated.
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    pub tokens_updated: Option<DateTime<Utc>>,
235
236    /// Declarative action to execute when verify fails.
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub on_fail: Option<OnFailAction>,
239
240    /// Declarative actions to execute when this bean is closed.
241    /// Runs after archive and post-close hook. Failures warn but don't revert.
242    #[serde(default, skip_serializing_if = "Vec::is_empty")]
243    pub on_close: Vec<OnCloseAction>,
244
245    /// Structured history of verification runs.
246    #[serde(default, skip_serializing_if = "Vec::is_empty")]
247    pub history: Vec<RunRecord>,
248
249    /// Structured output from verify commands (arbitrary JSON).
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub outputs: Option<serde_json::Value>,
252
253    /// Maximum agent loops for this bean (overrides config default, 0 = unlimited).
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub max_loops: Option<u32>,
256
257    /// Timeout in seconds for the verify command (overrides config default).
258    /// If the verify command exceeds this limit, it is killed and treated as failure.
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    pub verify_timeout: Option<u64>,
261
262    // -- Memory system fields --
263    /// Bean type: 'task' (default) or 'fact' (verified knowledge).
264    #[serde(
265        default = "default_bean_type",
266        skip_serializing_if = "is_default_bean_type"
267    )]
268    pub bean_type: String,
269
270    /// Unix timestamp of last successful verify (for staleness detection).
271    #[serde(default, skip_serializing_if = "Option::is_none")]
272    pub last_verified: Option<DateTime<Utc>>,
273
274    /// When this fact becomes stale (created_at + TTL). Only meaningful for facts.
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    pub stale_after: Option<DateTime<Utc>>,
277
278    /// File paths this bean is relevant to (for context relevance scoring).
279    #[serde(default, skip_serializing_if = "Vec::is_empty")]
280    pub paths: Vec<String>,
281
282    /// Structured attempt tracking: [{num, outcome, notes}].
283    /// Tracks claim→close cycles for episodic memory.
284    #[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    /// Create a new bean with sensible defaults.
318    /// Returns an error if the ID is invalid.
319    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    /// Create a new bean with sensible defaults.
369    /// Panics if the ID is invalid. Prefer `try_new` for fallible construction.
370    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    /// Get effective max_loops (per-bean override or config default).
375    /// A value of 0 means unlimited.
376    pub fn effective_max_loops(&self, config_max: u32) -> u32 {
377        self.max_loops.unwrap_or(config_max)
378    }
379
380    /// Get effective verify_timeout: bean-level override, then config default, then None.
381    pub fn effective_verify_timeout(&self, config_timeout: Option<u64>) -> Option<u64> {
382        self.verify_timeout.or(config_timeout)
383    }
384
385    /// Parse YAML frontmatter and markdown body.
386    /// Expects format:
387    /// ```text
388    /// ---
389    /// id: 1
390    /// title: Example
391    /// status: open
392    /// ...
393    /// ---
394    /// # Markdown body here
395    /// ```
396    fn parse_frontmatter(content: &str) -> Result<(String, Option<String>)> {
397        // Check if content starts with ---
398        if !content.starts_with("---\n") && !content.starts_with("---\r\n") {
399            // Not frontmatter format, try pure YAML
400            return Err(anyhow::anyhow!("Not markdown frontmatter format"));
401        }
402
403        // Find the second --- delimiter
404        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        // Skip the closing --- and any whitespace to get the body
418        let body_start = second_delimiter_pos + 3;
419        let body_raw = &after_first_delimiter[body_start..];
420
421        // Trim leading/trailing whitespace from body
422        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    /// Parse a bean from a string (either YAML or Markdown with YAML frontmatter).
429    pub fn from_string(content: &str) -> Result<Self> {
430        // Try frontmatter format first
431        match Self::parse_frontmatter(content) {
432            Ok((frontmatter, body)) => {
433                // Parse frontmatter as YAML
434                let mut bean: Bean = serde_yml::from_str(&frontmatter)?;
435
436                // If there's a body and no description yet, set it
437                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                // Fallback: treat entire content as YAML
447                let bean: Bean = serde_yml::from_str(content)?;
448                Ok(bean)
449            }
450        }
451    }
452
453    /// Read a bean from a file (supports both YAML and Markdown with YAML frontmatter).
454    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    /// Write this bean to a file.
460    /// For `.md` files, writes markdown frontmatter format (YAML between `---` delimiters
461    /// with description as the markdown body). For other extensions, writes pure YAML.
462    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            // Write frontmatter format: YAML metadata + markdown body
468            let mut frontmatter_bean = self.clone();
469            let description = frontmatter_bean.description.take(); // Remove from YAML
470            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    /// Calculate SHA256 hash of canonical form.
490    ///
491    /// Used for optimistic locking. The hash is calculated from a canonical
492    /// JSON representation with transient fields cleared.
493    pub fn hash(&self) -> String {
494        use sha2::{Digest, Sha256};
495        let canonical = self.clone();
496
497        // Serialize to JSON (deterministic)
498        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    /// Load bean with version hash for optimistic locking.
505    ///
506    /// Returns the bean and its content hash as a tuple. The hash can be
507    /// compared before saving to detect concurrent modifications.
508    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    /// Apply a JSON-serialized value to a field by name.
515    ///
516    /// Used by conflict resolution to set a field to a chosen value.
517    /// The value should be JSON-serialized (e.g., `"\"hello\""` for a string).
518    ///
519    /// # Arguments
520    /// * `field` - The field name to update
521    /// * `json_value` - JSON-serialized value to apply
522    ///
523    /// # Returns
524    /// * `Ok(())` on success
525    /// * `Err` if field is unknown or value cannot be deserialized
526    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// ---------------------------------------------------------------------------
561// Tests
562// ---------------------------------------------------------------------------
563
564#[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        // Serialize
574        let yaml = serde_yml::to_string(&bean).unwrap();
575
576        // Deserialize
577        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        // ISO 8601 timestamps contain 'T' between date and time
690        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        // Write
706        bean.to_file(&path).unwrap();
707
708        // Read back
709        let restored = Bean::from_file(&path).unwrap();
710        assert_eq!(bean, restored);
711
712        // Verify the file is valid YAML we can also read raw
713        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    // =====================================================================
765    // Tests for Markdown Frontmatter Parsing
766    // =====================================================================
767
768    #[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        // Leading newlines trimmed, but content preserved
899        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        // Use a .md extension to trigger frontmatter write
939        let dir = tempfile::tempdir().unwrap();
940        let path = dir.path().join("7-test.md");
941
942        // Write markdown content
943        std::fs::write(&path, content).unwrap();
944
945        // Read back as bean
946        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        // Write it back — should preserve frontmatter format for .md files
957        bean.to_file(&path).unwrap();
958
959        // Verify the file still has frontmatter format
960        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        // Description should NOT be in the YAML frontmatter section
971        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        // Read back one more time to verify full round-trip
980        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        // Should fail because no closing ---
995        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        // Description from YAML should take precedence
1053        assert_eq!(bean.description, Some("From YAML metadata".to_string()));
1054    }
1055
1056    // =====================================================================
1057    // Tests for Bean hash methods
1058    // =====================================================================
1059
1060    #[test]
1061    fn test_hash_consistency() {
1062        let bean1 = Bean::new("1", "Test bean");
1063        let bean2 = bean1.clone();
1064        // Same content produces same hash
1065        assert_eq!(bean1.hash(), bean2.hash());
1066        // Hash is deterministic
1067        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    // =====================================================================
1091    // on_close serialization tests
1092    // =====================================================================
1093
1094    #[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    // =====================================================================
1185    // RunResult / RunRecord / history tests
1186    // =====================================================================
1187
1188    #[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        // Optional fields should be omitted
1229        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    // =====================================================================
1341    // on_fail serialization tests
1342    // =====================================================================
1343
1344    #[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        // Optional fields should be omitted
1380        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        // The on_fail block should not contain priority or message
1415        // (the bean itself has a top-level priority field, so check within on_fail)
1416        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    // =====================================================================
1506    // outputs field tests
1507    // =====================================================================
1508
1509    #[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        // String value
1557        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        // Number value
1564        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        // Boolean value
1570        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        // Config-level zero also works
1617        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}