remem-ai 0.6.36

Local-first coding agent memory for Claude Code and OpenAI Codex
Documentation
use serde_json::Value;

use super::*;
use crate::db;
use crate::db::test_support::ScopedTestDataDir;
use crate::memory::governance::{GovernMemoryResult, GovernedMemory};
use anyhow::Result;

#[test]
fn parse_governance_id_text_accepts_commas_and_whitespace() -> Result<()> {
    let ids = parse_governance_id_text("1, 2\n3\t4")?;
    assert_eq!(ids, vec![1, 2, 3, 4]);
    Ok(())
}

#[test]
fn run_encrypt_initializes_missing_database() -> Result<()> {
    let test_dir = ScopedTestDataDir::new("encrypt-empty");
    std::env::remove_var("REMEM_ALLOW_PLAINTEXT_DB");
    std::env::remove_var("REMEM_CIPHER_KEY");

    run_encrypt(false)?;

    assert!(test_dir.path.join(".key").exists());
    let saved_key = std::fs::read_to_string(test_dir.path.join(".key"))?;
    assert!(saved_key.starts_with("v2:"), "got: {saved_key}");
    assert!(test_dir.db_path().exists());
    let header = std::fs::read(test_dir.db_path())?;
    assert_ne!(&header[..16], b"SQLite format 3\0");

    let conn = rusqlite::Connection::open(test_dir.db_path())?;
    crate::db::apply_cipher_key_if_available(&conn)?;
    let count: i64 = conn.query_row("SELECT COUNT(*) FROM sqlite_master", [], |row| row.get(0))?;
    assert!(count > 0);
    Ok(())
}

#[test]
fn run_encrypt_removes_plaintext_backup_for_existing_database() -> Result<()> {
    let test_dir = ScopedTestDataDir::new("encrypt-existing-no-plaintext-backup");
    std::env::remove_var("REMEM_ALLOW_PLAINTEXT_DB");
    std::env::remove_var("REMEM_CIPHER_KEY");
    std::fs::create_dir_all(&test_dir.path)?;
    {
        let conn = rusqlite::Connection::open(test_dir.db_path())?;
        conn.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)", [])?;
        conn.execute("INSERT INTO t (v) VALUES ('kept')", [])?;
    }

    run_encrypt(false)?;

    assert!(test_dir.path.join(".key").exists());
    assert!(
        !test_dir.path.join("remem.db.bak").exists(),
        "successful encryption must not leave a plaintext backup"
    );
    assert_no_plaintext_sqlite_files(&test_dir.path)?;
    let conn = rusqlite::Connection::open(test_dir.db_path())?;
    crate::db::apply_cipher_key_if_available(&conn)?;
    let value: String = conn.query_row("SELECT v FROM t WHERE id = 1", [], |row| row.get(0))?;
    assert_eq!(value, "kept");
    Ok(())
}

#[test]
fn run_encrypt_rolls_back_generated_key_when_preflight_fails() -> Result<()> {
    let test_dir = ScopedTestDataDir::new("encrypt-existing-preflight-fail");
    std::env::remove_var("REMEM_ALLOW_PLAINTEXT_DB");
    std::env::remove_var("REMEM_CIPHER_KEY");
    std::fs::create_dir_all(&test_dir.path)?;
    {
        let conn = rusqlite::Connection::open(test_dir.db_path())?;
        conn.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)", [])?;
        conn.execute("INSERT INTO t (v) VALUES ('plaintext')", [])?;
    }
    let backup_path = test_dir.path.join("remem.db.bak");
    std::fs::write(&backup_path, b"pre-existing backup sentinel")?;

    let error = run_encrypt(false).expect_err("pre-existing backup must block encryption");

    let message = format!("{error:#}");
    assert!(
        message.contains("temporary plaintext migration backup already exists"),
        "got: {message}"
    );
    assert!(
        !test_dir.path.join(".key").exists(),
        "failed preflight must remove the generated key file"
    );
    assert!(
        backup_path.exists(),
        "rollback must not remove a pre-existing backup"
    );
    let header = std::fs::read(test_dir.db_path())?;
    assert_eq!(&header[..16], b"SQLite format 3\0");
    Ok(())
}

