Skip to main content

tldr_cli/commands/daemon/
status.rs

1//! Daemon status command implementation
2//!
3//! CLI command: `tldr daemon status [--project PATH] [--session SESSION_ID]`
4//!
5//! This module provides status information about a running daemon:
6//! - Current status (initializing, indexing, ready, shutting_down)
7//! - Uptime
8//! - Number of indexed files
9//! - Cache statistics (hits, misses, hit rate, invalidations)
10//! - Session statistics (if requested)
11//! - Hook activity statistics
12
13use std::path::{Path, PathBuf};
14
15use clap::Args;
16use serde::Serialize;
17
18use crate::output::OutputFormat;
19
20use super::daemon_active::read_active;
21use super::error::DaemonError;
22use super::ipc::send_command;
23use super::types::{DaemonCommand, DaemonResponse, DaemonStatus, SalsaCacheStats};
24
25// =============================================================================
26// CLI Arguments
27// =============================================================================
28
29/// Arguments for the `daemon status` command.
30#[derive(Debug, Clone, Args)]
31pub struct DaemonStatusArgs {
32    /// Project root directory (default: current directory).
33    ///
34    /// When omitted, falls back to the active daemon's project path
35    /// recorded by `daemon start`, allowing `tldr daemon status` to
36    /// report the running daemon's status from any working directory.
37    #[arg(long, short = 'p', default_value = ".")]
38    pub project: PathBuf,
39
40    /// Session ID to get session-specific stats
41    #[arg(long, short = 's')]
42    pub session: Option<String>,
43}
44
45// =============================================================================
46// Output Types
47// =============================================================================
48
49/// Output structure for daemon status when running.
50#[derive(Debug, Clone, Serialize)]
51pub struct DaemonStatusOutput {
52    /// Current status
53    pub status: String,
54    /// Uptime in seconds
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub uptime: Option<f64>,
57    /// Human-readable uptime
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub uptime_human: Option<String>,
60    /// Number of indexed files
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub files: Option<usize>,
63    /// Project path
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub project: Option<PathBuf>,
66    /// Cache statistics
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub salsa_stats: Option<SalsaCacheStats>,
69    /// Optional message
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub message: Option<String>,
72}
73
74// =============================================================================
75// Command Implementation
76// =============================================================================
77
78impl DaemonStatusArgs {
79    /// Run the daemon status 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 status command.
87    async fn run_async(&self, format: OutputFormat, quiet: bool) -> anyhow::Result<()> {
88        // VAL-013 (issue #20): when `--project` is the default (the literal
89        // ".", meaning the user did not pass an explicit path), prefer the
90        // active-daemon discovery record. This lets `daemon status` from any
91        // cwd find the running daemon by its recorded project path rather
92        // than mis-hashing the caller's cwd.
93        //
94        // If a record exists AND its PID is alive (validated by
95        // `read_active` via `kill(pid, 0)`), use the recorded project. If
96        // no record exists, fall back to the previous behaviour (canonicalize
97        // `--project`, which defaults to the cwd).
98        //
99        // An explicit `--project` passed by the user (anything other than
100        // `"."`) is ALWAYS honoured — the workaround path is preserved.
101        let project = if self.project == Path::new(".") {
102            match read_active() {
103                Some(active) => active.project,
104                None => self.project.canonicalize().unwrap_or_else(|_| {
105                    std::env::current_dir()
106                        .unwrap_or_else(|_| PathBuf::from("."))
107                        .join(&self.project)
108                }),
109            }
110        } else {
111            self.project.canonicalize().unwrap_or_else(|_| {
112                std::env::current_dir()
113                    .unwrap_or_else(|_| PathBuf::from("."))
114                    .join(&self.project)
115            })
116        };
117
118        // Send status command
119        let cmd = DaemonCommand::Status {
120            session: self.session.clone(),
121        };
122
123        match send_command(&project, &cmd).await {
124            Ok(response) => self.handle_response(response, format, quiet),
125            Err(DaemonError::NotRunning) | Err(DaemonError::ConnectionRefused) => {
126                // Daemon not running
127                let output = DaemonStatusOutput {
128                    status: "not_running".to_string(),
129                    uptime: None,
130                    uptime_human: None,
131                    files: None,
132                    project: None,
133                    salsa_stats: None,
134                    message: Some("Daemon not running".to_string()),
135                };
136
137                if !quiet {
138                    match format {
139                        OutputFormat::Json | OutputFormat::Compact => {
140                            println!("{}", serde_json::to_string_pretty(&output)?);
141                        }
142                        OutputFormat::Text | OutputFormat::Sarif | OutputFormat::Dot => {
143                            println!("Daemon not running");
144                        }
145                    }
146                }
147
148                Ok(())
149            }
150            Err(e) => Err(anyhow::anyhow!("Failed to get daemon status: {}", e)),
151        }
152    }
153
154    /// Handle the daemon response.
155    fn handle_response(
156        &self,
157        response: DaemonResponse,
158        format: OutputFormat,
159        quiet: bool,
160    ) -> anyhow::Result<()> {
161        match response {
162            DaemonResponse::FullStatus {
163                status,
164                uptime,
165                files,
166                project,
167                salsa_stats,
168                ..
169            } => {
170                let status_str = format_status(status);
171                let uptime_human = format_uptime(uptime);
172
173                let output = DaemonStatusOutput {
174                    status: status_str.clone(),
175                    uptime: Some(uptime),
176                    uptime_human: Some(uptime_human.clone()),
177                    files: Some(files),
178                    project: Some(project.clone()),
179                    salsa_stats: Some(salsa_stats.clone()),
180                    message: None,
181                };
182
183                if !quiet {
184                    match format {
185                        OutputFormat::Json | OutputFormat::Compact => {
186                            println!("{}", serde_json::to_string_pretty(&output)?);
187                        }
188                        OutputFormat::Text | OutputFormat::Sarif | OutputFormat::Dot => {
189                            println!("TLDR Daemon Status");
190                            println!("==================");
191                            println!("Status:  {}", status_str);
192                            println!("Uptime:  {}", uptime_human);
193                            println!("Project: {}", project.display());
194                            println!("Files:   {}", files);
195                            println!();
196                            println!("Cache Statistics");
197                            println!("----------------");
198                            println!("Hits:          {}", format_number(salsa_stats.hits));
199                            println!("Misses:        {}", format_number(salsa_stats.misses));
200                            println!("Hit Rate:      {:.2}%", salsa_stats.hit_rate());
201                            println!(
202                                "Invalidations: {}",
203                                format_number(salsa_stats.invalidations)
204                            );
205                        }
206                    }
207                }
208
209                Ok(())
210            }
211            DaemonResponse::Status { status, message } => {
212                let output = DaemonStatusOutput {
213                    status: status.clone(),
214                    uptime: None,
215                    uptime_human: None,
216                    files: None,
217                    project: None,
218                    salsa_stats: None,
219                    message,
220                };
221
222                if !quiet {
223                    match format {
224                        OutputFormat::Json | OutputFormat::Compact => {
225                            println!("{}", serde_json::to_string_pretty(&output)?);
226                        }
227                        OutputFormat::Text | OutputFormat::Sarif | OutputFormat::Dot => {
228                            println!("Status: {}", status);
229                            if let Some(msg) = &output.message {
230                                println!("{}", msg);
231                            }
232                        }
233                    }
234                }
235
236                Ok(())
237            }
238            DaemonResponse::Error { error, .. } => Err(anyhow::anyhow!("Daemon error: {}", error)),
239            _ => Err(anyhow::anyhow!("Unexpected response from daemon")),
240        }
241    }
242}
243
244// =============================================================================
245// Helper Functions
246// =============================================================================
247
248/// Format DaemonStatus as a string.
249fn format_status(status: DaemonStatus) -> String {
250    match status {
251        DaemonStatus::Initializing => "initializing".to_string(),
252        DaemonStatus::Indexing => "indexing".to_string(),
253        DaemonStatus::Ready => "running".to_string(),
254        DaemonStatus::ShuttingDown => "shutting_down".to_string(),
255        DaemonStatus::Stopped => "stopped".to_string(),
256    }
257}
258
259/// Format uptime seconds as human-readable string.
260fn format_uptime(secs: f64) -> String {
261    let total_secs = secs as u64;
262    let hours = total_secs / 3600;
263    let minutes = (total_secs % 3600) / 60;
264    let seconds = total_secs % 60;
265    format!("{}h {}m {}s", hours, minutes, seconds)
266}
267
268/// Format a number with thousands separators.
269fn format_number(n: u64) -> String {
270    let s = n.to_string();
271    let bytes = s.as_bytes();
272    let mut result = String::new();
273    let len = bytes.len();
274
275    for (i, &b) in bytes.iter().enumerate() {
276        if i > 0 && (len - i).is_multiple_of(3) {
277            result.push(',');
278        }
279        result.push(b as char);
280    }
281
282    result
283}
284
285// =============================================================================
286// Tests
287// =============================================================================
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use tempfile::TempDir;
293
294    #[test]
295    fn test_daemon_status_args_default() {
296        let args = DaemonStatusArgs {
297            project: PathBuf::from("."),
298            session: None,
299        };
300
301        assert_eq!(args.project, PathBuf::from("."));
302        assert!(args.session.is_none());
303    }
304
305    #[test]
306    fn test_daemon_status_args_with_session() {
307        let args = DaemonStatusArgs {
308            project: PathBuf::from("/test/project"),
309            session: Some("test-session".to_string()),
310        };
311
312        assert_eq!(args.session, Some("test-session".to_string()));
313    }
314
315    #[test]
316    fn test_format_status() {
317        assert_eq!(format_status(DaemonStatus::Ready), "running");
318        assert_eq!(format_status(DaemonStatus::Initializing), "initializing");
319        assert_eq!(format_status(DaemonStatus::Indexing), "indexing");
320        assert_eq!(format_status(DaemonStatus::ShuttingDown), "shutting_down");
321        assert_eq!(format_status(DaemonStatus::Stopped), "stopped");
322    }
323
324    #[test]
325    fn test_format_uptime() {
326        assert_eq!(format_uptime(0.0), "0h 0m 0s");
327        assert_eq!(format_uptime(61.0), "0h 1m 1s");
328        assert_eq!(format_uptime(3661.0), "1h 1m 1s");
329        assert_eq!(format_uptime(7200.0), "2h 0m 0s");
330    }
331
332    #[test]
333    fn test_format_number() {
334        assert_eq!(format_number(0), "0");
335        assert_eq!(format_number(999), "999");
336        assert_eq!(format_number(1000), "1,000");
337        assert_eq!(format_number(1234567), "1,234,567");
338    }
339
340    #[test]
341    fn test_daemon_status_output_serialization() {
342        let output = DaemonStatusOutput {
343            status: "running".to_string(),
344            uptime: Some(3600.0),
345            uptime_human: Some("1h 0m 0s".to_string()),
346            files: Some(100),
347            project: Some(PathBuf::from("/test/project")),
348            salsa_stats: Some(SalsaCacheStats {
349                hits: 90,
350                misses: 10,
351                invalidations: 5,
352                recomputations: 3,
353            }),
354            message: None,
355        };
356
357        let json = serde_json::to_string(&output).unwrap();
358        assert!(json.contains("running"));
359        assert!(json.contains("3600"));
360        assert!(json.contains("hits"));
361    }
362
363    #[test]
364    fn test_daemon_status_output_not_running() {
365        let output = DaemonStatusOutput {
366            status: "not_running".to_string(),
367            uptime: None,
368            uptime_human: None,
369            files: None,
370            project: None,
371            salsa_stats: None,
372            message: Some("Daemon not running".to_string()),
373        };
374
375        let json = serde_json::to_string(&output).unwrap();
376        assert!(json.contains("not_running"));
377        assert!(json.contains("not running"));
378    }
379
380    #[tokio::test]
381    async fn test_daemon_status_not_running() {
382        let temp = TempDir::new().unwrap();
383        let args = DaemonStatusArgs {
384            project: temp.path().to_path_buf(),
385            session: None,
386        };
387
388        // Should succeed when daemon is not running (reports not_running)
389        let result = args.run_async(OutputFormat::Json, true).await;
390        assert!(result.is_ok());
391    }
392}