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