1use anyhow::Result;
10use serde::{Deserialize, Serialize};
11use std::fs;
12use std::path::PathBuf;
13
14use super::backpressure::ValidationResult;
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct WaveSummary {
20 pub wave_number: usize,
22 pub tasks_completed: Vec<String>,
24 pub files_changed: Vec<String>,
26}
27
28impl WaveSummary {
29 pub fn to_text(&self) -> String {
31 let mut lines = Vec::new();
32
33 lines.push(format!(
34 "Wave {} completed {} task(s):",
35 self.wave_number,
36 self.tasks_completed.len()
37 ));
38
39 for task_id in &self.tasks_completed {
40 lines.push(format!(" - {}", task_id));
41 }
42
43 if !self.files_changed.is_empty() {
44 let file_summary = if self.files_changed.len() <= 5 {
45 self.files_changed.join(", ")
46 } else {
47 format!(
48 "{} and {} more",
49 self.files_changed[..5].join(", "),
50 self.files_changed.len() - 5
51 )
52 };
53 lines.push(format!("Files changed: {}", file_summary));
54 }
55
56 lines.join("\n")
57 }
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct RoundState {
63 pub round_number: usize,
65 pub task_ids: Vec<String>,
67 pub tags: Vec<String>,
69 pub failures: Vec<String>,
71 pub started_at: String,
73 pub completed_at: Option<String>,
75}
76
77impl RoundState {
78 pub fn new(round_number: usize) -> Self {
79 Self {
80 round_number,
81 task_ids: Vec::new(),
82 tags: Vec::new(),
83 failures: Vec::new(),
84 started_at: chrono::Utc::now().to_rfc3339(),
85 completed_at: None,
86 }
87 }
88
89 pub fn mark_complete(&mut self) {
90 self.completed_at = Some(chrono::Utc::now().to_rfc3339());
91 }
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct WaveState {
97 pub wave_number: usize,
99 pub rounds: Vec<RoundState>,
101 pub validation: Option<ValidationResult>,
103 pub summary: Option<WaveSummary>,
105 pub started_at: String,
107 pub completed_at: Option<String>,
109}
110
111impl WaveState {
112 pub fn new(wave_number: usize) -> Self {
113 Self {
114 wave_number,
115 rounds: Vec::new(),
116 validation: None,
117 summary: None,
118 started_at: chrono::Utc::now().to_rfc3339(),
119 completed_at: None,
120 }
121 }
122
123 pub fn mark_complete(&mut self) {
124 self.completed_at = Some(chrono::Utc::now().to_rfc3339());
125 }
126
127 pub fn all_task_ids(&self) -> Vec<String> {
129 self.rounds
130 .iter()
131 .flat_map(|r| r.task_ids.clone())
132 .collect()
133 }
134
135 pub fn task_tags(&self) -> Vec<(String, String)> {
137 self.rounds
138 .iter()
139 .flat_map(|r| {
140 r.task_ids
141 .iter()
142 .zip(r.tags.iter())
143 .map(|(id, tag)| (id.clone(), tag.clone()))
144 })
145 .collect()
146 }
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct SwarmSession {
152 pub session_name: String,
154 pub tag: String,
156 pub terminal: String,
158 pub working_dir: String,
160 pub round_size: usize,
162 pub waves: Vec<WaveState>,
164 pub started_at: String,
166 pub completed_at: Option<String>,
168}
169
170impl SwarmSession {
171 pub fn new(
172 session_name: &str,
173 tag: &str,
174 terminal: &str,
175 working_dir: &str,
176 round_size: usize,
177 ) -> Self {
178 Self {
179 session_name: session_name.to_string(),
180 tag: tag.to_string(),
181 terminal: terminal.to_string(),
182 working_dir: working_dir.to_string(),
183 round_size,
184 waves: Vec::new(),
185 started_at: chrono::Utc::now().to_rfc3339(),
186 completed_at: None,
187 }
188 }
189
190 pub fn mark_complete(&mut self) {
191 self.completed_at = Some(chrono::Utc::now().to_rfc3339());
192 }
193
194 pub fn total_tasks(&self) -> usize {
196 self.waves
197 .iter()
198 .flat_map(|w| &w.rounds)
199 .map(|r| r.task_ids.len())
200 .sum()
201 }
202
203 pub fn total_failures(&self) -> usize {
205 self.waves
206 .iter()
207 .flat_map(|w| &w.rounds)
208 .map(|r| r.failures.len())
209 .sum()
210 }
211
212 pub fn get_previous_summary(&self) -> Option<String> {
215 self.waves
216 .last()
217 .and_then(|w| w.summary.as_ref().map(|s| s.to_text()))
218 }
219}
220
221pub fn swarm_dir(project_root: Option<&PathBuf>) -> PathBuf {
223 let root = project_root
224 .cloned()
225 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
226 root.join(".scud").join("swarm")
227}
228
229pub fn session_file(project_root: Option<&PathBuf>, session_name: &str) -> PathBuf {
231 swarm_dir(project_root).join(format!("{}.json", session_name))
232}
233
234pub fn save_session(project_root: Option<&PathBuf>, session: &SwarmSession) -> Result<()> {
236 let dir = swarm_dir(project_root);
237 fs::create_dir_all(&dir)?;
238
239 let file = session_file(project_root, &session.session_name);
240 let json = serde_json::to_string_pretty(session)?;
241 fs::write(file, json)?;
242
243 Ok(())
244}
245
246pub fn load_session(project_root: Option<&PathBuf>, session_name: &str) -> Result<SwarmSession> {
248 let file = session_file(project_root, session_name);
249 let json = fs::read_to_string(&file)?;
250 let session: SwarmSession = serde_json::from_str(&json)?;
251 Ok(session)
252}
253
254pub fn list_sessions(project_root: Option<&PathBuf>) -> Result<Vec<String>> {
256 let dir = swarm_dir(project_root);
257 if !dir.exists() {
258 return Ok(Vec::new());
259 }
260
261 let mut sessions = Vec::new();
262 for entry in fs::read_dir(dir)? {
263 let entry = entry?;
264 let path = entry.path();
265 if path.extension().map(|e| e == "json").unwrap_or(false) {
266 if let Some(stem) = path.file_stem() {
267 sessions.push(stem.to_string_lossy().to_string());
268 }
269 }
270 }
271
272 Ok(sessions)
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278
279 #[test]
280 fn test_round_state_new() {
281 let round = RoundState::new(0);
282 assert_eq!(round.round_number, 0);
283 assert!(round.task_ids.is_empty());
284 assert!(round.completed_at.is_none());
285 }
286
287 #[test]
288 fn test_wave_state_all_task_ids() {
289 let mut wave = WaveState::new(1);
290
291 let mut round1 = RoundState::new(0);
292 round1.task_ids = vec!["task:1".to_string(), "task:2".to_string()];
293
294 let mut round2 = RoundState::new(1);
295 round2.task_ids = vec!["task:3".to_string()];
296
297 wave.rounds.push(round1);
298 wave.rounds.push(round2);
299
300 let all_ids = wave.all_task_ids();
301 assert_eq!(all_ids.len(), 3);
302 assert!(all_ids.contains(&"task:1".to_string()));
303 assert!(all_ids.contains(&"task:2".to_string()));
304 assert!(all_ids.contains(&"task:3".to_string()));
305 }
306
307 #[test]
308 fn test_swarm_session_total_tasks() {
309 let mut session = SwarmSession::new("test-session", "test-tag", "tmux", "/test/path", 5);
310
311 let mut wave = WaveState::new(1);
312 let mut round = RoundState::new(0);
313 round.task_ids = vec!["task:1".to_string(), "task:2".to_string()];
314 wave.rounds.push(round);
315 session.waves.push(wave);
316
317 assert_eq!(session.total_tasks(), 2);
318 }
319
320 #[test]
321 fn test_wave_summary_to_text() {
322 let summary = WaveSummary {
323 wave_number: 1,
324 tasks_completed: vec!["task:1".to_string(), "task:2".to_string()],
325 files_changed: vec!["src/main.rs".to_string()],
326 };
327
328 let text = summary.to_text();
329 assert!(text.contains("Wave 1"));
330 assert!(text.contains("task:1"));
331 assert!(text.contains("src/main.rs"));
332 }
333
334 #[test]
335 fn test_get_previous_summary() {
336 let mut session = SwarmSession::new("test", "tag", "tmux", "/path", 5);
337
338 assert!(session.get_previous_summary().is_none());
340
341 let mut wave = WaveState::new(1);
343 wave.summary = Some(WaveSummary {
344 wave_number: 1,
345 tasks_completed: vec!["task:1".to_string()],
346 files_changed: vec![],
347 });
348 session.waves.push(wave);
349
350 let summary = session.get_previous_summary();
351 assert!(summary.is_some());
352 assert!(summary.unwrap().contains("task:1"));
353 }
354}