use std::fs;
use std::path::PathBuf;
use kimetsu_core::KimetsuResult;
use kimetsu_core::ids::RunId;
use kimetsu_core::memory::{MemoryKind, MemoryScope, normalize_memory_text};
use kimetsu_core::paths::{
user_brain_db_path, user_brain_enabled, user_brain_enabled_with, user_kimetsu_dir,
};
use rusqlite::{Connection, OpenFlags, OptionalExtension};
use time::OffsetDateTime;
use ulid::Ulid;
use crate::conflict;
use crate::embeddings;
use crate::project::MemoryRow;
use crate::redact;
use crate::schema;
pub fn open_user_brain() -> KimetsuResult<Option<Connection>> {
if !user_brain_enabled() {
return Ok(None);
}
let Some(dir) = user_kimetsu_dir() else {
return Ok(None);
};
fs::create_dir_all(&dir)?;
let db_path = dir.join("brain.db");
let conn = Connection::open(&db_path)?;
schema::initialize(&conn)?;
Ok(Some(conn))
}
pub fn open_user_brain_readonly() -> KimetsuResult<Option<Connection>> {
if !user_brain_enabled() {
return Ok(None);
}
let Some(db_path) = user_brain_db_path() else {
return Ok(None);
};
if !db_path.exists() {
return Ok(None);
}
let conn = Connection::open_with_flags(&db_path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
match schema::validate(&conn) {
Ok(()) => {}
Err(e)
if e.downcast_ref::<crate::migrate::SchemaNeedsMigration>()
.is_some() =>
{
return Ok(None);
}
Err(e) => return Err(e),
}
Ok(Some(conn))
}
pub fn open_user_brain_for_config(
config_use_user_brain: bool,
) -> KimetsuResult<Option<Connection>> {
if !user_brain_enabled_with(config_use_user_brain) {
return Ok(None);
}
let Some(dir) = user_kimetsu_dir() else {
return Ok(None);
};
fs::create_dir_all(&dir)?;
let db_path = dir.join("brain.db");
let conn = Connection::open(&db_path)?;
schema::initialize(&conn)?;
Ok(Some(conn))
}
pub fn open_user_brain_readonly_for_config(
config_use_user_brain: bool,
) -> KimetsuResult<Option<Connection>> {
if !user_brain_enabled_with(config_use_user_brain) {
return Ok(None);
}
let Some(db_path) = user_brain_db_path() else {
return Ok(None);
};
if !db_path.exists() {
return Ok(None);
}
let conn = Connection::open_with_flags(&db_path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
match schema::validate(&conn) {
Ok(()) => {}
Err(e)
if e.downcast_ref::<crate::migrate::SchemaNeedsMigration>()
.is_some() =>
{
return Ok(None);
}
Err(e) => return Err(e),
}
Ok(Some(conn))
}
pub fn user_brain_path() -> Option<PathBuf> {
user_brain_db_path()
}
pub fn add_user_memory(
conn: &Connection,
kind: MemoryKind,
text: &str,
confidence: f32,
) -> KimetsuResult<String> {
let redaction = redact::redact_secrets(text);
if redaction.was_redacted() {
eprintln!("kimetsu-brain (user): {}", redaction.summary());
}
let text = redaction.text.as_str();
let normalized = normalize_memory_text(text);
let existing: Option<String> = conn
.query_row(
"
SELECT memory_id FROM memories
WHERE scope = ?1 AND kind = ?2 AND normalized_text = ?3
AND invalidated_at IS NULL
AND superseded_by IS NULL
LIMIT 1
",
rusqlite::params!["global_user".to_string(), kind.to_string(), &normalized],
|row| row.get::<_, String>(0),
)
.optional()?;
if let Some(existing_id) = existing {
return Ok(existing_id);
}
let memory_id = Ulid::new().to_string();
let created_at = OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.map_err(|e| format!("timestamp format: {e}"))?;
let provenance = serde_json::json!({
"source": "user_brain",
"run_id": RunId::new().to_string(),
"text": text,
})
.to_string();
conn.execute(
"
INSERT INTO memories (
memory_id, scope, kind, text, normalized_text,
confidence, provenance_snapshot_json, created_at,
use_count, usefulness_score
)
VALUES (?1, 'global_user', ?2, ?3, ?4, ?5, ?6, ?7, 0, 0.0)
",
rusqlite::params![
memory_id,
kind.to_string(),
text,
normalized,
confidence,
provenance,
created_at,
],
)?;
conn.execute(
"
INSERT INTO memories_fts (memory_id, text, kind, scope)
VALUES (?1, ?2, ?3, 'global_user')
",
rusqlite::params![memory_id, text, kind.to_string()],
)?;
let embedder = embeddings::open_default_embedder();
let embedding_vec = embeddings::embed_and_persist(conn, &memory_id, text, embedder)?;
let conflicts = conflict::detect_and_record_with_vec(
conn,
&memory_id,
&MemoryScope::GlobalUser,
&kind.to_string(),
text,
embedding_vec.as_deref(),
embedder,
);
if conflicts > 0 {
eprintln!(
"kimetsu-brain (user): memory {memory_id} conflicts with {conflicts} existing memor{} (run `kimetsu brain memory conflicts` to review)",
if conflicts == 1 { "y" } else { "ies" }
);
}
Ok(memory_id)
}
pub fn list_user_memories(conn: &Connection) -> KimetsuResult<Vec<MemoryRow>> {
let mut stmt = conn.prepare(
"
SELECT memory_id, scope, kind, text, confidence, use_count, usefulness_score
FROM memories
WHERE invalidated_at IS NULL
AND superseded_by IS NULL
ORDER BY created_at DESC
LIMIT 100
",
)?;
let rows = stmt.query_map([], |row| {
Ok(MemoryRow {
memory_id: row.get(0)?,
scope: row.get(1)?,
kind: row.get(2)?,
text: row.get(3)?,
confidence: row.get(4)?,
use_count: row.get(5)?,
usefulness_score: row.get::<_, f64>(6)? as f32,
})
})?;
let mut memories = Vec::new();
for row in rows {
memories.push(row?);
}
Ok(memories)
}
#[doc(hidden)]
pub fn test_env_lock() -> &'static std::sync::Mutex<()> {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
&LOCK
}
#[doc(hidden)]
pub fn with_user_brain_disabled<R>(f: impl FnOnce() -> R) -> R {
let _guard = test_env_lock().lock().unwrap_or_else(|p| p.into_inner());
let prev_enabled = std::env::var("KIMETSU_USER_BRAIN").ok();
let prev_dir = std::env::var("KIMETSU_USER_BRAIN_DIR").ok();
unsafe {
std::env::set_var("KIMETSU_USER_BRAIN", "0");
std::env::remove_var("KIMETSU_USER_BRAIN_DIR");
}
let out = f();
unsafe {
match prev_enabled {
Some(v) => std::env::set_var("KIMETSU_USER_BRAIN", v),
None => std::env::remove_var("KIMETSU_USER_BRAIN"),
}
match prev_dir {
Some(v) => std::env::set_var("KIMETSU_USER_BRAIN_DIR", v),
None => std::env::remove_var("KIMETSU_USER_BRAIN_DIR"),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn with_user_brain_at(dir: &std::path::Path, f: impl FnOnce()) {
let _guard = test_env_lock().lock().unwrap_or_else(|p| p.into_inner());
let prev_dir = std::env::var("KIMETSU_USER_BRAIN_DIR").ok();
let prev_enabled = std::env::var("KIMETSU_USER_BRAIN").ok();
unsafe {
std::env::set_var("KIMETSU_USER_BRAIN_DIR", dir);
std::env::remove_var("KIMETSU_USER_BRAIN");
}
f();
unsafe {
match prev_dir {
Some(v) => std::env::set_var("KIMETSU_USER_BRAIN_DIR", v),
None => std::env::remove_var("KIMETSU_USER_BRAIN_DIR"),
}
match prev_enabled {
Some(v) => std::env::set_var("KIMETSU_USER_BRAIN", v),
None => std::env::remove_var("KIMETSU_USER_BRAIN"),
}
}
}
#[test]
fn open_user_brain_creates_db_on_first_call() {
let tmp = tempdir_in_test("kimetsu-user-brain-1");
with_user_brain_at(&tmp, || {
let conn = open_user_brain()
.expect("open ok")
.expect("user brain enabled");
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM memories", [], |row| row.get(0))
.expect("query memories");
assert_eq!(count, 0);
assert!(tmp.join("brain.db").exists());
});
}
#[test]
fn open_user_brain_returns_none_when_disabled() {
let tmp = tempdir_in_test("kimetsu-user-brain-2");
let _guard = test_env_lock().lock().unwrap_or_else(|p| p.into_inner());
let prev_enabled = std::env::var("KIMETSU_USER_BRAIN").ok();
let prev_dir = std::env::var("KIMETSU_USER_BRAIN_DIR").ok();
unsafe {
std::env::set_var("KIMETSU_USER_BRAIN", "0");
std::env::set_var("KIMETSU_USER_BRAIN_DIR", &tmp);
}
let result = open_user_brain().expect("open ok");
assert!(result.is_none(), "disabled should short-circuit to None");
assert!(!tmp.join("brain.db").exists());
unsafe {
match prev_dir {
Some(v) => std::env::set_var("KIMETSU_USER_BRAIN_DIR", v),
None => std::env::remove_var("KIMETSU_USER_BRAIN_DIR"),
}
match prev_enabled {
Some(v) => std::env::set_var("KIMETSU_USER_BRAIN", v),
None => std::env::remove_var("KIMETSU_USER_BRAIN"),
}
}
}
#[test]
fn open_user_brain_readonly_returns_none_before_first_write() {
let tmp = tempdir_in_test("kimetsu-user-brain-3");
with_user_brain_at(&tmp, || {
let result = open_user_brain_readonly().expect("open ok");
assert!(result.is_none(), "missing file -> None for readonly path");
});
}
#[test]
fn add_user_memory_persists_and_dedups() {
let tmp = tempdir_in_test("kimetsu-user-brain-4");
with_user_brain_at(&tmp, || {
let conn = open_user_brain().expect("open").expect("enabled");
let first =
add_user_memory(&conn, MemoryKind::Preference, "use thiserror", 1.0).expect("add");
let second = add_user_memory(&conn, MemoryKind::Preference, " use thiserror ", 1.0)
.expect("add normalized dup");
assert_eq!(first, second, "normalized-text dedup must hit");
let rows = list_user_memories(&conn).expect("list");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].text, "use thiserror");
assert_eq!(rows[0].scope, "global_user");
});
}
#[test]
fn user_brain_path_resolves_from_override_env() {
let tmp = tempdir_in_test("kimetsu-user-brain-5");
with_user_brain_at(&tmp, || {
let path = user_brain_path().expect("path");
assert!(path.starts_with(&tmp));
assert!(path.ends_with("brain.db"));
});
}
#[test]
fn readonly_degrades_to_none_on_stale_schema() {
let tmp = tempdir_in_test("kimetsu-user-brain-stale");
with_user_brain_at(&tmp, || {
let db_path = tmp.join("brain.db");
{
let conn = rusqlite::Connection::open(&db_path).expect("open stub db");
conn.execute_batch(
"CREATE TABLE schema_info (key TEXT PRIMARY KEY, value INTEGER NOT NULL);
INSERT INTO schema_info VALUES ('kimetsu_schema_version', 1);",
)
.expect("seed v1 stub");
}
let result = open_user_brain_readonly()
.expect("open_user_brain_readonly must not error on stale user brain");
assert!(
result.is_none(),
"stale user brain (v1 < target) must yield Ok(None), not an error"
);
});
}
#[test]
fn migration_upgrades_user_brain_creates_backup_and_preserves_data() {
let tmp = tempdir_in_test("kimetsu-user-brain-migrate");
with_user_brain_at(&tmp, || {
let mem_id = {
let conn = open_user_brain().expect("open ok").expect("enabled");
add_user_memory(
&conn,
MemoryKind::Preference,
"A7 user-brain migration test",
1.0,
)
.expect("add_user_memory")
};
let db_path = tmp.join("brain.db");
{
let conn = rusqlite::Connection::open(&db_path).expect("open for stamp-down");
conn.execute(
"UPDATE schema_info SET value = 1 WHERE key = 'kimetsu_schema_version'",
[],
)
.expect("stamp version back to 1");
let stamped: i64 = conn
.query_row(
"SELECT value FROM schema_info WHERE key = 'kimetsu_schema_version'",
[],
|r| r.get(0),
)
.expect("read stamped version");
assert_eq!(stamped, 1, "version should be 1 after stamp-down");
}
let conn = open_user_brain().expect("re-open ok").expect("enabled");
use kimetsu_core::KIMETSU_SCHEMA_VERSION;
let ver =
crate::migrate::current_version(&conn).expect("current_version after re-open");
assert_eq!(
ver, KIMETSU_SCHEMA_VERSION,
"user brain must be at current target version after re-open"
);
let bak_prefix = format!("brain.db.bak-1-{KIMETSU_SCHEMA_VERSION}-");
let bak_files: Vec<_> = std::fs::read_dir(&tmp)
.expect("read tmp dir")
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_str()
.map(|n| n.starts_with(&bak_prefix))
.unwrap_or(false)
})
.collect();
assert_eq!(
bak_files.len(),
1,
"exactly one user-brain backup sidecar {bak_prefix}* must exist; found: {:?}",
bak_files.iter().map(|e| e.file_name()).collect::<Vec<_>>()
);
let rows = list_user_memories(&conn).expect("list_user_memories");
assert!(
rows.iter().any(|r| r.memory_id == mem_id),
"seeded memory must survive v1→v2 migration; mem_id={mem_id}"
);
});
}
fn tempdir_in_test(prefix: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("{prefix}-{}", Ulid::new()));
std::fs::create_dir_all(&dir).expect("mkdir");
dir
}
#[test]
fn w3_open_user_brain_for_config_false_returns_none() {
let tmp = tempdir_in_test("kimetsu-user-brain-w3-1");
let _guard = test_env_lock().lock().unwrap_or_else(|p| p.into_inner());
let prev_enabled = std::env::var("KIMETSU_USER_BRAIN").ok();
let prev_dir = std::env::var("KIMETSU_USER_BRAIN_DIR").ok();
unsafe {
std::env::remove_var("KIMETSU_USER_BRAIN");
std::env::set_var("KIMETSU_USER_BRAIN_DIR", &tmp);
}
let result = open_user_brain_for_config(false).expect("no error");
assert!(
result.is_none(),
"config=false + env unset must return None"
);
assert!(
!tmp.join("brain.db").exists(),
"brain.db must not be created when user brain is off"
);
unsafe {
match prev_dir {
Some(v) => std::env::set_var("KIMETSU_USER_BRAIN_DIR", v),
None => std::env::remove_var("KIMETSU_USER_BRAIN_DIR"),
}
match prev_enabled {
Some(v) => std::env::set_var("KIMETSU_USER_BRAIN", v),
None => std::env::remove_var("KIMETSU_USER_BRAIN"),
}
}
}
#[test]
fn w3_open_user_brain_env_enable_overrides_config_false() {
let tmp = tempdir_in_test("kimetsu-user-brain-w3-2");
let _guard = test_env_lock().lock().unwrap_or_else(|p| p.into_inner());
let prev_enabled = std::env::var("KIMETSU_USER_BRAIN").ok();
let prev_dir = std::env::var("KIMETSU_USER_BRAIN_DIR").ok();
unsafe {
std::env::set_var("KIMETSU_USER_BRAIN", "1");
std::env::set_var("KIMETSU_USER_BRAIN_DIR", &tmp);
}
let result = open_user_brain_for_config(false).expect("no error");
assert!(
result.is_some(),
"KIMETSU_USER_BRAIN=1 must override config=false → brain enabled"
);
unsafe {
match prev_dir {
Some(v) => std::env::set_var("KIMETSU_USER_BRAIN_DIR", v),
None => std::env::remove_var("KIMETSU_USER_BRAIN_DIR"),
}
match prev_enabled {
Some(v) => std::env::set_var("KIMETSU_USER_BRAIN", v),
None => std::env::remove_var("KIMETSU_USER_BRAIN"),
}
}
}
#[test]
fn w3_open_user_brain_env_disable_overrides_config_true() {
let tmp = tempdir_in_test("kimetsu-user-brain-w3-3");
let _guard = test_env_lock().lock().unwrap_or_else(|p| p.into_inner());
let prev_enabled = std::env::var("KIMETSU_USER_BRAIN").ok();
let prev_dir = std::env::var("KIMETSU_USER_BRAIN_DIR").ok();
unsafe {
std::env::set_var("KIMETSU_USER_BRAIN", "0");
std::env::set_var("KIMETSU_USER_BRAIN_DIR", &tmp);
}
let result = open_user_brain_for_config(true).expect("no error");
assert!(
result.is_none(),
"KIMETSU_USER_BRAIN=0 must override config=true → brain disabled"
);
unsafe {
match prev_dir {
Some(v) => std::env::set_var("KIMETSU_USER_BRAIN_DIR", v),
None => std::env::remove_var("KIMETSU_USER_BRAIN_DIR"),
}
match prev_enabled {
Some(v) => std::env::set_var("KIMETSU_USER_BRAIN", v),
None => std::env::remove_var("KIMETSU_USER_BRAIN"),
}
}
}
#[test]
fn w3_open_user_brain_for_config_true_opens_normally() {
let tmp = tempdir_in_test("kimetsu-user-brain-w3-4");
with_user_brain_at(&tmp, || {
let result = open_user_brain_for_config(true).expect("no error");
assert!(
result.is_some(),
"config=true + env unset must open the brain"
);
assert!(tmp.join("brain.db").exists());
});
}
#[test]
fn fix5_dedup_does_not_collapse_onto_superseded_row() {
let tmp = tempdir_in_test("kimetsu-user-brain-fix5");
with_user_brain_at(&tmp, || {
let conn = open_user_brain().expect("open").expect("enabled");
let original = add_user_memory(&conn, MemoryKind::Preference, "use anyhow", 1.0)
.expect("original");
conn.execute(
"UPDATE memories SET superseded_by = 'fake-survivor' WHERE memory_id = ?1",
rusqlite::params![&original],
)
.expect("stamp superseded");
let second =
add_user_memory(&conn, MemoryKind::Preference, "use anyhow", 1.0).expect("second");
assert_ne!(
original, second,
"adding a text that matches only a superseded row must produce a new memory_id"
);
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM memories", [], |r| r.get(0))
.expect("count");
assert_eq!(count, 2, "two rows: original (superseded) + new active");
let second_superseded: Option<String> = conn
.query_row(
"SELECT superseded_by FROM memories WHERE memory_id = ?1",
rusqlite::params![&second],
|r| r.get(0),
)
.expect("query second");
assert!(
second_superseded.is_none(),
"newly created row must not be superseded"
);
});
}
}