Skip to main content

tldr_cli/commands/
daemon_router.rs

1//! Daemon Router - Auto-route CLI commands through daemon cache
2//!
3//! This module provides transparent routing of CLI commands through the daemon
4//! when it's running, falling back to direct compute when the daemon is unavailable.
5//!
6//! # Design
7//!
8//! Each command can call `try_daemon_route()` before doing direct compute.
9//! If the daemon is running and responds successfully, the cached result is returned.
10//! Otherwise, the command falls back to computing the result directly.
11//!
12//! # Performance
13//!
14//! The daemon maintains Salsa-style query memoization, providing ~35x speedup
15//! on cache hits compared to direct computation.
16
17use std::path::{Path, PathBuf};
18
19use serde::de::DeserializeOwned;
20
21use crate::commands::daemon::error::DaemonError;
22use crate::commands::daemon::ipc::send_raw_command;
23
24// =============================================================================
25// Core Router Function
26// =============================================================================
27
28/// Try to route a command through the daemon.
29///
30/// Returns `Some(result)` if the daemon is running and responds successfully.
31/// Returns `None` if the daemon is not running or an error occurs (caller should fallback).
32///
33/// # Arguments
34///
35/// * `project` - Project root directory (used to find the correct daemon)
36/// * `endpoint` - Command name (e.g., "calls", "impact", "structure")
37/// * `params` - Additional JSON parameters for the command
38///
39/// # Example
40///
41/// ```ignore
42/// if let Some(result) = try_daemon_route::<CallGraphOutput>(
43///     &self.path,
44///     "calls",
45///     json!({"language": language.to_string()})
46/// ) {
47///     return writer.write(&result);
48/// }
49/// // Fallback to direct compute...
50/// ```
51pub fn try_daemon_route<T: DeserializeOwned>(
52    project: &Path,
53    endpoint: &str,
54    params: serde_json::Value,
55) -> Option<T> {
56    // Use blocking runtime for sync commands
57    let runtime = match tokio::runtime::Runtime::new() {
58        Ok(rt) => rt,
59        Err(_) => return None,
60    };
61
62    runtime.block_on(try_daemon_route_async(project, endpoint, params))
63}
64
65/// Async version of try_daemon_route.
66///
67/// Used internally and can be called directly from async contexts.
68pub async fn try_daemon_route_async<T: DeserializeOwned>(
69    project: &Path,
70    endpoint: &str,
71    params: serde_json::Value,
72) -> Option<T> {
73    // Resolve project path to absolute
74    let project = project.canonicalize().unwrap_or_else(|_| {
75        std::env::current_dir()
76            .unwrap_or_else(|_| PathBuf::from("."))
77            .join(project)
78    });
79
80    // Build command JSON
81    let mut cmd_obj = serde_json::json!({
82        "cmd": endpoint.to_lowercase()
83    });
84
85    // Merge additional parameters
86    if let serde_json::Value::Object(params_obj) = params {
87        if let serde_json::Value::Object(ref mut cmd_map) = cmd_obj {
88            for (key, value) in params_obj {
89                cmd_map.insert(key, value);
90            }
91        }
92    }
93
94    let command_json = match serde_json::to_string(&cmd_obj) {
95        Ok(json) => json,
96        Err(_) => return None,
97    };
98
99    // Send to daemon
100    let response = match send_raw_command(&project, &command_json).await {
101        Ok(resp) => resp,
102        Err(DaemonError::NotRunning) => return None,
103        Err(DaemonError::ConnectionRefused) => return None,
104        Err(_) => return None,
105    };
106
107    // Parse response - check for error response first
108    let response_value: serde_json::Value = match serde_json::from_str(&response) {
109        Ok(v) => v,
110        Err(_) => return None,
111    };
112
113    // Check if response is an error
114    if let Some(status) = response_value.get("status") {
115        if status == "error" {
116            return None;
117        }
118    }
119
120    // If the response has a "result" field, extract it (daemon wraps results)
121    let result_value = if response_value.get("result").is_some() {
122        response_value
123            .get("result")
124            .cloned()
125            .unwrap_or(response_value)
126    } else {
127        response_value
128    };
129
130    // Deserialize to target type
131    serde_json::from_value(result_value).ok()
132}
133
134/// Check if the daemon is running for a project.
135///
136/// This is a lightweight check that doesn't send a command.
137pub fn is_daemon_running(project: &Path) -> bool {
138    let runtime = match tokio::runtime::Runtime::new() {
139        Ok(rt) => rt,
140        Err(_) => return false,
141    };
142
143    runtime.block_on(is_daemon_running_async(project))
144}
145
146/// Async version of is_daemon_running.
147pub async fn is_daemon_running_async(project: &Path) -> bool {
148    use crate::commands::daemon::ipc::check_socket_alive;
149    check_socket_alive(project).await
150}
151
152// =============================================================================
153// Convenience Builders
154// =============================================================================
155
156/// Build JSON params with optional path.
157pub fn params_with_path(path: Option<&Path>) -> serde_json::Value {
158    let mut obj = serde_json::Map::new();
159    if let Some(p) = path {
160        obj.insert("path".to_string(), serde_json::json!(p));
161    }
162    serde_json::Value::Object(obj)
163}
164
165/// Build JSON params with file path.
166pub fn params_with_file(file: &Path) -> serde_json::Value {
167    serde_json::json!({
168        "file": file
169    })
170}
171
172/// Build JSON params with file and function.
173pub fn params_with_file_function(file: &Path, function: &str) -> serde_json::Value {
174    serde_json::json!({
175        "file": file,
176        "function": function
177    })
178}
179
180/// Build JSON params with file, function, and line.
181pub fn params_with_file_function_line(file: &Path, function: &str, line: u32) -> serde_json::Value {
182    serde_json::json!({
183        "file": file,
184        "function": function,
185        "line": line
186    })
187}
188
189/// Build JSON params with function name and optional depth.
190pub fn params_with_func_depth(func: &str, depth: Option<usize>) -> serde_json::Value {
191    let mut obj = serde_json::Map::new();
192    obj.insert("func".to_string(), serde_json::json!(func));
193    if let Some(d) = depth {
194        obj.insert("depth".to_string(), serde_json::json!(d));
195    }
196    serde_json::Value::Object(obj)
197}
198
199/// Build JSON params with module and optional path.
200pub fn params_with_module(module: &str, path: Option<&Path>) -> serde_json::Value {
201    let mut obj = serde_json::Map::new();
202    obj.insert("module".to_string(), serde_json::json!(module));
203    if let Some(p) = path {
204        obj.insert("path".to_string(), serde_json::json!(p));
205    }
206    serde_json::Value::Object(obj)
207}
208
209/// Build JSON params with pattern and max_results.
210pub fn params_with_pattern(pattern: &str, max_results: Option<usize>) -> serde_json::Value {
211    let mut obj = serde_json::Map::new();
212    obj.insert("pattern".to_string(), serde_json::json!(pattern));
213    if let Some(m) = max_results {
214        obj.insert("max_results".to_string(), serde_json::json!(m));
215    }
216    serde_json::Value::Object(obj)
217}
218
219/// Build JSON params with entry point and depth.
220pub fn params_with_entry_depth(entry: &str, depth: Option<usize>) -> serde_json::Value {
221    let mut obj = serde_json::Map::new();
222    obj.insert("entry".to_string(), serde_json::json!(entry));
223    if let Some(d) = depth {
224        obj.insert("depth".to_string(), serde_json::json!(d));
225    }
226    serde_json::Value::Object(obj)
227}
228
229/// Build JSON params with path and lang.
230pub fn params_with_path_lang(path: &Path, lang: Option<&str>) -> serde_json::Value {
231    let mut obj = serde_json::Map::new();
232    obj.insert("path".to_string(), serde_json::json!(path));
233    if let Some(l) = lang {
234        obj.insert("lang".to_string(), serde_json::json!(l));
235    }
236    serde_json::Value::Object(obj)
237}
238
239/// Build JSON params for dead code analysis.
240pub fn params_for_dead(path: Option<&Path>, entry: Option<&[String]>) -> serde_json::Value {
241    let mut obj = serde_json::Map::new();
242    if let Some(p) = path {
243        obj.insert("path".to_string(), serde_json::json!(p));
244    }
245    if let Some(e) = entry {
246        obj.insert("entry".to_string(), serde_json::json!(e));
247    }
248    serde_json::Value::Object(obj)
249}
250
251// =============================================================================
252// Tests
253// =============================================================================
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use tempfile::TempDir;
259
260    #[test]
261    fn test_params_with_path() {
262        let params = params_with_path(Some(Path::new("/test/path")));
263        assert_eq!(params.get("path").unwrap(), "/test/path");
264    }
265
266    #[test]
267    fn test_params_with_path_none() {
268        let params = params_with_path(None);
269        assert!(params.get("path").is_none());
270    }
271
272    #[test]
273    fn test_params_with_file_function() {
274        let params = params_with_file_function(Path::new("/test/file.py"), "my_func");
275        assert_eq!(params.get("file").unwrap(), "/test/file.py");
276        assert_eq!(params.get("function").unwrap(), "my_func");
277    }
278
279    #[test]
280    fn test_params_with_file_function_line() {
281        let params = params_with_file_function_line(Path::new("/test/file.py"), "my_func", 42);
282        assert_eq!(params.get("file").unwrap(), "/test/file.py");
283        assert_eq!(params.get("function").unwrap(), "my_func");
284        assert_eq!(params.get("line").unwrap(), 42);
285    }
286
287    #[test]
288    fn test_params_with_func_depth() {
289        let params = params_with_func_depth("process_data", Some(5));
290        assert_eq!(params.get("func").unwrap(), "process_data");
291        assert_eq!(params.get("depth").unwrap(), 5);
292    }
293
294    #[test]
295    fn test_params_with_pattern() {
296        let params = params_with_pattern("fn main", Some(100));
297        assert_eq!(params.get("pattern").unwrap(), "fn main");
298        assert_eq!(params.get("max_results").unwrap(), 100);
299    }
300
301    #[test]
302    fn test_is_daemon_running_no_daemon() {
303        let temp = TempDir::new().unwrap();
304        assert!(!is_daemon_running(temp.path()));
305    }
306
307    #[test]
308    fn test_try_daemon_route_no_daemon() {
309        let temp = TempDir::new().unwrap();
310        let result: Option<serde_json::Value> =
311            try_daemon_route(temp.path(), "ping", serde_json::json!({}));
312        assert!(result.is_none());
313    }
314}