1use chrono::{DateTime, Utc};
36use serde::{Deserialize, Serialize};
37use std::fs::{self, File, OpenOptions};
38use std::io::{self, Read, Seek, SeekFrom, Write};
39use std::path::{Path, PathBuf};
40use std::process;
41
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
44pub struct LoopEntry {
45 pub id: String,
47
48 pub pid: u32,
50
51 pub started: DateTime<Utc>,
53
54 pub prompt: String,
56
57 #[serde(skip_serializing_if = "Option::is_none")]
59 pub worktree_path: Option<String>,
60
61 pub workspace: String,
63}
64
65impl LoopEntry {
66 pub fn new(prompt: impl Into<String>, worktree_path: Option<impl Into<String>>) -> Self {
68 Self {
69 id: Self::generate_id(),
70 pid: process::id(),
71 started: Utc::now(),
72 prompt: prompt.into(),
73 worktree_path: worktree_path.map(Into::into),
74 workspace: std::env::current_dir()
75 .map(|p| p.display().to_string())
76 .unwrap_or_default(),
77 }
78 }
79
80 pub fn with_workspace(
82 prompt: impl Into<String>,
83 worktree_path: Option<impl Into<String>>,
84 workspace: impl Into<String>,
85 ) -> Self {
86 Self {
87 id: Self::generate_id(),
88 pid: process::id(),
89 started: Utc::now(),
90 prompt: prompt.into(),
91 worktree_path: worktree_path.map(Into::into),
92 workspace: workspace.into(),
93 }
94 }
95
96 pub fn with_id(
101 id: impl Into<String>,
102 prompt: impl Into<String>,
103 worktree_path: Option<impl Into<String>>,
104 workspace: impl Into<String>,
105 ) -> Self {
106 Self {
107 id: id.into(),
108 pid: process::id(),
109 started: Utc::now(),
110 prompt: prompt.into(),
111 worktree_path: worktree_path.map(Into::into),
112 workspace: workspace.into(),
113 }
114 }
115
116 fn generate_id() -> String {
118 use std::time::{SystemTime, UNIX_EPOCH};
119 let duration = SystemTime::now()
120 .duration_since(UNIX_EPOCH)
121 .expect("Time went backwards");
122 let timestamp = duration.as_secs();
123 let hex_suffix = format!("{:04x}", duration.subsec_micros() % 0x10000);
124 format!("loop-{}-{}", timestamp, hex_suffix)
125 }
126
127 #[cfg(unix)]
133 pub fn is_alive(&self) -> bool {
134 use nix::sys::signal::kill;
135 use nix::unistd::Pid;
136
137 let pid_alive = kill(Pid::from_raw(self.pid as i32), None)
139 .map(|_| true)
140 .unwrap_or(false);
141
142 if !pid_alive {
143 return false;
144 }
145
146 if let Some(ref wt_path) = self.worktree_path {
148 return std::path::Path::new(wt_path).is_dir();
149 }
150
151 true
152 }
153
154 #[cfg(not(unix))]
155 pub fn is_alive(&self) -> bool {
156 if let Some(ref wt_path) = self.worktree_path {
158 return std::path::Path::new(wt_path).is_dir();
159 }
160 true
161 }
162
163 #[cfg(unix)]
168 pub fn is_pid_alive(&self) -> bool {
169 use nix::sys::signal::kill;
170 use nix::unistd::Pid;
171
172 kill(Pid::from_raw(self.pid as i32), None)
173 .map(|_| true)
174 .unwrap_or(false)
175 }
176
177 #[cfg(not(unix))]
178 pub fn is_pid_alive(&self) -> bool {
179 true
180 }
181}
182
183#[derive(Debug, Default, Clone, Serialize, Deserialize)]
185struct RegistryData {
186 loops: Vec<LoopEntry>,
187}
188
189#[derive(Debug, thiserror::Error)]
191pub enum RegistryError {
192 #[error("IO error: {0}")]
194 Io(#[from] io::Error),
195
196 #[error("Failed to parse registry: {0}")]
198 ParseError(String),
199
200 #[error("Loop not found: {0}")]
202 NotFound(String),
203
204 #[error("File locking not supported on this platform")]
206 UnsupportedPlatform,
207}
208
209pub struct LoopRegistry {
213 registry_path: PathBuf,
215}
216
217impl LoopRegistry {
218 pub const REGISTRY_FILE: &'static str = ".ralph/loops.json";
220
221 pub fn new(workspace_root: impl AsRef<Path>) -> Self {
223 Self {
224 registry_path: workspace_root.as_ref().join(Self::REGISTRY_FILE),
225 }
226 }
227
228 pub fn register(&self, entry: LoopEntry) -> Result<String, RegistryError> {
232 let id = entry.id.clone();
233 self.with_lock(|data| {
234 data.loops.retain(|e| e.pid != entry.pid);
236 data.loops.push(entry);
237 })?;
238 Ok(id)
239 }
240
241 pub fn deregister(&self, id: &str) -> Result<(), RegistryError> {
243 let mut found = false;
244 self.with_lock(|data| {
245 let original_len = data.loops.len();
246 data.loops.retain(|e| e.id != id);
247 found = data.loops.len() != original_len;
248 })?;
249 if !found {
250 return Err(RegistryError::NotFound(id.to_string()));
251 }
252 Ok(())
253 }
254
255 pub fn get(&self, id: &str) -> Result<Option<LoopEntry>, RegistryError> {
257 let mut result = None;
258 self.with_lock(|data| {
259 result = data.loops.iter().find(|e| e.id == id).cloned();
260 })?;
261 Ok(result)
262 }
263
264 pub fn list(&self) -> Result<Vec<LoopEntry>, RegistryError> {
266 let mut result = Vec::new();
267 self.with_lock(|data| {
268 result = data.loops.clone();
269 })?;
270 Ok(result)
271 }
272
273 pub fn clean_stale(&self) -> Result<usize, RegistryError> {
275 let mut removed = 0;
276 self.with_lock(|data| {
277 let original_len = data.loops.len();
278 data.loops.retain(|e| e.is_alive());
279 removed = original_len - data.loops.len();
280 })?;
281 Ok(removed)
282 }
283
284 pub fn deregister_current_process(&self) -> Result<bool, RegistryError> {
289 let pid = std::process::id();
290 let mut found = false;
291 self.with_lock(|data| {
292 let original_len = data.loops.len();
293 data.loops.retain(|e| e.pid != pid);
294 found = data.loops.len() != original_len;
295 })?;
296 Ok(found)
297 }
298
299 #[cfg(unix)]
301 fn with_lock<F>(&self, f: F) -> Result<(), RegistryError>
302 where
303 F: FnOnce(&mut RegistryData),
304 {
305 use nix::fcntl::{Flock, FlockArg};
306
307 if let Some(parent) = self.registry_path.parent() {
309 fs::create_dir_all(parent)?;
310 }
311
312 let file = OpenOptions::new()
314 .read(true)
315 .write(true)
316 .create(true)
317 .truncate(false)
318 .open(&self.registry_path)?;
319
320 let flock = Flock::lock(file, FlockArg::LockExclusive).map_err(|(_, errno)| {
322 RegistryError::Io(io::Error::new(
323 io::ErrorKind::Other,
324 format!("flock failed: {}", errno),
325 ))
326 })?;
327
328 let mut data = self.read_data_from_file(&flock)?;
330
331 data.loops.retain(|e| e.is_pid_alive());
336
337 f(&mut data);
339
340 self.write_data_to_file(&flock, &data)?;
342
343 Ok(())
344 }
345
346 #[cfg(not(unix))]
347 fn with_lock<F>(&self, _f: F) -> Result<(), RegistryError>
348 where
349 F: FnOnce(&mut RegistryData),
350 {
351 Err(RegistryError::UnsupportedPlatform)
352 }
353
354 #[cfg(unix)]
356 fn read_data_from_file(
357 &self,
358 flock: &nix::fcntl::Flock<File>,
359 ) -> Result<RegistryData, RegistryError> {
360 use std::os::fd::AsFd;
361
362 let borrowed_fd = flock.as_fd();
364 let owned_fd = borrowed_fd.try_clone_to_owned()?;
365 let mut file: File = owned_fd.into();
366
367 file.seek(SeekFrom::Start(0))?;
368
369 let mut contents = String::new();
370 file.read_to_string(&mut contents)?;
371
372 if contents.trim().is_empty() {
373 return Ok(RegistryData::default());
374 }
375
376 serde_json::from_str(&contents).map_err(|e| RegistryError::ParseError(e.to_string()))
377 }
378
379 #[cfg(unix)]
381 fn write_data_to_file(
382 &self,
383 flock: &nix::fcntl::Flock<File>,
384 data: &RegistryData,
385 ) -> Result<(), RegistryError> {
386 use std::os::fd::AsFd;
387
388 let borrowed_fd = flock.as_fd();
390 let owned_fd = borrowed_fd.try_clone_to_owned()?;
391 let mut file: File = owned_fd.into();
392
393 file.set_len(0)?;
394 file.seek(SeekFrom::Start(0))?;
395
396 let json = serde_json::to_string_pretty(data)
397 .map_err(|e| RegistryError::ParseError(e.to_string()))?;
398
399 file.write_all(json.as_bytes())?;
400 file.sync_all()?;
401
402 Ok(())
403 }
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409 use tempfile::TempDir;
410
411 #[test]
412 fn test_loop_entry_creation() {
413 let entry = LoopEntry::new("test prompt", None::<String>);
414 assert!(entry.id.starts_with("loop-"));
415 assert_eq!(entry.pid, process::id());
416 assert_eq!(entry.prompt, "test prompt");
417 assert!(entry.worktree_path.is_none());
418 }
419
420 #[test]
421 fn test_loop_entry_with_worktree() {
422 let entry = LoopEntry::new("test prompt", Some("/path/to/worktree"));
423 assert_eq!(entry.worktree_path, Some("/path/to/worktree".to_string()));
424 }
425
426 #[test]
427 fn test_loop_entry_id_format() {
428 let entry = LoopEntry::new("test", None::<String>);
429 let parts: Vec<&str> = entry.id.split('-').collect();
430 assert_eq!(parts.len(), 3);
431 assert_eq!(parts[0], "loop");
432 }
433
434 #[test]
435 fn test_loop_entry_is_alive() {
436 let entry = LoopEntry::new("test", None::<String>);
437 assert!(entry.is_alive());
439 }
440
441 #[test]
442 fn test_loop_entry_with_id() {
443 let entry = LoopEntry::with_id(
444 "bright-maple",
445 "test prompt",
446 Some("/path/to/worktree"),
447 "/workspace",
448 );
449 assert_eq!(entry.id, "bright-maple");
450 assert_eq!(entry.pid, process::id());
451 assert_eq!(entry.prompt, "test prompt");
452 assert_eq!(entry.worktree_path, Some("/path/to/worktree".to_string()));
453 assert_eq!(entry.workspace, "/workspace");
454 }
455
456 #[test]
457 fn test_registry_creates_file() {
458 let temp_dir = TempDir::new().unwrap();
459 let registry_path = temp_dir.path().join(".ralph/loops.json");
460
461 assert!(!registry_path.exists());
462
463 let registry = LoopRegistry::new(temp_dir.path());
464 let entry = LoopEntry::new("test prompt", None::<String>);
465 registry.register(entry).unwrap();
466
467 assert!(registry_path.exists());
468 }
469
470 #[test]
471 fn test_registry_register_and_list() {
472 let temp_dir = TempDir::new().unwrap();
473 let registry = LoopRegistry::new(temp_dir.path());
474
475 let entry = LoopEntry::new("test prompt", None::<String>);
476 let id = entry.id.clone();
477
478 registry.register(entry).unwrap();
479
480 let loops = registry.list().unwrap();
481 assert_eq!(loops.len(), 1);
482 assert_eq!(loops[0].id, id);
483 assert_eq!(loops[0].prompt, "test prompt");
484 }
485
486 #[test]
487 fn test_registry_get() {
488 let temp_dir = TempDir::new().unwrap();
489 let registry = LoopRegistry::new(temp_dir.path());
490
491 let entry = LoopEntry::new("test prompt", None::<String>);
492 let id = entry.id.clone();
493
494 registry.register(entry).unwrap();
495
496 let retrieved = registry.get(&id).unwrap();
497 assert!(retrieved.is_some());
498 assert_eq!(retrieved.unwrap().prompt, "test prompt");
499 }
500
501 #[test]
502 fn test_registry_get_nonexistent() {
503 let temp_dir = TempDir::new().unwrap();
504 let registry = LoopRegistry::new(temp_dir.path());
505
506 let entry = LoopEntry::new("dummy", None::<String>);
508 let id = entry.id.clone();
509 registry.register(entry).unwrap();
510 registry.deregister(&id).unwrap();
511
512 let retrieved = registry.get("nonexistent").unwrap();
513 assert!(retrieved.is_none());
514 }
515
516 #[test]
517 fn test_registry_deregister() {
518 let temp_dir = TempDir::new().unwrap();
519 let registry = LoopRegistry::new(temp_dir.path());
520
521 let entry = LoopEntry::new("test prompt", None::<String>);
522 let id = entry.id.clone();
523
524 registry.register(entry).unwrap();
525 assert_eq!(registry.list().unwrap().len(), 1);
526
527 registry.deregister(&id).unwrap();
528 assert_eq!(registry.list().unwrap().len(), 0);
529 }
530
531 #[test]
532 fn test_registry_deregister_nonexistent() {
533 let temp_dir = TempDir::new().unwrap();
534 let registry = LoopRegistry::new(temp_dir.path());
535
536 let entry = LoopEntry::new("dummy", None::<String>);
538 let id = entry.id.clone();
539 registry.register(entry).unwrap();
540 registry.deregister(&id).unwrap();
541
542 let result = registry.deregister("nonexistent");
543 assert!(matches!(result, Err(RegistryError::NotFound(_))));
544 }
545
546 #[test]
547 fn test_registry_same_pid_replaces() {
548 let temp_dir = TempDir::new().unwrap();
550 let registry = LoopRegistry::new(temp_dir.path());
551
552 let wt_dir = temp_dir.path().join("worktree");
554 fs::create_dir_all(&wt_dir).unwrap();
555
556 let entry1 = LoopEntry::new("prompt 1", None::<String>);
557 let entry2 = LoopEntry::new("prompt 2", Some(wt_dir.display().to_string()));
558
559 assert_eq!(entry1.pid, entry2.pid);
561
562 registry.register(entry1).unwrap();
563 registry.register(entry2).unwrap();
564
565 let loops = registry.list().unwrap();
567 assert_eq!(loops.len(), 1);
568 assert_eq!(loops[0].prompt, "prompt 2");
569 }
570
571 #[test]
572 fn test_registry_different_pids_coexist() {
573 let temp_dir = TempDir::new().unwrap();
575 let registry = LoopRegistry::new(temp_dir.path());
576
577 let entry1 = LoopEntry::new("prompt 1", None::<String>);
579 let id1 = entry1.id.clone();
580 registry.register(entry1).unwrap();
581
582 let mut entry2 = LoopEntry::new("prompt 2", Some("/worktree"));
584 entry2.pid = 99999; let id2 = entry2.id.clone();
586
587 let registry_path = temp_dir.path().join(".ralph/loops.json");
589 let content = fs::read_to_string(®istry_path).unwrap();
590 let mut data: serde_json::Value = serde_json::from_str(&content).unwrap();
591 let loops = data["loops"].as_array_mut().unwrap();
592 loops.push(serde_json::json!({
593 "id": id2,
594 "pid": 99999,
595 "started": entry2.started,
596 "prompt": "prompt 2",
597 "worktree_path": "/worktree",
598 "workspace": entry2.workspace
599 }));
600 fs::write(®istry_path, serde_json::to_string_pretty(&data).unwrap()).unwrap();
601
602 let loops = registry.list().unwrap();
605 assert_eq!(loops.len(), 1);
606 assert_eq!(loops[0].id, id1);
607 }
608
609 #[test]
610 fn test_registry_replaces_same_pid() {
611 let temp_dir = TempDir::new().unwrap();
612 let registry = LoopRegistry::new(temp_dir.path());
613
614 let entry1 = LoopEntry::new("prompt 1", None::<String>);
616 registry.register(entry1).unwrap();
617
618 let entry2 = LoopEntry::new("prompt 2", None::<String>);
620 registry.register(entry2).unwrap();
621
622 let loops = registry.list().unwrap();
624 assert_eq!(loops.len(), 1);
625 assert_eq!(loops[0].prompt, "prompt 2");
626 }
627
628 #[test]
629 fn test_registry_persistence() {
630 let temp_dir = TempDir::new().unwrap();
631
632 let id = {
633 let registry = LoopRegistry::new(temp_dir.path());
634 let entry = LoopEntry::new("persistent prompt", None::<String>);
635 let id = entry.id.clone();
636 registry.register(entry).unwrap();
637 id
638 };
639
640 let registry = LoopRegistry::new(temp_dir.path());
642 let loops = registry.list().unwrap();
643 assert_eq!(loops.len(), 1);
644 assert_eq!(loops[0].id, id);
645 assert_eq!(loops[0].prompt, "persistent prompt");
646 }
647
648 #[test]
649 fn test_entry_serialization() {
650 let entry = LoopEntry::new("test prompt", Some("/worktree/path"));
651 let json = serde_json::to_string(&entry).unwrap();
652 let deserialized: LoopEntry = serde_json::from_str(&json).unwrap();
653
654 assert_eq!(deserialized.id, entry.id);
655 assert_eq!(deserialized.pid, entry.pid);
656 assert_eq!(deserialized.prompt, "test prompt");
657 assert_eq!(
658 deserialized.worktree_path,
659 Some("/worktree/path".to_string())
660 );
661 }
662
663 #[test]
664 fn test_entry_serialization_no_worktree() {
665 let entry = LoopEntry::new("test prompt", None::<String>);
666 let json = serde_json::to_string(&entry).unwrap();
667
668 assert!(!json.contains("worktree_path"));
670
671 let deserialized: LoopEntry = serde_json::from_str(&json).unwrap();
672 assert!(deserialized.worktree_path.is_none());
673 }
674
675 #[test]
676 fn test_deregister_current_process() {
677 let temp_dir = TempDir::new().unwrap();
678 let registry = LoopRegistry::new(temp_dir.path());
679
680 let entry = LoopEntry::new("test prompt", None::<String>);
682 registry.register(entry).unwrap();
683 assert_eq!(registry.list().unwrap().len(), 1);
684
685 let found = registry.deregister_current_process().unwrap();
687 assert!(found);
688 assert_eq!(registry.list().unwrap().len(), 0);
689
690 let found = registry.deregister_current_process().unwrap();
692 assert!(!found);
693 }
694
695 #[test]
696 fn test_zombie_worktree_detected_as_dead() {
697 let temp_dir = TempDir::new().unwrap();
698
699 let wt_dir = temp_dir.path().join("fake-worktree");
701 fs::create_dir_all(&wt_dir).unwrap();
702
703 let mut entry = LoopEntry::new("zombie test", Some(wt_dir.display().to_string()));
704 entry.pid = process::id();
706
707 assert!(entry.is_alive());
709 assert!(entry.is_pid_alive());
710
711 fs::remove_dir_all(&wt_dir).unwrap();
713
714 assert!(!entry.is_alive());
716 assert!(entry.is_pid_alive());
717 }
718
719 #[test]
720 fn test_no_worktree_entry_unaffected() {
721 let entry = LoopEntry::new("primary loop", None::<String>);
723 assert!(entry.is_alive());
724 assert!(entry.is_pid_alive());
725 }
726
727 #[test]
728 fn test_with_lock_keeps_zombie_until_explicit_cleanup() {
729 let temp_dir = TempDir::new().unwrap();
730 let registry = LoopRegistry::new(temp_dir.path());
731
732 let wt_dir = temp_dir.path().join("zombie-worktree");
734 fs::create_dir_all(&wt_dir).unwrap();
735
736 let entry = LoopEntry::new("zombie keep test", Some(wt_dir.display().to_string()));
737 let id = entry.id.clone();
738 registry.register(entry).unwrap();
739
740 fs::remove_dir_all(&wt_dir).unwrap();
742
743 let got = registry.get(&id).unwrap();
746 assert!(got.is_some());
747
748 let removed = registry.clean_stale().unwrap();
750 assert_eq!(removed, 1);
751 assert!(registry.get(&id).unwrap().is_none());
752 }
753}