1use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6
7use super::execution::ExecutionStatus;
8
9#[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#[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 #[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 #[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 #[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 #[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 #[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}