1use anyhow::Result;
10use serde::{Deserialize, Serialize};
11use std::fs;
12use std::path::PathBuf;
13use std::process::Command;
14
15use crate::backpressure::ValidationResult;
16
17pub fn get_current_commit() -> Option<String> {
19 Command::new("git")
20 .args(["rev-parse", "HEAD"])
21 .output()
22 .ok()
23 .and_then(|output| {
24 if output.status.success() {
25 Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
26 } else {
27 None
28 }
29 })
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct WaveSummary {
36 pub wave_number: usize,
38 pub tasks_completed: Vec<String>,
40 pub files_changed: Vec<String>,
42}
43
44impl WaveSummary {
45 pub fn to_text(&self) -> String {
47 let mut lines = Vec::new();
48
49 lines.push(format!(
50 "Wave {} completed {} task(s):",
51 self.wave_number,
52 self.tasks_completed.len()
53 ));
54
55 for task_id in &self.tasks_completed {
56 lines.push(format!(" - {}", task_id));
57 }
58
59 if !self.files_changed.is_empty() {
60 let file_summary = if self.files_changed.len() <= 5 {
61 self.files_changed.join(", ")
62 } else {
63 format!(
64 "{} and {} more",
65 self.files_changed[..5].join(", "),
66 self.files_changed.len() - 5
67 )
68 };
69 lines.push(format!("Files changed: {}", file_summary));
70 }
71
72 lines.join("\n")
73 }
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct RoundState {
79 pub round_number: usize,
81 pub task_ids: Vec<String>,
83 pub tags: Vec<String>,
85 pub failures: Vec<String>,
87 pub started_at: String,
89 pub completed_at: Option<String>,
91}
92
93impl RoundState {
94 pub fn new(round_number: usize) -> Self {
95 Self {
96 round_number,
97 task_ids: Vec::new(),
98 tags: Vec::new(),
99 failures: Vec::new(),
100 started_at: chrono::Utc::now().to_rfc3339(),
101 completed_at: None,
102 }
103 }
104
105 pub fn mark_complete(&mut self) {
106 self.completed_at = Some(chrono::Utc::now().to_rfc3339());
107 }
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct WaveState {
113 pub wave_number: usize,
115 pub rounds: Vec<RoundState>,
117 pub validation: Option<ValidationResult>,
119 pub summary: Option<WaveSummary>,
121 #[serde(default)]
123 pub start_commit: Option<String>,
124 pub started_at: String,
126 pub completed_at: Option<String>,
128}
129
130impl WaveState {
131 pub fn new(wave_number: usize) -> Self {
132 Self {
133 wave_number,
134 rounds: Vec::new(),
135 validation: None,
136 summary: None,
137 start_commit: get_current_commit(),
138 started_at: chrono::Utc::now().to_rfc3339(),
139 completed_at: None,
140 }
141 }
142
143 pub fn mark_complete(&mut self) {
144 self.completed_at = Some(chrono::Utc::now().to_rfc3339());
145 }
146
147 pub fn all_task_ids(&self) -> Vec<String> {
149 self.rounds
150 .iter()
151 .flat_map(|r| r.task_ids.clone())
152 .collect()
153 }
154
155 pub fn task_tags(&self) -> Vec<(String, String)> {
157 self.rounds
158 .iter()
159 .flat_map(|r| {
160 r.task_ids
161 .iter()
162 .zip(r.tags.iter())
163 .map(|(id, tag)| (id.clone(), tag.clone()))
164 })
165 .collect()
166 }
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct SwarmSession {
172 pub session_name: String,
174 pub tag: String,
176 pub terminal: String,
178 pub working_dir: String,
180 pub round_size: usize,
182 pub waves: Vec<WaveState>,
184 pub started_at: String,
186 pub completed_at: Option<String>,
188}
189
190impl SwarmSession {
191 pub fn new(
192 session_name: &str,
193 tag: &str,
194 terminal: &str,
195 working_dir: &str,
196 round_size: usize,
197 ) -> Self {
198 Self {
199 session_name: session_name.to_string(),
200 tag: tag.to_string(),
201 terminal: terminal.to_string(),
202 working_dir: working_dir.to_string(),
203 round_size,
204 waves: Vec::new(),
205 started_at: chrono::Utc::now().to_rfc3339(),
206 completed_at: None,
207 }
208 }
209
210 pub fn mark_complete(&mut self) {
211 self.completed_at = Some(chrono::Utc::now().to_rfc3339());
212 }
213
214 pub fn total_tasks(&self) -> usize {
216 self.waves
217 .iter()
218 .flat_map(|w| &w.rounds)
219 .map(|r| r.task_ids.len())
220 .sum()
221 }
222
223 pub fn total_failures(&self) -> usize {
225 self.waves
226 .iter()
227 .flat_map(|w| &w.rounds)
228 .map(|r| r.failures.len())
229 .sum()
230 }
231
232 pub fn get_previous_summary(&self) -> Option<String> {
235 self.waves
236 .last()
237 .and_then(|w| w.summary.as_ref().map(|s| s.to_text()))
238 }
239}
240
241pub fn swarm_dir(project_root: Option<&PathBuf>) -> PathBuf {
243 let root = project_root
244 .cloned()
245 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
246 root.join(".scud").join("swarm")
247}
248
249pub fn lock_file_path(project_root: Option<&PathBuf>, tag: &str) -> PathBuf {
251 swarm_dir(project_root).join(format!("{}.lock", tag))
252}
253
254pub struct SessionLock {
257 _file: fs::File,
258 path: PathBuf,
259}
260
261impl SessionLock {
262 pub fn path(&self) -> &PathBuf {
264 &self.path
265 }
266}
267
268impl Drop for SessionLock {
269 fn drop(&mut self) {
270 let _ = fs::remove_file(&self.path);
273 }
274}
275
276pub fn acquire_session_lock(project_root: Option<&PathBuf>, tag: &str) -> Result<SessionLock> {
280 use fs2::FileExt;
281
282 let dir = swarm_dir(project_root);
283 fs::create_dir_all(&dir)?;
284
285 let lock_path = lock_file_path(project_root, tag);
286 let file = fs::OpenOptions::new()
287 .write(true)
288 .create(true)
289 .truncate(true)
290 .open(&lock_path)?;
291
292 file.try_lock_exclusive().map_err(|_| {
294 anyhow::anyhow!(
295 "Another swarm session is already running for tag '{}'. \
296 If this is incorrect, remove the lock file: {}",
297 tag,
298 lock_path.display()
299 )
300 })?;
301
302 use std::io::Write;
304 let mut file = file;
305 writeln!(
306 file,
307 "pid={}\nstarted={}",
308 std::process::id(),
309 chrono::Utc::now().to_rfc3339()
310 )?;
311
312 Ok(SessionLock {
313 _file: file,
314 path: lock_path,
315 })
316}
317
318pub fn session_file(project_root: Option<&PathBuf>, session_name: &str) -> PathBuf {
320 swarm_dir(project_root).join(format!("{}.json", session_name))
321}
322
323pub fn save_session(project_root: Option<&PathBuf>, session: &SwarmSession) -> Result<()> {
325 let dir = swarm_dir(project_root);
326 fs::create_dir_all(&dir)?;
327
328 let file = session_file(project_root, &session.session_name);
329 let json = serde_json::to_string_pretty(session)?;
330 fs::write(file, json)?;
331
332 Ok(())
333}
334
335pub fn load_session(project_root: Option<&PathBuf>, session_name: &str) -> Result<SwarmSession> {
337 let file = session_file(project_root, session_name);
338 let json = fs::read_to_string(&file)?;
339 let session: SwarmSession = serde_json::from_str(&json)?;
340 Ok(session)
341}
342
343pub fn list_sessions(project_root: Option<&PathBuf>) -> Result<Vec<String>> {
345 let dir = swarm_dir(project_root);
346 if !dir.exists() {
347 return Ok(Vec::new());
348 }
349
350 let mut sessions = Vec::new();
351 for entry in fs::read_dir(dir)? {
352 let entry = entry?;
353 let path = entry.path();
354 if path.extension().map(|e| e == "json").unwrap_or(false) {
355 if let Some(stem) = path.file_stem() {
356 sessions.push(stem.to_string_lossy().to_string());
357 }
358 }
359 }
360
361 Ok(sessions)
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367
368 #[test]
369 fn test_round_state_new() {
370 let round = RoundState::new(0);
371 assert_eq!(round.round_number, 0);
372 assert!(round.task_ids.is_empty());
373 assert!(round.completed_at.is_none());
374 }
375
376 #[test]
377 fn test_wave_state_all_task_ids() {
378 let mut wave = WaveState::new(1);
379
380 let mut round1 = RoundState::new(0);
381 round1.task_ids = vec!["task:1".to_string(), "task:2".to_string()];
382
383 let mut round2 = RoundState::new(1);
384 round2.task_ids = vec!["task:3".to_string()];
385
386 wave.rounds.push(round1);
387 wave.rounds.push(round2);
388
389 let all_ids = wave.all_task_ids();
390 assert_eq!(all_ids.len(), 3);
391 assert!(all_ids.contains(&"task:1".to_string()));
392 assert!(all_ids.contains(&"task:2".to_string()));
393 assert!(all_ids.contains(&"task:3".to_string()));
394 }
395
396 #[test]
397 fn test_swarm_session_total_tasks() {
398 let mut session = SwarmSession::new("test-session", "test-tag", "tmux", "/test/path", 5);
399
400 let mut wave = WaveState::new(1);
401 let mut round = RoundState::new(0);
402 round.task_ids = vec!["task:1".to_string(), "task:2".to_string()];
403 wave.rounds.push(round);
404 session.waves.push(wave);
405
406 assert_eq!(session.total_tasks(), 2);
407 }
408
409 #[test]
410 fn test_wave_summary_to_text() {
411 let summary = WaveSummary {
412 wave_number: 1,
413 tasks_completed: vec!["task:1".to_string(), "task:2".to_string()],
414 files_changed: vec!["src/main.rs".to_string()],
415 };
416
417 let text = summary.to_text();
418 assert!(text.contains("Wave 1"));
419 assert!(text.contains("task:1"));
420 assert!(text.contains("src/main.rs"));
421 }
422
423 #[test]
424 fn test_get_previous_summary() {
425 let mut session = SwarmSession::new("test", "tag", "tmux", "/path", 5);
426
427 assert!(session.get_previous_summary().is_none());
429
430 let mut wave = WaveState::new(1);
432 wave.summary = Some(WaveSummary {
433 wave_number: 1,
434 tasks_completed: vec!["task:1".to_string()],
435 files_changed: vec![],
436 });
437 session.waves.push(wave);
438
439 let summary = session.get_previous_summary();
440 assert!(summary.is_some());
441 assert!(summary.unwrap().contains("task:1"));
442 }
443
444 #[test]
445 fn test_session_lock_contention() {
446 use tempfile::TempDir;
447
448 let temp_dir = TempDir::new().unwrap();
450 let project_root = temp_dir.path().to_path_buf();
451
452 let _lock1 = acquire_session_lock(Some(&project_root), "test-tag")
454 .expect("First lock should succeed");
455
456 let result = acquire_session_lock(Some(&project_root), "test-tag");
458
459 match result {
461 Ok(_) => panic!("Second lock should fail"),
462 Err(e) => {
463 let error_msg = e.to_string();
464 assert!(
465 error_msg.contains("already running"),
466 "Error message should mention 'already running', got: {}",
467 error_msg
468 );
469 }
470 }
471 }
472
473 #[test]
474 fn test_get_current_commit() {
475 let result = get_current_commit();
476
477 assert!(result.is_some(), "Expected Some(sha) in a git repository");
479
480 let sha = result.unwrap();
481
482 assert_eq!(
484 sha.len(),
485 40,
486 "Expected SHA to be 40 characters long, got {}",
487 sha.len()
488 );
489
490 assert!(
492 sha.chars().all(|c| c.is_ascii_hexdigit()),
493 "Expected SHA to contain only hex characters, got: {}",
494 sha
495 );
496 }
497}