1use std::collections::HashMap;
7use std::path::PathBuf;
8use std::time::Duration;
9
10use serde::{Deserialize, Serialize};
11use tldr_core::Language;
12
13pub const IDLE_TIMEOUT: Duration = Duration::from_secs(30 * 60);
19
20pub const IDLE_TIMEOUT_SECS: u64 = 30 * 60;
22
23pub const DEFAULT_REINDEX_THRESHOLD: usize = 20;
25
26pub const HOOK_FLUSH_THRESHOLD: usize = 5;
28
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
35pub struct DaemonConfig {
36 pub semantic_enabled: bool,
38
39 pub auto_reindex_threshold: usize,
41
42 pub semantic_model: String,
44
45 pub idle_timeout_secs: u64,
47}
48
49impl Default for DaemonConfig {
50 fn default() -> Self {
51 Self {
52 semantic_enabled: true,
53 auto_reindex_threshold: DEFAULT_REINDEX_THRESHOLD,
54 semantic_model: "bge-large-en-v1.5".to_string(),
55 idle_timeout_secs: IDLE_TIMEOUT_SECS,
56 }
57 }
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "snake_case")]
67pub enum DaemonStatus {
68 Initializing,
70 Indexing,
72 Ready,
74 ShuttingDown,
76 Stopped,
78}
79
80#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
86pub struct SalsaCacheStats {
87 pub hits: u64,
89
90 pub misses: u64,
92
93 pub invalidations: u64,
95
96 pub recomputations: u64,
98}
99
100impl SalsaCacheStats {
101 pub fn hit_rate(&self) -> f64 {
103 let total = self.hits + self.misses;
104 if total == 0 {
105 return 0.0;
106 }
107 (self.hits as f64 / total as f64) * 100.0
108 }
109}
110
111#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
113pub struct DedupStats {
114 pub unique_hashes: usize,
116
117 pub duplicates_avoided: usize,
119
120 pub bytes_saved: u64,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct SessionStats {
127 pub session_id: String,
129
130 pub raw_tokens: u64,
132
133 pub tldr_tokens: u64,
135
136 pub requests: u64,
138
139 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub started_at: Option<chrono::DateTime<chrono::Utc>>,
142}
143
144impl SessionStats {
145 pub fn new(session_id: String) -> Self {
147 Self {
148 session_id,
149 raw_tokens: 0,
150 tldr_tokens: 0,
151 requests: 0,
152 started_at: Some(chrono::Utc::now()),
153 }
154 }
155
156 pub fn record_request(&mut self, raw_tokens: u64, tldr_tokens: u64) {
158 self.raw_tokens += raw_tokens;
159 self.tldr_tokens += tldr_tokens;
160 self.requests += 1;
161 }
162
163 pub fn savings_tokens(&self) -> i64 {
165 self.raw_tokens as i64 - self.tldr_tokens as i64
166 }
167
168 pub fn savings_percent(&self) -> f64 {
170 if self.raw_tokens == 0 {
171 return 0.0;
172 }
173 (self.savings_tokens() as f64 / self.raw_tokens as f64) * 100.0
174 }
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct HookStats {
180 pub hook_name: String,
182
183 pub invocations: u64,
185
186 pub successes: u64,
188
189 pub failures: u64,
191
192 #[serde(default)]
194 pub metrics: HashMap<String, f64>,
195
196 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub started_at: Option<chrono::DateTime<chrono::Utc>>,
199}
200
201impl HookStats {
202 pub fn new(hook_name: String) -> Self {
204 Self {
205 hook_name,
206 invocations: 0,
207 successes: 0,
208 failures: 0,
209 metrics: HashMap::new(),
210 started_at: Some(chrono::Utc::now()),
211 }
212 }
213
214 pub fn record_invocation(&mut self, success: bool, metrics: Option<HashMap<String, f64>>) {
216 self.invocations += 1;
217 if success {
218 self.successes += 1;
219 } else {
220 self.failures += 1;
221 }
222 if let Some(m) = metrics {
223 for (key, value) in m {
224 *self.metrics.entry(key).or_insert(0.0) += value;
225 }
226 }
227 }
228
229 pub fn success_rate(&self) -> f64 {
231 if self.invocations == 0 {
232 return 100.0;
233 }
234 (self.successes as f64 / self.invocations as f64) * 100.0
235 }
236}
237
238#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
240pub struct GlobalStats {
241 pub total_invocations: u64,
243
244 pub estimated_tokens_saved: i64,
246
247 pub raw_tokens_total: u64,
249
250 pub tldr_tokens_total: u64,
252
253 pub savings_percent: f64,
255}
256
257#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
259pub struct CacheFileInfo {
260 pub file_count: usize,
262
263 pub total_bytes: u64,
265
266 pub total_size_human: String,
268}
269
270#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
272pub struct AllSessionsSummary {
273 pub active_sessions: usize,
275
276 pub total_raw_tokens: u64,
278
279 pub total_tldr_tokens: u64,
281
282 pub total_requests: u64,
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize)]
292#[serde(tag = "cmd", rename_all = "snake_case")]
293pub enum DaemonCommand {
294 Ping,
296
297 Status {
299 #[serde(skip_serializing_if = "Option::is_none")]
301 session: Option<String>,
302 },
303
304 Shutdown,
306
307 Notify {
309 file: PathBuf,
311 },
312
313 Track {
315 hook: String,
317 #[serde(default = "default_true")]
319 success: bool,
320 #[serde(default)]
322 metrics: HashMap<String, f64>,
323 },
324
325 Warm {
327 #[serde(default)]
329 language: Option<String>,
330 },
331
332 Semantic {
334 query: String,
336 #[serde(default = "default_top_k")]
338 top_k: usize,
339 },
340
341 Search {
344 pattern: String,
345 max_results: Option<usize>,
346 },
347
348 Extract {
350 file: PathBuf,
351 session: Option<String>,
352 },
353
354 Tree { path: Option<PathBuf> },
356
357 Structure {
359 path: PathBuf,
360 #[serde(
364 default,
365 rename = "language",
366 alias = "lang",
367 skip_serializing_if = "Option::is_none"
368 )]
369 lang: Option<String>,
370 },
371
372 Context {
374 entry: String,
375 depth: Option<usize>,
376 #[serde(default, alias = "lang", skip_serializing_if = "Option::is_none")]
379 language: Option<Language>,
380 },
381
382 Cfg { file: PathBuf, function: String },
384
385 Dfg { file: PathBuf, function: String },
387
388 Slice {
390 file: PathBuf,
391 function: String,
392 line: usize,
393 },
394
395 Calls {
397 path: Option<PathBuf>,
398 #[serde(default, alias = "lang", skip_serializing_if = "Option::is_none")]
401 language: Option<Language>,
402 },
403
404 Impact {
406 func: String,
407 depth: Option<usize>,
408 #[serde(default, alias = "lang", skip_serializing_if = "Option::is_none")]
411 language: Option<Language>,
412 },
413
414 Dead {
416 path: Option<PathBuf>,
417 entry: Option<Vec<String>>,
418 #[serde(default, alias = "lang", skip_serializing_if = "Option::is_none")]
421 language: Option<Language>,
422 },
423
424 Arch {
426 path: Option<PathBuf>,
427 #[serde(default, alias = "lang", skip_serializing_if = "Option::is_none")]
430 language: Option<Language>,
431 },
432
433 Imports { file: PathBuf },
435
436 Importers {
438 module: String,
439 path: Option<PathBuf>,
440 #[serde(default, alias = "lang", skip_serializing_if = "Option::is_none")]
443 language: Option<Language>,
444 },
445
446 Diagnostics {
448 path: PathBuf,
449 project: Option<bool>,
450 },
451
452 ChangeImpact {
454 files: Option<Vec<PathBuf>>,
455 session: Option<bool>,
456 git: Option<bool>,
457 #[serde(default, alias = "lang", skip_serializing_if = "Option::is_none")]
460 language: Option<Language>,
461 },
462}
463
464fn default_true() -> bool {
465 true
466}
467
468fn default_top_k() -> usize {
469 10
470}
471
472#[derive(Debug, Clone, Serialize, Deserialize)]
481#[serde(untagged)]
482pub enum DaemonResponse {
483 FullStatus {
485 status: DaemonStatus,
486 uptime: f64,
487 files: usize,
488 project: PathBuf,
489 salsa_stats: SalsaCacheStats,
490 #[serde(skip_serializing_if = "Option::is_none")]
491 dedup_stats: Option<DedupStats>,
492 #[serde(skip_serializing_if = "Option::is_none")]
493 session_stats: Option<SessionStats>,
494 #[serde(skip_serializing_if = "Option::is_none")]
495 all_sessions: Option<AllSessionsSummary>,
496 #[serde(skip_serializing_if = "Option::is_none")]
497 hook_stats: Option<HashMap<String, HookStats>>,
498 },
499
500 NotifyResponse {
502 status: String,
503 dirty_count: usize,
504 threshold: usize,
505 reindex_triggered: bool,
506 },
507
508 TrackResponse {
510 status: String,
511 hook: String,
512 total_invocations: u64,
513 flushed: bool,
514 },
515
516 Error { status: String, error: String },
518
519 Status {
521 status: String,
522 #[serde(skip_serializing_if = "Option::is_none")]
523 message: Option<String>,
524 },
525
526 Result(serde_json::Value),
528}
529
530#[cfg(test)]
531mod tests {
532 use super::*;
533
534 #[test]
535 fn test_daemon_config_default() {
536 let config = DaemonConfig::default();
537
538 assert!(config.semantic_enabled);
539 assert_eq!(config.auto_reindex_threshold, DEFAULT_REINDEX_THRESHOLD);
540 assert_eq!(config.semantic_model, "bge-large-en-v1.5");
541 assert_eq!(config.idle_timeout_secs, IDLE_TIMEOUT_SECS);
542 }
543
544 #[test]
545 fn test_daemon_config_serialize_deserialize() {
546 let config = DaemonConfig::default();
547 let json = serde_json::to_string(&config).unwrap();
548
549 assert!(json.contains("semantic_enabled"));
550 assert!(json.contains("auto_reindex_threshold"));
551 assert!(json.contains("20")); let parsed: DaemonConfig = serde_json::from_str(&json).unwrap();
555 assert_eq!(config, parsed);
556 }
557
558 #[test]
559 fn test_daemon_status_serialization() {
560 let status = DaemonStatus::Ready;
561 let json = serde_json::to_string(&status).unwrap();
562 assert_eq!(json, r#""ready""#);
563
564 let status = DaemonStatus::Initializing;
565 let json = serde_json::to_string(&status).unwrap();
566 assert_eq!(json, r#""initializing""#);
567
568 let status = DaemonStatus::ShuttingDown;
569 let json = serde_json::to_string(&status).unwrap();
570 assert_eq!(json, r#""shutting_down""#);
571 }
572
573 #[test]
574 fn test_salsa_cache_stats_hit_rate_empty() {
575 let stats = SalsaCacheStats::default();
576 assert_eq!(stats.hit_rate(), 0.0);
577 }
578
579 #[test]
580 fn test_salsa_cache_stats_hit_rate_calculation() {
581 let stats = SalsaCacheStats {
582 hits: 90,
583 misses: 10,
584 invalidations: 5,
585 recomputations: 3,
586 };
587 assert!((stats.hit_rate() - 90.0).abs() < 0.01);
588 }
589
590 #[test]
591 fn test_session_stats_savings_calculation() {
592 let stats = SessionStats {
593 session_id: "test123".to_string(),
594 raw_tokens: 1000,
595 tldr_tokens: 100,
596 requests: 10,
597 started_at: None,
598 };
599
600 assert_eq!(stats.savings_tokens(), 900);
601 assert!((stats.savings_percent() - 90.0).abs() < 0.01);
602 }
603
604 #[test]
605 fn test_session_stats_zero_tokens() {
606 let stats = SessionStats {
607 session_id: "empty".to_string(),
608 raw_tokens: 0,
609 tldr_tokens: 0,
610 requests: 0,
611 started_at: None,
612 };
613
614 assert_eq!(stats.savings_tokens(), 0);
615 assert_eq!(stats.savings_percent(), 0.0);
616 }
617
618 #[test]
619 fn test_hook_stats_success_rate() {
620 let mut stats = HookStats::new("test-hook".to_string());
621 stats.record_invocation(true, None);
622 stats.record_invocation(true, None);
623 stats.record_invocation(false, None);
624
625 assert_eq!(stats.invocations, 3);
626 assert_eq!(stats.successes, 2);
627 assert_eq!(stats.failures, 1);
628 assert!((stats.success_rate() - 66.67).abs() < 0.1);
629 }
630
631 #[test]
632 fn test_hook_stats_metrics_accumulation() {
633 let mut stats = HookStats::new("test-hook".to_string());
634
635 let mut metrics = HashMap::new();
636 metrics.insert("errors_found".to_string(), 3.0);
637 stats.record_invocation(true, Some(metrics));
638
639 let mut metrics2 = HashMap::new();
640 metrics2.insert("errors_found".to_string(), 2.0);
641 stats.record_invocation(true, Some(metrics2));
642
643 assert_eq!(*stats.metrics.get("errors_found").unwrap(), 5.0);
644 }
645
646 #[test]
647 fn test_daemon_command_ping_serialization() {
648 let cmd = DaemonCommand::Ping;
649 let json = serde_json::to_string(&cmd).unwrap();
650 assert_eq!(json, r#"{"cmd":"ping"}"#);
651 }
652
653 #[test]
654 fn test_daemon_command_status_serialization() {
655 let cmd = DaemonCommand::Status { session: None };
656 let json = serde_json::to_string(&cmd).unwrap();
657 assert_eq!(json, r#"{"cmd":"status"}"#);
658 }
659
660 #[test]
661 fn test_daemon_command_status_with_session() {
662 let cmd = DaemonCommand::Status {
663 session: Some("abc123".to_string()),
664 };
665 let json = serde_json::to_string(&cmd).unwrap();
666 assert!(json.contains("abc123"));
667 }
668
669 #[test]
670 fn test_daemon_command_notify_serialization() {
671 let cmd = DaemonCommand::Notify {
672 file: PathBuf::from("/path/to/file.rs"),
673 };
674 let json = serde_json::to_string(&cmd).unwrap();
675 assert!(json.contains("notify"));
676 assert!(json.contains("/path/to/file.rs"));
677 }
678
679 #[test]
680 fn test_daemon_command_track_serialization() {
681 let mut metrics = HashMap::new();
682 metrics.insert("errors_found".to_string(), 3.0);
683
684 let cmd = DaemonCommand::Track {
685 hook: "pre-commit".to_string(),
686 success: true,
687 metrics,
688 };
689 let json = serde_json::to_string(&cmd).unwrap();
690
691 assert!(json.contains("track"));
692 assert!(json.contains("pre-commit"));
693 assert!(json.contains("errors_found"));
694 }
695
696 #[test]
697 fn test_daemon_response_status_deserialization() {
698 let json = r#"{"status": "ok", "message": "Daemon started"}"#;
699 let response: DaemonResponse = serde_json::from_str(json).unwrap();
700
701 match response {
702 DaemonResponse::Status { status, message } => {
703 assert_eq!(status, "ok");
704 assert_eq!(message, Some("Daemon started".to_string()));
705 }
706 _ => panic!("Expected Status response"),
707 }
708 }
709
710 #[test]
711 fn test_daemon_response_notify_deserialization() {
712 let json = r#"{
713 "status": "ok",
714 "dirty_count": 5,
715 "threshold": 20,
716 "reindex_triggered": false
717 }"#;
718 let response: DaemonResponse = serde_json::from_str(json).unwrap();
719
720 match response {
721 DaemonResponse::NotifyResponse {
722 dirty_count,
723 threshold,
724 reindex_triggered,
725 ..
726 } => {
727 assert_eq!(dirty_count, 5);
728 assert_eq!(threshold, 20);
729 assert!(!reindex_triggered);
730 }
731 _ => panic!("Expected NotifyResponse"),
732 }
733 }
734
735 #[test]
736 fn test_daemon_response_error_deserialization() {
737 let json = r#"{"status": "error", "error": "Something went wrong"}"#;
738 let response: DaemonResponse = serde_json::from_str(json).unwrap();
739
740 match response {
741 DaemonResponse::Error { status, error } => {
742 assert_eq!(status, "error");
743 assert_eq!(error, "Something went wrong");
744 }
745 _ => panic!("Expected Error response, got {:?}", response),
746 }
747 }
748
749 #[test]
750 fn test_daemon_response_status_only_deserialization() {
751 let json = r#"{"status": "ok"}"#;
752 let response: DaemonResponse = serde_json::from_str(json).unwrap();
753
754 match response {
755 DaemonResponse::Status { status, message } => {
756 assert_eq!(status, "ok");
757 assert_eq!(message, None);
758 }
759 _ => panic!("Expected Status response"),
760 }
761 }
762
763 #[test]
764 fn test_cache_file_info_fields() {
765 let info = CacheFileInfo {
766 file_count: 25,
767 total_bytes: 1048576,
768 total_size_human: "1.0 MB".to_string(),
769 };
770
771 let json = serde_json::to_string(&info).unwrap();
772 assert!(json.contains("file_count"));
773 assert!(json.contains("25"));
774 assert!(json.contains("total_bytes"));
775 assert!(json.contains("1.0 MB"));
776 }
777
778 #[test]
779 fn test_global_stats_fields() {
780 let stats = GlobalStats {
781 total_invocations: 1500,
782 estimated_tokens_saved: 4500000,
783 raw_tokens_total: 5000000,
784 tldr_tokens_total: 500000,
785 savings_percent: 90.0,
786 };
787
788 let json = serde_json::to_string(&stats).unwrap();
789 assert!(json.contains("total_invocations"));
790 assert!(json.contains("estimated_tokens_saved"));
791 assert!(json.contains("savings_percent"));
792 }
793
794 #[test]
795 fn test_all_sessions_summary_fields() {
796 let summary = AllSessionsSummary {
797 active_sessions: 3,
798 total_raw_tokens: 500000,
799 total_tldr_tokens: 50000,
800 total_requests: 200,
801 };
802
803 let json = serde_json::to_string(&summary).unwrap();
804 assert!(json.contains("active_sessions"));
805 assert!(json.contains("total_raw_tokens"));
806 assert!(json.contains("total_requests"));
807 }
808
809 #[test]
810 fn test_dedup_stats_fields() {
811 let stats = DedupStats {
812 unique_hashes: 500,
813 duplicates_avoided: 120,
814 bytes_saved: 1048576,
815 };
816
817 let json = serde_json::to_string(&stats).unwrap();
818 assert!(json.contains("unique_hashes"));
819 assert!(json.contains("duplicates_avoided"));
820 assert!(json.contains("bytes_saved"));
821 }
822}