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(&[
310 PathBuf::from("src/lib.rs"),
311 PathBuf::from("src/main.rs"),
312 ]);
313 }
314
315 #[test]
317 fn test_daemon_client_no_daemon_fallback() {
318 let client = NoDaemon;
319 assert!(!client.is_available());
320 assert!(client.query_call_graph().is_none());
321
322 let fid = FunctionId::new("test.rs", "test_fn", 1);
323 assert!(client.query_cfg(&fid).is_none());
324 assert!(client.query_dfg(&fid).is_none());
325 assert!(client.query_ssa(&fid).is_none());
326
327 client.notify_changed_files(&[PathBuf::from("a.rs")]);
329 }
330
331 #[test]
337 fn test_daemon_client_trait_object_safe() {
338 let client: Box<dyn DaemonClient> = Box::new(NoDaemon);
339 assert!(!client.is_available());
340 assert!(client.query_call_graph().is_none());
341
342 let fid = FunctionId::new("test.rs", "f", 1);
343 assert!(client.query_cfg(&fid).is_none());
344 assert!(client.query_dfg(&fid).is_none());
345 assert!(client.query_ssa(&fid).is_none());
346 client.notify_changed_files(&[]);
347 }
348
349 #[test]
351 fn test_daemon_client_send_sync() {
352 fn assert_send_sync<T: Send + Sync>() {}
353 assert_send_sync::<NoDaemon>();
354 assert_send_sync::<LocalDaemonClient>();
355 }
356
357 #[test]
363 fn test_local_daemon_client_no_socket() {
364 let client = LocalDaemonClient::new(Path::new("/tmp/nonexistent-bugbot-project-xyz"));
365 assert!(
366 !client.is_available(),
367 "No daemon should be running for a nonexistent project"
368 );
369 }
370
371 #[test]
373 fn test_local_daemon_client_unavailable_returns_none() {
374 let client = LocalDaemonClient::new(Path::new("/tmp/nonexistent-bugbot-project-xyz"));
375 let fid = FunctionId::new("test.rs", "func", 1);
376
377 assert!(client.query_call_graph().is_none());
378 assert!(client.query_cfg(&fid).is_none());
379 assert!(client.query_dfg(&fid).is_none());
380 assert!(client.query_ssa(&fid).is_none());
381 }
382
383 #[test]
385 fn test_local_daemon_client_notify_when_unavailable() {
386 let client = LocalDaemonClient::new(Path::new("/tmp/nonexistent-bugbot-project-xyz"));
387 client.notify_changed_files(&[PathBuf::from("src/lib.rs")]);
388 }
390
391 #[test]
393 fn test_local_daemon_client_socket_path_computation() {
394 let client = LocalDaemonClient::new(Path::new("/tmp/test-project-for-socket-path"));
395 let socket = client.socket_path();
396 let socket_name = socket.file_name().unwrap().to_string_lossy();
397
398 assert!(
400 socket_name.starts_with("tldr-"),
401 "Socket name should start with 'tldr-', got: {}",
402 socket_name
403 );
404 assert!(
405 socket_name.ends_with("-v1.0.sock"),
406 "Socket name should end with '-v1.0.sock', got: {}",
407 socket_name
408 );
409 }
410
411 #[test]
413 fn test_create_daemon_client_no_daemon() {
414 let client = create_daemon_client(Path::new("/tmp/nonexistent-bugbot-factory-test"));
415 assert!(
416 !client.is_available(),
417 "Factory should return NoDaemon for nonexistent project"
418 );
419 assert!(client.query_call_graph().is_none());
420 }
421
422 struct MockDaemon {
429 available: bool,
430 call_graph: Option<ProjectCallGraph>,
431 }
432
433 impl MockDaemon {
434 fn available_with_call_graph() -> Self {
435 Self {
436 available: true,
437 call_graph: Some(ProjectCallGraph::default()),
438 }
439 }
440
441 fn unavailable() -> Self {
442 Self {
443 available: false,
444 call_graph: None,
445 }
446 }
447 }
448
449 impl DaemonClient for MockDaemon {
450 fn is_available(&self) -> bool {
451 self.available
452 }
453
454 fn query_call_graph(&self) -> Option<ProjectCallGraph> {
455 self.call_graph.clone()
456 }
457
458 fn query_cfg(&self, _function_id: &FunctionId) -> Option<CfgInfo> {
459 None
460 }
461
462 fn query_dfg(&self, _function_id: &FunctionId) -> Option<DfgInfo> {
463 None
464 }
465
466 fn query_ssa(&self, _function_id: &FunctionId) -> Option<SsaFunction> {
467 None
468 }
469
470 fn notify_changed_files(&self, _changed_files: &[PathBuf]) {}
471 }
472
473 #[test]
475 fn test_mock_daemon_available_returns_call_graph() {
476 let mock = MockDaemon::available_with_call_graph();
477 assert!(mock.is_available());
478 assert!(
479 mock.query_call_graph().is_some(),
480 "Available mock daemon should return cached call graph"
481 );
482 }
483
484 #[test]
486 fn test_mock_daemon_unavailable_returns_none() {
487 let mock = MockDaemon::unavailable();
488 assert!(!mock.is_available());
489 assert!(mock.query_call_graph().is_none());
490 }
491
492 #[test]
494 fn test_mock_daemon_as_trait_object() {
495 let client: Box<dyn DaemonClient> = Box::new(MockDaemon::available_with_call_graph());
496 assert!(client.is_available());
497 assert!(client.query_call_graph().is_some());
498
499 let client2: Box<dyn DaemonClient> = Box::new(MockDaemon::unavailable());
500 assert!(!client2.is_available());
501 assert!(client2.query_call_graph().is_none());
502 }
503
504 struct TrackingDaemon {
510 notified: std::sync::Mutex<Vec<Vec<PathBuf>>>,
511 }
512
513 impl TrackingDaemon {
514 fn new() -> Self {
515 Self {
516 notified: std::sync::Mutex::new(Vec::new()),
517 }
518 }
519
520 fn notifications(&self) -> Vec<Vec<PathBuf>> {
521 self.notified.lock().unwrap().clone()
522 }
523 }
524
525 impl DaemonClient for TrackingDaemon {
526 fn is_available(&self) -> bool {
527 true
528 }
529
530 fn query_call_graph(&self) -> Option<ProjectCallGraph> {
531 None
532 }
533
534 fn query_cfg(&self, _function_id: &FunctionId) -> Option<CfgInfo> {
535 None
536 }
537
538 fn query_dfg(&self, _function_id: &FunctionId) -> Option<DfgInfo> {
539 None
540 }
541
542 fn query_ssa(&self, _function_id: &FunctionId) -> Option<SsaFunction> {
543 None
544 }
545
546 fn notify_changed_files(&self, changed_files: &[PathBuf]) {
547 self.notified
548 .lock()
549 .unwrap()
550 .push(changed_files.to_vec());
551 }
552 }
553
554 #[test]
557 fn test_daemon_cache_invalidation_on_changed_files() {
558 let daemon = TrackingDaemon::new();
559 assert!(daemon.is_available());
560
561 let files = vec![
562 PathBuf::from("src/lib.rs"),
563 PathBuf::from("src/main.rs"),
564 ];
565 daemon.notify_changed_files(&files);
566
567 let notifications = daemon.notifications();
568 assert_eq!(notifications.len(), 1, "Should have recorded one notification");
569 assert_eq!(
570 notifications[0],
571 files,
572 "Notification should contain the changed files"
573 );
574 }
575
576 #[test]
578 fn test_daemon_multiple_notifications_accumulate() {
579 let daemon = TrackingDaemon::new();
580
581 daemon.notify_changed_files(&[PathBuf::from("a.rs")]);
582 daemon.notify_changed_files(&[PathBuf::from("b.rs"), PathBuf::from("c.rs")]);
583
584 let notifications = daemon.notifications();
585 assert_eq!(notifications.len(), 2, "Should have recorded two notifications");
586 assert_eq!(notifications[0].len(), 1);
587 assert_eq!(notifications[1].len(), 2);
588 }
589}