tl_cli/cache/
sqlite.rs

1use anyhow::{Context, Result};
2use rusqlite::Connection;
3use std::path::PathBuf;
4
5use crate::translation::TranslationRequest;
6
7/// Manages translation caching using a `SQLite` database.
8///
9/// The cache stores translations keyed by source text, target language,
10/// model, endpoint, and prompt hash to avoid redundant API calls.
11///
12/// # Example
13///
14/// ```no_run
15/// use tl_cli::cache::CacheManager;
16/// use tl_cli::translation::TranslationRequest;
17///
18/// let cache = CacheManager::new().unwrap();
19/// let request = TranslationRequest {
20///     source_text: "Hello".to_string(),
21///     target_language: "ja".to_string(),
22///     model: "gpt-4".to_string(),
23///     endpoint: "https://api.openai.com".to_string(),
24/// };
25///
26/// // Check cache
27/// if let Some(cached) = cache.get(&request).unwrap() {
28///     println!("Cached: {}", cached);
29/// }
30/// ```
31pub struct CacheManager {
32    db_path: PathBuf,
33}
34
35impl CacheManager {
36    /// Creates a new cache manager.
37    ///
38    /// Initializes the `SQLite` database at `~/.cache/tl/translations.db`.
39    pub fn new() -> Result<Self> {
40        let cache_dir = dirs::cache_dir()
41            .context("Failed to determine cache directory")?
42            .join("tl");
43
44        std::fs::create_dir_all(&cache_dir).with_context(|| {
45            format!("Failed to create cache directory: {}", cache_dir.display())
46        })?;
47
48        let db_path = cache_dir.join("translations.db");
49        let manager = Self { db_path };
50
51        manager.init_db()?;
52
53        Ok(manager)
54    }
55
56    fn init_db(&self) -> Result<()> {
57        let conn = self.connect()?;
58
59        conn.execute(
60            "CREATE TABLE IF NOT EXISTS translations (
61                id INTEGER PRIMARY KEY AUTOINCREMENT,
62                cache_key TEXT UNIQUE NOT NULL,
63                source_text TEXT NOT NULL,
64                translated_text TEXT NOT NULL,
65                target_language TEXT NOT NULL,
66                model TEXT NOT NULL,
67                endpoint TEXT NOT NULL,
68                prompt_hash TEXT NOT NULL,
69                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
70                accessed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
71            )",
72            [],
73        )
74        .context("Failed to create translations table")?;
75
76        conn.execute(
77            "CREATE INDEX IF NOT EXISTS idx_cache_key ON translations(cache_key)",
78            [],
79        )
80        .context("Failed to create index")?;
81
82        Ok(())
83    }
84
85    fn connect(&self) -> Result<Connection> {
86        Connection::open(&self.db_path)
87            .with_context(|| format!("Failed to open cache database: {}", self.db_path.display()))
88    }
89
90    /// Retrieves a cached translation if available.
91    ///
92    /// Returns `None` if no cached translation exists for the request.
93    /// Updates the `accessed_at` timestamp on cache hit.
94    pub fn get(&self, request: &TranslationRequest) -> Result<Option<String>> {
95        let cache_key = request.cache_key();
96        let conn = self.connect()?;
97
98        let mut stmt =
99            conn.prepare("SELECT translated_text FROM translations WHERE cache_key = ?1")?;
100
101        let result: Option<String> = stmt.query_row([&cache_key], |row| row.get(0)).ok();
102
103        if result.is_some() {
104            conn.execute(
105                "UPDATE translations SET accessed_at = CURRENT_TIMESTAMP WHERE cache_key = ?1",
106                [&cache_key],
107            )?;
108        }
109
110        Ok(result)
111    }
112
113    /// Stores a translation in the cache.
114    ///
115    /// If a translation with the same cache key already exists, it is replaced.
116    pub fn put(&self, request: &TranslationRequest, translated_text: &str) -> Result<()> {
117        let cache_key = request.cache_key();
118        let prompt_hash = TranslationRequest::prompt_hash();
119        let conn = self.connect()?;
120
121        conn.execute(
122            "INSERT OR REPLACE INTO translations
123             (cache_key, source_text, translated_text, target_language, model, endpoint, prompt_hash)
124             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
125            [
126                &cache_key,
127                &request.source_text,
128                translated_text,
129                &request.target_language,
130                &request.model,
131                &request.endpoint,
132                &prompt_hash,
133            ],
134        )
135        .context("Failed to insert translation into cache")?;
136
137        Ok(())
138    }
139}
140
141#[cfg(test)]
142#[allow(clippy::unwrap_used)]
143mod tests {
144    use super::*;
145    use tempfile::TempDir;
146
147    fn create_test_manager(temp_dir: &TempDir) -> CacheManager {
148        let db_path = temp_dir.path().join("translations.db");
149        let manager = CacheManager { db_path };
150        manager.init_db().unwrap();
151        manager
152    }
153
154    fn create_test_request() -> TranslationRequest {
155        TranslationRequest {
156            source_text: "Hello, World!".to_string(),
157            target_language: "ja".to_string(),
158            model: "gpt-oss:20b".to_string(),
159            endpoint: "http://localhost:11434".to_string(),
160        }
161    }
162
163    #[test]
164    fn test_cache_miss() {
165        let temp_dir = TempDir::new().unwrap();
166        let manager = create_test_manager(&temp_dir);
167        let request = create_test_request();
168
169        let result = manager.get(&request).unwrap();
170        assert!(result.is_none());
171    }
172
173    #[test]
174    fn test_cache_hit() {
175        let temp_dir = TempDir::new().unwrap();
176        let manager = create_test_manager(&temp_dir);
177        let request = create_test_request();
178
179        manager.put(&request, "こんにちは、世界!").unwrap();
180
181        let result = manager.get(&request).unwrap();
182        assert_eq!(result, Some("こんにちは、世界!".to_string()));
183    }
184
185    #[test]
186    fn test_different_requests_different_keys() {
187        let temp_dir = TempDir::new().unwrap();
188        let manager = create_test_manager(&temp_dir);
189
190        let request1 = TranslationRequest {
191            source_text: "Hello".to_string(),
192            target_language: "ja".to_string(),
193            model: "model1".to_string(),
194            endpoint: "http://localhost:11434".to_string(),
195        };
196
197        let request2 = TranslationRequest {
198            source_text: "Hello".to_string(),
199            target_language: "en".to_string(),
200            model: "model1".to_string(),
201            endpoint: "http://localhost:11434".to_string(),
202        };
203
204        manager.put(&request1, "Translation 1").unwrap();
205        manager.put(&request2, "Translation 2").unwrap();
206
207        assert_eq!(
208            manager.get(&request1).unwrap(),
209            Some("Translation 1".to_string())
210        );
211        assert_eq!(
212            manager.get(&request2).unwrap(),
213            Some("Translation 2".to_string())
214        );
215    }
216
217    #[test]
218    fn test_cache_key_includes_endpoint() {
219        let temp_dir = TempDir::new().unwrap();
220        let manager = create_test_manager(&temp_dir);
221
222        let request1 = TranslationRequest {
223            source_text: "Hello".to_string(),
224            target_language: "ja".to_string(),
225            model: "model1".to_string(),
226            endpoint: "http://localhost:11434".to_string(),
227        };
228
229        let request2 = TranslationRequest {
230            source_text: "Hello".to_string(),
231            target_language: "ja".to_string(),
232            model: "model1".to_string(),
233            endpoint: "http://production:11434".to_string(),
234        };
235
236        manager.put(&request1, "Local Translation").unwrap();
237        manager.put(&request2, "Production Translation").unwrap();
238
239        assert_eq!(
240            manager.get(&request1).unwrap(),
241            Some("Local Translation".to_string())
242        );
243        assert_eq!(
244            manager.get(&request2).unwrap(),
245            Some("Production Translation".to_string())
246        );
247    }
248}