lwc 0.17.10

Agent-driven proactive memory CLI for AI agents — autonomously recall, maintain, and evolve persistent, source-grounded knowledge across sessions.
use super::*;
use rusqlite::{Connection, OpenFlags};
use std::collections::BTreeSet;
use std::time::{Duration, Instant};
use tempfile::tempdir;

fn live_store() -> (tempfile::TempDir, StorePath) {
    let temp = tempdir().unwrap();
    let root = temp.path().join(".lwc");
    fs::create_dir(&root).unwrap();
    let path = root.join("wiki.db");
    Store::initialize("project", &path).unwrap();
    (temp, StorePath::new(Scope::Project, path))
}

fn operation_count(database: &Path) -> i64 {
    Connection::open_with_flags(database, OpenFlags::SQLITE_OPEN_READ_ONLY)
        .unwrap()
        .query_row("SELECT COUNT(*) FROM operations", [], |row| row.get(0))
        .unwrap()
}

#[test]
fn hook_store_timeout_companions_preserve_default_and_zero_busy_timeouts() {
    let (_temp, live) = live_store();

    let default_open = Store::open_for_hook("project", &live.path).unwrap();
    assert_eq!(default_open.busy_timeout_millis_for_test().unwrap(), 250);

    let zero_open =
        Store::open_for_hook_with_timeout("project", &live.path, Duration::ZERO).unwrap();
    assert_eq!(zero_open.busy_timeout_millis_for_test().unwrap(), 0);

    let default_snapshot =
        Store::open_for_hook_with_timeout("project", &live.path, Duration::ZERO).unwrap();
    default_snapshot.begin_hook_snapshot().unwrap();
    assert_eq!(
        default_snapshot.busy_timeout_millis_for_test().unwrap(),
        250
    );

    let zero_snapshot = Store::open_for_hook("project", &live.path).unwrap();
    zero_snapshot
        .begin_hook_snapshot_with_timeout(Duration::ZERO)
        .unwrap();
    assert_eq!(zero_snapshot.busy_timeout_millis_for_test().unwrap(), 0);
}

#[test]
fn hook_summary_is_bounded_redacted_deterministic_and_read_only() {
    let (temp, live) = live_store();
    let mut drafts = Vec::new();
    for name in ["draft-a", "draft-b", "draft-c", "draft-d", "draft-e"] {
        begin(&live, name).unwrap();
        drafts.push(resolve_effective(live.clone(), Some(name)).unwrap().path);
    }
    for (index, path) in drafts.iter().enumerate() {
        Store::open("project", path)
            .unwrap()
            .schema_set(&format!("PRIVATE DRAFT BODY {index}"))
            .unwrap();
    }
    let directory = live.path.parent().unwrap().join("changesets");
    fs::write(directory.join("irrelevant.txt"), b"not sqlite").unwrap();
    #[cfg(unix)]
    {
        std::os::unix::fs::symlink(&live.path, directory.join("linked.db")).unwrap();
    }

    let live_operations = operation_count(&live.path);
    let draft_operations = drafts
        .iter()
        .map(|path| operation_count(path))
        .collect::<Vec<_>>();
    let first = hook_summary(&live, Instant::now() + Duration::from_secs(2)).unwrap();
    let second = hook_summary(&live, Instant::now() + Duration::from_secs(2)).unwrap();

    assert_eq!(first, second);
    assert_eq!(first.changesets.len(), 3);
    assert_eq!(first.omitted, 2);
    assert_eq!(first.changesets[0].name, "draft-a");
    assert!(!first.changesets[0].empty);
    assert_eq!(first.changesets[0].staged_operation_count, 1);
    assert_eq!(first.changesets[0].status, "draft");
    assert!(!first.changesets[0].conflict);

    let encoded = serde_json::to_value(&first).unwrap();
    assert_eq!(
        encoded
            .as_object()
            .unwrap()
            .keys()
            .cloned()
            .collect::<BTreeSet<_>>(),
        BTreeSet::from(["changesets".into(), "omitted".into()])
    );
    assert_eq!(
        encoded["changesets"][0]
            .as_object()
            .unwrap()
            .keys()
            .cloned()
            .collect::<BTreeSet<_>>(),
        BTreeSet::from([
            "conflict".into(),
            "empty".into(),
            "id".into(),
            "name".into(),
            "staged_operation_count".into(),
            "status".into(),
        ])
    );
    let encoded = serde_json::to_string(&first).unwrap();
    for forbidden in [
        "database",
        "base_revision",
        "draft_revision",
        "operations",
        "path",
        "PRIVATE DRAFT BODY",
        temp.path().to_str().unwrap(),
    ] {
        assert!(
            !encoded.contains(forbidden),
            "leaked {forbidden}: {encoded}"
        );
    }
    assert_eq!(operation_count(&live.path), live_operations);
    assert_eq!(
        drafts
            .iter()
            .map(|path| operation_count(path))
            .collect::<Vec<_>>(),
        draft_operations
    );
}

