Skip to main content

tldr_cli/commands/daemon/
stop.rs

1//! Daemon stop command implementation
2//!
3//! CLI command: `tldr daemon stop [--project PATH]`
4//!
5//! This module handles stopping the TLDR daemon gracefully by:
6//! - Connecting to the daemon via IPC
7//! - Sending a shutdown command
8//! - Waiting for the daemon to exit
9//! - Cleaning up socket and PID files
10
11use std::path::PathBuf;
12
13use clap::Args;
14use serde::Serialize;
15
16use crate::output::OutputFormat;
17
18use super::daemon_active::remove_active;
19use super::daemon_registry::{live_entries, remove_entry};
20use super::error::DaemonError;
21use super::ipc::{check_socket_alive, cleanup_socket, send_command};
22use super::pid::{cleanup_stale_pid, compute_pid_path};
23use super::types::DaemonCommand;
24
25// =============================================================================
26// CLI Arguments
27// =============================================================================
28
29/// Arguments for the `daemon stop` command.
30#[derive(Debug, Clone, Args)]
31pub struct DaemonStopArgs {
32    /// Project root directory (default: current directory).
33    ///
34    /// Mutually exclusive with `--all`. When neither is set, the command
35    /// targets the daemon for the current working directory.
36    #[arg(long, short = 'p', default_value = ".")]
37    pub project: PathBuf,
38
39    /// Stop ALL running daemons known to the v0.3.0 multi-daemon registry.
40    ///
41    /// Mutually exclusive with `--project`. Iterates the registry, sends
42    /// shutdown to each daemon, and removes the entry on success.
43    #[arg(long, conflicts_with = "project")]
44    pub all: bool,
45}
46
47// =============================================================================
48// Output Types
49// =============================================================================
50
51/// Output structure for daemon stop result.
52#[derive(Debug, Clone, Serialize)]
53pub struct DaemonStopOutput {
54    /// Status message
55    pub status: String,
56    /// Optional message
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub message: Option<String>,
59}
60
61// =============================================================================
62// Command Implementation
63// =============================================================================
64
65impl DaemonStopArgs {
66    /// Run the daemon stop command.
67    pub fn run(&self, format: OutputFormat, quiet: bool) -> anyhow::Result<()> {
68        // Create a new tokio runtime for the async operations
69        let runtime = tokio::runtime::Runtime::new()?;
70        runtime.block_on(self.run_async(format, quiet))
71    }
72
73    /// Async implementation of the daemon stop command.
74    async fn run_async(&self, format: OutputFormat, quiet: bool) -> anyhow::Result<()> {
75        // --all: iterate the v0.3.0 registry and stop each known daemon.
76        if self.all {
77            return self.run_stop_all(format, quiet).await;
78        }
79
80        // Resolve project path to absolute
81        let project = self.project.canonicalize().unwrap_or_else(|_| {
82            std::env::current_dir()
83                .unwrap_or_else(|_| PathBuf::from("."))
84                .join(&self.project)
85        });
86
87        // Check if daemon is running
88        if !check_socket_alive(&project).await {
89            // Daemon not running
90            let output = DaemonStopOutput {
91                status: "ok".to_string(),
92                message: Some("Daemon not running".to_string()),
93            };
94
95            if !quiet {
96                match format {
97                    OutputFormat::Json | OutputFormat::Compact => {
98                        println!("{}", serde_json::to_string_pretty(&output)?);
99                    }
100                    OutputFormat::Text | OutputFormat::Sarif | OutputFormat::Dot => {
101                        println!("Daemon not running");
102                    }
103                }
104            }
105
106            // Clean up any stale files (legacy daemon-active.json + v0.3.0
107            // registry entry).
108            let pid_path = compute_pid_path(&project);
109            let _ = cleanup_stale_pid(&pid_path);
110            let _ = cleanup_socket(&project);
111            let _ = remove_active();
112            let _ = remove_entry(&project);
113
114            return Ok(());
115        }
116
117        // Send shutdown command
118        let cmd = DaemonCommand::Shutdown;
119        match send_command(&project, &cmd).await {
120            Ok(_response) => {
121                // Wait for daemon to actually stop
122                let mut retries = 0;
123                while retries < 50 {
124                    // 5 seconds max
125                    if !check_socket_alive(&project).await {
126                        break;
127                    }
128                    tokio::time::sleep(std::time::Duration::from_millis(100)).await;
129                    retries += 1;
130                }
131
132                // Clean up files (legacy daemon-active.json + v0.3.0 entry).
133                let _ = cleanup_socket(&project);
134                let pid_path = compute_pid_path(&project);
135                let _ = cleanup_stale_pid(&pid_path);
136                let _ = remove_active();
137                let _ = remove_entry(&project);
138
139                let output = DaemonStopOutput {
140                    status: "ok".to_string(),
141                    message: Some("Daemon stopped".to_string()),
142                };
143
144                if !quiet {
145                    match format {
146                        OutputFormat::Json | OutputFormat::Compact => {
147                            println!("{}", serde_json::to_string_pretty(&output)?);
148                        }
149                        OutputFormat::Text | OutputFormat::Sarif | OutputFormat::Dot => {
150                            println!("Daemon stopped");
151                        }
152                    }
153                }
154
155                Ok(())
156            }
157            Err(DaemonError::NotRunning) | Err(DaemonError::ConnectionRefused) => {
158                // Daemon already stopped or not responding
159                let output = DaemonStopOutput {
160                    status: "ok".to_string(),
161                    message: Some("Daemon not running".to_string()),
162                };
163
164                if !quiet {
165                    match format {
166                        OutputFormat::Json | OutputFormat::Compact => {
167                            println!("{}", serde_json::to_string_pretty(&output)?);
168                        }
169                        OutputFormat::Text | OutputFormat::Sarif | OutputFormat::Dot => {
170                            println!("Daemon not running");
171                        }
172                    }
173                }
174
175                // Clean up any stale files (legacy daemon-active.json +
176                // v0.3.0 registry entry).
177                let _ = cleanup_socket(&project);
178                let pid_path = compute_pid_path(&project);
179                let _ = cleanup_stale_pid(&pid_path);
180                let _ = remove_active();
181                let _ = remove_entry(&project);
182
183                Ok(())
184            }
185            Err(e) => Err(anyhow::anyhow!("Failed to stop daemon: {}", e)),
186        }
187    }
188
189    /// Implements `daemon stop --all`. Iterates the v0.3.0 registry and
190    /// sends shutdown to each known daemon. Best-effort: a failure on one
191    /// entry does not abort the iteration; failures are reported in the
192    /// final output but the command exits 0 if at least the registry is
193    /// reachable.
194    async fn run_stop_all(&self, format: OutputFormat, quiet: bool) -> anyhow::Result<()> {
195        let entries = live_entries();
196
197        if entries.is_empty() {
198            let output = DaemonStopOutput {
199                status: "ok".to_string(),
200                message: Some("No daemons running".to_string()),
201            };
202            if !quiet {
203                match format {
204                    OutputFormat::Json | OutputFormat::Compact => {
205                        println!("{}", serde_json::to_string_pretty(&output)?);
206                    }
207                    OutputFormat::Text | OutputFormat::Sarif | OutputFormat::Dot => {
208                        println!("No daemons running");
209                    }
210                }
211            }
212            // Defensive: ensure no legacy daemon-active.json lingers.
213            let _ = remove_active();
214            return Ok(());
215        }
216
217        let mut stopped = 0usize;
218        let mut failed = 0usize;
219        for entry in &entries {
220            let project = &entry.project;
221            let cmd = DaemonCommand::Shutdown;
222            match send_command(project, &cmd).await {
223                Ok(_response) => {
224                    // Wait briefly for the daemon to exit.
225                    let mut retries = 0;
226                    while retries < 50 {
227                        if !check_socket_alive(project).await {
228                            break;
229                        }
230                        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
231                        retries += 1;
232                    }
233                    let _ = cleanup_socket(project);
234                    let pid_path = compute_pid_path(project);
235                    let _ = cleanup_stale_pid(&pid_path);
236                    let _ = remove_entry(project);
237                    stopped += 1;
238                }
239                Err(DaemonError::NotRunning) | Err(DaemonError::ConnectionRefused) => {
240                    // Already dead — clean up the registry record.
241                    let _ = cleanup_socket(project);
242                    let pid_path = compute_pid_path(project);
243                    let _ = cleanup_stale_pid(&pid_path);
244                    let _ = remove_entry(project);
245                    stopped += 1;
246                }
247                Err(_) => {
248                    failed += 1;
249                }
250            }
251        }
252        // Defensive: drop any legacy single-slot record.
253        let _ = remove_active();
254
255        let summary = if failed == 0 {
256            format!("Stopped {} daemon(s)", stopped)
257        } else {
258            format!("Stopped {} daemon(s); {} failed", stopped, failed)
259        };
260        let output = DaemonStopOutput {
261            status: "ok".to_string(),
262            message: Some(summary.clone()),
263        };
264        if !quiet {
265            match format {
266                OutputFormat::Json | OutputFormat::Compact => {
267                    println!("{}", serde_json::to_string_pretty(&output)?);
268                }
269                OutputFormat::Text | OutputFormat::Sarif | OutputFormat::Dot => {
270                    println!("{}", summary);
271                }
272            }
273        }
274        Ok(())
275    }
276}
277
278// =============================================================================
279// Tests
280// =============================================================================
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use tempfile::TempDir;
286
287    #[test]
288    fn test_daemon_stop_args_default() {
289        let args = DaemonStopArgs {
290            project: PathBuf::from("."),
291            all: false,
292        };
293
294        assert_eq!(args.project, PathBuf::from("."));
295        assert!(!args.all);
296    }
297
298    #[test]
299    fn test_daemon_stop_output_serialization() {
300        let output = DaemonStopOutput {
301            status: "ok".to_string(),
302            message: Some("Daemon stopped".to_string()),
303        };
304
305        let json = serde_json::to_string(&output).unwrap();
306        assert!(json.contains("ok"));
307        assert!(json.contains("Daemon stopped"));
308    }
309
310    #[test]
311    fn test_daemon_stop_output_not_running() {
312        let output = DaemonStopOutput {
313            status: "ok".to_string(),
314            message: Some("Daemon not running".to_string()),
315        };
316
317        let json = serde_json::to_string(&output).unwrap();
318        assert!(json.contains("ok"));
319        assert!(json.contains("not running"));
320    }
321
322    #[tokio::test]
323    async fn test_daemon_stop_not_running() {
324        let temp = TempDir::new().unwrap();
325        let args = DaemonStopArgs {
326            project: temp.path().to_path_buf(),
327            all: false,
328        };
329
330        // Should succeed when daemon is not running
331        let result = args.run_async(OutputFormat::Json, true).await;
332        assert!(result.is_ok());
333    }
334}