Skip to main content

tldr_cli/commands/daemon/
types.rs

1//! Core types for the TLDR daemon subsystem
2//!
3//! Types for daemon configuration, status, statistics, and IPC messages.
4//! All types are serializable for JSON IPC communication.
5
6use std::collections::HashMap;
7use std::path::PathBuf;
8use std::time::Duration;
9
10use serde::{Deserialize, Serialize};
11
12// =============================================================================
13// Constants
14// =============================================================================
15
16/// Idle timeout before daemon auto-shutdown (30 minutes)
17pub const IDLE_TIMEOUT: Duration = Duration::from_secs(30 * 60);
18
19/// Idle timeout in seconds for serialization
20pub const IDLE_TIMEOUT_SECS: u64 = 30 * 60;
21
22/// Default threshold for triggering semantic re-index
23pub const DEFAULT_REINDEX_THRESHOLD: usize = 20;
24
25/// Default flush interval for hook stats (every N invocations)
26pub const HOOK_FLUSH_THRESHOLD: usize = 5;
27
28// =============================================================================
29// Configuration Types
30// =============================================================================
31
32/// Daemon configuration loaded from .tldr/config.json or .claude/settings.json
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
34pub struct DaemonConfig {
35    /// Whether semantic search is enabled
36    pub semantic_enabled: bool,
37
38    /// Number of dirty files before auto re-index
39    pub auto_reindex_threshold: usize,
40
41    /// Embedding model for semantic search
42    pub semantic_model: String,
43
44    /// Idle timeout in seconds (default: 1800 = 30 min)
45    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// =============================================================================
60// Status Types
61// =============================================================================
62
63/// Daemon runtime status
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "snake_case")]
66pub enum DaemonStatus {
67    /// Daemon is starting up, acquiring locks
68    Initializing,
69    /// Daemon is building initial indexes
70    Indexing,
71    /// Daemon is ready to accept queries
72    Ready,
73    /// Daemon is shutting down
74    ShuttingDown,
75    /// Daemon has stopped
76    Stopped,
77}
78
79// =============================================================================
80// Statistics Types
81// =============================================================================
82
83/// Statistics for Salsa-style query cache
84#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
85pub struct SalsaCacheStats {
86    /// Number of cache hits (query result reused)
87    pub hits: u64,
88
89    /// Number of cache misses (query recomputed)
90    pub misses: u64,
91
92    /// Number of invalidations (file changed)
93    pub invalidations: u64,
94
95    /// Number of recomputations triggered by invalidation
96    pub recomputations: u64,
97}
98
99impl SalsaCacheStats {
100    /// Calculate hit rate as percentage (0-100)
101    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/// Statistics for content-hash deduplication
111#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
112pub struct DedupStats {
113    /// Number of unique content hashes
114    pub unique_hashes: usize,
115
116    /// Number of duplicate content blocks avoided
117    pub duplicates_avoided: usize,
118
119    /// Bytes saved through deduplication
120    pub bytes_saved: u64,
121}
122
123/// Per-session statistics for token tracking
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct SessionStats {
126    /// Session identifier (8-char truncated UUID)
127    pub session_id: String,
128
129    /// Raw tokens (what vanilla Claude would use)
130    pub raw_tokens: u64,
131
132    /// TLDR tokens (what was actually returned)
133    pub tldr_tokens: u64,
134
135    /// Number of requests in this session
136    pub requests: u64,
137
138    /// When session started (ISO 8601 timestamp)
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub started_at: Option<chrono::DateTime<chrono::Utc>>,
141}
142
143impl SessionStats {
144    /// Create a new session with the given ID
145    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    /// Record a request's token usage
156    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    /// Tokens saved
163    pub fn savings_tokens(&self) -> i64 {
164        self.raw_tokens as i64 - self.tldr_tokens as i64
165    }
166
167    /// Savings as percentage (0-100)
168    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/// Per-hook activity statistics
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct HookStats {
179    /// Hook name
180    pub hook_name: String,
181
182    /// Total invocations
183    pub invocations: u64,
184
185    /// Successful invocations
186    pub successes: u64,
187
188    /// Failed invocations
189    pub failures: u64,
190
191    /// Hook-specific metrics (e.g., errors_found, queries_routed)
192    #[serde(default)]
193    pub metrics: HashMap<String, f64>,
194
195    /// When tracking started (ISO 8601 timestamp)
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub started_at: Option<chrono::DateTime<chrono::Utc>>,
198}
199
200impl HookStats {
201    /// Create a new hook stats tracker
202    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    /// Record a hook invocation
214    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    /// Success rate as percentage (0-100)
229    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/// Aggregated global stats (from JSONL store)
238#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
239pub struct GlobalStats {
240    /// Total number of invocations across all sessions
241    pub total_invocations: u64,
242
243    /// Estimated tokens saved across all sessions
244    pub estimated_tokens_saved: i64,
245
246    /// Total raw tokens processed
247    pub raw_tokens_total: u64,
248
249    /// Total TLDR tokens returned
250    pub tldr_tokens_total: u64,
251
252    /// Savings percentage (0-100)
253    pub savings_percent: f64,
254}
255
256/// Cache file info for cache stats
257#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
258pub struct CacheFileInfo {
259    /// Number of cache files
260    pub file_count: usize,
261
262    /// Total size in bytes
263    pub total_bytes: u64,
264
265    /// Size formatted as human-readable
266    pub total_size_human: String,
267}
268
269/// Summary of all active sessions
270#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
271pub struct AllSessionsSummary {
272    /// Number of active sessions
273    pub active_sessions: usize,
274
275    /// Total raw tokens across all sessions
276    pub total_raw_tokens: u64,
277
278    /// Total TLDR tokens across all sessions
279    pub total_tldr_tokens: u64,
280
281    /// Total requests across all sessions
282    pub total_requests: u64,
283}
284
285// =============================================================================
286// IPC Message Types
287// =============================================================================
288
289/// Command sent to daemon via socket
290#[derive(Debug, Clone, Serialize, Deserialize)]
291#[serde(tag = "cmd", rename_all = "snake_case")]
292pub enum DaemonCommand {
293    /// Health check
294    Ping,
295
296    /// Get daemon status
297    Status {
298        /// Optional session ID to get session-specific stats
299        #[serde(skip_serializing_if = "Option::is_none")]
300        session: Option<String>,
301    },
302
303    /// Graceful shutdown
304    Shutdown,
305
306    /// File change notification
307    Notify {
308        /// Path to the changed file
309        file: PathBuf,
310    },
311
312    /// Track hook activity
313    Track {
314        /// Hook name
315        hook: String,
316        /// Whether invocation was successful
317        #[serde(default = "default_true")]
318        success: bool,
319        /// Hook-specific metrics
320        #[serde(default)]
321        metrics: HashMap<String, f64>,
322    },
323
324    /// Warm call graph cache
325    Warm {
326        /// Optional language filter
327        #[serde(default)]
328        language: Option<String>,
329    },
330
331    /// Semantic search (if model loaded)
332    Semantic {
333        /// Search query
334        query: String,
335        /// Number of results to return
336        #[serde(default = "default_top_k")]
337        top_k: usize,
338    },
339
340    // Pass-through analysis commands
341    /// Search for patterns in files
342    Search {
343        pattern: String,
344        max_results: Option<usize>,
345    },
346
347    /// Extract file information
348    Extract {
349        file: PathBuf,
350        session: Option<String>,
351    },
352
353    /// Get file tree
354    Tree { path: Option<PathBuf> },
355
356    /// Get code structure
357    Structure { path: PathBuf, lang: Option<String> },
358
359    /// Get context for entry point
360    Context { entry: String, depth: Option<usize> },
361
362    /// Get control flow graph
363    Cfg { file: PathBuf, function: String },
364
365    /// Get data flow graph
366    Dfg { file: PathBuf, function: String },
367
368    /// Get program slice
369    Slice {
370        file: PathBuf,
371        function: String,
372        line: usize,
373    },
374
375    /// Get call graph
376    Calls { path: Option<PathBuf> },
377
378    /// Get impact analysis
379    Impact { func: String, depth: Option<usize> },
380
381    /// Find dead code
382    Dead {
383        path: Option<PathBuf>,
384        entry: Option<Vec<String>>,
385    },
386
387    /// Get architecture analysis
388    Arch { path: Option<PathBuf> },
389
390    /// Get imports for a file
391    Imports { file: PathBuf },
392
393    /// Find files that import a module
394    Importers {
395        module: String,
396        path: Option<PathBuf>,
397    },
398
399    /// Run diagnostics
400    Diagnostics {
401        path: PathBuf,
402        project: Option<bool>,
403    },
404
405    /// Analyze change impact
406    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/// Response from daemon
422///
423/// IMPORTANT: Variant order matters for serde(untagged)!
424/// Variants are tried in declaration order, so more specific variants
425/// (with more required fields) must come BEFORE less specific ones.
426///
427/// Key design: Error uses "error" field, Status uses "message" field.
428/// This makes them structurally distinguishable for serde untagged.
429#[derive(Debug, Clone, Serialize, Deserialize)]
430#[serde(untagged)]
431pub enum DaemonResponse {
432    /// Full status response (5 required fields including typed enum status)
433    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    /// Notify response (4 required fields)
450    NotifyResponse {
451        status: String,
452        dirty_count: usize,
453        threshold: usize,
454        reindex_triggered: bool,
455    },
456
457    /// Track response
458    TrackResponse {
459        status: String,
460        hook: String,
461        total_invocations: u64,
462        flushed: bool,
463    },
464
465    /// Error response (uses "error" field to distinguish from Status)
466    Error { status: String, error: String },
467
468    /// Simple status response (catch-all with only 1 required field)
469    Status {
470        status: String,
471        #[serde(skip_serializing_if = "Option::is_none")]
472        message: Option<String>,
473    },
474
475    /// Generic JSON result (for analysis commands) - MUST be last (catch-all)
476    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")); // DEFAULT_REINDEX_THRESHOLD
501
502        // Deserialize back
503        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}