Skip to main content

bn/bean/
types.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4// ---------------------------------------------------------------------------
5// Status
6// ---------------------------------------------------------------------------
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum Status {
11    Open,
12    InProgress,
13    Closed,
14}
15
16impl std::fmt::Display for Status {
17    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18        match self {
19            Status::Open => write!(f, "open"),
20            Status::InProgress => write!(f, "in_progress"),
21            Status::Closed => write!(f, "closed"),
22        }
23    }
24}
25
26// ---------------------------------------------------------------------------
27// RunResult / RunRecord (verification history)
28// ---------------------------------------------------------------------------
29
30/// Outcome of a verification run.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum RunResult {
34    Pass,
35    Fail,
36    Timeout,
37    Cancelled,
38}
39
40/// A single verification run record.
41#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
42pub struct RunRecord {
43    pub attempt: u32,
44    pub started_at: DateTime<Utc>,
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub finished_at: Option<DateTime<Utc>>,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub duration_secs: Option<f64>,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub agent: Option<String>,
51    pub result: RunResult,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub exit_code: Option<i32>,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub tokens: Option<u64>,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub cost: Option<f64>,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub output_snippet: Option<String>,
60}
61
62// ---------------------------------------------------------------------------
63// OnCloseAction
64// ---------------------------------------------------------------------------
65
66/// Declarative action to run when a bean's verify command fails.
67#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
68#[serde(tag = "action", rename_all = "snake_case")]
69pub enum OnFailAction {
70    /// Retry with optional max attempts and delay.
71    Retry {
72        #[serde(skip_serializing_if = "Option::is_none")]
73        max: Option<u32>,
74        #[serde(skip_serializing_if = "Option::is_none")]
75        delay_secs: Option<u64>,
76    },
77    /// Bump priority and add message.
78    Escalate {
79        #[serde(skip_serializing_if = "Option::is_none")]
80        priority: Option<u8>,
81        #[serde(skip_serializing_if = "Option::is_none")]
82        message: Option<String>,
83    },
84}
85
86/// Declarative actions to run when a bean is closed.
87/// Processed after the bean is archived and post-close hook fires.
88#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
89#[serde(tag = "action", rename_all = "snake_case")]
90pub enum OnCloseAction {
91    /// Run a shell command in the project root.
92    Run { command: String },
93    /// Print a notification message.
94    Notify { message: String },
95}
96
97// ---------------------------------------------------------------------------
98// AttemptRecord (for memory system attempt tracking)
99// ---------------------------------------------------------------------------
100
101/// Outcome of a claim→close cycle.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(rename_all = "snake_case")]
104pub enum AttemptOutcome {
105    Success,
106    Failed,
107    Abandoned,
108}
109
110/// A single attempt record (claim→close cycle).
111#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
112pub struct AttemptRecord {
113    pub num: u32,
114    pub outcome: AttemptOutcome,
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub notes: Option<String>,
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub agent: Option<String>,
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub started_at: Option<DateTime<Utc>>,
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub finished_at: Option<DateTime<Utc>>,
123}
124
125// ---------------------------------------------------------------------------
126// Tests
127// ---------------------------------------------------------------------------
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn status_serializes_as_lowercase() {
135        let open = serde_yml::to_string(&Status::Open).unwrap();
136        let in_progress = serde_yml::to_string(&Status::InProgress).unwrap();
137        let closed = serde_yml::to_string(&Status::Closed).unwrap();
138
139        assert_eq!(open.trim(), "open");
140        assert_eq!(in_progress.trim(), "in_progress");
141        assert_eq!(closed.trim(), "closed");
142    }
143
144    #[test]
145    fn run_result_serializes_as_snake_case() {
146        assert_eq!(
147            serde_yml::to_string(&RunResult::Pass).unwrap().trim(),
148            "pass"
149        );
150        assert_eq!(
151            serde_yml::to_string(&RunResult::Fail).unwrap().trim(),
152            "fail"
153        );
154        assert_eq!(
155            serde_yml::to_string(&RunResult::Timeout).unwrap().trim(),
156            "timeout"
157        );
158        assert_eq!(
159            serde_yml::to_string(&RunResult::Cancelled).unwrap().trim(),
160            "cancelled"
161        );
162    }
163
164    #[test]
165    fn run_record_minimal_round_trip() {
166        let now = Utc::now();
167        let record = RunRecord {
168            attempt: 1,
169            started_at: now,
170            finished_at: None,
171            duration_secs: None,
172            agent: None,
173            result: RunResult::Pass,
174            exit_code: None,
175            tokens: None,
176            cost: None,
177            output_snippet: None,
178        };
179
180        let yaml = serde_yml::to_string(&record).unwrap();
181        let restored: RunRecord = serde_yml::from_str(&yaml).unwrap();
182        assert_eq!(record, restored);
183
184        // Optional fields should be omitted
185        assert!(!yaml.contains("finished_at:"));
186        assert!(!yaml.contains("duration_secs:"));
187        assert!(!yaml.contains("agent:"));
188        assert!(!yaml.contains("exit_code:"));
189        assert!(!yaml.contains("tokens:"));
190        assert!(!yaml.contains("cost:"));
191        assert!(!yaml.contains("output_snippet:"));
192    }
193
194    #[test]
195    fn run_record_full_round_trip() {
196        let now = Utc::now();
197        let record = RunRecord {
198            attempt: 3,
199            started_at: now,
200            finished_at: Some(now),
201            duration_secs: Some(12.5),
202            agent: Some("agent-42".to_string()),
203            result: RunResult::Fail,
204            exit_code: Some(1),
205            tokens: Some(5000),
206            cost: Some(0.03),
207            output_snippet: Some("FAILED: assertion error".to_string()),
208        };
209
210        let yaml = serde_yml::to_string(&record).unwrap();
211        let restored: RunRecord = serde_yml::from_str(&yaml).unwrap();
212        assert_eq!(record, restored);
213    }
214
215    #[test]
216    fn history_with_cancelled_result() {
217        let now = Utc::now();
218        let record = RunRecord {
219            attempt: 1,
220            started_at: now,
221            finished_at: None,
222            duration_secs: None,
223            agent: None,
224            result: RunResult::Cancelled,
225            exit_code: None,
226            tokens: None,
227            cost: None,
228            output_snippet: None,
229        };
230
231        let yaml = serde_yml::to_string(&record).unwrap();
232        assert!(yaml.contains("cancelled"));
233        let restored: RunRecord = serde_yml::from_str(&yaml).unwrap();
234        assert_eq!(restored.result, RunResult::Cancelled);
235    }
236}