1use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
11pub struct Lock {
12 pub owner: String,
14 pub nonce: String,
16 pub expires_unix_ms: u64,
18 pub resource: String,
20}
21
22impl Lock {
23 pub fn new(owner: String, resource: String, ttl_ms: u64) -> Self {
25 let now = current_time_ms();
26 Self {
27 owner,
28 nonce: uuid::Uuid::new_v4().to_string(),
29 expires_unix_ms: now + ttl_ms,
30 resource,
31 }
32 }
33
34 pub fn is_expired(&self) -> bool {
36 let now = current_time_ms();
37 now >= self.expires_unix_ms
38 }
39
40 pub fn time_remaining_ms(&self) -> u64 {
42 let now = current_time_ms();
43 self.expires_unix_ms.saturating_sub(now)
44 }
45
46 pub fn renew(&mut self, ttl_ms: u64) {
48 let now = current_time_ms();
49 self.expires_unix_ms = now + ttl_ms;
50 }
51
52 pub fn expired(owner: String, resource: String) -> Self {
54 Self {
55 owner,
56 nonce: uuid::Uuid::new_v4().to_string(),
57 expires_unix_ms: 0,
58 resource,
59 }
60 }
61
62 pub fn namespace(&self) -> Option<&str> {
64 self.resource.split(':').next()
65 }
66
67 pub fn conflicts_with(&self, other_resource: &str) -> bool {
69 if self.is_expired() {
70 return false;
71 }
72
73 let self_ns = self.namespace();
74 let other_ns = other_resource.split(':').next();
75
76 match (self_ns, other_ns) {
77 (Some("repo"), _) => true,
79 (_, Some("repo")) => true,
80
81 (Some("path"), Some("path")) => {
83 let self_path = self.resource.strip_prefix("path:").unwrap_or("");
84 let other_path = other_resource.strip_prefix("path:").unwrap_or("");
85 paths_overlap(self_path, other_path)
86 }
87
88 (Some("issue"), Some("issue")) => self.resource == other_resource,
90
91 _ => false,
93 }
94 }
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
99#[serde(rename_all = "lowercase")]
100pub enum LockPolicy {
101 Off,
103 #[default]
105 Warn,
106 Require,
108}
109
110impl LockPolicy {
111 #[allow(clippy::should_implement_trait)]
113 pub fn from_str(s: &str) -> Option<Self> {
114 match s.to_lowercase().as_str() {
115 "off" => Some(LockPolicy::Off),
116 "warn" => Some(LockPolicy::Warn),
117 "require" => Some(LockPolicy::Require),
118 _ => None,
119 }
120 }
121
122 pub fn as_str(&self) -> &'static str {
124 match self {
125 LockPolicy::Off => "off",
126 LockPolicy::Warn => "warn",
127 LockPolicy::Require => "require",
128 }
129 }
130}
131
132#[derive(Debug, Clone)]
134pub struct LockStatus {
135 pub lock: Lock,
137 pub owned_by_self: bool,
139}
140
141#[derive(Debug, Clone)]
143pub enum LockCheckResult {
144 Clear,
146 Warning(Vec<Lock>),
148 Blocked(Vec<Lock>),
150}
151
152impl LockCheckResult {
153 pub fn should_proceed(&self) -> bool {
155 !matches!(self, LockCheckResult::Blocked(_))
156 }
157
158 pub fn conflicts(&self) -> &[Lock] {
160 match self {
161 LockCheckResult::Clear => &[],
162 LockCheckResult::Warning(locks) | LockCheckResult::Blocked(locks) => locks,
163 }
164 }
165}
166
167pub fn resource_hash(resource: &str) -> String {
171 let mut hasher = Sha256::new();
172 hasher.update(resource.as_bytes());
173 let result = hasher.finalize();
174 hex::encode(&result[..8]) }
176
177pub const DEFAULT_LOCK_TTL_MS: u64 = 5 * 60 * 1000;
179
180fn current_time_ms() -> u64 {
182 std::time::SystemTime::now()
183 .duration_since(std::time::UNIX_EPOCH)
184 .unwrap_or_default()
185 .as_millis() as u64
186}
187
188fn paths_overlap(path1: &str, path2: &str) -> bool {
190 if path1 == path2 {
191 return true;
192 }
193
194 let p1 = path1.trim_end_matches('/');
196 let p2 = path2.trim_end_matches('/');
197
198 if p1 == p2 {
199 return true;
200 }
201
202 let p1_dir = if p1.ends_with('/') {
204 p1.to_string()
205 } else {
206 format!("{}/", p1)
207 };
208 let p2_dir = if p2.ends_with('/') {
209 p2.to_string()
210 } else {
211 format!("{}/", p2)
212 };
213
214 p2.starts_with(&p1_dir) || p1.starts_with(&p2_dir)
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220
221 #[test]
222 fn test_lock_creation() {
223 let lock = Lock::new("actor123".to_string(), "repo:global".to_string(), 60000);
224 assert_eq!(lock.owner, "actor123");
225 assert_eq!(lock.resource, "repo:global");
226 assert!(!lock.is_expired());
227 assert!(lock.time_remaining_ms() > 0);
228 }
229
230 #[test]
231 fn test_lock_expiration() {
232 let lock = Lock::expired("actor123".to_string(), "repo:global".to_string());
233 assert!(lock.is_expired());
234 assert_eq!(lock.time_remaining_ms(), 0);
235 }
236
237 #[test]
238 fn test_lock_namespace() {
239 let lock = Lock::new("actor".to_string(), "repo:global".to_string(), 1000);
240 assert_eq!(lock.namespace(), Some("repo"));
241
242 let lock = Lock::new("actor".to_string(), "path:src/main.rs".to_string(), 1000);
243 assert_eq!(lock.namespace(), Some("path"));
244
245 let lock = Lock::new("actor".to_string(), "issue:abc123".to_string(), 1000);
246 assert_eq!(lock.namespace(), Some("issue"));
247 }
248
249 #[test]
250 fn test_repo_lock_conflicts() {
251 let repo_lock = Lock::new("actor".to_string(), "repo:global".to_string(), 60000);
252
253 assert!(repo_lock.conflicts_with("repo:global"));
255 assert!(repo_lock.conflicts_with("path:src/main.rs"));
256 assert!(repo_lock.conflicts_with("issue:abc123"));
257 }
258
259 #[test]
260 fn test_path_lock_conflicts() {
261 let path_lock = Lock::new("actor".to_string(), "path:src/".to_string(), 60000);
262
263 assert!(path_lock.conflicts_with("path:src/main.rs"));
265 assert!(path_lock.conflicts_with("path:src/lib.rs"));
266 assert!(path_lock.conflicts_with("path:src/"));
267
268 assert!(!path_lock.conflicts_with("path:tests/"));
270 assert!(!path_lock.conflicts_with("path:docs/"));
271
272 assert!(!path_lock.conflicts_with("issue:abc123"));
274 }
275
276 #[test]
277 fn test_issue_lock_conflicts() {
278 let issue_lock = Lock::new("actor".to_string(), "issue:abc123".to_string(), 60000);
279
280 assert!(issue_lock.conflicts_with("issue:abc123"));
282 assert!(!issue_lock.conflicts_with("issue:def456"));
283 assert!(!issue_lock.conflicts_with("path:src/"));
284 }
285
286 #[test]
287 fn test_expired_lock_no_conflict() {
288 let expired = Lock::expired("actor".to_string(), "repo:global".to_string());
289
290 assert!(!expired.conflicts_with("repo:global"));
292 assert!(!expired.conflicts_with("path:src/"));
293 }
294
295 #[test]
296 fn test_resource_hash() {
297 let hash1 = resource_hash("repo:global");
298 let hash2 = resource_hash("repo:global");
299 let hash3 = resource_hash("issue:abc123");
300
301 assert_eq!(hash1, hash2);
303 assert_ne!(hash1, hash3);
305 assert_eq!(hash1.len(), 16);
307 }
308
309 #[test]
310 fn test_lock_policy_parse() {
311 assert_eq!(LockPolicy::from_str("off"), Some(LockPolicy::Off));
312 assert_eq!(LockPolicy::from_str("warn"), Some(LockPolicy::Warn));
313 assert_eq!(LockPolicy::from_str("require"), Some(LockPolicy::Require));
314 assert_eq!(LockPolicy::from_str("WARN"), Some(LockPolicy::Warn));
315 assert_eq!(LockPolicy::from_str("invalid"), None);
316 }
317
318 #[test]
319 fn test_paths_overlap() {
320 assert!(paths_overlap("src/main.rs", "src/main.rs"));
322
323 assert!(paths_overlap("src/", "src/main.rs"));
325 assert!(paths_overlap("src", "src/main.rs"));
326
327 assert!(paths_overlap("src/main.rs", "src/"));
329
330 assert!(!paths_overlap("src/", "tests/"));
332 assert!(!paths_overlap("src/main.rs", "src/lib.rs"));
333 }
334
335 #[test]
336 fn test_lock_check_result() {
337 let clear = LockCheckResult::Clear;
338 assert!(clear.should_proceed());
339 assert!(clear.conflicts().is_empty());
340
341 let lock = Lock::new("other".to_string(), "repo:global".to_string(), 1000);
342 let warning = LockCheckResult::Warning(vec![lock.clone()]);
343 assert!(warning.should_proceed());
344 assert_eq!(warning.conflicts().len(), 1);
345
346 let blocked = LockCheckResult::Blocked(vec![lock]);
347 assert!(!blocked.should_proceed());
348 assert_eq!(blocked.conflicts().len(), 1);
349 }
350}