Skip to main content

tldr_cli/commands/bugbot/l2/
daemon_client.rs

1//! Daemon client for L2Context -- routes IR queries through the daemon's
2//! QueryCache when available, falling back to on-the-fly computation.
3//!
4//! # Architecture
5//!
6//! The daemon client trait abstracts communication with a running `tldr-daemon`
7//! process. When a daemon is available, IR artifacts (call graphs, CFGs, DFGs,
8//! SSA) are fetched from the daemon's persistent cache, avoiding redundant
9//! recomputation across `bugbot check` invocations within the same edit session.
10//!
11//! When no daemon is running, the `NoDaemon` implementation returns `None` for
12//! all queries, causing L2Context to fall back to on-the-fly IR construction
13//! via its existing DashMap/OnceLock caches.
14//!
15//! # Cache Invalidation
16//!
17//! The daemon registers file dependencies for each cached artifact. When
18//! `L2Context` is constructed with `changed_files`, it notifies the daemon
19//! of those files so the daemon can invalidate stale cache entries before
20//! any queries are made.
21//!
22//! # Tiered Execution
23//!
24//! - **Foreground tier** (<200ms, blocking): ScanEngine, DeltaEngine, GraphEngine
25//! - **Deferred tier** (daemon-queued, returns within 2s): FlowEngine
26//!
27//! When a daemon is available, deferred engines use cached results from prior
28//! daemon runs. When no daemon is running, deferred engines run synchronously
29//! after foreground engines complete.
30
31use std::path::{Path, PathBuf};
32
33use tldr_core::ssa::SsaFunction;
34use tldr_core::{CfgInfo, DfgInfo, ProjectCallGraph};
35
36use super::types::FunctionId;
37
38/// Trait for communicating with the tldr-daemon process.
39///
40/// Provides query methods for IR artifacts that the daemon may have cached
41/// from prior analysis runs. All methods return `Option<T>` -- `None` means
42/// the daemon does not have the artifact cached (or no daemon is running),
43/// and the caller should compute it locally.
44///
45/// Implementations must be `Send + Sync` so they can be stored in `L2Context`
46/// (which is shared across threads via `Arc` or moved to a background thread).
47pub trait DaemonClient: Send + Sync {
48    /// Check whether a daemon is currently running and reachable.
49    ///
50    /// Returns `true` if the daemon is available for queries, `false` otherwise.
51    /// This is a lightweight check (e.g., socket existence) that does not
52    /// perform a full handshake.
53    fn is_available(&self) -> bool;
54
55    /// Query the daemon for a cached project call graph.
56    ///
57    /// Returns `Some(graph)` if the daemon has a cached call graph for the
58    /// project, `None` if unavailable or the daemon is not running.
59    fn query_call_graph(&self) -> Option<ProjectCallGraph>;
60
61    /// Query the daemon for a cached CFG for a specific function.
62    ///
63    /// Returns `Some(cfg)` if the daemon has a cached CFG for the given
64    /// function, `None` if unavailable.
65    fn query_cfg(&self, function_id: &FunctionId) -> Option<CfgInfo>;
66
67    /// Query the daemon for a cached DFG for a specific function.
68    ///
69    /// Returns `Some(dfg)` if the daemon has a cached DFG for the given
70    /// function, `None` if unavailable.
71    fn query_dfg(&self, function_id: &FunctionId) -> Option<DfgInfo>;
72
73    /// Query the daemon for cached SSA for a specific function.
74    ///
75    /// Returns `Some(ssa)` if the daemon has cached SSA for the given
76    /// function, `None` if unavailable.
77    fn query_ssa(&self, function_id: &FunctionId) -> Option<SsaFunction>;
78
79    /// Notify the daemon that files have changed, triggering cache invalidation.
80    ///
81    /// The daemon will invalidate any cached artifacts whose file dependencies
82    /// overlap with the provided `changed_files`. This should be called before
83    /// any queries are made for the current analysis session.
84    fn notify_changed_files(&self, changed_files: &[PathBuf]);
85}
86
87/// Fallback implementation used when no daemon is running.
88///
89/// Returns `None` for all queries and no-ops for notifications. This is the
90/// default used by `L2Context` when daemon integration is not available.
91pub 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        // No daemon running, nothing to invalidate.
116    }
117}
118
119/// Daemon client that connects to a running local daemon via Unix socket.
120///
121/// Probes the daemon socket path computed from the project root. If the socket
122/// exists and the daemon responds to a ping, queries are routed through the
123/// daemon's HTTP API. Otherwise, behaves like `NoDaemon`.
124///
125/// This is a synchronous (blocking) client suitable for use from the L2 analysis
126/// thread. The daemon's handlers use `spawn_blocking` internally, so the client
127/// can safely make blocking HTTP calls.
128pub struct LocalDaemonClient {
129    /// Project root used to compute the daemon socket path.
130    project: PathBuf,
131    /// Cached socket path (computed once on construction).
132    socket_path: PathBuf,
133    /// Whether the daemon was reachable at construction time.
134    available: bool,
135}
136
137impl LocalDaemonClient {
138    /// Create a new `LocalDaemonClient` for the given project.
139    ///
140    /// Probes the daemon socket to determine availability. The probe is a
141    /// non-blocking check of socket file existence (actual connectivity is
142    /// verified lazily on first query).
143    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    /// Compute the daemon socket path for a project.
154    ///
155    /// Uses the same algorithm as `tldr-daemon`'s `server::compute_socket_path`:
156    /// MD5 hash of canonicalized project path, first 8 hex chars.
157    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    /// Get the project root path.
172    pub fn project(&self) -> &Path {
173        &self.project
174    }
175
176    /// Get the computed socket path.
177    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        // In the future, this will make an HTTP request to the daemon's
192        // /calls endpoint via the Unix socket. For now, the daemon server
193        // infrastructure exists but the client-side HTTP plumbing is not
194        // wired. Return None to fall back to local computation.
195        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            // In the future, this will POST to the daemon's invalidation endpoint.
222            // For now, the notification is a no-op since the daemon does not yet
223            // expose an invalidation API.
224        }
225    }
226}
227
228/// Create the appropriate daemon client for a project.
229///
230/// If a daemon socket exists for the project, returns a `LocalDaemonClient`.
231/// Otherwise, returns a `NoDaemon` fallback. This factory function is the
232/// primary entry point for daemon integration in the pipeline.
233pub 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    // =========================================================================
248    // NoDaemon fallback tests
249    // =========================================================================
250
251    /// NoDaemon must report unavailable.
252    #[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    /// NoDaemon must return None for call graph queries.
262    #[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    /// NoDaemon must return None for CFG queries.
272    #[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    /// NoDaemon must return None for DFG queries.
283    #[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    /// NoDaemon must return None for SSA queries.
294    #[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    /// NoDaemon notify_changed_files must not panic (no-op).
305    #[test]
306    fn test_no_daemon_notify_changed_files_is_noop() {
307        let client = NoDaemon;
308        // Should not panic even with non-empty list
309        client.notify_changed_files(&[
310            PathBuf::from("src/lib.rs"),
311            PathBuf::from("src/main.rs"),
312        ]);
313    }
314
315    /// NoDaemon must return None for ALL query types (comprehensive check).
316    #[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        // notify should not panic
328        client.notify_changed_files(&[PathBuf::from("a.rs")]);
329    }
330
331    // =========================================================================
332    // DaemonClient trait object safety
333    // =========================================================================
334
335    /// DaemonClient must be object-safe (usable as Box<dyn DaemonClient>).
336    #[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    /// DaemonClient must be Send + Sync (required for L2Context).
350    #[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    // =========================================================================
358    // LocalDaemonClient tests
359    // =========================================================================
360
361    /// LocalDaemonClient for a nonexistent project should not find a socket.
362    #[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    /// LocalDaemonClient should return None for all queries when unavailable.
372    #[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    /// LocalDaemonClient should not panic on notify when unavailable.
384    #[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        // No panic = success
389    }
390
391    /// LocalDaemonClient socket path should use MD5 hash of project path.
392    #[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        // Socket name should match pattern: tldr-{8hex}-v1.0.sock
399        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    /// create_daemon_client factory should return NoDaemon for nonexistent projects.
412    #[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    // =========================================================================
423    // Mock daemon for integration testing
424    // =========================================================================
425
426    /// A mock daemon client that returns pre-configured cached results.
427    /// Used in L2Context integration tests to verify daemon-first routing.
428    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    /// MockDaemon available should report available and return cached call graph.
474    #[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    /// MockDaemon unavailable should report unavailable and return None.
485    #[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    /// MockDaemon as trait object must work correctly.
493    #[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    // =========================================================================
505    // Cache invalidation tests
506    // =========================================================================
507
508    /// A mock daemon that tracks whether notify_changed_files was called.
509    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    /// Daemon cache invalidation: notify_changed_files should be callable and
555    /// record the changed files for verification.
556    #[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    /// Multiple notify calls should accumulate.
577    #[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}