Skip to main content

tldr_cli/commands/daemon/
query.rs

1//! Daemon query command implementation
2//!
3//! CLI command: `tldr daemon query CMD [--project PATH] [--json PARAMS]`
4//!
5//! This module provides raw query passthrough to the daemon for:
6//! - Low-level debugging
7//! - Custom commands
8//! - Direct access to daemon functionality
9//!
10//! # Security Mitigations
11//!
12//! - TIGER-P3-03: Message size limits enforced by IPC layer
13//! - TIGER-P3-05: Rate limiting handled in daemon (client just sends)
14
15use std::path::PathBuf;
16
17use clap::Args;
18use serde::Serialize;
19
20use crate::output::OutputFormat;
21
22use super::error::{DaemonError, DaemonResult};
23use super::ipc::send_raw_command;
24
25// =============================================================================
26// CLI Arguments
27// =============================================================================
28
29/// Arguments for the `daemon query` command.
30#[derive(Debug, Clone, Args)]
31pub struct DaemonQueryArgs {
32    /// Command name to send (e.g., ping, status, search, structure)
33    pub cmd: String,
34
35    /// Project root directory (default: current directory)
36    #[arg(long, short = 'p', default_value = ".")]
37    pub project: PathBuf,
38
39    /// Additional JSON parameters for the command
40    #[arg(long, short = 'j')]
41    pub json: Option<String>,
42}
43
44// =============================================================================
45// Output Types
46// =============================================================================
47
48/// Output structure for query errors.
49#[derive(Debug, Clone, Serialize)]
50pub struct DaemonQueryErrorOutput {
51    /// Status (always "error")
52    pub status: String,
53    /// Error message
54    pub error: String,
55}
56
57// =============================================================================
58// Command Implementation
59// =============================================================================
60
61impl DaemonQueryArgs {
62    /// Run the daemon query command.
63    pub fn run(&self, format: OutputFormat, quiet: bool) -> anyhow::Result<()> {
64        // Create a new tokio runtime for the async operations
65        let runtime = tokio::runtime::Runtime::new()?;
66        runtime.block_on(self.run_async(format, quiet))
67    }
68
69    /// Async implementation of the daemon query command.
70    async fn run_async(&self, format: OutputFormat, quiet: bool) -> anyhow::Result<()> {
71        // Resolve project path to absolute
72        let project = self.project.canonicalize().unwrap_or_else(|_| {
73            std::env::current_dir()
74                .unwrap_or_else(|_| PathBuf::from("."))
75                .join(&self.project)
76        });
77
78        // Build the command JSON
79        let command_json = self.build_command_json()?;
80
81        // Send command to daemon
82        match send_raw_command(&project, &command_json).await {
83            Ok(response) => {
84                // Print raw response (pass-through)
85                if !quiet {
86                    match format {
87                        OutputFormat::Json => {
88                            // Pretty-print JSON
89                            if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&response)
90                            {
91                                println!("{}", serde_json::to_string_pretty(&parsed)?);
92                            } else {
93                                println!("{}", response);
94                            }
95                        }
96                        OutputFormat::Compact => {
97                            // Raw JSON
98                            println!("{}", response);
99                        }
100                        OutputFormat::Text | OutputFormat::Sarif | OutputFormat::Dot => {
101                            // Try to format as text if possible
102                            if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&response)
103                            {
104                                self.print_text_output(&parsed);
105                            } else {
106                                println!("{}", response);
107                            }
108                        }
109                    }
110                }
111                Ok(())
112            }
113            Err(DaemonError::NotRunning) | Err(DaemonError::ConnectionRefused) => {
114                let output = DaemonQueryErrorOutput {
115                    status: "error".to_string(),
116                    error: "Daemon not running".to_string(),
117                };
118
119                if !quiet {
120                    match format {
121                        OutputFormat::Json | OutputFormat::Compact => {
122                            println!("{}", serde_json::to_string_pretty(&output)?);
123                        }
124                        OutputFormat::Text | OutputFormat::Sarif | OutputFormat::Dot => {
125                            eprintln!("Error: Daemon not running");
126                        }
127                    }
128                }
129
130                Err(anyhow::anyhow!("Daemon not running"))
131            }
132            Err(DaemonError::InvalidMessage(msg)) => {
133                let output = DaemonQueryErrorOutput {
134                    status: "error".to_string(),
135                    error: format!("Invalid JSON parameters: {}", msg),
136                };
137
138                if !quiet {
139                    match format {
140                        OutputFormat::Json | OutputFormat::Compact => {
141                            println!("{}", serde_json::to_string_pretty(&output)?);
142                        }
143                        OutputFormat::Text | OutputFormat::Sarif | OutputFormat::Dot => {
144                            eprintln!("Error: Invalid JSON parameters: {}", msg);
145                        }
146                    }
147                }
148
149                Err(anyhow::anyhow!("Invalid JSON parameters: {}", msg))
150            }
151            Err(e) => {
152                let output = DaemonQueryErrorOutput {
153                    status: "error".to_string(),
154                    error: e.to_string(),
155                };
156
157                if !quiet {
158                    match format {
159                        OutputFormat::Json | OutputFormat::Compact => {
160                            println!("{}", serde_json::to_string_pretty(&output)?);
161                        }
162                        OutputFormat::Text | OutputFormat::Sarif | OutputFormat::Dot => {
163                            eprintln!("Error: {}", e);
164                        }
165                    }
166                }
167
168                Err(anyhow::anyhow!("Query failed: {}", e))
169            }
170        }
171    }
172
173    /// Build the command JSON from the cmd string and optional JSON parameters.
174    fn build_command_json(&self) -> anyhow::Result<String> {
175        // Start with the base command
176        let mut cmd_obj = serde_json::json!({
177            "cmd": self.cmd.to_lowercase()
178        });
179
180        // Merge additional JSON parameters if provided
181        if let Some(json_str) = &self.json {
182            let params: serde_json::Value = serde_json::from_str(json_str)
183                .map_err(|e| anyhow::anyhow!("Invalid JSON parameters: {}", e))?;
184
185            if let serde_json::Value::Object(params_obj) = params {
186                if let serde_json::Value::Object(ref mut cmd_map) = cmd_obj {
187                    for (key, value) in params_obj {
188                        cmd_map.insert(key, value);
189                    }
190                }
191            }
192        }
193
194        Ok(serde_json::to_string(&cmd_obj)?)
195    }
196
197    /// Print text output for common response types.
198    fn print_text_output(&self, response: &serde_json::Value) {
199        // Check for error response
200        if let Some(error) = response.get("error") {
201            eprintln!("Error: {}", error);
202            return;
203        }
204
205        // Check for status response
206        if let Some(status) = response.get("status") {
207            println!("Status: {}", status);
208            if let Some(message) = response.get("message") {
209                println!("{}", message);
210            }
211        }
212
213        // For complex responses, just pretty-print the JSON
214        if response.as_object().map(|o| o.len() > 2).unwrap_or(false) {
215            println!(
216                "{}",
217                serde_json::to_string_pretty(response).unwrap_or_default()
218            );
219        }
220    }
221}
222
223/// Send a typed DaemonCommand (async version).
224///
225/// Convenience function that builds the command and sends it.
226pub async fn cmd_query(args: DaemonQueryArgs) -> DaemonResult<()> {
227    // Resolve project path to absolute
228    let project = args.project.canonicalize().unwrap_or_else(|_| {
229        std::env::current_dir()
230            .unwrap_or_else(|_| PathBuf::from("."))
231            .join(&args.project)
232    });
233
234    // Build the command JSON
235    let mut cmd_obj = serde_json::json!({
236        "cmd": args.cmd.to_lowercase()
237    });
238
239    // Merge additional JSON parameters if provided
240    if let Some(json_str) = &args.json {
241        let params: serde_json::Value = serde_json::from_str(json_str)
242            .map_err(|e| DaemonError::InvalidMessage(format!("Invalid JSON: {}", e)))?;
243
244        if let serde_json::Value::Object(params_obj) = params {
245            if let serde_json::Value::Object(ref mut cmd_map) = cmd_obj {
246                for (key, value) in params_obj {
247                    cmd_map.insert(key, value);
248                }
249            }
250        }
251    }
252
253    let command_json =
254        serde_json::to_string(&cmd_obj).map_err(|e| DaemonError::InvalidMessage(e.to_string()))?;
255
256    // Send command to daemon
257    let response = send_raw_command(&project, &command_json).await?;
258
259    // Print response
260    println!("{}", response);
261
262    Ok(())
263}
264
265// =============================================================================
266// Tests
267// =============================================================================
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272    use tempfile::TempDir;
273
274    #[test]
275    fn test_daemon_query_args_default() {
276        let args = DaemonQueryArgs {
277            cmd: "ping".to_string(),
278            project: PathBuf::from("."),
279            json: None,
280        };
281
282        assert_eq!(args.cmd, "ping");
283        assert_eq!(args.project, PathBuf::from("."));
284        assert!(args.json.is_none());
285    }
286
287    #[test]
288    fn test_daemon_query_args_with_json() {
289        let args = DaemonQueryArgs {
290            cmd: "search".to_string(),
291            project: PathBuf::from("/test/project"),
292            json: Some(r#"{"pattern": "fn main"}"#.to_string()),
293        };
294
295        assert_eq!(args.cmd, "search");
296        assert!(args.json.is_some());
297    }
298
299    #[test]
300    fn test_build_command_json_simple() {
301        let args = DaemonQueryArgs {
302            cmd: "ping".to_string(),
303            project: PathBuf::from("."),
304            json: None,
305        };
306
307        let json = args.build_command_json().unwrap();
308        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
309
310        assert_eq!(parsed.get("cmd").unwrap(), "ping");
311    }
312
313    #[test]
314    fn test_build_command_json_with_params() {
315        let args = DaemonQueryArgs {
316            cmd: "search".to_string(),
317            project: PathBuf::from("."),
318            json: Some(r#"{"pattern": "fn main", "max_results": 10}"#.to_string()),
319        };
320
321        let json = args.build_command_json().unwrap();
322        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
323
324        assert_eq!(parsed.get("cmd").unwrap(), "search");
325        assert_eq!(parsed.get("pattern").unwrap(), "fn main");
326        assert_eq!(parsed.get("max_results").unwrap(), 10);
327    }
328
329    #[test]
330    fn test_build_command_json_invalid_params() {
331        let args = DaemonQueryArgs {
332            cmd: "search".to_string(),
333            project: PathBuf::from("."),
334            json: Some("not valid json".to_string()),
335        };
336
337        let result = args.build_command_json();
338        assert!(result.is_err());
339    }
340
341    #[test]
342    fn test_build_command_json_lowercases_cmd() {
343        let args = DaemonQueryArgs {
344            cmd: "PING".to_string(),
345            project: PathBuf::from("."),
346            json: None,
347        };
348
349        let json = args.build_command_json().unwrap();
350        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
351
352        assert_eq!(parsed.get("cmd").unwrap(), "ping");
353    }
354
355    #[test]
356    fn test_daemon_query_error_output_serialization() {
357        let output = DaemonQueryErrorOutput {
358            status: "error".to_string(),
359            error: "Daemon not running".to_string(),
360        };
361
362        let json = serde_json::to_string(&output).unwrap();
363        assert!(json.contains("error"));
364        assert!(json.contains("Daemon not running"));
365    }
366
367    #[tokio::test]
368    async fn test_daemon_query_not_running() {
369        let temp = TempDir::new().unwrap();
370        let args = DaemonQueryArgs {
371            cmd: "ping".to_string(),
372            project: temp.path().to_path_buf(),
373            json: None,
374        };
375
376        // Should fail when daemon is not running
377        let result = cmd_query(args).await;
378        assert!(result.is_err());
379    }
380}