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 mut data = Vec::new();
79        ciborium::into_writer(&diagnostics, &mut data)?;
80        let write_txn = self.db.begin_write()?;
81        {
82            let mut table = write_txn.open_table(DIAGNOSTICS_TABLE)?;
83            table.insert(file_path, data.as_slice())?;
84        }
85        write_txn.commit()?;
86        Ok(())
87    }
88
89    pub fn update_insights(&self, file_path: &str, insights: &ProseInsights) -> Result<()> {
90        let mut data = Vec::new();
91        ciborium::into_writer(&insights, &mut data)?;
92        let write_txn = self.db.begin_write()?;
93        {
94            let mut table = write_txn.open_table(INSIGHTS_TABLE)?;
95            table.insert(file_path, data.as_slice())?;
96        }
97        write_txn.commit()?;
98        Ok(())
99    }
100
101    pub fn get_diagnostics(&self, file_path: &str) -> Result<Option<Vec<Diagnostic>>> {
102        let read_txn = self.db.begin_read()?;
103        let table = read_txn.open_table(DIAGNOSTICS_TABLE)?;
104        let result = table.get(file_path)?;
105
106        if let Some(data) = result {
107            let diagnostics = ciborium::from_reader(data.value())?;
108            Ok(Some(diagnostics))
109        } else {
110            Ok(None)
111        }
112    }
113
114    pub fn get_insights(&self, file_path: &str) -> Result<Option<ProseInsights>> {
115        let read_txn = self.db.begin_read()?;
116        let table = read_txn.open_table(INSIGHTS_TABLE)?;
117        let result = table.get(file_path)?;
118
119        if let Some(data) = result {
120            let insights = ciborium::from_reader(data.value())?;
121            Ok(Some(insights))
122        } else {
123            Ok(None)
124        }
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    fn temp_workspace(name: &str) -> (WorkspaceIndex, PathBuf) {
133        let dir = std::env::temp_dir().join(format!("lang_check_ws_{}", name));
134        let _ = std::fs::remove_dir_all(&dir);
135        std::fs::create_dir_all(&dir).unwrap();
136        let idx = WorkspaceIndex::new(&dir).unwrap();
137        (idx, dir)
138    }
139
140    fn cleanup(dir: &Path) {
141        let _ = std::fs::remove_dir_all(dir);
142    }
143
144    #[test]
145    fn create_workspace_index() {
146        let (idx, dir) = temp_workspace("create");
147        assert_eq!(idx.get_root_path().unwrap(), &dir);
148        cleanup(&dir);
149    }
150
151    #[test]
152    fn diagnostics_roundtrip() {
153        let (idx, dir) = temp_workspace("diag_rt");
154
155        let diags = vec![Diagnostic {
156            start_byte: 0,
157            end_byte: 5,
158            message: "test error".to_string(),
159            suggestions: vec!["fix".to_string()],
160            rule_id: "test.rule".to_string(),
161            severity: 2,
162            unified_id: "test.unified".to_string(),
163            confidence: 0.9,
164        }];
165
166        idx.update_diagnostics("test.md", &diags).unwrap();
167        let retrieved = idx.get_diagnostics("test.md").unwrap().unwrap();
168        assert_eq!(retrieved.len(), 1);
169        assert_eq!(retrieved[0].message, "test error");
170        assert_eq!(retrieved[0].start_byte, 0);
171        assert_eq!(retrieved[0].suggestions, vec!["fix"]);
172
173        cleanup(&dir);
174    }
175
176    #[test]
177    fn diagnostics_missing_file_returns_none() {
178        let (idx, dir) = temp_workspace("diag_none");
179        let result = idx.get_diagnostics("nonexistent.md").unwrap();
180        assert!(result.is_none());
181        cleanup(&dir);
182    }
183
184    #[test]
185    fn insights_roundtrip() {
186        let (idx, dir) = temp_workspace("insights_rt");
187
188        let insights = ProseInsights {
189            word_count: 100,
190            sentence_count: 5,
191            character_count: 450,
192            reading_level: 8.5,
193        };
194
195        idx.update_insights("doc.md", &insights).unwrap();
196        let retrieved = idx.get_insights("doc.md").unwrap().unwrap();
197        assert_eq!(retrieved.word_count, 100);
198        assert_eq!(retrieved.sentence_count, 5);
199        assert_eq!(retrieved.character_count, 450);
200        assert!((retrieved.reading_level - 8.5).abs() < 0.01);
201
202        cleanup(&dir);
203    }
204
205    #[test]
206    fn file_hash_unchanged_detection() {
207        let (idx, dir) = temp_workspace("hash_unchanged");
208
209        let content = "Hello, world!";
210        idx.update_file_hash("test.md", content).unwrap();
211        assert!(idx.is_file_unchanged("test.md", content));
212
213        cleanup(&dir);
214    }
215
216    #[test]
217    fn file_hash_changed_detection() {
218        let (idx, dir) = temp_workspace("hash_changed");
219
220        idx.update_file_hash("test.md", "original content").unwrap();
221        assert!(!idx.is_file_unchanged("test.md", "modified content"));
222
223        cleanup(&dir);
224    }
225
226    #[test]
227    fn file_hash_new_file() {
228        let (idx, dir) = temp_workspace("hash_new");
229        assert!(!idx.is_file_unchanged("new.md", "any content"));
230        cleanup(&dir);
231    }
232
233    #[test]
234    fn overwrite_diagnostics() {
235        let (idx, dir) = temp_workspace("diag_overwrite");
236
237        let diags1 = vec![Diagnostic {
238            start_byte: 0,
239            end_byte: 3,
240            message: "first".to_string(),
241            ..Default::default()
242        }];
243        idx.update_diagnostics("f.md", &diags1).unwrap();
244
245        let diags2 = vec![
246            Diagnostic {
247                start_byte: 0,
248                end_byte: 3,
249                message: "second".to_string(),
250                ..Default::default()
251            },
252            Diagnostic {
253                start_byte: 10,
254                end_byte: 15,
255                message: "third".to_string(),
256                ..Default::default()
257            },
258        ];
259        idx.update_diagnostics("f.md", &diags2).unwrap();
260
261        let retrieved = idx.get_diagnostics("f.md").unwrap().unwrap();
262        assert_eq!(retrieved.len(), 2);
263        assert_eq!(retrieved[0].message, "second");
264
265        cleanup(&dir);
266    }
267}