use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fs::{self, File, OpenOptions};
use std::io::{self, Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::process;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LoopEntry {
pub id: String,
pub pid: u32,
pub started: DateTime<Utc>,
pub prompt: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub worktree_path: Option<String>,
pub workspace: String,
}
impl LoopEntry {
pub fn new(prompt: impl Into<String>, worktree_path: Option<impl Into<String>>) -> Self {
Self {
id: Self::generate_id(),
pid: process::id(),
started: Utc::now(),
prompt: prompt.into(),
worktree_path: worktree_path.map(Into::into),
workspace: std::env::current_dir()
.map(|p| p.display().to_string())
.unwrap_or_default(),
}
}
pub fn with_workspace(
prompt: impl Into<String>,
worktree_path: Option<impl Into<String>>,
workspace: impl Into<String>,
) -> Self {
Self {
id: Self::generate_id(),
pid: process::id(),
started: Utc::now(),
prompt: prompt.into(),
worktree_path: worktree_path.map(Into::into),
workspace: workspace.into(),
}
}
pub fn with_id(
id: impl Into<String>,
prompt: impl Into<String>,
worktree_path: Option<impl Into<String>>,
workspace: impl Into<String>,
) -> Self {
Self {
id: id.into(),
pid: process::id(),
started: Utc::now(),
prompt: prompt.into(),
worktree_path: worktree_path.map(Into::into),
workspace: workspace.into(),
}
}
fn generate_id() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Time went backwards");
let timestamp = duration.as_secs();
let hex_suffix = format!("{:04x}", duration.subsec_micros() % 0x10000);
format!("loop-{}-{}", timestamp, hex_suffix)
}
#[cfg(unix)]
pub fn is_alive(&self) -> bool {
use nix::sys::signal::kill;
use nix::unistd::Pid;
let pid_alive = kill(Pid::from_raw(self.pid as i32), None)
.map(|_| true)
.unwrap_or(false);
if !pid_alive {
return false;
}
if let Some(ref wt_path) = self.worktree_path {
return std::path::Path::new(wt_path).is_dir();
}
true
}
#[cfg(not(unix))]
pub fn is_alive(&self) -> bool {
if let Some(ref wt_path) = self.worktree_path {
return std::path::Path::new(wt_path).is_dir();
}
true
}
#[cfg(unix)]
pub fn is_pid_alive(&self) -> bool {
use nix::sys::signal::kill;
use nix::unistd::Pid;
kill(Pid::from_raw(self.pid as i32), None)
.map(|_| true)
.unwrap_or(false)
}
#[cfg(not(unix))]
pub fn is_pid_alive(&self) -> bool {
true
}
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
struct RegistryData {
loops: Vec<LoopEntry>,
}
#[derive(Debug, thiserror::Error)]
pub enum RegistryError {
#[error("IO error: {0}")]
Io(#[from] io::Error),
#[error("Failed to parse registry: {0}")]
ParseError(String),
#[error("Loop not found: {0}")]
NotFound(String),
#[error("File locking not supported on this platform")]
UnsupportedPlatform,
}
pub struct LoopRegistry {
registry_path: PathBuf,
}
impl LoopRegistry {
pub const REGISTRY_FILE: &'static str = ".ralph/loops.json";
pub fn new(workspace_root: impl AsRef<Path>) -> Self {
Self {
registry_path: workspace_root.as_ref().join(Self::REGISTRY_FILE),
}
}
pub fn register(&self, entry: LoopEntry) -> Result<String, RegistryError> {
let id = entry.id.clone();
self.with_lock(|data| {
data.loops.retain(|e| e.pid != entry.pid);
data.loops.push(entry);
})?;
Ok(id)
}
pub fn deregister(&self, id: &str) -> Result<(), RegistryError> {
let mut found = false;
self.with_lock(|data| {
let original_len = data.loops.len();
data.loops.retain(|e| e.id != id);
found = data.loops.len() != original_len;
})?;
if !found {
return Err(RegistryError::NotFound(id.to_string()));
}
Ok(())
}
pub fn get(&self, id: &str) -> Result<Option<LoopEntry>, RegistryError> {
let mut result = None;
self.with_lock(|data| {
result = data.loops.iter().find(|e| e.id == id).cloned();
})?;
Ok(result)
}
pub fn list(&self) -> Result<Vec<LoopEntry>, RegistryError> {
let mut result = Vec::new();
self.with_lock(|data| {
result = data.loops.clone();
})?;
Ok(result)
}
pub fn clean_stale(&self) -> Result<usize, RegistryError> {
let mut removed = 0;
self.with_lock(|data| {
let original_len = data.loops.len();
data.loops.retain(|e| e.is_alive());
removed = original_len - data.loops.len();
})?;
Ok(removed)
}
pub fn deregister_current_process(&self) -> Result<bool, RegistryError> {
let pid = std::process::id();
let mut found = false;
self.with_lock(|data| {
let original_len = data.loops.len();
data.loops.retain(|e| e.pid != pid);
found = data.loops.len() != original_len;
})?;
Ok(found)
}
#[cfg(unix)]
fn with_lock<F>(&self, f: F) -> Result<(), RegistryError>
where
F: FnOnce(&mut RegistryData),
{
use nix::fcntl::{Flock, FlockArg};
if let Some(parent) = self.registry_path.parent() {
fs::create_dir_all(parent)?;
}
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&self.registry_path)?;
let flock = Flock::lock(file, FlockArg::LockExclusive).map_err(|(_, errno)| {
RegistryError::Io(io::Error::new(
io::ErrorKind::Other,
format!("flock failed: {}", errno),
))
})?;
let mut data = self.read_data_from_file(&flock)?;
data.loops.retain(|e| e.is_pid_alive());
f(&mut data);
self.write_data_to_file(&flock, &data)?;
Ok(())
}
#[cfg(not(unix))]
fn with_lock<F>(&self, _f: F) -> Result<(), RegistryError>
where
F: FnOnce(&mut RegistryData),
{
Err(RegistryError::UnsupportedPlatform)
}
#[cfg(unix)]
fn read_data_from_file(
&self,
flock: &nix::fcntl::Flock<File>,
) -> Result<RegistryData, RegistryError> {
use std::os::fd::AsFd;
let borrowed_fd = flock.as_fd();
let owned_fd = borrowed_fd.try_clone_to_owned()?;
let mut file: File = owned_fd.into();
file.seek(SeekFrom::Start(0))?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
if contents.trim().is_empty() {
return Ok(RegistryData::default());
}
serde_json::from_str(&contents).map_err(|e| RegistryError::ParseError(e.to_string()))
}
#[cfg(unix)]
fn write_data_to_file(
&self,
flock: &nix::fcntl::Flock<File>,
data: &RegistryData,
) -> Result<(), RegistryError> {
use std::os::fd::AsFd;
let borrowed_fd = flock.as_fd();
let owned_fd = borrowed_fd.try_clone_to_owned()?;
let mut file: File = owned_fd.into();
file.set_len(0)?;
file.seek(SeekFrom::Start(0))?;
let json = serde_json::to_string_pretty(data)
.map_err(|e| RegistryError::ParseError(e.to_string()))?;
file.write_all(json.as_bytes())?;
file.sync_all()?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_loop_entry_creation() {
let entry = LoopEntry::new("test prompt", None::<String>);
assert!(entry.id.starts_with("loop-"));
assert_eq!(entry.pid, process::id());
assert_eq!(entry.prompt, "test prompt");
assert!(entry.worktree_path.is_none());
}
#[test]
fn test_loop_entry_with_worktree() {
let entry = LoopEntry::new("test prompt", Some("/path/to/worktree"));
assert_eq!(entry.worktree_path, Some("/path/to/worktree".to_string()));
}
#[test]
fn test_loop_entry_id_format() {
let entry = LoopEntry::new("test", None::<String>);
let parts: Vec<&str> = entry.id.split('-').collect();
assert_eq!(parts.len(), 3);
assert_eq!(parts[0], "loop");
}
#[test]
fn test_loop_entry_is_alive() {
let entry = LoopEntry::new("test", None::<String>);
assert!(entry.is_alive());
}
#[test]
fn test_loop_entry_with_id() {
let entry = LoopEntry::with_id(
"bright-maple",
"test prompt",
Some("/path/to/worktree"),
"/workspace",
);
assert_eq!(entry.id, "bright-maple");
assert_eq!(entry.pid, process::id());
assert_eq!(entry.prompt, "test prompt");
assert_eq!(entry.worktree_path, Some("/path/to/worktree".to_string()));
assert_eq!(entry.workspace, "/workspace");
}
#[test]
fn test_registry_creates_file() {
let temp_dir = TempDir::new().unwrap();
let registry_path = temp_dir.path().join(".ralph/loops.json");
assert!(!registry_path.exists());
let registry = LoopRegistry::new(temp_dir.path());
let entry = LoopEntry::new("test prompt", None::<String>);
registry.register(entry).unwrap();
assert!(registry_path.exists());
}
#[test]
fn test_registry_register_and_list() {
let temp_dir = TempDir::new().unwrap();
let registry = LoopRegistry::new(temp_dir.path());
let entry = LoopEntry::new("test prompt", None::<String>);
let id = entry.id.clone();
registry.register(entry).unwrap();
let loops = registry.list().unwrap();
assert_eq!(loops.len(), 1);
assert_eq!(loops[0].id, id);
assert_eq!(loops[0].prompt, "test prompt");
}
#[test]
fn test_registry_get() {
let temp_dir = TempDir::new().unwrap();
let registry = LoopRegistry::new(temp_dir.path());
let entry = LoopEntry::new("test prompt", None::<String>);
let id = entry.id.clone();
registry.register(entry).unwrap();
let retrieved = registry.get(&id).unwrap();
assert!(retrieved.is_some());
assert_eq!(retrieved.unwrap().prompt, "test prompt");
}
#[test]
fn test_registry_get_nonexistent() {
let temp_dir = TempDir::new().unwrap();
let registry = LoopRegistry::new(temp_dir.path());
let entry = LoopEntry::new("dummy", None::<String>);
let id = entry.id.clone();
registry.register(entry).unwrap();
registry.deregister(&id).unwrap();
let retrieved = registry.get("nonexistent").unwrap();
assert!(retrieved.is_none());
}
#[test]
fn test_registry_deregister() {
let temp_dir = TempDir::new().unwrap();
let registry = LoopRegistry::new(temp_dir.path());
let entry = LoopEntry::new("test prompt", None::<String>);
let id = entry.id.clone();
registry.register(entry).unwrap();
assert_eq!(registry.list().unwrap().len(), 1);
registry.deregister(&id).unwrap();
assert_eq!(registry.list().unwrap().len(), 0);
}
#[test]
fn test_registry_deregister_nonexistent() {
let temp_dir = TempDir::new().unwrap();
let registry = LoopRegistry::new(temp_dir.path());
let entry = LoopEntry::new("dummy", None::<String>);
let id = entry.id.clone();
registry.register(entry).unwrap();
registry.deregister(&id).unwrap();
let result = registry.deregister("nonexistent");
assert!(matches!(result, Err(RegistryError::NotFound(_))));
}
#[test]
fn test_registry_same_pid_replaces() {
let temp_dir = TempDir::new().unwrap();
let registry = LoopRegistry::new(temp_dir.path());
let wt_dir = temp_dir.path().join("worktree");
fs::create_dir_all(&wt_dir).unwrap();
let entry1 = LoopEntry::new("prompt 1", None::<String>);
let entry2 = LoopEntry::new("prompt 2", Some(wt_dir.display().to_string()));
assert_eq!(entry1.pid, entry2.pid);
registry.register(entry1).unwrap();
registry.register(entry2).unwrap();
let loops = registry.list().unwrap();
assert_eq!(loops.len(), 1);
assert_eq!(loops[0].prompt, "prompt 2");
}
#[test]
fn test_registry_different_pids_coexist() {
let temp_dir = TempDir::new().unwrap();
let registry = LoopRegistry::new(temp_dir.path());
let entry1 = LoopEntry::new("prompt 1", None::<String>);
let id1 = entry1.id.clone();
registry.register(entry1).unwrap();
let mut entry2 = LoopEntry::new("prompt 2", Some("/worktree"));
entry2.pid = 99999; let id2 = entry2.id.clone();
let registry_path = temp_dir.path().join(".ralph/loops.json");
let content = fs::read_to_string(®istry_path).unwrap();
let mut data: serde_json::Value = serde_json::from_str(&content).unwrap();
let loops = data["loops"].as_array_mut().unwrap();
loops.push(serde_json::json!({
"id": id2,
"pid": 99999,
"started": entry2.started,
"prompt": "prompt 2",
"worktree_path": "/worktree",
"workspace": entry2.workspace
}));
fs::write(®istry_path, serde_json::to_string_pretty(&data).unwrap()).unwrap();
let loops = registry.list().unwrap();
assert_eq!(loops.len(), 1);
assert_eq!(loops[0].id, id1);
}
#[test]
fn test_registry_replaces_same_pid() {
let temp_dir = TempDir::new().unwrap();
let registry = LoopRegistry::new(temp_dir.path());
let entry1 = LoopEntry::new("prompt 1", None::<String>);
registry.register(entry1).unwrap();
let entry2 = LoopEntry::new("prompt 2", None::<String>);
registry.register(entry2).unwrap();
let loops = registry.list().unwrap();
assert_eq!(loops.len(), 1);
assert_eq!(loops[0].prompt, "prompt 2");
}
#[test]
fn test_registry_persistence() {
let temp_dir = TempDir::new().unwrap();
let id = {
let registry = LoopRegistry::new(temp_dir.path());
let entry = LoopEntry::new("persistent prompt", None::<String>);
let id = entry.id.clone();
registry.register(entry).unwrap();
id
};
let registry = LoopRegistry::new(temp_dir.path());
let loops = registry.list().unwrap();
assert_eq!(loops.len(), 1);
assert_eq!(loops[0].id, id);
assert_eq!(loops[0].prompt, "persistent prompt");
}
#[test]
fn test_entry_serialization() {
let entry = LoopEntry::new("test prompt", Some("/worktree/path"));
let json = serde_json::to_string(&entry).unwrap();
let deserialized: LoopEntry = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.id, entry.id);
assert_eq!(deserialized.pid, entry.pid);
assert_eq!(deserialized.prompt, "test prompt");
assert_eq!(
deserialized.worktree_path,
Some("/worktree/path".to_string())
);
}
#[test]
fn test_entry_serialization_no_worktree() {
let entry = LoopEntry::new("test prompt", None::<String>);
let json = serde_json::to_string(&entry).unwrap();
assert!(!json.contains("worktree_path"));
let deserialized: LoopEntry = serde_json::from_str(&json).unwrap();
assert!(deserialized.worktree_path.is_none());
}
#[test]
fn test_deregister_current_process() {
let temp_dir = TempDir::new().unwrap();
let registry = LoopRegistry::new(temp_dir.path());
let entry = LoopEntry::new("test prompt", None::<String>);
registry.register(entry).unwrap();
assert_eq!(registry.list().unwrap().len(), 1);
let found = registry.deregister_current_process().unwrap();
assert!(found);
assert_eq!(registry.list().unwrap().len(), 0);
let found = registry.deregister_current_process().unwrap();
assert!(!found);
}
#[test]
fn test_zombie_worktree_detected_as_dead() {
let temp_dir = TempDir::new().unwrap();
let wt_dir = temp_dir.path().join("fake-worktree");
fs::create_dir_all(&wt_dir).unwrap();
let mut entry = LoopEntry::new("zombie test", Some(wt_dir.display().to_string()));
entry.pid = process::id();
assert!(entry.is_alive());
assert!(entry.is_pid_alive());
fs::remove_dir_all(&wt_dir).unwrap();
assert!(!entry.is_alive());
assert!(entry.is_pid_alive());
}
#[test]
fn test_no_worktree_entry_unaffected() {
let entry = LoopEntry::new("primary loop", None::<String>);
assert!(entry.is_alive());
assert!(entry.is_pid_alive());
}
#[test]
fn test_with_lock_keeps_zombie_until_explicit_cleanup() {
let temp_dir = TempDir::new().unwrap();
let registry = LoopRegistry::new(temp_dir.path());
let wt_dir = temp_dir.path().join("zombie-worktree");
fs::create_dir_all(&wt_dir).unwrap();
let entry = LoopEntry::new("zombie keep test", Some(wt_dir.display().to_string()));
let id = entry.id.clone();
registry.register(entry).unwrap();
fs::remove_dir_all(&wt_dir).unwrap();
let got = registry.get(&id).unwrap();
assert!(got.is_some());
let removed = registry.clean_stale().unwrap();
assert_eq!(removed, 1);
assert!(registry.get(&id).unwrap().is_none());
}
}