1use std::collections::HashMap;
7use std::path::PathBuf;
8use std::time::Duration;
9
10use serde::{Deserialize, Serialize};
11
12pub const IDLE_TIMEOUT: Duration = Duration::from_secs(30 * 60);
18
19pub const IDLE_TIMEOUT_SECS: u64 = 30 * 60;
21
22pub const DEFAULT_REINDEX_THRESHOLD: usize = 20;
24
25pub const HOOK_FLUSH_THRESHOLD: usize = 5;
27
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
34pub struct DaemonConfig {
35 pub semantic_enabled: bool,
37
38 pub auto_reindex_threshold: usize,
40
41 pub semantic_model: String,
43
44 pub idle_timeout_secs: u64,
46}
47
48impl Default for DaemonConfig {
49 fn default() -> Self {
50 Self {
51 semantic_enabled: true,
52 auto_reindex_threshold: DEFAULT_REINDEX_THRESHOLD,
53 semantic_model: "bge-large-en-v1.5".to_string(),
54 idle_timeout_secs: IDLE_TIMEOUT_SECS,
55 }
56 }
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "snake_case")]
66pub enum DaemonStatus {
67 Initializing,
69 Indexing,
71 Ready,
73 ShuttingDown,
75 Stopped,
77}
78
79#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
85pub struct SalsaCacheStats {
86 pub hits: u64,
88
89 pub misses: u64,
91
92 pub invalidations: u64,
94
95 pub recomputations: u64,
97}
98
99impl SalsaCacheStats {
100 pub fn hit_rate(&self) -> f64 {
102 let total = self.hits + self.misses;
103 if total == 0 {
104 return 0.0;
105 }
106 (self.hits as f64 / total as f64) * 100.0
107 }
108}
109
110#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
112pub struct DedupStats {
113 pub unique_hashes: usize,
115
116 pub duplicates_avoided: usize,
118
119 pub bytes_saved: u64,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct SessionStats {
126 pub session_id: String,
128
129 pub raw_tokens: u64,
131
132 pub tldr_tokens: u64,
134
135 pub requests: u64,
137
138 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub started_at: Option<chrono::DateTime<chrono::Utc>>,
141}
142
143impl SessionStats {
144 pub fn new(session_id: String) -> Self {
146 Self {
147 session_id,
148 raw_tokens: 0,
149 tldr_tokens: 0,
150 requests: 0,
151 started_at: Some(chrono::Utc::now()),
152 }
153 }
154
155 pub fn record_request(&mut self, raw_tokens: u64, tldr_tokens: u64) {
157 self.raw_tokens += raw_tokens;
158 self.tldr_tokens += tldr_tokens;
159 self.requests += 1;
160 }
161
162 pub fn savings_tokens(&self) -> i64 {
164 self.raw_tokens as i64 - self.tldr_tokens as i64
165 }
166
167 pub fn savings_percent(&self) -> f64 {
169 if self.raw_tokens == 0 {
170 return 0.0;
171 }
172 (self.savings_tokens() as f64 / self.raw_tokens as f64) * 100.0
173 }
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct HookStats {
179 pub hook_name: String,
181
182 pub invocations: u64,
184
185 pub successes: u64,
187
188 pub failures: u64,
190
191 #[serde(default)]
193 pub metrics: HashMap<String, f64>,
194
195 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub started_at: Option<chrono::DateTime<chrono::Utc>>,
198}
199
200impl HookStats {
201 pub fn new(hook_name: String) -> Self {
203 Self {
204 hook_name,
205 invocations: 0,
206 successes: 0,
207 failures: 0,
208 metrics: HashMap::new(),
209 started_at: Some(chrono::Utc::now()),
210 }
211 }
212
213 pub fn record_invocation(&mut self, success: bool, metrics: Option<HashMap<String, f64>>) {
215 self.invocations += 1;
216 if success {
217 self.successes += 1;
218 } else {
219 self.failures += 1;
220 }
221 if let Some(m) = metrics {
222 for (key, value) in m {
223 *self.metrics.entry(key).or_insert(0.0) += value;
224 }
225 }
226 }
227
228 pub fn success_rate(&self) -> f64 {
230 if self.invocations == 0 {
231 return 100.0;
232 }
233 (self.successes as f64 / self.invocations as f64) * 100.0
234 }
235}
236
237#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
239pub struct GlobalStats {
240 pub total_invocations: u64,
242
243 pub estimated_tokens_saved: i64,
245
246 pub raw_tokens_total: u64,
248
249 pub tldr_tokens_total: u64,
251
252 pub savings_percent: f64,
254}
255
256#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
258pub struct CacheFileInfo {
259 pub file_count: usize,
261
262 pub total_bytes: u64,
264
265 pub total_size_human: String,
267}
268
269#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
271pub struct AllSessionsSummary {
272 pub active_sessions: usize,
274
275 pub total_raw_tokens: u64,
277
278 pub total_tldr_tokens: u64,
280
281 pub total_requests: u64,
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize)]
291#[serde(tag = "cmd", rename_all = "snake_case")]
292pub enum DaemonCommand {
293 Ping,
295
296 Status {
298 #[serde(skip_serializing_if = "Option::is_none")]
300 session: Option<String>,
301 },
302
303 Shutdown,
305
306 Notify {
308 file: PathBuf,
310 },
311
312 Track {
314 hook: String,
316 #[serde(default = "default_true")]
318 success: bool,
319 #[serde(default)]
321 metrics: HashMap<String, f64>,
322 },
323
324 Warm {
326 #[serde(default)]
328 language: Option<String>,
329 },
330
331 Semantic {
333 query: String,
335 #[serde(default = "default_top_k")]
337 top_k: usize,
338 },
339
340 Search {
343 pattern: String,
344 max_results: Option<usize>,
345 },
346
347 Extract {
349 file: PathBuf,
350 session: Option<String>,
351 },
352
353 Tree { path: Option<PathBuf> },
355
356 Structure { path: PathBuf, lang: Option<String> },
358
359 Context { entry: String, depth: Option<usize> },
361
362 Cfg { file: PathBuf, function: String },
364
365 Dfg { file: PathBuf, function: String },
367
368 Slice {
370 file: PathBuf,
371 function: String,
372 line: usize,
373 },
374
375 Calls { path: Option<PathBuf> },
377
378 Impact { func: String, depth: Option<usize> },
380
381 Dead {
383 path: Option<PathBuf>,
384 entry: Option<Vec<String>>,
385 },
386
387 Arch { path: Option<PathBuf> },
389
390 Imports { file: PathBuf },
392
393 Importers {
395 module: String,
396 path: Option<PathBuf>,
397 },
398
399 Diagnostics {
401 path: PathBuf,
402 project: Option<bool>,
403 },
404
405 ChangeImpact {
407 files: Option<Vec<PathBuf>>,
408 session: Option<bool>,
409 git: Option<bool>,
410 },
411}
412
413fn default_true() -> bool {
414 true
415}
416
417fn default_top_k() -> usize {
418 10
419}
420
421#[derive(Debug, Clone, Serialize, Deserialize)]
430#[serde(untagged)]
431pub enum DaemonResponse {
432 FullStatus {
434 status: DaemonStatus,
435 uptime: f64,
436 files: usize,
437 project: PathBuf,
438 salsa_stats: SalsaCacheStats,
439 #[serde(skip_serializing_if = "Option::is_none")]
440 dedup_stats: Option<DedupStats>,
441 #[serde(skip_serializing_if = "Option::is_none")]
442 session_stats: Option<SessionStats>,
443 #[serde(skip_serializing_if = "Option::is_none")]
444 all_sessions: Option<AllSessionsSummary>,
445 #[serde(skip_serializing_if = "Option::is_none")]
446 hook_stats: Option<HashMap<String, HookStats>>,
447 },
448
449 NotifyResponse {
451 status: String,
452 dirty_count: usize,
453 threshold: usize,
454 reindex_triggered: bool,
455 },
456
457 TrackResponse {
459 status: String,
460 hook: String,
461 total_invocations: u64,
462 flushed: bool,
463 },
464
465 Error { status: String, error: String },
467
468 Status {
470 status: String,
471 #[serde(skip_serializing_if = "Option::is_none")]
472 message: Option<String>,
473 },
474
475 Result(serde_json::Value),
477}
478
479#[cfg(test)]
480mod tests {
481 use super::*;
482
483 #[test]
484 fn test_daemon_config_default() {
485 let config = DaemonConfig::default();
486
487 assert!(config.semantic_enabled);
488 assert_eq!(config.auto_reindex_threshold, DEFAULT_REINDEX_THRESHOLD);
489 assert_eq!(config.semantic_model, "bge-large-en-v1.5");
490 assert_eq!(config.idle_timeout_secs, IDLE_TIMEOUT_SECS);
491 }
492
493 #[test]
494 fn test_daemon_config_serialize_deserialize() {
495 let config = DaemonConfig::default();
496 let json = serde_json::to_string(&config).unwrap();
497
498 assert!(json.contains("semantic_enabled"));
499 assert!(json.contains("auto_reindex_threshold"));
500 assert!(json.contains("20")); let parsed: DaemonConfig = serde_json::from_str(&json).unwrap();
504 assert_eq!(config, parsed);
505 }
506
507 #[test]
508 fn test_daemon_status_serialization() {
509 let status = DaemonStatus::Ready;
510 let json = serde_json::to_string(&status).unwrap();
511 assert_eq!(json, r#""ready""#);
512
513 let status = DaemonStatus::Initializing;
514 let json = serde_json::to_string(&status).unwrap();
515 assert_eq!(json, r#""initializing""#);
516
517 let status = DaemonStatus::ShuttingDown;
518 let json = serde_json::to_string(&status).unwrap();
519 assert_eq!(json, r#""shutting_down""#);
520 }
521
522 #[test]
523 fn test_salsa_cache_stats_hit_rate_empty() {
524 let stats = SalsaCacheStats::default();
525 assert_eq!(stats.hit_rate(), 0.0);
526 }
527
528 #[test]
529 fn test_salsa_cache_stats_hit_rate_calculation() {
530 let stats = SalsaCacheStats {
531 hits: 90,
532 misses: 10,
533 invalidations: 5,
534 recomputations: 3,
535 };
536 assert!((stats.hit_rate() - 90.0).abs() < 0.01);
537 }
538
539 #[test]
540 fn test_session_stats_savings_calculation() {
541 let stats = SessionStats {
542 session_id: "test123".to_string(),
543 raw_tokens: 1000,
544 tldr_tokens: 100,
545 requests: 10,
546 started_at: None,
547 };
548
549 assert_eq!(stats.savings_tokens(), 900);
550 assert!((stats.savings_percent() - 90.0).abs() < 0.01);
551 }
552
553 #[test]
554 fn test_session_stats_zero_tokens() {
555 let stats = SessionStats {
556 session_id: "empty".to_string(),
557 raw_tokens: 0,
558 tldr_tokens: 0,
559 requests: 0,
560 started_at: None,
561 };
562
563 assert_eq!(stats.savings_tokens(), 0);
564 assert_eq!(stats.savings_percent(), 0.0);
565 }
566
567 #[test]
568 fn test_hook_stats_success_rate() {
569 let mut stats = HookStats::new("test-hook".to_string());
570 stats.record_invocation(true, None);
571 stats.record_invocation(true, None);
572 stats.record_invocation(false, None);
573
574 assert_eq!(stats.invocations, 3);
575 assert_eq!(stats.successes, 2);
576 assert_eq!(stats.failures, 1);
577 assert!((stats.success_rate() - 66.67).abs() < 0.1);
578 }
579
580 #[test]
581 fn test_hook_stats_metrics_accumulation() {
582 let mut stats = HookStats::new("test-hook".to_string());
583
584 let mut metrics = HashMap::new();
585 metrics.insert("errors_found".to_string(), 3.0);
586 stats.record_invocation(true, Some(metrics));
587
588 let mut metrics2 = HashMap::new();
589 metrics2.insert("errors_found".to_string(), 2.0);
590 stats.record_invocation(true, Some(metrics2));
591
592 assert_eq!(*stats.metrics.get("errors_found").unwrap(), 5.0);
593 }
594
595 #[test]
596 fn test_daemon_command_ping_serialization() {
597 let cmd = DaemonCommand::Ping;
598 let json = serde_json::to_string(&cmd).unwrap();
599 assert_eq!(json, r#"{"cmd":"ping"}"#);
600 }
601
602 #[test]
603 fn test_daemon_command_status_serialization() {
604 let cmd = DaemonCommand::Status { session: None };
605 let json = serde_json::to_string(&cmd).unwrap();
606 assert_eq!(json, r#"{"cmd":"status"}"#);
607 }
608
609 #[test]
610 fn test_daemon_command_status_with_session() {
611 let cmd = DaemonCommand::Status {
612 session: Some("abc123".to_string()),
613 };
614 let json = serde_json::to_string(&cmd).unwrap();
615 assert!(json.contains("abc123"));
616 }
617
618 #[test]
619 fn test_daemon_command_notify_serialization() {
620 let cmd = DaemonCommand::Notify {
621 file: PathBuf::from("/path/to/file.rs"),
622 };
623 let json = serde_json::to_string(&cmd).unwrap();
624 assert!(json.contains("notify"));
625 assert!(json.contains("/path/to/file.rs"));
626 }
627
628 #[test]
629 fn test_daemon_command_track_serialization() {
630 let mut metrics = HashMap::new();
631 metrics.insert("errors_found".to_string(), 3.0);
632
633 let cmd = DaemonCommand::Track {
634 hook: "pre-commit".to_string(),
635 success: true,
636 metrics,
637 };
638 let json = serde_json::to_string(&cmd).unwrap();
639
640 assert!(json.contains("track"));
641 assert!(json.contains("pre-commit"));
642 assert!(json.contains("errors_found"));
643 }
644
645 #[test]
646 fn test_daemon_response_status_deserialization() {
647 let json = r#"{"status": "ok", "message": "Daemon started"}"#;
648 let response: DaemonResponse = serde_json::from_str(json).unwrap();
649
650 match response {
651 DaemonResponse::Status { status, message } => {
652 assert_eq!(status, "ok");
653 assert_eq!(message, Some("Daemon started".to_string()));
654 }
655 _ => panic!("Expected Status response"),
656 }
657 }
658
659 #[test]
660 fn test_daemon_response_notify_deserialization() {
661 let json = r#"{
662 "status": "ok",
663 "dirty_count": 5,
664 "threshold": 20,
665 "reindex_triggered": false
666 }"#;
667 let response: DaemonResponse = serde_json::from_str(json).unwrap();
668
669 match response {
670 DaemonResponse::NotifyResponse {
671 dirty_count,
672 threshold,
673 reindex_triggered,
674 ..
675 } => {
676 assert_eq!(dirty_count, 5);
677 assert_eq!(threshold, 20);
678 assert!(!reindex_triggered);
679 }
680 _ => panic!("Expected NotifyResponse"),
681 }
682 }
683
684 #[test]
685 fn test_daemon_response_error_deserialization() {
686 let json = r#"{"status": "error", "error": "Something went wrong"}"#;
687 let response: DaemonResponse = serde_json::from_str(json).unwrap();
688
689 match response {
690 DaemonResponse::Error { status, error } => {
691 assert_eq!(status, "error");
692 assert_eq!(error, "Something went wrong");
693 }
694 _ => panic!("Expected Error response, got {:?}", response),
695 }
696 }
697
698 #[test]
699 fn test_daemon_response_status_only_deserialization() {
700 let json = r#"{"status": "ok"}"#;
701 let response: DaemonResponse = serde_json::from_str(json).unwrap();
702
703 match response {
704 DaemonResponse::Status { status, message } => {
705 assert_eq!(status, "ok");
706 assert_eq!(message, None);
707 }
708 _ => panic!("Expected Status response"),
709 }
710 }
711
712 #[test]
713 fn test_cache_file_info_fields() {
714 let info = CacheFileInfo {
715 file_count: 25,
716 total_bytes: 1048576,
717 total_size_human: "1.0 MB".to_string(),
718 };
719
720 let json = serde_json::to_string(&info).unwrap();
721 assert!(json.contains("file_count"));
722 assert!(json.contains("25"));
723 assert!(json.contains("total_bytes"));
724 assert!(json.contains("1.0 MB"));
725 }
726
727 #[test]
728 fn test_global_stats_fields() {
729 let stats = GlobalStats {
730 total_invocations: 1500,
731 estimated_tokens_saved: 4500000,
732 raw_tokens_total: 5000000,
733 tldr_tokens_total: 500000,
734 savings_percent: 90.0,
735 };
736
737 let json = serde_json::to_string(&stats).unwrap();
738 assert!(json.contains("total_invocations"));
739 assert!(json.contains("estimated_tokens_saved"));
740 assert!(json.contains("savings_percent"));
741 }
742
743 #[test]
744 fn test_all_sessions_summary_fields() {
745 let summary = AllSessionsSummary {
746 active_sessions: 3,
747 total_raw_tokens: 500000,
748 total_tldr_tokens: 50000,
749 total_requests: 200,
750 };
751
752 let json = serde_json::to_string(&summary).unwrap();
753 assert!(json.contains("active_sessions"));
754 assert!(json.contains("total_raw_tokens"));
755 assert!(json.contains("total_requests"));
756 }
757
758 #[test]
759 fn test_dedup_stats_fields() {
760 let stats = DedupStats {
761 unique_hashes: 500,
762 duplicates_avoided: 120,
763 bytes_saved: 1048576,
764 };
765
766 let json = serde_json::to_string(&stats).unwrap();
767 assert!(json.contains("unique_hashes"));
768 assert!(json.contains("duplicates_avoided"));
769 assert!(json.contains("bytes_saved"));
770 }
771}