1use crate::models::{Area, Project, Task};
4use anyhow::Result;
5use chrono::{DateTime, Utc};
6use moka::future::Cache;
7use parking_lot::RwLock;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::sync::Arc;
11use std::time::Duration;
12use uuid::Uuid;
13
14#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum InvalidationStrategy {
17 TimeBased,
19 EventBased,
21 DependencyBased,
23 Hybrid,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct CacheDependency {
30 pub entity_type: String,
32 pub entity_id: Option<Uuid>,
34 pub invalidating_operations: Vec<String>,
36}
37
38#[derive(Debug, Clone)]
40pub struct CacheConfig {
41 pub max_capacity: u64,
43 pub ttl: Duration,
45 pub tti: Duration,
47 pub invalidation_strategy: InvalidationStrategy,
49 pub enable_cache_warming: bool,
51 pub warming_interval: Duration,
53 pub max_warming_entries: usize,
55}
56
57impl Default for CacheConfig {
58 fn default() -> Self {
59 Self {
60 max_capacity: 1000,
61 ttl: Duration::from_secs(300), tti: Duration::from_secs(60), invalidation_strategy: InvalidationStrategy::Hybrid,
64 enable_cache_warming: true,
65 warming_interval: Duration::from_secs(60), max_warming_entries: 50,
67 }
68 }
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct CachedData<T> {
74 pub data: T,
75 pub cached_at: DateTime<Utc>,
76 pub expires_at: DateTime<Utc>,
77 pub dependencies: Vec<CacheDependency>,
79 pub access_count: u64,
81 pub last_accessed: DateTime<Utc>,
83 pub warming_priority: u32,
85}
86
87impl<T> CachedData<T> {
88 pub fn new(data: T, ttl: Duration) -> Self {
89 let now = Utc::now();
90 Self {
91 data,
92 cached_at: now,
93 expires_at: now + chrono::Duration::from_std(ttl).unwrap_or_default(),
94 dependencies: Vec::new(),
95 access_count: 0,
96 last_accessed: now,
97 warming_priority: 0,
98 }
99 }
100
101 pub fn new_with_dependencies(
102 data: T,
103 ttl: Duration,
104 dependencies: Vec<CacheDependency>,
105 ) -> Self {
106 let now = Utc::now();
107 Self {
108 data,
109 cached_at: now,
110 expires_at: now + chrono::Duration::from_std(ttl).unwrap_or_default(),
111 dependencies,
112 access_count: 0,
113 last_accessed: now,
114 warming_priority: 0,
115 }
116 }
117
118 pub fn is_expired(&self) -> bool {
119 Utc::now() > self.expires_at
120 }
121
122 pub fn is_idle(&self, tti: Duration) -> bool {
123 let now = Utc::now();
124 let idle_duration = now - self.last_accessed;
125 idle_duration > chrono::Duration::from_std(tti).unwrap_or_default()
126 }
127
128 pub fn record_access(&mut self) {
129 self.access_count += 1;
130 self.last_accessed = Utc::now();
131 }
132
133 pub fn update_warming_priority(&mut self, priority: u32) {
134 self.warming_priority = priority;
135 }
136
137 pub fn add_dependency(&mut self, dependency: CacheDependency) {
138 self.dependencies.push(dependency);
139 }
140
141 pub fn has_dependency(&self, entity_type: &str, entity_id: Option<&Uuid>) -> bool {
142 self.dependencies.iter().any(|dep| {
143 dep.entity_type == entity_type
144 && entity_id.is_none_or(|id| dep.entity_id.as_ref() == Some(id))
145 })
146 }
147}
148
149#[derive(Debug, Clone, Default, Serialize, Deserialize)]
151pub struct CacheStats {
152 pub hits: u64,
153 pub misses: u64,
154 pub entries: u64,
155 pub hit_rate: f64,
156}
157
158impl CacheStats {
159 pub fn calculate_hit_rate(&mut self) {
160 let total = self.hits + self.misses;
161 self.hit_rate = if total > 0 {
162 #[allow(clippy::cast_precision_loss)]
163 {
164 self.hits as f64 / total as f64
165 }
166 } else {
167 0.0
168 };
169 }
170}
171
172pub struct ThingsCache {
174 tasks: Cache<String, CachedData<Vec<Task>>>,
176 projects: Cache<String, CachedData<Vec<Project>>>,
178 areas: Cache<String, CachedData<Vec<Area>>>,
180 search_results: Cache<String, CachedData<Vec<Task>>>,
182 stats: Arc<RwLock<CacheStats>>,
184 config: CacheConfig,
186 warming_entries: Arc<RwLock<HashMap<String, u32>>>,
188 warming_task: Option<tokio::task::JoinHandle<()>>,
190}
191
192impl ThingsCache {
193 #[must_use]
195 pub fn new(config: &CacheConfig) -> Self {
196 let tasks = Cache::builder()
197 .max_capacity(config.max_capacity)
198 .time_to_live(config.ttl)
199 .time_to_idle(config.tti)
200 .build();
201
202 let projects = Cache::builder()
203 .max_capacity(config.max_capacity)
204 .time_to_live(config.ttl)
205 .time_to_idle(config.tti)
206 .build();
207
208 let areas = Cache::builder()
209 .max_capacity(config.max_capacity)
210 .time_to_live(config.ttl)
211 .time_to_idle(config.tti)
212 .build();
213
214 let search_results = Cache::builder()
215 .max_capacity(config.max_capacity)
216 .time_to_live(config.ttl)
217 .time_to_idle(config.tti)
218 .build();
219
220 let mut cache = Self {
221 tasks,
222 projects,
223 areas,
224 search_results,
225 stats: Arc::new(RwLock::new(CacheStats::default())),
226 config: config.clone(),
227 warming_entries: Arc::new(RwLock::new(HashMap::new())),
228 warming_task: None,
229 };
230
231 if config.enable_cache_warming {
233 cache.start_cache_warming();
234 }
235
236 cache
237 }
238
239 #[must_use]
241 pub fn new_default() -> Self {
242 Self::new(&CacheConfig::default())
243 }
244
245 pub async fn get_tasks<F, Fut>(&self, key: &str, fetcher: F) -> Result<Vec<Task>>
252 where
253 F: FnOnce() -> Fut,
254 Fut: std::future::Future<Output = Result<Vec<Task>>>,
255 {
256 if let Some(mut cached) = self.tasks.get(key).await {
257 if !cached.is_expired() && !cached.is_idle(self.config.tti) {
258 cached.record_access();
259 self.record_hit();
260
261 if cached.access_count > 3 {
263 self.add_to_warming(key.to_string(), cached.warming_priority + 1);
264 }
265
266 return Ok(cached.data);
267 }
268 }
269
270 self.record_miss();
271 let data = fetcher().await?;
272
273 let dependencies = Self::create_task_dependencies(&data);
275 let mut cached_data =
276 CachedData::new_with_dependencies(data.clone(), self.config.ttl, dependencies);
277
278 let priority = if key.starts_with("inbox:") {
280 10
281 } else if key.starts_with("today:") {
282 8
283 } else {
284 5
285 };
286 cached_data.update_warming_priority(priority);
287
288 self.tasks.insert(key.to_string(), cached_data).await;
289 Ok(data)
290 }
291
292 pub async fn get_projects<F, Fut>(&self, key: &str, fetcher: F) -> Result<Vec<Project>>
299 where
300 F: FnOnce() -> Fut,
301 Fut: std::future::Future<Output = Result<Vec<Project>>>,
302 {
303 if let Some(mut cached) = self.projects.get(key).await {
304 if !cached.is_expired() && !cached.is_idle(self.config.tti) {
305 cached.record_access();
306 self.record_hit();
307
308 if cached.access_count > 3 {
310 self.add_to_warming(key.to_string(), cached.warming_priority + 1);
311 }
312
313 return Ok(cached.data);
314 }
315 }
316
317 self.record_miss();
318 let data = fetcher().await?;
319
320 let dependencies = Self::create_project_dependencies(&data);
322 let mut cached_data =
323 CachedData::new_with_dependencies(data.clone(), self.config.ttl, dependencies);
324
325 let priority = if key.starts_with("projects:") { 7 } else { 5 };
327 cached_data.update_warming_priority(priority);
328
329 self.projects.insert(key.to_string(), cached_data).await;
330 Ok(data)
331 }
332
333 pub async fn get_areas<F, Fut>(&self, key: &str, fetcher: F) -> Result<Vec<Area>>
340 where
341 F: FnOnce() -> Fut,
342 Fut: std::future::Future<Output = Result<Vec<Area>>>,
343 {
344 if let Some(mut cached) = self.areas.get(key).await {
345 if !cached.is_expired() && !cached.is_idle(self.config.tti) {
346 cached.record_access();
347 self.record_hit();
348
349 if cached.access_count > 3 {
351 self.add_to_warming(key.to_string(), cached.warming_priority + 1);
352 }
353
354 return Ok(cached.data);
355 }
356 }
357
358 self.record_miss();
359 let data = fetcher().await?;
360
361 let dependencies = Self::create_area_dependencies(&data);
363 let mut cached_data =
364 CachedData::new_with_dependencies(data.clone(), self.config.ttl, dependencies);
365
366 let priority = if key.starts_with("areas:") { 6 } else { 5 };
368 cached_data.update_warming_priority(priority);
369
370 self.areas.insert(key.to_string(), cached_data).await;
371 Ok(data)
372 }
373
374 pub async fn get_search_results<F, Fut>(&self, key: &str, fetcher: F) -> Result<Vec<Task>>
381 where
382 F: FnOnce() -> Fut,
383 Fut: std::future::Future<Output = Result<Vec<Task>>>,
384 {
385 if let Some(mut cached) = self.search_results.get(key).await {
386 if !cached.is_expired() && !cached.is_idle(self.config.tti) {
387 cached.record_access();
388 self.record_hit();
389
390 if cached.access_count > 3 {
392 self.add_to_warming(key.to_string(), cached.warming_priority + 1);
393 }
394
395 return Ok(cached.data);
396 }
397 }
398
399 self.record_miss();
400 let data = fetcher().await?;
401
402 let dependencies = Self::create_task_dependencies(&data);
404 let mut cached_data =
405 CachedData::new_with_dependencies(data.clone(), self.config.ttl, dependencies);
406
407 let priority = if key.starts_with("search:") { 4 } else { 3 };
409 cached_data.update_warming_priority(priority);
410
411 self.search_results
412 .insert(key.to_string(), cached_data)
413 .await;
414 Ok(data)
415 }
416
417 pub fn invalidate_all(&self) {
419 self.tasks.invalidate_all();
420 self.projects.invalidate_all();
421 self.areas.invalidate_all();
422 self.search_results.invalidate_all();
423 }
424
425 pub async fn invalidate(&self, key: &str) {
427 self.tasks.remove(key).await;
428 self.projects.remove(key).await;
429 self.areas.remove(key).await;
430 self.search_results.remove(key).await;
431 }
432
433 #[must_use]
435 pub fn get_stats(&self) -> CacheStats {
436 let mut stats = self.stats.read().clone();
437 stats.entries = self.tasks.entry_count()
438 + self.projects.entry_count()
439 + self.areas.entry_count()
440 + self.search_results.entry_count();
441 stats.calculate_hit_rate();
442 stats
443 }
444
445 pub fn reset_stats(&self) {
447 let mut stats = self.stats.write();
448 *stats = CacheStats::default();
449 }
450
451 fn record_hit(&self) {
453 let mut stats = self.stats.write();
454 stats.hits += 1;
455 }
456
457 fn record_miss(&self) {
459 let mut stats = self.stats.write();
460 stats.misses += 1;
461 }
462
463 fn create_task_dependencies(tasks: &[Task]) -> Vec<CacheDependency> {
465 let mut dependencies = Vec::new();
466
467 for task in tasks {
469 dependencies.push(CacheDependency {
470 entity_type: "task".to_string(),
471 entity_id: Some(task.uuid),
472 invalidating_operations: vec![
473 "task_updated".to_string(),
474 "task_deleted".to_string(),
475 "task_completed".to_string(),
476 ],
477 });
478
479 if let Some(project_uuid) = task.project_uuid {
481 dependencies.push(CacheDependency {
482 entity_type: "project".to_string(),
483 entity_id: Some(project_uuid),
484 invalidating_operations: vec![
485 "project_updated".to_string(),
486 "project_deleted".to_string(),
487 ],
488 });
489 }
490
491 if let Some(area_uuid) = task.area_uuid {
493 dependencies.push(CacheDependency {
494 entity_type: "area".to_string(),
495 entity_id: Some(area_uuid),
496 invalidating_operations: vec![
497 "area_updated".to_string(),
498 "area_deleted".to_string(),
499 ],
500 });
501 }
502 }
503
504 dependencies
505 }
506
507 fn create_project_dependencies(projects: &[Project]) -> Vec<CacheDependency> {
509 let mut dependencies = Vec::new();
510
511 for project in projects {
512 dependencies.push(CacheDependency {
513 entity_type: "project".to_string(),
514 entity_id: Some(project.uuid),
515 invalidating_operations: vec![
516 "project_updated".to_string(),
517 "project_deleted".to_string(),
518 ],
519 });
520
521 if let Some(area_uuid) = project.area_uuid {
522 dependencies.push(CacheDependency {
523 entity_type: "area".to_string(),
524 entity_id: Some(area_uuid),
525 invalidating_operations: vec![
526 "area_updated".to_string(),
527 "area_deleted".to_string(),
528 ],
529 });
530 }
531 }
532
533 dependencies
534 }
535
536 fn create_area_dependencies(areas: &[Area]) -> Vec<CacheDependency> {
538 let mut dependencies = Vec::new();
539
540 for area in areas {
541 dependencies.push(CacheDependency {
542 entity_type: "area".to_string(),
543 entity_id: Some(area.uuid),
544 invalidating_operations: vec![
545 "area_updated".to_string(),
546 "area_deleted".to_string(),
547 ],
548 });
549 }
550
551 dependencies
552 }
553
554 fn start_cache_warming(&mut self) {
556 let warming_entries = Arc::clone(&self.warming_entries);
557 let warming_interval = self.config.warming_interval;
558 let max_entries = self.config.max_warming_entries;
559
560 let handle = tokio::spawn(async move {
561 let mut interval = tokio::time::interval(warming_interval);
562 loop {
563 interval.tick().await;
564
565 let entries_to_warm = {
567 let entries = warming_entries.read();
568 let mut sorted_entries: Vec<_> = entries.iter().collect();
569 sorted_entries.sort_by(|a, b| b.1.cmp(a.1));
570 sorted_entries
571 .into_iter()
572 .take(max_entries)
573 .map(|(key, _)| key.clone())
574 .collect::<Vec<_>>()
575 };
576
577 if !entries_to_warm.is_empty() {
580 tracing::debug!("Cache warming {} entries", entries_to_warm.len());
581 }
582 }
583 });
584
585 self.warming_task = Some(handle);
586 }
587
588 pub fn add_to_warming(&self, key: String, priority: u32) {
590 let mut entries = self.warming_entries.write();
591 entries.insert(key, priority);
592 }
593
594 pub fn remove_from_warming(&self, key: &str) {
596 let mut entries = self.warming_entries.write();
597 entries.remove(key);
598 }
599
600 pub fn invalidate_by_entity(&self, entity_type: &str, entity_id: Option<&Uuid>) {
602 self.tasks.invalidate_all();
608 self.projects.invalidate_all();
609 self.areas.invalidate_all();
610 self.search_results.invalidate_all();
611
612 tracing::debug!(
613 "Invalidated all caches due to entity change: {} {:?}",
614 entity_type,
615 entity_id
616 );
617 }
618
619 pub fn invalidate_by_operation(&self, operation: &str) {
621 match operation {
626 "task_created" | "task_updated" | "task_deleted" | "task_completed" => {
627 self.tasks.invalidate_all();
628 self.search_results.invalidate_all();
629 }
630 "project_created" | "project_updated" | "project_deleted" => {
631 self.projects.invalidate_all();
632 self.tasks.invalidate_all(); }
634 "area_created" | "area_updated" | "area_deleted" => {
635 self.areas.invalidate_all();
636 self.projects.invalidate_all(); self.tasks.invalidate_all(); }
639 _ => {
640 self.invalidate_all();
642 }
643 }
644
645 tracing::debug!("Invalidated caches due to operation: {}", operation);
646 }
647
648 #[must_use]
650 pub fn get_warming_stats(&self) -> (usize, u32) {
651 let entries = self.warming_entries.read();
652 let count = entries.len();
653 let max_priority = entries.values().max().copied().unwrap_or(0);
654 (count, max_priority)
655 }
656
657 pub fn stop_cache_warming(&mut self) {
659 if let Some(handle) = self.warming_task.take() {
660 handle.abort();
661 }
662 }
663}
664
665pub mod keys {
667 #[must_use]
669 pub fn inbox(limit: Option<usize>) -> String {
670 format!(
671 "inbox:{}",
672 limit.map_or("all".to_string(), |l| l.to_string())
673 )
674 }
675
676 #[must_use]
678 pub fn today(limit: Option<usize>) -> String {
679 format!(
680 "today:{}",
681 limit.map_or("all".to_string(), |l| l.to_string())
682 )
683 }
684
685 #[must_use]
687 pub fn projects(area_uuid: Option<&str>) -> String {
688 format!("projects:{}", area_uuid.unwrap_or("all"))
689 }
690
691 #[must_use]
693 pub fn areas() -> String {
694 "areas:all".to_string()
695 }
696
697 #[must_use]
699 pub fn search(query: &str, limit: Option<usize>) -> String {
700 format!(
701 "search:{}:{}",
702 query,
703 limit.map_or("all".to_string(), |l| l.to_string())
704 )
705 }
706}
707
708#[cfg(test)]
709mod tests {
710 use super::*;
711 use crate::test_utils::{create_mock_areas, create_mock_projects, create_mock_tasks};
712 use std::time::Duration;
713
714 #[test]
715 fn test_cache_config_default() {
716 let config = CacheConfig::default();
717
718 assert_eq!(config.max_capacity, 1000);
719 assert_eq!(config.ttl, Duration::from_secs(300));
720 assert_eq!(config.tti, Duration::from_secs(60));
721 }
722
723 #[test]
724 fn test_cache_config_custom() {
725 let config = CacheConfig {
726 max_capacity: 500,
727 ttl: Duration::from_secs(600),
728 tti: Duration::from_secs(120),
729 invalidation_strategy: InvalidationStrategy::Hybrid,
730 enable_cache_warming: true,
731 warming_interval: Duration::from_secs(60),
732 max_warming_entries: 50,
733 };
734
735 assert_eq!(config.max_capacity, 500);
736 assert_eq!(config.ttl, Duration::from_secs(600));
737 assert_eq!(config.tti, Duration::from_secs(120));
738 }
739
740 #[test]
741 fn test_cached_data_creation() {
742 let data = vec![1, 2, 3];
743 let ttl = Duration::from_secs(60);
744 let cached = CachedData::new(data.clone(), ttl);
745
746 assert_eq!(cached.data, data);
747 assert!(cached.cached_at <= Utc::now());
748 assert!(cached.expires_at > cached.cached_at);
749 assert!(!cached.is_expired());
750 }
751
752 #[test]
753 fn test_cached_data_expiration() {
754 let data = vec![1, 2, 3];
755 let ttl = Duration::from_millis(1);
756 let cached = CachedData::new(data, ttl);
757
758 assert!(!cached.is_expired());
760
761 std::thread::sleep(Duration::from_millis(10));
763 }
765
766 #[test]
767 fn test_cached_data_serialization() {
768 let data = vec![1, 2, 3];
769 let ttl = Duration::from_secs(60);
770 let cached = CachedData::new(data, ttl);
771
772 let json = serde_json::to_string(&cached).unwrap();
774 assert!(json.contains("data"));
775 assert!(json.contains("cached_at"));
776 assert!(json.contains("expires_at"));
777
778 let deserialized: CachedData<Vec<i32>> = serde_json::from_str(&json).unwrap();
780 assert_eq!(deserialized.data, cached.data);
781 }
782
783 #[test]
784 fn test_cache_stats_default() {
785 let stats = CacheStats::default();
786
787 assert_eq!(stats.hits, 0);
788 assert_eq!(stats.misses, 0);
789 assert_eq!(stats.entries, 0);
790 assert!((stats.hit_rate - 0.0).abs() < f64::EPSILON);
791 }
792
793 #[test]
794 fn test_cache_stats_calculation() {
795 let mut stats = CacheStats {
796 hits: 8,
797 misses: 2,
798 entries: 5,
799 hit_rate: 0.0,
800 };
801
802 stats.calculate_hit_rate();
803 assert!((stats.hit_rate - 0.8).abs() < f64::EPSILON);
804 }
805
806 #[test]
807 fn test_cache_stats_zero_total() {
808 let mut stats = CacheStats {
809 hits: 0,
810 misses: 0,
811 entries: 0,
812 hit_rate: 0.0,
813 };
814
815 stats.calculate_hit_rate();
816 assert!((stats.hit_rate - 0.0).abs() < f64::EPSILON);
817 }
818
819 #[test]
820 fn test_cache_stats_serialization() {
821 let stats = CacheStats {
822 hits: 10,
823 misses: 5,
824 entries: 3,
825 hit_rate: 0.67,
826 };
827
828 let json = serde_json::to_string(&stats).unwrap();
830 assert!(json.contains("hits"));
831 assert!(json.contains("misses"));
832 assert!(json.contains("entries"));
833 assert!(json.contains("hit_rate"));
834
835 let deserialized: CacheStats = serde_json::from_str(&json).unwrap();
837 assert_eq!(deserialized.hits, stats.hits);
838 assert_eq!(deserialized.misses, stats.misses);
839 assert_eq!(deserialized.entries, stats.entries);
840 assert!((deserialized.hit_rate - stats.hit_rate).abs() < f64::EPSILON);
841 }
842
843 #[test]
844 fn test_cache_stats_clone() {
845 let stats = CacheStats {
846 hits: 5,
847 misses: 3,
848 entries: 2,
849 hit_rate: 0.625,
850 };
851
852 let cloned = stats.clone();
853 assert_eq!(cloned.hits, stats.hits);
854 assert_eq!(cloned.misses, stats.misses);
855 assert_eq!(cloned.entries, stats.entries);
856 assert!((cloned.hit_rate - stats.hit_rate).abs() < f64::EPSILON);
857 }
858
859 #[test]
860 fn test_cache_stats_debug() {
861 let stats = CacheStats {
862 hits: 1,
863 misses: 1,
864 entries: 1,
865 hit_rate: 0.5,
866 };
867
868 let debug_str = format!("{stats:?}");
869 assert!(debug_str.contains("CacheStats"));
870 assert!(debug_str.contains("hits"));
871 assert!(debug_str.contains("misses"));
872 }
873
874 #[tokio::test]
875 async fn test_cache_new() {
876 let config = CacheConfig::default();
877 let _cache = ThingsCache::new(&config);
878
879 }
882
883 #[tokio::test]
884 async fn test_cache_new_default() {
885 let _cache = ThingsCache::new_default();
886
887 }
890
891 #[tokio::test]
892 async fn test_cache_basic_operations() {
893 let cache = ThingsCache::new_default();
894
895 let result = cache.get_tasks("test", || async { Ok(vec![]) }).await;
897 assert!(result.is_ok());
898
899 let result = cache.get_tasks("test", || async { Ok(vec![]) }).await;
901 assert!(result.is_ok());
902
903 let stats = cache.get_stats();
904 assert_eq!(stats.hits, 1);
905 assert_eq!(stats.misses, 1);
906 }
907
908 #[tokio::test]
909 async fn test_cache_tasks_with_data() {
910 let cache = ThingsCache::new_default();
911 let mock_tasks = create_mock_tasks();
912
913 let result = cache
915 .get_tasks("tasks", || async { Ok(mock_tasks.clone()) })
916 .await;
917 assert!(result.is_ok());
918 assert_eq!(result.unwrap().len(), mock_tasks.len());
919
920 let result = cache.get_tasks("tasks", || async { Ok(vec![]) }).await;
922 assert!(result.is_ok());
923 assert_eq!(result.unwrap().len(), mock_tasks.len());
924
925 let stats = cache.get_stats();
926 assert_eq!(stats.hits, 1);
927 assert_eq!(stats.misses, 1);
928 }
929
930 #[tokio::test]
931 async fn test_cache_projects() {
932 let cache = ThingsCache::new_default();
933 let mock_projects = create_mock_projects();
934
935 let result = cache
937 .get_projects("projects", || async { Ok(mock_projects.clone()) })
938 .await;
939 assert!(result.is_ok());
940
941 let result = cache
943 .get_projects("projects", || async { Ok(vec![]) })
944 .await;
945 assert!(result.is_ok());
946
947 let stats = cache.get_stats();
948 assert_eq!(stats.hits, 1);
949 assert_eq!(stats.misses, 1);
950 }
951
952 #[tokio::test]
953 async fn test_cache_areas() {
954 let cache = ThingsCache::new_default();
955 let mock_areas = create_mock_areas();
956
957 let result = cache
959 .get_areas("areas", || async { Ok(mock_areas.clone()) })
960 .await;
961 assert!(result.is_ok());
962
963 let result = cache.get_areas("areas", || async { Ok(vec![]) }).await;
965 assert!(result.is_ok());
966
967 let stats = cache.get_stats();
968 assert_eq!(stats.hits, 1);
969 assert_eq!(stats.misses, 1);
970 }
971
972 #[tokio::test]
973 async fn test_cache_search_results() {
974 let cache = ThingsCache::new_default();
975 let mock_tasks = create_mock_tasks();
976
977 let result = cache
979 .get_search_results("search:test", || async { Ok(mock_tasks.clone()) })
980 .await;
981 assert!(result.is_ok());
982
983 let result = cache
985 .get_search_results("search:test", || async { Ok(vec![]) })
986 .await;
987 assert!(result.is_ok());
988
989 let stats = cache.get_stats();
990 assert_eq!(stats.hits, 1);
991 assert_eq!(stats.misses, 1);
992 }
993
994 #[tokio::test]
995 async fn test_cache_fetcher_error() {
996 let cache = ThingsCache::new_default();
997
998 let result = cache
1000 .get_tasks("error", || async { Err(anyhow::anyhow!("Test error")) })
1001 .await;
1002
1003 assert!(result.is_err());
1004 assert!(result.unwrap_err().to_string().contains("Test error"));
1005
1006 let stats = cache.get_stats();
1007 assert_eq!(stats.hits, 0);
1008 assert_eq!(stats.misses, 1);
1009 }
1010
1011 #[tokio::test]
1012 async fn test_cache_expiration() {
1013 let config = CacheConfig {
1014 max_capacity: 100,
1015 ttl: Duration::from_millis(10),
1016 tti: Duration::from_millis(5),
1017 invalidation_strategy: InvalidationStrategy::Hybrid,
1018 enable_cache_warming: true,
1019 warming_interval: Duration::from_secs(60),
1020 max_warming_entries: 50,
1021 };
1022 let cache = ThingsCache::new(&config);
1023
1024 let _ = cache.get_tasks("test", || async { Ok(vec![]) }).await;
1026
1027 tokio::time::sleep(Duration::from_millis(20)).await;
1029
1030 let _ = cache.get_tasks("test", || async { Ok(vec![]) }).await;
1032
1033 let stats = cache.get_stats();
1034 assert_eq!(stats.misses, 2);
1035 }
1036
1037 #[tokio::test]
1038 async fn test_cache_invalidate_all() {
1039 let cache = ThingsCache::new_default();
1040
1041 let _ = cache.get_tasks("tasks", || async { Ok(vec![]) }).await;
1043 let _ = cache
1044 .get_projects("projects", || async { Ok(vec![]) })
1045 .await;
1046 let _ = cache.get_areas("areas", || async { Ok(vec![]) }).await;
1047 let _ = cache
1048 .get_search_results("search", || async { Ok(vec![]) })
1049 .await;
1050
1051 cache.invalidate_all();
1053
1054 let _ = cache.get_tasks("tasks", || async { Ok(vec![]) }).await;
1056 let _ = cache
1057 .get_projects("projects", || async { Ok(vec![]) })
1058 .await;
1059 let _ = cache.get_areas("areas", || async { Ok(vec![]) }).await;
1060 let _ = cache
1061 .get_search_results("search", || async { Ok(vec![]) })
1062 .await;
1063
1064 let stats = cache.get_stats();
1065 assert_eq!(stats.misses, 8); }
1067
1068 #[tokio::test]
1069 async fn test_cache_invalidate_specific() {
1070 let cache = ThingsCache::new_default();
1071
1072 let _ = cache.get_tasks("key1", || async { Ok(vec![]) }).await;
1074 let _ = cache.get_tasks("key2", || async { Ok(vec![]) }).await;
1075
1076 cache.invalidate("key1").await;
1078
1079 let _ = cache.get_tasks("key1", || async { Ok(vec![]) }).await;
1081 let _ = cache.get_tasks("key2", || async { Ok(vec![]) }).await;
1082
1083 let stats = cache.get_stats();
1084 assert_eq!(stats.hits, 1); assert_eq!(stats.misses, 3); }
1087
1088 #[tokio::test]
1089 async fn test_cache_reset_stats() {
1090 let cache = ThingsCache::new_default();
1091
1092 let _ = cache.get_tasks("test", || async { Ok(vec![]) }).await;
1094 let _ = cache.get_tasks("test", || async { Ok(vec![]) }).await;
1095
1096 let stats_before = cache.get_stats();
1097 assert!(stats_before.hits > 0 || stats_before.misses > 0);
1098
1099 cache.reset_stats();
1101
1102 let stats_after = cache.get_stats();
1103 assert_eq!(stats_after.hits, 0);
1104 assert_eq!(stats_after.misses, 0);
1105 assert!((stats_after.hit_rate - 0.0).abs() < f64::EPSILON);
1106 }
1107
1108 #[test]
1109 fn test_cache_keys_inbox() {
1110 assert_eq!(keys::inbox(None), "inbox:all");
1111 assert_eq!(keys::inbox(Some(10)), "inbox:10");
1112 assert_eq!(keys::inbox(Some(0)), "inbox:0");
1113 }
1114
1115 #[test]
1116 fn test_cache_keys_today() {
1117 assert_eq!(keys::today(None), "today:all");
1118 assert_eq!(keys::today(Some(5)), "today:5");
1119 assert_eq!(keys::today(Some(100)), "today:100");
1120 }
1121
1122 #[test]
1123 fn test_cache_keys_projects() {
1124 assert_eq!(keys::projects(None), "projects:all");
1125 assert_eq!(keys::projects(Some("uuid-123")), "projects:uuid-123");
1126 assert_eq!(keys::projects(Some("")), "projects:");
1127 }
1128
1129 #[test]
1130 fn test_cache_keys_areas() {
1131 assert_eq!(keys::areas(), "areas:all");
1132 }
1133
1134 #[test]
1135 fn test_cache_keys_search() {
1136 assert_eq!(keys::search("test query", None), "search:test query:all");
1137 assert_eq!(keys::search("test query", Some(10)), "search:test query:10");
1138 assert_eq!(keys::search("", Some(5)), "search::5");
1139 }
1140
1141 #[tokio::test]
1142 async fn test_cache_multiple_keys() {
1143 let cache = ThingsCache::new_default();
1144 let mock_tasks1 = create_mock_tasks();
1145 let mock_tasks2 = create_mock_tasks();
1146
1147 let _ = cache
1149 .get_tasks("key1", || async { Ok(mock_tasks1.clone()) })
1150 .await;
1151 let _ = cache
1152 .get_tasks("key2", || async { Ok(mock_tasks2.clone()) })
1153 .await;
1154
1155 let result1 = cache
1157 .get_tasks("key1", || async { Ok(vec![]) })
1158 .await
1159 .unwrap();
1160 let result2 = cache
1161 .get_tasks("key2", || async { Ok(vec![]) })
1162 .await
1163 .unwrap();
1164
1165 assert_eq!(result1.len(), mock_tasks1.len());
1166 assert_eq!(result2.len(), mock_tasks2.len());
1167
1168 let stats = cache.get_stats();
1169 assert_eq!(stats.hits, 2);
1170 assert_eq!(stats.misses, 2);
1171 }
1172
1173 #[tokio::test]
1174 async fn test_cache_entry_count() {
1175 let cache = ThingsCache::new_default();
1176
1177 let stats = cache.get_stats();
1179 assert_eq!(stats.entries, 0);
1180
1181 let _ = cache.get_tasks("tasks", || async { Ok(vec![]) }).await;
1183 let _ = cache
1184 .get_projects("projects", || async { Ok(vec![]) })
1185 .await;
1186 let _ = cache.get_areas("areas", || async { Ok(vec![]) }).await;
1187 let _ = cache
1188 .get_search_results("search", || async { Ok(vec![]) })
1189 .await;
1190
1191 let stats = cache.get_stats();
1194 let _ = stats.entries;
1196 }
1197
1198 #[tokio::test]
1199 async fn test_cache_hit_rate_calculation() {
1200 let cache = ThingsCache::new_default();
1201
1202 let _ = cache.get_tasks("test", || async { Ok(vec![]) }).await; let _ = cache.get_tasks("test", || async { Ok(vec![]) }).await; let _ = cache.get_tasks("test", || async { Ok(vec![]) }).await; let stats = cache.get_stats();
1208 assert_eq!(stats.hits, 2);
1209 assert_eq!(stats.misses, 1);
1210 assert!((stats.hit_rate - 2.0 / 3.0).abs() < 0.001);
1211 }
1212}