1use anyhow::{Context, Result};
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::path::Path;
6
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
9pub struct Lock {
10 pub agent_id: String,
11 #[serde(default)]
12 pub branch: Option<String>,
13 pub claimed_at: DateTime<Utc>,
14 pub signed_by: String,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
19pub struct LockSettings {
20 #[serde(default = "default_stale_timeout")]
21 pub stale_lock_timeout_minutes: u64,
22}
23
24fn default_stale_timeout() -> u64 {
25 60
26}
27
28impl Default for LockSettings {
29 fn default() -> Self {
30 Self {
31 stale_lock_timeout_minutes: default_stale_timeout(),
32 }
33 }
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
38pub struct LocksFile {
39 pub version: u32,
40 pub locks: HashMap<String, Lock>,
42 #[serde(default)]
43 pub settings: LockSettings,
44}
45
46impl LocksFile {
47 pub fn load(path: &Path) -> Result<Self> {
49 let content = std::fs::read_to_string(path)
50 .with_context(|| format!("Failed to read {}", path.display()))?;
51 serde_json::from_str(&content)
52 .with_context(|| format!("Failed to parse {}", path.display()))
53 }
54
55 pub fn is_locked(&self, issue_id: i64) -> bool {
57 self.locks.contains_key(&issue_id.to_string())
58 }
59
60 pub fn get_lock(&self, issue_id: i64) -> Option<&Lock> {
62 self.locks.get(&issue_id.to_string())
63 }
64
65 pub fn is_locked_by(&self, issue_id: i64, agent_id: &str) -> bool {
67 self.locks
68 .get(&issue_id.to_string())
69 .map(|l| l.agent_id == agent_id)
70 .unwrap_or(false)
71 }
72
73 pub fn agent_locks(&self, agent_id: &str) -> Vec<i64> {
75 self.locks
76 .iter()
77 .filter(|(_, lock)| lock.agent_id == agent_id)
78 .filter_map(|(id, _)| id.parse().ok())
79 .collect()
80 }
81
82 pub fn empty() -> Self {
84 LocksFile {
85 version: 1,
86 locks: HashMap::new(),
87 settings: LockSettings::default(),
88 }
89 }
90
91 pub fn save(&self, path: &Path) -> Result<()> {
93 let json = serde_json::to_string_pretty(self)?;
94 std::fs::write(path, json).with_context(|| format!("Failed to write {}", path.display()))
95 }
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
100pub struct Heartbeat {
101 pub agent_id: String,
102 pub last_heartbeat: DateTime<Utc>,
103 pub active_issue_id: Option<i64>,
104 pub machine_id: String,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
109pub struct Keyring {
110 pub trusted_fingerprints: Vec<String>,
111}
112
113impl Keyring {
114 pub fn load(path: &Path) -> Result<Self> {
116 let content = std::fs::read_to_string(path)
117 .with_context(|| format!("Failed to read {}", path.display()))?;
118 serde_json::from_str(&content)
119 .with_context(|| format!("Failed to parse {}", path.display()))
120 }
121
122 pub fn is_trusted(&self, fingerprint: &str) -> bool {
124 self.trusted_fingerprints.iter().any(|f| f == fingerprint)
125 }
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131 use tempfile::tempdir;
132
133 fn sample_lock() -> Lock {
134 Lock {
135 agent_id: "worker-1".to_string(),
136 branch: Some("feature/auth".to_string()),
137 claimed_at: Utc::now(),
138 signed_by: "ABCD1234".to_string(),
139 }
140 }
141
142 fn sample_locks_file() -> LocksFile {
143 let mut locks = HashMap::new();
144 locks.insert("5".to_string(), sample_lock());
145 locks.insert(
146 "8".to_string(),
147 Lock {
148 agent_id: "worker-2".to_string(),
149 branch: Some("fix/api-timeout".to_string()),
150 claimed_at: Utc::now(),
151 signed_by: "EFGH5678".to_string(),
152 },
153 );
154 LocksFile {
155 version: 1,
156 locks,
157 settings: LockSettings::default(),
158 }
159 }
160
161 #[test]
162 fn test_empty_locks() {
163 let locks = LocksFile::empty();
164 assert_eq!(locks.version, 1);
165 assert!(locks.locks.is_empty());
166 assert_eq!(locks.settings.stale_lock_timeout_minutes, 60);
167 }
168
169 #[test]
170 fn test_is_locked() {
171 let locks = sample_locks_file();
172 assert!(locks.is_locked(5));
173 assert!(locks.is_locked(8));
174 assert!(!locks.is_locked(1));
175 }
176
177 #[test]
178 fn test_get_lock() {
179 let locks = sample_locks_file();
180 let lock = locks.get_lock(5).unwrap();
181 assert_eq!(lock.agent_id, "worker-1");
182 assert!(locks.get_lock(99).is_none());
183 }
184
185 #[test]
186 fn test_is_locked_by() {
187 let locks = sample_locks_file();
188 assert!(locks.is_locked_by(5, "worker-1"));
189 assert!(!locks.is_locked_by(5, "worker-2"));
190 assert!(locks.is_locked_by(8, "worker-2"));
191 assert!(!locks.is_locked_by(99, "worker-1"));
192 }
193
194 #[test]
195 fn test_agent_locks() {
196 let locks = sample_locks_file();
197 let mut w1 = locks.agent_locks("worker-1");
198 w1.sort();
199 assert_eq!(w1, vec![5]);
200 let mut w2 = locks.agent_locks("worker-2");
201 w2.sort();
202 assert_eq!(w2, vec![8]);
203 assert!(locks.agent_locks("nobody").is_empty());
204 }
205
206 #[test]
207 fn test_load_and_save_roundtrip() {
208 let dir = tempdir().unwrap();
209 let path = dir.path().join("locks.json");
210 let locks = sample_locks_file();
211 let json = serde_json::to_string_pretty(&locks).unwrap();
212 std::fs::write(&path, json).unwrap();
213
214 let loaded = LocksFile::load(&path).unwrap();
215 assert_eq!(loaded.version, locks.version);
216 assert_eq!(loaded.locks.len(), locks.locks.len());
217 assert_eq!(
218 loaded.settings.stale_lock_timeout_minutes,
219 locks.settings.stale_lock_timeout_minutes
220 );
221 }
222
223 #[test]
224 fn test_default_settings() {
225 let settings = LockSettings::default();
226 assert_eq!(settings.stale_lock_timeout_minutes, 60);
227 }
228
229 #[test]
230 fn test_keyring_is_trusted() {
231 let keyring = Keyring {
232 trusted_fingerprints: vec!["AAAA1111".to_string(), "BBBB2222".to_string()],
233 };
234 assert!(keyring.is_trusted("AAAA1111"));
235 assert!(keyring.is_trusted("BBBB2222"));
236 assert!(!keyring.is_trusted("CCCC3333"));
237 }
238
239 #[test]
240 fn test_heartbeat_json_roundtrip() {
241 let hb = Heartbeat {
242 agent_id: "worker-1".to_string(),
243 last_heartbeat: Utc::now(),
244 active_issue_id: Some(42),
245 machine_id: "my-host".to_string(),
246 };
247 let json = serde_json::to_string(&hb).unwrap();
248 let parsed: Heartbeat = serde_json::from_str(&json).unwrap();
249 assert_eq!(parsed.agent_id, hb.agent_id);
250 assert_eq!(parsed.active_issue_id, Some(42));
251 }
252}