#[test]
fn hook_summary_filters_empty_drafts_before_the_limit() {
    let (_temp, live) = live_store();
    for name in ["empty-a", "empty-b", "empty-c"] {
        begin(&live, name).unwrap();
    }
    begin(&live, "relevant-z").unwrap();
    let relevant = resolve_effective(live.clone(), Some("relevant-z"))
        .unwrap()
        .path;
    Store::open("project", relevant)
        .unwrap()
        .schema_set("PRIVATE FOURTH DRAFT BODY")
        .unwrap();

    let summary = hook_summary(&live, Instant::now() + Duration::from_secs(2)).unwrap();

    assert_eq!(summary.changesets.len(), 1);
    assert_eq!(summary.changesets[0].name, "relevant-z");
    assert!(!summary.changesets[0].empty);
    assert_eq!(summary.omitted, 0);
    assert!(
        !serde_json::to_string(&summary)
            .unwrap()
            .contains("PRIVATE FOURTH DRAFT BODY")
    );
}

#[test]
fn hook_summary_orders_conflicts_before_nonempty_drafts_then_by_name() {
    let (_temp, live) = live_store();
    for name in ["active-a", "conflict-z"] {
        begin(&live, name).unwrap();
        let path = resolve_effective(live.clone(), Some(name)).unwrap().path;
        Store::open("project", &path)
            .unwrap()
            .schema_set("PRIVATE RELEVANT DRAFT BODY")
            .unwrap();
        if name == "conflict-z" {
            Connection::open(path)
                .unwrap()
                .execute(
                    "UPDATE meta SET value=?1 WHERE key='store_id'",
                    ["f".repeat(64)],
                )
                .unwrap();
        }
    }

    let summary = hook_summary(&live, Instant::now() + Duration::from_secs(2)).unwrap();

    assert_eq!(summary.changesets.len(), 2);
    assert_eq!(summary.changesets[0].name, "conflict-z");
    assert!(summary.changesets[0].conflict);
    assert_eq!(summary.changesets[1].name, "active-a");
    assert!(!summary.changesets[1].conflict);
    assert_eq!(summary.omitted, 0);
}

#[test]
fn hook_summary_all_empty_drafts_are_idle() {
    let (_temp, live) = live_store();
    for name in ["empty-a", "empty-b", "empty-c", "empty-d"] {
        begin(&live, name).unwrap();
    }

    let summary = hook_summary(&live, Instant::now() + Duration::from_secs(2)).unwrap();

    assert!(summary.changesets.is_empty());
    assert_eq!(summary.omitted, 0);
}

#[test]
fn hook_summary_missing_directory_is_empty_and_does_not_create_it() {
    let (_temp, live) = live_store();
    let directory = live.path.parent().unwrap().join("changesets");

    let summary = hook_summary(&live, Instant::now() + Duration::from_secs(2)).unwrap();

    assert!(summary.changesets.is_empty());
    assert_eq!(summary.omitted, 0);
    assert!(!directory.exists());
}

#[test]
fn hook_summary_fails_open_at_the_fixed_directory_scan_cap() {
    let (_temp, live) = live_store();
    let directory = live.path.parent().unwrap().join("changesets");
    fs::create_dir(&directory).unwrap();
    for index in 0..=CHANGESET_HOOK_MAX_SCAN_ITEMS {
        fs::write(directory.join(format!("junk-{index}")), b"junk").unwrap();
    }

    assert_eq!(
        hook_summary(&live, Instant::now() + Duration::from_secs(2))
            .unwrap_err()
            .code,
        "changeset_hook_limit"
    );
}

