Skip to main content

sessionwiki/
index.rs

1use crate::adapters::{self, Adapter};
2use anyhow::{Context, Result};
3use rusqlite::{params, Connection};
4use serde::Serialize;
5use std::collections::HashMap;
6use std::io::{IsTerminal, Write};
7use std::path::PathBuf;
8
9/// Where a legacy index would sit, given where this one is going.
10///
11/// Beside the destination, not under `dirs::data_dir()`. The migration used to
12/// ask the ambient data dir wherever the index was actually headed, and then
13/// RENAME what it found into that destination - so a run with
14/// `SESSIONWIKI_DATA` pointed at a temp dir would move the real
15/// `~/.local/share/sessiondex` into it, and the tags, notes and summaries in
16/// there are not rebuildable. Eight test files set that variable.
17fn legacy_candidates(dir: &std::path::Path) -> Vec<PathBuf> {
18    let Some(parent) = dir.parent() else {
19        return Vec::new();
20    };
21    ["sessiondex", "session-atlas"]
22        .iter()
23        .map(|n| parent.join(n))
24        .collect()
25}
26
27/// The index lives outside the session stores and never touches them.
28/// Default: ~/.local/share/sessionwiki/index.db (platform equivalent).
29pub fn db_path() -> Result<PathBuf> {
30    let dir = std::env::var_os("SESSIONWIKI_DATA")
31        .map(PathBuf::from)
32        .or_else(|| dirs::data_dir().map(|d| d.join("sessionwiki")))
33        .context("cannot determine a data directory")?;
34    // One-time migration from earlier names, newest first. This carries over
35    // the existing index AND the curated tags/notes/summaries, which are not
36    // rebuildable. The project was session-atlas, then sessiondex.
37    if !dir.exists() {
38        {
39            for old in legacy_candidates(&dir) {
40                if old.exists() {
41                    // A failed rename must not pass silently: the user would
42                    // get a fresh empty index while their curation sits
43                    // stranded in the old directory. Re-check after failure,
44                    // though - a concurrent first run (hook + CLI) may have
45                    // migrated it already, which is success, not failure.
46                    if let Err(e) = std::fs::rename(&old, &dir) {
47                        if !dir.exists() && old.exists() {
48                            eprintln!(
49                                "warning: could not migrate {} -> {} ({e}); \
50                                 starting a fresh index. Move it manually to \
51                                 keep your tags, notes, and archive.",
52                                old.display(),
53                                dir.display()
54                            );
55                        }
56                    }
57                    break;
58                }
59            }
60        }
61    }
62    std::fs::create_dir_all(&dir)?;
63    Ok(dir.join("index.db"))
64}
65
66/// The index path IF it already exists - with none of the directory creation or
67/// legacy migration `db_path` performs. For strictly read-only callers (`doctor`)
68/// that must not mutate the filesystem just to check for the index.
69pub fn existing_db_path() -> Option<PathBuf> {
70    let dir = std::env::var_os("SESSIONWIKI_DATA")
71        .map(PathBuf::from)
72        .or_else(|| dirs::data_dir().map(|d| d.join("sessionwiki")))?;
73    let db = dir.join("index.db");
74    db.exists().then_some(db)
75}
76
77/// `user_version` versions the disposable cache: a mismatch drops and rebuilds
78/// the derived tables (files/messages/msgs/touched) instead of migrating. The
79/// durable tables (summaries, tags, notes, archive) hold what cannot be
80/// re-derived - LLM output, user curation, and sessions whose originals the tool
81/// deleted - and are versioned separately by `meta.durable_version` via forward,
82/// additive-only migrations that never drop, so they survive every upgrade. The
83/// two counters are independent and must never gate each other.
84pub const SCHEMA_VERSION: i64 = 8; // 8: redact secrets at index time (rebuild scrubs old rows)
85
86/// Version of the durable schema this binary ships. The durable CREATE
87/// statements are frozen at this shape; every later durable change is a
88/// migration in DURABLE_MIGRATIONS. Independent of SCHEMA_VERSION (the cache).
89const BASELINE_DURABLE_VERSION: i64 = 1;
90
91/// Read meta.durable_version, seeding the baseline when absent. Absence covers
92/// both a fresh DB (durables just created at baseline) and an existing
93/// pre-feature index (durables already at baseline) - both correctly start at
94/// BASELINE. INSERT OR IGNORE is safe under a concurrent first-open.
95fn read_or_init_durable_version(conn: &Connection) -> Result<i64> {
96    conn.execute(
97        "INSERT OR IGNORE INTO meta(key, value) VALUES ('durable_version', ?1)",
98        params![BASELINE_DURABLE_VERSION.to_string()],
99    )?;
100    let v: String = conn.query_row(
101        "SELECT value FROM meta WHERE key = 'durable_version'",
102        [],
103        |r| r.get(0),
104    )?;
105    Ok(v.parse().unwrap_or(BASELINE_DURABLE_VERSION))
106}
107
108/// One migration step: plain DDL, or Rust code for transformations SQL cannot
109/// express (e.g. unicode normalization). Both run inside the same gated
110/// transaction and must stay additive-and-repair-only - never drop durable data.
111enum MigrationStep {
112    // The natural shape for most future migrations (ALTER/CREATE); only data
113    // repairs need `Fix`. Allowed while no registered migration uses it.
114    #[allow(dead_code)]
115    Sql(&'static str),
116    Fix(fn(&Connection) -> Result<()>),
117}
118
119struct Migration {
120    version: i64,
121    step: MigrationStep,
122}
123
124/// Forward-only, additive-only durable migrations applied in order. ALTER ADD
125/// COLUMN / CREATE / data repair only - never DROP/RENAME a durable column or
126/// table.
127const DURABLE_MIGRATIONS: &[Migration] = &[
128    // v2: tags written by pre-0.17 binaries were lowercased but not
129    // NFC-normalized, so a decomposed-form tag (macOS IME) is unreachable by
130    // the normalized lookups. Re-normalize stored rows once.
131    Migration {
132        version: 2,
133        step: MigrationStep::Fix(normalize_stored_tags),
134    },
135];
136
137/// Durable migration v2: converge every stored tag on `norm_tag` form. An NFC
138/// twin that already exists absorbs the row (INSERT OR IGNORE + DELETE).
139fn normalize_stored_tags(conn: &Connection) -> Result<()> {
140    let rows: Vec<(String, String)> = conn
141        .prepare("SELECT session_id, tag FROM tags")?
142        .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
143        .collect::<rusqlite::Result<_>>()?;
144    for (sid, tag) in rows {
145        let norm = norm_tag(&tag);
146        if norm != tag {
147            conn.execute(
148                "INSERT OR IGNORE INTO tags(session_id, tag) VALUES (?1, ?2)",
149                params![sid, norm],
150            )?;
151            conn.execute(
152                "DELETE FROM tags WHERE session_id = ?1 AND tag = ?2",
153                params![sid, tag],
154            )?;
155        }
156    }
157    Ok(())
158}
159
160/// Apply migrations whose version exceeds the stored durable_version, in one
161/// IMMEDIATE transaction (re-reading the version inside it so a concurrent
162/// process that already migrated makes this a no-op). DDL + version bump are
163/// atomic. Version-gating is the only thing that makes re-running safe, since
164/// SQLite ALTER ADD COLUMN is not idempotent.
165fn run_durable_migrations(conn: &Connection, migrations: &[Migration]) -> Result<()> {
166    conn.execute_batch("BEGIN IMMEDIATE")?;
167    let outcome = (|| -> Result<()> {
168        let current: i64 = conn
169            .query_row(
170                "SELECT value FROM meta WHERE key = 'durable_version'",
171                [],
172                |r| r.get::<_, String>(0),
173            )?
174            .parse()
175            .unwrap_or(BASELINE_DURABLE_VERSION);
176        for m in migrations.iter().filter(|m| m.version > current) {
177            match m.step {
178                MigrationStep::Sql(sql) => conn.execute_batch(sql)?,
179                MigrationStep::Fix(f) => f(conn)?,
180            }
181            conn.execute(
182                "UPDATE meta SET value = ?1 WHERE key = 'durable_version'",
183                params![m.version.to_string()],
184            )?;
185        }
186        Ok(())
187    })();
188    match outcome {
189        Ok(()) => conn.execute_batch("COMMIT")?,
190        Err(e) => {
191            let _ = conn.execute_batch("ROLLBACK");
192            return Err(e);
193        }
194    }
195    Ok(())
196}
197
198/// Create the derived cache + durable tables if absent (idempotent). Shared by
199/// `open()` and tests so both exercise the identical DDL.
200fn create_cache_schema(conn: &Connection) -> Result<()> {
201    conn.execute_batch(
202        "CREATE TABLE IF NOT EXISTS files(
203            path       TEXT PRIMARY KEY,
204            mtime      INTEGER NOT NULL,
205            size       INTEGER NOT NULL,
206            session_id TEXT NOT NULL,
207            tool       TEXT NOT NULL,
208            project    TEXT NOT NULL DEFAULT '',
209            title      TEXT NOT NULL DEFAULT '',
210            started    TEXT,
211            ended      TEXT,
212            msg_count  INTEGER NOT NULL DEFAULT 0,
213            kind       TEXT NOT NULL DEFAULT 'main',
214            -- Set when the tool deleted the original session file but we kept
215            -- the indexed copy (archive mode). NULL for live sessions.
216            archived_at TEXT
217        );
218        CREATE INDEX IF NOT EXISTS idx_files_session ON files(session_id);
219        -- Plain rows + external-content FTS. Deleting a session is an
220        -- indexed lookup here; with session_id stored UNINDEXED inside the
221        -- FTS table it was a full scan per file, which made re-index runs
222        -- quadratic in practice.
223        CREATE TABLE IF NOT EXISTS messages(
224            id         INTEGER PRIMARY KEY,
225            session_id TEXT NOT NULL,
226            role       TEXT NOT NULL,
227            text       TEXT NOT NULL
228        );
229        CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id);
230        CREATE VIRTUAL TABLE IF NOT EXISTS msgs USING fts5(
231            text,
232            content='messages',
233            content_rowid='id',
234            tokenize='trigram'
235        );
236        CREATE TABLE IF NOT EXISTS summaries(
237            session_id TEXT PRIMARY KEY,
238            summary    TEXT NOT NULL,
239            created    TEXT NOT NULL
240        );
241        -- Curation layer (the editable 'wiki' part). Like summaries, these
242        -- are user-authored and survive index rebuilds: only files/messages/
243        -- msgs are dropped on a schema bump, never these.
244        CREATE TABLE IF NOT EXISTS tags(
245            session_id TEXT NOT NULL,
246            tag        TEXT NOT NULL,
247            PRIMARY KEY (session_id, tag)
248        );
249        CREATE INDEX IF NOT EXISTS idx_tags_tag ON tags(tag);
250        CREATE TABLE IF NOT EXISTS notes(
251            session_id TEXT PRIMARY KEY,
252            note       TEXT NOT NULL,
253            updated    TEXT NOT NULL
254        );
255        -- Archive (durable, never dropped on a schema bump). When the tool
256        -- deletes a session's original file, we keep a self-contained copy
257        -- here - the distilled transcript and provenance plus the metadata
258        -- needed to reconstruct the files row. This is the only table that is
259        -- not re-derivable from disk, so on a schema bump the cache tables are
260        -- rehydrated from it. Live sessions are NOT stored here.
261        CREATE TABLE IF NOT EXISTS archive(
262            session_id  TEXT PRIMARY KEY,
263            path        TEXT NOT NULL,
264            mtime       INTEGER NOT NULL,
265            size        INTEGER NOT NULL,
266            tool        TEXT NOT NULL,
267            project     TEXT NOT NULL DEFAULT '',
268            title       TEXT NOT NULL DEFAULT '',
269            started     TEXT,
270            ended       TEXT,
271            msg_count   INTEGER NOT NULL DEFAULT 0,
272            kind        TEXT NOT NULL DEFAULT 'main',
273            transcript  TEXT NOT NULL,  -- JSON [[role,text],...] in order
274            touched     TEXT NOT NULL,  -- JSON [path,...]
275            archived_at TEXT NOT NULL
276        );
277        -- Provenance: which files each session edited or created, from its
278        -- tool calls. Rebuilt from the sessions on sync, so it is dropped on a
279        -- schema bump like messages - not curated. The path index powers
280        -- `trace` (sessions for a file) and shared-file relatedness.
281        CREATE TABLE IF NOT EXISTS touched(
282            session_id TEXT NOT NULL,
283            path       TEXT NOT NULL,
284            PRIMARY KEY (session_id, path)
285        );
286        CREATE INDEX IF NOT EXISTS idx_touched_path ON touched(path);
287        -- Durable key/value scratchpad. Holds `durable_version` (the durable-
288        -- schema version, separate from user_version). Never dropped.
289        -- Evidence layer over `touched`: the concrete edits (kind + a bounded
290        -- snippet of the change) behind each touched path. A log - multiple rows
291        -- per (session, path) - so the whole change history of a file survives.
292        -- Derived from the sessions on sync, dropped on a schema bump like
293        -- touched. Powers `edits_for` and the file-history page.
294        CREATE TABLE IF NOT EXISTS edits(
295            session_id TEXT NOT NULL,
296            path       TEXT NOT NULL,
297            kind       TEXT NOT NULL,
298            ts         TEXT,
299            snippet    TEXT NOT NULL
300        );
301        CREATE INDEX IF NOT EXISTS idx_edits_path ON edits(path);
302        CREATE INDEX IF NOT EXISTS idx_edits_session ON edits(session_id);
303        CREATE TABLE IF NOT EXISTS meta(
304            key   TEXT PRIMARY KEY,
305            value TEXT NOT NULL
306        );",
307    )?;
308    Ok(())
309}
310
311#[cfg(test)]
312mod migration_tests {
313    use super::*;
314
315    fn mem() -> Connection {
316        let c = Connection::open_in_memory().unwrap();
317        c.execute_batch(
318            "CREATE TABLE meta(key TEXT PRIMARY KEY, value TEXT NOT NULL);
319             CREATE TABLE notes(session_id TEXT PRIMARY KEY, note TEXT NOT NULL, updated TEXT NOT NULL);
320             INSERT INTO meta VALUES('durable_version','1');
321             INSERT INTO notes VALUES('s1','keep me','t');",
322        )
323        .unwrap();
324        c
325    }
326
327    #[test]
328    fn runner_applies_gated_and_is_idempotent() {
329        let c = mem();
330        let migs = [Migration {
331            version: 2,
332            step: MigrationStep::Sql("ALTER TABLE notes ADD COLUMN pinned INTEGER"),
333        }];
334        run_durable_migrations(&c, &migs).unwrap();
335        let cols: Vec<String> = c
336            .prepare("SELECT name FROM pragma_table_info('notes')")
337            .unwrap()
338            .query_map([], |r| r.get(0))
339            .unwrap()
340            .map(|r| r.unwrap())
341            .collect();
342        assert!(cols.contains(&"pinned".to_string()), "column added");
343        let v: String = c
344            .query_row(
345                "SELECT value FROM meta WHERE key='durable_version'",
346                [],
347                |r| r.get(0),
348            )
349            .unwrap();
350        assert_eq!(v, "2", "version advanced");
351        let note: String = c
352            .query_row("SELECT note FROM notes WHERE session_id='s1'", [], |r| {
353                r.get(0)
354            })
355            .unwrap();
356        assert_eq!(note, "keep me", "existing durable row preserved");
357        // re-run is a clean no-op (ADD COLUMN would otherwise error duplicate column)
358        run_durable_migrations(&c, &migs).unwrap();
359    }
360}
361
362/// A read-only handle for serving processes (the MCP server): it can never
363/// create the schema, migrate, VACUUM, or write durable data. Fails if no
364/// index exists yet (unlike `open`, which creates one).
365pub fn open_readonly() -> Result<Connection> {
366    use rusqlite::OpenFlags;
367    let conn = Connection::open_with_flags(
368        db_path()?,
369        OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
370    )?;
371    conn.busy_timeout(std::time::Duration::from_millis(5000))?;
372    Ok(conn)
373}
374
375pub fn open() -> Result<Connection> {
376    let conn = Connection::open(db_path()?)?;
377    conn.pragma_update(None, "journal_mode", "WAL")?;
378    conn.pragma_update(None, "synchronous", "NORMAL")?;
379    conn.busy_timeout(std::time::Duration::from_millis(5000))?;
380    let version: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?;
381    let bumped = version != SCHEMA_VERSION;
382    if bumped {
383        // Drop only the derived cache. The durable tables (summaries, tags,
384        // notes, archive) are never dropped: rebuilding the index is cheap,
385        // re-running an LLM or recovering a session the tool already deleted is
386        // not. Archived sessions are rehydrated into the cache below.
387        conn.execute_batch(
388            "DROP TABLE IF EXISTS msgs;
389             DROP TABLE IF EXISTS messages;
390             DROP TABLE IF EXISTS touched;
391             DROP TABLE IF EXISTS files;
392             DROP TABLE IF EXISTS edits;",
393        )?;
394        conn.pragma_update(None, "user_version", SCHEMA_VERSION)?;
395    }
396    create_cache_schema(&conn)?;
397    // Durable-table versioning, independent of user_version (the cache). `meta`
398    // exists from the CREATE batch above.
399    let durable = read_or_init_durable_version(&conn)?;
400    let latest = DURABLE_MIGRATIONS
401        .iter()
402        .map(|m| m.version)
403        .max()
404        .unwrap_or(BASELINE_DURABLE_VERSION);
405    if durable < latest {
406        // Back up the irreplaceable durable data before the first migration runs.
407        let bak = db_path()?.with_extension(format!("db.bak-v{durable}"));
408        let _ = std::fs::remove_file(&bak);
409        conn.execute("VACUUM INTO ?1", params![bak.to_string_lossy()])?;
410        run_durable_migrations(&conn, DURABLE_MIGRATIONS)?;
411    }
412    // Replay archived sessions into the cache whenever any are missing from it
413    // - after a schema bump (which dropped the cache) or if the cache was
414    // cleared some other way. Gated on a count so a normal open does nothing.
415    let arch_total: i64 = conn.query_row("SELECT count(*) FROM archive", [], |r| r.get(0))?;
416    let arch_live: i64 = conn.query_row(
417        "SELECT count(*) FROM files WHERE archived_at IS NOT NULL",
418        [],
419        |r| r.get(0),
420    )?;
421    if arch_total > arch_live {
422        rehydrate_archive(&conn)?;
423    }
424    Ok(conn)
425}
426
427/// After a schema bump drops the cache tables, replay archived sessions back
428/// into them from the durable `archive` table, so search, `trace`, and reading
429/// keep working for sessions whose originals the tool deleted. This is what
430/// makes archive survive a rebuild; without it a version bump would silently
431/// lose exactly the data that cannot be re-derived from disk.
432fn rehydrate_archive(conn: &Connection) -> Result<()> {
433    let mut stmt = conn.prepare(
434        "SELECT session_id, path, tool, project, title, started, ended,
435                kind, transcript, touched, archived_at FROM archive",
436    )?;
437    let rows: Vec<ArchiveRow> = stmt
438        .query_map([], |r| {
439            Ok(ArchiveRow {
440                session_id: r.get(0)?,
441                path: r.get(1)?,
442                tool: r.get(2)?,
443                project: r.get(3)?,
444                title: r.get(4)?,
445                started: r.get(5)?,
446                ended: r.get(6)?,
447                kind: r.get(7)?,
448                transcript: r.get(8)?,
449                touched: r.get(9)?,
450                archived_at: r.get(10)?,
451            })
452        })?
453        .collect::<rusqlite::Result<_>>()?;
454
455    for a in rows {
456        // The transcript is the durable backup; if it will not deserialize,
457        // skip the session rather than rehydrate an empty shell that claims to
458        // have content - that would be silent data loss disguised as success.
459        let msgs: Vec<(String, String)> = match serde_json::from_str(&a.transcript) {
460            Ok(m) => m,
461            Err(e) => {
462                eprintln!(
463                    "archive: skipping {} - unreadable transcript ({e})",
464                    a.session_id
465                );
466                continue;
467            }
468        };
469        let paths: Vec<String> = serde_json::from_str(&a.touched).unwrap_or_else(|e| {
470            eprintln!("archive: {} has unreadable provenance ({e})", a.session_id);
471            Vec::new()
472        });
473
474        // Idempotent: clear any existing cache rows for this session first, so
475        // re-running rehydrate never duplicates messages/FTS rows.
476        delete_session_msgs(conn, &a.session_id)?;
477        delete_session_provenance(conn, &a.session_id)?;
478        conn.execute(
479            "DELETE FROM files WHERE session_id = ?1",
480            params![a.session_id],
481        )?;
482
483        // mtime/size are forced to 0 so that if this file ever reappears on
484        // disk, the next sync always sees a mismatch and re-parses it, clearing
485        // archived_at. msg_count comes from the actual transcript, never the
486        // stored count, so the displayed count can never outrun the content.
487        conn.execute(
488            "INSERT INTO files
489             (path, mtime, size, session_id, tool, project, title, started, ended,
490              msg_count, kind, archived_at)
491             VALUES (?1,0,0,?2,?3,?4,?5,?6,?7,?8,?9,?10)",
492            params![
493                a.path,
494                a.session_id,
495                a.tool,
496                crate::util::nfc(&a.project),
497                a.title,
498                a.started,
499                a.ended,
500                msgs.len() as i64,
501                a.kind,
502                a.archived_at,
503            ],
504        )?;
505        {
506            let mut ins_row = conn
507                .prepare_cached("INSERT INTO messages(session_id, role, text) VALUES (?1,?2,?3)")?;
508            let mut ins_fts =
509                conn.prepare_cached("INSERT INTO msgs(rowid, text) VALUES (?1,?2)")?;
510            for (role, text) in &msgs {
511                // Re-normalize on rehydrate: pre-fix archives hold raw/NFD JSON,
512                // so this is where archived Korean sessions become NFC again.
513                // Also redact - a pre-redaction archive holds raw secrets.
514                let text = crate::redact::redact(&crate::util::nfc(text)).into_owned();
515                ins_row.execute(params![a.session_id, role, text])?;
516                ins_fts.execute(params![conn.last_insert_rowid(), text])?;
517            }
518        }
519        let mut ins_touched =
520            conn.prepare_cached("INSERT OR IGNORE INTO touched(session_id, path) VALUES (?1,?2)")?;
521        for p in &paths {
522            ins_touched.execute(params![a.session_id, crate::util::nfc(p)])?;
523        }
524    }
525    Ok(())
526}
527
528struct ArchiveRow {
529    session_id: String,
530    path: String,
531    tool: String,
532    project: String,
533    title: String,
534    started: Option<String>,
535    ended: Option<String>,
536    kind: String,
537    transcript: String,
538    touched: String,
539    archived_at: String,
540}
541
542/// Bring the index up to date with what is on disk. Only files whose
543/// (mtime, size) changed since the last run are re-parsed.
544/// Insert one parsed session into the cache tables (files, messages, msgs,
545/// touched) and tag it if an oh-my-* harness drove it. `key` is the stored
546/// path/identity, `mtime` the change-token, `size` the byte size (0 for
547/// shared-store sessions). Shared by the file-per-session and shared-store paths.
548fn index_one(
549    tx: &rusqlite::Transaction,
550    session: &crate::model::Session,
551    key: &str,
552    mtime: i64,
553    size: i64,
554) -> Result<()> {
555    delete_session_msgs(tx, &session.id)?;
556    delete_session_provenance(tx, &session.id)?;
557    // A path that was archived (the tool deleted it, now it is back) is live
558    // again: this INSERT clears archived_at, and the durable copy is dropped.
559    tx.execute(
560        "DELETE FROM archive WHERE session_id = ?1",
561        params![session.id],
562    )?;
563    tx.execute(
564        "INSERT OR REPLACE INTO files
565         (path, mtime, size, session_id, tool, project, title, started, ended, msg_count, kind)
566         VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)",
567        params![
568            key,
569            mtime,
570            size,
571            session.id,
572            session.tool,
573            crate::util::nfc(&session.project),
574            crate::redact::redact(&session.title).as_ref(),
575            session.started.map(|t| t.to_rfc3339()),
576            session.ended.map(|t| t.to_rfc3339()),
577            session.messages.len() as i64,
578            if session.subagent { "sub" } else { "main" },
579        ],
580    )?;
581    // Contract: messages are inserted in transcript order within one
582    // transaction, so the autoincrement messages.id is a monotonic proxy for
583    // order (preview + the web transcript rely on it). Keep this sequential.
584    {
585        let mut ins_row =
586            tx.prepare_cached("INSERT INTO messages(session_id, role, text) VALUES (?1,?2,?3)")?;
587        let mut ins_fts = tx.prepare_cached("INSERT INTO msgs(rowid, text) VALUES (?1,?2)")?;
588        for m in &session.messages {
589            // Normalize once and reuse for the plain row and the external-content
590            // FTS row: they MUST be byte-identical or delete_session_msgs corrupts.
591            // Strip secrets before they enter the index (which outlives the
592            // original session via archive mode). Redact then reuse for both rows.
593            let text = crate::redact::redact(&crate::util::nfc(&m.text)).into_owned();
594            ins_row.execute(params![session.id, m.role.label(), text])?;
595            ins_fts.execute(params![tx.last_insert_rowid(), text])?;
596        }
597        let mut ins_touched =
598            tx.prepare_cached("INSERT OR IGNORE INTO touched(session_id, path) VALUES (?1,?2)")?;
599        for p in &session.touched {
600            ins_touched.execute(params![session.id, crate::util::nfc(p)])?;
601        }
602        // Evidence layer (a log, not a set - the same file edited twice keeps
603        // both rows). Prior rows were cleared with `touched` above.
604        let mut ins_edit = tx.prepare_cached(
605            "INSERT INTO edits(session_id, path, kind, ts, snippet) VALUES (?1,?2,?3,?4,?5)",
606        )?;
607        for e in &session.edits {
608            ins_edit.execute(params![
609                session.id,
610                crate::util::nfc(&e.path),
611                e.kind.as_str(),
612                e.ts.map(|t| t.to_rfc3339()),
613                crate::redact::redact(&e.snippet).as_ref(),
614            ])?;
615        }
616    }
617    // Tag sessions an oh-my-* harness drove (it wraps Claude Code / Codex /
618    // OpenCode) so they are filterable; recomputed on every reindex.
619    if matches!(session.tool, "claude-code" | "codex" | "opencode") {
620        if let Some(h) = crate::adapters::harness::detect(&session.project) {
621            add_tag(tx, &session.id, h)?;
622        }
623    }
624    Ok(())
625}
626
627/// The end-of-adapter sync line. Honest about failures: "indexed 12/14
628/// (2 failed to parse)" rather than pretending everything landed.
629fn report_indexed(tool: &str, total: usize, failed: usize) {
630    if failed > 0 {
631        eprintln!(
632            "\r[{tool}] indexed {}/{total} ({failed} failed to parse)    ",
633            total - failed
634        );
635    } else {
636        eprintln!("\r[{tool}] indexed {total}/{total}    ");
637    }
638}
639
640pub fn sync(conn: &mut Connection, only_tool: Option<&str>) -> Result<()> {
641    sync_bounded(conn, only_tool, None)
642}
643
644/// Like [`sync`], but when `since` is set, only files/sessions modified at or
645/// after that epoch-second are (re)parsed - a bounded FRESHNESS top-up for the
646/// MCP path, so `recent_sessions` picks up a just-started sibling without paying
647/// the full-corpus re-parse (the 46GB-codex trap). Deletion reconciliation still
648/// sees every file, so a bounded run never archives a live session; older
649/// changed files are simply left for the next full `sync`.
650pub fn sync_bounded(
651    conn: &mut Connection,
652    only_tool: Option<&str>,
653    since: Option<i64>,
654) -> Result<()> {
655    let adapters: Vec<Box<dyn Adapter>> = match only_tool {
656        Some(t) => adapters::by_name(t).into_iter().collect(),
657        None => adapters::all(),
658    };
659    sync_with(conn, &adapters, since)
660}
661
662/// Like [`sync_bounded`], but over an explicit adapter list instead of the
663/// built-in registry. A program that embeds this crate as a library can index
664/// its own sessions by passing its own [`Adapter`] alongside `adapters::all()`.
665/// Readers without that adapter use the indexed transcript; call this again
666/// to make later changes to those sessions visible to the standalone binary.
667pub fn sync_with(
668    conn: &mut Connection,
669    adapters: &[Box<dyn Adapter>],
670    since: Option<i64>,
671) -> Result<()> {
672    // Per-session progress redraws one line with `\r`, which only means
673    // anything on a terminal. Redirected into a log it becomes a single line
674    // megabytes long, so decide once per sync and skip those writes when stderr
675    // is not a terminal. Warnings and the per-tool summary still print.
676    let progress = std::io::stderr().is_terminal();
677    let mut known: HashMap<String, (i64, i64)> = HashMap::new();
678    {
679        let mut stmt = conn.prepare("SELECT path, mtime, size FROM files")?;
680        let rows = stmt.query_map([], |r| {
681            Ok((
682                r.get::<_, String>(0)?,
683                (r.get::<_, i64>(1)?, r.get::<_, i64>(2)?),
684            ))
685        })?;
686        for row in rows {
687            let (path, ms) = row?;
688            known.insert(path, ms);
689        }
690    }
691
692    let mut archived_total = 0usize;
693    for adapter in adapters {
694        // `store_present` tells "the tool pruned some sessions" (root exists,
695        // those gone) apart from "the whole store vanished" (uninstall,
696        // unmounted) - we must not mass-archive on the latter.
697        let tool = adapter.name();
698        let store_present = adapter.root().is_some_and(|r| r.exists());
699
700        // Shared store (e.g. OpenCode's SQLite db): enumerate sessions by key +
701        // change-token and re-parse only the changed ones, bypassing the
702        // file-per-session path. The token is stored in the `mtime` column
703        // (size 0), so the same change comparison works.
704        if let Some(store) = adapter.store() {
705            let mut seen: Vec<String> = Vec::with_capacity(store.keys.len());
706            let mut pending: Vec<String> = Vec::new();
707            for (key, token) in &store.keys {
708                if known.get(key) != Some(&(*token, 0)) && since.is_none_or(|s| *token >= s) {
709                    pending.push(key.clone());
710                }
711                seen.push(key.clone());
712            }
713            // Reconcile deletions only when the whole store was read this run.
714            // If a backing db was present but unreadable (locked, half-written),
715            // `seen` is partial - pruning off it would archive the whole corpus
716            // on a transient hiccup, so skip reconciliation until a clean read.
717            if store.had_error {
718                // The discover path says this out loud; this one did not. It is
719                // the path aider takes, whose walk is capped at two seconds over
720                // the whole home - so on a large home the flag can be set every
721                // run, reconciliation never happens, and nothing says why a
722                // session the tool deleted is still listed.
723                eprintln!(
724                    "[{tool}] the store could not be read in full; \
725                     skipping deletion reconciliation this run"
726                );
727            } else {
728                archived_total += archive_or_prune(
729                    conn,
730                    tool,
731                    &seen,
732                    store_present,
733                    adapter.reconcile_scope().as_deref(),
734                )?;
735            }
736
737            if !pending.is_empty() {
738                let token_of: HashMap<&str, i64> =
739                    store.keys.iter().map(|(k, t)| (k.as_str(), *t)).collect();
740                let total = pending.len();
741                let mut failed = 0usize;
742                let tx = conn.transaction()?;
743                for (i, key) in pending.iter().enumerate() {
744                    if progress {
745                        eprint!("\r[{tool}] indexing {}/{total}", i + 1);
746                        std::io::stderr().flush().ok();
747                    }
748                    // A failed parse is warned, not silently dropped: the user
749                    // must know a session is missing from the corpus.
750                    let session = match adapter.parse_key(key) {
751                        Ok(s) => s,
752                        Err(e) => {
753                            failed += 1;
754                            eprintln!("\r[{tool}] failed to parse {key}: {e:#}");
755                            continue;
756                        }
757                    };
758                    let token = token_of.get(key.as_str()).copied().unwrap_or(0);
759                    index_one(&tx, &session, key, token, 0)?;
760                }
761                tx.commit()?;
762                report_indexed(tool, total, failed);
763            }
764            continue;
765        }
766
767        let discovered = adapter.discover();
768        let mut seen: Vec<String> = Vec::with_capacity(discovered.files.len());
769        let mut pending: Vec<(PathBuf, i64, i64)> = Vec::new();
770
771        for f in discovered.files {
772            let meta = match f.metadata() {
773                Ok(m) => m,
774                Err(e) => {
775                    // The file was just discovered, so it exists: a failed
776                    // stat must keep it in `seen` (dropping it would let
777                    // reconciliation archive a live session) and be warned,
778                    // not swallowed.
779                    eprintln!("\r[{tool}] failed to stat {}: {e}", f.display());
780                    seen.push(f.to_string_lossy().into_owned());
781                    continue;
782                }
783            };
784            let mtime = meta
785                .modified()
786                .ok()
787                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
788                .map(|d| d.as_secs() as i64)
789                .unwrap_or(0);
790            let size = meta.len() as i64;
791            let key = f.to_string_lossy().into_owned();
792            if known.get(&key) != Some(&(mtime, size)) && since.is_none_or(|s| mtime >= s) {
793                pending.push((f, mtime, size));
794            }
795            seen.push(key);
796        }
797
798        // Same guard the shared-store path has: a partial walk (an unreadable
799        // directory) means `seen` is incomplete - reconciling deletions off it
800        // would archive live sessions, so wait for a clean walk.
801        if discovered.had_error {
802            eprintln!(
803                "[{tool}] some session directories could not be read; \
804                 skipping deletion reconciliation this run"
805            );
806        } else {
807            archived_total += archive_or_prune(
808                conn,
809                tool,
810                &seen,
811                store_present,
812                adapter.reconcile_scope().as_deref(),
813            )?;
814        }
815
816        if pending.is_empty() {
817            continue;
818        }
819        let total = pending.len();
820        let mut done = 0usize;
821        let mut failed = 0usize;
822        let tx = conn.transaction()?;
823        for (path, mtime, size) in pending {
824            done += 1;
825            if progress {
826                eprint!("\r[{tool}] indexing {done}/{total}");
827                std::io::stderr().flush().ok();
828            }
829
830            // A failed parse is warned, not silently dropped: the user must
831            // know a session is missing from the corpus.
832            let session = match adapter.parse(&path) {
833                Ok(s) => s,
834                Err(e) => {
835                    failed += 1;
836                    eprintln!("\r[{tool}] failed to parse {}: {e:#}", path.display());
837                    continue;
838                }
839            };
840            let key = path.to_string_lossy();
841            index_one(&tx, &session, &key, mtime, size)?;
842        }
843        tx.commit()?;
844        report_indexed(tool, total, failed);
845    }
846
847    // The one passive signal that archive is earning its keep: how many
848    // sessions we kept this run that the tool deleted, and the running total.
849    if archived_total > 0 {
850        let kept: i64 = conn.query_row(
851            "SELECT count(*) FROM files WHERE archived_at IS NOT NULL",
852            [],
853            |r| r.get(0),
854        )?;
855        eprintln!(
856            "archived {archived_total} session(s) the tool removed ({kept} kept that your tools have deleted)"
857        );
858    }
859    Ok(())
860}
861
862/// External-content FTS5 requires handing back the old rows on delete.
863fn delete_session_msgs(conn: &Connection, session_id: &str) -> Result<()> {
864    conn.execute(
865        "INSERT INTO msgs(msgs, rowid, text)
866         SELECT 'delete', id, text FROM messages WHERE session_id = ?1",
867        params![session_id],
868    )?;
869    conn.execute(
870        "DELETE FROM messages WHERE session_id = ?1",
871        params![session_id],
872    )?;
873    Ok(())
874}
875
876/// Clear a session's derived provenance - `touched` AND `edits` together - so a
877/// delete site can never remember one and forget the other (they drifted once,
878/// leaving orphaned edit rows visible through `edits_for`).
879fn delete_session_provenance(conn: &Connection, session_id: &str) -> Result<()> {
880    conn.execute(
881        "DELETE FROM touched WHERE session_id = ?1",
882        params![session_id],
883    )?;
884    conn.execute(
885        "DELETE FROM edits WHERE session_id = ?1",
886        params![session_id],
887    )?;
888    Ok(())
889}
890
891/// Reconcile the index with a tool's store after discovery. Sessions whose
892/// original file disappeared are **archived** (kept in the durable `archive`
893/// table and flagged in `files`, with messages/touched left in place so
894/// search and trace keep working) instead of deleted - unless
895/// `SESSIONWIKI_NO_ARCHIVE` is set or the session has no indexed content, in
896/// which case they are pruned as before. Returns how many were newly archived.
897///
898/// Guard: if the store root is gone (uninstalled, unmounted), do not touch its
899/// sessions - that is "the whole store vanished", not "the tool pruned some".
900/// An existing-but-empty store is a legitimate prune-everything and proceeds.
901fn archive_or_prune(
902    conn: &Connection,
903    tool: &str,
904    seen: &[String],
905    store_present: bool,
906    scope: Option<&str>,
907) -> Result<usize> {
908    let no_archive = std::env::var_os("SESSIONWIKI_NO_ARCHIVE").is_some();
909    // A scoped adapter speaks only for the keys under its prefix; everything
910    // else under the same tool name belongs to another store and must be left
911    // alone. Filtering happens in Rust, not with SQL `LIKE`: keys are paths and
912    // `_` is a LIKE wildcard.
913    let in_scope = |key: &str| scope.is_none_or(|p| key.starts_with(p));
914    let seen_set: std::collections::HashSet<&str> = seen
915        .iter()
916        .map(String::as_str)
917        .filter(|k| in_scope(k))
918        .collect();
919
920    let mut stmt =
921        conn.prepare("SELECT path, session_id FROM files WHERE tool = ?1 AND archived_at IS NULL")?;
922    let all_live: Vec<(String, String)> = stmt
923        .query_map(params![tool], |r| Ok((r.get(0)?, r.get(1)?)))?
924        .collect::<rusqlite::Result<_>>()?;
925    let live: Vec<(String, String)> = all_live.into_iter().filter(|(p, _)| in_scope(p)).collect();
926    let gone: Vec<(String, String)> = live
927        .into_iter()
928        .filter(|(p, _)| !seen_set.contains(p.as_str()))
929        .collect();
930    if gone.is_empty() {
931        return Ok(0);
932    }
933    if !store_present {
934        eprintln!(
935            "[{tool}] store not found - skipping ({} indexed session(s) left untouched, not archived)",
936            gone.len()
937        );
938        return Ok(0);
939    }
940    // The root exists but discovery returned nothing while we still had live
941    // sessions: could be a legitimate prune-everything, but also a transient
942    // read failure (permissions, a half-mounted network FS). Archiving keeps
943    // the data (reversible on the next good sync), but say so loudly.
944    if seen_set.is_empty() {
945        eprintln!(
946            "[{tool}] no sessions found on disk but {} were indexed - archiving them; \
947             if the store is just unreadable right now, they will un-archive on the next sync",
948            gone.len()
949        );
950    }
951
952    let mut archived = 0usize;
953    for (path, sid) in gone {
954        if no_archive {
955            conn.execute("DELETE FROM files WHERE path = ?1", params![path])?;
956            delete_session_msgs(conn, &sid)?;
957            delete_session_provenance(conn, &sid)?;
958        } else {
959            archive_session(conn, &path, &sid)?;
960            archived += 1;
961        }
962    }
963    Ok(archived)
964}
965
966/// Copy a session whose original file is gone into the durable `archive` table
967/// and flag its `files` row. The messages/msgs/touched rows are left in place
968/// so search and `trace` keep working; the archive copy is the rebuild-survival
969/// backup (replayed by `rehydrate_archive` after a schema bump).
970fn archive_session(conn: &Connection, path: &str, sid: &str) -> Result<()> {
971    let mut s =
972        conn.prepare("SELECT role, text FROM messages WHERE session_id = ?1 ORDER BY id")?;
973    let transcript: Vec<(String, String)> = s
974        .query_map(params![sid], |r| Ok((r.get(0)?, r.get(1)?)))?
975        .collect::<rusqlite::Result<_>>()?;
976    drop(s);
977    let mut s = conn.prepare("SELECT path FROM touched WHERE session_id = ?1 ORDER BY rowid")?;
978    let touched: Vec<String> = s
979        .query_map(params![sid], |r| r.get(0))?
980        .collect::<rusqlite::Result<_>>()?;
981    drop(s);
982    let transcript_json = serde_json::to_string(&transcript)?;
983    let touched_json = serde_json::to_string(&touched)?;
984    conn.execute(
985        "INSERT OR REPLACE INTO archive
986         (session_id, path, mtime, size, tool, project, title, started, ended,
987          msg_count, kind, transcript, touched, archived_at)
988         SELECT session_id, path, mtime, size, tool, project, title, started, ended,
989                msg_count, kind, ?2, ?3, datetime('now')
990         FROM files WHERE path = ?1",
991        params![path, transcript_json, touched_json],
992    )?;
993    conn.execute(
994        "UPDATE files SET archived_at = datetime('now') WHERE path = ?1",
995        params![path],
996    )?;
997    Ok(())
998}
999
1000/// Serializes to the agent-facing JSON contract: snake_case keys matching the
1001/// web API (`id`, `msgs`, tags as an array). The absolute `path` is never
1002/// serialized verbatim - only the tool's own `native_id` (the codex rollout /
1003/// claude transcript UUID extracted from the filename) is exposed, so an agent
1004/// can join a harness "tower" row (which knows only the native id) back to a
1005/// session without the local path ever leaking.
1006#[derive(Serialize)]
1007pub struct SessionRow {
1008    #[serde(rename = "id")]
1009    pub session_id: String,
1010    pub tool: String,
1011    /// The NATIVE session file path. Never serialized as-is; it is surfaced only
1012    /// as the extracted `native_id` UUID (or null when the filename carries no
1013    /// UUID) via [`ser_native_id`].
1014    #[serde(rename = "native_id", serialize_with = "ser_native_id")]
1015    pub path: String,
1016    pub project: String,
1017    pub title: String,
1018    pub started: Option<String>,
1019    #[serde(rename = "msgs")]
1020    pub msg_count: i64,
1021    pub kind: String,
1022    /// Tail of the conversation (last assistant message), so a list can show
1023    /// how the session ended without opening it.
1024    pub preview: Option<String>,
1025    /// Cached LLM synopsis, if `summarize` has been run for this session.
1026    pub summary: Option<String>,
1027    /// Comma-joined user tags, if any. Serialized as a string array (or null).
1028    #[serde(serialize_with = "ser_tags")]
1029    pub tags: Option<String>,
1030    /// True if the tool deleted the original and we kept the indexed copy.
1031    pub archived: bool,
1032    /// The swapdex account profile active when this session started, when a
1033    /// swapdex switch timeline exists on the machine. Null otherwise - a
1034    /// missing badge, never a guess.
1035    pub account: Option<String>,
1036}
1037
1038/// Tags are stored comma-joined but the JSON contract is an array (matching the
1039/// web API). Null when there are no tags.
1040fn ser_tags<S: serde::Serializer>(tags: &Option<String>, s: S) -> Result<S::Ok, S::Error> {
1041    match tags {
1042        Some(t) => s.collect_seq(t.split(',')),
1043        None => s.serialize_none(),
1044    }
1045}
1046
1047/// Serialize a session's stored `path` as its `native_id` only: the tool's own
1048/// session UUID (or null when the filename carries no UUID). The absolute path
1049/// is never emitted - only the extracted UUID reaches the JSON contract.
1050fn ser_native_id<S: serde::Serializer>(path: &str, s: S) -> Result<S::Ok, S::Error> {
1051    match native_id_of(path) {
1052        Some(id) => s.serialize_some(&id),
1053        None => s.serialize_none(),
1054    }
1055}
1056
1057/// Extract the native session UUID embedded in a session file's path - the id
1058/// the originating tool (and a harness "tower") knows the session by: the Codex
1059/// rollout UUID (`rollout-<ts>-<uuid>.jsonl`) or the Claude Code transcript UUID
1060/// (`<uuid>.jsonl`, or `agent-<uuid>.jsonl` for a subagent). Returns the first
1061/// canonical 8-4-4-4-12 UUID found in the file NAME, lowercased, or None when the
1062/// filename carries no UUID (tools that key sessions differently). Scanning the
1063/// file name (not the whole path) keeps a codex timestamp or a parent directory
1064/// from being mistaken for the session's own id.
1065pub fn native_id_of(path: &str) -> Option<String> {
1066    let name = std::path::Path::new(path).file_name()?.to_string_lossy();
1067    find_uuid(&name)
1068}
1069
1070/// The first canonical UUID (8-4-4-4-12 hex with dashes) appearing in `s`,
1071/// lowercased. None when there is no such substring.
1072fn find_uuid(s: &str) -> Option<String> {
1073    let b = s.as_bytes();
1074    if b.len() < 36 {
1075        return None;
1076    }
1077    for start in 0..=b.len() - 36 {
1078        if is_uuid_bytes(&b[start..start + 36]) {
1079            return Some(s[start..start + 36].to_ascii_lowercase());
1080        }
1081    }
1082    None
1083}
1084
1085/// Whether a 36-byte window is a canonical UUID: hex everywhere except dashes at
1086/// positions 8, 13, 18, 23.
1087fn is_uuid_bytes(b: &[u8]) -> bool {
1088    b.len() == 36
1089        && b.iter().enumerate().all(|(i, &c)| match i {
1090            8 | 13 | 18 | 23 => c == b'-',
1091            _ => c.is_ascii_hexdigit(),
1092        })
1093}
1094
1095/// Whether `q` could be a native-id lookup (full UUID or a UUID prefix), as
1096/// opposed to a sessionwiki short id (always 12 hex chars, no dashes). A valid
1097/// UUID prefix longer than its first 8-hex group must carry a dash (position 8
1098/// is always `-`), so a plain-hex string of 9+ chars can only be a short id and
1099/// never triggers the native scan - which keeps short-id resolution unchanged.
1100fn looks_like_native_prefix(q: &str) -> bool {
1101    let len = q.len();
1102    if !(4..=36).contains(&len) {
1103        return false;
1104    }
1105    if !q.bytes().all(|c| c.is_ascii_hexdigit() || c == b'-') {
1106        return false;
1107    }
1108    // Plain hex, no dash: only a first-group prefix (<= 8 chars) can be a UUID.
1109    q.contains('-') || len <= 8
1110}
1111
1112/// Correlated subquery for the preview column; messages.id preserves
1113/// insertion order, which is message order.
1114const PREVIEW_SQL: &str = "(SELECT substr(m2.text, 1, 280) FROM messages m2
1115    WHERE m2.session_id = f.session_id AND m2.role = 'assistant'
1116    ORDER BY m2.id DESC LIMIT 1)";
1117
1118const SUMMARY_SQL: &str = "(SELECT s.summary FROM summaries s WHERE s.session_id = f.session_id)";
1119
1120const TAGS_SQL: &str =
1121    "(SELECT group_concat(t.tag, ',') FROM tags t WHERE t.session_id = f.session_id)";
1122
1123pub fn recent(
1124    conn: &Connection,
1125    limit: usize,
1126    tool: Option<&str>,
1127    project: Option<&str>,
1128    tag: Option<&str>,
1129    include_subagents: bool,
1130) -> Result<Vec<SessionRow>> {
1131    let mut sql = format!(
1132        "SELECT session_id, tool, path, project, title, started, msg_count, kind, {PREVIEW_SQL}, {SUMMARY_SQL}, {TAGS_SQL}, (archived_at IS NOT NULL)
1133         FROM files f WHERE 1=1",
1134    );
1135    let mut args: Vec<String> = Vec::new();
1136    // A tag filter is an explicit ask for *those* sessions; don't hide subagent
1137    // hits behind the main-only default (the tag cloud counts every kind, so a
1138    // sub-only tag would otherwise show in the cloud but return nothing here).
1139    if !include_subagents && tag.is_none() {
1140        sql.push_str(" AND kind = 'main'");
1141    }
1142    if let Some(t) = tool {
1143        sql.push_str(" AND tool = ?");
1144        args.push(t.to_string());
1145    }
1146    if let Some(p) = project {
1147        sql.push_str(" AND project LIKE ?");
1148        args.push(format!("%{}%", crate::util::nfc(p)));
1149    }
1150    if let Some(t) = tag {
1151        sql.push_str(
1152            " AND EXISTS (SELECT 1 FROM tags g WHERE g.session_id = f.session_id AND g.tag = ?)",
1153        );
1154        args.push(norm_tag(t)); // stored tags are normalized; match their form
1155    }
1156    sql.push_str(&format!(" ORDER BY started DESC LIMIT {limit}"));
1157
1158    let mut stmt = conn.prepare(&sql)?;
1159    let rows = stmt.query_map(rusqlite::params_from_iter(args), |r| {
1160        Ok(SessionRow {
1161            session_id: r.get(0)?,
1162            tool: r.get(1)?,
1163            path: r.get(2)?,
1164            project: r.get(3)?,
1165            title: r.get(4)?,
1166            started: r.get(5)?,
1167            msg_count: r.get(6)?,
1168            kind: r.get(7)?,
1169            preview: r.get(8)?,
1170            summary: r.get(9)?,
1171            tags: r.get(10)?,
1172            archived: r.get(11)?,
1173            account: None,
1174        })
1175    })?;
1176    let mut out = rows.collect::<rusqlite::Result<Vec<_>>>()?;
1177    crate::account_link::annotate(out.iter_mut());
1178    Ok(out)
1179}
1180
1181/// Recent main sessions whose launch project is EXACTLY this directory (for the
1182/// SessionStart recall hook). Exact equality - never the substring `--project`
1183/// filter, which over-matches sibling/child paths. Newest first, stable.
1184pub fn project_brief(conn: &Connection, project: &str, limit: usize) -> Result<Vec<SessionRow>> {
1185    let p = crate::util::nfc(project.trim_end_matches('/'));
1186    let mut stmt = conn.prepare(&format!(
1187        "SELECT session_id, tool, path, project, title, started, msg_count, kind, {PREVIEW_SQL}, {SUMMARY_SQL}, {TAGS_SQL}, (archived_at IS NOT NULL)
1188         FROM files f
1189         WHERE f.project = ?1 AND f.kind = 'main'
1190         ORDER BY f.started DESC, f.session_id
1191         LIMIT ?2"
1192    ))?;
1193    let rows = stmt.query_map(params![p, limit as i64], |r| {
1194        Ok(SessionRow {
1195            session_id: r.get(0)?,
1196            tool: r.get(1)?,
1197            path: r.get(2)?,
1198            project: r.get(3)?,
1199            title: r.get(4)?,
1200            started: r.get(5)?,
1201            msg_count: r.get(6)?,
1202            kind: r.get(7)?,
1203            preview: r.get(8)?,
1204            summary: r.get(9)?,
1205            tags: r.get(10)?,
1206            archived: r.get(11)?,
1207            account: None,
1208        })
1209    })?;
1210    let mut out = rows.collect::<rusqlite::Result<Vec<_>>>()?;
1211    crate::account_link::annotate(out.iter_mut());
1212    Ok(out)
1213}
1214
1215pub struct Hit {
1216    pub row: SessionRow,
1217    pub role: String,
1218    pub snippet: String,
1219}
1220
1221/// Full-text search, best match per session. The trigram tokenizer gives
1222/// substring matching, which also makes CJK text searchable.
1223pub fn search(
1224    conn: &Connection,
1225    query: &str,
1226    limit: usize,
1227    tool: Option<&str>,
1228    project: Option<&str>,
1229) -> Result<Vec<Hit>> {
1230    // A plain quoted string disables FTS5 operator parsing: users type
1231    // text, not query syntax.
1232    // Normalize the query to NFC so it lines up with the NFC-normalized indexed
1233    // text, then quote (the quoting is FTS5 syntax we add, not user content).
1234    let fts_query = format!("\"{}\"", crate::util::nfc(query).replace('"', "\"\""));
1235
1236    // snippet()/rank only work in a plain FTS5 query context, not under
1237    // joins or GROUP BY, so match in a subquery and attach metadata outside.
1238    //
1239    // Tradeoff: we take the top 1000 message hits by rank, then group to
1240    // sessions. For a very common term this can miss sessions whose only hits
1241    // fall past rank 1000 - a deliberate choice that keeps the query fast on a
1242    // multi-million-message index. Narrow the query to surface the long tail.
1243    let mut sql = String::from(
1244        "SELECT f.session_id, f.tool, f.path, f.project, f.title, f.started, f.msg_count, f.kind,
1245                m.role, x.snip, min(x.rank) AS best, (f.archived_at IS NOT NULL)
1246         FROM (SELECT rowid AS mid,
1247                      snippet(msgs, 0, char(2), char(3), char(8230), 18) AS snip,
1248                      rank
1249               FROM msgs WHERE msgs MATCH ? ORDER BY rank LIMIT 4000) x
1250         JOIN messages m ON m.id = x.mid
1251         JOIN files f ON f.session_id = m.session_id
1252         WHERE 1=1",
1253    );
1254    let mut args: Vec<String> = vec![fts_query];
1255    if let Some(t) = tool {
1256        sql.push_str(" AND f.tool = ?");
1257        args.push(t.to_string());
1258    }
1259    if let Some(p) = project {
1260        sql.push_str(" AND f.project LIKE ?");
1261        args.push(format!("%{}%", crate::util::nfc(p)));
1262    }
1263    sql.push_str(&format!(
1264        " GROUP BY f.session_id ORDER BY best LIMIT {limit}"
1265    ));
1266
1267    let mut stmt = conn.prepare(&sql)?;
1268    let rows = stmt.query_map(rusqlite::params_from_iter(args), |r| {
1269        Ok(Hit {
1270            row: SessionRow {
1271                session_id: r.get(0)?,
1272                tool: r.get(1)?,
1273                path: r.get(2)?,
1274                project: r.get(3)?,
1275                title: r.get(4)?,
1276                started: r.get(5)?,
1277                msg_count: r.get(6)?,
1278                kind: r.get(7)?,
1279                preview: None,
1280                summary: None,
1281                tags: None,
1282                archived: r.get(11)?,
1283                account: None,
1284            },
1285            role: r.get(8)?,
1286            snippet: r.get(9)?,
1287        })
1288    })?;
1289    let mut out = rows.collect::<rusqlite::Result<Vec<_>>>()?;
1290    crate::account_link::annotate(out.iter_mut().map(|h| &mut h.row));
1291    Ok(out)
1292}
1293
1294/// Substring search for queries too short for the trigram FTS index (1-2
1295/// chars, e.g. the Korean words 회사 / 검색). The trigram tokenizer needs >=3
1296/// chars, so these terms are unindexable; we fall back to a LIKE scan of
1297/// messages.text. Returns the same `Hit` shape as `search` so callers are
1298/// agnostic to which path ran.
1299///
1300/// Perf: this is a table scan, used ONLY for short queries (the >=3 path stays
1301/// on FTS). We cap the candidate rows scanned (SCAN_CAP) ordered newest-first
1302/// so a very common 2-char term cannot walk an unbounded table; the tradeoff is
1303/// that a session whose only match is older than the newest SCAN_CAP hits can be
1304/// missed. Narrow to a >=3-char term to use the exact FTS path instead. LIKE has
1305/// no rank, so results are ordered by recency (newest session first).
1306pub fn search_like(
1307    conn: &Connection,
1308    query: &str,
1309    limit: usize,
1310    tool: Option<&str>,
1311    project: Option<&str>,
1312) -> Result<Vec<Hit>> {
1313    const SCAN_CAP: i64 = 50_000;
1314
1315    // NFC so a decomposed query (macOS Korean) matches NFC-stored text, then
1316    // escape LIKE metacharacters ('\' first so an escape char is literal).
1317    let q = crate::util::nfc(query.trim());
1318    let pattern = format!(
1319        "%{}%",
1320        q.replace('\\', "\\\\")
1321            .replace('%', "\\%")
1322            .replace('_', "\\_")
1323    );
1324
1325    let mut sql = String::from(
1326        "SELECT f.session_id, f.tool, f.path, f.project, f.title, f.started, f.msg_count, f.kind,
1327                x.role, x.text, (f.archived_at IS NOT NULL)
1328         FROM (SELECT m.session_id AS sid, m.role AS role, m.text AS text, m.id AS mid
1329               FROM messages m
1330               WHERE m.text LIKE ?1 ESCAPE '\\'
1331               ORDER BY m.id DESC LIMIT ?2) x
1332         JOIN files f ON f.session_id = x.sid
1333         WHERE 1=1",
1334    );
1335    let mut args: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(pattern), Box::new(SCAN_CAP)];
1336    if let Some(t) = tool {
1337        sql.push_str(" AND f.tool = ?");
1338        args.push(Box::new(t.to_string()));
1339    }
1340    if let Some(p) = project {
1341        sql.push_str(" AND f.project LIKE ?");
1342        args.push(Box::new(format!("%{}%", crate::util::nfc(p))));
1343    }
1344    // One row per session (its newest matching message), sessions newest-first.
1345    sql.push_str(&format!(
1346        " GROUP BY f.session_id ORDER BY max(x.mid) DESC LIMIT {limit}"
1347    ));
1348
1349    let mut stmt = conn.prepare(&sql)?;
1350    let rows = stmt.query_map(
1351        rusqlite::params_from_iter(args.iter().map(|b| b.as_ref())),
1352        |r| {
1353            let role: String = r.get(8)?;
1354            let text: String = r.get(9)?;
1355            Ok(Hit {
1356                row: SessionRow {
1357                    session_id: r.get(0)?,
1358                    tool: r.get(1)?,
1359                    path: r.get(2)?,
1360                    project: r.get(3)?,
1361                    title: r.get(4)?,
1362                    started: r.get(5)?,
1363                    msg_count: r.get(6)?,
1364                    kind: r.get(7)?,
1365                    preview: None,
1366                    summary: None,
1367                    tags: None,
1368                    archived: r.get(10)?,
1369                    account: None,
1370                },
1371                role,
1372                snippet: snippet_around(&text, &q),
1373            })
1374        },
1375    )?;
1376    let mut out = rows.collect::<rusqlite::Result<Vec<_>>>()?;
1377    crate::account_link::annotate(out.iter_mut().map(|h| &mut h.row));
1378    Ok(out)
1379}
1380
1381/// Build a snippet for a LIKE hit: a window around the first (case-insensitive,
1382/// NFC) match of `needle` in `text`, with the match wrapped in \u{2}..\u{3} -
1383/// the same delimiters snippet() emits - so the CLI's ANSI swap and the web UI
1384/// render LIKE hits identically to FTS hits. Matching is on chars, not bytes,
1385/// so multibyte CJK is never sliced mid-codepoint.
1386fn snippet_around(text: &str, needle: &str) -> String {
1387    const WINDOW: usize = 36;
1388    let hay_chars: Vec<char> = text.to_lowercase().chars().collect();
1389    let nee_chars: Vec<char> = needle.to_lowercase().chars().collect();
1390    let chars: Vec<char> = text.chars().collect();
1391
1392    let match_at = if nee_chars.is_empty() {
1393        None
1394    } else {
1395        hay_chars
1396            .windows(nee_chars.len())
1397            .position(|w| w == nee_chars.as_slice())
1398    };
1399    let Some(start) = match_at else {
1400        return chars
1401            .iter()
1402            .take(WINDOW * 2)
1403            .collect::<String>()
1404            .replace('\n', " ");
1405    };
1406    let end = start + nee_chars.len();
1407    let lo = start.saturating_sub(WINDOW);
1408    let hi = (end + WINDOW).min(chars.len());
1409
1410    let mut out = String::new();
1411    if lo > 0 {
1412        out.push('\u{2026}');
1413    }
1414    out.extend(&chars[lo..start]);
1415    out.push('\u{2}');
1416    out.extend(&chars[start..end]);
1417    out.push('\u{3}');
1418    out.extend(&chars[end..hi]);
1419    if hi < chars.len() {
1420        out.push('\u{2026}');
1421    }
1422    out.replace('\n', " ")
1423}
1424
1425/// Escape LIKE metacharacters ('\' first) so an id/path fragment like "%" or
1426/// "_" can't turn a prefix match into a wildcard that resolves every session.
1427fn escape_like(s: &str) -> String {
1428    s.replace('\\', "\\\\")
1429        .replace('%', "\\%")
1430        .replace('_', "\\_")
1431}
1432
1433/// The SELECT column list every id-resolution query shares, in the order
1434/// [`map_resolve_row`] reads.
1435const RESOLVE_COLS: &str = "session_id, tool, path, project, title, started, msg_count, kind";
1436
1437/// Map a resolve-query row to a [`SessionRow`]. Callers must SELECT
1438/// [`RESOLVE_COLS`] followed by `{SUMMARY_SQL}, {TAGS_SQL}, (archived_at IS NOT NULL)`.
1439fn map_resolve_row(r: &rusqlite::Row) -> rusqlite::Result<SessionRow> {
1440    Ok(SessionRow {
1441        session_id: r.get(0)?,
1442        tool: r.get(1)?,
1443        path: r.get(2)?,
1444        project: r.get(3)?,
1445        title: r.get(4)?,
1446        started: r.get(5)?,
1447        msg_count: r.get(6)?,
1448        kind: r.get(7)?,
1449        preview: None,
1450        summary: r.get(8)?,
1451        tags: r.get(9)?,
1452        archived: r.get(10)?,
1453        account: None,
1454    })
1455}
1456
1457/// Resolve a (possibly abbreviated) session id to its file row(s). Matches on
1458/// the sessionwiki short id (prefix) AND on the tool's own native id (the codex
1459/// rollout / claude transcript UUID, full or prefix), so a harness "tower" row -
1460/// which knows only the native id - can be reopened directly. Short-id behavior
1461/// is unchanged: the native scan only runs for native-shaped queries, and never
1462/// displaces an existing short-id match on a plain-hex prefix (see
1463/// [`looks_like_native_prefix`]).
1464pub fn resolve(conn: &Connection, id_prefix: &str) -> Result<Vec<SessionRow>> {
1465    let mut out = resolve_by_short_id(conn, id_prefix)?;
1466
1467    // Native-id join. A plain-hex prefix stays short-id-only when it already
1468    // matched something (no new ambiguity); a dashed prefix is unambiguously a
1469    // UUID (short ids have no dashes) so it always broadens to the native scan.
1470    let native_ok =
1471        looks_like_native_prefix(id_prefix) && (out.is_empty() || id_prefix.contains('-'));
1472    if native_ok {
1473        for row in resolve_by_native_id(conn, id_prefix)? {
1474            if out.len() >= 10 {
1475                break;
1476            }
1477            if out.iter().all(|r| r.session_id != row.session_id) {
1478                out.push(row);
1479            }
1480        }
1481    }
1482    Ok(out)
1483}
1484
1485/// Prefix match on the sessionwiki short id (the historical `resolve`).
1486fn resolve_by_short_id(conn: &Connection, id_prefix: &str) -> Result<Vec<SessionRow>> {
1487    let mut stmt = conn.prepare(&format!(
1488        "SELECT {RESOLVE_COLS}, {SUMMARY_SQL}, {TAGS_SQL}, (archived_at IS NOT NULL)
1489         FROM files f WHERE session_id LIKE ?1 ESCAPE '\\' LIMIT 10",
1490    ))?;
1491    let pattern = format!("{}%", escape_like(id_prefix));
1492    let rows = stmt.query_map(params![pattern], map_resolve_row)?;
1493    Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
1494}
1495
1496/// Match rows whose native id (derived from the filename) starts with `prefix`.
1497/// The native id is a substring of the stored path, so a coarse `path LIKE
1498/// '%prefix%'` yields a bounded superset which we then confirm per row - a
1499/// planted path fragment can never satisfy the exact `native_id_of` check.
1500fn resolve_by_native_id(conn: &Connection, prefix: &str) -> Result<Vec<SessionRow>> {
1501    let mut stmt = conn.prepare(&format!(
1502        "SELECT {RESOLVE_COLS}, {SUMMARY_SQL}, {TAGS_SQL}, (archived_at IS NOT NULL)
1503         FROM files f WHERE path LIKE ?1 ESCAPE '\\' LIMIT 500",
1504    ))?;
1505    let pattern = format!("%{}%", escape_like(prefix));
1506    let want = prefix.to_ascii_lowercase();
1507    let rows = stmt.query_map(params![pattern], map_resolve_row)?;
1508    let mut out = Vec::new();
1509    for row in rows {
1510        let row = row?;
1511        if native_id_of(&row.path).is_some_and(|n| n.starts_with(&want)) {
1512            out.push(row);
1513            if out.len() >= 10 {
1514                break;
1515            }
1516        }
1517    }
1518    Ok(out)
1519}
1520
1521/// Locate a session file directly on disk by its NATIVE id (codex rollout UUID
1522/// or claude transcript UUID), full or prefix, WITHOUT the index. This is the
1523/// live-session path: a session started moments ago may not be indexed yet, but
1524/// its file already exists under the tool's store root, so `session_window` /
1525/// `show` can still open it in one call. Scans only the codex and claude roots -
1526/// the two tools whose native id a harness tower knows - and returns the first
1527/// (tool, path) whose filename-derived native id matches. None if nothing on
1528/// disk matches (or the query is not native-shaped).
1529pub fn locate_by_native_id(prefix: &str) -> Option<(String, PathBuf)> {
1530    if !looks_like_native_prefix(prefix) {
1531        return None;
1532    }
1533    let want = prefix.to_ascii_lowercase();
1534    for name in ["claude-code", "codex"] {
1535        let Some(adapter) = adapters::by_name(name) else {
1536            continue;
1537        };
1538        let Some(root) = adapter.root() else { continue };
1539        if !root.exists() {
1540            continue;
1541        }
1542        let hit = walkdir::WalkDir::new(&root)
1543            .into_iter()
1544            .filter_map(std::result::Result::ok)
1545            .find(|e| {
1546                e.file_type().is_file()
1547                    && e.path().extension().is_some_and(|x| x == "jsonl")
1548                    && native_id_of(&e.path().to_string_lossy())
1549                        .is_some_and(|n| n.starts_with(&want))
1550            });
1551        if let Some(e) = hit {
1552            return Some((name.to_string(), e.into_path()));
1553        }
1554    }
1555    None
1556}
1557
1558/// A minimal, un-indexed [`SessionRow`] for a live session located on disk by
1559/// [`locate_by_native_id`]. The real path and tool drive a direct parse (via
1560/// `load_session`); the metadata fields are placeholders the window/show render
1561/// path does not consult (it reads the parsed transcript). The `session_id`
1562/// matches the id the adapter would assign, so a later sync reconciles cleanly.
1563pub fn live_row(tool: String, path: PathBuf) -> SessionRow {
1564    let path = path.to_string_lossy().into_owned();
1565    SessionRow {
1566        session_id: crate::util::short_id(&path),
1567        tool,
1568        path,
1569        project: String::new(),
1570        title: String::new(),
1571        started: None,
1572        msg_count: 0,
1573        kind: "main".into(),
1574        preview: None,
1575        summary: None,
1576        tags: None,
1577        archived: false,
1578        account: None,
1579    }
1580}
1581
1582/// Store (or replace) the cached synopsis for a session. The synopsis comes from
1583/// the user's own LLM over the raw transcript, so it can echo a secret - redact
1584/// before it lands in this durable table.
1585pub fn set_summary(conn: &Connection, session_id: &str, summary: &str) -> Result<()> {
1586    conn.execute(
1587        "INSERT OR REPLACE INTO summaries(session_id, summary, created)
1588         VALUES (?1, ?2, datetime('now'))",
1589        params![session_id, crate::redact::redact(summary).as_ref()],
1590    )?;
1591    Ok(())
1592}
1593
1594/// Most recent main sessions that have no cached summary yet.
1595pub fn unsummarized(
1596    conn: &Connection,
1597    limit: usize,
1598    tool: Option<&str>,
1599) -> Result<Vec<SessionRow>> {
1600    let mut sql = format!(
1601        "SELECT session_id, tool, path, project, title, started, msg_count, kind, {PREVIEW_SQL}, {SUMMARY_SQL}, {TAGS_SQL}, (archived_at IS NOT NULL)
1602         FROM files f
1603         WHERE kind = 'main' AND NOT EXISTS
1604               (SELECT 1 FROM summaries s WHERE s.session_id = f.session_id)",
1605    );
1606    let mut args: Vec<String> = Vec::new();
1607    if let Some(t) = tool {
1608        sql.push_str(" AND tool = ?");
1609        args.push(t.to_string());
1610    }
1611    sql.push_str(&format!(" ORDER BY started DESC LIMIT {limit}"));
1612    let mut stmt = conn.prepare(&sql)?;
1613    let rows = stmt.query_map(rusqlite::params_from_iter(args), |r| {
1614        Ok(SessionRow {
1615            session_id: r.get(0)?,
1616            tool: r.get(1)?,
1617            path: r.get(2)?,
1618            project: r.get(3)?,
1619            title: r.get(4)?,
1620            started: r.get(5)?,
1621            msg_count: r.get(6)?,
1622            kind: r.get(7)?,
1623            preview: r.get(8)?,
1624            summary: r.get(9)?,
1625            tags: r.get(10)?,
1626            archived: r.get(11)?,
1627            account: None,
1628        })
1629    })?;
1630    Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
1631}
1632
1633// --- curation (the editable wiki layer) ---
1634
1635/// One canonical form per tag: trimmed, lowercased, NFC-normalized - the same
1636/// normalization every other indexed string gets, so a tag typed in decomposed
1637/// form (macOS IME, some CJK inputs) matches on add, filter, and remove alike.
1638fn norm_tag(tag: &str) -> String {
1639    crate::util::nfc(&tag.trim().to_lowercase())
1640}
1641
1642pub fn add_tag(conn: &Connection, session_id: &str, tag: &str) -> Result<()> {
1643    // Tags are joined with ',' at read time and the JSON/web contract splits on
1644    // it, so a comma inside a tag would corrupt the array into two elements.
1645    // Reject it (and the empty tag) at the input boundary.
1646    let tag = norm_tag(tag);
1647    if tag.is_empty() || tag.contains(',') {
1648        anyhow::bail!("a tag must be non-empty and contain no commas");
1649    }
1650    conn.execute(
1651        "INSERT OR IGNORE INTO tags(session_id, tag) VALUES (?1, ?2)",
1652        params![session_id, tag],
1653    )?;
1654    Ok(())
1655}
1656
1657pub fn remove_tag(conn: &Connection, session_id: &str, tag: &str) -> Result<usize> {
1658    Ok(conn.execute(
1659        "DELETE FROM tags WHERE session_id = ?1 AND tag = ?2",
1660        params![session_id, norm_tag(tag)],
1661    )?)
1662}
1663
1664pub fn set_note(conn: &Connection, session_id: &str, note: &str) -> Result<()> {
1665    conn.execute(
1666        "INSERT OR REPLACE INTO notes(session_id, note, updated)
1667         VALUES (?1, ?2, datetime('now'))",
1668        params![session_id, note],
1669    )?;
1670    Ok(())
1671}
1672
1673pub fn note_for(conn: &Connection, session_id: &str) -> Result<Option<String>> {
1674    Ok(conn
1675        .query_row(
1676            "SELECT note FROM notes WHERE session_id = ?1",
1677            params![session_id],
1678            |r| r.get(0),
1679        )
1680        .ok())
1681}
1682
1683/// All tags in use, with how many sessions carry each.
1684pub fn tag_counts(conn: &Connection) -> Result<Vec<(String, i64)>> {
1685    let mut stmt =
1686        conn.prepare("SELECT tag, count(*) FROM tags GROUP BY tag ORDER BY count(*) DESC, tag")?;
1687    let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?;
1688    Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
1689}
1690
1691// --- provenance: sessions <-> the code they produced ---
1692
1693/// Files a session edited or created, in the order it first touched them.
1694pub fn files_for(conn: &Connection, session_id: &str) -> Result<Vec<String>> {
1695    let mut stmt = conn.prepare("SELECT path FROM touched WHERE session_id = ?1 ORDER BY rowid")?;
1696    let rows = stmt.query_map(params![session_id], |r| r.get(0))?;
1697    Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
1698}
1699
1700/// One recorded edit to a file - the evidence layer behind `touched`. `matched_path`
1701/// is the absolute stored path the query resolved to.
1702#[derive(Debug, Serialize)]
1703pub struct FileEdit {
1704    pub session_id: String,
1705    pub kind: String,
1706    pub ts: Option<String>,
1707    pub snippet: String,
1708    pub matched_path: String,
1709}
1710
1711/// The concrete edits made to a file, newest first - the evidence for "why does
1712/// this file look like this". Resolves a relative path against the absolute one
1713/// on disk by suffix, the same match `sessions_for_file` uses, so
1714/// `edits_for("src/auth.rs")` finds `/home/me/proj/src/auth.rs`.
1715/// A `usize` limit clamped to a positive `i64` for SQLite: a value past i64::MAX
1716/// wraps negative, which SQLite reads as "unlimited" - defeating the memory bound.
1717fn sql_limit(n: usize) -> i64 {
1718    i64::try_from(n).unwrap_or(i64::MAX)
1719}
1720
1721pub fn edits_for(conn: &Connection, query: &str, limit: usize) -> Result<Vec<FileEdit>> {
1722    let q = crate::util::nfc(query.trim().trim_start_matches("./"));
1723    // Escape LIKE metacharacters so a caller-supplied `%`/`_` matches literally.
1724    let esc = q
1725        .replace('\\', "\\\\")
1726        .replace('%', "\\%")
1727        .replace('_', "\\_");
1728    let suffix = format!("%/{esc}");
1729    let mut stmt = conn.prepare(
1730        "SELECT session_id, kind, ts, snippet, path
1731         FROM edits
1732         WHERE path = ?1 OR path LIKE ?2 ESCAPE '\\'
1733            OR (length(?1) > length(path)
1734                AND substr(?1, -length(path)) = path
1735                AND substr(?1, -length(path)-1, 1) = '/')
1736         ORDER BY ts DESC, rowid DESC LIMIT ?3",
1737    )?;
1738    let rows = stmt.query_map(params![q, suffix, sql_limit(limit)], |r| {
1739        Ok(FileEdit {
1740            session_id: r.get(0)?,
1741            kind: r.get(1)?,
1742            ts: r.get(2)?,
1743            snippet: r.get(3)?,
1744            matched_path: r.get(4)?,
1745        })
1746    })?;
1747    Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
1748}
1749
1750/// A single session's edits to a file, newest first - scoped by session so no
1751/// session's edits can be starved by another's under a shared cap. Uses the SAME
1752/// suffix path match as `sessions_for_file`, so a session that edited the file
1753/// under more than one spelling (abs + relative) contributes ALL its edits, not
1754/// just the one spelling `sessions_for_file`'s GROUP BY happened to pick.
1755pub fn edits_for_session(
1756    conn: &Connection,
1757    session_id: &str,
1758    query: &str,
1759    limit: usize,
1760) -> Result<Vec<FileEdit>> {
1761    let q = crate::util::nfc(query.trim().trim_start_matches("./"));
1762    let esc = q
1763        .replace('\\', "\\\\")
1764        .replace('%', "\\%")
1765        .replace('_', "\\_");
1766    let suffix = format!("%/{esc}");
1767    let mut stmt = conn.prepare(
1768        "SELECT session_id, kind, ts, snippet, path
1769         FROM edits
1770         WHERE session_id = ?1
1771           AND (path = ?2 OR path LIKE ?3 ESCAPE '\\'
1772                OR (length(?2) > length(path)
1773                    AND substr(?2, -length(path)) = path
1774                    AND substr(?2, -length(path)-1, 1) = '/'))
1775         ORDER BY ts DESC, rowid DESC LIMIT ?4",
1776    )?;
1777    let rows = stmt.query_map(params![session_id, q, suffix, sql_limit(limit)], |r| {
1778        Ok(FileEdit {
1779            session_id: r.get(0)?,
1780            kind: r.get(1)?,
1781            ts: r.get(2)?,
1782            snippet: r.get(3)?,
1783            matched_path: r.get(4)?,
1784        })
1785    })?;
1786    Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
1787}
1788
1789/// One session's slice of a file's history: its metadata plus the edits it made
1790/// to this file.
1791#[derive(Serialize)]
1792pub struct SessionEvidence {
1793    pub session: SessionRow,
1794    pub edits: Vec<FileEdit>,
1795}
1796
1797/// A file's full evidence chain - the sessions that edited it, newest first,
1798/// each carrying its own edits. What the file-history page renders.
1799#[derive(Serialize)]
1800pub struct FileHistory {
1801    pub path: String,
1802    pub sessions: Vec<SessionEvidence>,
1803}
1804
1805/// Assemble a file's evidence chain: the sessions that touched it (with metadata,
1806/// newest first) joined to each session's recorded edits. Sessions that touched
1807/// the file but carry no structured edits (other adapters, archived sessions)
1808/// appear with an empty `edits` list - the touch is still evidence.
1809pub fn evidence_for(conn: &Connection, path: &str, limit: usize) -> Result<FileHistory> {
1810    let sessions = sessions_for_file(conn, path, limit)?;
1811    // Fetch edits PER session (scoped by session, matched by the SAME query path
1812    // so every spelling counts), so one session's edits are never starved by
1813    // another's - and `limit == 0` does no edit query.
1814    const PER_SESSION_EDIT_CAP: usize = 500;
1815    let sessions = sessions
1816        .into_iter()
1817        .map(|(session, _matched)| {
1818            let edits = edits_for_session(conn, &session.session_id, path, PER_SESSION_EDIT_CAP)?;
1819            Ok(SessionEvidence { session, edits })
1820        })
1821        .collect::<Result<Vec<_>>>()?;
1822    Ok(FileHistory {
1823        path: path.to_string(),
1824        sessions,
1825    })
1826}
1827
1828/// Sessions that touched a file, newest first - the reverse provenance link.
1829/// Matches either the exact stored path or any stored path ending in the
1830/// query, so a relative `src/auth.rs` finds `/home/me/proj/src/auth.rs`. The
1831/// matched stored path is returned alongside each session.
1832/// The file name to retry with when a full path traces to nothing.
1833///
1834/// Folders get renamed. A session recorded `~/Project/lunch/diag.py`; the folder
1835/// is `~/Project/slack` now, so tracing by the path the file has TODAY matched
1836/// nothing and reported that no session had touched it - about a file whose
1837/// whole history was in the index under its old directory.
1838///
1839/// The name is the part that survives a move. `None` when there is nothing to
1840/// fall back to: a bare name would just repeat the same miss, and a trailing
1841/// slash names a directory rather than a file.
1842pub fn basename_fallback(query: &str) -> Option<String> {
1843    let q = query.trim();
1844    if q.is_empty() || q.ends_with('/') {
1845        return None;
1846    }
1847    let (head, name) = q.rsplit_once('/')?;
1848    (!head.is_empty() && !name.is_empty()).then(|| name.to_string())
1849}
1850
1851pub fn sessions_for_file(
1852    conn: &Connection,
1853    query: &str,
1854    limit: usize,
1855) -> Result<Vec<(SessionRow, String)>> {
1856    let q = crate::util::nfc(query.trim().trim_start_matches("./"));
1857    // Escape LIKE metacharacters: the query is a caller-supplied path (the MCP
1858    // trace_file arg included), so a bare `%` must match a literal `%`, not act
1859    // as a wildcard that enumerates the whole index.
1860    let esc = q
1861        .replace('\\', "\\\\")
1862        .replace('%', "\\%")
1863        .replace('_', "\\_");
1864    let suffix = format!("%/{esc}");
1865    let mut stmt = conn.prepare(&format!(
1866        "SELECT f.session_id, f.tool, f.path, f.project, f.title, f.started, f.msg_count, f.kind,
1867                {SUMMARY_SQL}, {TAGS_SQL}, t.path, (f.archived_at IS NOT NULL)
1868         FROM touched t JOIN files f ON f.session_id = t.session_id
1869         WHERE t.path = ?1 OR t.path LIKE ?2 ESCAPE '\\'
1870            OR (length(?1) > length(t.path)
1871                AND substr(?1, -length(t.path)) = t.path
1872                AND substr(?1, -length(t.path)-1, 1) = '/')
1873         GROUP BY f.session_id
1874         ORDER BY f.started DESC, f.session_id LIMIT ?3"
1875    ))?;
1876    let rows = stmt.query_map(params![q, suffix, sql_limit(limit)], |r| {
1877        Ok((
1878            SessionRow {
1879                session_id: r.get(0)?,
1880                tool: r.get(1)?,
1881                path: r.get(2)?,
1882                project: r.get(3)?,
1883                title: r.get(4)?,
1884                started: r.get(5)?,
1885                msg_count: r.get(6)?,
1886                kind: r.get(7)?,
1887                preview: None,
1888                summary: r.get(8)?,
1889                tags: r.get(9)?,
1890                archived: r.get(11)?,
1891                account: None,
1892            },
1893            r.get::<_, String>(10)?,
1894        ))
1895    })?;
1896    let mut out = rows.collect::<rusqlite::Result<Vec<_>>>()?;
1897    crate::account_link::annotate(out.iter_mut().map(|(r, _)| r));
1898    Ok(out)
1899}
1900
1901/// Like `sessions_for_file` but returns the start/end epoch window and project
1902/// that blame's commit->session attribution needs. Matches both an exact stored
1903/// path and any stored path ending in the query suffix, so a repo-relative query
1904/// (e.g. `src/auth.rs`) catches Claude Code's absolute touched paths and Codex's
1905/// relative ones alike (NFC-normalized).
1906pub fn sessions_touching(
1907    conn: &Connection,
1908    query: &str,
1909) -> Result<Vec<crate::blame::TouchingSession>> {
1910    let q = crate::util::nfc(query.trim().trim_start_matches("./"));
1911    let suffix = format!("%/{q}");
1912    let mut stmt = conn.prepare(
1913        "SELECT f.session_id, f.tool, f.title, f.project, f.started, f.ended, (f.archived_at IS NOT NULL)
1914         FROM touched t JOIN files f ON f.session_id = t.session_id
1915         WHERE t.path = ?1 OR t.path LIKE ?2
1916         GROUP BY f.session_id",
1917    )?;
1918    let rows = stmt.query_map(params![q, suffix], |r| {
1919        let started: Option<String> = r.get(4)?;
1920        let ended: Option<String> = r.get(5)?;
1921        Ok(crate::blame::TouchingSession {
1922            session_id: r.get(0)?,
1923            tool: r.get(1)?,
1924            title: r.get(2)?,
1925            project: r.get(3)?,
1926            started: started.as_deref().and_then(to_epoch),
1927            ended: ended.as_deref().and_then(to_epoch),
1928            archived: r.get(6)?,
1929        })
1930    })?;
1931    Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
1932}
1933
1934/// Parse a stored timestamp (RFC3339) to epoch seconds; None if unparseable.
1935fn to_epoch(s: &str) -> Option<i64> {
1936    chrono::DateTime::parse_from_rfc3339(s)
1937        .ok()
1938        .map(|d| d.timestamp())
1939}
1940
1941// --- archive: serving and forgetting sessions whose originals are gone ---
1942
1943/// The display name for a tool this binary's adapter registry does not know.
1944///
1945/// A program that embeds this crate can register its own adapters, so rows in
1946/// the index may name a tool the standalone binary has never heard of. Those
1947/// rows used to print as "unknown". `Session.tool` is `&'static str`, so the
1948/// row's own string has to outlive the call: intern it once and leak it. The
1949/// set of tool names is small and fixed by the tools a user actually runs, so
1950/// the leak is bounded by that, not by the number of sessions.
1951fn interned_tool(name: &str) -> &'static str {
1952    use std::collections::HashSet;
1953    use std::sync::{Mutex, OnceLock};
1954    static NAMES: OnceLock<Mutex<HashSet<&'static str>>> = OnceLock::new();
1955    let mut names = NAMES
1956        .get_or_init(|| Mutex::new(HashSet::new()))
1957        .lock()
1958        .unwrap_or_else(std::sync::PoisonError::into_inner);
1959    if let Some(existing) = names.get(name) {
1960        return existing;
1961    }
1962    let leaked: &'static str = Box::leak(name.to_owned().into_boxed_str());
1963    names.insert(leaked);
1964    leaked
1965}
1966
1967/// Reconstruct an archived or external-adapter session from its indexed copy.
1968/// This retained transcript omits per-message timestamps and full tool I/O,
1969/// which were never indexed.
1970pub fn session_from_index(conn: &Connection, row: &SessionRow) -> Result<crate::model::Session> {
1971    use crate::model::{Message, Role};
1972    let mut stmt =
1973        conn.prepare("SELECT role, text FROM messages WHERE session_id = ?1 ORDER BY id")?;
1974    let messages: Vec<Message> = stmt
1975        .query_map(params![row.session_id], |r| {
1976            let role: String = r.get(0)?;
1977            let text: String = r.get(1)?;
1978            Ok(Message {
1979                role: match role.as_str() {
1980                    "user" => Role::User,
1981                    "assistant" => Role::Assistant,
1982                    _ => Role::Tool,
1983                },
1984                text,
1985                ts: None,
1986            })
1987        })?
1988        .collect::<rusqlite::Result<_>>()?;
1989    drop(stmt);
1990
1991    let tool = adapters::by_name(&row.tool)
1992        .map(|a| a.name())
1993        .unwrap_or_else(|| interned_tool(&row.tool));
1994    let started = row
1995        .started
1996        .as_deref()
1997        .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
1998        .map(|t| t.with_timezone(&chrono::Utc));
1999    Ok(crate::model::Session {
2000        id: row.session_id.clone(),
2001        tool,
2002        // A shared-store key carries a U+001F separator in the stored path;
2003        // render it human-readable (`<db>#<id>`) so it never leaks into show /
2004        // brief / web. Real file paths never contain U+001F, so this is a no-op
2005        // for every other adapter.
2006        path: std::path::PathBuf::from(row.path.replace('\u{1f}', "#")),
2007        project: row.project.clone(),
2008        started,
2009        ended: started,
2010        title: row.title.clone(),
2011        subagent: row.kind == "sub",
2012        messages,
2013        touched: files_for(conn, &row.session_id)?,
2014        edits: Vec::new(),
2015    })
2016}
2017
2018/// Permanently remove a session from the index AND the archive - the only way
2019/// to undo archiving for a session the user genuinely wants gone. Curation for
2020/// it (tags/notes/summary) goes too, since the session no longer exists here.
2021/// All-or-nothing: a crash mid-forget must not leave the FTS index out of sync
2022/// with `messages`, nor an `archive` row that would resurrect it on rebuild.
2023pub fn forget(conn: &mut Connection, session_id: &str) -> Result<()> {
2024    let tx = conn.transaction()?;
2025    delete_session_msgs(&tx, session_id)?;
2026    for table in [
2027        "files",
2028        "touched",
2029        "edits",
2030        "archive",
2031        "summaries",
2032        "tags",
2033        "notes",
2034    ] {
2035        tx.execute(
2036            &format!("DELETE FROM {table} WHERE session_id = ?1"),
2037            params![session_id],
2038        )?;
2039    }
2040    tx.commit()?;
2041    Ok(())
2042}
2043
2044// --- related sessions (backlinks) ---
2045
2046/// Sessions related to `session_id`. A session is most usefully "related" to
2047/// the others about the same codebase, so same-project sessions are the spine;
2048/// sessions sharing a user tag are layered on as explicit links. Both are
2049/// indexed lookups, so this is instant even over a large store - the earlier
2050/// full-text-on-title approach was both slow and noisy (generic title words
2051/// like "session" matched everything).
2052pub fn related(conn: &Connection, session_id: &str, limit: usize) -> Result<Vec<SessionRow>> {
2053    let Some(target) = resolve(conn, session_id)?.into_iter().next() else {
2054        return Ok(vec![]);
2055    };
2056    let target_tags: Vec<String> = target
2057        .tags
2058        .as_deref()
2059        .map(|t| t.split(',').map(String::from).collect())
2060        .unwrap_or_default();
2061
2062    let mut out: Vec<SessionRow> = Vec::new();
2063    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
2064    seen.insert(target.session_id.clone());
2065
2066    // 1. same project (exact), most recent first - the same-context spine.
2067    if !target.project.is_empty() {
2068        let sql = format!(
2069            "SELECT session_id, tool, path, project, title, started, msg_count, kind,
2070                    {PREVIEW_SQL}, {SUMMARY_SQL}, {TAGS_SQL}, (archived_at IS NOT NULL)
2071             FROM files f
2072             WHERE kind = 'main' AND project = ?1 AND session_id != ?2
2073             ORDER BY started DESC LIMIT ?3"
2074        );
2075        let mut stmt = conn.prepare(&sql)?;
2076        let rows = stmt.query_map(
2077            params![target.project, target.session_id, limit as i64 + 1],
2078            map_row,
2079        )?;
2080        for row in rows {
2081            let row = row?;
2082            if seen.insert(row.session_id.clone()) {
2083                out.push(row);
2084            }
2085        }
2086    }
2087
2088    // 2. sessions that edited a file this one also edited - the strongest
2089    //    signal that two sessions are about the same work, and one no other
2090    //    session viewer has, since it comes from the provenance link.
2091    if out.len() < limit {
2092        let sql = format!(
2093            "SELECT DISTINCT f.session_id, f.tool, f.path, f.project, f.title, f.started,
2094                    f.msg_count, f.kind, {PREVIEW_SQL}, {SUMMARY_SQL}, {TAGS_SQL}, (f.archived_at IS NOT NULL)
2095             FROM touched a
2096             JOIN touched b ON a.path = b.path AND b.session_id != a.session_id
2097             JOIN files f ON f.session_id = b.session_id
2098             WHERE a.session_id = ?1 AND f.kind = 'main'
2099             ORDER BY f.started DESC LIMIT 50"
2100        );
2101        let mut stmt = conn.prepare(&sql)?;
2102        let rows = stmt.query_map(params![target.session_id], map_row)?;
2103        for row in rows {
2104            let row = row?;
2105            if seen.insert(row.session_id.clone()) {
2106                out.push(row);
2107                if out.len() >= limit {
2108                    break;
2109                }
2110            }
2111        }
2112    }
2113
2114    // 3. sessions that share a tag with the target (explicit wiki links).
2115    if out.len() < limit && !target_tags.is_empty() {
2116        let placeholders = target_tags
2117            .iter()
2118            .map(|_| "?")
2119            .collect::<Vec<_>>()
2120            .join(",");
2121        let sql = format!(
2122            "SELECT DISTINCT f.session_id, f.tool, f.path, f.project, f.title, f.started,
2123                    f.msg_count, f.kind, {PREVIEW_SQL}, {SUMMARY_SQL}, {TAGS_SQL}, (f.archived_at IS NOT NULL)
2124             FROM files f JOIN tags t ON t.session_id = f.session_id
2125             WHERE f.kind = 'main' AND t.tag IN ({placeholders})
2126             ORDER BY f.started DESC LIMIT 50"
2127        );
2128        let mut stmt = conn.prepare(&sql)?;
2129        let rows = stmt.query_map(rusqlite::params_from_iter(&target_tags), map_row)?;
2130        for row in rows {
2131            let row = row?;
2132            if seen.insert(row.session_id.clone()) {
2133                out.push(row);
2134                if out.len() >= limit {
2135                    break;
2136                }
2137            }
2138        }
2139    }
2140
2141    out.truncate(limit);
2142    crate::account_link::annotate(out.iter_mut());
2143    Ok(out)
2144}
2145
2146/// Row mapper for the full session-list column set.
2147fn map_row(r: &rusqlite::Row) -> rusqlite::Result<SessionRow> {
2148    Ok(SessionRow {
2149        session_id: r.get(0)?,
2150        tool: r.get(1)?,
2151        path: r.get(2)?,
2152        project: r.get(3)?,
2153        title: r.get(4)?,
2154        started: r.get(5)?,
2155        msg_count: r.get(6)?,
2156        kind: r.get(7)?,
2157        preview: r.get(8)?,
2158        summary: r.get(9)?,
2159        tags: r.get(10)?,
2160        archived: r.get(11)?,
2161        account: None,
2162    })
2163}
2164
2165// --- session engineering: management views ---
2166
2167pub struct ProjectRow {
2168    pub project: String,
2169    pub sessions: i64,
2170    pub messages: i64,
2171    pub oldest: Option<String>,
2172    pub newest: Option<String>,
2173}
2174
2175/// One row per project (a wiki "category" page), busiest first.
2176pub fn projects(conn: &Connection) -> Result<Vec<ProjectRow>> {
2177    let mut stmt = conn.prepare(
2178        "SELECT project, count(*), coalesce(sum(msg_count), 0), min(started), max(started)
2179         FROM files WHERE kind = 'main' AND project != ''
2180         GROUP BY project ORDER BY count(*) DESC, max(started) DESC",
2181    )?;
2182    let rows = stmt.query_map([], |r| {
2183        Ok(ProjectRow {
2184            project: r.get(0)?,
2185            sessions: r.get(1)?,
2186            messages: r.get(2)?,
2187            oldest: r.get(3)?,
2188            newest: r.get(4)?,
2189        })
2190    })?;
2191    Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
2192}
2193
2194pub struct Stats {
2195    pub per_tool: Vec<(String, i64, i64)>, // tool, sessions, messages
2196    pub per_month: Vec<(String, i64)>,     // YYYY-MM, sessions
2197    pub total_sessions: i64,
2198    pub total_messages: i64,
2199    pub projects: i64,
2200    pub tags: i64,
2201    pub summarized: i64,
2202    /// Distinct files linked to at least one session (provenance coverage).
2203    pub files: i64,
2204    /// Sessions kept after the tool deleted their originals (archive mode).
2205    pub archived: i64,
2206}
2207
2208pub fn stats(conn: &Connection) -> Result<Stats> {
2209    let mut per_tool_stmt = conn.prepare(
2210        "SELECT tool, count(*), coalesce(sum(msg_count),0) FROM files WHERE kind='main'
2211         GROUP BY tool ORDER BY count(*) DESC",
2212    )?;
2213    let per_tool = per_tool_stmt
2214        .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
2215        .collect::<rusqlite::Result<Vec<_>>>()?;
2216
2217    let mut per_month_stmt = conn.prepare(
2218        "SELECT substr(started,1,7) AS ym, count(*) FROM files
2219         WHERE kind='main' AND started IS NOT NULL
2220         GROUP BY ym ORDER BY ym DESC LIMIT 12",
2221    )?;
2222    let per_month = per_month_stmt
2223        .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
2224        .collect::<rusqlite::Result<Vec<_>>>()?;
2225
2226    let one = |sql: &str| -> Result<i64> { Ok(conn.query_row(sql, [], |r| r.get(0))?) };
2227    Ok(Stats {
2228        per_tool,
2229        per_month,
2230        total_sessions: one("SELECT count(*) FROM files WHERE kind='main'")?,
2231        total_messages: one("SELECT coalesce(sum(msg_count),0) FROM files WHERE kind='main'")?,
2232        projects: one(
2233            "SELECT count(DISTINCT project) FROM files WHERE kind='main' AND project!=''",
2234        )?,
2235        tags: one("SELECT count(DISTINCT tag) FROM tags")?,
2236        summarized: one("SELECT count(*) FROM summaries")?,
2237        files: one("SELECT count(DISTINCT path) FROM touched")?,
2238        archived: one("SELECT count(*) FROM files WHERE archived_at IS NOT NULL")?,
2239    })
2240}
2241
2242#[cfg(test)]
2243mod native_id_tests {
2244    use super::*;
2245
2246    // Realistic native store paths: a Codex rollout (uuid trails a timestamp) and
2247    // a Claude Code transcript (uuid IS the filename), plus a subagent transcript.
2248    const CODEX: &str = "/home/u/.codex/sessions/2025/05/13/rollout-2025-05-13T18-19-30-0a000000-0000-4000-8000-000000000001.jsonl";
2249    const CODEX_UUID: &str = "0a000000-0000-4000-8000-000000000001";
2250    const CLAUDE: &str =
2251        "/home/u/.claude/projects/-home-u-proj/1b111111-1111-4111-8111-111111111111.jsonl";
2252    const CLAUDE_UUID: &str = "1b111111-1111-4111-8111-111111111111";
2253    const SUBAGENT: &str = "/home/u/.claude/projects/-x/1b111111-1111-4111-8111-111111111111/subagents/agent-2c222222-2222-4222-8222-222222222222.jsonl";
2254    const SUBAGENT_UUID: &str = "2c222222-2222-4222-8222-222222222222";
2255
2256    fn mem() -> Connection {
2257        let c = Connection::open_in_memory().unwrap();
2258        c.execute_batch(
2259            "CREATE TABLE files(
2260                 path TEXT PRIMARY KEY, mtime INTEGER NOT NULL DEFAULT 0,
2261                 size INTEGER NOT NULL DEFAULT 0, session_id TEXT NOT NULL,
2262                 tool TEXT NOT NULL, project TEXT NOT NULL DEFAULT '',
2263                 title TEXT NOT NULL DEFAULT '', started TEXT, ended TEXT,
2264                 msg_count INTEGER NOT NULL DEFAULT 0,
2265                 kind TEXT NOT NULL DEFAULT 'main', archived_at TEXT);
2266             CREATE TABLE summaries(session_id TEXT PRIMARY KEY, summary TEXT NOT NULL, created TEXT NOT NULL);
2267             CREATE TABLE tags(session_id TEXT NOT NULL, tag TEXT NOT NULL, PRIMARY KEY(session_id, tag));",
2268        )
2269        .unwrap();
2270        c
2271    }
2272
2273    /// Insert a files row and return the sessionwiki short id it was keyed by.
2274    fn seed(c: &Connection, tool: &str, path: &str) -> String {
2275        let sid = crate::util::short_id(path);
2276        c.execute(
2277            "INSERT INTO files(path, session_id, tool, msg_count) VALUES(?1,?2,?3,3)",
2278            params![path, sid, tool],
2279        )
2280        .unwrap();
2281        sid
2282    }
2283
2284    #[test]
2285    fn native_id_extracted_per_tool() {
2286        assert_eq!(native_id_of(CODEX).as_deref(), Some(CODEX_UUID));
2287        assert_eq!(native_id_of(CLAUDE).as_deref(), Some(CLAUDE_UUID));
2288        // A subagent transcript resolves to its OWN uuid (scanned from the file
2289        // name), not the parent uuid in the directory above it.
2290        assert_eq!(native_id_of(SUBAGENT).as_deref(), Some(SUBAGENT_UUID));
2291        // Files with no uuid in the name have no native id (not every tool keys
2292        // sessions this way).
2293        assert_eq!(native_id_of("/x/opencode.db#session-42"), None);
2294    }
2295
2296    #[test]
2297    fn native_id_uppercase_is_normalized_to_lowercase() {
2298        let p = "/a/b/AB000000-0000-4000-8000-0000000000FF.jsonl";
2299        assert_eq!(
2300            native_id_of(p).as_deref(),
2301            Some("ab000000-0000-4000-8000-0000000000ff")
2302        );
2303    }
2304
2305    #[test]
2306    fn resolve_by_full_native_id() {
2307        let c = mem();
2308        let sid = seed(&c, "codex", CODEX);
2309        let hits = resolve(&c, CODEX_UUID).unwrap();
2310        assert_eq!(hits.len(), 1, "full native uuid resolves");
2311        assert_eq!(hits[0].session_id, sid);
2312    }
2313
2314    #[test]
2315    fn resolve_by_native_prefix_hex_and_dashed() {
2316        let c = mem();
2317        let sid = seed(&c, "claude-code", CLAUDE);
2318        // First-group (8 hex) prefix.
2319        let a = resolve(&c, "1b111111").unwrap();
2320        assert_eq!(a.len(), 1, "8-hex native prefix resolves");
2321        assert_eq!(a[0].session_id, sid);
2322        // Dashed prefix past the first group.
2323        let b = resolve(&c, "1b111111-1111").unwrap();
2324        assert_eq!(b.len(), 1, "dashed native prefix resolves");
2325        assert_eq!(b[0].session_id, sid);
2326    }
2327
2328    #[test]
2329    fn resolve_still_matches_short_id_unchanged() {
2330        let c = mem();
2331        let sid = seed(&c, "codex", CODEX);
2332        // Full short id and a short-id prefix both resolve (existing behavior).
2333        assert_eq!(resolve(&c, &sid).unwrap().len(), 1);
2334        assert_eq!(resolve(&c, &sid[..6]).unwrap()[0].session_id, sid);
2335    }
2336
2337    #[test]
2338    fn short_id_lookup_does_not_run_the_native_scan() {
2339        // A 12-hex, dash-free string is short-id-shaped and must never trigger a
2340        // native scan (which would be a needless path scan and could add a
2341        // spurious collision). Guard the gate that governs it directly.
2342        assert!(!looks_like_native_prefix("abcdef012345"));
2343        // ... while genuine native shapes do pass.
2344        assert!(looks_like_native_prefix("0a000000"));
2345        assert!(looks_like_native_prefix(
2346            "0a000000-0000-4000-8000-000000000001"
2347        ));
2348        assert!(looks_like_native_prefix("0a000000-0000"));
2349        // Non-hex, too short, or empty never look native.
2350        assert!(!looks_like_native_prefix("zzz"));
2351        assert!(!looks_like_native_prefix("a1"));
2352        assert!(!looks_like_native_prefix(""));
2353    }
2354
2355    #[test]
2356    fn native_prefix_never_hides_an_existing_short_id_match() {
2357        // A plain-hex prefix that already matched a short id stays short-id-only:
2358        // even if some other session's native uuid also starts with those hex
2359        // digits, the plain-hex query keeps the established (short-id) result.
2360        let c = mem();
2361        // Seed a session whose SHORT id begins with the same 8 hex as another
2362        // session's native uuid, and the native one too.
2363        let native_path =
2364            "/home/u/.codex/sessions/2025/01/01/rollout-2025-01-01T00-00-00-deadbeef-0000-4000-8000-000000000009.jsonl";
2365        seed(&c, "codex", native_path);
2366        // Force a files row whose short id we control to start with "deadbeef".
2367        c.execute(
2368            "INSERT INTO files(path, session_id, tool, msg_count) VALUES('/synthetic', 'deadbeef1234', 'codex', 1)",
2369            [],
2370        )
2371        .unwrap();
2372        let hits = resolve(&c, "deadbeef").unwrap();
2373        // Short id matched, so the query stays short-id-only: exactly the one row.
2374        assert_eq!(hits.len(), 1);
2375        assert_eq!(hits[0].session_id, "deadbeef1234");
2376    }
2377
2378    #[test]
2379    fn session_row_serializes_native_id_not_path() {
2380        let row = SessionRow {
2381            session_id: "abc123def456".into(),
2382            tool: "codex".into(),
2383            path: CODEX.into(),
2384            project: "proj".into(),
2385            title: "t".into(),
2386            started: None,
2387            msg_count: 3,
2388            kind: "main".into(),
2389            preview: None,
2390            summary: None,
2391            tags: None,
2392            archived: false,
2393            account: None,
2394        };
2395        let v = serde_json::to_value(&row).unwrap();
2396        assert_eq!(v["id"], "abc123def456");
2397        assert_eq!(v["native_id"], CODEX_UUID, "native_id present in JSON");
2398        assert!(
2399            v.get("path").is_none(),
2400            "the absolute path is never serialized"
2401        );
2402        // A pathless/uuid-less session serializes native_id as null, never a guess.
2403        let mut row2 = row;
2404        row2.path = "/x/opencode.db#s1".into();
2405        let v2 = serde_json::to_value(&row2).unwrap();
2406        assert!(v2["native_id"].is_null());
2407    }
2408}
2409
2410#[cfg(test)]
2411mod edits_tests {
2412    use super::*;
2413
2414    fn conn_with_edits() -> Connection {
2415        let c = Connection::open_in_memory().unwrap();
2416        c.execute_batch(
2417            "CREATE TABLE edits(session_id TEXT NOT NULL, path TEXT NOT NULL,
2418                 kind TEXT NOT NULL, ts TEXT, snippet TEXT NOT NULL);
2419             CREATE INDEX idx_edits_path ON edits(path);",
2420        )
2421        .unwrap();
2422        c
2423    }
2424
2425    fn add(c: &Connection, sid: &str, path: &str, kind: &str, ts: &str, snip: &str) {
2426        c.execute(
2427            "INSERT INTO edits(session_id, path, kind, ts, snippet) VALUES(?1,?2,?3,?4,?5)",
2428            params![sid, path, kind, ts, snip],
2429        )
2430        .unwrap();
2431    }
2432
2433    #[test]
2434    fn edits_for_returns_a_files_edits_by_suffix_newest_first() {
2435        let c = conn_with_edits();
2436        add(
2437            &c,
2438            "s1",
2439            "/home/me/proj/src/auth.rs",
2440            "edit",
2441            "2026-06-08T10:00:00Z",
2442            "let a = 1;",
2443        );
2444        add(
2445            &c,
2446            "s2",
2447            "/home/me/proj/src/auth.rs",
2448            "write",
2449            "2026-06-09T10:00:00Z",
2450            "fn main() {}",
2451        );
2452        add(
2453            &c,
2454            "s3",
2455            "/home/me/proj/src/other.rs",
2456            "edit",
2457            "2026-06-10T10:00:00Z",
2458            "nope",
2459        );
2460
2461        // A relative path finds the absolute stored path by suffix, like `trace`.
2462        let hits = edits_for(&c, "src/auth.rs", 50).unwrap();
2463
2464        assert_eq!(hits.len(), 2, "both auth.rs edits, not other.rs");
2465        assert_eq!(hits[0].kind, "write", "newest edit first");
2466        assert!(hits[0].snippet.contains("fn main()"));
2467        assert_eq!(hits[1].kind, "edit");
2468    }
2469
2470    #[test]
2471    fn index_one_persists_a_sessions_edits() {
2472        use crate::model::{EditEvent, EditKind, Session};
2473        let mut c = Connection::open_in_memory().unwrap();
2474        create_cache_schema(&c).unwrap();
2475
2476        let session = Session {
2477            id: "sx".into(),
2478            tool: "claude-code",
2479            path: "/store/sx.jsonl".into(),
2480            project: "/proj".into(),
2481            started: None,
2482            ended: None,
2483            title: "t".into(),
2484            subagent: false,
2485            messages: vec![],
2486            touched: vec!["/proj/src/auth.rs".into()],
2487            edits: vec![EditEvent {
2488                path: "/proj/src/auth.rs".into(),
2489                kind: EditKind::Write,
2490                snippet: "fn main() {}".into(),
2491                ts: None,
2492            }],
2493        };
2494
2495        let tx = c.transaction().unwrap();
2496        index_one(&tx, &session, "/store/sx.jsonl", 0, 0).unwrap();
2497        tx.commit().unwrap();
2498
2499        let hits = edits_for(&c, "src/auth.rs", 50).unwrap();
2500        assert_eq!(hits.len(), 1, "the session's one edit was persisted");
2501        assert_eq!(hits[0].session_id, "sx");
2502        assert_eq!(hits[0].kind, "write");
2503        assert!(hits[0].snippet.contains("fn main()"));
2504    }
2505
2506    #[test]
2507    fn forget_removes_a_sessions_edits() {
2508        let mut c = Connection::open_in_memory().unwrap();
2509        create_cache_schema(&c).unwrap();
2510        c.execute(
2511            "INSERT INTO files(path, session_id, tool, mtime, size) VALUES('/store/s.jsonl','s1','claude-code',0,0)",
2512            [],
2513        )
2514        .unwrap();
2515        c.execute(
2516            "INSERT INTO edits(session_id,path,kind,ts,snippet) VALUES('s1','/proj/a.rs','write',NULL,'x')",
2517            [],
2518        )
2519        .unwrap();
2520        assert_eq!(edits_for(&c, "a.rs", 10).unwrap().len(), 1);
2521
2522        forget(&mut c, "s1").unwrap();
2523
2524        assert!(
2525            edits_for(&c, "a.rs", 10).unwrap().is_empty(),
2526            "forget must remove the session's edits, not orphan them"
2527        );
2528    }
2529
2530    #[test]
2531    fn edits_for_is_deterministic_when_timestamps_tie() {
2532        let c = conn_with_edits();
2533        add(&c, "s1", "/p/a.rs", "edit", "2026-01-01T00:00:00Z", "first");
2534        add(
2535            &c,
2536            "s2",
2537            "/p/a.rs",
2538            "write",
2539            "2026-01-01T00:00:00Z",
2540            "second",
2541        );
2542        let hits = edits_for(&c, "a.rs", 10).unwrap();
2543        // Equal ts -> deterministic tie-break by rowid DESC (latest insert first).
2544        assert_eq!(hits[0].snippet, "second");
2545        assert_eq!(hits[1].snippet, "first");
2546    }
2547
2548    #[test]
2549    fn edits_for_session_returns_only_that_sessions_edits() {
2550        let c = conn_with_edits();
2551        add(
2552            &c,
2553            "s1",
2554            "/p/a.rs",
2555            "edit",
2556            "2026-01-01T00:00:00Z",
2557            "s1-edit",
2558        );
2559        add(
2560            &c,
2561            "s2",
2562            "/p/a.rs",
2563            "write",
2564            "2026-02-01T00:00:00Z",
2565            "s2-edit",
2566        );
2567        // Scoped by exact session + path, so one session's edits can never be
2568        // starved by another's under a shared cap.
2569        let hits = edits_for_session(&c, "s1", "/p/a.rs", 10).unwrap();
2570        assert_eq!(hits.len(), 1);
2571        assert_eq!(hits[0].snippet, "s1-edit");
2572    }
2573
2574    #[test]
2575    fn edits_for_session_matches_every_spelling_of_the_path() {
2576        let c = conn_with_edits();
2577        // One session edited the file under two path spellings (abs + relative).
2578        add(
2579            &c,
2580            "s1",
2581            "/proj/src/auth.rs",
2582            "edit",
2583            "2026-01-01T00:00:00Z",
2584            "abs",
2585        );
2586        add(
2587            &c,
2588            "s1",
2589            "src/auth.rs",
2590            "write",
2591            "2026-01-02T00:00:00Z",
2592            "rel",
2593        );
2594        add(
2595            &c,
2596            "s2",
2597            "/other/auth.rs",
2598            "edit",
2599            "2026-01-03T00:00:00Z",
2600            "different-file",
2601        );
2602        // Suffix match scoped to s1 must catch BOTH spellings - never miss edits
2603        // just because sessions_for_file's GROUP BY picked the other spelling.
2604        let hits = edits_for_session(&c, "s1", "src/auth.rs", 10).unwrap();
2605        assert_eq!(hits.len(), 2, "all of s1's edits to the file, any spelling");
2606    }
2607
2608    #[test]
2609    fn index_redacts_secrets_in_messages_and_edit_snippets() {
2610        use crate::model::{EditEvent, EditKind, Message, Role, Session};
2611        let mut c = Connection::open_in_memory().unwrap();
2612        create_cache_schema(&c).unwrap();
2613        let session = Session {
2614            id: "sx".into(),
2615            tool: "claude-code",
2616            path: "/s.jsonl".into(),
2617            project: "/p".into(),
2618            started: None,
2619            ended: None,
2620            title: "title with AKIAIOSFODNN7EXAMPLE in it".into(),
2621            subagent: false,
2622            messages: vec![Message {
2623                role: Role::User,
2624                text: "my key is sk-abcdef012345678901234567890123 ok".into(),
2625                ts: None,
2626            }],
2627            touched: vec!["/p/a.rs".into()],
2628            edits: vec![EditEvent {
2629                path: "/p/a.rs".into(),
2630                kind: EditKind::Write,
2631                snippet: "const T = \"ghp_016C7f9aBcDeFgHiJkLmNoPqRsTuVwXyZ012\";".into(),
2632                ts: None,
2633            }],
2634        };
2635        let tx = c.transaction().unwrap();
2636        index_one(&tx, &session, "/s.jsonl", 0, 0).unwrap();
2637        tx.commit().unwrap();
2638
2639        let msg: String = c
2640            .query_row("SELECT text FROM messages WHERE session_id='sx'", [], |r| {
2641                r.get(0)
2642            })
2643            .unwrap();
2644        assert!(!msg.contains("sk-abcdef"), "message secret redacted: {msg}");
2645        assert!(msg.contains("[redacted:openai]"), "{msg}");
2646        let snip = edits_for(&c, "a.rs", 10).unwrap()[0].snippet.clone();
2647        assert!(!snip.contains("ghp_016C"), "edit secret redacted: {snip}");
2648        assert!(snip.contains("[redacted:github]"), "{snip}");
2649        // Title is durable (copied into archives) - must be redacted too.
2650        let title: String = c
2651            .query_row("SELECT title FROM files WHERE session_id='sx'", [], |r| {
2652                r.get(0)
2653            })
2654            .unwrap();
2655        assert!(
2656            !title.contains("AKIAIOSFODNN7EXAMPLE"),
2657            "title secret redacted: {title}"
2658        );
2659        // LLM synopsis can echo a secret into the durable summaries table.
2660        set_summary(
2661            &c,
2662            "sx",
2663            "we set sk-abcdef012345678901234567890123 as the key",
2664        )
2665        .unwrap();
2666        let sum: String = c
2667            .query_row(
2668                "SELECT summary FROM summaries WHERE session_id='sx'",
2669                [],
2670                |r| r.get(0),
2671            )
2672            .unwrap();
2673        assert!(!sum.contains("sk-abcdef"), "summary secret redacted: {sum}");
2674    }
2675
2676    #[test]
2677    fn evidence_for_assembles_sessions_with_their_edits_newest_first() {
2678        use crate::model::{EditEvent, EditKind, Session};
2679        let mut c = Connection::open_in_memory().unwrap();
2680        create_cache_schema(&c).unwrap();
2681
2682        for (sid, store, started, kind, snip) in [
2683            (
2684                "old",
2685                "/store/old.jsonl",
2686                "2026-06-01T00:00:00Z",
2687                EditKind::Edit,
2688                "v1",
2689            ),
2690            (
2691                "new",
2692                "/store/new.jsonl",
2693                "2026-06-09T00:00:00Z",
2694                EditKind::Write,
2695                "v2",
2696            ),
2697        ] {
2698            let started = chrono::DateTime::parse_from_rfc3339(started)
2699                .unwrap()
2700                .with_timezone(&chrono::Utc);
2701            let session = Session {
2702                id: sid.into(),
2703                tool: "claude-code",
2704                path: store.into(),
2705                project: "/proj".into(),
2706                started: Some(started),
2707                ended: None,
2708                title: format!("{sid} title"),
2709                subagent: false,
2710                messages: vec![],
2711                touched: vec!["/proj/src/a.rs".into()],
2712                edits: vec![EditEvent {
2713                    path: "/proj/src/a.rs".into(),
2714                    kind,
2715                    snippet: snip.into(),
2716                    ts: None,
2717                }],
2718            };
2719            let tx = c.transaction().unwrap();
2720            index_one(&tx, &session, store, 0, 0).unwrap();
2721            tx.commit().unwrap();
2722        }
2723
2724        let hist = evidence_for(&c, "src/a.rs", 50).unwrap();
2725        assert_eq!(hist.path, "src/a.rs");
2726        assert_eq!(hist.sessions.len(), 2, "both sessions that edited the file");
2727        assert_eq!(
2728            hist.sessions[0].session.session_id, "new",
2729            "newest session first"
2730        );
2731        assert_eq!(hist.sessions[0].edits.len(), 1);
2732        assert_eq!(hist.sessions[0].edits[0].snippet, "v2");
2733        assert_eq!(hist.sessions[1].session.session_id, "old");
2734    }
2735}
2736
2737#[cfg(test)]
2738mod moved_file_tests {
2739    use super::*;
2740
2741    /// Folders get renamed. A session recorded `~/Project/lunch/diag.py`; the
2742    /// folder is now `~/Project/slack`, so tracing the file by the path it has
2743    /// TODAY found nothing and said "no session touched a file matching" - about
2744    /// a file whose whole history was sitting in the index under its old name.
2745    ///
2746    /// The basename is the part that survives a move, so a full path that finds
2747    /// nothing falls back to it, and the caller is told the match was by name so
2748    /// it can say the folder has moved.
2749    #[test]
2750    fn a_renamed_folder_still_traces_by_file_name() {
2751        assert_eq!(
2752            basename_fallback("/Users/b/Project/slack/diag.py").as_deref(),
2753            Some("diag.py"),
2754            "a full path falls back to its file name"
2755        );
2756        // Already a bare name: there is nothing to fall back to, and retrying
2757        // the same query would just repeat the miss.
2758        assert_eq!(basename_fallback("diag.py"), None);
2759        assert_eq!(basename_fallback(""), None);
2760        // A trailing slash names a directory, not a file to trace.
2761        assert_eq!(basename_fallback("/Users/b/Project/slack/"), None);
2762    }
2763}
2764
2765#[cfg(test)]
2766mod legacy_migration_tests {
2767    use super::*;
2768
2769    /// The migration looked for the old directories under `dirs::data_dir()`
2770    /// no matter where the index was actually going, and then RENAMED what it
2771    /// found into that destination. With `SESSIONWIKI_DATA` pointed at a temp
2772    /// dir - which eight test files do - a machine still holding
2773    /// `~/.local/share/sessiondex` would have had its real index moved into
2774    /// that temp dir and deleted with it. The comment above says the tags,
2775    /// notes and summaries in there are not rebuildable.
2776    #[test]
2777    fn a_legacy_index_is_only_looked_for_beside_the_new_one() {
2778        let under_home = std::path::Path::new("/home/someone/.local/share/sessionwiki");
2779        let got = legacy_candidates(under_home);
2780        assert_eq!(
2781            got,
2782            vec![
2783                std::path::PathBuf::from("/home/someone/.local/share/sessiondex"),
2784                std::path::PathBuf::from("/home/someone/.local/share/session-atlas"),
2785            ],
2786            "the normal case must keep working"
2787        );
2788
2789        let redirected = std::path::Path::new("/tmp/sessionwiki-test-xyz");
2790        for c in legacy_candidates(redirected) {
2791            assert!(
2792                c.starts_with("/tmp"),
2793                "a redirected run reached outside its own tree: {}",
2794                c.display()
2795            );
2796        }
2797    }
2798
2799    #[test]
2800    fn a_destination_with_no_parent_offers_nothing_to_migrate() {
2801        assert!(legacy_candidates(std::path::Path::new("/")).is_empty());
2802    }
2803}
2804
2805#[cfg(test)]
2806mod embedder_hook_tests {
2807    use super::*;
2808    use crate::adapters::{Adapter, Discovered, Store};
2809    use crate::model::{Message, Role, Session};
2810    use std::path::Path;
2811
2812    /// A shared-store adapter an embedding program could supply: it lists only
2813    /// the keys under its own prefix and reconciles only that prefix.
2814    struct FakeStore {
2815        keys: Vec<(String, i64)>,
2816        scope: Option<String>,
2817    }
2818
2819    impl Adapter for FakeStore {
2820        fn name(&self) -> &'static str {
2821            "mjolnir"
2822        }
2823        fn root(&self) -> Option<PathBuf> {
2824            // Any existing directory: the reconciliation guard only asks
2825            // whether the store root is still there.
2826            Some(std::env::current_dir().unwrap())
2827        }
2828        fn discover(&self) -> Discovered {
2829            Discovered {
2830                files: Vec::new(),
2831                had_error: false,
2832            }
2833        }
2834        fn parse(&self, _path: &Path) -> Result<Session> {
2835            anyhow::bail!("shared store")
2836        }
2837        fn store(&self) -> Option<Store> {
2838            Some(Store {
2839                keys: self.keys.clone(),
2840                files: Vec::new(),
2841                had_error: false,
2842            })
2843        }
2844        fn parse_key(&self, key: &str) -> Result<Session> {
2845            Ok(Session {
2846                id: key.rsplit('/').next().unwrap().to_string(),
2847                tool: "mjolnir",
2848                path: PathBuf::from(key),
2849                project: "/proj".into(),
2850                started: None,
2851                ended: None,
2852                title: "a restored session".into(),
2853                subagent: false,
2854                messages: vec![Message {
2855                    role: Role::User,
2856                    text: "make the tests green".into(),
2857                    ts: None,
2858                }],
2859                touched: vec![],
2860                edits: vec![],
2861            })
2862        }
2863        fn reconcile_scope(&self) -> Option<String> {
2864            self.scope.clone()
2865        }
2866    }
2867
2868    fn insert_live_row(c: &Connection, path: &str, sid: &str) {
2869        c.execute(
2870            "INSERT INTO files(path, session_id, tool, mtime, size, project, title, msg_count, kind)
2871             VALUES(?1, ?2, 'mjolnir', 0, 0, '/proj', 't', 1, 'session')",
2872            params![path, sid],
2873        )
2874        .unwrap();
2875        c.execute(
2876            "INSERT INTO messages(session_id, role, text) VALUES(?1,'user','hello')",
2877            params![sid],
2878        )
2879        .unwrap();
2880    }
2881
2882    fn archived_at(c: &Connection, path: &str) -> Option<String> {
2883        c.query_row(
2884            "SELECT archived_at FROM files WHERE path = ?1",
2885            params![path],
2886            |r| r.get(0),
2887        )
2888        .unwrap()
2889    }
2890
2891    /// Two installations of one tool share a tool name and one index. A sync
2892    /// driven by the first must not archive the second's rows just because it
2893    /// never lists them.
2894    #[test]
2895    fn reconcile_scope_limits_archiving_to_the_adapters_own_keys() {
2896        let mut c = Connection::open_in_memory().unwrap();
2897        create_cache_schema(&c).unwrap();
2898        insert_live_row(&c, "/data/one/sess-a", "sa");
2899        insert_live_row(&c, "/data/two/sess-b", "sb");
2900
2901        let adapters: Vec<Box<dyn Adapter>> = vec![Box::new(FakeStore {
2902            keys: Vec::new(),
2903            scope: Some("/data/one/".to_string()),
2904        })];
2905        sync_with(&mut c, &adapters, None).unwrap();
2906
2907        assert!(
2908            archived_at(&c, "/data/one/sess-a").is_some(),
2909            "the in-scope row the adapter no longer lists must be archived"
2910        );
2911        assert!(
2912            archived_at(&c, "/data/two/sess-b").is_none(),
2913            "the other installation's row must be left live"
2914        );
2915    }
2916
2917    /// Without a scope the adapter still speaks for every row of its tool.
2918    #[test]
2919    fn an_unscoped_adapter_still_archives_every_row_of_its_tool() {
2920        let mut c = Connection::open_in_memory().unwrap();
2921        create_cache_schema(&c).unwrap();
2922        insert_live_row(&c, "/data/one/sess-a", "sa");
2923        insert_live_row(&c, "/data/two/sess-b", "sb");
2924
2925        let adapters: Vec<Box<dyn Adapter>> = vec![Box::new(FakeStore {
2926            keys: Vec::new(),
2927            scope: None,
2928        })];
2929        sync_with(&mut c, &adapters, None).unwrap();
2930
2931        assert!(archived_at(&c, "/data/one/sess-a").is_some());
2932        assert!(archived_at(&c, "/data/two/sess-b").is_some());
2933    }
2934
2935    /// The point of `sync_with`: an embedding program indexes its own sessions
2936    /// with its own adapter, which is in no built-in registry.
2937    #[test]
2938    fn sync_with_indexes_a_session_from_a_supplied_adapter() {
2939        let mut c = Connection::open_in_memory().unwrap();
2940        create_cache_schema(&c).unwrap();
2941
2942        let adapters: Vec<Box<dyn Adapter>> = vec![Box::new(FakeStore {
2943            keys: vec![("/data/one/sess-a".to_string(), 42)],
2944            scope: Some("/data/one/".to_string()),
2945        })];
2946        sync_with(&mut c, &adapters, None).unwrap();
2947
2948        let rows = recent(&c, 10, Some("mjolnir"), None, None, false).unwrap();
2949        assert_eq!(rows.len(), 1, "the supplied adapter's session was indexed");
2950        assert_eq!(rows[0].session_id, "sess-a");
2951        assert_eq!(rows[0].title, "a restored session");
2952        assert_eq!(rows[0].msg_count, 1);
2953        assert!(!rows[0].archived, "a listed session stays live");
2954    }
2955
2956    /// A row whose tool only an embedder's adapter knows still shows that
2957    /// tool's name, rather than the "unknown" the registry lookup used to give.
2958    #[test]
2959    fn a_session_keeps_its_own_tool_name_when_no_adapter_is_registered() {
2960        let mut c = Connection::open_in_memory().unwrap();
2961        create_cache_schema(&c).unwrap();
2962
2963        let adapters: Vec<Box<dyn Adapter>> = vec![Box::new(FakeStore {
2964            keys: vec![("/data/one/sess-a".to_string(), 42)],
2965            scope: Some("/data/one/".to_string()),
2966        })];
2967        sync_with(&mut c, &adapters, None).unwrap();
2968
2969        let rows = recent(&c, 10, Some("mjolnir"), None, None, false).unwrap();
2970        assert!(
2971            crate::adapters::by_name("mjolnir").is_none(),
2972            "the built-in registry must not know this tool, or the test proves nothing"
2973        );
2974        let session = session_from_index(&c, &rows[0]).unwrap();
2975        assert_eq!(session.tool, "mjolnir");
2976    }
2977
2978    /// The interner hands back one leaked string per name, however often it is
2979    /// asked, so the leak is bounded by the number of tool names.
2980    #[test]
2981    fn interning_a_tool_name_twice_yields_the_same_string() {
2982        let first = interned_tool("a-tool-no-adapter-knows");
2983        let second = interned_tool(&String::from("a-tool-no-adapter-knows"));
2984        assert_eq!(first, "a-tool-no-adapter-knows");
2985        assert!(std::ptr::eq(first, second));
2986    }
2987}