1use serde::{Deserialize, Serialize};
2use std::collections::BTreeMap;
3
4#[derive(Debug, Default, Clone, Serialize, Deserialize)]
6pub struct AgentStateSnapshot {
7 pub todos: Vec<TodoItem>,
8 pub files: BTreeMap<String, String>,
9 pub scratchpad: BTreeMap<String, serde_json::Value>,
10}
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct TodoItem {
14 pub content: String,
15 pub status: TodoStatus,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum TodoStatus {
21 Pending,
22 InProgress,
23 Completed,
24}
25
26impl TodoItem {
27 pub fn pending(content: impl Into<String>) -> Self {
28 Self {
29 content: content.into(),
30 status: TodoStatus::Pending,
31 }
32 }
33}
34
35impl AgentStateSnapshot {
36 pub fn merge(&mut self, other: AgentStateSnapshot) {
38 self.files.extend(other.files);
40
41 if !other.todos.is_empty() {
43 self.todos = other.todos;
44 }
45
46 self.scratchpad.extend(other.scratchpad);
48 }
49
50 pub fn reduce_files(
53 left: Option<BTreeMap<String, String>>,
54 right: Option<BTreeMap<String, String>>,
55 ) -> Option<BTreeMap<String, String>> {
56 match (left, right) {
57 (None, None) => None,
58 (Some(l), None) => Some(l),
59 (None, Some(r)) => Some(r),
60 (Some(mut l), Some(r)) => {
61 l.extend(r); Some(l)
63 }
64 }
65 }
66
67 pub fn with_merged_files(&self, new_files: Option<BTreeMap<String, String>>) -> Self {
69 let mut result = self.clone();
70 if let Some(files) = new_files {
71 result.files.extend(files);
72 }
73 result
74 }
75
76 pub fn with_updated_todos(&self, new_todos: Vec<TodoItem>) -> Self {
77 if new_todos.is_empty() {
78 self.clone()
79 } else {
80 let mut result = self.clone();
81 result.todos = new_todos;
82 result
83 }
84 }
85}
86
87#[cfg(test)]
88mod tests {
89 use super::*;
90
91 #[test]
92 fn test_file_reducer_both_none() {
93 let result = AgentStateSnapshot::reduce_files(None, None);
94 assert!(result.is_none());
95 }
96
97 #[test]
98 fn test_file_reducer_left_some_right_none() {
99 let mut left = BTreeMap::new();
100 left.insert("file1.txt".to_string(), "content1".to_string());
101
102 let result = AgentStateSnapshot::reduce_files(Some(left.clone()), None);
103 assert_eq!(result, Some(left));
104 }
105
106 #[test]
107 fn test_file_reducer_left_none_right_some() {
108 let mut right = BTreeMap::new();
109 right.insert("file2.txt".to_string(), "content2".to_string());
110
111 let result = AgentStateSnapshot::reduce_files(None, Some(right.clone()));
112 assert_eq!(result, Some(right));
113 }
114
115 #[test]
116 fn test_file_reducer_both_some_merges() {
117 let mut left = BTreeMap::new();
118 left.insert("file1.txt".to_string(), "content1".to_string());
119 left.insert("shared.txt".to_string(), "old_content".to_string());
120
121 let mut right = BTreeMap::new();
122 right.insert("file2.txt".to_string(), "content2".to_string());
123 right.insert("shared.txt".to_string(), "new_content".to_string());
124
125 let result = AgentStateSnapshot::reduce_files(Some(left), Some(right)).unwrap();
126
127 assert_eq!(result.get("file1.txt").unwrap(), "content1");
129 assert_eq!(result.get("file2.txt").unwrap(), "content2");
130 assert_eq!(result.get("shared.txt").unwrap(), "new_content"); assert_eq!(result.len(), 3);
132 }
133
134 #[test]
135 fn test_merge_combines_states() {
136 let mut state1 = AgentStateSnapshot::default();
137 state1
138 .files
139 .insert("file1.txt".to_string(), "content1".to_string());
140 state1.todos.push(TodoItem::pending("task1"));
141 state1
142 .scratchpad
143 .insert("key1".to_string(), serde_json::json!("value1"));
144
145 let mut state2 = AgentStateSnapshot::default();
146 state2
147 .files
148 .insert("file2.txt".to_string(), "content2".to_string());
149 state2.todos.push(TodoItem::pending("task2"));
150 state2
151 .scratchpad
152 .insert("key2".to_string(), serde_json::json!("value2"));
153
154 let mut merged = state1.clone();
155 merged.merge(state2);
156
157 assert_eq!(merged.files.len(), 2);
159 assert_eq!(merged.files.get("file1.txt").unwrap(), "content1");
160 assert_eq!(merged.files.get("file2.txt").unwrap(), "content2");
161
162 assert_eq!(merged.todos.len(), 1);
164 assert_eq!(merged.todos[0].content, "task2");
165
166 assert_eq!(merged.scratchpad.len(), 2);
168 assert_eq!(merged.scratchpad.get("key1").unwrap(), "value1");
169 assert_eq!(merged.scratchpad.get("key2").unwrap(), "value2");
170 }
171
172 #[test]
173 fn test_merge_empty_todos_preserves_existing() {
174 let mut state1 = AgentStateSnapshot::default();
175 state1.todos.push(TodoItem::pending("task1"));
176
177 let state2 = AgentStateSnapshot::default(); let mut merged = state1.clone();
180 merged.merge(state2);
181
182 assert_eq!(merged.todos.len(), 1);
184 assert_eq!(merged.todos[0].content, "task1");
185 }
186
187 #[test]
188 fn test_with_merged_files() {
189 let mut state = AgentStateSnapshot::default();
190 state
191 .files
192 .insert("existing.txt".to_string(), "existing".to_string());
193
194 let mut new_files = BTreeMap::new();
195 new_files.insert("new.txt".to_string(), "new_content".to_string());
196 new_files.insert("existing.txt".to_string(), "updated".to_string()); let result = state.with_merged_files(Some(new_files));
199
200 assert_eq!(result.files.len(), 2);
201 assert_eq!(result.files.get("existing.txt").unwrap(), "updated");
202 assert_eq!(result.files.get("new.txt").unwrap(), "new_content");
203 }
204
205 #[test]
206 fn test_with_updated_todos() {
207 let mut state = AgentStateSnapshot::default();
208 state.todos.push(TodoItem::pending("old_task"));
209
210 let new_todos = vec![
211 TodoItem::pending("new_task1"),
212 TodoItem::pending("new_task2"),
213 ];
214
215 let result = state.with_updated_todos(new_todos);
216
217 assert_eq!(result.todos.len(), 2);
218 assert_eq!(result.todos[0].content, "new_task1");
219 assert_eq!(result.todos[1].content, "new_task2");
220 }
221
222 #[test]
223 fn test_with_updated_todos_empty_preserves_existing() {
224 let mut state = AgentStateSnapshot::default();
225 state.todos.push(TodoItem::pending("existing_task"));
226
227 let result = state.with_updated_todos(vec![]);
228
229 assert_eq!(result.todos.len(), 1);
231 assert_eq!(result.todos[0].content, "existing_task");
232 }
233}