1use std::collections::BTreeMap;
15use std::path::{Path, PathBuf};
16use std::time::SystemTime;
17
18use anyhow::{Context, Result, bail};
19use rusqlite::types::Value as SqlValue;
20use rusqlite::{Connection, OptionalExtension, Row, params_from_iter};
21use serde::Serialize;
22
23use crate::card::{Card, CardOptions};
24use crate::config::{About, Config, Fields, Rank, plural};
25use crate::fields::Mapping;
26use crate::index::{check_schema, meta, open_ro, stored_config};
27use crate::text::{Query, facet_token, group_token, normalize_bound, normalize_name};
28
29const FUZZY_CUTOFF: f64 = 0.6;
30pub const MAX_LIMIT: usize = 50;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, clap::ValueEnum)]
33#[serde(rename_all = "snake_case")]
34pub enum Scope {
35 Group,
37 Others,
39 All,
41}
42
43#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, clap::ValueEnum)]
44#[serde(rename_all = "snake_case")]
45pub enum Sort {
46 #[default]
48 Relevance,
49 Newest,
50}
51
52#[derive(Debug, Clone, Serialize)]
53pub struct GroupMatch {
54 pub key: String,
55 #[serde(skip_serializing_if = "Option::is_none")]
56 pub name: Option<String>,
57 pub records: i64,
58 pub match_type: &'static str,
59 #[serde(skip_serializing_if = "Option::is_none")]
60 pub similarity: Option<f64>,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
64#[serde(rename_all = "snake_case")]
65pub enum Status {
66 Ok,
67 AmbiguousGroup,
68 UnknownGroup,
69 BadRequest,
70}
71
72#[derive(Debug, Clone, Default)]
73pub struct SearchRequest {
74 pub group: Option<String>,
75 pub query: String,
76 pub scope: Option<Scope>,
78 pub filters: Vec<(String, String)>,
80 pub since: Option<String>,
81 pub until: Option<String>,
82 pub sort: Sort,
83 pub limit: usize,
84 pub offset: usize,
85 pub fallback: bool,
87}
88
89#[derive(Debug, Serialize)]
90pub struct Filter {
91 pub field: String,
92 pub value: String,
93}
94
95#[derive(Debug, Serialize)]
96pub struct SearchOutcome {
97 pub status: Status,
98 pub query: String,
99 pub scope: Scope,
100 pub sort: Sort,
101 #[serde(skip_serializing_if = "Option::is_none")]
102 pub group: Option<GroupMatch>,
103 #[serde(skip_serializing_if = "Vec::is_empty")]
104 pub candidates: Vec<GroupMatch>,
105 #[serde(skip_serializing_if = "Vec::is_empty")]
106 pub filters: Vec<Filter>,
107 #[serde(skip_serializing_if = "Option::is_none")]
108 pub since: Option<String>,
109 #[serde(skip_serializing_if = "Option::is_none")]
110 pub until: Option<String>,
111 pub total_matches: i64,
113 #[serde(skip_serializing_if = "is_zero")]
114 pub offset: usize,
115 pub results: Vec<Card>,
116 #[serde(skip_serializing_if = "Vec::is_empty")]
117 pub other_groups: Vec<Card>,
118 pub corpus_records: i64,
119 #[serde(skip_serializing_if = "Vec::is_empty")]
120 pub notes: Vec<String>,
121 #[serde(skip)]
122 pub about: About,
123}
124
125fn is_zero(n: &usize) -> bool {
126 *n == 0
127}
128
129#[derive(Debug, Serialize)]
130pub struct FacetSummary {
131 pub field: String,
132 pub distinct: i64,
133 pub top: Vec<(String, i64)>,
134}
135
136#[derive(Debug, Serialize)]
138pub struct Description {
139 pub index_path: PathBuf,
140 pub index_bytes: u64,
141 pub about: About,
142 pub fields: Fields,
143 pub rank: Rank,
144 pub record_count: i64,
145 pub group_count: i64,
146 #[serde(skip_serializing_if = "Option::is_none")]
147 pub date_min: Option<String>,
148 #[serde(skip_serializing_if = "Option::is_none")]
149 pub date_max: Option<String>,
150 pub filters: Vec<FacetSummary>,
151 #[serde(skip_serializing_if = "Vec::is_empty")]
152 pub largest_groups: Vec<GroupMatch>,
153 #[serde(skip_serializing_if = "Option::is_none")]
154 pub example_id: Option<String>,
155 pub mapping_inferred: bool,
156 #[serde(skip_serializing_if = "Option::is_none")]
157 pub built_at: Option<String>,
158 #[serde(skip_serializing_if = "Option::is_none")]
159 pub updated_at: Option<String>,
160 pub skipped_lines: i64,
161 pub schema_version: String,
162}
163
164enum Pick {
165 One(GroupMatch),
166 Many(Vec<GroupMatch>),
167 None,
168}
169
170pub struct Store {
171 conn: Connection,
172 path: PathBuf,
173 stamp: Option<(u64, SystemTime)>,
174 mapping: Mapping,
175 pub card_opts: CardOptions,
176}
177
178fn file_stamp(path: &Path) -> Option<(u64, SystemTime)> {
179 let meta = std::fs::metadata(path).ok()?;
180 Some((meta.len(), meta.modified().ok()?))
181}
182
183impl Store {
184 pub fn open(path: &Path, card_opts: CardOptions) -> Result<Self> {
185 if !path.exists() {
186 bail!(
187 "no index at {}. Build one with `leviathan index <data>` \
188 or point --index / LEVIATHAN_INDEX at an existing one",
189 path.display()
190 );
191 }
192 let conn = open_ro(path).with_context(|| format!("open index {}", path.display()))?;
193 check_schema(&conn, path)?;
194 let mapping = Mapping::new(&stored_config(&conn)?)?;
195 Ok(Self { conn, path: path.to_path_buf(), stamp: file_stamp(path), mapping, card_opts })
196 }
197
198 pub fn reload_if_replaced(&mut self) -> Result<bool> {
200 let now = file_stamp(&self.path);
201 if now.is_some() && now != self.stamp {
202 *self = Self::open(&self.path, self.card_opts)?;
203 return Ok(true);
204 }
205 Ok(false)
206 }
207
208 pub fn config(&self) -> &Config {
209 &self.mapping.config
210 }
211
212 fn meta(&self, key: &str) -> Result<Option<String>> {
213 meta(&self.conn, key)
214 }
215
216 fn meta_i64(&self, key: &str) -> Result<i64> {
217 Ok(self.meta(key)?.and_then(|v| v.parse().ok()).unwrap_or(0))
218 }
219
220 fn groups_where(
223 &self,
224 clause: &str,
225 arg: &str,
226 match_type: &'static str,
227 limit: usize,
228 ) -> Result<Vec<GroupMatch>> {
229 let sql = format!(
230 "SELECT key, name, record_count FROM groups WHERE {clause} ORDER BY record_count DESC, key LIMIT ?2"
231 );
232 Ok(self
233 .conn
234 .prepare_cached(&sql)?
235 .query_map(rusqlite::params![arg, limit as i64], |r| group_row(r, match_type))?
236 .collect::<rusqlite::Result<Vec<_>>>()?)
237 }
238
239 pub fn resolve(&self, query: &str, limit: usize) -> Result<Vec<GroupMatch>> {
241 let query = query.trim();
242 if query.is_empty() || !self.mapping.has_group() {
243 return Ok(Vec::new());
244 }
245 let limit = limit.clamp(1, MAX_LIMIT);
246 let lower = query.to_lowercase();
247 let normalized = normalize_name(query);
248 let tiers: [(&str, &str, &'static str); 3] = [
249 ("key = ?1", query, "exact"),
250 ("key_lower = ?1", &lower, "case_insensitive"),
251 ("name_normalized = ?1", &normalized, "name"),
252 ];
253 for (clause, arg, kind) in tiers {
254 let rows = self.groups_where(clause, arg, kind, limit)?;
255 if !rows.is_empty() {
256 return Ok(rows);
257 }
258 }
259
260 let escaped = normalized.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_");
261 let contains = self.groups_where(
262 "name_normalized LIKE '%' || ?1 || '%' ESCAPE '\\' OR key_lower LIKE '%' || ?1 || '%' ESCAPE '\\'",
263 &escaped,
264 "contains",
265 limit,
266 )?;
267 if !contains.is_empty() {
268 return Ok(contains);
269 }
270
271 let mut stmt = self
272 .conn
273 .prepare_cached("SELECT key, name, record_count, key_lower, name_normalized FROM groups")?;
274 let mut scored: Vec<GroupMatch> = stmt
275 .query_map([], |r| {
276 let key_lower: Option<String> = r.get(3)?;
277 let name_norm: Option<String> = r.get(4)?;
278 let mut m = group_row(r, "fuzzy")?;
279 let score = [key_lower, name_norm]
280 .iter()
281 .flatten()
282 .map(|c| strsim::sorensen_dice(&normalized, c))
283 .fold(0.0, f64::max);
284 m.similarity = Some((score * 100.0).round() / 100.0);
285 Ok(m)
286 })?
287 .filter_map(|m| m.ok())
288 .filter(|m| m.similarity.unwrap_or(0.0) >= FUZZY_CUTOFF)
289 .collect();
290 scored
291 .sort_by(|a, b| b.similarity.partial_cmp(&a.similarity).unwrap().then(b.records.cmp(&a.records)));
292 scored.truncate(limit);
293 Ok(scored)
294 }
295
296 fn pick(&self, group: &str) -> Result<Pick> {
297 let mut found = self.resolve(group, 10)?;
298 Ok(match found.len() {
299 0 => Pick::None,
300 1 => Pick::One(found.remove(0)),
301 _ => Pick::Many(found),
302 })
303 }
304
305 pub fn search(&self, req: &SearchRequest) -> Result<SearchOutcome> {
308 let about = self.config().about.clone();
309 let group_noun = about.group.clone();
310 let limit = req.limit.clamp(1, MAX_LIMIT);
311 let scope = if req.group.is_some() { req.scope.unwrap_or(Scope::Group) } else { Scope::All };
312 let mut out = SearchOutcome {
313 status: Status::Ok,
314 query: req.query.clone(),
315 scope,
316 sort: req.sort,
317 group: None,
318 candidates: Vec::new(),
319 filters: req.filters.iter().map(|(f, v)| Filter { field: f.clone(), value: v.clone() }).collect(),
320 since: None,
321 until: None,
322 total_matches: 0,
323 offset: req.offset,
324 results: Vec::new(),
325 other_groups: Vec::new(),
326 corpus_records: self.meta_i64("record_count")?,
327 notes: Vec::new(),
328 about,
329 };
330 let bad = |mut out: SearchOutcome, note: String| {
331 out.status = Status::BadRequest;
332 out.notes.push(note);
333 Ok(out)
334 };
335
336 let filterable: Vec<&str> = self.mapping.filters.iter().map(|p| p.raw.as_str()).collect();
337 for (field, _) in &req.filters {
338 if !filterable.contains(&field.as_str()) {
339 let available =
340 if filterable.is_empty() { "none".to_string() } else { filterable.join(", ") };
341 return bad(out, format!("`{field}` is not a filter field; filterable: {available}"));
342 }
343 }
344 for raw in [&req.since, &req.until].into_iter().flatten() {
345 if normalize_bound(raw).is_none() {
346 return bad(out, format!("unrecognized date {raw:?}; use YYYY, YYYY-MM or YYYY-MM-DD"));
347 }
348 }
349 out.since = req.since.as_deref().and_then(normalize_bound);
350 out.until = req.until.as_deref().and_then(normalize_bound);
351 if (out.since.is_some() || out.until.is_some()) && !self.mapping.has_date() {
352 return bad(out, "this index has no date field, so --since/--until cannot apply".into());
353 }
354
355 if let Some(group) = &req.group {
356 if !self.mapping.has_group() {
357 return bad(out, "this index has no group field; search without one".into());
358 }
359 match self.pick(group)? {
360 Pick::One(m) => out.group = Some(m),
361 Pick::Many(c) => {
362 out.status = Status::AmbiguousGroup;
363 out.notes.push(format!(
364 "{} {} match {group:?}; ask which one, then retry with its exact key",
365 c.len(),
366 plural(&group_noun)
367 ));
368 out.candidates = c;
369 return Ok(out);
370 }
371 Pick::None => {
372 out.status = Status::UnknownGroup;
373 out.notes.push(format!(
374 "no {group_noun} matches {group:?}; try `resolve` with part of the name, or search without one"
375 ));
376 return Ok(out);
377 }
378 }
379 }
380
381 let query = Query::parse(&req.query);
382 if query.is_empty() && !req.query.trim().is_empty() && query.excluded.is_empty() {
383 out.notes.push("the query has no searchable words; listing newest instead".into());
384 }
385 let plan = Plan {
386 group: out.group.as_ref().map(|g| (g.key.clone(), group_token(&g.key))),
387 scope,
388 filters: self.filter_clauses(&req.filters),
389 since: out.since.clone(),
390 until: out.until.clone(),
391 };
392 let highlight = query.highlight();
393
394 if query.is_empty() {
395 (out.total_matches, out.results) = self.browse(&plan, limit, req.offset)?;
396 if !query.excluded.is_empty() {
397 out.notes.push("exclusions need at least one positive word; they were ignored".into());
398 }
399 return Ok(out);
400 }
401
402 let positive = query.fts().expect("non-empty query");
403 let negative = query.not_fts();
404 let expr = plan.expr(&positive, negative.as_deref(), scope);
405 let sort = req.sort;
406 (out.total_matches, out.results) = self.ranked(&expr, &plan, sort, limit, req.offset, &highlight)?;
407
408 if scope == Scope::Group
409 && out.results.is_empty()
410 && req.offset == 0
411 && req.fallback
412 && plan.group.is_some()
413 {
414 let others = plan.expr(&positive, negative.as_deref(), Scope::Others);
415 out.other_groups = self.ranked(&others, &plan, sort, limit, 0, &highlight)?.1;
416 for c in &mut out.other_groups {
417 c.other_group = true;
418 }
419 if !out.other_groups.is_empty() {
420 out.notes.push(format!(
421 "nothing matched in this {group_noun}; other_groups results come from OTHER {} - say so and keep each card's {group_noun} visible",
422 plural(&group_noun)
423 ));
424 }
425 }
426 Ok(out)
427 }
428
429 fn filter_clauses(&self, filters: &[(String, String)]) -> Vec<String> {
431 let mut by_field: BTreeMap<&str, Vec<String>> = BTreeMap::new();
432 for (field, value) in filters {
433 by_field
434 .entry(field.as_str())
435 .or_default()
436 .push(format!("tags : \"{}\"", facet_token(field, value)));
437 }
438 by_field.into_values().map(|alts| format!("({})", alts.join(" OR "))).collect()
439 }
440
441 fn ranked(
443 &self,
444 expr: &str,
445 plan: &Plan,
446 sort: Sort,
447 limit: usize,
448 offset: usize,
449 highlight: &[String],
450 ) -> Result<(i64, Vec<Card>)> {
451 struct Hit {
452 score: f64,
453 date: String,
454 rowid: i64,
455 }
456 let want = offset + limit;
457 let overfetch = if sort == Sort::Newest { want } else { (want * 10).max(100) } as i64;
458 let (dates, mut args) = plan.date_sql(2);
459 args.insert(0, SqlValue::Text(expr.to_string()));
460 args.push(SqlValue::Integer(overfetch));
461 let limit_param = args.len();
462 let sql = if dates.is_empty() && sort == Sort::Relevance {
463 format!(
464 "SELECT r.rowid, r.boost, r.date, f.rank, f.total FROM \
465 (SELECT rowid, rank, count(*) OVER () AS total FROM record_fts WHERE record_fts MATCH ?1 \
466 ORDER BY rank LIMIT ?{limit_param}) AS f \
467 JOIN records AS r ON r.rowid = f.rowid"
468 )
469 } else {
470 let order = if sort == Sort::Newest { "r.date DESC, r.rowid DESC" } else { "record_fts.rank" };
471 format!(
472 "SELECT r.rowid, r.boost, r.date, record_fts.rank, count(*) OVER () FROM record_fts \
473 JOIN records AS r ON r.rowid = record_fts.rowid \
474 WHERE record_fts MATCH ?1 {dates} ORDER BY {order} LIMIT ?{limit_param}"
475 )
476 };
477 let mut total = 0i64;
478 let mut hits: Vec<Hit> = self
479 .conn
480 .prepare_cached(&sql)?
481 .query_map(params_from_iter(args), |r| {
482 let boost: f64 = r.get(1)?;
483 let bm25: f64 = r.get(3)?;
484 total = r.get(4)?;
485 Ok(Hit {
486 score: bm25 * boost,
487 date: r.get::<_, Option<String>>(2)?.unwrap_or_default(),
488 rowid: r.get(0)?,
489 })
490 })?
491 .collect::<rusqlite::Result<_>>()?;
492 if sort == Sort::Relevance {
493 hits.sort_by(|a, b| a.score.total_cmp(&b.score).then_with(|| b.date.cmp(&a.date)));
494 }
495 let cards = hits
496 .into_iter()
497 .skip(offset)
498 .take(limit)
499 .enumerate()
500 .map(|(i, hit)| {
501 let mut card = self.card(hit.rowid, highlight)?;
502 card.rank = Some(offset + i + 1);
503 card.relevance = Some((-hit.score * 100.0).round() / 100.0);
504 Ok(card)
505 })
506 .collect::<Result<_>>()?;
507 Ok((total, cards))
508 }
509
510 fn browse(&self, plan: &Plan, limit: usize, offset: usize) -> Result<(i64, Vec<Card>)> {
514 let (dates, mut args) = plan.date_sql(1);
515 let mut clauses = vec!["1".to_string()];
516 if !dates.is_empty() {
517 clauses.push(dates.trim_start_matches("AND ").to_string());
518 }
519 if let Some((key, _)) = &plan.group
520 && plan.scope != Scope::All
521 {
522 args.push(SqlValue::Text(key.clone()));
523 let n = args.len();
524 clauses.push(if plan.scope == Scope::Group {
525 format!("r.grp = ?{n}")
526 } else {
527 format!("(r.grp IS NULL OR r.grp <> ?{n})")
528 });
529 }
530 if !plan.filters.is_empty() {
531 args.push(SqlValue::Text(plan.filters.join(" AND ")));
532 clauses.push(format!(
533 "r.rowid IN (SELECT rowid FROM record_fts WHERE record_fts MATCH ?{})",
534 args.len()
535 ));
536 }
537 let where_sql = clauses.join(" AND ");
538 let total: i64 = self.conn.query_row(
539 &format!("SELECT count(*) FROM records AS r WHERE {where_sql}"),
540 params_from_iter(args.clone()),
541 |r| r.get(0),
542 )?;
543 let n = args.len();
544 args.push(SqlValue::Integer(limit as i64));
545 args.push(SqlValue::Integer(offset as i64));
546 let rows: Vec<i64> = self
547 .conn
548 .prepare_cached(&format!(
549 "SELECT r.rowid FROM records AS r WHERE {where_sql} \
550 ORDER BY r.date DESC, r.rowid DESC LIMIT ?{} OFFSET ?{}",
551 n + 1,
552 n + 2
553 ))?
554 .query_map(params_from_iter(args), |r| r.get(0))?
555 .collect::<rusqlite::Result<_>>()?;
556 let cards = rows.iter().map(|rowid| self.card(*rowid, &[])).collect::<Result<_>>()?;
557 Ok((total, cards))
558 }
559
560 fn card(&self, rowid: i64, highlight: &[String]) -> Result<Card> {
561 let (id, doc): (String, String) = self
562 .conn
563 .prepare_cached("SELECT id, doc FROM records WHERE rowid = ?1")?
564 .query_row([rowid], |r| Ok((r.get(0)?, r.get(1)?)))?;
565 Card::build(&id, &doc, &self.mapping, self.card_opts, highlight)
566 }
567
568 pub fn get(&self, id: &str) -> Result<Option<serde_json::Value>> {
570 let raw: Option<String> = self
571 .conn
572 .prepare_cached("SELECT doc FROM records WHERE id = ?1")?
573 .query_row([id.trim()], |r| r.get(0))
574 .optional()?;
575 raw.map(|r| serde_json::from_str(&r).map_err(Into::into)).transpose()
576 }
577
578 pub fn describe(&self, top: usize) -> Result<Description> {
581 let cfg = self.config();
582 let mut filters = Vec::new();
583 for p in &self.mapping.filters {
584 let distinct: i64 =
585 self.conn
586 .query_row("SELECT COUNT(*) FROM facets WHERE field = ?1", [&p.raw], |r| r.get(0))?;
587 let top_values = self
588 .conn
589 .prepare_cached(
590 "SELECT value, count FROM facets WHERE field = ?1 ORDER BY count DESC, value LIMIT ?2",
591 )?
592 .query_map(rusqlite::params![p.raw, top as i64], |r| Ok((r.get(0)?, r.get(1)?)))?
593 .collect::<rusqlite::Result<_>>()?;
594 filters.push(FacetSummary { field: p.raw.clone(), distinct, top: top_values });
595 }
596 let largest_groups = self
597 .conn
598 .prepare_cached(
599 "SELECT key, name, record_count FROM groups ORDER BY record_count DESC, key LIMIT 5",
600 )?
601 .query_map([], |r| group_row(r, "listing"))?
602 .collect::<rusqlite::Result<_>>()?;
603 let nonempty = |v: Option<String>| v.filter(|s| !s.is_empty());
604 Ok(Description {
605 index_path: self.path.clone(),
606 index_bytes: std::fs::metadata(&self.path).map(|m| m.len()).unwrap_or(0),
607 about: cfg.about.clone(),
608 fields: cfg.fields.clone(),
609 rank: cfg.rank.clone(),
610 record_count: self.meta_i64("record_count")?,
611 group_count: self.meta_i64("group_count")?,
612 date_min: nonempty(self.meta("date_min")?),
613 date_max: nonempty(self.meta("date_max")?),
614 filters,
615 largest_groups,
616 example_id: self
617 .conn
618 .query_row("SELECT id FROM records ORDER BY date DESC, rowid DESC LIMIT 1", [], |r| r.get(0))
619 .optional()?,
620 mapping_inferred: self.meta("mapping_inferred")?.as_deref() == Some("true"),
621 built_at: self.meta("built_at")?,
622 updated_at: self.meta("updated_at")?,
623 skipped_lines: self.meta_i64("skipped_lines")?,
624 schema_version: self.meta("schema_version")?.unwrap_or_default(),
625 })
626 }
627}
628
629#[derive(Debug, Clone)]
631struct Plan {
632 group: Option<(String, String)>,
634 scope: Scope,
635 filters: Vec<String>,
636 since: Option<String>,
637 until: Option<String>,
638}
639
640impl Plan {
641 fn expr(&self, positive: &str, negative: Option<&str>, scope: Scope) -> String {
642 let mut parts: Vec<String> = Vec::new();
643 if let (Some((_, token)), Scope::Group) = (&self.group, scope) {
644 parts.push(format!("tags : \"{token}\""));
645 }
646 parts.extend(self.filters.iter().cloned());
647 parts.push(if scope == Scope::Group && self.group.is_some() {
648 format!("{{title body}} : ({positive})")
649 } else {
650 format!("({positive})")
651 });
652 let mut expr = parts.join(" AND ");
653 if let (Some((_, token)), Scope::Others) = (&self.group, scope) {
654 expr = format!("({expr}) NOT tags : \"{token}\"");
655 }
656 if let Some(neg) = negative {
657 expr = format!("({expr}) NOT ({neg})");
658 }
659 expr
660 }
661
662 fn date_sql(&self, first: usize) -> (String, Vec<SqlValue>) {
666 let mut sql = String::new();
667 let mut args = Vec::new();
668 if let Some(since) = &self.since {
669 sql.push_str(&format!(" AND r.date >= ?{}", first + args.len()));
670 args.push(SqlValue::Text(since.clone()));
671 }
672 if let Some(until) = &self.until {
673 let n = first + args.len();
674 sql.push_str(&format!(" AND substr(r.date, 1, length(?{n})) <= ?{n}"));
675 args.push(SqlValue::Text(until.clone()));
676 }
677 (sql.trim_start().to_string(), args)
678 }
679}
680
681fn group_row(r: &Row<'_>, match_type: &'static str) -> rusqlite::Result<GroupMatch> {
682 Ok(GroupMatch { key: r.get(0)?, name: r.get(1)?, records: r.get(2)?, match_type, similarity: None })
683}