Skip to main content

lang_check/
workspace.rs

1use crate::checker::Diagnostic;
2use crate::insights::ProseInsights;
3use anyhow::Result;
4use redb::{Database, ReadableDatabase, TableDefinition};
5use std::collections::hash_map::DefaultHasher;
6use std::hash::{Hash, Hasher};
7use std::path::{Path, PathBuf};
8
9const DIAGNOSTICS_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("diagnostics");
10const INSIGHTS_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("insights");
11const FILE_HASHES_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("file_hashes");
12
13pub struct WorkspaceIndex {
14    db: Database,
15    root_path: PathBuf,
16}
17
18impl WorkspaceIndex {
19    pub fn new(path: &Path) -> Result<Self> {
20        let db = Database::create(path.join(".languagecheck.db"))?;
21
22        let write_txn = db.begin_write()?;
23        {
24            let _table = write_txn.open_table(DIAGNOSTICS_TABLE)?;
25            let _table = write_txn.open_table(INSIGHTS_TABLE)?;
26            let _table = write_txn.open_table(FILE_HASHES_TABLE)?;
27        }
28        write_txn.commit()?;
29
30        Ok(Self {
31            db,
32            root_path: path.to_path_buf(),
33        })
34    }
35
36    #[must_use]
37    pub fn get_root_path(&self) -> Option<&Path> {
38        Some(&self.root_path)
39    }
40
41    /// Check if a file's content has changed since last indexing.
42    /// Returns true if unchanged (cache hit), false if changed or new.
43    #[must_use]
44    pub fn is_file_unchanged(&self, file_path: &str, content: &str) -> bool {
45        let new_hash = Self::hash_content(content);
46        let Ok(read_txn) = self.db.begin_read() else {
47            return false;
48        };
49        let Ok(table) = read_txn.open_table(FILE_HASHES_TABLE) else {
50            return false;
51        };
52        let Ok(Some(stored)) = table.get(file_path) else {
53            return false;
54        };
55
56        stored.value() == new_hash.to_le_bytes()
57    }
58
59    /// Store the content hash for a file after indexing.
60    pub fn update_file_hash(&self, file_path: &str, content: &str) -> Result<()> {
61        let hash = Self::hash_content(content);
62        let write_txn = self.db.begin_write()?;
63        {
64            let mut table = write_txn.open_table(FILE_HASHES_TABLE)?;
65            table.insert(file_path, hash.to_le_bytes().as_slice())?;
66        }
67        write_txn.commit()?;
68        Ok(())
69    }
70
71    fn hash_content(content: &str) -> u64 {
72        let mut hasher = DefaultHasher::new();
73        content.hash(&mut hasher);
74        hasher.finish()
75    }
76
77    pub fn update_diagnostics(&self, file_path: &str, diagnostics: &[Diagnostic]) -> Result<()> {
78        let data = serde_cbor::to_vec(&diagnostics)?;
79        let write_txn = self.db.begin_write()?;
80        {
81            let mut table = write_txn.open_table(DIAGNOSTICS_TABLE)?;
82            table.insert(file_path, data.as_slice())?;
83        }
84        write_txn.commit()?;
85        Ok(())
86    }
87
88    pub fn update_insights(&self, file_path: &str, insights: &ProseInsights) -> Result<()> {
89        let data = serde_cbor::to_vec(&insights)?;
90        let write_txn = self.db.begin_write()?;
91        {
92            let mut table = write_txn.open_table(INSIGHTS_TABLE)?;
93            table.insert(file_path, data.as_slice())?;
94        }
95        write_txn.commit()?;
96        Ok(())
97    }
98
99    pub fn get_diagnostics(&self, file_path: &str) -> Result<Option<Vec<Diagnostic>>> {
100        let read_txn = self.db.begin_read()?;
101        let table = read_txn.open_table(DIAGNOSTICS_TABLE)?;
102        let result = table.get(file_path)?;
103
104        if let Some(data) = result {
105            let diagnostics = serde_cbor::from_slice(data.value())?;
106            Ok(Some(diagnostics))
107        } else {
108            Ok(None)
109        }
110    }
111
112    pub fn get_insights(&self, file_path: &str) -> Result<Option<ProseInsights>> {
113        let read_txn = self.db.begin_read()?;
114        let table = read_txn.open_table(INSIGHTS_TABLE)?;
115        let result = table.get(file_path)?;
116
117        if let Some(data) = result {
118            let insights = serde_cbor::from_slice(data.value())?;
119            Ok(Some(insights))
120        } else {
121            Ok(None)
122        }
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    fn temp_workspace(name: &str) -> (WorkspaceIndex, PathBuf) {
131        let dir = std::env::temp_dir().join(format!("lang_check_ws_{}", name));
132        let _ = std::fs::remove_dir_all(&dir);
133        std::fs::create_dir_all(&dir).unwrap();
134        let idx = WorkspaceIndex::new(&dir).unwrap();
135        (idx, dir)
136    }
137
138    fn cleanup(dir: &Path) {
139        let _ = std::fs::remove_dir_all(dir);
140    }
141
142    #[test]
143    fn create_workspace_index() {
144        let (idx, dir) = temp_workspace("create");
145        assert_eq!(idx.get_root_path().unwrap(), &dir);
146        cleanup(&dir);
147    }
148
149    #[test]
150    fn diagnostics_roundtrip() {
151        let (idx, dir) = temp_workspace("diag_rt");
152
153        let diags = vec![Diagnostic {
154            start_byte: 0,
155            end_byte: 5,
156            message: "test error".to_string(),
157            suggestions: vec!["fix".to_string()],
158            rule_id: "test.rule".to_string(),
159            severity: 2,
160            unified_id: "test.unified".to_string(),
161            confidence: 0.9,
162        }];
163
164        idx.update_diagnostics("test.md", &diags).unwrap();
165        let retrieved = idx.get_diagnostics("test.md").unwrap().unwrap();
166        assert_eq!(retrieved.len(), 1);
167        assert_eq!(retrieved[0].message, "test error");
168        assert_eq!(retrieved[0].start_byte, 0);
169        assert_eq!(retrieved[0].suggestions, vec!["fix"]);
170
171        cleanup(&dir);
172    }
173
174    #[test]
175    fn diagnostics_missing_file_returns_none() {
176        let (idx, dir) = temp_workspace("diag_none");
177        let result = idx.get_diagnostics("nonexistent.md").unwrap();
178        assert!(result.is_none());
179        cleanup(&dir);
180    }
181
182    #[test]
183    fn insights_roundtrip() {
184        let (idx, dir) = temp_workspace("insights_rt");
185
186        let insights = ProseInsights {
187            word_count: 100,
188            sentence_count: 5,
189            character_count: 450,
190            reading_level: 8.5,
191        };
192
193        idx.update_insights("doc.md", &insights).unwrap();
194        let retrieved = idx.get_insights("doc.md").unwrap().unwrap();
195        assert_eq!(retrieved.word_count, 100);
196        assert_eq!(retrieved.sentence_count, 5);
197        assert_eq!(retrieved.character_count, 450);
198        assert!((retrieved.reading_level - 8.5).abs() < 0.01);
199
200        cleanup(&dir);
201    }
202
203    #[test]
204    fn file_hash_unchanged_detection() {
205        let (idx, dir) = temp_workspace("hash_unchanged");
206
207        let content = "Hello, world!";
208        idx.update_file_hash("test.md", content).unwrap();
209        assert!(idx.is_file_unchanged("test.md", content));
210
211        cleanup(&dir);
212    }
213
214    #[test]
215    fn file_hash_changed_detection() {
216        let (idx, dir) = temp_workspace("hash_changed");
217
218        idx.update_file_hash("test.md", "original content").unwrap();
219        assert!(!idx.is_file_unchanged("test.md", "modified content"));
220
221        cleanup(&dir);
222    }
223
224    #[test]
225    fn file_hash_new_file() {
226        let (idx, dir) = temp_workspace("hash_new");
227        assert!(!idx.is_file_unchanged("new.md", "any content"));
228        cleanup(&dir);
229    }
230
231    #[test]
232    fn overwrite_diagnostics() {
233        let (idx, dir) = temp_workspace("diag_overwrite");
234
235        let diags1 = vec![Diagnostic {
236            start_byte: 0,
237            end_byte: 3,
238            message: "first".to_string(),
239            ..Default::default()
240        }];
241        idx.update_diagnostics("f.md", &diags1).unwrap();
242
243        let diags2 = vec![
244            Diagnostic {
245                start_byte: 0,
246                end_byte: 3,
247                message: "second".to_string(),
248                ..Default::default()
249            },
250            Diagnostic {
251                start_byte: 10,
252                end_byte: 15,
253                message: "third".to_string(),
254                ..Default::default()
255            },
256        ];
257        idx.update_diagnostics("f.md", &diags2).unwrap();
258
259        let retrieved = idx.get_diagnostics("f.md").unwrap().unwrap();
260        assert_eq!(retrieved.len(), 2);
261        assert_eq!(retrieved[0].message, "second");
262
263        cleanup(&dir);
264    }
265}