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 ReviewState {
113 pub reviewed_tasks: Vec<String>,
115 pub all_passed: bool,
117 pub tasks_needing_improvement: Vec<String>,
119 pub completed_at: String,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct RepairAttempt {
126 pub attempt_number: usize,
128 pub attributed_tasks: Vec<String>,
130 pub cleared_tasks: Vec<String>,
132 pub attribution_confidence: String,
134 pub validation_passed: bool,
136 pub completed_at: String,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct WaveState {
143 pub wave_number: usize,
145 pub rounds: Vec<RoundState>,
147 pub validation: Option<ValidationResult>,
149 pub summary: Option<WaveSummary>,
151 #[serde(default)]
153 pub start_commit: Option<String>,
154 #[serde(default)]
156 pub review: Option<ReviewState>,
157 #[serde(default)]
159 pub repairs: Vec<RepairAttempt>,
160 pub started_at: String,
162 pub completed_at: Option<String>,
164}
165
166impl WaveState {
167 pub fn new(wave_number: usize) -> Self {
168 Self {
169 wave_number,
170 rounds: Vec::new(),
171 validation: None,
172 summary: None,
173 start_commit: get_current_commit(),
174 review: None,
175 repairs: Vec::new(),
176 started_at: chrono::Utc::now().to_rfc3339(),
177 completed_at: None,
178 }
179 }
180
181 pub fn mark_complete(&mut self) {
182 self.completed_at = Some(chrono::Utc::now().to_rfc3339());
183 }
184
185 pub fn all_task_ids(&self) -> Vec<String> {
187 self.rounds
188 .iter()
189 .flat_map(|r| r.task_ids.clone())
190 .collect()
191 }
192
193 pub fn task_tags(&self) -> Vec<(String, String)> {
195 self.rounds
196 .iter()
197 .flat_map(|r| {
198 r.task_ids
199 .iter()
200 .zip(r.tags.iter())
201 .map(|(id, tag)| (id.clone(), tag.clone()))
202 })
203 .collect()
204 }
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct SwarmSession {
210 pub session_name: String,
212 pub tag: String,
214 pub terminal: String,
216 pub working_dir: String,
218 pub round_size: usize,
220 pub waves: Vec<WaveState>,
222 pub started_at: String,
224 pub completed_at: Option<String>,
226}
227
228impl SwarmSession {
229 pub fn new(
230 session_name: &str,
231 tag: &str,
232 terminal: &str,
233 working_dir: &str,
234 round_size: usize,
235 ) -> Self {
236 Self {
237 session_name: session_name.to_string(),
238 tag: tag.to_string(),
239 terminal: terminal.to_string(),
240 working_dir: working_dir.to_string(),
241 round_size,
242 waves: Vec::new(),
243 started_at: chrono::Utc::now().to_rfc3339(),
244 completed_at: None,
245 }
246 }
247
248 pub fn mark_complete(&mut self) {
249 self.completed_at = Some(chrono::Utc::now().to_rfc3339());
250 }
251
252 pub fn total_tasks(&self) -> usize {
254 self.waves
255 .iter()
256 .flat_map(|w| &w.rounds)
257 .map(|r| r.task_ids.len())
258 .sum()
259 }
260
261 pub fn total_failures(&self) -> usize {
263 self.waves
264 .iter()
265 .flat_map(|w| &w.rounds)
266 .map(|r| r.failures.len())
267 .sum()
268 }
269
270 pub fn get_previous_summary(&self) -> Option<String> {
273 self.waves
274 .last()
275 .and_then(|w| w.summary.as_ref().map(|s| s.to_text()))
276 }
277}
278
279pub fn swarm_dir(project_root: Option<&PathBuf>) -> PathBuf {
281 let root = project_root
282 .cloned()
283 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
284 root.join(".scud").join("swarm")
285}
286
287pub fn lock_file_path(project_root: Option<&PathBuf>, tag: &str) -> PathBuf {
289 swarm_dir(project_root).join(format!("{}.lock", tag))
290}
291
292pub struct SessionLock {
295 _file: fs::File,
296 path: PathBuf,
297}
298
299impl SessionLock {
300 pub fn path(&self) -> &PathBuf {
302 &self.path
303 }
304}
305
306impl Drop for SessionLock {
307 fn drop(&mut self) {
308 let _ = fs::remove_file(&self.path);
311 }
312}
313
314pub fn acquire_session_lock(project_root: Option<&PathBuf>, tag: &str) -> Result<SessionLock> {
318 use fs2::FileExt;
319
320 let dir = swarm_dir(project_root);
321 fs::create_dir_all(&dir)?;
322
323 let lock_path = lock_file_path(project_root, tag);
324 let file = fs::OpenOptions::new()
325 .write(true)
326 .create(true)
327 .truncate(true)
328 .open(&lock_path)?;
329
330 file.try_lock_exclusive().map_err(|_| {
332 anyhow::anyhow!(
333 "Another swarm session is already running for tag '{}'. \
334 If this is incorrect, remove the lock file: {}",
335 tag,
336 lock_path.display()
337 )
338 })?;
339
340 use std::io::Write;
342 let mut file = file;
343 writeln!(
344 file,
345 "pid={}\nstarted={}",
346 std::process::id(),
347 chrono::Utc::now().to_rfc3339()
348 )?;
349
350 Ok(SessionLock {
351 _file: file,
352 path: lock_path,
353 })
354}
355
356pub fn session_file(project_root: Option<&PathBuf>, session_name: &str) -> PathBuf {
358 swarm_dir(project_root).join(format!("{}.json", session_name))
359}
360
361pub fn save_session(project_root: Option<&PathBuf>, session: &SwarmSession) -> Result<()> {
363 let dir = swarm_dir(project_root);
364 fs::create_dir_all(&dir)?;
365
366 let file = session_file(project_root, &session.session_name);
367 let json = serde_json::to_string_pretty(session)?;
368 fs::write(file, json)?;
369
370 Ok(())
371}
372
373pub fn load_session(project_root: Option<&PathBuf>, session_name: &str) -> Result<SwarmSession> {
375 let file = session_file(project_root, session_name);
376 let json = fs::read_to_string(&file)?;
377 let session: SwarmSession = serde_json::from_str(&json)?;
378 Ok(session)
379}
380
381pub fn list_sessions(project_root: Option<&PathBuf>) -> Result<Vec<String>> {
383 let dir = swarm_dir(project_root);
384 if !dir.exists() {
385 return Ok(Vec::new());
386 }
387
388 let mut sessions = Vec::new();
389 for entry in fs::read_dir(dir)? {
390 let entry = entry?;
391 let path = entry.path();
392 if path.extension().map(|e| e == "json").unwrap_or(false) {
393 if let Some(stem) = path.file_stem() {
394 sessions.push(stem.to_string_lossy().to_string());
395 }
396 }
397 }
398
399 Ok(sessions)
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405
406 #[test]
407 fn test_round_state_new() {
408 let round = RoundState::new(0);
409 assert_eq!(round.round_number, 0);
410 assert!(round.task_ids.is_empty());
411 assert!(round.completed_at.is_none());
412 }
413
414 #[test]
415 fn test_wave_state_all_task_ids() {
416 let mut wave = WaveState::new(1);
417
418 let mut round1 = RoundState::new(0);
419 round1.task_ids = vec!["task:1".to_string(), "task:2".to_string()];
420
421 let mut round2 = RoundState::new(1);
422 round2.task_ids = vec!["task:3".to_string()];
423
424 wave.rounds.push(round1);
425 wave.rounds.push(round2);
426
427 let all_ids = wave.all_task_ids();
428 assert_eq!(all_ids.len(), 3);
429 assert!(all_ids.contains(&"task:1".to_string()));
430 assert!(all_ids.contains(&"task:2".to_string()));
431 assert!(all_ids.contains(&"task:3".to_string()));
432 }
433
434 #[test]
435 fn test_swarm_session_total_tasks() {
436 let mut session = SwarmSession::new("test-session", "test-tag", "tmux", "/test/path", 5);
437
438 let mut wave = WaveState::new(1);
439 let mut round = RoundState::new(0);
440 round.task_ids = vec!["task:1".to_string(), "task:2".to_string()];
441 wave.rounds.push(round);
442 session.waves.push(wave);
443
444 assert_eq!(session.total_tasks(), 2);
445 }
446
447 #[test]
448 fn test_wave_summary_to_text() {
449 let summary = WaveSummary {
450 wave_number: 1,
451 tasks_completed: vec!["task:1".to_string(), "task:2".to_string()],
452 files_changed: vec!["src/main.rs".to_string()],
453 };
454
455 let text = summary.to_text();
456 assert!(text.contains("Wave 1"));
457 assert!(text.contains("task:1"));
458 assert!(text.contains("src/main.rs"));
459 }
460
461 #[test]
462 fn test_get_previous_summary() {
463 let mut session = SwarmSession::new("test", "tag", "tmux", "/path", 5);
464
465 assert!(session.get_previous_summary().is_none());
467
468 let mut wave = WaveState::new(1);
470 wave.summary = Some(WaveSummary {
471 wave_number: 1,
472 tasks_completed: vec!["task:1".to_string()],
473 files_changed: vec![],
474 });
475 session.waves.push(wave);
476
477 let summary = session.get_previous_summary();
478 assert!(summary.is_some());
479 assert!(summary.unwrap().contains("task:1"));
480 }
481
482 #[test]
483 fn test_session_lock_contention() {
484 use tempfile::TempDir;
485
486 let temp_dir = TempDir::new().unwrap();
488 let project_root = temp_dir.path().to_path_buf();
489
490 let _lock1 = acquire_session_lock(Some(&project_root), "test-tag")
492 .expect("First lock should succeed");
493
494 let result = acquire_session_lock(Some(&project_root), "test-tag");
496
497 match result {
499 Ok(_) => panic!("Second lock should fail"),
500 Err(e) => {
501 let error_msg = e.to_string();
502 assert!(
503 error_msg.contains("already running"),
504 "Error message should mention 'already running', got: {}",
505 error_msg
506 );
507 }
508 }
509 }
510
511 #[test]
512 fn test_get_current_commit() {
513 let result = get_current_commit();
514
515 assert!(result.is_some(), "Expected Some(sha) in a git repository");
517
518 let sha = result.unwrap();
519
520 assert_eq!(
522 sha.len(),
523 40,
524 "Expected SHA to be 40 characters long, got {}",
525 sha.len()
526 );
527
528 assert!(
530 sha.chars().all(|c| c.is_ascii_hexdigit()),
531 "Expected SHA to contain only hex characters, got: {}",
532 sha
533 );
534 }
535}