use engram_core::migrations::{self, FTS_TOKENIZER_KEY, FTS_TOKENIZER_TARGET, fts_tokenizer_v1};
use engram_storage::{Database, Memory};
fn read_meta(database: &Database, key: &str) -> Option<String> {
use rusqlite::OptionalExtension;
database
.connection()
.query_row(
"SELECT value FROM schema_meta WHERE key = ?1",
[key],
|row| row.get::<_, String>(0),
)
.optional()
.expect("query meta")
}
fn seed(database: &Database, id: &str, context: &str) {
let memory = Memory {
id: id.to_string(),
memory_type: "decision".to_string(),
context: context.to_string(),
action: "act".to_string(),
result: "res".to_string(),
score: 0.0,
embedding_context: None,
embedding_action: None,
embedding_result: None,
indexed: false,
tags: None,
project: None,
parent_id: None,
source_ids: None,
insight_type: None,
created_at: "2026-05-01T00:00:00Z".to_string(),
updated_at: "2026-05-01T00:00:00Z".to_string(),
used_count: 0,
last_used_at: None,
superseded_by: None,
};
database.insert_memory(&memory).expect("seed memory");
}
fn downgrade_to_unicode61(database: &Database) {
database
.connection()
.execute_batch(
"DROP TABLE IF EXISTS memories_fts;
CREATE VIRTUAL TABLE memories_fts USING fts5(
context, action, result,
content='memories', content_rowid='rowid');
INSERT INTO memories_fts(memories_fts) VALUES('rebuild');",
)
.expect("rebuild legacy unicode61 fts");
}
fn seed_multi_row_legacy(database: &Database) {
seed(database, "m1", "schedule a nightly run");
seed(database, "m2", "unrelated refund topic");
seed(database, "m3", "database migration notes");
seed(database, "m4", "queue retry backoff");
downgrade_to_unicode61(database);
}
#[test]
fn legacy_unicode61_misses_stem_until_migration() {
let database = Database::in_memory().expect("in-memory db");
seed_multi_row_legacy(&database);
let before = database.search_fts("running", 10).expect("search before");
assert!(
before.is_empty(),
"legacy unicode61 must miss the cross-inflection stem"
);
let applied = fts_tokenizer_v1::run(&database).expect("migration runs");
assert!(applied, "migration must report it ran on a legacy db");
let after = database.search_fts("running", 10).expect("search after");
assert_eq!(after.len(), 1, "porter rebuild must recall the stemmed row");
assert_eq!(after[0].memory.id, "m1");
assert_eq!(
read_meta(&database, FTS_TOKENIZER_KEY).as_deref(),
Some(FTS_TOKENIZER_TARGET),
"migration must record the meta flag"
);
}
#[test]
fn second_run_is_idempotent_noop() {
let database = Database::in_memory().expect("in-memory db");
seed_multi_row_legacy(&database);
let first = fts_tokenizer_v1::run(&database).expect("first run");
assert!(first, "first run applies");
let second = fts_tokenizer_v1::run(&database).expect("second run");
assert!(
!second,
"second run must be a no-op (meta already at target)"
);
let after = database.search_fts("running", 10).expect("search after");
assert_eq!(after.len(), 1);
}
#[test]
fn fresh_database_stems_without_migration() {
let database = Database::in_memory().expect("in-memory db");
seed(&database, "m1", "schedule a nightly run");
let results = database.search_fts("running", 10).expect("search fresh");
assert_eq!(results.len(), 1, "fresh porter index must stem natively");
assert_eq!(results[0].memory.id, "m1");
}
#[test]
fn run_pending_reports_fts_tokenizer_applied() {
let database = Database::in_memory().expect("in-memory db");
seed_multi_row_legacy(&database);
let report = migrations::run_pending(&database).expect("run_pending");
assert!(
report.fts_tokenizer_v1_applied,
"run_pending must apply the fts tokenizer migration on a legacy db"
);
let after = database.search_fts("running", 10).expect("search after");
assert_eq!(after.len(), 1);
}