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 #[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 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}