1use std::path::{Path, PathBuf};
30
31use rusqlite::{Connection, OpenFlags, params};
32
33use crate::entry::LogEntry;
34use crate::error::{LogdiveError, Result};
35
36pub const BATCH_SIZE: usize = 1000;
39
40const DEFAULT_DB_FILENAME: &str = "index.db";
41const LOGDIVE_HOME_DIRNAME: &str = ".logdive";
42
43pub fn db_path(override_path: Option<&Path>) -> PathBuf {
51 if let Some(p) = override_path {
52 return p.to_path_buf();
53 }
54 let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
58 PathBuf::from(home)
59 .join(LOGDIVE_HOME_DIRNAME)
60 .join(DEFAULT_DB_FILENAME)
61}
62
63#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
66pub struct InsertStats {
67 pub inserted: usize,
69 pub deduplicated: usize,
72 pub skipped_no_timestamp: usize,
74}
75
76impl InsertStats {
77 fn extend(&mut self, other: InsertStats) {
78 self.inserted += other.inserted;
79 self.deduplicated += other.deduplicated;
80 self.skipped_no_timestamp += other.skipped_no_timestamp;
81 }
82}
83
84#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
90#[non_exhaustive]
91pub struct PruneStats {
92 pub deleted: u64,
94}
95
96#[derive(Debug, Clone)]
112#[non_exhaustive]
113pub struct Stats {
114 pub entries: u64,
116 pub min_timestamp: Option<String>,
120 pub max_timestamp: Option<String>,
123 pub tags: Vec<Option<String>>,
127}
128
129#[derive(Debug)]
131pub struct Indexer {
132 conn: Connection,
133}
134
135impl Indexer {
136 pub fn open(path: &Path) -> Result<Self> {
141 ensure_parent_dir(path)?;
142 let conn = Connection::open(path)?;
143 init_schema(&conn)?;
144 Ok(Self { conn })
145 }
146
147 pub fn open_in_memory() -> Result<Self> {
150 let conn = Connection::open_in_memory()?;
151 init_schema(&conn)?;
152 Ok(Self { conn })
153 }
154
155 pub fn open_read_only(path: &Path) -> Result<Self> {
169 let flags = OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI;
173 let conn = Connection::open_with_flags(path, flags)?;
174 Ok(Self { conn })
175 }
176
177 pub fn connection(&self) -> &Connection {
183 &self.conn
184 }
185
186 pub fn insert_batch(&mut self, entries: &[LogEntry]) -> Result<InsertStats> {
192 let mut total = InsertStats::default();
193 for chunk in entries.chunks(BATCH_SIZE) {
194 let stats = insert_one_chunk(&mut self.conn, chunk)?;
195 total.extend(stats);
196 }
197 Ok(total)
198 }
199
200 pub fn prune(&mut self, cutoff: &str) -> Result<PruneStats> {
224 let deleted = self.conn.execute(
225 "DELETE FROM log_entries WHERE timestamp < ?1",
226 params![cutoff],
227 )?;
228 self.conn.execute_batch("VACUUM")?;
230 Ok(PruneStats {
231 deleted: deleted as u64,
232 })
233 }
234
235 pub fn stats(&self) -> Result<Stats> {
245 let entries_i64: i64 =
247 self.conn
248 .query_row("SELECT COUNT(*) FROM log_entries", [], |row| row.get(0))?;
249 let entries = entries_i64 as u64;
250
251 let (min_timestamp, max_timestamp): (Option<String>, Option<String>) =
255 self.conn.query_row(
256 "SELECT MIN(timestamp), MAX(timestamp) FROM log_entries",
257 [],
258 |row| Ok((row.get(0)?, row.get(1)?)),
259 )?;
260
261 let mut stmt = self
264 .conn
265 .prepare("SELECT DISTINCT tag FROM log_entries ORDER BY tag")?;
266 let rows = stmt.query_map([], |row| row.get::<_, Option<String>>(0))?;
267 let mut tags: Vec<Option<String>> = Vec::new();
268 for row in rows {
269 tags.push(row?);
270 }
271
272 Ok(Stats {
273 entries,
274 min_timestamp,
275 max_timestamp,
276 tags,
277 })
278 }
279}
280
281fn ensure_parent_dir(path: &Path) -> Result<()> {
286 let Some(parent) = path.parent() else {
287 return Ok(());
288 };
289 if parent.as_os_str().is_empty() {
290 return Ok(());
292 }
293 std::fs::create_dir_all(parent).map_err(|io_err| LogdiveError::io_at(parent, io_err))
294}
295
296fn init_schema(conn: &Connection) -> Result<()> {
297 conn.execute_batch(
300 "CREATE TABLE IF NOT EXISTS log_entries (
301 id INTEGER PRIMARY KEY AUTOINCREMENT,
302 timestamp TEXT NOT NULL,
303 level TEXT,
304 message TEXT,
305 tag TEXT,
306 fields TEXT,
307 raw TEXT NOT NULL,
308 raw_hash TEXT NOT NULL UNIQUE,
309 ingested_at TEXT NOT NULL DEFAULT (datetime('now'))
310 );
311 CREATE INDEX IF NOT EXISTS idx_level ON log_entries(level);
312 CREATE INDEX IF NOT EXISTS idx_tag ON log_entries(tag);
313 CREATE INDEX IF NOT EXISTS idx_timestamp ON log_entries(timestamp);",
314 )?;
315 Ok(())
316}
317
318fn insert_one_chunk(conn: &mut Connection, entries: &[LogEntry]) -> Result<InsertStats> {
319 let tx = conn.transaction()?;
320 let mut stats = InsertStats::default();
321
322 {
323 let mut stmt = tx.prepare(
324 "INSERT OR IGNORE INTO log_entries
325 (timestamp, level, message, tag, fields, raw, raw_hash)
326 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
327 )?;
328
329 for entry in entries {
330 let Some(ref ts) = entry.timestamp else {
332 stats.skipped_no_timestamp += 1;
333 continue;
334 };
335
336 let fields_json = serde_json::to_string(&entry.fields)
339 .expect("serializing serde_json::Map<String, Value> is infallible");
340 let raw_hash = blake3::hash(entry.raw.as_bytes()).to_hex().to_string();
341
342 let changes = stmt.execute(params![
343 ts,
344 entry.level,
345 entry.message,
346 entry.tag,
347 fields_json,
348 entry.raw,
349 raw_hash,
350 ])?;
351
352 if changes == 0 {
353 stats.deduplicated += 1;
354 } else {
355 stats.inserted += 1;
356 }
357 }
358 }
359
360 tx.commit()?;
361 Ok(stats)
362}
363
364#[cfg(test)]
369mod tests {
370 use super::*;
371 use serde_json::json;
372
373 fn make_entry(ts: &str, level: &str, message: &str) -> LogEntry {
377 let raw = format!(r#"{{"timestamp":"{ts}","level":"{level}","message":"{message}"}}"#);
378 let mut e = LogEntry::new(raw);
379 e.timestamp = Some(ts.to_string());
380 e.level = Some(level.to_string());
381 e.message = Some(message.to_string());
382 e
383 }
384
385 #[test]
386 fn open_in_memory_creates_table_and_three_indexes() {
387 let idx = Indexer::open_in_memory().expect("open in-memory");
388 let table_count: i64 = idx
389 .connection()
390 .query_row(
391 "SELECT COUNT(*) FROM sqlite_master \
392 WHERE type='table' AND name='log_entries'",
393 [],
394 |row| row.get(0),
395 )
396 .unwrap();
397 assert_eq!(table_count, 1);
398
399 let index_count: i64 = idx
400 .connection()
401 .query_row(
402 "SELECT COUNT(*) FROM sqlite_master \
403 WHERE type='index' AND name IN ('idx_level','idx_tag','idx_timestamp')",
404 [],
405 |row| row.get(0),
406 )
407 .unwrap();
408 assert_eq!(index_count, 3);
409 }
410
411 #[test]
412 fn insert_batch_adds_rows_and_reports_stats() {
413 let mut idx = Indexer::open_in_memory().unwrap();
414 let entries = vec![
415 make_entry("2026-04-20T10:00:00Z", "info", "one"),
416 make_entry("2026-04-20T10:00:01Z", "error", "two"),
417 ];
418 let stats = idx.insert_batch(&entries).unwrap();
419
420 assert_eq!(stats.inserted, 2);
421 assert_eq!(stats.deduplicated, 0);
422 assert_eq!(stats.skipped_no_timestamp, 0);
423
424 let count: i64 = idx
425 .connection()
426 .query_row("SELECT COUNT(*) FROM log_entries", [], |row| row.get(0))
427 .unwrap();
428 assert_eq!(count, 2);
429 }
430
431 #[test]
432 fn reinsert_is_deduplicated_by_raw_hash() {
433 let mut idx = Indexer::open_in_memory().unwrap();
434 let entries = vec![make_entry("2026-04-20T10:00:00Z", "info", "hello")];
435
436 let first = idx.insert_batch(&entries).unwrap();
437 assert_eq!(first.inserted, 1);
438 assert_eq!(first.deduplicated, 0);
439
440 let second = idx.insert_batch(&entries).unwrap();
441 assert_eq!(second.inserted, 0);
442 assert_eq!(second.deduplicated, 1);
443
444 let count: i64 = idx
445 .connection()
446 .query_row("SELECT COUNT(*) FROM log_entries", [], |row| row.get(0))
447 .unwrap();
448 assert_eq!(count, 1);
449 }
450
451 #[test]
452 fn entries_without_timestamp_are_skipped_not_fabricated() {
453 let mut idx = Indexer::open_in_memory().unwrap();
454 let mut no_ts = LogEntry::new(r#"{"level":"info"}"#);
455 no_ts.level = Some("info".to_string());
456
457 let stats = idx.insert_batch(&[no_ts]).unwrap();
458 assert_eq!(stats.inserted, 0);
459 assert_eq!(stats.skipped_no_timestamp, 1);
460
461 let count: i64 = idx
462 .connection()
463 .query_row("SELECT COUNT(*) FROM log_entries", [], |row| row.get(0))
464 .unwrap();
465 assert_eq!(count, 0);
466 }
467
468 #[test]
469 fn mixed_batch_counts_each_outcome_category() {
470 let mut idx = Indexer::open_in_memory().unwrap();
471 idx.insert_batch(&[make_entry("2026-04-20T10:00:00Z", "info", "first")])
472 .unwrap();
473
474 let mut no_ts = LogEntry::new(r#"{"level":"warn"}"#);
475 no_ts.level = Some("warn".to_string());
476
477 let mixed = vec![
478 make_entry("2026-04-20T10:00:00Z", "info", "first"),
479 make_entry("2026-04-20T10:00:05Z", "error", "second"),
480 no_ts,
481 ];
482 let stats = idx.insert_batch(&mixed).unwrap();
483 assert_eq!(stats.inserted, 1);
484 assert_eq!(stats.deduplicated, 1);
485 assert_eq!(stats.skipped_no_timestamp, 1);
486 }
487
488 #[test]
489 fn fields_are_stored_as_json_queryable_via_json_extract() {
490 let mut idx = Indexer::open_in_memory().unwrap();
491 let mut e = make_entry("2026-04-20T10:00:00Z", "info", "hi");
492 e.fields.insert("service".to_string(), json!("payments"));
493 e.fields.insert("req_id".to_string(), json!(42));
494 idx.insert_batch(&[e]).unwrap();
495
496 let service: String = idx
497 .connection()
498 .query_row(
499 "SELECT json_extract(fields, '$.service') FROM log_entries",
500 [],
501 |row| row.get(0),
502 )
503 .unwrap();
504 assert_eq!(service, "payments");
505
506 let req_id: i64 = idx
507 .connection()
508 .query_row(
509 "SELECT json_extract(fields, '$.req_id') FROM log_entries",
510 [],
511 |row| row.get(0),
512 )
513 .unwrap();
514 assert_eq!(req_id, 42);
515 }
516
517 #[test]
518 fn empty_fields_round_trip_as_empty_json_object_not_null() {
519 let mut idx = Indexer::open_in_memory().unwrap();
520 idx.insert_batch(&[make_entry("2026-04-20T10:00:00Z", "info", "x")])
521 .unwrap();
522
523 let stored: String = idx
524 .connection()
525 .query_row("SELECT fields FROM log_entries", [], |row| row.get(0))
526 .unwrap();
527 assert_eq!(stored, "{}");
528 }
529
530 #[test]
531 fn raw_hash_is_a_64_char_hex_blake3_digest() {
532 let mut idx = Indexer::open_in_memory().unwrap();
533 idx.insert_batch(&[make_entry("2026-04-20T10:00:00Z", "info", "hash me")])
534 .unwrap();
535
536 let stored_hash: String = idx
537 .connection()
538 .query_row("SELECT raw_hash FROM log_entries", [], |row| row.get(0))
539 .unwrap();
540 assert_eq!(stored_hash.len(), 64);
541 assert!(stored_hash.chars().all(|c| c.is_ascii_hexdigit()));
542 }
543
544 #[test]
545 fn chunking_handles_batches_larger_than_batch_size() {
546 let mut idx = Indexer::open_in_memory().unwrap();
547 let total = BATCH_SIZE + 337;
548 let entries: Vec<_> = (0..total)
549 .map(|i| make_entry("2026-04-20T10:00:00Z", "info", &format!("message-{i}")))
550 .collect();
551
552 let stats = idx.insert_batch(&entries).unwrap();
553 assert_eq!(stats.inserted, total);
554 assert_eq!(stats.deduplicated, 0);
555
556 let count: i64 = idx
557 .connection()
558 .query_row("SELECT COUNT(*) FROM log_entries", [], |row| row.get(0))
559 .unwrap();
560 assert_eq!(count, total as i64);
561 }
562
563 #[test]
564 fn db_path_returns_override_verbatim() {
565 let p = Path::new("/tmp/logdive-test/override.db");
566 assert_eq!(
567 db_path(Some(p)),
568 PathBuf::from("/tmp/logdive-test/override.db")
569 );
570 }
571
572 #[test]
573 fn db_path_default_ends_with_standard_location() {
574 let default = db_path(None);
575 assert!(default.ends_with(".logdive/index.db"));
576 }
577
578 #[test]
579 fn open_creates_parent_directory_and_is_idempotent_across_opens() {
580 let dir = tempfile::tempdir().unwrap();
581 let db = dir.path().join("sub").join("dir").join("index.db");
582
583 {
584 let mut idx = Indexer::open(&db).expect("first open");
585 idx.insert_batch(&[make_entry("2026-04-20T10:00:00Z", "info", "persist me")])
586 .unwrap();
587 }
588
589 {
590 let idx = Indexer::open(&db).expect("second open");
591 let count: i64 = idx
592 .connection()
593 .query_row("SELECT COUNT(*) FROM log_entries", [], |row| row.get(0))
594 .unwrap();
595 assert_eq!(count, 1);
596 }
597 }
598
599 #[test]
600 fn io_error_variant_attaches_parent_path() {
601 let dir = tempfile::tempdir().unwrap();
605 let blocker = dir.path().join("blocker");
606 std::fs::write(&blocker, b"not a directory").unwrap();
607 let bad_db = blocker.join("child").join("index.db");
608
609 let err = Indexer::open(&bad_db).unwrap_err();
610 match err {
611 LogdiveError::Io { path, .. } => {
612 assert!(path.starts_with(dir.path()));
613 }
614 other => panic!("expected Io variant, got {other:?}"),
615 }
616 }
617
618 #[test]
623 fn stats_empty_database_returns_zeroed_values() {
624 let idx = Indexer::open_in_memory().unwrap();
625 let stats = idx.stats().unwrap();
626
627 assert_eq!(stats.entries, 0);
628 assert_eq!(stats.min_timestamp, None);
629 assert_eq!(stats.max_timestamp, None);
630 assert!(stats.tags.is_empty());
631 }
632
633 #[test]
634 fn stats_counts_entries() {
635 let mut idx = Indexer::open_in_memory().unwrap();
636 let entries: Vec<_> = (0..5)
637 .map(|i| make_entry("2026-04-20T10:00:00Z", "info", &format!("msg-{i}")))
638 .collect();
639 idx.insert_batch(&entries).unwrap();
640
641 let stats = idx.stats().unwrap();
642 assert_eq!(stats.entries, 5);
643 }
644
645 #[test]
646 fn stats_timestamp_range_uses_lexical_min_and_max() {
647 let mut idx = Indexer::open_in_memory().unwrap();
648 idx.insert_batch(&[
651 make_entry("2026-04-22T15:30:00Z", "error", "second"),
652 make_entry("2026-04-20T10:00:00Z", "info", "first"),
653 make_entry("2026-04-21T12:00:00Z", "warn", "third"),
654 ])
655 .unwrap();
656
657 let stats = idx.stats().unwrap();
658 assert_eq!(stats.min_timestamp.as_deref(), Some("2026-04-20T10:00:00Z"));
659 assert_eq!(stats.max_timestamp.as_deref(), Some("2026-04-22T15:30:00Z"));
660 }
661
662 #[test]
663 fn stats_distinct_tags_place_untagged_first_then_alphabetical() {
664 let mut idx = Indexer::open_in_memory().unwrap();
665
666 let untagged = make_entry("2026-04-20T10:00:00Z", "info", "untagged-msg");
668
669 let mut api1 = make_entry("2026-04-20T10:00:01Z", "info", "api-msg-1");
671 api1.tag = Some("api".to_string());
672 let mut api2 = make_entry("2026-04-20T10:00:02Z", "info", "api-msg-2");
673 api2.tag = Some("api".to_string());
674
675 let mut payments = make_entry("2026-04-20T10:00:03Z", "info", "payments-msg");
677 payments.tag = Some("payments".to_string());
678
679 idx.insert_batch(&[untagged, api1, api2, payments]).unwrap();
680
681 let stats = idx.stats().unwrap();
682 assert_eq!(stats.tags.len(), 3);
683 assert_eq!(stats.tags[0], None);
685 assert_eq!(stats.tags[1], Some("api".to_string()));
686 assert_eq!(stats.tags[2], Some("payments".to_string()));
687 }
688
689 #[test]
690 fn stats_entries_count_respects_dedup() {
691 let mut idx = Indexer::open_in_memory().unwrap();
692 idx.insert_batch(&[make_entry("2026-04-20T10:00:00Z", "info", "dup")])
694 .unwrap();
695 idx.insert_batch(&[make_entry("2026-04-20T10:00:00Z", "info", "dup")])
696 .unwrap();
697
698 let stats = idx.stats().unwrap();
699 assert_eq!(stats.entries, 1);
700 }
701
702 #[test]
703 fn stats_entries_count_excludes_timestamp_less_entries() {
704 let mut idx = Indexer::open_in_memory().unwrap();
705
706 let mut no_ts = LogEntry::new(r#"{"level":"info"}"#);
707 no_ts.level = Some("info".to_string());
708
709 idx.insert_batch(&[make_entry("2026-04-20T10:00:00Z", "info", "present"), no_ts])
710 .unwrap();
711
712 let stats = idx.stats().unwrap();
713 assert_eq!(stats.entries, 1);
714 }
715
716 #[test]
721 fn open_read_only_errors_when_file_is_missing() {
722 let dir = tempfile::tempdir().unwrap();
723 let missing = dir.path().join("does-not-exist.db");
724 let err = Indexer::open_read_only(&missing).unwrap_err();
725 assert!(matches!(err, LogdiveError::Sqlite(_)));
728 }
729
730 #[test]
731 fn open_read_only_can_read_existing_rows() {
732 let dir = tempfile::tempdir().unwrap();
733 let db = dir.path().join("ro.db");
734
735 {
737 let mut idx = Indexer::open(&db).unwrap();
738 idx.insert_batch(&[make_entry("2026-04-20T10:00:00Z", "info", "visible")])
739 .unwrap();
740 }
741
742 let ro = Indexer::open_read_only(&db).unwrap();
744 let count: i64 = ro
745 .connection()
746 .query_row("SELECT COUNT(*) FROM log_entries", [], |row| row.get(0))
747 .unwrap();
748 assert_eq!(count, 1);
749
750 let stats = ro.stats().unwrap();
751 assert_eq!(stats.entries, 1);
752 }
753
754 #[test]
755 fn open_read_only_rejects_writes_at_sqlite_level() {
756 let dir = tempfile::tempdir().unwrap();
757 let db = dir.path().join("ro-reject.db");
758
759 {
761 let _ = Indexer::open(&db).unwrap();
762 }
763
764 let ro = Indexer::open_read_only(&db).unwrap();
766 let result = ro.connection().execute(
767 "INSERT INTO log_entries (timestamp, raw, raw_hash) VALUES ('x', 'y', 'z')",
768 [],
769 );
770 assert!(result.is_err(), "read-only connection must reject writes");
771 }
772
773 #[test]
774 fn open_read_only_does_not_run_schema_migrations() {
775 let dir = tempfile::tempdir().unwrap();
780 let db = dir.path().join("bare.db");
781
782 {
784 let c = Connection::open(&db).unwrap();
785 c.execute_batch("PRAGMA user_version = 0;").unwrap();
787 }
788
789 let ro = Indexer::open_read_only(&db).expect("open ro on bare db");
791
792 let err = ro
794 .connection()
795 .query_row("SELECT COUNT(*) FROM log_entries", [], |row| {
796 row.get::<_, i64>(0)
797 });
798 assert!(err.is_err());
799 }
800
801 #[test]
806 fn prune_deletes_entries_strictly_older_than_cutoff() {
807 let mut idx = Indexer::open_in_memory().unwrap();
808 idx.insert_batch(&[
809 make_entry("2026-04-01T00:00:00Z", "info", "old one"),
810 make_entry("2026-04-10T00:00:00Z", "info", "old two"),
811 make_entry("2026-04-20T00:00:00Z", "info", "kept"),
812 ])
813 .unwrap();
814
815 let stats = idx.prune("2026-04-15T00:00:00Z").unwrap();
816 assert_eq!(stats.deleted, 2);
817
818 let count: i64 = idx
819 .connection()
820 .query_row("SELECT COUNT(*) FROM log_entries", [], |row| row.get(0))
821 .unwrap();
822 assert_eq!(count, 1);
823
824 let surviving: String = idx
826 .connection()
827 .query_row("SELECT message FROM log_entries", [], |row| row.get(0))
828 .unwrap();
829 assert_eq!(surviving, "kept");
830 }
831
832 #[test]
833 fn prune_keeps_entry_exactly_at_cutoff() {
834 let mut idx = Indexer::open_in_memory().unwrap();
837 idx.insert_batch(&[make_entry("2026-04-15T00:00:00Z", "info", "boundary")])
838 .unwrap();
839
840 let stats = idx.prune("2026-04-15T00:00:00Z").unwrap();
841 assert_eq!(stats.deleted, 0);
842
843 let count: i64 = idx
844 .connection()
845 .query_row("SELECT COUNT(*) FROM log_entries", [], |row| row.get(0))
846 .unwrap();
847 assert_eq!(count, 1);
848 }
849
850 #[test]
851 fn prune_on_empty_database_deletes_nothing() {
852 let mut idx = Indexer::open_in_memory().unwrap();
853 let stats = idx.prune("2026-04-15T00:00:00Z").unwrap();
854 assert_eq!(stats.deleted, 0);
855 }
856
857 #[test]
858 fn prune_with_cutoff_before_all_entries_deletes_nothing() {
859 let mut idx = Indexer::open_in_memory().unwrap();
860 idx.insert_batch(&[
861 make_entry("2026-04-20T00:00:00Z", "info", "a"),
862 make_entry("2026-04-21T00:00:00Z", "info", "b"),
863 ])
864 .unwrap();
865
866 let stats = idx.prune("2026-01-01T00:00:00Z").unwrap();
867 assert_eq!(stats.deleted, 0);
868
869 let count: i64 = idx
870 .connection()
871 .query_row("SELECT COUNT(*) FROM log_entries", [], |row| row.get(0))
872 .unwrap();
873 assert_eq!(count, 2);
874 }
875
876 #[test]
877 fn prune_with_cutoff_after_all_entries_deletes_all() {
878 let mut idx = Indexer::open_in_memory().unwrap();
879 idx.insert_batch(&[
880 make_entry("2026-04-20T00:00:00Z", "info", "a"),
881 make_entry("2026-04-21T00:00:00Z", "info", "b"),
882 make_entry("2026-04-22T00:00:00Z", "info", "c"),
883 ])
884 .unwrap();
885
886 let stats = idx.prune("2027-01-01T00:00:00Z").unwrap();
887 assert_eq!(stats.deleted, 3);
888
889 let count: i64 = idx
890 .connection()
891 .query_row("SELECT COUNT(*) FROM log_entries", [], |row| row.get(0))
892 .unwrap();
893 assert_eq!(count, 0);
894 }
895
896 #[test]
897 fn prune_returns_accurate_deleted_count() {
898 let mut idx = Indexer::open_in_memory().unwrap();
899 let entries: Vec<_> = (1..=10)
901 .map(|day| {
902 make_entry(
903 &format!("2026-04-{day:02}T00:00:00Z"),
904 "info",
905 &format!("day-{day}"),
906 )
907 })
908 .collect();
909 idx.insert_batch(&entries).unwrap();
910
911 let stats = idx.prune("2026-04-06T00:00:00Z").unwrap();
913 assert_eq!(stats.deleted, 5);
914
915 let count: i64 = idx
916 .connection()
917 .query_row("SELECT COUNT(*) FROM log_entries", [], |row| row.get(0))
918 .unwrap();
919 assert_eq!(count, 5);
920 }
921
922 #[test]
923 fn prune_then_stats_reflects_deletion() {
924 let mut idx = Indexer::open_in_memory().unwrap();
925 idx.insert_batch(&[
926 make_entry("2026-04-01T00:00:00Z", "info", "gone"),
927 make_entry("2026-04-20T00:00:00Z", "info", "stays"),
928 ])
929 .unwrap();
930
931 idx.prune("2026-04-10T00:00:00Z").unwrap();
932
933 let stats = idx.stats().unwrap();
934 assert_eq!(stats.entries, 1);
935 assert_eq!(stats.min_timestamp.as_deref(), Some("2026-04-20T00:00:00Z"));
936 assert_eq!(stats.max_timestamp.as_deref(), Some("2026-04-20T00:00:00Z"));
937 }
938
939 #[test]
940 fn prune_works_on_disk_backed_index() {
941 let dir = tempfile::tempdir().unwrap();
944 let db = dir.path().join("prune.db");
945 let mut idx = Indexer::open(&db).unwrap();
946 idx.insert_batch(&[
947 make_entry("2026-04-01T00:00:00Z", "info", "old"),
948 make_entry("2026-04-20T00:00:00Z", "info", "new"),
949 ])
950 .unwrap();
951
952 let stats = idx.prune("2026-04-10T00:00:00Z").unwrap();
953 assert_eq!(stats.deleted, 1);
954
955 let count: i64 = idx
956 .connection()
957 .query_row("SELECT COUNT(*) FROM log_entries", [], |row| row.get(0))
958 .unwrap();
959 assert_eq!(count, 1);
960 }
961}