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::daemon_registry::live_entries;
22use super::error::DaemonError;
23use super::ipc::send_command;
24use super::types::{DaemonCommand, DaemonResponse, DaemonStatus, SalsaCacheStats};
25
26#[derive(Debug, Clone, Args)]
32pub struct DaemonStatusArgs {
33 #[arg(long, short = 'p', default_value = ".")]
39 pub project: PathBuf,
40
41 #[arg(long, short = 's')]
43 pub session: Option<String>,
44}
45
46#[derive(Debug, Clone, Serialize)]
52pub struct DaemonStatusOutput {
53 pub status: String,
55 #[serde(skip_serializing_if = "Option::is_none")]
57 pub uptime: Option<f64>,
58 #[serde(skip_serializing_if = "Option::is_none")]
60 pub uptime_human: Option<String>,
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub files: Option<usize>,
64 #[serde(skip_serializing_if = "Option::is_none")]
66 pub project: Option<PathBuf>,
67 #[serde(skip_serializing_if = "Option::is_none")]
69 pub salsa_stats: Option<SalsaCacheStats>,
70 #[serde(skip_serializing_if = "Option::is_none")]
72 pub message: Option<String>,
73}
74
75impl DaemonStatusArgs {
80 pub fn run(&self, format: OutputFormat, quiet: bool) -> anyhow::Result<()> {
82 let runtime = tokio::runtime::Runtime::new()?;
84 runtime.block_on(self.run_async(format, quiet))
85 }
86
87 async fn run_async(&self, format: OutputFormat, quiet: bool) -> anyhow::Result<()> {
89 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 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 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 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
252fn 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
267fn 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
276fn 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#[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 let result = args.run_async(OutputFormat::Json, true).await;
398 assert!(result.is_ok());
399 }
400}