1use serde::{Deserialize, Serialize};
7use sha2::{Sha256, Digest};
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 if now >= self.expires_unix_ms {
44 0
45 } else {
46 self.expires_unix_ms - now
47 }
48 }
49
50 pub fn renew(&mut self, ttl_ms: u64) {
52 let now = current_time_ms();
53 self.expires_unix_ms = now + ttl_ms;
54 }
55
56 pub fn expired(owner: String, resource: String) -> Self {
58 Self {
59 owner,
60 nonce: uuid::Uuid::new_v4().to_string(),
61 expires_unix_ms: 0,
62 resource,
63 }
64 }
65
66 pub fn namespace(&self) -> Option<&str> {
68 self.resource.split(':').next()
69 }
70
71 pub fn conflicts_with(&self, other_resource: &str) -> bool {
73 if self.is_expired() {
74 return false;
75 }
76
77 let self_ns = self.namespace();
78 let other_ns = other_resource.split(':').next();
79
80 match (self_ns, other_ns) {
81 (Some("repo"), _) => true,
83 (_, Some("repo")) => true,
84
85 (Some("path"), Some("path")) => {
87 let self_path = self.resource.strip_prefix("path:").unwrap_or("");
88 let other_path = other_resource.strip_prefix("path:").unwrap_or("");
89 paths_overlap(self_path, other_path)
90 }
91
92 (Some("issue"), Some("issue")) => self.resource == other_resource,
94
95 _ => false,
97 }
98 }
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
103#[serde(rename_all = "lowercase")]
104pub enum LockPolicy {
105 Off,
107 #[default]
109 Warn,
110 Require,
112}
113
114impl LockPolicy {
115 pub fn from_str(s: &str) -> Option<Self> {
117 match s.to_lowercase().as_str() {
118 "off" => Some(LockPolicy::Off),
119 "warn" => Some(LockPolicy::Warn),
120 "require" => Some(LockPolicy::Require),
121 _ => None,
122 }
123 }
124
125 pub fn as_str(&self) -> &'static str {
127 match self {
128 LockPolicy::Off => "off",
129 LockPolicy::Warn => "warn",
130 LockPolicy::Require => "require",
131 }
132 }
133}
134
135#[derive(Debug, Clone)]
137pub struct LockStatus {
138 pub lock: Lock,
140 pub owned_by_self: bool,
142}
143
144#[derive(Debug, Clone)]
146pub enum LockCheckResult {
147 Clear,
149 Warning(Vec<Lock>),
151 Blocked(Vec<Lock>),
153}
154
155impl LockCheckResult {
156 pub fn should_proceed(&self) -> bool {
158 !matches!(self, LockCheckResult::Blocked(_))
159 }
160
161 pub fn conflicts(&self) -> &[Lock] {
163 match self {
164 LockCheckResult::Clear => &[],
165 LockCheckResult::Warning(locks) | LockCheckResult::Blocked(locks) => locks,
166 }
167 }
168}
169
170pub fn resource_hash(resource: &str) -> String {
174 let mut hasher = Sha256::new();
175 hasher.update(resource.as_bytes());
176 let result = hasher.finalize();
177 hex::encode(&result[..8]) }
179
180pub const DEFAULT_LOCK_TTL_MS: u64 = 5 * 60 * 1000;
182
183fn current_time_ms() -> u64 {
185 std::time::SystemTime::now()
186 .duration_since(std::time::UNIX_EPOCH)
187 .unwrap()
188 .as_millis() as u64
189}
190
191fn paths_overlap(path1: &str, path2: &str) -> bool {
193 if path1 == path2 {
194 return true;
195 }
196
197 let p1 = path1.trim_end_matches('/');
199 let p2 = path2.trim_end_matches('/');
200
201 if p1 == p2 {
202 return true;
203 }
204
205 let p1_dir = if p1.ends_with('/') { p1.to_string() } else { format!("{}/", p1) };
207 let p2_dir = if p2.ends_with('/') { p2.to_string() } else { format!("{}/", p2) };
208
209 p2.starts_with(&p1_dir) || p1.starts_with(&p2_dir)
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215
216 #[test]
217 fn test_lock_creation() {
218 let lock = Lock::new("actor123".to_string(), "repo:global".to_string(), 60000);
219 assert_eq!(lock.owner, "actor123");
220 assert_eq!(lock.resource, "repo:global");
221 assert!(!lock.is_expired());
222 assert!(lock.time_remaining_ms() > 0);
223 }
224
225 #[test]
226 fn test_lock_expiration() {
227 let lock = Lock::expired("actor123".to_string(), "repo:global".to_string());
228 assert!(lock.is_expired());
229 assert_eq!(lock.time_remaining_ms(), 0);
230 }
231
232 #[test]
233 fn test_lock_namespace() {
234 let lock = Lock::new("actor".to_string(), "repo:global".to_string(), 1000);
235 assert_eq!(lock.namespace(), Some("repo"));
236
237 let lock = Lock::new("actor".to_string(), "path:src/main.rs".to_string(), 1000);
238 assert_eq!(lock.namespace(), Some("path"));
239
240 let lock = Lock::new("actor".to_string(), "issue:abc123".to_string(), 1000);
241 assert_eq!(lock.namespace(), Some("issue"));
242 }
243
244 #[test]
245 fn test_repo_lock_conflicts() {
246 let repo_lock = Lock::new("actor".to_string(), "repo:global".to_string(), 60000);
247
248 assert!(repo_lock.conflicts_with("repo:global"));
250 assert!(repo_lock.conflicts_with("path:src/main.rs"));
251 assert!(repo_lock.conflicts_with("issue:abc123"));
252 }
253
254 #[test]
255 fn test_path_lock_conflicts() {
256 let path_lock = Lock::new("actor".to_string(), "path:src/".to_string(), 60000);
257
258 assert!(path_lock.conflicts_with("path:src/main.rs"));
260 assert!(path_lock.conflicts_with("path:src/lib.rs"));
261 assert!(path_lock.conflicts_with("path:src/"));
262
263 assert!(!path_lock.conflicts_with("path:tests/"));
265 assert!(!path_lock.conflicts_with("path:docs/"));
266
267 assert!(!path_lock.conflicts_with("issue:abc123"));
269 }
270
271 #[test]
272 fn test_issue_lock_conflicts() {
273 let issue_lock = Lock::new("actor".to_string(), "issue:abc123".to_string(), 60000);
274
275 assert!(issue_lock.conflicts_with("issue:abc123"));
277 assert!(!issue_lock.conflicts_with("issue:def456"));
278 assert!(!issue_lock.conflicts_with("path:src/"));
279 }
280
281 #[test]
282 fn test_expired_lock_no_conflict() {
283 let expired = Lock::expired("actor".to_string(), "repo:global".to_string());
284
285 assert!(!expired.conflicts_with("repo:global"));
287 assert!(!expired.conflicts_with("path:src/"));
288 }
289
290 #[test]
291 fn test_resource_hash() {
292 let hash1 = resource_hash("repo:global");
293 let hash2 = resource_hash("repo:global");
294 let hash3 = resource_hash("issue:abc123");
295
296 assert_eq!(hash1, hash2);
298 assert_ne!(hash1, hash3);
300 assert_eq!(hash1.len(), 16);
302 }
303
304 #[test]
305 fn test_lock_policy_parse() {
306 assert_eq!(LockPolicy::from_str("off"), Some(LockPolicy::Off));
307 assert_eq!(LockPolicy::from_str("warn"), Some(LockPolicy::Warn));
308 assert_eq!(LockPolicy::from_str("require"), Some(LockPolicy::Require));
309 assert_eq!(LockPolicy::from_str("WARN"), Some(LockPolicy::Warn));
310 assert_eq!(LockPolicy::from_str("invalid"), None);
311 }
312
313 #[test]
314 fn test_paths_overlap() {
315 assert!(paths_overlap("src/main.rs", "src/main.rs"));
317
318 assert!(paths_overlap("src/", "src/main.rs"));
320 assert!(paths_overlap("src", "src/main.rs"));
321
322 assert!(paths_overlap("src/main.rs", "src/"));
324
325 assert!(!paths_overlap("src/", "tests/"));
327 assert!(!paths_overlap("src/main.rs", "src/lib.rs"));
328 }
329
330 #[test]
331 fn test_lock_check_result() {
332 let clear = LockCheckResult::Clear;
333 assert!(clear.should_proceed());
334 assert!(clear.conflicts().is_empty());
335
336 let lock = Lock::new("other".to_string(), "repo:global".to_string(), 1000);
337 let warning = LockCheckResult::Warning(vec![lock.clone()]);
338 assert!(warning.should_proceed());
339 assert_eq!(warning.conflicts().len(), 1);
340
341 let blocked = LockCheckResult::Blocked(vec![lock]);
342 assert!(!blocked.should_proceed());
343 assert_eq!(blocked.conflicts().len(), 1);
344 }
345}