use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Lock {
pub owner: String,
pub nonce: String,
pub expires_unix_ms: u64,
pub resource: String,
}
impl Lock {
pub fn new(owner: String, resource: String, ttl_ms: u64) -> Self {
let now = current_time_ms();
Self {
owner,
nonce: uuid::Uuid::new_v4().to_string(),
expires_unix_ms: now + ttl_ms,
resource,
}
}
pub fn is_expired(&self) -> bool {
let now = current_time_ms();
now >= self.expires_unix_ms
}
pub fn time_remaining_ms(&self) -> u64 {
let now = current_time_ms();
self.expires_unix_ms.saturating_sub(now)
}
pub fn renew(&mut self, ttl_ms: u64) {
let now = current_time_ms();
self.expires_unix_ms = now + ttl_ms;
}
pub fn expired(owner: String, resource: String) -> Self {
Self {
owner,
nonce: uuid::Uuid::new_v4().to_string(),
expires_unix_ms: 0,
resource,
}
}
pub fn namespace(&self) -> Option<&str> {
self.resource.split(':').next()
}
pub fn conflicts_with(&self, other_resource: &str) -> bool {
if self.is_expired() {
return false;
}
let self_ns = self.namespace();
let other_ns = other_resource.split(':').next();
match (self_ns, other_ns) {
(Some("repo"), _) => true,
(_, Some("repo")) => true,
(Some("path"), Some("path")) => {
let self_path = self.resource.strip_prefix("path:").unwrap_or("");
let other_path = other_resource.strip_prefix("path:").unwrap_or("");
paths_overlap(self_path, other_path)
}
(Some("issue"), Some("issue")) => self.resource == other_resource,
_ => false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LockPolicy {
Off,
#[default]
Warn,
Require,
}
impl LockPolicy {
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"off" => Some(LockPolicy::Off),
"warn" => Some(LockPolicy::Warn),
"require" => Some(LockPolicy::Require),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
LockPolicy::Off => "off",
LockPolicy::Warn => "warn",
LockPolicy::Require => "require",
}
}
}
#[derive(Debug, Clone)]
pub struct LockStatus {
pub lock: Lock,
pub owned_by_self: bool,
}
#[derive(Debug, Clone)]
pub enum LockCheckResult {
Clear,
Warning(Vec<Lock>),
Blocked(Vec<Lock>),
}
impl LockCheckResult {
pub fn should_proceed(&self) -> bool {
!matches!(self, LockCheckResult::Blocked(_))
}
pub fn conflicts(&self) -> &[Lock] {
match self {
LockCheckResult::Clear => &[],
LockCheckResult::Warning(locks) | LockCheckResult::Blocked(locks) => locks,
}
}
}
pub fn resource_hash(resource: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(resource.as_bytes());
let result = hasher.finalize();
hex::encode(&result[..8]) }
pub const DEFAULT_LOCK_TTL_MS: u64 = 5 * 60 * 1000;
fn current_time_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
fn paths_overlap(path1: &str, path2: &str) -> bool {
if path1 == path2 {
return true;
}
let p1 = path1.trim_end_matches('/');
let p2 = path2.trim_end_matches('/');
if p1 == p2 {
return true;
}
let p1_dir = if p1.ends_with('/') {
p1.to_string()
} else {
format!("{}/", p1)
};
let p2_dir = if p2.ends_with('/') {
p2.to_string()
} else {
format!("{}/", p2)
};
p2.starts_with(&p1_dir) || p1.starts_with(&p2_dir)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_lock_creation() {
let lock = Lock::new("actor123".to_string(), "repo:global".to_string(), 60000);
assert_eq!(lock.owner, "actor123");
assert_eq!(lock.resource, "repo:global");
assert!(!lock.is_expired());
assert!(lock.time_remaining_ms() > 0);
}
#[test]
fn test_lock_expiration() {
let lock = Lock::expired("actor123".to_string(), "repo:global".to_string());
assert!(lock.is_expired());
assert_eq!(lock.time_remaining_ms(), 0);
}
#[test]
fn test_lock_namespace() {
let lock = Lock::new("actor".to_string(), "repo:global".to_string(), 1000);
assert_eq!(lock.namespace(), Some("repo"));
let lock = Lock::new("actor".to_string(), "path:src/main.rs".to_string(), 1000);
assert_eq!(lock.namespace(), Some("path"));
let lock = Lock::new("actor".to_string(), "issue:abc123".to_string(), 1000);
assert_eq!(lock.namespace(), Some("issue"));
}
#[test]
fn test_repo_lock_conflicts() {
let repo_lock = Lock::new("actor".to_string(), "repo:global".to_string(), 60000);
assert!(repo_lock.conflicts_with("repo:global"));
assert!(repo_lock.conflicts_with("path:src/main.rs"));
assert!(repo_lock.conflicts_with("issue:abc123"));
}
#[test]
fn test_path_lock_conflicts() {
let path_lock = Lock::new("actor".to_string(), "path:src/".to_string(), 60000);
assert!(path_lock.conflicts_with("path:src/main.rs"));
assert!(path_lock.conflicts_with("path:src/lib.rs"));
assert!(path_lock.conflicts_with("path:src/"));
assert!(!path_lock.conflicts_with("path:tests/"));
assert!(!path_lock.conflicts_with("path:docs/"));
assert!(!path_lock.conflicts_with("issue:abc123"));
}
#[test]
fn test_issue_lock_conflicts() {
let issue_lock = Lock::new("actor".to_string(), "issue:abc123".to_string(), 60000);
assert!(issue_lock.conflicts_with("issue:abc123"));
assert!(!issue_lock.conflicts_with("issue:def456"));
assert!(!issue_lock.conflicts_with("path:src/"));
}
#[test]
fn test_expired_lock_no_conflict() {
let expired = Lock::expired("actor".to_string(), "repo:global".to_string());
assert!(!expired.conflicts_with("repo:global"));
assert!(!expired.conflicts_with("path:src/"));
}
#[test]
fn test_resource_hash() {
let hash1 = resource_hash("repo:global");
let hash2 = resource_hash("repo:global");
let hash3 = resource_hash("issue:abc123");
assert_eq!(hash1, hash2);
assert_ne!(hash1, hash3);
assert_eq!(hash1.len(), 16);
}
#[test]
fn test_lock_policy_parse() {
assert_eq!(LockPolicy::from_str("off"), Some(LockPolicy::Off));
assert_eq!(LockPolicy::from_str("warn"), Some(LockPolicy::Warn));
assert_eq!(LockPolicy::from_str("require"), Some(LockPolicy::Require));
assert_eq!(LockPolicy::from_str("WARN"), Some(LockPolicy::Warn));
assert_eq!(LockPolicy::from_str("invalid"), None);
}
#[test]
fn test_paths_overlap() {
assert!(paths_overlap("src/main.rs", "src/main.rs"));
assert!(paths_overlap("src/", "src/main.rs"));
assert!(paths_overlap("src", "src/main.rs"));
assert!(paths_overlap("src/main.rs", "src/"));
assert!(!paths_overlap("src/", "tests/"));
assert!(!paths_overlap("src/main.rs", "src/lib.rs"));
}
#[test]
fn test_lock_check_result() {
let clear = LockCheckResult::Clear;
assert!(clear.should_proceed());
assert!(clear.conflicts().is_empty());
let lock = Lock::new("other".to_string(), "repo:global".to_string(), 1000);
let warning = LockCheckResult::Warning(vec![lock.clone()]);
assert!(warning.should_proceed());
assert_eq!(warning.conflicts().len(), 1);
let blocked = LockCheckResult::Blocked(vec![lock]);
assert!(!blocked.should_proceed());
assert_eq!(blocked.conflicts().len(), 1);
}
}