1#[cfg(feature = "db-sqlite")]
8mod sqlite;
9
10#[cfg(feature = "db-postgres")]
11mod postgres;
12
13#[cfg(feature = "db-sqlite")]
14pub use sqlite::SqliteLockDatabase;
15
16#[cfg(feature = "db-postgres")]
17pub use postgres::PostgresLockDatabase;
18
19use std::collections::HashMap;
20
21use serde::{Deserialize, Serialize};
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct LockRecord {
26 pub id: String,
28 pub repo_id: String,
30 pub path: String,
32 pub pubkey: String,
34 pub locked_at: u64,
36}
37
38#[derive(Debug, Clone, Default)]
40pub struct LockFilters {
41 pub path: Option<String>,
42 pub id: Option<String>,
43 pub cursor: Option<String>,
44 pub limit: Option<u32>,
45}
46
47#[derive(Debug, thiserror::Error)]
49pub enum LockError {
50 #[error("path already locked: {0}")]
51 Conflict(String),
52 #[error("not found")]
53 NotFound,
54 #[error("forbidden: {0}")]
55 Forbidden(String),
56 #[error("lock database error: {0}")]
57 Internal(String),
58}
59
60pub trait LockDatabase: Send + Sync {
65 fn create_lock(
68 &mut self,
69 repo: &str,
70 path: &str,
71 pubkey: &str,
72 ) -> Result<LockRecord, LockError>;
73
74 fn delete_lock(
77 &mut self,
78 repo: &str,
79 id: &str,
80 force: bool,
81 requester: &str,
82 ) -> Result<LockRecord, LockError>;
83
84 fn list_locks(
87 &self,
88 repo: &str,
89 filters: &LockFilters,
90 ) -> Result<(Vec<LockRecord>, Option<String>), LockError>;
91
92 fn get_lock(&self, repo: &str, id: &str) -> Result<LockRecord, LockError>;
94
95 fn get_lock_by_path(&self, repo: &str, path: &str) -> Result<LockRecord, LockError>;
97}
98
99#[derive(Clone)]
101pub struct MemoryLockDatabase {
102 locks: HashMap<String, LockRecord>,
103}
104
105impl MemoryLockDatabase {
106 pub fn new() -> Self {
107 Self {
108 locks: HashMap::new(),
109 }
110 }
111}
112
113impl Default for MemoryLockDatabase {
114 fn default() -> Self {
115 Self::new()
116 }
117}
118
119fn lock_key(repo: &str, id: &str) -> String {
120 format!("{}:{}", repo, id)
121}
122
123fn path_key(repo: &str, path: &str) -> String {
124 format!("{}:{}", repo, path)
125}
126
127impl LockDatabase for MemoryLockDatabase {
128 fn create_lock(
129 &mut self,
130 repo: &str,
131 path: &str,
132 pubkey: &str,
133 ) -> Result<LockRecord, LockError> {
134 let pk = path_key(repo, path);
135 if let Some(existing) = self
136 .locks
137 .values()
138 .find(|l| path_key(&l.repo_id, &l.path) == pk)
139 {
140 return Err(LockError::Conflict(existing.id.clone()));
141 }
142
143 let id = uuid::Uuid::new_v4().to_string();
144 let locked_at = std::time::SystemTime::now()
145 .duration_since(std::time::UNIX_EPOCH)
146 .unwrap_or_default()
147 .as_secs();
148
149 let record = LockRecord {
150 id: id.clone(),
151 repo_id: repo.to_string(),
152 path: path.to_string(),
153 pubkey: pubkey.to_string(),
154 locked_at,
155 };
156
157 let key = lock_key(repo, &id);
158 self.locks.insert(key, record.clone());
159 Ok(record)
160 }
161
162 fn delete_lock(
163 &mut self,
164 repo: &str,
165 id: &str,
166 force: bool,
167 requester: &str,
168 ) -> Result<LockRecord, LockError> {
169 let key = lock_key(repo, id);
170 let lock = self.locks.get(&key).cloned().ok_or(LockError::NotFound)?;
171
172 if !force && lock.pubkey != requester {
173 return Err(LockError::Forbidden(
174 "only the lock owner or an admin can unlock".to_string(),
175 ));
176 }
177
178 self.locks.remove(&key);
179 Ok(lock)
180 }
181
182 fn list_locks(
183 &self,
184 repo: &str,
185 filters: &LockFilters,
186 ) -> Result<(Vec<LockRecord>, Option<String>), LockError> {
187 let mut locks: Vec<LockRecord> = self
188 .locks
189 .values()
190 .filter(|l| l.repo_id == repo)
191 .filter(|l| filters.path.as_ref().map_or(true, |p| l.path == *p))
192 .filter(|l| filters.id.as_ref().map_or(true, |id| l.id == *id))
193 .cloned()
194 .collect();
195
196 locks.sort_by_key(|l| l.locked_at);
197
198 let limit = filters.limit.unwrap_or(100) as usize;
199 let cursor_val = filters
200 .cursor
201 .as_ref()
202 .and_then(|c| c.parse::<usize>().ok());
203
204 let start = cursor_val.unwrap_or(0);
205 let end = std::cmp::min(start + limit, locks.len());
206
207 if start >= locks.len() {
208 return Ok((vec![], None));
209 }
210
211 let next_cursor = if end < locks.len() {
212 Some(end.to_string())
213 } else {
214 None
215 };
216
217 Ok((locks[start..end].to_vec(), next_cursor))
218 }
219
220 fn get_lock(&self, repo: &str, id: &str) -> Result<LockRecord, LockError> {
221 let key = lock_key(repo, id);
222 self.locks.get(&key).cloned().ok_or(LockError::NotFound)
223 }
224
225 fn get_lock_by_path(&self, repo: &str, path: &str) -> Result<LockRecord, LockError> {
226 self.locks
227 .values()
228 .find(|l| l.repo_id == repo && l.path == path)
229 .cloned()
230 .ok_or(LockError::NotFound)
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 #[test]
239 fn test_create_lock() {
240 let mut db = MemoryLockDatabase::new();
241 let lock = db.create_lock("repo1", "file.txt", "pk1").unwrap();
242 assert_eq!(lock.repo_id, "repo1");
243 assert_eq!(lock.path, "file.txt");
244 assert_eq!(lock.pubkey, "pk1");
245 assert!(!lock.id.is_empty());
246 }
247
248 #[test]
249 fn test_create_lock_conflict() {
250 let mut db = MemoryLockDatabase::new();
251 db.create_lock("repo1", "file.txt", "pk1").unwrap();
252 let result = db.create_lock("repo1", "file.txt", "pk2");
253 assert!(matches!(result, Err(LockError::Conflict(_))));
254 }
255
256 #[test]
257 fn test_create_lock_different_repos_same_path() {
258 let mut db = MemoryLockDatabase::new();
259 db.create_lock("repo1", "file.txt", "pk1").unwrap();
260 let result = db.create_lock("repo2", "file.txt", "pk2");
261 assert!(result.is_ok());
262 }
263
264 #[test]
265 fn test_delete_lock_owner() {
266 let mut db = MemoryLockDatabase::new();
267 let lock = db.create_lock("repo1", "file.txt", "pk1").unwrap();
268 let deleted = db.delete_lock("repo1", &lock.id, false, "pk1").unwrap();
269 assert_eq!(deleted.id, lock.id);
270 }
271
272 #[test]
273 fn test_delete_lock_non_owner_no_force() {
274 let mut db = MemoryLockDatabase::new();
275 let lock = db.create_lock("repo1", "file.txt", "pk1").unwrap();
276 let result = db.delete_lock("repo1", &lock.id, false, "pk2");
277 assert!(matches!(result, Err(LockError::Forbidden(_))));
278 }
279
280 #[test]
281 fn test_delete_lock_non_owner_force() {
282 let mut db = MemoryLockDatabase::new();
283 let lock = db.create_lock("repo1", "file.txt", "pk1").unwrap();
284 let deleted = db.delete_lock("repo1", &lock.id, true, "pk2").unwrap();
285 assert_eq!(deleted.id, lock.id);
286 }
287
288 #[test]
289 fn test_delete_lock_not_found() {
290 let mut db = MemoryLockDatabase::new();
291 let result = db.delete_lock("repo1", "nonexistent", false, "pk1");
292 assert!(matches!(result, Err(LockError::NotFound)));
293 }
294
295 #[test]
296 fn test_list_locks() {
297 let mut db = MemoryLockDatabase::new();
298 db.create_lock("repo1", "a.txt", "pk1").unwrap();
299 db.create_lock("repo1", "b.txt", "pk1").unwrap();
300 db.create_lock("repo2", "c.txt", "pk1").unwrap();
301
302 let (locks, cursor) = db.list_locks("repo1", &LockFilters::default()).unwrap();
303 assert_eq!(locks.len(), 2);
304 assert!(cursor.is_none());
305 }
306
307 #[test]
308 fn test_list_locks_with_path_filter() {
309 let mut db = MemoryLockDatabase::new();
310 db.create_lock("repo1", "a.txt", "pk1").unwrap();
311 db.create_lock("repo1", "b.txt", "pk1").unwrap();
312
313 let filters = LockFilters {
314 path: Some("a.txt".to_string()),
315 ..Default::default()
316 };
317 let (locks, _) = db.list_locks("repo1", &filters).unwrap();
318 assert_eq!(locks.len(), 1);
319 assert_eq!(locks[0].path, "a.txt");
320 }
321
322 #[test]
323 fn test_list_locks_pagination() {
324 let mut db = MemoryLockDatabase::new();
325 for i in 0..5 {
326 db.create_lock("repo1", &format!("file{}.txt", i), "pk1")
327 .unwrap();
328 }
329
330 let filters = LockFilters {
331 limit: Some(2),
332 ..Default::default()
333 };
334 let (locks, cursor) = db.list_locks("repo1", &filters).unwrap();
335 assert_eq!(locks.len(), 2);
336 assert!(cursor.is_some());
337
338 let filters2 = LockFilters {
339 limit: Some(2),
340 cursor,
341 ..Default::default()
342 };
343 let (locks2, cursor2) = db.list_locks("repo1", &filters2).unwrap();
344 assert_eq!(locks2.len(), 2);
345 assert!(cursor2.is_some());
346
347 let filters3 = LockFilters {
348 limit: Some(2),
349 cursor: cursor2,
350 ..Default::default()
351 };
352 let (locks3, cursor3) = db.list_locks("repo1", &filters3).unwrap();
353 assert_eq!(locks3.len(), 1);
354 assert!(cursor3.is_none());
355 }
356
357 #[test]
358 fn test_get_lock_by_path() {
359 let mut db = MemoryLockDatabase::new();
360 let lock = db.create_lock("repo1", "file.txt", "pk1").unwrap();
361 let found = db.get_lock_by_path("repo1", "file.txt").unwrap();
362 assert_eq!(found.id, lock.id);
363 }
364
365 #[test]
366 fn test_get_lock_by_path_not_found() {
367 let db = MemoryLockDatabase::new();
368 let result = db.get_lock_by_path("repo1", "nonexistent.txt");
369 assert!(matches!(result, Err(LockError::NotFound)));
370 }
371}