Skip to main content

tldr_cli/commands/daemon/
notify.rs

1//! Daemon notify command implementation
2//!
3//! CLI command: `tldr daemon notify FILE [--project PATH]`
4//!
5//! This module provides file change notifications to the daemon for:
6//! - Cache invalidation
7//! - Dirty file tracking
8//! - Automatic re-indexing when threshold is reached
9//!
10//! # Security Mitigations
11//!
12//! - TIGER-P3-03: Validates file path is within project root
13//! - TIGER-P3-05: Rate limiting handled in daemon (client just sends)
14//!
15//! # Use Case
16//!
17//! Editor hooks call this on file save to keep daemon cache fresh.
18
19use std::path::PathBuf;
20
21use clap::Args;
22use serde::Serialize;
23
24use crate::output::OutputFormat;
25
26use super::error::{DaemonError, DaemonResult};
27use super::ipc::send_command;
28use super::types::{DaemonCommand, DaemonResponse};
29
30// =============================================================================
31// CLI Arguments
32// =============================================================================
33
34/// Arguments for the `daemon notify` command.
35#[derive(Debug, Clone, Args)]
36pub struct DaemonNotifyArgs {
37    /// Path to the changed file
38    pub file: PathBuf,
39
40    /// Project root directory (default: current directory)
41    #[arg(long, short = 'p', default_value = ".")]
42    pub project: PathBuf,
43}
44
45// =============================================================================
46// Output Types
47// =============================================================================
48
49/// Output structure for successful notify response.
50#[derive(Debug, Clone, Serialize)]
51pub struct DaemonNotifyOutput {
52    /// Status (always "ok")
53    pub status: String,
54    /// Number of dirty files tracked
55    pub dirty_count: usize,
56    /// Threshold for triggering re-index
57    pub threshold: usize,
58    /// Whether re-index was triggered
59    pub reindex_triggered: bool,
60    /// Optional message
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub message: Option<String>,
63}
64
65/// Output structure for notify errors.
66#[derive(Debug, Clone, Serialize)]
67pub struct DaemonNotifyErrorOutput {
68    /// Status (always "error")
69    pub status: String,
70    /// Error message
71    pub error: String,
72}
73
74// =============================================================================
75// Command Implementation
76// =============================================================================
77
78impl DaemonNotifyArgs {
79    /// Run the daemon notify command.
80    pub fn run(&self, format: OutputFormat, quiet: bool) -> anyhow::Result<()> {
81        // Create a new tokio runtime for the async operations
82        let runtime = tokio::runtime::Runtime::new()?;
83        runtime.block_on(self.run_async(format, quiet))
84    }
85
86    /// Async implementation of the daemon notify command.
87    async fn run_async(&self, format: OutputFormat, quiet: bool) -> anyhow::Result<()> {
88        // Resolve project path to absolute
89        let project = self.project.canonicalize().unwrap_or_else(|_| {
90            std::env::current_dir()
91                .unwrap_or_else(|_| PathBuf::from("."))
92                .join(&self.project)
93        });
94
95        // Resolve file path to absolute
96        let file = self.file.canonicalize().unwrap_or_else(|_| {
97            std::env::current_dir()
98                .unwrap_or_else(|_| PathBuf::from("."))
99                .join(&self.file)
100        });
101
102        // TIGER-P3-03: Validate file path is within project root
103        if !file.starts_with(&project) {
104            let output = DaemonNotifyErrorOutput {
105                status: "error".to_string(),
106                error: format!(
107                    "File '{}' is outside project root '{}'",
108                    file.display(),
109                    project.display()
110                ),
111            };
112
113            if !quiet {
114                match format {
115                    OutputFormat::Json | OutputFormat::Compact => {
116                        println!("{}", serde_json::to_string_pretty(&output)?);
117                    }
118                    OutputFormat::Text | OutputFormat::Sarif | OutputFormat::Dot => {
119                        eprintln!("Error: File '{}' is outside project root", file.display());
120                    }
121                }
122            }
123
124            return Err(anyhow::anyhow!("File is outside project root"));
125        }
126
127        // Build notify command
128        let cmd = DaemonCommand::Notify { file: file.clone() };
129
130        // Send to daemon
131        match send_command(&project, &cmd).await {
132            Ok(response) => self.handle_response(response, format, quiet),
133            Err(DaemonError::NotRunning) | Err(DaemonError::ConnectionRefused) => {
134                // Daemon not running - silently succeed
135                // File edits should never fail due to daemon status
136                if !quiet {
137                    match format {
138                        OutputFormat::Json | OutputFormat::Compact => {
139                            let output = DaemonNotifyOutput {
140                                status: "ok".to_string(),
141                                dirty_count: 0,
142                                threshold: 20,
143                                reindex_triggered: false,
144                                message: Some(
145                                    "Daemon not running (notification ignored)".to_string(),
146                                ),
147                            };
148                            println!("{}", serde_json::to_string_pretty(&output)?);
149                        }
150                        OutputFormat::Text | OutputFormat::Sarif | OutputFormat::Dot => {
151                            // Silent - don't interrupt editor workflow
152                        }
153                    }
154                }
155                Ok(())
156            }
157            Err(e) => {
158                // Other errors - also silently succeed
159                // File edits should never fail due to daemon issues
160                if !quiet {
161                    match format {
162                        OutputFormat::Json | OutputFormat::Compact => {
163                            let output = DaemonNotifyOutput {
164                                status: "ok".to_string(),
165                                dirty_count: 0,
166                                threshold: 20,
167                                reindex_triggered: false,
168                                message: Some(format!("Notification failed: {} (ignored)", e)),
169                            };
170                            println!("{}", serde_json::to_string_pretty(&output)?);
171                        }
172                        OutputFormat::Text | OutputFormat::Sarif | OutputFormat::Dot => {
173                            // Silent - don't interrupt editor workflow
174                        }
175                    }
176                }
177                Ok(())
178            }
179        }
180    }
181
182    /// Handle the daemon response.
183    fn handle_response(
184        &self,
185        response: DaemonResponse,
186        format: OutputFormat,
187        quiet: bool,
188    ) -> anyhow::Result<()> {
189        match response {
190            DaemonResponse::NotifyResponse {
191                status,
192                dirty_count,
193                threshold,
194                reindex_triggered,
195            } => {
196                let output = DaemonNotifyOutput {
197                    status,
198                    dirty_count,
199                    threshold,
200                    reindex_triggered,
201                    message: None,
202                };
203
204                if !quiet {
205                    match format {
206                        OutputFormat::Json | OutputFormat::Compact => {
207                            println!("{}", serde_json::to_string_pretty(&output)?);
208                        }
209                        OutputFormat::Text | OutputFormat::Sarif | OutputFormat::Dot => {
210                            if reindex_triggered {
211                                println!("Reindex triggered ({}/{} files)", dirty_count, threshold);
212                            } else {
213                                println!("Tracked: {}/{} files", dirty_count, threshold);
214                            }
215                        }
216                    }
217                }
218
219                Ok(())
220            }
221            DaemonResponse::Status { status, message } => {
222                // Simple status response (probably "ok")
223                let output = DaemonNotifyOutput {
224                    status: status.clone(),
225                    dirty_count: 0,
226                    threshold: 20,
227                    reindex_triggered: false,
228                    message,
229                };
230
231                if !quiet {
232                    match format {
233                        OutputFormat::Json | OutputFormat::Compact => {
234                            println!("{}", serde_json::to_string_pretty(&output)?);
235                        }
236                        OutputFormat::Text | OutputFormat::Sarif | OutputFormat::Dot => {
237                            println!("Status: {}", status);
238                        }
239                    }
240                }
241
242                Ok(())
243            }
244            DaemonResponse::Error { error, .. } => {
245                let output = DaemonNotifyErrorOutput {
246                    status: "error".to_string(),
247                    error: error.clone(),
248                };
249
250                if !quiet {
251                    match format {
252                        OutputFormat::Json | OutputFormat::Compact => {
253                            println!("{}", serde_json::to_string_pretty(&output)?);
254                        }
255                        OutputFormat::Text | OutputFormat::Sarif | OutputFormat::Dot => {
256                            eprintln!("Error: {}", error);
257                        }
258                    }
259                }
260
261                // Don't fail - file edits should work even with daemon errors
262                Ok(())
263            }
264            _ => {
265                // Unexpected response - treat as success
266                Ok(())
267            }
268        }
269    }
270}
271
272/// Send a notify command to the daemon (async version).
273///
274/// Convenience function that validates and sends the notification.
275///
276/// # Security
277///
278/// - TIGER-P3-03: Validates file path is within project root
279pub async fn cmd_notify(args: DaemonNotifyArgs) -> DaemonResult<()> {
280    // Resolve project path to absolute
281    let project = args.project.canonicalize().unwrap_or_else(|_| {
282        std::env::current_dir()
283            .unwrap_or_else(|_| PathBuf::from("."))
284            .join(&args.project)
285    });
286
287    // Resolve file path to absolute
288    let file = args.file.canonicalize().unwrap_or_else(|_| {
289        std::env::current_dir()
290            .unwrap_or_else(|_| PathBuf::from("."))
291            .join(&args.file)
292    });
293
294    // TIGER-P3-03: Validate file path is within project root
295    if !file.starts_with(&project) {
296        return Err(DaemonError::PermissionDenied { path: file });
297    }
298
299    // Build notify command
300    let cmd = DaemonCommand::Notify { file };
301
302    // Send to daemon
303    let response = send_command(&project, &cmd).await?;
304
305    // Print response
306    match response {
307        DaemonResponse::NotifyResponse {
308            dirty_count,
309            threshold,
310            reindex_triggered,
311            ..
312        } => {
313            if reindex_triggered {
314                println!("Reindex triggered ({}/{} files)", dirty_count, threshold);
315            } else {
316                println!("Tracked: {}/{} files", dirty_count, threshold);
317            }
318            Ok(())
319        }
320        DaemonResponse::Error { error, .. } => {
321            eprintln!("Error: {}", error);
322            Ok(()) // Don't fail - file edits should work
323        }
324        _ => Ok(()),
325    }
326}
327
328// =============================================================================
329// Tests
330// =============================================================================
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335    use std::fs;
336    use tempfile::TempDir;
337
338    #[test]
339    fn test_daemon_notify_args_default() {
340        let args = DaemonNotifyArgs {
341            file: PathBuf::from("test.rs"),
342            project: PathBuf::from("."),
343        };
344
345        assert_eq!(args.file, PathBuf::from("test.rs"));
346        assert_eq!(args.project, PathBuf::from("."));
347    }
348
349    #[test]
350    fn test_daemon_notify_args_with_project() {
351        let args = DaemonNotifyArgs {
352            file: PathBuf::from("/test/project/src/main.rs"),
353            project: PathBuf::from("/test/project"),
354        };
355
356        assert_eq!(args.file, PathBuf::from("/test/project/src/main.rs"));
357        assert_eq!(args.project, PathBuf::from("/test/project"));
358    }
359
360    #[test]
361    fn test_daemon_notify_output_serialization() {
362        let output = DaemonNotifyOutput {
363            status: "ok".to_string(),
364            dirty_count: 5,
365            threshold: 20,
366            reindex_triggered: false,
367            message: None,
368        };
369
370        let json = serde_json::to_string(&output).unwrap();
371        assert!(json.contains("ok"));
372        assert!(json.contains("5"));
373        assert!(json.contains("20"));
374        assert!(json.contains("false"));
375    }
376
377    #[test]
378    fn test_daemon_notify_output_reindex_triggered() {
379        let output = DaemonNotifyOutput {
380            status: "ok".to_string(),
381            dirty_count: 20,
382            threshold: 20,
383            reindex_triggered: true,
384            message: None,
385        };
386
387        let json = serde_json::to_string(&output).unwrap();
388        assert!(json.contains("true"));
389    }
390
391    #[test]
392    fn test_daemon_notify_error_output_serialization() {
393        let output = DaemonNotifyErrorOutput {
394            status: "error".to_string(),
395            error: "File outside project root".to_string(),
396        };
397
398        let json = serde_json::to_string(&output).unwrap();
399        assert!(json.contains("error"));
400        assert!(json.contains("File outside project root"));
401    }
402
403    #[tokio::test]
404    async fn test_daemon_notify_file_outside_project() {
405        let temp = TempDir::new().unwrap();
406        let outside_file = TempDir::new().unwrap();
407        let test_file = outside_file.path().join("outside.rs");
408        fs::write(&test_file, "fn main() {}").unwrap();
409
410        let args = DaemonNotifyArgs {
411            file: test_file.clone(),
412            project: temp.path().to_path_buf(),
413        };
414
415        // Should fail because file is outside project
416        let result = cmd_notify(args).await;
417        assert!(result.is_err());
418        assert!(matches!(result, Err(DaemonError::PermissionDenied { .. })));
419    }
420
421    #[tokio::test]
422    async fn test_daemon_notify_file_inside_project() {
423        let temp = TempDir::new().unwrap();
424        let test_file = temp.path().join("test.rs");
425        fs::write(&test_file, "fn main() {}").unwrap();
426
427        let args = DaemonNotifyArgs {
428            file: test_file.clone(),
429            project: temp.path().to_path_buf(),
430        };
431
432        // Should fail because daemon is not running (but path validation passed)
433        let result = cmd_notify(args).await;
434        // NotRunning error means path validation passed
435        assert!(result.is_err());
436        assert!(matches!(result, Err(DaemonError::NotRunning)));
437    }
438
439    #[tokio::test]
440    async fn test_daemon_notify_silent_when_not_running() {
441        let temp = TempDir::new().unwrap();
442        let test_file = temp.path().join("test.rs");
443        fs::write(&test_file, "fn main() {}").unwrap();
444
445        let args = DaemonNotifyArgs {
446            file: test_file.clone(),
447            project: temp.path().to_path_buf(),
448        };
449
450        // The run method should succeed even when daemon is not running
451        let result = args.run_async(OutputFormat::Json, true).await;
452        assert!(result.is_ok());
453    }
454}