tldr_cli/commands/daemon/
notify.rs1use 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#[derive(Debug, Clone, Args)]
36pub struct DaemonNotifyArgs {
37 pub file: PathBuf,
39
40 #[arg(long, short = 'p', default_value = ".")]
42 pub project: PathBuf,
43}
44
45#[derive(Debug, Clone, Serialize)]
51pub struct DaemonNotifyOutput {
52 pub status: String,
54 pub dirty_count: usize,
56 pub threshold: usize,
58 pub reindex_triggered: bool,
60 #[serde(skip_serializing_if = "Option::is_none")]
62 pub message: Option<String>,
63}
64
65#[derive(Debug, Clone, Serialize)]
67pub struct DaemonNotifyErrorOutput {
68 pub status: String,
70 pub error: String,
72}
73
74impl DaemonNotifyArgs {
79 pub fn run(&self, format: OutputFormat, quiet: bool) -> anyhow::Result<()> {
81 let runtime = tokio::runtime::Runtime::new()?;
83 runtime.block_on(self.run_async(format, quiet))
84 }
85
86 async fn run_async(&self, format: OutputFormat, quiet: bool) -> anyhow::Result<()> {
88 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 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 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 let cmd = DaemonCommand::Notify { file: file.clone() };
129
130 match send_command(&project, &cmd).await {
132 Ok(response) => self.handle_response(response, format, quiet),
133 Err(DaemonError::NotRunning) | Err(DaemonError::ConnectionRefused) => {
134 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 }
153 }
154 }
155 Ok(())
156 }
157 Err(e) => {
158 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 }
175 }
176 }
177 Ok(())
178 }
179 }
180 }
181
182 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 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 Ok(())
263 }
264 _ => {
265 Ok(())
267 }
268 }
269 }
270}
271
272pub async fn cmd_notify(args: DaemonNotifyArgs) -> DaemonResult<()> {
280 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 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 if !file.starts_with(&project) {
296 return Err(DaemonError::PermissionDenied { path: file });
297 }
298
299 let cmd = DaemonCommand::Notify { file };
301
302 let response = send_command(&project, &cmd).await?;
304
305 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(()) }
324 _ => Ok(()),
325 }
326}
327
328#[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 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 let result = cmd_notify(args).await;
434 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 let result = args.run_async(OutputFormat::Json, true).await;
452 assert!(result.is_ok());
453 }
454}