1use dashmap::DashMap;
2use std::time::Instant;
3use uuid::Uuid;
4
5use dk_core::SymbolKind;
6
7#[derive(Debug, Clone)]
9pub struct SymbolClaim {
10 pub session_id: Uuid,
11 pub agent_name: String,
12 pub qualified_name: String,
13 pub kind: SymbolKind,
14 pub first_touched_at: Instant,
15}
16
17#[derive(Debug, Clone)]
20pub struct ConflictInfo {
21 pub qualified_name: String,
22 pub kind: SymbolKind,
23 pub conflicting_session: Uuid,
24 pub conflicting_agent: String,
25 pub first_touched_at: Instant,
26}
27
28pub struct SymbolClaimTracker {
38 claims: DashMap<(Uuid, String), Vec<SymbolClaim>>,
40}
41
42impl SymbolClaimTracker {
43 pub fn new() -> Self {
45 Self {
46 claims: DashMap::new(),
47 }
48 }
49
50 pub fn record_claim(&self, repo_id: Uuid, file_path: &str, claim: SymbolClaim) {
54 let key = (repo_id, file_path.to_string());
55 let mut entry = self.claims.entry(key).or_default();
56 let claims = entry.value_mut();
57
58 if let Some(existing) = claims.iter_mut().find(|c| {
60 c.session_id == claim.session_id && c.qualified_name == claim.qualified_name
61 }) {
62 existing.kind = claim.kind;
63 existing.agent_name = claim.agent_name;
64 } else {
66 claims.push(claim);
67 }
68 }
69
70 pub fn check_conflicts(
74 &self,
75 repo_id: Uuid,
76 file_path: &str,
77 session_id: Uuid,
78 qualified_names: &[String],
79 ) -> Vec<ConflictInfo> {
80 let key = (repo_id, file_path.to_string());
81 let Some(entry) = self.claims.get(&key) else {
82 return Vec::new();
83 };
84
85 let mut conflicts = Vec::new();
86 for name in qualified_names {
87 for claim in entry.value() {
88 if claim.qualified_name == *name && claim.session_id != session_id {
89 conflicts.push(ConflictInfo {
90 qualified_name: name.clone(),
91 kind: claim.kind.clone(),
92 conflicting_session: claim.session_id,
93 conflicting_agent: claim.agent_name.clone(),
94 first_touched_at: claim.first_touched_at,
95 });
96 break;
98 }
99 }
100 }
101 conflicts
102 }
103
104 pub fn get_all_conflicts_for_session(
109 &self,
110 repo_id: Uuid,
111 session_id: Uuid,
112 ) -> Vec<(String, ConflictInfo)> {
113 let mut results = Vec::new();
114 for entry in self.claims.iter() {
115 let (entry_repo_id, file_path) = entry.key();
116 if *entry_repo_id != repo_id {
117 continue;
118 }
119 let claims = entry.value();
120
121 let my_symbols: Vec<&SymbolClaim> = claims
123 .iter()
124 .filter(|c| c.session_id == session_id)
125 .collect();
126
127 for my_claim in &my_symbols {
128 for other_claim in claims {
130 if other_claim.session_id != session_id
131 && other_claim.qualified_name == my_claim.qualified_name
132 {
133 results.push((
134 file_path.clone(),
135 ConflictInfo {
136 qualified_name: my_claim.qualified_name.clone(),
137 kind: my_claim.kind.clone(),
138 conflicting_session: other_claim.session_id,
139 conflicting_agent: other_claim.agent_name.clone(),
140 first_touched_at: other_claim.first_touched_at,
141 },
142 ));
143 break;
145 }
146 }
147 }
148 }
149 results
150 }
151
152 pub fn clear_session(&self, session_id: Uuid) {
154 let mut empty_keys = Vec::new();
157 for mut entry in self.claims.iter_mut() {
158 entry.value_mut().retain(|c| c.session_id != session_id);
159 if entry.value().is_empty() {
160 empty_keys.push(entry.key().clone());
161 }
162 }
163 for key in empty_keys {
164 self.claims.remove_if(&key, |_, v| v.is_empty());
166 }
167 }
168}
169
170impl Default for SymbolClaimTracker {
171 fn default() -> Self {
172 Self::new()
173 }
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 fn make_claim(session_id: Uuid, agent: &str, name: &str, kind: SymbolKind) -> SymbolClaim {
181 SymbolClaim {
182 session_id,
183 agent_name: agent.to_string(),
184 qualified_name: name.to_string(),
185 kind,
186 first_touched_at: Instant::now(),
187 }
188 }
189
190 #[test]
191 fn no_conflict_different_symbols_same_file() {
192 let tracker = SymbolClaimTracker::new();
193 let repo = Uuid::new_v4();
194 let session_a = Uuid::new_v4();
195 let session_b = Uuid::new_v4();
196
197 tracker.record_claim(
198 repo,
199 "src/lib.rs",
200 make_claim(session_a, "agent-1", "fn_a", SymbolKind::Function),
201 );
202
203 let conflicts = tracker.check_conflicts(
204 repo,
205 "src/lib.rs",
206 session_b,
207 &["fn_b".to_string()],
208 );
209 assert!(conflicts.is_empty(), "different symbols should not conflict");
210 }
211
212 #[test]
213 fn conflict_same_symbol() {
214 let tracker = SymbolClaimTracker::new();
215 let repo = Uuid::new_v4();
216 let session_a = Uuid::new_v4();
217 let session_b = Uuid::new_v4();
218
219 tracker.record_claim(
220 repo,
221 "src/lib.rs",
222 make_claim(session_a, "agent-1", "fn_a", SymbolKind::Function),
223 );
224
225 let conflicts = tracker.check_conflicts(
226 repo,
227 "src/lib.rs",
228 session_b,
229 &["fn_a".to_string()],
230 );
231 assert_eq!(conflicts.len(), 1);
232 assert_eq!(conflicts[0].qualified_name, "fn_a");
233 assert_eq!(conflicts[0].conflicting_session, session_a);
234 assert_eq!(conflicts[0].conflicting_agent, "agent-1");
235 }
236
237 #[test]
238 fn claims_cleared_on_session_destroy() {
239 let tracker = SymbolClaimTracker::new();
240 let repo = Uuid::new_v4();
241 let session_a = Uuid::new_v4();
242 let session_b = Uuid::new_v4();
243
244 tracker.record_claim(
245 repo,
246 "src/lib.rs",
247 make_claim(session_a, "agent-1", "fn_a", SymbolKind::Function),
248 );
249
250 tracker.clear_session(session_a);
251
252 let conflicts = tracker.check_conflicts(
253 repo,
254 "src/lib.rs",
255 session_b,
256 &["fn_a".to_string()],
257 );
258 assert!(conflicts.is_empty(), "cleared session should not cause conflicts");
259 }
260
261 #[test]
262 fn same_session_no_self_conflict() {
263 let tracker = SymbolClaimTracker::new();
264 let repo = Uuid::new_v4();
265 let session_a = Uuid::new_v4();
266
267 tracker.record_claim(
268 repo,
269 "src/lib.rs",
270 make_claim(session_a, "agent-1", "fn_a", SymbolKind::Function),
271 );
272 tracker.record_claim(
274 repo,
275 "src/lib.rs",
276 make_claim(session_a, "agent-1", "fn_a", SymbolKind::Function),
277 );
278
279 let conflicts = tracker.check_conflicts(
280 repo,
281 "src/lib.rs",
282 session_a,
283 &["fn_a".to_string()],
284 );
285 assert!(conflicts.is_empty(), "same session should not conflict with itself");
286 }
287
288 #[test]
289 fn multiple_conflicts() {
290 let tracker = SymbolClaimTracker::new();
291 let repo = Uuid::new_v4();
292 let session_a = Uuid::new_v4();
293 let session_b = Uuid::new_v4();
294
295 tracker.record_claim(
296 repo,
297 "src/lib.rs",
298 make_claim(session_a, "agent-1", "fn_a", SymbolKind::Function),
299 );
300 tracker.record_claim(
301 repo,
302 "src/lib.rs",
303 make_claim(session_a, "agent-1", "fn_b", SymbolKind::Function),
304 );
305
306 let conflicts = tracker.check_conflicts(
307 repo,
308 "src/lib.rs",
309 session_b,
310 &["fn_a".to_string(), "fn_b".to_string()],
311 );
312 assert_eq!(conflicts.len(), 2);
313
314 let names: Vec<&str> = conflicts.iter().map(|c| c.qualified_name.as_str()).collect();
315 assert!(names.contains(&"fn_a"));
316 assert!(names.contains(&"fn_b"));
317 }
318}