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