#[test]
fn run_encrypt_rekey_raw_migrates_legacy_hex_key() -> Result<()> {
    let test_dir = ScopedTestDataDir::new("encrypt-rekey-raw");
    std::env::remove_var("REMEM_CIPHER_KEY");
    let legacy_hex = "2".repeat(64);
    std::fs::create_dir_all(&test_dir.path)?;
    std::fs::write(test_dir.path.join(".key"), &legacy_hex)?;
    {
        let conn = rusqlite::Connection::open(test_dir.db_path())?;
        conn.pragma_update(None, "key", &legacy_hex)?;
        conn.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)", [])?;
        conn.execute("INSERT INTO t (v) VALUES ('migrated')", [])?;
    }

    run_encrypt(true)?;

    let saved_key = std::fs::read_to_string(test_dir.path.join(".key"))?;
    assert_eq!(saved_key, format!("v2:{legacy_hex}"));
    let key_backup = std::fs::read_to_string(test_dir.path.join(".key.bak"))?;
    assert_eq!(key_backup, legacy_hex);
    let backup_dir = test_dir.path.join("backups");
    let backup_count = std::fs::read_dir(&backup_dir)?.count();
    assert_eq!(backup_count, 1, "expected one DB backup in {backup_dir:?}");

    let raw_conn = rusqlite::Connection::open(test_dir.db_path())?;
    crate::db::configure_cipher(
        &raw_conn,
        Some(&crate::db::CipherKey::Raw(legacy_hex.clone())),
    )?;
    let value: String = raw_conn.query_row("SELECT v FROM t WHERE id = 1", [], |row| row.get(0))?;
    assert_eq!(value, "migrated");

    let legacy_conn = rusqlite::Connection::open(test_dir.db_path())?;
    legacy_conn.pragma_update(None, "key", &legacy_hex)?;
    assert!(
        !crate::db::can_read_schema(&legacy_conn),
        "legacy passphrase path must no longer unlock the DB"
    );
    Ok(())
}

fn assert_no_plaintext_sqlite_files(dir: &std::path::Path) -> Result<()> {
    for entry in std::fs::read_dir(dir)? {
        let entry = entry?;
        if !entry.file_type()?.is_file() {
            continue;
        }
        let bytes = std::fs::read(entry.path())?;
        if bytes.len() < 16 {
            continue;
        }
        assert_ne!(
            &bytes[..16],
            b"SQLite format 3\0",
            "{} must not be a plaintext SQLite database",
            entry.path().display()
        );
    }
    Ok(())
}

#[test]
fn parse_governance_id_text_rejects_invalid_ids() {
    let err = parse_governance_id_text("1 nope 2").expect_err("invalid id should fail");
    assert!(err.to_string().contains("invalid memory id"));
}

#[test]
fn collect_governance_ids_reads_file_sources() -> Result<()> {
    let nanos = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)?
        .as_nanos();
    let path = std::env::temp_dir().join(format!(
        "remem-governance-ids-{}-{}.txt",
        std::process::id(),
        nanos
    ));
    std::fs::write(&path, "5\n6,7")?;
    let ids = collect_governance_ids(&[4], Some(&path), false)?;
    std::fs::remove_file(&path)?;
    assert_eq!(ids, vec![4, 5, 6, 7]);
    Ok(())
}

#[test]
fn cli_governance_json_result_is_machine_parseable() -> std::result::Result<(), serde_json::Error> {
    let result = GovernMemoryResult {
        dry_run: true,
        action: "stale".to_string(),
        reason: Some("stale fact".to_string()),
        affected: vec![GovernedMemory {
            id: 7,
            title: "Old memory".to_string(),
            previous_status: "active".to_string(),
            new_status: "stale".to_string(),
        }],
    };

    let text = serde_json::to_string(&result)?;
    let parsed: Value = serde_json::from_str(&text)?;

    assert_eq!(parsed["dry_run"], true);
    assert_eq!(parsed["action"], "stale");
    assert_eq!(parsed["affected"][0]["new_status"], "stale");
    Ok(())
}

#[test]
fn cleanup_report_json_exposes_dry_run_plan_counts() -> std::result::Result<(), serde_json::Error> {
    let report = CleanupReport {
        dry_run: true,
        retention_days: CleanupRetentionDays {
            old_events: 30,
            compressed_source_observations: 90,
            stale_memories: 180,
            archived_failures: 90,
            workstream_auto_pause: 14,
            workstream_auto_abandon: 30,
        },
        plan: CleanupPlan {
            expired_memories_to_stale: 1,
            inactive_workstreams_to_pause: 2,
            long_paused_workstreams_to_abandon: 3,
            old_events_to_delete: 4,
            compressed_source_observations_to_delete: 5,
            stale_memories_to_archive: 6,
            archived_failures_to_purge: db::ArchivedFailurePurgePlan {
                pending_observations: 7,
                extraction_tasks: 8,
                extraction_replay_ranges: 9,
                jobs: 10,
            },
        },
        applied: None,
    };

    let parsed = serde_json::to_value(report)?;

    assert_eq!(parsed["dry_run"], true);
    assert_eq!(parsed["applied"], Value::Null);
    assert_eq!(parsed["plan"]["old_events_to_delete"], 4);
    assert_eq!(
        parsed["plan"]["archived_failures_to_purge"]["extraction_tasks"],
        8
    );
    assert_eq!(
        parsed["plan"]["compressed_source_observations_to_delete"],
        5
    );
    Ok(())
}