Skip to main content

difflore_cli/
hook_cache.rs

1//! Small hot-path helpers for lifecycle hooks.
2//!
3//! The cache prevents repeated rule injections for the same file and
4//! event kind within a short window. It is deliberately advisory: any
5//! IO or parse failure returns "do not skip" so hooks keep working.
6
7use 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}