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