tldr_cli/commands/daemon/
status.rs1use 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#[derive(Debug, Clone, Args)]
31pub struct DaemonStatusArgs {
32 #[arg(long, short = 'p', default_value = ".")]
38 pub project: PathBuf,
39
40 #[arg(long, short = 's')]
42 pub session: Option<String>,
43}
44
45#[derive(Debug, Clone, Serialize)]
51pub struct DaemonStatusOutput {
52 pub status: String,
54 #[serde(skip_serializing_if = "Option::is_none")]
56 pub uptime: Option<f64>,
57 #[serde(skip_serializing_if = "Option::is_none")]
59 pub uptime_human: Option<String>,
60 #[serde(skip_serializing_if = "Option::is_none")]
62 pub files: Option<usize>,
63 #[serde(skip_serializing_if = "Option::is_none")]
65 pub project: Option<PathBuf>,
66 #[serde(skip_serializing_if = "Option::is_none")]
68 pub salsa_stats: Option<SalsaCacheStats>,
69 #[serde(skip_serializing_if = "Option::is_none")]
71 pub message: Option<String>,
72}
73
74impl DaemonStatusArgs {
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 = 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 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 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 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
244fn 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
259fn 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
268fn 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#[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 let result = args.run_async(OutputFormat::Json, true).await;
390 assert!(result.is_ok());
391 }
392}