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_rejects_update() {
775 let dir = tempfile::tempdir().unwrap();
776 let db = dir.path().join("ro-update.db");
777 {
778 let _ = Indexer::open(&db).unwrap();
779 }
780 let ro = Indexer::open_read_only(&db).unwrap();
781 let result = ro
782 .connection()
783 .execute("UPDATE log_entries SET level = 'x' WHERE 1=0", []);
784 assert!(result.is_err(), "read-only connection must reject UPDATE");
785 }
786
787 #[test]
788 fn open_read_only_rejects_delete() {
789 let dir = tempfile::tempdir().unwrap();
790 let db = dir.path().join("ro-delete.db");
791 {
792 let _ = Indexer::open(&db).unwrap();
793 }
794 let ro = Indexer::open_read_only(&db).unwrap();
795 let result = ro
796 .connection()
797 .execute("DELETE FROM log_entries WHERE 1=0", []);
798 assert!(result.is_err(), "read-only connection must reject DELETE");
799 }
800
801 #[test]
802 fn open_read_only_rejects_create_table() {
803 let dir = tempfile::tempdir().unwrap();
804 let db = dir.path().join("ro-ddl.db");
805 {
806 let _ = Indexer::open(&db).unwrap();
807 }
808 let ro = Indexer::open_read_only(&db).unwrap();
809 let result = ro
810 .connection()
811 .execute_batch("CREATE TABLE sec_test (x TEXT)");
812 assert!(
813 result.is_err(),
814 "read-only connection must reject CREATE TABLE"
815 );
816 }
817
818 #[test]
819 fn open_read_only_rejects_pragma_user_version_write() {
820 let dir = tempfile::tempdir().unwrap();
821 let db = dir.path().join("ro-pragma.db");
822 {
823 let _ = Indexer::open(&db).unwrap();
824 }
825 let ro = Indexer::open_read_only(&db).unwrap();
826 let result = ro.connection().execute_batch("PRAGMA user_version = 42");
827 assert!(
828 result.is_err(),
829 "read-only connection must reject PRAGMA writes"
830 );
831 }
832
833 #[test]
834 fn open_read_only_does_not_run_schema_migrations() {
835 let dir = tempfile::tempdir().unwrap();
840 let db = dir.path().join("bare.db");
841
842 {
844 let c = Connection::open(&db).unwrap();
845 c.execute_batch("PRAGMA user_version = 0;").unwrap();
847 }
848
849 let ro = Indexer::open_read_only(&db).expect("open ro on bare db");
851
852 let err = ro
854 .connection()
855 .query_row("SELECT COUNT(*) FROM log_entries", [], |row| {
856 row.get::<_, i64>(0)
857 });
858 assert!(err.is_err());
859 }
860
861 #[test]
866 fn prune_deletes_entries_strictly_older_than_cutoff() {
867 let mut idx = Indexer::open_in_memory().unwrap();
868 idx.insert_batch(&[
869 make_entry("2026-04-01T00:00:00Z", "info", "old one"),
870 make_entry("2026-04-10T00:00:00Z", "info", "old two"),
871 make_entry("2026-04-20T00:00:00Z", "info", "kept"),
872 ])
873 .unwrap();
874
875 let stats = idx.prune("2026-04-15T00:00:00Z").unwrap();
876 assert_eq!(stats.deleted, 2);
877
878 let count: i64 = idx
879 .connection()
880 .query_row("SELECT COUNT(*) FROM log_entries", [], |row| row.get(0))
881 .unwrap();
882 assert_eq!(count, 1);
883
884 let surviving: String = idx
886 .connection()
887 .query_row("SELECT message FROM log_entries", [], |row| row.get(0))
888 .unwrap();
889 assert_eq!(surviving, "kept");
890 }
891
892 #[test]
893 fn prune_keeps_entry_exactly_at_cutoff() {
894 let mut idx = Indexer::open_in_memory().unwrap();
897 idx.insert_batch(&[make_entry("2026-04-15T00:00:00Z", "info", "boundary")])
898 .unwrap();
899
900 let stats = idx.prune("2026-04-15T00:00:00Z").unwrap();
901 assert_eq!(stats.deleted, 0);
902
903 let count: i64 = idx
904 .connection()
905 .query_row("SELECT COUNT(*) FROM log_entries", [], |row| row.get(0))
906 .unwrap();
907 assert_eq!(count, 1);
908 }
909
910 #[test]
911 fn prune_on_empty_database_deletes_nothing() {
912 let mut idx = Indexer::open_in_memory().unwrap();
913 let stats = idx.prune("2026-04-15T00:00:00Z").unwrap();
914 assert_eq!(stats.deleted, 0);
915 }
916
917 #[test]
918 fn prune_with_cutoff_before_all_entries_deletes_nothing() {
919 let mut idx = Indexer::open_in_memory().unwrap();
920 idx.insert_batch(&[
921 make_entry("2026-04-20T00:00:00Z", "info", "a"),
922 make_entry("2026-04-21T00:00:00Z", "info", "b"),
923 ])
924 .unwrap();
925
926 let stats = idx.prune("2026-01-01T00:00:00Z").unwrap();
927 assert_eq!(stats.deleted, 0);
928
929 let count: i64 = idx
930 .connection()
931 .query_row("SELECT COUNT(*) FROM log_entries", [], |row| row.get(0))
932 .unwrap();
933 assert_eq!(count, 2);
934 }
935
936 #[test]
937 fn prune_with_cutoff_after_all_entries_deletes_all() {
938 let mut idx = Indexer::open_in_memory().unwrap();
939 idx.insert_batch(&[
940 make_entry("2026-04-20T00:00:00Z", "info", "a"),
941 make_entry("2026-04-21T00:00:00Z", "info", "b"),
942 make_entry("2026-04-22T00:00:00Z", "info", "c"),
943 ])
944 .unwrap();
945
946 let stats = idx.prune("2027-01-01T00:00:00Z").unwrap();
947 assert_eq!(stats.deleted, 3);
948
949 let count: i64 = idx
950 .connection()
951 .query_row("SELECT COUNT(*) FROM log_entries", [], |row| row.get(0))
952 .unwrap();
953 assert_eq!(count, 0);
954 }
955
956 #[test]
957 fn prune_returns_accurate_deleted_count() {
958 let mut idx = Indexer::open_in_memory().unwrap();
959 let entries: Vec<_> = (1..=10)
961 .map(|day| {
962 make_entry(
963 &format!("2026-04-{day:02}T00:00:00Z"),
964 "info",
965 &format!("day-{day}"),
966 )
967 })
968 .collect();
969 idx.insert_batch(&entries).unwrap();
970
971 let stats = idx.prune("2026-04-06T00:00:00Z").unwrap();
973 assert_eq!(stats.deleted, 5);
974
975 let count: i64 = idx
976 .connection()
977 .query_row("SELECT COUNT(*) FROM log_entries", [], |row| row.get(0))
978 .unwrap();
979 assert_eq!(count, 5);
980 }
981
982 #[test]
983 fn prune_then_stats_reflects_deletion() {
984 let mut idx = Indexer::open_in_memory().unwrap();
985 idx.insert_batch(&[
986 make_entry("2026-04-01T00:00:00Z", "info", "gone"),
987 make_entry("2026-04-20T00:00:00Z", "info", "stays"),
988 ])
989 .unwrap();
990
991 idx.prune("2026-04-10T00:00:00Z").unwrap();
992
993 let stats = idx.stats().unwrap();
994 assert_eq!(stats.entries, 1);
995 assert_eq!(stats.min_timestamp.as_deref(), Some("2026-04-20T00:00:00Z"));
996 assert_eq!(stats.max_timestamp.as_deref(), Some("2026-04-20T00:00:00Z"));
997 }
998
999 #[test]
1000 fn prune_works_on_disk_backed_index() {
1001 let dir = tempfile::tempdir().unwrap();
1004 let db = dir.path().join("prune.db");
1005 let mut idx = Indexer::open(&db).unwrap();
1006 idx.insert_batch(&[
1007 make_entry("2026-04-01T00:00:00Z", "info", "old"),
1008 make_entry("2026-04-20T00:00:00Z", "info", "new"),
1009 ])
1010 .unwrap();
1011
1012 let stats = idx.prune("2026-04-10T00:00:00Z").unwrap();
1013 assert_eq!(stats.deleted, 1);
1014
1015 let count: i64 = idx
1016 .connection()
1017 .query_row("SELECT COUNT(*) FROM log_entries", [], |row| row.get(0))
1018 .unwrap();
1019 assert_eq!(count, 1);
1020 }
1021
1022 #[test]
1023 fn prune_one_second_boundary_deletes_only_strictly_older() {
1024 let mut idx = Indexer::open_in_memory().unwrap();
1027 idx.insert_batch(&[
1028 make_entry("2026-04-20T10:00:00Z", "info", "at-cutoff"),
1029 make_entry("2026-04-20T10:00:01Z", "info", "one-second-later"),
1030 ])
1031 .unwrap();
1032
1033 let stats = idx.prune("2026-04-20T10:00:00Z").unwrap();
1034 assert_eq!(
1035 stats.deleted, 0,
1036 "row at cutoff must be retained (strict <)"
1037 );
1038
1039 let stats = idx.prune("2026-04-20T10:00:01Z").unwrap();
1040 assert_eq!(
1041 stats.deleted, 1,
1042 "row strictly before the second cutoff must be deleted"
1043 );
1044 }
1045
1046 #[test]
1047 fn prune_idempotent_second_prune_with_same_cutoff_deletes_nothing() {
1048 let mut idx = Indexer::open_in_memory().unwrap();
1051 idx.insert_batch(&[
1052 make_entry("2026-04-01T00:00:00Z", "info", "old"),
1053 make_entry("2026-04-20T00:00:00Z", "info", "keep"),
1054 ])
1055 .unwrap();
1056
1057 let first = idx.prune("2026-04-10T00:00:00Z").unwrap();
1058 assert_eq!(first.deleted, 1);
1059
1060 let second = idx.prune("2026-04-10T00:00:00Z").unwrap();
1061 assert_eq!(
1062 second.deleted, 0,
1063 "re-pruning same cutoff must delete nothing"
1064 );
1065 }
1066}