git_claw/infra/
registry.rs1use crate::infra::error::RegistryError;
4use crate::infra::git::git_worktree_prune;
5use serde::{Deserialize, Serialize};
6use std::fs::{self, File};
7use std::io::Write;
8use std::path::{Path, PathBuf};
9use std::time::{Duration, SystemTime, UNIX_EPOCH};
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct SlotRecord {
14 pub id: u32,
15 pub name: String,
16 pub branch_type: String,
17 pub branch: String,
18 pub path: String,
19 pub allocated_at: String,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct SlotRegistry {
25 pub version: u32,
26 pub slots: Vec<SlotRecord>,
27}
28
29impl SlotRegistry {
30 pub const CURRENT_VERSION: u32 = 1;
31 pub const CREATION_GRACE_PERIOD: Duration = Duration::from_secs(30);
32
33 pub fn empty() -> Self {
35 Self {
36 version: Self::CURRENT_VERSION,
37 slots: Vec::new(),
38 }
39 }
40
41 pub fn load_or_empty(path: &Path) -> Result<Self, RegistryError> {
43 if !path.exists() {
44 return Ok(Self::empty());
45 }
46
47 let content = fs::read_to_string(path).map_err(|e| RegistryError::IoError {
48 path: path.to_string_lossy().to_string(),
49 source: e,
50 })?;
51
52 let registry: SlotRegistry =
53 serde_json::from_str(&content).map_err(|e| RegistryError::JsonError {
54 path: path.to_string_lossy().to_string(),
55 source: e,
56 })?;
57
58 Ok(registry)
59 }
60
61 pub fn save_atomic(&self, path: &Path) -> Result<(), RegistryError> {
63 if let Some(parent) = path.parent() {
64 fs::create_dir_all(parent).map_err(|e| RegistryError::IoError {
65 path: parent.to_string_lossy().to_string(),
66 source: e,
67 })?;
68 }
69
70 let tmp_path = format!("{}.tmp.{}", path.display(), std::process::id());
71 let tmp_path = PathBuf::from(tmp_path);
72
73 let json = serde_json::to_string_pretty(self).map_err(|e| RegistryError::JsonError {
74 path: path.to_string_lossy().to_string(),
75 source: e,
76 })?;
77
78 let mut file = File::create(&tmp_path).map_err(|e| RegistryError::IoError {
79 path: tmp_path.to_string_lossy().to_string(),
80 source: e,
81 })?;
82
83 file.write_all(json.as_bytes())
84 .map_err(|e| RegistryError::IoError {
85 path: tmp_path.to_string_lossy().to_string(),
86 source: e,
87 })?;
88
89 file.sync_all().map_err(|e| RegistryError::IoError {
90 path: tmp_path.to_string_lossy().to_string(),
91 source: e,
92 })?;
93
94 fs::rename(&tmp_path, path).map_err(|e| RegistryError::IoError {
95 path: path.to_string_lossy().to_string(),
96 source: e,
97 })?;
98
99 Ok(())
100 }
101
102 pub fn find_by_id(&self, id: u32) -> Option<&SlotRecord> {
104 self.slots.iter().find(|s| s.id == id)
105 }
106
107 pub fn find_by_name(&self, name: &str) -> Option<&SlotRecord> {
109 self.slots.iter().find(|s| s.name == name)
110 }
111
112 pub fn occupied_ids(&self) -> Vec<u32> {
114 self.slots.iter().map(|s| s.id).collect()
115 }
116
117 pub fn remove_by_name(&mut self, name: &str) -> bool {
119 let initial_len = self.slots.len();
120 self.slots.retain(|s| s.name != name);
121 self.slots.len() < initial_len
122 }
123
124 pub fn remove_by_id(&mut self, id: u32) -> bool {
126 let initial_len = self.slots.len();
127 self.slots.retain(|s| s.id != id);
128 self.slots.len() < initial_len
129 }
130
131 pub fn auto_gc(
134 &mut self,
135 registry_path: &Path,
136 repo_root: Option<&Path>,
137 respect_grace_period: bool,
138 ) -> Result<usize, RegistryError> {
139 let now = SystemTime::now()
140 .duration_since(UNIX_EPOCH)
141 .unwrap_or_default()
142 .as_secs();
143
144 let initial_count = self.slots.len();
145 self.slots.retain(|slot| {
146 let path = Path::new(&slot.path);
147 if path.exists() {
148 return true;
149 }
150
151 if respect_grace_period {
152 if let Ok(ts) = slot.allocated_at.parse::<u64>() {
153 if now.saturating_sub(ts) < Self::CREATION_GRACE_PERIOD.as_secs() {
154 return true;
155 }
156 }
157 }
158
159 false
160 });
161
162 let purged = initial_count - self.slots.len();
163 if purged > 0 {
164 self.save_atomic(registry_path)?;
165 let _ = git_worktree_prune(repo_root);
166 }
167
168 Ok(purged)
169 }
170}