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(&[PathBuf::from("src/lib.rs"), PathBuf::from("src/main.rs")]);
310    }
311
312    /// NoDaemon must return None for ALL query types (comprehensive check).
313    #[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        // notify should not panic
325        client.notify_changed_files(&[PathBuf::from("a.rs")]);
326    }
327
328    // =========================================================================
329    // DaemonClient trait object safety
330    // =========================================================================
331
332    /// DaemonClient must be object-safe (usable as Box<dyn DaemonClient>).
333    #[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    /// DaemonClient must be Send + Sync (required for L2Context).
347    #[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    // =========================================================================
355    // LocalDaemonClient tests
356    // =========================================================================
357
358    /// LocalDaemonClient for a nonexistent project should not find a socket.
359    #[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    /// LocalDaemonClient should return None for all queries when unavailable.
369    #[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    /// LocalDaemonClient should not panic on notify when unavailable.
381    #[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        // No panic = success
386    }
387
388    /// LocalDaemonClient socket path should use MD5 hash of project path.
389    #[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        // Socket name should match pattern: tldr-{8hex}-v1.0.sock
396        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    /// create_daemon_client factory should return NoDaemon for nonexistent projects.
409    #[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    // =========================================================================
420    // Mock daemon for integration testing
421    // =========================================================================
422
423    /// A mock daemon client that returns pre-configured cached results.
424    /// Used in L2Context integration tests to verify daemon-first routing.
425    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    /// MockDaemon available should report available and return cached call graph.
471    #[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    /// MockDaemon unavailable should report unavailable and return None.
482    #[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    /// MockDaemon as trait object must work correctly.
490    #[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    // =========================================================================
502    // Cache invalidation tests
503    // =========================================================================
504
505    /// A mock daemon that tracks whether notify_changed_files was called.
506    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    /// Daemon cache invalidation: notify_changed_files should be callable and
549    /// record the changed files for verification.
550    #[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    /// Multiple notify calls should accumulate.
571    #[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}