remem-ai 0.5.11

Persistent memory for Claude Code and Codex — single binary, automatic context
Documentation
use std::cell::RefCell;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use rusqlite::{Connection, OpenFlags};

thread_local! {
    static DATA_DIR_OVERRIDE: RefCell<Option<PathBuf>> = const { RefCell::new(None) };
}

pub(crate) fn with_data_dir<T>(dir: &Path, f: impl FnOnce() -> T) -> T {
    let _guard = DataDirOverrideGuard::set(dir.to_path_buf());
    f()
}

pub fn deterministic_hash(data: &[u8]) -> u64 {
    const FNV_OFFSET: u64 = 0xcbf29ce484222325;
    const FNV_PRIME: u64 = 0x00000100000001B3;
    let mut hash = FNV_OFFSET;
    for &byte in data {
        hash ^= byte as u64;
        hash = hash.wrapping_mul(FNV_PRIME);
    }
    hash
}

pub fn to_sql_refs(params: &[Box<dyn rusqlite::types::ToSql>]) -> Vec<&dyn rusqlite::types::ToSql> {
    params.iter().map(|b| b.as_ref()).collect()
}

pub fn truncate_str(s: &str, max_bytes: usize) -> &str {
    if s.len() <= max_bytes {
        return s;
    }
    let mut end = max_bytes;
    while end > 0 && !s.is_char_boundary(end) {
        end -= 1;
    }
    &s[..end]
}

pub fn canonical_project_path(cwd: &str) -> PathBuf {
    crate::project_id::canonical_project_path(cwd)
}

pub fn project_from_cwd(cwd: &str) -> String {
    crate::project_id::project_from_cwd(cwd)
}

pub fn data_dir() -> PathBuf {
    if let Some(path) = DATA_DIR_OVERRIDE.with(|slot| slot.borrow().clone()) {
        return path;
    }
    std::env::var("REMEM_DATA_DIR")
        .map(PathBuf::from)
        .unwrap_or_else(|_| {
            dirs::home_dir()
                .unwrap_or_else(|| PathBuf::from("."))
                .join(".remem")
        })
}

pub fn db_path() -> PathBuf {
    data_dir().join("remem.db")
}

pub fn open_db() -> Result<Connection> {
    let path = db_path();
    let key = super::crypto::require_cipher_key_or_plaintext_override()?;
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let perms = std::fs::Permissions::from_mode(0o700);
            if let Err(e) = std::fs::set_permissions(parent, perms) {
                crate::log::warn("db", &format!("cannot set data dir permissions: {}", e));
            }
        }
    }

    let conn = open_configured_connection(&path, key.as_deref())?;
    crate::retrieval::vector::load_vec_extension(&conn)?;
    crate::migrate::run_migrations(&conn)?;
    crate::retrieval::vector::ensure_vec_table(&conn)?;
    Ok(conn)
}

pub fn open_db_read_only() -> Result<Connection> {
    let path = db_path();
    let key = super::crypto::require_cipher_key_or_plaintext_override()?;
    if !path.exists() {
        anyhow::bail!("database not found: {}", path.display());
    }

    open_configured_read_only_connection(&path, key.as_deref())
}

pub(crate) fn open_configured_connection(path: &Path, key: Option<&str>) -> Result<Connection> {
    let conn = Connection::open(path)
        .with_context(|| format!("Failed to open database: {}", path.display()))?;

    super::crypto::configure_cipher(&conn, key)?;

    conn.execute_batch(
        "PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;",
    )?;
    Ok(conn)
}

pub(crate) fn open_configured_read_only_connection(
    path: &Path,
    key: Option<&str>,
) -> Result<Connection> {
    let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)
        .with_context(|| format!("Failed to open database read-only: {}", path.display()))?;

    super::crypto::configure_cipher(&conn, key)?;
    conn.execute_batch("PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;")?;
    Ok(conn)
}

pub fn detect_git_branch(cwd: &str) -> Option<String> {
    let output = std::process::Command::new("git")
        .args(["rev-parse", "--abbrev-ref", "HEAD"])
        .current_dir(cwd)
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::null())
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
    if branch.is_empty() || branch == "HEAD" {
        None
    } else {
        Some(branch)
    }
}

