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