lantern 0.2.2

Local-first, provenance-aware semantic search for agent activity
Documentation
use std::fs;

use lantern::forget::forget;
use lantern::ingest::ingest_path;
use lantern::search::{SearchOptions, search};
use lantern::store::Store;
use tempfile::tempdir;

fn setup_store_with(files: &[(&str, &str)]) -> (tempfile::TempDir, Store) {
    let root = tempdir().unwrap();
    let mut store = Store::initialize(&root.path().join("store")).unwrap();
    let data = root.path().join("data");
    fs::create_dir_all(&data).unwrap();
    for (name, body) in files {
        fs::write(data.join(name), body).unwrap();
    }
    ingest_path(&mut store, &data).unwrap();
    (root, store)
}

fn counts(store: &Store) -> (i64, i64, i64) {
    let conn = store.conn();
    let sources: i64 = conn
        .query_row("SELECT COUNT(*) FROM sources", [], |r| r.get(0))
        .unwrap();
    let chunks: i64 = conn
        .query_row("SELECT COUNT(*) FROM chunks", [], |r| r.get(0))
        .unwrap();
    let fts: i64 = conn
        .query_row("SELECT COUNT(*) FROM chunks_fts", [], |r| r.get(0))
        .unwrap();
    (sources, chunks, fts)
}

#[test]
fn empty_pattern_is_rejected() {
    let (_root, mut store) = setup_store_with(&[("a.md", "content")]);
    let err = forget(&mut store, "", true).unwrap_err();
    assert!(err.to_string().contains("must not be empty"));
    let err = forget(&mut store, "   ", true).unwrap_err();
    assert!(err.to_string().contains("must not be empty"));
}

#[test]
fn no_matches_reports_empty_and_changes_nothing() {
    let (_root, mut store) = setup_store_with(&[("a.md", "content")]);
    let before = counts(&store);

    let report = forget(&mut store, "does-not-match", true).unwrap();
    assert!(report.removed.is_empty());
    assert!(report.applied);
    assert_eq!(counts(&store), before);
}

#[test]
fn removes_matching_source_chunks_and_fts_entries() {
    let (_root, mut store) = setup_store_with(&[
        ("apples.md", "apple notes with needle keyword"),
        ("bananas.md", "unrelated banana words"),
    ]);
    let (src_before, chunk_before, fts_before) = counts(&store);
    assert_eq!(src_before, 2);
    assert!(chunk_before >= 2);
    assert_eq!(chunk_before, fts_before);

    let report = forget(&mut store, "apples", true).unwrap();
    assert_eq!(report.removed.len(), 1);
    assert!(report.removed[0].uri.ends_with("/apples.md"));
    assert!(report.removed[0].chunks > 0);

    let (src_after, chunk_after, fts_after) = counts(&store);
    assert_eq!(src_after, 1);
    assert_eq!(chunk_after, chunk_before - report.removed[0].chunks);
    assert_eq!(
        chunk_after, fts_after,
        "fts index must stay aligned with chunks"
    );

    // Search for a term that only existed in the removed file should now miss.
    let hits = search(&store, "needle", SearchOptions::default()).unwrap();
    assert!(hits.is_empty());
    // Words from the surviving file still resolve.
    let hits = search(&store, "banana", SearchOptions::default()).unwrap();
    assert_eq!(hits.len(), 1);
}

#[test]
fn dry_run_preserves_state_but_still_lists_candidates() {
    let (_root, mut store) = setup_store_with(&[("apples.md", "one"), ("bananas.md", "two")]);
    let before = counts(&store);

    let report = forget(&mut store, "apples", false).unwrap();
    assert!(!report.applied);
    assert_eq!(report.removed.len(), 1);
    assert!(report.removed[0].uri.ends_with("/apples.md"));

    assert_eq!(
        counts(&store),
        before,
        "dry-run must not modify the database"
    );
}

#[test]
fn substring_pattern_matches_any_source_containing_it() {
    let (_root, mut store) = setup_store_with(&[
        ("alpha-1.md", "first"),
        ("alpha-2.md", "second"),
        ("beta.md", "third"),
    ]);

    let report = forget(&mut store, "alpha", true).unwrap();
    assert_eq!(report.removed.len(), 2);

    let (src_after, _, _) = counts(&store);
    assert_eq!(src_after, 1);
}