1use crate::checker::Diagnostic;
2use crate::insights::ProseInsights;
3use anyhow::Result;
4use redb::{Database, ReadableDatabase, TableDefinition};
5use serde::{Deserialize, Serialize};
6use std::collections::hash_map::DefaultHasher;
7use std::hash::{Hash, Hasher};
8use std::path::{Path, PathBuf};
9
10type Table = TableDefinition<'static, &'static str, &'static [u8]>;
13
14const DIAGNOSTICS_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("diagnostics");
15const INSIGHTS_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("insights");
16const FILE_HASHES_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("file_hashes");
17
18#[must_use]
36pub fn check_fingerprint(
37 text: &str,
38 config: &crate::config::Config,
39 dictionary: &crate::dictionary::Dictionary,
40 ignore_store: &crate::hashing::IgnoreStore,
41 names_enabled: bool,
42 schemas: u64,
43) -> u64 {
44 let config_repr = serde_json::to_string(config).unwrap_or_default();
45 let packs = if config.engines.hunspell.enabled {
49 crate::packs::PackRegistry::for_hunspell(&config.engines.hunspell)
50 .fingerprint(&config.engines.hunspell.languages)
51 } else {
52 0
53 };
54 crate::hashing::stable_hash(&format!(
55 "{}\x1e{}\x1e{}\x1e{}\x1e{}\x1e{}\x1e{}\x1e{}",
56 crate::hashing::stable_hash(text),
57 crate::hashing::stable_hash(&config_repr),
58 dictionary.fingerprint(),
59 ignore_store.fingerprint(),
60 names_enabled,
61 schemas,
62 packs,
63 env!("CARGO_PKG_VERSION"),
64 ))
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct CachedCheck {
79 pub fingerprint: u64,
80 pub diagnostics: Vec<Diagnostic>,
81}
82
83pub struct WorkspaceIndex {
84 db: Database,
85 root_path: PathBuf,
86}
87
88impl WorkspaceIndex {
89 pub fn new(workspace_root: &Path, db_path: Option<&Path>) -> Result<Self> {
98 let resolved_path = match db_path {
99 Some(p) => p.to_path_buf(),
100 None => default_db_path(workspace_root)?,
101 };
102
103 if let Some(parent) = resolved_path.parent() {
104 std::fs::create_dir_all(parent)?;
105 }
106
107 let db = Database::create(&resolved_path)?;
108
109 let write_txn = db.begin_write()?;
110 {
111 let _table = write_txn.open_table(DIAGNOSTICS_TABLE)?;
112 let _table = write_txn.open_table(INSIGHTS_TABLE)?;
113 let _table = write_txn.open_table(FILE_HASHES_TABLE)?;
114 }
115 write_txn.commit()?;
116
117 Ok(Self {
118 db,
119 root_path: workspace_root.to_path_buf(),
120 })
121 }
122
123 #[must_use]
124 pub fn get_root_path(&self) -> Option<&Path> {
125 Some(&self.root_path)
126 }
127
128 #[must_use]
131 pub fn is_file_unchanged(&self, file_path: &str, content: &str) -> bool {
132 let new_hash = crate::hashing::content_hash(content);
133 let Ok(read_txn) = self.db.begin_read() else {
134 return false;
135 };
136 let Ok(table) = read_txn.open_table(FILE_HASHES_TABLE) else {
137 return false;
138 };
139 let Ok(Some(stored)) = table.get(file_path) else {
140 return false;
141 };
142
143 stored.value() == new_hash.to_le_bytes()
144 }
145
146 pub fn update_file_hash(&self, file_path: &str, content: &str) -> Result<()> {
148 let hash = crate::hashing::content_hash(content);
149 self.put_bytes(FILE_HASHES_TABLE, file_path, hash.to_le_bytes().as_slice())
150 }
151
152 pub fn update_insights(&self, file_path: &str, insights: &ProseInsights) -> Result<()> {
153 self.put_cbor(INSIGHTS_TABLE, file_path, &insights)
154 }
155
156 pub fn get_insights(&self, file_path: &str) -> Result<Option<ProseInsights>> {
157 self.get_cbor(INSIGHTS_TABLE, file_path)
158 }
159
160 #[must_use]
166 pub fn cached_check(&self, file_path: &str, fingerprint: u64) -> Option<Vec<Diagnostic>> {
167 let stored: CachedCheck = self.get_cbor(DIAGNOSTICS_TABLE, file_path).ok()??;
168 (stored.fingerprint == fingerprint).then_some(stored.diagnostics)
169 }
170
171 pub fn store_check(
173 &self,
174 file_path: &str,
175 fingerprint: u64,
176 diagnostics: &[Diagnostic],
177 ) -> Result<()> {
178 self.put_cbor(
179 DIAGNOSTICS_TABLE,
180 file_path,
181 &CachedCheck {
182 fingerprint,
183 diagnostics: diagnostics.to_vec(),
184 },
185 )
186 }
187
188 fn put_bytes(&self, table: Table, key: &str, bytes: &[u8]) -> Result<()> {
190 let write_txn = self.db.begin_write()?;
191 {
192 let mut table = write_txn.open_table(table)?;
193 table.insert(key, bytes)?;
194 }
195 write_txn.commit()?;
196 Ok(())
197 }
198
199 fn put_cbor<T: serde::Serialize>(&self, table: Table, key: &str, value: &T) -> Result<()> {
201 let mut data = Vec::new();
202 ciborium::into_writer(value, &mut data)?;
204 self.put_bytes(table, key, &data)
205 }
206
207 fn get_cbor<T: serde::de::DeserializeOwned>(
209 &self,
210 table: Table,
211 key: &str,
212 ) -> Result<Option<T>> {
213 let read_txn = self.db.begin_read()?;
214 let table = read_txn.open_table(table)?;
215 let Some(data) = table.get(key)? else {
216 return Ok(None);
217 };
218 Ok(Some(ciborium::from_reader(data.value())?))
220 }
221}
222
223fn default_db_path(workspace_root: &Path) -> Result<PathBuf> {
230 let data_dir = dirs::data_dir()
231 .ok_or_else(|| anyhow::anyhow!("Could not determine user data directory"))?;
232
233 let canonical = workspace_root
234 .canonicalize()
235 .unwrap_or_else(|_| workspace_root.to_path_buf());
236
237 let mut hasher = DefaultHasher::new(); canonical.to_string_lossy().hash(&mut hasher);
244 let hash = hasher.finish();
245
246 let db_dir = data_dir.join("language-check").join("dbs");
247 Ok(db_dir.join(format!("{hash:016x}.db")))
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253
254 fn temp_workspace(name: &str) -> (WorkspaceIndex, PathBuf) {
255 let dir = std::env::temp_dir().join(format!("lang_check_ws_{}", name));
256 let _ = std::fs::remove_dir_all(&dir);
257 std::fs::create_dir_all(&dir).unwrap();
258 let db_path = dir.join(".languagecheck.db");
260 let idx = WorkspaceIndex::new(&dir, Some(&db_path)).unwrap();
261 (idx, dir)
262 }
263
264 fn cleanup(dir: &Path) {
265 let _ = std::fs::remove_dir_all(dir);
266 }
267
268 #[test]
269 fn create_workspace_index() {
270 let (idx, dir) = temp_workspace("create");
271 assert_eq!(idx.get_root_path().unwrap(), &dir);
272 cleanup(&dir);
273 }
274
275 #[test]
276 fn diagnostics_roundtrip() {
277 let (idx, dir) = temp_workspace("diag_rt");
278
279 let diags = vec![Diagnostic {
280 start_byte: 0,
281 end_byte: 5,
282 message: "test error".to_string(),
283 suggestions: vec!["fix".to_string()],
284 rule_id: "test.rule".to_string(),
285 severity: 2,
286 unified_id: "test.unified".to_string(),
287 confidence: 0.9,
288 language: String::new(),
289 pack_installable: false,
290 }];
291
292 idx.store_check("test.md", 7, &diags).unwrap();
293 let retrieved = idx.cached_check("test.md", 7).unwrap();
294 assert_eq!(retrieved.len(), 1);
295 assert_eq!(retrieved[0].message, "test error");
296 assert_eq!(retrieved[0].start_byte, 0);
297 assert_eq!(retrieved[0].suggestions, vec!["fix"]);
298
299 cleanup(&dir);
300 }
301
302 #[test]
303 fn diagnostics_missing_file_returns_none() {
304 let (idx, dir) = temp_workspace("diag_none");
305 let result = idx.cached_check("nonexistent.md", 7);
306 assert!(result.is_none());
307 cleanup(&dir);
308 }
309
310 #[test]
311 fn a_stored_result_is_not_served_under_a_different_fingerprint() {
312 let (idx, dir) = temp_workspace("fingerprint_guard");
316 let diags = vec![Diagnostic {
317 start_byte: 0,
318 end_byte: 4,
319 message: "stale".to_string(),
320 suggestions: Vec::new(),
321 rule_id: "spelling.typo".to_string(),
322 severity: 2,
323 unified_id: "spelling.typo".to_string(),
324 confidence: 0.8,
325 language: String::new(),
326 pack_installable: false,
327 }];
328 idx.store_check("f.md", 100, &diags).unwrap();
329
330 assert!(
331 idx.cached_check("f.md", 100).is_some(),
332 "the same inputs must hit"
333 );
334 assert!(
335 idx.cached_check("f.md", 101).is_none(),
336 "changed inputs must miss"
337 );
338
339 cleanup(&dir);
340 }
341
342 #[test]
343 fn insights_roundtrip() {
344 let (idx, dir) = temp_workspace("insights_rt");
345
346 let insights = ProseInsights {
347 word_count: 100,
348 sentence_count: 5,
349 character_count: 450,
350 reading_level: 8.5,
351 };
352
353 idx.update_insights("doc.md", &insights).unwrap();
354 let retrieved = idx.get_insights("doc.md").unwrap().unwrap();
355 assert_eq!(retrieved.word_count, 100);
356 assert_eq!(retrieved.sentence_count, 5);
357 assert_eq!(retrieved.character_count, 450);
358 assert!((retrieved.reading_level - 8.5).abs() < 0.01);
359
360 cleanup(&dir);
361 }
362
363 #[test]
364 fn file_hash_unchanged_detection() {
365 let (idx, dir) = temp_workspace("hash_unchanged");
366
367 let content = "Hello, world!";
368 idx.update_file_hash("test.md", content).unwrap();
369 assert!(idx.is_file_unchanged("test.md", content));
370
371 cleanup(&dir);
372 }
373
374 #[test]
375 fn file_hash_changed_detection() {
376 let (idx, dir) = temp_workspace("hash_changed");
377
378 idx.update_file_hash("test.md", "original content").unwrap();
379 assert!(!idx.is_file_unchanged("test.md", "modified content"));
380
381 cleanup(&dir);
382 }
383
384 #[test]
385 fn file_hash_new_file() {
386 let (idx, dir) = temp_workspace("hash_new");
387 assert!(!idx.is_file_unchanged("new.md", "any content"));
388 cleanup(&dir);
389 }
390
391 #[test]
392 fn overwrite_diagnostics() {
393 let (idx, dir) = temp_workspace("diag_overwrite");
394
395 let diags1 = vec![Diagnostic {
396 start_byte: 0,
397 end_byte: 3,
398 message: "first".to_string(),
399 ..Default::default()
400 }];
401 idx.store_check("f.md", 1, &diags1).unwrap();
402
403 let diags2 = vec![
404 Diagnostic {
405 start_byte: 0,
406 end_byte: 3,
407 message: "second".to_string(),
408 ..Default::default()
409 },
410 Diagnostic {
411 start_byte: 10,
412 end_byte: 15,
413 message: "third".to_string(),
414 ..Default::default()
415 },
416 ];
417 idx.store_check("f.md", 2, &diags2).unwrap();
418
419 let retrieved = idx.cached_check("f.md", 2).unwrap();
420 assert_eq!(retrieved.len(), 2);
421 assert_eq!(retrieved[0].message, "second");
422
423 cleanup(&dir);
424 }
425}