1use anyhow::{Context, Result};
2use rusqlite::Connection;
3use std::path::PathBuf;
4
5use crate::paths;
6use crate::translation::TranslationRequest;
7
8pub struct CacheManager {
34 db_path: PathBuf,
35}
36
37impl CacheManager {
38 pub fn new() -> Result<Self> {
43 let cache_dir = paths::cache_dir()?;
44
45 std::fs::create_dir_all(&cache_dir).with_context(|| {
46 format!("Failed to create cache directory: {}", cache_dir.display())
47 })?;
48
49 let db_path = cache_dir.join("translations.db");
50 let manager = Self { db_path };
51
52 manager.init_db()?;
53
54 Ok(manager)
55 }
56
57 fn init_db(&self) -> Result<()> {
58 let conn = self.connect()?;
59
60 conn.execute(
61 "CREATE TABLE IF NOT EXISTS translations (
62 id INTEGER PRIMARY KEY AUTOINCREMENT,
63 cache_key TEXT UNIQUE NOT NULL,
64 source_text TEXT NOT NULL,
65 translated_text TEXT NOT NULL,
66 target_language TEXT NOT NULL,
67 model TEXT NOT NULL,
68 endpoint TEXT NOT NULL,
69 prompt_hash TEXT NOT NULL,
70 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
71 accessed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
72 )",
73 [],
74 )
75 .context("Failed to create translations table")?;
76
77 conn.execute(
78 "CREATE INDEX IF NOT EXISTS idx_cache_key ON translations(cache_key)",
79 [],
80 )
81 .context("Failed to create index")?;
82
83 Ok(())
84 }
85
86 fn connect(&self) -> Result<Connection> {
87 Connection::open(&self.db_path)
88 .with_context(|| format!("Failed to open cache database: {}", self.db_path.display()))
89 }
90
91 pub fn get(&self, request: &TranslationRequest) -> Result<Option<String>> {
96 let cache_key = request.cache_key();
97 let conn = self.connect()?;
98
99 let mut stmt =
100 conn.prepare("SELECT translated_text FROM translations WHERE cache_key = ?1")?;
101
102 let result: Option<String> = stmt.query_row([&cache_key], |row| row.get(0)).ok();
103
104 if result.is_some() {
105 conn.execute(
106 "UPDATE translations SET accessed_at = CURRENT_TIMESTAMP WHERE cache_key = ?1",
107 [&cache_key],
108 )?;
109 }
110
111 Ok(result)
112 }
113
114 pub fn put(&self, request: &TranslationRequest, translated_text: &str) -> Result<()> {
118 let cache_key = request.cache_key();
119 let prompt_hash = TranslationRequest::prompt_hash();
120 let conn = self.connect()?;
121
122 conn.execute(
123 "INSERT OR REPLACE INTO translations
124 (cache_key, source_text, translated_text, target_language, model, endpoint, prompt_hash)
125 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
126 [
127 &cache_key,
128 &request.source_text,
129 translated_text,
130 &request.target_language,
131 &request.model,
132 &request.endpoint,
133 &prompt_hash,
134 ],
135 )
136 .context("Failed to insert translation into cache")?;
137
138 Ok(())
139 }
140}
141
142#[cfg(test)]
143#[allow(clippy::unwrap_used)]
144mod tests {
145 use super::*;
146 use tempfile::TempDir;
147
148 fn create_test_manager(temp_dir: &TempDir) -> CacheManager {
149 let db_path = temp_dir.path().join("translations.db");
150 let manager = CacheManager { db_path };
151 manager.init_db().unwrap();
152 manager
153 }
154
155 fn create_test_request() -> TranslationRequest {
156 TranslationRequest {
157 source_text: "Hello, World!".to_string(),
158 target_language: "ja".to_string(),
159 model: "gpt-oss:20b".to_string(),
160 endpoint: "http://localhost:11434".to_string(),
161 style: None,
162 }
163 }
164
165 #[test]
166 fn test_cache_miss() {
167 let temp_dir = TempDir::new().unwrap();
168 let manager = create_test_manager(&temp_dir);
169 let request = create_test_request();
170
171 let result = manager.get(&request).unwrap();
172 assert!(result.is_none());
173 }
174
175 #[test]
176 fn test_cache_hit() {
177 let temp_dir = TempDir::new().unwrap();
178 let manager = create_test_manager(&temp_dir);
179 let request = create_test_request();
180
181 manager.put(&request, "こんにちは、世界!").unwrap();
182
183 let result = manager.get(&request).unwrap();
184 assert_eq!(result, Some("こんにちは、世界!".to_string()));
185 }
186
187 #[test]
188 fn test_different_requests_different_keys() {
189 let temp_dir = TempDir::new().unwrap();
190 let manager = create_test_manager(&temp_dir);
191
192 let request1 = TranslationRequest {
193 source_text: "Hello".to_string(),
194 target_language: "ja".to_string(),
195 model: "model1".to_string(),
196 endpoint: "http://localhost:11434".to_string(),
197 style: None,
198 };
199
200 let request2 = TranslationRequest {
201 source_text: "Hello".to_string(),
202 target_language: "en".to_string(),
203 model: "model1".to_string(),
204 endpoint: "http://localhost:11434".to_string(),
205 style: None,
206 };
207
208 manager.put(&request1, "Translation 1").unwrap();
209 manager.put(&request2, "Translation 2").unwrap();
210
211 assert_eq!(
212 manager.get(&request1).unwrap(),
213 Some("Translation 1".to_string())
214 );
215 assert_eq!(
216 manager.get(&request2).unwrap(),
217 Some("Translation 2".to_string())
218 );
219 }
220
221 #[test]
222 fn test_cache_key_includes_endpoint() {
223 let temp_dir = TempDir::new().unwrap();
224 let manager = create_test_manager(&temp_dir);
225
226 let request1 = TranslationRequest {
227 source_text: "Hello".to_string(),
228 target_language: "ja".to_string(),
229 model: "model1".to_string(),
230 endpoint: "http://localhost:11434".to_string(),
231 style: None,
232 };
233
234 let request2 = TranslationRequest {
235 source_text: "Hello".to_string(),
236 target_language: "ja".to_string(),
237 model: "model1".to_string(),
238 endpoint: "http://production:11434".to_string(),
239 style: None,
240 };
241
242 manager.put(&request1, "Local Translation").unwrap();
243 manager.put(&request2, "Production Translation").unwrap();
244
245 assert_eq!(
246 manager.get(&request1).unwrap(),
247 Some("Local Translation".to_string())
248 );
249 assert_eq!(
250 manager.get(&request2).unwrap(),
251 Some("Production Translation".to_string())
252 );
253 }
254}