struct DataDirOverrideGuard {
    previous: Option<PathBuf>,
}

impl DataDirOverrideGuard {
    fn set(path: PathBuf) -> Self {
        let previous = DATA_DIR_OVERRIDE.with(|slot| slot.replace(Some(path)));
        Self { previous }
    }
}

impl Drop for DataDirOverrideGuard {
    fn drop(&mut self) {
        let previous = self.previous.take();
        DATA_DIR_OVERRIDE.with(|slot| {
            slot.replace(previous);
        });
    }
}

pub fn detect_git_commit(cwd: &str) -> Option<String> {
    let output = std::process::Command::new("git")
        .args(["rev-parse", "--short", "HEAD"])
        .current_dir(cwd)
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::null())
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    let sha = String::from_utf8_lossy(&output.stdout).trim().to_string();
    if sha.is_empty() {
        None
    } else {
        Some(sha)
    }
}

#[cfg(test)]
mod tests {
    use rusqlite::Connection;

    use super::*;
    use crate::db::crypto::ALLOW_PLAINTEXT_ENV;
    use crate::db::test_support::ScopedTestDataDir;

    #[test]
    fn open_db_read_only_does_not_create_missing_database() {
        let test_dir = ScopedTestDataDir::new("readonly-missing");
        test_dir.remove_db_files();

        let err = open_db_read_only().expect_err("missing database should fail");

        let message = err.to_string();
        assert!(
            message.contains("database not found"),
            "unexpected error: {message}"
        );
        assert!(
            !test_dir.path.exists(),
            "read-only open must not create data dir"
        );
        assert!(
            !test_dir.db_path().exists(),
            "read-only open must not create database file"
        );
    }

    #[test]
    fn open_db_read_only_refuses_plaintext_without_explicit_override() -> Result<()> {
        let test_dir = ScopedTestDataDir::new("readonly-cipher-fail-closed");
        let conn = crate::db::open_db()?;
        drop(conn);
        std::env::remove_var(ALLOW_PLAINTEXT_ENV);

        let err = crate::db::open_db_read_only()
            .expect_err("read-only open must enforce the plaintext guard");

        let message = err.to_string();
        assert!(message.contains("SQLCipher key"), "got: {message}");
        assert!(
            test_dir.db_path().exists(),
            "read-only guard must not remove the existing database"
        );
        Ok(())
    }

    #[test]
    fn open_db_read_only_does_not_run_migrations() -> Result<()> {
        let _test_dir = ScopedTestDataDir::new("readonly-no-migration");
        let path = crate::db::db_path();
        std::fs::create_dir_all(crate::db::data_dir())?;
        let conn = Connection::open(&path)?;
        conn.execute("CREATE TABLE marker (id INTEGER PRIMARY KEY)", [])?;
        drop(conn);

        let readonly = crate::db::open_db_read_only()?;
        let marker_exists: i64 = readonly.query_row(
            "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'marker'",
            [],
            |row| row.get(0),
        )?;
        let migrations_exists: i64 = readonly.query_row(
            "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'",
            [],
            |row| row.get(0),
        )?;

        assert_eq!(marker_exists, 1);
        assert_eq!(migrations_exists, 0);
        Ok(())
    }

    #[test]
    fn open_db_read_only_opens_existing_database_without_write_access() -> Result<()> {
        let test_dir = ScopedTestDataDir::new("readonly-existing");
        std::fs::create_dir_all(&test_dir.path)?;
        let setup = Connection::open(test_dir.db_path())?;
        setup.execute_batch(
            "CREATE TABLE readonly_probe(id INTEGER PRIMARY KEY);
             INSERT INTO readonly_probe(id) VALUES (1);",
        )?;
        drop(setup);

        let conn = open_db_read_only()?;
        let count: i64 =
            conn.query_row("SELECT COUNT(*) FROM readonly_probe", [], |row| row.get(0))?;
        assert_eq!(count, 1);

        let err = conn
            .execute("INSERT INTO readonly_probe(id) VALUES (2)", [])
            .expect_err("read-only connection must reject writes");
        assert_eq!(err.sqlite_error_code(), Some(rusqlite::ErrorCode::ReadOnly));
        Ok(())
    }
}