use rusqlite::{params, Connection};
use sessionwiki::index;
use std::sync::Mutex;
static LOCK: Mutex<()> = Mutex::new(());
fn seed(conn: &Connection, id: &str, tool: &str, project: &str, title: &str, started: &str) {
conn.execute(
"INSERT OR REPLACE INTO files
(path, mtime, size, session_id, tool, project, title, started, ended, msg_count, kind)
VALUES (?1, 0, 0, ?2, ?3, ?4, ?5, ?6, ?6, 2, 'main')",
params![
format!("/fake/{id}.jsonl"),
id,
tool,
project,
title,
started
],
)
.unwrap();
conn.execute(
"INSERT INTO messages(session_id, role, text) VALUES (?1, 'assistant', ?2)",
params![id, format!("outcome for {title}")],
)
.unwrap();
}
fn fresh_index() -> Connection {
let dir = std::env::temp_dir().join("sessionwiki-test-curation");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::env::set_var("SESSIONWIKI_DATA", &dir);
let conn = index::open().unwrap();
conn.execute_batch(
"DELETE FROM files; DELETE FROM messages; DELETE FROM tags;
DELETE FROM notes; DELETE FROM touched; DELETE FROM archive;",
)
.unwrap();
conn
}
fn touch(conn: &Connection, id: &str, path: &str) {
conn.execute(
"INSERT OR IGNORE INTO touched(session_id, path) VALUES (?1, ?2)",
params![id, path],
)
.unwrap();
}
fn seed_msg(conn: &Connection, id: &str, project: &str, title: &str, started: &str, text: &str) {
conn.execute(
"INSERT OR REPLACE INTO files
(path, mtime, size, session_id, tool, project, title, started, ended, msg_count, kind)
VALUES (?1, 0, 0, ?2, 'codex', ?3, ?4, ?5, ?5, 1, 'main')",
params![format!("/fake/{id}.jsonl"), id, project, title, started],
)
.unwrap();
conn.execute(
"INSERT INTO messages(session_id, role, text) VALUES (?1, 'user', ?2)",
params![id, text],
)
.unwrap();
}
fn nfd_hoesa() -> String {
['\u{1112}', '\u{116C}', '\u{1109}', '\u{1161}']
.iter()
.collect()
}
#[test]
fn tags_round_trip_and_counts() {
let _g = LOCK.lock().unwrap();
let conn = fresh_index();
seed(
&conn,
"a1",
"codex",
"/proj/api",
"fix auth",
"2026-06-10T10:00:00+00:00",
);
index::add_tag(&conn, "a1", "Auth").unwrap(); index::add_tag(&conn, "a1", "bug").unwrap();
index::add_tag(&conn, "a1", "auth").unwrap();
let counts = index::tag_counts(&conn).unwrap();
assert!(counts.iter().any(|(t, n)| t == "auth" && *n == 1));
assert!(counts.iter().any(|(t, n)| t == "bug" && *n == 1));
assert_eq!(index::remove_tag(&conn, "a1", "bug").unwrap(), 1);
let counts = index::tag_counts(&conn).unwrap();
assert!(!counts.iter().any(|(t, _)| t == "bug"));
}
#[test]
fn add_tag_rejects_comma_and_empty() {
let _g = LOCK.lock().unwrap();
let conn = fresh_index();
seed(&conn, "t1", "codex", "/p", "x", "2026-06-10T10:00:00+00:00");
assert!(
index::add_tag(&conn, "t1", "foo,bar").is_err(),
"comma rejected"
);
assert!(
index::add_tag(&conn, "t1", " ").is_err(),
"empty rejected"
);
index::add_tag(&conn, "t1", "Perf").unwrap(); let counts = index::tag_counts(&conn).unwrap();
assert!(counts.iter().any(|(t, n)| t == "perf" && *n == 1));
assert!(!counts.iter().any(|(t, _)| t.contains(',') || t.is_empty()));
}
#[test]
fn notes_round_trip() {
let _g = LOCK.lock().unwrap();
let conn = fresh_index();
seed(
&conn,
"n1",
"codex",
"/proj/api",
"task",
"2026-06-10T10:00:00+00:00",
);
assert!(index::note_for(&conn, "n1").unwrap().is_none());
index::set_note(&conn, "n1", "revisit the retry logic").unwrap();
assert_eq!(
index::note_for(&conn, "n1").unwrap().as_deref(),
Some("revisit the retry logic")
);
index::set_note(&conn, "n1", "done").unwrap(); assert_eq!(
index::note_for(&conn, "n1").unwrap().as_deref(),
Some("done")
);
}
#[test]
fn related_prefers_same_project_then_shared_tags() {
let _g = LOCK.lock().unwrap();
let conn = fresh_index();
seed(
&conn,
"p1",
"codex",
"/proj/api",
"cors fix",
"2026-06-10T10:00:00+00:00",
);
seed(
&conn,
"p2",
"codex",
"/proj/api",
"rate limiter",
"2026-06-09T10:00:00+00:00",
);
seed(
&conn,
"p3",
"claude-code",
"/proj/web",
"ui work",
"2026-06-08T10:00:00+00:00",
);
seed(
&conn,
"p4",
"gemini",
"/proj/other",
"unrelated",
"2026-06-07T10:00:00+00:00",
);
let rel = index::related(&conn, "p1", 10).unwrap();
let ids: Vec<&str> = rel.iter().map(|r| r.session_id.as_str()).collect();
assert!(
ids.contains(&"p2"),
"same-project session should be related"
);
assert!(!ids.contains(&"p4"), "unrelated project should not appear");
index::add_tag(&conn, "p1", "spike").unwrap();
index::add_tag(&conn, "p3", "spike").unwrap();
let rel = index::related(&conn, "p1", 10).unwrap();
let ids: Vec<&str> = rel.iter().map(|r| r.session_id.as_str()).collect();
assert!(
ids.contains(&"p3"),
"shared-tag session should now be related"
);
assert!(!ids.contains(&"p1"), "a session is never related to itself");
}
#[test]
fn provenance_files_trace_and_shared_file_relatedness() {
let _g = LOCK.lock().unwrap();
let conn = fresh_index();
seed(
&conn,
"f1",
"claude-code",
"/proj/api",
"add auth guard",
"2026-06-10T10:00:00+00:00",
);
seed(
&conn,
"f2",
"codex",
"/proj/web",
"reuse auth helper",
"2026-06-11T10:00:00+00:00",
);
seed(
&conn,
"f3",
"codex",
"/proj/web",
"unrelated",
"2026-06-09T10:00:00+00:00",
);
touch(&conn, "f1", "/home/me/proj/src/auth.rs");
touch(&conn, "f1", "/home/me/proj/src/lib.rs");
touch(&conn, "f2", "/home/me/proj/src/auth.rs");
touch(&conn, "f3", "/home/me/proj/src/other.rs");
assert_eq!(
index::files_for(&conn, "f1").unwrap(),
["/home/me/proj/src/auth.rs", "/home/me/proj/src/lib.rs"]
);
let hits = index::sessions_for_file(&conn, "src/auth.rs", 10).unwrap();
let ids: Vec<&str> = hits.iter().map(|(r, _)| r.session_id.as_str()).collect();
assert_eq!(ids, ["f2", "f1"]);
assert_eq!(hits[0].1, "/home/me/proj/src/auth.rs"); let one = index::sessions_for_file(&conn, "src/other.rs", 10).unwrap();
assert_eq!(one.len(), 1);
assert_eq!(one[0].0.session_id, "f3");
let rel = index::related(&conn, "f1", 10).unwrap();
let ids: Vec<&str> = rel.iter().map(|r| r.session_id.as_str()).collect();
assert!(
ids.contains(&"f2"),
"session editing the same file should be related across projects"
);
assert!(
!ids.contains(&"f3"),
"session touching only other files should not be related"
);
assert_eq!(index::stats(&conn).unwrap().files, 3);
}
#[test]
fn projects_and_stats_rollups() {
let _g = LOCK.lock().unwrap();
let conn = fresh_index();
seed(
&conn,
"s1",
"codex",
"/proj/api",
"a",
"2026-06-10T10:00:00+00:00",
);
seed(
&conn,
"s2",
"codex",
"/proj/api",
"b",
"2026-05-10T10:00:00+00:00",
);
seed(
&conn,
"s3",
"gemini",
"/proj/web",
"c",
"2026-06-10T10:00:00+00:00",
);
let projects = index::projects(&conn).unwrap();
let api = projects.iter().find(|p| p.project == "/proj/api").unwrap();
assert_eq!(api.sessions, 2);
assert_eq!(projects[0].project, "/proj/api");
let st = index::stats(&conn).unwrap();
assert_eq!(st.total_sessions, 3);
assert_eq!(st.projects, 2);
assert!(st.per_tool.iter().any(|(t, n, _)| t == "codex" && *n == 2));
assert!(st.per_month.iter().any(|(ym, _)| ym == "2026-06"));
}
#[test]
fn archive_survives_schema_bump_rehydration() {
let _g = LOCK.lock().unwrap();
let conn = fresh_index();
conn.execute(
"INSERT INTO archive
(session_id, path, mtime, size, tool, project, title, started, ended,
msg_count, kind, transcript, touched, archived_at)
VALUES ('arc1','/gone/s.jsonl',0,0,'codex','/proj/api','rate limiter fix',
'2026-06-10T10:00:00+00:00','2026-06-10T10:05:00+00:00',2,'main',
?1, ?2, '2026-06-12T00:00:00Z')",
params![
r#"[["user","why does the rate limiter go negative"],["assistant","fixed the off-by-one in rate_limiter.rs"]]"#,
r#"["/proj/api/src/rate_limiter.rs"]"#
],
)
.unwrap();
drop(conn);
{
let conn = index::open().unwrap();
conn.pragma_update(None, "user_version", 0i64).unwrap();
}
let conn = index::open().unwrap();
let rows = index::resolve(&conn, "arc1").unwrap();
assert_eq!(rows.len(), 1);
assert!(
rows[0].archived,
"rehydrated session must be flagged archived"
);
assert_eq!(rows[0].title, "rate limiter fix");
assert_eq!(
index::files_for(&conn, "arc1").unwrap(),
["/proj/api/src/rate_limiter.rs"]
);
let hits = index::sessions_for_file(&conn, "src/rate_limiter.rs", 10).unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].0.session_id, "arc1");
let s = index::search(&conn, "negative", 10, None, None).unwrap();
assert!(
s.iter().any(|h| h.row.session_id == "arc1"),
"archived transcript must stay searchable after a rebuild"
);
let sess = index::session_from_index(&conn, &rows[0]).unwrap();
assert_eq!(sess.messages.len(), 2);
assert_eq!(sess.tool, "codex");
}
#[test]
fn archive_on_prune_serves_and_forgets() {
let _g = LOCK.lock().unwrap();
let home = std::env::temp_dir().join("sessionwiki-test-arc-home");
let _ = std::fs::remove_dir_all(&home);
let proj = home.join(".claude/projects/myproj");
std::fs::create_dir_all(&proj).unwrap();
let sess = proj.join("aaaaaaaa-0000-4000-8000-000000000abc.jsonl");
std::fs::write(
&sess,
concat!(
r#"{"type":"summary","summary":"Fix the login bug","leafUuid":"x"}"#,
"\n",
r#"{"type":"user","cwd":"/home/dev/app","timestamp":"2026-06-10T10:00:00.000Z","message":{"role":"user","content":"login throws on empty password"}}"#,
"\n",
r#"{"type":"assistant","cwd":"/home/dev/app","timestamp":"2026-06-10T10:00:05.000Z","message":{"role":"assistant","content":[{"type":"tool_use","name":"Edit","input":{"file_path":"/home/dev/app/src/login.rs"}}]}}"#,
"\n",
r#"{"type":"assistant","cwd":"/home/dev/app","timestamp":"2026-06-10T10:00:10.000Z","message":{"role":"assistant","content":[{"type":"text","text":"Guarded the empty-password case."}]}}"#,
"\n",
),
)
.unwrap();
std::env::set_var("HOME", &home);
let data = std::env::temp_dir().join("sessionwiki-test-arc-data");
let _ = std::fs::remove_dir_all(&data);
std::fs::create_dir_all(&data).unwrap();
std::env::set_var("SESSIONWIKI_DATA", &data);
let mut conn = index::open().unwrap();
index::sync(&mut conn, Some("claude-code")).unwrap();
let rows = index::recent(&conn, 50, None, None, None, false).unwrap();
let sid = rows
.iter()
.find(|r| r.title.contains("login"))
.map(|r| r.session_id.clone())
.expect("session indexed");
assert!(!rows.iter().find(|r| r.session_id == sid).unwrap().archived);
assert_eq!(
index::files_for(&conn, &sid).unwrap(),
["/home/dev/app/src/login.rs"]
);
std::fs::remove_file(&sess).unwrap();
index::sync(&mut conn, Some("claude-code")).unwrap();
let r = index::resolve(&conn, &sid)
.unwrap()
.into_iter()
.next()
.unwrap();
assert!(r.archived, "pruned session must be archived, not deleted");
assert!(
index::sessions_for_file(&conn, "src/login.rs", 10)
.unwrap()
.iter()
.any(|(s, _)| s.session_id == sid),
"trace must survive the prune"
);
assert!(
index::search(&conn, "empty password", 10, None, None)
.unwrap()
.iter()
.any(|h| h.row.session_id == sid),
"search must survive the prune"
);
let obj = index::session_from_index(&conn, &r).unwrap();
assert!(obj
.messages
.iter()
.any(|m| m.text.contains("empty-password")));
index::forget(&mut conn, &sid).unwrap();
assert!(index::resolve(&conn, &sid).unwrap().is_empty());
let _ = std::fs::remove_dir_all(&home);
let _ = std::fs::remove_dir_all(&data);
}
#[test]
fn nfd_fixture_is_actually_decomposed() {
let nfd = nfd_hoesa();
let composed = sessionwiki::util::nfc(&nfd);
assert_ne!(nfd, composed, "fixture must differ from its NFC form");
assert_eq!(composed.chars().count(), 2, "NFC 회사 is two syllables");
assert_eq!(composed, "회사", "NFC form must be the composed word");
assert_eq!(sessionwiki::util::nfc("회사"), "회사"); }
#[test]
fn nfd_message_is_found_by_nfc_query_via_fts() {
let _g = LOCK.lock().unwrap();
let conn = fresh_index();
let nfd_text = format!("{} 프로젝트", nfd_hoesa()); let text = sessionwiki::util::nfc(&nfd_text);
conn.execute(
"INSERT OR REPLACE INTO files
(path, mtime, size, session_id, tool, project, title, started, ended, msg_count, kind)
VALUES ('/fake/k.jsonl', 0, 0, 'k1', 'codex', '/p', 't', NULL, NULL, 1, 'main')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO messages(session_id, role, text) VALUES ('k1','user',?1)",
params![text],
)
.unwrap();
conn.execute(
"INSERT INTO msgs(rowid, text) VALUES (last_insert_rowid(), ?1)",
params![text],
)
.unwrap();
let hits = index::search(&conn, "회사 프로젝트", 10, None, None).unwrap();
assert!(
hits.iter().any(|h| h.row.session_id == "k1"),
"NFC query must find the NFD-sourced message"
);
let nfd_q = format!("{} 프로젝트", nfd_hoesa());
assert!(index::search(&conn, &nfd_q, 10, None, None)
.unwrap()
.iter()
.any(|h| h.row.session_id == "k1"));
}
#[test]
fn nfd_touched_path_is_traceable_by_nfc() {
let _g = LOCK.lock().unwrap();
let conn = fresh_index();
seed(
&conn,
"t1",
"codex",
"/p",
"korean file",
"2026-06-10T10:00:00+00:00",
);
let nfd_path = format!("/home/me/{}.rs", nfd_hoesa());
conn.execute(
"INSERT OR IGNORE INTO touched(session_id, path) VALUES ('t1', ?1)",
params![sessionwiki::util::nfc(&nfd_path)],
)
.unwrap();
assert!(
index::sessions_for_file(&conn, "회사.rs", 10)
.unwrap()
.iter()
.any(|(r, _)| r.session_id == "t1"),
"NFC trace query must match the NFC-stored Korean path"
);
assert!(
index::sessions_for_file(&conn, &format!("{}.rs", nfd_hoesa()), 10)
.unwrap()
.iter()
.any(|(r, _)| r.session_id == "t1"),
"NFD trace query must be normalized and match"
);
}
#[test]
fn archived_nfd_transcript_searchable_after_rehydrate() {
let _g = LOCK.lock().unwrap();
let conn = fresh_index();
let transcript = serde_json::to_string(&vec![
("user".to_string(), "korean question".to_string()),
("assistant".to_string(), format!("{} 프로젝트", nfd_hoesa())),
])
.unwrap();
conn.execute(
"INSERT INTO archive
(session_id, path, mtime, size, tool, project, title, started, ended,
msg_count, kind, transcript, touched, archived_at)
VALUES ('arcK','/gone/k.jsonl',0,0,'codex','/p','korean archived',
NULL, NULL, 2, 'main', ?1, '[]', '2026-06-12T00:00:00Z')",
params![transcript],
)
.unwrap();
drop(conn);
{
let conn = index::open().unwrap();
conn.pragma_update(None, "user_version", 0i64).unwrap();
}
let conn = index::open().unwrap();
assert!(
index::search(&conn, "회사 프로젝트", 10, None, None)
.unwrap()
.iter()
.any(|h| h.row.session_id == "arcK"),
"rehydrate must re-normalize archived NFD text to NFC"
);
}
#[test]
fn short_korean_query_matches_via_like() {
let _g = LOCK.lock().unwrap();
let conn = fresh_index();
seed_msg(
&conn,
"k1",
"/p",
"company",
"2026-06-10T10:00:00+00:00",
"우리 회사 온보딩 절차",
);
seed_msg(
&conn,
"k2",
"/p",
"other",
"2026-06-09T10:00:00+00:00",
"rate limiter off-by-one",
);
let hits = index::search_like(&conn, "회사", 10, None, None).unwrap();
let ids: Vec<&str> = hits.iter().map(|h| h.row.session_id.as_str()).collect();
assert!(ids.contains(&"k1"), "2-char Korean must match via LIKE");
assert!(!ids.contains(&"k2"));
let h = hits.iter().find(|h| h.row.session_id == "k1").unwrap();
assert!(
h.snippet.contains("\u{2}회사\u{3}"),
"match must be delimited like FTS"
);
assert_eq!(h.role, "user");
assert!(index::search_like(&conn, &nfd_hoesa(), 10, None, None)
.unwrap()
.iter()
.any(|h| h.row.session_id == "k1"));
}
#[test]
fn short_latin_query_matches_via_like() {
let _g = LOCK.lock().unwrap();
let conn = fresh_index();
seed_msg(
&conn,
"l1",
"/p",
"db",
"2026-06-10T10:00:00+00:00",
"the DB connection pool leaked",
);
assert!(index::search_like(&conn, "db", 10, None, None)
.unwrap()
.iter()
.any(|h| h.row.session_id == "l1"));
assert!(index::search_like(&conn, "zz", 10, None, None)
.unwrap()
.is_empty());
}
#[test]
fn short_query_escapes_like_wildcards() {
let _g = LOCK.lock().unwrap();
let conn = fresh_index();
seed_msg(
&conn,
"e1",
"/p",
"lit",
"2026-06-10T10:00:00+00:00",
"rate was 9%x today",
);
seed_msg(
&conn,
"e2",
"/p",
"wild",
"2026-06-09T10:00:00+00:00",
"rate was 9zx today",
);
let ids: Vec<String> = index::search_like(&conn, "9%x", 10, None, None)
.unwrap()
.iter()
.map(|h| h.row.session_id.clone())
.collect();
assert_eq!(
ids,
["e1"],
"% must be escaped to a literal, not a wildcard"
);
}
#[test]
fn short_query_orders_by_recency() {
let _g = LOCK.lock().unwrap();
let conn = fresh_index();
seed_msg(
&conn,
"r_old",
"/p",
"old",
"2026-06-01T10:00:00+00:00",
"회사 A",
);
seed_msg(
&conn,
"r_new",
"/p",
"new",
"2026-06-15T10:00:00+00:00",
"회사 B",
);
let ids: Vec<String> = index::search_like(&conn, "회사", 10, None, None)
.unwrap()
.iter()
.map(|h| h.row.session_id.clone())
.collect();
assert_eq!(
ids,
["r_new", "r_old"],
"newest session first (LIKE has no rank)"
);
}
#[test]
fn long_query_still_uses_fts_unchanged() {
let _g = LOCK.lock().unwrap();
let conn = fresh_index();
conn.execute(
"INSERT OR REPLACE INTO files
(path, mtime, size, session_id, tool, project, title, started, ended, msg_count, kind)
VALUES ('/fake/g1.jsonl', 0, 0, 'g1', 'codex', '/p', 'fts', ?1, ?1, 1, 'main')",
params!["2026-06-10T10:00:00+00:00"],
)
.unwrap();
conn.execute(
"INSERT INTO messages(session_id, role, text) VALUES ('g1','assistant','negative balance bug')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO msgs(rowid, text) VALUES (last_insert_rowid(), 'negative balance bug')",
[],
)
.unwrap();
assert!(index::search(&conn, "negative", 10, None, None)
.unwrap()
.iter()
.any(|h| h.row.session_id == "g1"));
}
#[test]
fn tags_are_nfc_normalized_on_add_filter_and_remove() {
let _g = LOCK.lock().unwrap();
let conn = fresh_index();
seed(
&conn,
"nfc1",
"codex",
"/proj/api",
"accent tag",
"2026-06-10T10:00:00+00:00",
);
let nfd = "cafe\u{301}";
let nfc = "caf\u{e9}";
index::add_tag(&conn, "nfc1", nfd).unwrap();
assert!(
index::tag_counts(&conn)
.unwrap()
.iter()
.any(|(t, _)| t == nfc),
"stored tag must be the composed (NFC) form"
);
for form in [nfd, nfc] {
let rows = index::recent(&conn, 10, None, None, Some(form), false).unwrap();
assert_eq!(rows.len(), 1, "tag filter must match form {form:?}");
}
assert_eq!(index::remove_tag(&conn, "nfc1", nfd).unwrap(), 1);
assert!(index::tag_counts(&conn).unwrap().is_empty());
}