solo-storage 0.7.0

Solo: SQLite + SQLCipher persistence layer
Documentation
// SPDX-License-Identifier: Apache-2.0

//! Test fixtures shared across `writer` and `reader` test modules.
//!
//! Lives behind `#[cfg(any(test, feature = "test-support"))]` so it never
//! compiles into release builds. The `StubVectorIndex` is a minimal stand-in
//! for the real HNSW that lands in commit 1.3 — it records every `add` and
//! `remove` call so tests can assert ordering and counts.

#![cfg(any(test, feature = "test-support"))]

use parking_lot::Mutex;
use rusqlite::Connection;
use solo_core::{
    Confidence, EncodingContext, Episode, Embedding, EmbeddingDtype, MemoryId, Result, Tier,
    VectorIndex,
};
use std::path::Path;

/// Open a fresh on-disk SQLite database, run the migrations, return the
/// connection paired with a `tempfile::TempDir` whose lifetime tests must
/// keep alive. Tests use the returned `Connection` directly OR re-open the
/// file by path.
pub fn open_test_db() -> (Connection, tempfile::TempDir) {
    let tmp = tempfile::TempDir::new().expect("tempdir");
    let path = tmp.path().join("test.db");
    let mut conn = Connection::open(&path).expect("open db");
    conn.execute_batch(
        "PRAGMA journal_mode = wal;
         PRAGMA foreign_keys = ON;
         PRAGMA busy_timeout = 5000;",
    )
    .expect("startup pragmas");
    crate::migration::run_migrations(&mut conn).expect("migrations");
    (conn, tmp)
}

/// Open a fresh database file without consuming the path's TempDir.
pub fn open_test_db_at(path: &Path) -> Connection {
    let mut conn = Connection::open(path).expect("open db");
    conn.execute_batch(
        "PRAGMA journal_mode = wal;
         PRAGMA foreign_keys = ON;
         PRAGMA busy_timeout = 5000;",
    )
    .expect("startup pragmas");
    crate::migration::run_migrations(&mut conn).expect("migrations");
    conn
}

/// Build a minimal valid `Episode` for tests. Each call generates a fresh
/// `MemoryId` so the sequence is unique even when fixture content matches.
pub fn fixture_episode(content: &str) -> Episode {
    Episode {
        memory_id: MemoryId::new(),
        ts_ms: chrono::Utc::now().timestamp_millis(),
        source_type: "user_message".into(),
        source_id: None,
        content: content.into(),
        encoding_context: EncodingContext::default(),
        provenance: None,
        confidence: Confidence::new(0.9).unwrap(),
        strength: 0.5,
        salience: 0.5,
        tier: Tier::Hot,
    }
}

/// Build an FP32 embedding of the given dim, filled with zeros. Sufficient
/// for tests that exercise SQL persistence + HNSW dispatch but don't care
/// about vector content.
pub fn fixture_embedding(dim: usize) -> Embedding {
    Embedding {
        dtype: EmbeddingDtype::F32,
        dim,
        data: vec![0u8; dim * 4],
    }
}

/// Test-only `VectorIndex` that records every call. Behaves correctly under
/// `Arc<dyn VectorIndex + Send + Sync>` — interior mutability via
/// `parking_lot::Mutex`. NOT a real ANN index; `search` returns whatever was
/// added in insertion order.
pub struct StubVectorIndex {
    state: Mutex<StubState>,
    dim: usize,
    /// If set, `add` sleeps for this duration before returning. Used by the
    /// "slow writer doesn't deadlock" property test.
    add_sleep: Mutex<Option<std::time::Duration>>,
    /// If true, `save` returns Err. Used by the "snapshot failure
    /// continuation" property test.
    save_fails: Mutex<bool>,
}

struct StubState {
    entries: Vec<(i64, Vec<f32>)>,
    add_calls: usize,
    remove_calls: usize,
    save_calls: usize,
}

impl StubVectorIndex {
    pub fn new(dim: usize) -> Self {
        Self {
            state: Mutex::new(StubState {
                entries: Vec::new(),
                add_calls: 0,
                remove_calls: 0,
                save_calls: 0,
            }),
            dim,
            add_sleep: Mutex::new(None),
            save_fails: Mutex::new(false),
        }
    }

    pub fn add_count(&self) -> usize {
        self.state.lock().add_calls
    }

    pub fn remove_count(&self) -> usize {
        self.state.lock().remove_calls
    }

    pub fn save_count(&self) -> usize {
        self.state.lock().save_calls
    }

    pub fn last_added(&self) -> Option<(i64, Vec<f32>)> {
        self.state.lock().entries.last().cloned()
    }

    pub fn entries(&self) -> Vec<(i64, Vec<f32>)> {
        self.state.lock().entries.clone()
    }

    /// Make every `add` block for `dur`. Useful for testing backpressure
    /// and channel-full scenarios.
    pub fn set_add_sleep(&self, dur: Option<std::time::Duration>) {
        *self.add_sleep.lock() = dur;
    }

    /// Make every `save` return Err. Useful for testing snapshot-failure
    /// continuation per ADR-0003 §"Snapshot save failure handling".
    pub fn set_save_fails(&self, fail: bool) {
        *self.save_fails.lock() = fail;
    }
}

impl VectorIndex for StubVectorIndex {
    fn add(&self, rowid: i64, embedding: &[f32]) -> Result<()> {
        // Sleep BEFORE taking the state lock so concurrent adds can pile
        // up in flight (which is what the channel-full test exercises).
        if let Some(dur) = *self.add_sleep.lock() {
            std::thread::sleep(dur);
        }
        let mut s = self.state.lock();
        s.add_calls += 1;
        s.entries.push((rowid, embedding.to_vec()));
        Ok(())
    }

    fn remove(&self, rowid: i64) -> Result<()> {
        let mut s = self.state.lock();
        s.remove_calls += 1;
        s.entries.retain(|(r, _)| *r != rowid);
        Ok(())
    }

    fn search(&self, _query: &[f32], k: usize) -> Result<Vec<(i64, f32)>> {
        let s = self.state.lock();
        Ok(s.entries
            .iter()
            .take(k)
            .map(|(r, _)| (*r, 0.0))
            .collect())
    }

    fn save(&self, _path: &std::path::Path) -> Result<()> {
        self.state.lock().save_calls += 1;
        if *self.save_fails.lock() {
            return Err(solo_core::Error::vector_index(
                "stub configured to fail save",
            ));
        }
        Ok(())
    }

    fn len(&self) -> usize {
        self.state.lock().entries.len()
    }

    fn dim(&self) -> usize {
        self.dim
    }
}