tldr_cli/commands/bugbot/l2/
daemon_client.rs1use std::path::{Path, PathBuf};
32
33use tldr_core::ssa::SsaFunction;
34use tldr_core::{CfgInfo, DfgInfo, ProjectCallGraph};
35
36use super::types::FunctionId;
37
38pub trait DaemonClient: Send + Sync {
48 fn is_available(&self) -> bool;
54
55 fn query_call_graph(&self) -> Option<ProjectCallGraph>;
60
61 fn query_cfg(&self, function_id: &FunctionId) -> Option<CfgInfo>;
66
67 fn query_dfg(&self, function_id: &FunctionId) -> Option<DfgInfo>;
72
73 fn query_ssa(&self, function_id: &FunctionId) -> Option<SsaFunction>;
78
79 fn notify_changed_files(&self, changed_files: &[PathBuf]);
85}
86
87pub struct NoDaemon;
92
93impl DaemonClient for NoDaemon {
94 fn is_available(&self) -> bool {
95 false
96 }
97
98 fn query_call_graph(&self) -> Option<ProjectCallGraph> {
99 None
100 }
101
102 fn query_cfg(&self, _function_id: &FunctionId) -> Option<CfgInfo> {
103 None
104 }
105
106 fn query_dfg(&self, _function_id: &FunctionId) -> Option<DfgInfo> {
107 None
108 }
109
110 fn query_ssa(&self, _function_id: &FunctionId) -> Option<SsaFunction> {
111 None
112 }
113
114 fn notify_changed_files(&self, _changed_files: &[PathBuf]) {
115 }
117}
118
119pub struct LocalDaemonClient {
129 project: PathBuf,
131 socket_path: PathBuf,
133 available: bool,
135}
136
137impl LocalDaemonClient {
138 pub fn new(project: &Path) -> Self {
144 let socket_path = Self::compute_socket_path(project);
145 let available = socket_path.exists();
146 Self {
147 project: project.to_path_buf(),
148 socket_path,
149 available,
150 }
151 }
152
153 fn compute_socket_path(project: &Path) -> PathBuf {
158 let canonical = dunce::canonicalize(project).unwrap_or_else(|_| project.to_path_buf());
159 let path_str = canonical.to_string_lossy();
160 let digest = md5::compute(path_str.as_bytes());
161 let hash = format!("{:x}", digest);
162 let hash_prefix = &hash[..8];
163
164 let socket_dir = std::env::var("TLDR_SOCKET_DIR")
165 .map(PathBuf::from)
166 .unwrap_or_else(|_| std::env::temp_dir());
167
168 socket_dir.join(format!("tldr-{}-v1.0.sock", hash_prefix))
169 }
170
171 pub fn project(&self) -> &Path {
173 &self.project
174 }
175
176 pub fn socket_path(&self) -> &Path {
178 &self.socket_path
179 }
180}
181
182impl DaemonClient for LocalDaemonClient {
183 fn is_available(&self) -> bool {
184 self.available
185 }
186
187 fn query_call_graph(&self) -> Option<ProjectCallGraph> {
188 if !self.available {
189 return None;
190 }
191 None
196 }
197
198 fn query_cfg(&self, _function_id: &FunctionId) -> Option<CfgInfo> {
199 if !self.available {
200 return None;
201 }
202 None
203 }
204
205 fn query_dfg(&self, _function_id: &FunctionId) -> Option<DfgInfo> {
206 if !self.available {
207 return None;
208 }
209 None
210 }
211
212 fn query_ssa(&self, _function_id: &FunctionId) -> Option<SsaFunction> {
213 if !self.available {
214 return None;
215 }
216 None
217 }
218
219 fn notify_changed_files(&self, _changed_files: &[PathBuf]) {
220 if self.available {
221 }
225 }
226}
227
228pub fn create_daemon_client(project: &Path) -> Box<dyn DaemonClient> {
234 let client = LocalDaemonClient::new(project);
235 if client.is_available() {
236 Box::new(client)
237 } else {
238 Box::new(NoDaemon)
239 }
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245 use std::path::PathBuf;
246
247 #[test]
253 fn test_no_daemon_is_not_available() {
254 let client = NoDaemon;
255 assert!(
256 !client.is_available(),
257 "NoDaemon should always report not available"
258 );
259 }
260
261 #[test]
263 fn test_no_daemon_query_call_graph_returns_none() {
264 let client = NoDaemon;
265 assert!(
266 client.query_call_graph().is_none(),
267 "NoDaemon should return None for call graph"
268 );
269 }
270
271 #[test]
273 fn test_no_daemon_query_cfg_returns_none() {
274 let client = NoDaemon;
275 let fid = FunctionId::new("test.py", "foo", 1);
276 assert!(
277 client.query_cfg(&fid).is_none(),
278 "NoDaemon should return None for CFG"
279 );
280 }
281
282 #[test]
284 fn test_no_daemon_query_dfg_returns_none() {
285 let client = NoDaemon;
286 let fid = FunctionId::new("test.py", "bar", 5);
287 assert!(
288 client.query_dfg(&fid).is_none(),
289 "NoDaemon should return None for DFG"
290 );
291 }
292
293 #[test]
295 fn test_no_daemon_query_ssa_returns_none() {
296 let client = NoDaemon;
297 let fid = FunctionId::new("test.py", "baz", 10);
298 assert!(
299 client.query_ssa(&fid).is_none(),
300 "NoDaemon should return None for SSA"
301 );
302 }
303
304 #[test]
306 fn test_no_daemon_notify_changed_files_is_noop() {
307 let client = NoDaemon;
308 client.notify_changed_files(&[PathBuf::from("src/lib.rs"), PathBuf::from("src/main.rs")]);
310 }
311
312 #[test]
314 fn test_daemon_client_no_daemon_fallback() {
315 let client = NoDaemon;
316 assert!(!client.is_available());
317 assert!(client.query_call_graph().is_none());
318
319 let fid = FunctionId::new("test.rs", "test_fn", 1);
320 assert!(client.query_cfg(&fid).is_none());
321 assert!(client.query_dfg(&fid).is_none());
322 assert!(client.query_ssa(&fid).is_none());
323
324 client.notify_changed_files(&[PathBuf::from("a.rs")]);
326 }
327
328 #[test]
334 fn test_daemon_client_trait_object_safe() {
335 let client: Box<dyn DaemonClient> = Box::new(NoDaemon);
336 assert!(!client.is_available());
337 assert!(client.query_call_graph().is_none());
338
339 let fid = FunctionId::new("test.rs", "f", 1);
340 assert!(client.query_cfg(&fid).is_none());
341 assert!(client.query_dfg(&fid).is_none());
342 assert!(client.query_ssa(&fid).is_none());
343 client.notify_changed_files(&[]);
344 }
345
346 #[test]
348 fn test_daemon_client_send_sync() {
349 fn assert_send_sync<T: Send + Sync>() {}
350 assert_send_sync::<NoDaemon>();
351 assert_send_sync::<LocalDaemonClient>();
352 }
353
354 #[test]
360 fn test_local_daemon_client_no_socket() {
361 let client = LocalDaemonClient::new(Path::new("/tmp/nonexistent-bugbot-project-xyz"));
362 assert!(
363 !client.is_available(),
364 "No daemon should be running for a nonexistent project"
365 );
366 }
367
368 #[test]
370 fn test_local_daemon_client_unavailable_returns_none() {
371 let client = LocalDaemonClient::new(Path::new("/tmp/nonexistent-bugbot-project-xyz"));
372 let fid = FunctionId::new("test.rs", "func", 1);
373
374 assert!(client.query_call_graph().is_none());
375 assert!(client.query_cfg(&fid).is_none());
376 assert!(client.query_dfg(&fid).is_none());
377 assert!(client.query_ssa(&fid).is_none());
378 }
379
380 #[test]
382 fn test_local_daemon_client_notify_when_unavailable() {
383 let client = LocalDaemonClient::new(Path::new("/tmp/nonexistent-bugbot-project-xyz"));
384 client.notify_changed_files(&[PathBuf::from("src/lib.rs")]);
385 }
387
388 #[test]
390 fn test_local_daemon_client_socket_path_computation() {
391 let client = LocalDaemonClient::new(Path::new("/tmp/test-project-for-socket-path"));
392 let socket = client.socket_path();
393 let socket_name = socket.file_name().unwrap().to_string_lossy();
394
395 assert!(
397 socket_name.starts_with("tldr-"),
398 "Socket name should start with 'tldr-', got: {}",
399 socket_name
400 );
401 assert!(
402 socket_name.ends_with("-v1.0.sock"),
403 "Socket name should end with '-v1.0.sock', got: {}",
404 socket_name
405 );
406 }
407
408 #[test]
410 fn test_create_daemon_client_no_daemon() {
411 let client = create_daemon_client(Path::new("/tmp/nonexistent-bugbot-factory-test"));
412 assert!(
413 !client.is_available(),
414 "Factory should return NoDaemon for nonexistent project"
415 );
416 assert!(client.query_call_graph().is_none());
417 }
418
419 struct MockDaemon {
426 available: bool,
427 call_graph: Option<ProjectCallGraph>,
428 }
429
430 impl MockDaemon {
431 fn available_with_call_graph() -> Self {
432 Self {
433 available: true,
434 call_graph: Some(ProjectCallGraph::default()),
435 }
436 }
437
438 fn unavailable() -> Self {
439 Self {
440 available: false,
441 call_graph: None,
442 }
443 }
444 }
445
446 impl DaemonClient for MockDaemon {
447 fn is_available(&self) -> bool {
448 self.available
449 }
450
451 fn query_call_graph(&self) -> Option<ProjectCallGraph> {
452 self.call_graph.clone()
453 }
454
455 fn query_cfg(&self, _function_id: &FunctionId) -> Option<CfgInfo> {
456 None
457 }
458
459 fn query_dfg(&self, _function_id: &FunctionId) -> Option<DfgInfo> {
460 None
461 }
462
463 fn query_ssa(&self, _function_id: &FunctionId) -> Option<SsaFunction> {
464 None
465 }
466
467 fn notify_changed_files(&self, _changed_files: &[PathBuf]) {}
468 }
469
470 #[test]
472 fn test_mock_daemon_available_returns_call_graph() {
473 let mock = MockDaemon::available_with_call_graph();
474 assert!(mock.is_available());
475 assert!(
476 mock.query_call_graph().is_some(),
477 "Available mock daemon should return cached call graph"
478 );
479 }
480
481 #[test]
483 fn test_mock_daemon_unavailable_returns_none() {
484 let mock = MockDaemon::unavailable();
485 assert!(!mock.is_available());
486 assert!(mock.query_call_graph().is_none());
487 }
488
489 #[test]
491 fn test_mock_daemon_as_trait_object() {
492 let client: Box<dyn DaemonClient> = Box::new(MockDaemon::available_with_call_graph());
493 assert!(client.is_available());
494 assert!(client.query_call_graph().is_some());
495
496 let client2: Box<dyn DaemonClient> = Box::new(MockDaemon::unavailable());
497 assert!(!client2.is_available());
498 assert!(client2.query_call_graph().is_none());
499 }
500
501 struct TrackingDaemon {
507 notified: std::sync::Mutex<Vec<Vec<PathBuf>>>,
508 }
509
510 impl TrackingDaemon {
511 fn new() -> Self {
512 Self {
513 notified: std::sync::Mutex::new(Vec::new()),
514 }
515 }
516
517 fn notifications(&self) -> Vec<Vec<PathBuf>> {
518 self.notified.lock().unwrap().clone()
519 }
520 }
521
522 impl DaemonClient for TrackingDaemon {
523 fn is_available(&self) -> bool {
524 true
525 }
526
527 fn query_call_graph(&self) -> Option<ProjectCallGraph> {
528 None
529 }
530
531 fn query_cfg(&self, _function_id: &FunctionId) -> Option<CfgInfo> {
532 None
533 }
534
535 fn query_dfg(&self, _function_id: &FunctionId) -> Option<DfgInfo> {
536 None
537 }
538
539 fn query_ssa(&self, _function_id: &FunctionId) -> Option<SsaFunction> {
540 None
541 }
542
543 fn notify_changed_files(&self, changed_files: &[PathBuf]) {
544 self.notified.lock().unwrap().push(changed_files.to_vec());
545 }
546 }
547
548 #[test]
551 fn test_daemon_cache_invalidation_on_changed_files() {
552 let daemon = TrackingDaemon::new();
553 assert!(daemon.is_available());
554
555 let files = vec![PathBuf::from("src/lib.rs"), PathBuf::from("src/main.rs")];
556 daemon.notify_changed_files(&files);
557
558 let notifications = daemon.notifications();
559 assert_eq!(
560 notifications.len(),
561 1,
562 "Should have recorded one notification"
563 );
564 assert_eq!(
565 notifications[0], files,
566 "Notification should contain the changed files"
567 );
568 }
569
570 #[test]
572 fn test_daemon_multiple_notifications_accumulate() {
573 let daemon = TrackingDaemon::new();
574
575 daemon.notify_changed_files(&[PathBuf::from("a.rs")]);
576 daemon.notify_changed_files(&[PathBuf::from("b.rs"), PathBuf::from("c.rs")]);
577
578 let notifications = daemon.notifications();
579 assert_eq!(
580 notifications.len(),
581 2,
582 "Should have recorded two notifications"
583 );
584 assert_eq!(notifications[0].len(), 1);
585 assert_eq!(notifications[1].len(), 2);
586 }
587}