1use std::{collections::BTreeSet, error::Error, fmt, path::Path};
7
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10use vlajmaji_lexicon::{FieldPresence, FormRole, Lexicon, RecordIdentity, SourceCrossReference};
11
12mod sqlite;
13pub use sqlite::SqliteStore;
14
15mod dictionary;
16mod dictionary_schema;
17pub use dictionary::{DictionaryStore, DictionaryStoreError, DictionaryStoreInvalidity};
18
19#[cfg(feature = "graph")]
20mod graph;
21#[cfg(feature = "graph")]
22pub use graph::GraphStore;
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26#[non_exhaustive]
27pub enum SearchQuery {
28 ExactSpelling(String),
30 DefinitionContains(String),
32 Form {
34 value: String,
36 role: Option<FormRole>,
38 },
39 CrossReferences {
41 spelling: String,
43 depth: u8,
45 },
46 Field {
48 name: String,
50 state: PresenceState,
52 raw_value: Option<String>,
54 },
55 ProposalExact(String),
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
61#[non_exhaustive]
62pub enum PresenceState {
63 Missing,
65 Null,
67 Value,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
73pub struct EdgeHit {
74 pub origin_id: String,
76 pub target_id: Option<String>,
78 pub target_spelling: String,
80 pub byte_start: usize,
82 pub byte_end: usize,
84 pub rule_version: String,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90pub struct SearchResult {
91 pub record_ids: BTreeSet<String>,
93 pub edges: BTreeSet<EdgeHit>,
95 pub candidates: usize,
97 pub path: String,
99 pub fallback_reason: Option<String>,
101}
102
103#[derive(Debug)]
105#[non_exhaustive]
106pub enum StoreError {
107 ExistingPath,
109 InvalidStore(String),
111 InvalidArchive(String),
113 Backend(String),
115 Io(std::io::Error),
117}
118
119impl fmt::Display for StoreError {
120 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121 match self {
122 Self::ExistingPath => write!(f, "store path already exists"),
123 Self::InvalidStore(message) => write!(f, "invalid store: {message}"),
124 Self::InvalidArchive(message) => write!(f, "invalid archive: {message}"),
125 Self::Backend(message) => write!(f, "backend error: {message}"),
126 Self::Io(error) => write!(f, "filesystem error: {error}"),
127 }
128 }
129}
130
131impl Error for StoreError {
132 fn source(&self) -> Option<&(dyn Error + 'static)> {
133 match self {
134 Self::Io(error) => Some(error),
135 _ => None,
136 }
137 }
138}
139
140#[must_use]
142pub fn archive_sha256(bytes: &[u8]) -> String {
143 format!("{:x}", Sha256::digest(bytes))
144}
145
146pub fn validate_archive(bytes: &[u8]) -> Result<Lexicon, StoreError> {
148 Lexicon::from_archive_bytes(bytes)
149 .map_err(|error| StoreError::InvalidArchive(error.to_string()))
150}
151
152#[must_use]
157pub fn record_id(identity: &RecordIdentity) -> String {
158 let parts = [
159 identity.snapshot().namespace().as_bytes(),
160 identity.snapshot().sha256().as_bytes(),
161 identity.locator().as_bytes(),
162 ];
163 let mut tuple = Vec::new();
164 for part in parts {
165 tuple.extend_from_slice(&(part.len() as u64).to_be_bytes());
166 tuple.extend_from_slice(part);
167 }
168 let mut out = String::with_capacity(tuple.len() * 2);
169 for byte in tuple {
170 use fmt::Write as _;
171 let _ = write!(out, "{byte:02x}");
172 }
173 out
174}
175
176pub(crate) fn edge_hits(reference: &SourceCrossReference) -> Vec<EdgeHit> {
177 let targets: Vec<Option<String>> = if reference.targets().is_empty() {
178 vec![None]
179 } else {
180 reference
181 .targets()
182 .iter()
183 .map(|target| Some(record_id(target)))
184 .collect()
185 };
186 targets
187 .into_iter()
188 .map(|target_id| EdgeHit {
189 origin_id: record_id(reference.origin()),
190 target_id,
191 target_spelling: reference.target_spelling().to_owned(),
192 byte_start: reference.byte_start(),
193 byte_end: reference.byte_end(),
194 rule_version: reference.rule_version().to_owned(),
195 })
196 .collect()
197}
198
199pub(crate) fn role_name(role: FormRole) -> &'static str {
200 match role {
201 FormRole::SourceReportedForm => "source-reported",
202 FormRole::AssignedRafsi => "assigned-rafsi",
203 _ => "other-source-role",
204 }
205}
206
207#[must_use]
211pub fn scan_reference(lexicon: &Lexicon, query: &SearchQuery) -> SearchResult {
212 let mut result = empty_result("independent-rust-scan", None);
213 match query {
214 SearchQuery::ExactSpelling(spelling) => {
215 for record in lexicon.lookup_exact(spelling) {
216 result.record_ids.insert(record_id(record.identity()));
217 }
218 }
219 SearchQuery::DefinitionContains(term) => {
220 let needle = term.to_ascii_lowercase();
221 if needle.is_empty() {
222 return result;
223 }
224 for record in lexicon.records() {
225 if record
226 .definition()
227 .is_some_and(|definition| definition.to_ascii_lowercase().contains(&needle))
228 {
229 result.record_ids.insert(record_id(record.identity()));
230 }
231 }
232 }
233 SearchQuery::Form { value, role } => {
234 for form in lexicon.forms() {
235 if form.form() == value && role.is_none_or(|expected| form.role() == expected) {
236 result.record_ids.insert(record_id(form.source_record()));
237 }
238 }
239 }
240 SearchQuery::CrossReferences { spelling, depth } => {
241 if !(1..=2).contains(depth) {
242 return result;
243 }
244 let mut frontier: BTreeSet<String> = lexicon
245 .lookup_exact(spelling)
246 .into_iter()
247 .map(|record| record_id(record.identity()))
248 .collect();
249 for _ in 0..*depth {
250 let mut next = BTreeSet::new();
251 for reference in lexicon.cross_references() {
252 for hit in edge_hits(reference) {
253 if frontier.contains(&hit.origin_id) {
254 result.edges.insert(hit.clone());
255 if let Some(target_id) = hit.target_id {
256 next.insert(target_id.clone());
257 result.record_ids.insert(target_id);
258 }
259 }
260 }
261 }
262 frontier = next;
263 }
264 }
265 SearchQuery::Field {
266 name,
267 state,
268 raw_value,
269 } => {
270 for record in lexicon.records() {
271 let presence = record.field(name);
272 let matches = match (state, presence) {
273 (PresenceState::Missing, FieldPresence::Missing) => raw_value.is_none(),
274 (PresenceState::Null, FieldPresence::Null) => raw_value
275 .as_deref()
276 .is_none_or(|expected| expected == "null"),
277 (PresenceState::Value, FieldPresence::Value(raw)) => {
278 raw_value.as_deref().is_none_or(|expected| expected == raw)
279 }
280 _ => false,
281 };
282 if matches {
283 result.record_ids.insert(record_id(record.identity()));
284 }
285 }
286 }
287 SearchQuery::ProposalExact(spelling) => {
288 for proposal in lexicon.lookup_proposals(spelling) {
289 result.record_ids.insert(proposal.id().to_owned());
290 }
291 }
292 }
293 result.candidates = result.record_ids.len();
294 result
295}
296
297pub(crate) fn empty_result(path: &str, fallback_reason: Option<String>) -> SearchResult {
298 SearchResult {
299 record_ids: BTreeSet::new(),
300 edges: BTreeSet::new(),
301 candidates: 0,
302 path: path.to_owned(),
303 fallback_reason,
304 }
305}
306
307pub trait LexicalStore {
309 fn search(&self, query: &SearchQuery) -> Result<SearchResult, StoreError>;
311 fn archive_bytes(&self) -> Result<Vec<u8>, StoreError>;
313 fn flush(&self) -> Result<(), StoreError>;
315 fn logical_bytes(&self) -> Result<u64, StoreError>;
317}
318
319pub(crate) fn require_fresh(path: &Path) -> Result<(), StoreError> {
320 if path.exists() {
321 return Err(StoreError::ExistingPath);
322 }
323 Ok(())
324}
325
326pub(crate) fn directory_bytes(path: &Path) -> Result<u64, StoreError> {
327 fn walk(path: &Path, total: &mut u64) -> Result<(), StoreError> {
328 for entry in std::fs::read_dir(path).map_err(StoreError::Io)? {
329 let entry = entry.map_err(StoreError::Io)?;
330 let metadata = entry.metadata().map_err(StoreError::Io)?;
331 if metadata.is_dir() {
332 walk(&entry.path(), total)?;
333 } else if metadata.is_file() {
334 *total = total.saturating_add(metadata.len());
335 }
336 }
337 Ok(())
338 }
339 let mut total = 0;
340 walk(path, &mut total)?;
341 Ok(total)
342}
343
344#[cfg(test)]
345mod identity_tests {
346 use super::*;
347 use vlajmaji_lexicon::{SourceFormat, SourceMetadata, TimePrecision};
348
349 #[test]
350 fn tuple_identity_is_length_prefixed_and_byte_exact() {
351 let mut lex = Lexicon::new();
352 let src = SourceMetadata::new(
353 "a\0b",
354 "x",
355 SourceFormat::LaLojbanJson,
356 "m",
357 "r",
358 None,
359 TimePrecision::Unknown,
360 );
361 lex.import_source(src, br#"[{"word":"c","definition":"d"}]"#, None)
362 .unwrap();
363 let left = record_id(lex.records()[0].identity());
364 let mut other = Lexicon::new();
365 let src = SourceMetadata::new(
366 "a",
367 "x",
368 SourceFormat::LaLojbanJson,
369 "m",
370 "r",
371 None,
372 TimePrecision::Unknown,
373 );
374 other
375 .import_source(src, br#"[{"word":"b\u0000c","definition":"d"}]"#, None)
376 .unwrap();
377 assert_ne!(left, record_id(other.records()[0].identity()));
378 }
379}