#[cfg(unix)]
#[test]
fn hook_summary_rejects_a_symlinked_changeset_directory() {
    let (temp, live) = live_store();
    let outside = temp.path().join("outside-changesets");
    fs::create_dir(&outside).unwrap();
    std::os::unix::fs::symlink(outside, live.path.parent().unwrap().join("changesets")).unwrap();

    let error = hook_summary(&live, Instant::now() + Duration::from_secs(2)).unwrap_err();

    assert_eq!(error.code, "changeset_hook_unavailable");
    assert!(!error.to_string().contains(temp.path().to_str().unwrap()));
}

#[test]
fn hook_summary_fails_open_without_accumulating_busy_waits_across_locked_drafts() {
    let (_temp, live) = live_store();
    let mut draft_paths = Vec::new();
    for index in 0..10 {
        let name = format!("locked-{index:02}");
        begin(&live, &name).unwrap();
        draft_paths.push(resolve_effective(live.clone(), Some(&name)).unwrap().path);
    }
    let operations = draft_paths
        .iter()
        .map(|path| operation_count(path))
        .collect::<Vec<_>>();
    let locks = draft_paths
        .iter()
        .map(|path| {
            let connection = Connection::open(path).unwrap();
            connection
                .execute_batch("PRAGMA locking_mode=EXCLUSIVE; BEGIN EXCLUSIVE;")
                .unwrap();
            connection
        })
        .collect::<Vec<_>>();

    let started = Instant::now();
    let error = hook_summary(&live, Instant::now() + Duration::from_millis(400)).unwrap_err();
    let elapsed = started.elapsed();
    drop(locks);

    assert!(
        elapsed < Duration::from_millis(500),
        "Hook summary accumulated per-draft busy waits: {elapsed:?}"
    );
    assert_eq!(error.code, "changeset_hook_unavailable");
    assert!(!error.to_string().contains("locked-"));
    assert_eq!(
        draft_paths
            .iter()
            .map(|path| operation_count(path))
            .collect::<Vec<_>>(),
        operations
    );
}

#[test]
fn hook_summary_fails_open_for_a_shaped_but_unreadable_draft_without_leaking_it() {
    let (temp, live) = live_store();
    begin(&live, "available-draft").unwrap();
    let available = resolve_effective(live.clone(), Some("available-draft"))
        .unwrap()
        .path;
    let before = operation_count(&available);
    let directory = live.path.parent().unwrap().join("changesets");
    fs::write(
        directory.join("private-draft.db"),
        b"PRIVATE INVALID DATABASE",
    )
    .unwrap();

    let error = hook_summary(&live, Instant::now() + Duration::from_secs(2)).unwrap_err();

    assert_eq!(error.code, "changeset_hook_unavailable");
    let error = error.to_string();
    assert!(!error.contains("private-draft"));
    assert!(!error.contains("PRIVATE INVALID DATABASE"));
    assert!(!error.contains(temp.path().to_str().unwrap()));
    assert_eq!(operation_count(&available), before);
}

#[test]
fn hook_summary_expired_deadline_fails_open_without_partial_results_or_writes() {
    let (_temp, live) = live_store();
    begin(&live, "deadline-a").unwrap();
    begin(&live, "deadline-b").unwrap();
    let paths = ["deadline-a", "deadline-b"]
        .map(|name| resolve_effective(live.clone(), Some(name)).unwrap().path);
    Store::open("project", &paths[0])
        .unwrap()
        .schema_set("PRIVATE DEADLINE BODY")
        .unwrap();
    let live_operations = operation_count(&live.path);
    let draft_operations = paths
        .iter()
        .map(|path| operation_count(path))
        .collect::<Vec<_>>();

    let started = Instant::now();
    let error = hook_summary(&live, started).unwrap_err();

    assert_eq!(error.code, "changeset_hook_timeout");
    assert!(started.elapsed() < Duration::from_millis(100));
    assert_eq!(operation_count(&live.path), live_operations);
    assert_eq!(
        paths
            .iter()
            .map(|path| operation_count(path))
            .collect::<Vec<_>>(),
        draft_operations
    );
    assert!(!error.to_string().contains("PRIVATE DEADLINE BODY"));
}