difflore_cli/
hook_cache.rs1use std::collections::BTreeMap;
8use std::fs;
9use std::path::PathBuf;
10
11use serde::{Deserialize, Serialize};
12
13const DEFAULT_TTL_MS: i64 = 120_000;
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16struct CacheFile {
17 version: u32,
18 entries: BTreeMap<String, CacheEntry>,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22struct CacheEntry {
23 ts_ms: i64,
24 rules_injected: usize,
25}
26
27pub fn should_skip_recent(file_path: &str, purpose: &str) -> bool {
28 let project_root = difflore_core::db::current_project_root();
29 let project_hash = difflore_core::db::project_hash_from_root(&project_root);
30 should_skip_recent_for_project_hash(file_path, purpose, &project_hash)
31}
32
33pub fn should_skip_recent_for_project_hash(
34 file_path: &str,
35 purpose: &str,
36 project_hash: &str,
37) -> bool {
38 let ttl = ttl_ms();
39 if ttl <= 0 {
40 return false;
41 }
42 let Some(path) = cache_path() else {
43 return false;
44 };
45 let Ok(raw) = fs::read_to_string(path) else {
46 return false;
47 };
48 let Ok(cache) = serde_json::from_str::<CacheFile>(&raw) else {
49 return false;
50 };
51 let key = cache_key_for_project_hash(file_path, purpose, project_hash);
52 let Some(entry) = cache.entries.get(&key) else {
53 return false;
54 };
55 now_ms().saturating_sub(entry.ts_ms) < ttl && entry.rules_injected > 0
56}
57
58pub fn remember_injection(file_path: &str, purpose: &str, rules_injected: usize) {
59 let Some(path) = cache_path() else {
60 return;
61 };
62 let mut cache = fs::read_to_string(&path)
63 .ok()
64 .and_then(|raw| serde_json::from_str::<CacheFile>(&raw).ok())
65 .unwrap_or_else(|| CacheFile {
66 version: 1,
67 entries: BTreeMap::new(),
68 });
69 let now = now_ms();
70 let ttl = ttl_ms().max(DEFAULT_TTL_MS);
71 cache
72 .entries
73 .retain(|_, entry| now.saturating_sub(entry.ts_ms) <= ttl * 4);
74 cache.entries.insert(
75 cache_key(file_path, purpose),
76 CacheEntry {
77 ts_ms: now,
78 rules_injected,
79 },
80 );
81 if let Some(parent) = path.parent() {
82 let _ = fs::create_dir_all(parent);
83 }
84 if let Ok(json) = serde_json::to_string_pretty(&cache) {
85 let _ = fs::write(path, json);
86 }
87}
88
89fn cache_key(file_path: &str, purpose: &str) -> String {
90 let project_root = difflore_core::db::current_project_root();
91 let project_hash = difflore_core::db::project_hash_from_root(&project_root);
92 cache_key_for_project_hash(file_path, purpose, &project_hash)
93}
94
95fn cache_key_for_project_hash(file_path: &str, purpose: &str, project_hash: &str) -> String {
96 let normalized = file_path.trim().replace('\\', "/");
97 format!("{project_hash}:{purpose}:{normalized}")
98}
99
100fn cache_path() -> Option<PathBuf> {
101 difflore_core::paths::data_home()
102 .ok()
103 .map(|dir| dir.join("hook-cache.json"))
104}
105
106fn ttl_ms() -> i64 {
107 difflore_core::env::var(difflore_core::env::DIFFLORE_HOOK_CACHE_TTL_MS)
108 .and_then(|v| v.parse::<i64>().ok())
109 .unwrap_or(DEFAULT_TTL_MS)
110}
111
112fn now_ms() -> i64 {
113 use std::time::{SystemTime, UNIX_EPOCH};
114 SystemTime::now()
115 .duration_since(UNIX_EPOCH)
116 .map_or(0, |d| d.as_millis() as i64)
117}