Skip to main content

libgrite_core/
lock.rs

1//! Lock types for team coordination
2//!
3//! grite uses lease-based locks stored as git refs for coordination.
4//! Locks are optional and designed for coordination, not enforcement.
5
6use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8
9/// A lease-based lock on a resource
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
11pub struct Lock {
12    /// Actor ID who owns the lock (hex-encoded)
13    pub owner: String,
14    /// Unique nonce for this lock instance
15    pub nonce: String,
16    /// When the lock expires (Unix timestamp in ms)
17    pub expires_unix_ms: u64,
18    /// Resource being locked (e.g., "repo:global", "issue:abc123")
19    pub resource: String,
20}
21
22impl Lock {
23    /// Create a new lock
24    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    /// Check if the lock has expired
35    pub fn is_expired(&self) -> bool {
36        let now = current_time_ms();
37        now >= self.expires_unix_ms
38    }
39
40    /// Get time remaining in milliseconds (0 if expired)
41    pub fn time_remaining_ms(&self) -> u64 {
42        let now = current_time_ms();
43        self.expires_unix_ms.saturating_sub(now)
44    }
45
46    /// Extend the lock's expiration
47    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    /// Create an expired lock (for release)
53    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    /// Get the namespace of this lock's resource
63    pub fn namespace(&self) -> Option<&str> {
64        self.resource.split(':').next()
65    }
66
67    /// Check if this lock conflicts with another resource
68    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            // Repo-wide lock conflicts with everything
78            (Some("repo"), _) => true,
79            (_, Some("repo")) => true,
80
81            // Path locks only conflict with overlapping paths
82            (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            // Issue locks only conflict with same issue
89            (Some("issue"), Some("issue")) => self.resource == other_resource,
90
91            // Different namespaces don't conflict (except repo)
92            _ => false,
93        }
94    }
95}
96
97/// Lock policy for write operations
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
99#[serde(rename_all = "lowercase")]
100pub enum LockPolicy {
101    /// No lock checks
102    Off,
103    /// Warn on conflicts but continue (default)
104    #[default]
105    Warn,
106    /// Block if conflicting lock exists
107    Require,
108}
109
110impl LockPolicy {
111    /// Parse from string
112    #[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    /// Convert to string
123    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/// Status of a lock check
133#[derive(Debug, Clone)]
134pub struct LockStatus {
135    /// The lock
136    pub lock: Lock,
137    /// Whether it's owned by the current actor
138    pub owned_by_self: bool,
139}
140
141/// Result of a lock conflict check
142#[derive(Debug, Clone)]
143pub enum LockCheckResult {
144    /// No conflicts
145    Clear,
146    /// Conflicts exist but policy allows continue (warn)
147    Warning(Vec<Lock>),
148    /// Conflicts exist and policy blocks operation
149    Blocked(Vec<Lock>),
150}
151
152impl LockCheckResult {
153    /// Check if operation should proceed
154    pub fn should_proceed(&self) -> bool {
155        !matches!(self, LockCheckResult::Blocked(_))
156    }
157
158    /// Get conflicting locks if any
159    pub fn conflicts(&self) -> &[Lock] {
160        match self {
161            LockCheckResult::Clear => &[],
162            LockCheckResult::Warning(locks) | LockCheckResult::Blocked(locks) => locks,
163        }
164    }
165}
166
167/// Compute the hash for a lock ref name
168///
169/// Returns first 16 chars of SHA256 hex
170pub 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]) // 8 bytes = 16 hex chars
175}
176
177/// Default lock TTL in milliseconds (5 minutes)
178pub const DEFAULT_LOCK_TTL_MS: u64 = 5 * 60 * 1000;
179
180/// Get current time in milliseconds since Unix epoch
181fn 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
188/// Check if two paths overlap (one is prefix of the other or they're equal)
189fn paths_overlap(path1: &str, path2: &str) -> bool {
190    if path1 == path2 {
191        return true;
192    }
193
194    // Normalize paths - remove trailing slashes for comparison
195    let p1 = path1.trim_end_matches('/');
196    let p2 = path2.trim_end_matches('/');
197
198    if p1 == p2 {
199        return true;
200    }
201
202    // Check if one is a prefix of the other (as a directory)
203    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        // Repo lock conflicts with everything
254        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        // Path lock conflicts with overlapping paths
264        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        // Doesn't conflict with non-overlapping
269        assert!(!path_lock.conflicts_with("path:tests/"));
270        assert!(!path_lock.conflicts_with("path:docs/"));
271
272        // Doesn't conflict with other namespaces (except repo)
273        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        // Issue lock only conflicts with same issue
281        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        // Expired locks don't conflict
291        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        // Same resource produces same hash
302        assert_eq!(hash1, hash2);
303        // Different resources produce different hashes
304        assert_ne!(hash1, hash3);
305        // Hash is 16 hex chars
306        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        // Exact match
321        assert!(paths_overlap("src/main.rs", "src/main.rs"));
322
323        // Directory contains file
324        assert!(paths_overlap("src/", "src/main.rs"));
325        assert!(paths_overlap("src", "src/main.rs"));
326
327        // File in directory
328        assert!(paths_overlap("src/main.rs", "src/"));
329
330        // Non-overlapping
331        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}