Skip to main content

agm_core/model/
state.rs

1//! State sidecar model types for `.agm.state` files.
2
3use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6
7use super::execution::ExecutionStatus;
8
9/// Represents a parsed `.agm.state` sidecar file.
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
11pub struct StateFile {
12    pub format_version: String,
13    pub package: String,
14    pub version: String,
15    pub session_id: String,
16    pub started_at: String,
17    pub updated_at: String,
18    pub nodes: BTreeMap<String, NodeState>,
19}
20
21/// Execution state of a single node.
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23pub struct NodeState {
24    pub execution_status: ExecutionStatus,
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub executed_by: Option<String>,
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub executed_at: Option<String>,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub execution_log: Option<String>,
31    pub retry_count: u32,
32}
33
34impl Default for NodeState {
35    fn default() -> Self {
36        Self {
37            execution_status: ExecutionStatus::Pending,
38            executed_by: None,
39            executed_at: None,
40            execution_log: None,
41            retry_count: 0,
42        }
43    }
44}
45
46#[cfg(test)]
47mod tests {
48    use super::*;
49
50    // -----------------------------------------------------------------------
51    // A: Default
52    // -----------------------------------------------------------------------
53
54    #[test]
55    fn test_node_state_default_is_pending_with_zero_retries() {
56        let state = NodeState::default();
57        assert_eq!(state.execution_status, ExecutionStatus::Pending);
58        assert_eq!(state.retry_count, 0);
59        assert!(state.executed_by.is_none());
60        assert!(state.executed_at.is_none());
61        assert!(state.execution_log.is_none());
62    }
63
64    // -----------------------------------------------------------------------
65    // B: Serde roundtrip — minimal
66    // -----------------------------------------------------------------------
67
68    #[test]
69    fn test_state_file_serde_roundtrip_minimal() {
70        let state = StateFile {
71            format_version: "1.0".to_owned(),
72            package: "test.pkg".to_owned(),
73            version: "0.1.0".to_owned(),
74            session_id: "run-001".to_owned(),
75            started_at: "2026-04-08T10:00:00Z".to_owned(),
76            updated_at: "2026-04-08T10:00:00Z".to_owned(),
77            nodes: BTreeMap::new(),
78        };
79        let json = serde_json::to_string(&state).unwrap();
80        let back: StateFile = serde_json::from_str(&json).unwrap();
81        assert_eq!(state, back);
82    }
83
84    // -----------------------------------------------------------------------
85    // C: Serde roundtrip — full
86    // -----------------------------------------------------------------------
87
88    #[test]
89    fn test_state_file_serde_roundtrip_full() {
90        let mut nodes = BTreeMap::new();
91        nodes.insert(
92            "migration.001".to_owned(),
93            NodeState {
94                execution_status: ExecutionStatus::Completed,
95                executed_by: Some("shell-agent".to_owned()),
96                executed_at: Some("2026-04-08T10:05:00Z".to_owned()),
97                execution_log: Some(".agm/logs/migration.001.log".to_owned()),
98                retry_count: 1,
99            },
100        );
101        nodes.insert(
102            "migration.002".to_owned(),
103            NodeState {
104                execution_status: ExecutionStatus::Pending,
105                executed_by: None,
106                executed_at: None,
107                execution_log: None,
108                retry_count: 0,
109            },
110        );
111        let state = StateFile {
112            format_version: "1.0".to_owned(),
113            package: "acme.migration".to_owned(),
114            version: "1.0.0".to_owned(),
115            session_id: "run-2026-04-08-153200".to_owned(),
116            started_at: "2026-04-08T15:32:00Z".to_owned(),
117            updated_at: "2026-04-08T15:35:00Z".to_owned(),
118            nodes,
119        };
120        let json = serde_json::to_string(&state).unwrap();
121        let back: StateFile = serde_json::from_str(&json).unwrap();
122        assert_eq!(state, back);
123    }
124
125    // -----------------------------------------------------------------------
126    // D: Optional fields absent in JSON when None
127    // -----------------------------------------------------------------------
128
129    #[test]
130    fn test_node_state_optional_fields_absent_when_none() {
131        let state = NodeState::default();
132        let json = serde_json::to_string(&state).unwrap();
133        assert!(!json.contains("executed_by"));
134        assert!(!json.contains("executed_at"));
135        assert!(!json.contains("execution_log"));
136    }
137
138    // -----------------------------------------------------------------------
139    // E: NodeState serde roundtrip with all fields set
140    // -----------------------------------------------------------------------
141
142    #[test]
143    fn test_node_state_serde_roundtrip_all_fields() {
144        let state = NodeState {
145            execution_status: ExecutionStatus::Failed,
146            executed_by: Some("ci-agent".to_owned()),
147            executed_at: Some("2026-04-08T12:00:00Z".to_owned()),
148            execution_log: Some(".agm/logs/test.log".to_owned()),
149            retry_count: 3,
150        };
151        let json = serde_json::to_string(&state).unwrap();
152        let back: NodeState = serde_json::from_str(&json).unwrap();
153        assert_eq!(state, back);
154    }
155}