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    /// Index of the best-matching message within its session (0-based, in the
1220    /// order `show` prints them). Lets a caller jump straight to the passage.
1221    pub i: usize,
1222}
1223
1224/// Full-text search, best match per session. The trigram tokenizer gives
1225/// substring matching, which also makes CJK text searchable.
1226pub fn search(
1227    conn: &Connection,
1228    query: &str,
1229    limit: usize,
1230    tool: Option<&str>,
1231    project: Option<&str>,
1232) -> Result<Vec<Hit>> {
1233    // A plain quoted string disables FTS5 operator parsing: users type
1234    // text, not query syntax.
1235    // Normalize the query to NFC so it lines up with the NFC-normalized indexed
1236    // text, then quote (the quoting is FTS5 syntax we add, not user content).
1237    let fts_query = format!("\"{}\"", crate::util::nfc(query).replace('"', "\"\""));
1238
1239    // snippet()/rank only work in a plain FTS5 query context, not under
1240    // joins or GROUP BY, so match in a subquery and attach metadata outside.
1241    //
1242    // Tradeoff: we take the top 1000 message hits by rank, then group to
1243    // sessions. For a very common term this can miss sessions whose only hits
1244    // fall past rank 1000 - a deliberate choice that keeps the query fast on a
1245    // multi-million-message index. Narrow the query to surface the long tail.
1246    let mut sql = String::from(
1247        "SELECT f.session_id, f.tool, f.path, f.project, f.title, f.started, f.msg_count, f.kind,
1248                m.role, x.snip, min(x.rank) AS best, (f.archived_at IS NOT NULL),
1249                m.id AS mid
1250         FROM (SELECT rowid AS mid,
1251                      snippet(msgs, 0, char(2), char(3), char(8230), 18) AS snip,
1252                      rank
1253               FROM msgs WHERE msgs MATCH ? ORDER BY rank LIMIT 4000) x
1254         JOIN messages m ON m.id = x.mid
1255         JOIN files f ON f.session_id = m.session_id
1256         WHERE 1=1",
1257    );
1258    let mut args: Vec<String> = vec![fts_query];
1259    if let Some(t) = tool {
1260        sql.push_str(" AND f.tool = ?");
1261        args.push(t.to_string());
1262    }
1263    if let Some(p) = project {
1264        sql.push_str(" AND f.project LIKE ?");
1265        args.push(format!("%{}%", crate::util::nfc(p)));
1266    }
1267    sql.push_str(" GROUP BY f.session_id");
1268    // The message's position in its session. `messages` has no ordinal column,
1269    // so per-session order is `id` order and the position is how many of that
1270    // session's messages precede it. Counting outside the grouped query keeps
1271    // the FTS match in the plain context snippet()/rank need.
1272    let sql = format!(
1273        "SELECT g.*,
1274                (SELECT COUNT(*) FROM messages m2
1275                  WHERE m2.session_id = g.session_id AND m2.id < g.mid) AS i
1276         FROM ({sql}) g ORDER BY g.best LIMIT {limit}"
1277    );
1278
1279    let mut stmt = conn.prepare(&sql)?;
1280    let rows = stmt.query_map(rusqlite::params_from_iter(args), |r| {
1281        Ok(Hit {
1282            row: SessionRow {
1283                session_id: r.get(0)?,
1284                tool: r.get(1)?,
1285                path: r.get(2)?,
1286                project: r.get(3)?,
1287                title: r.get(4)?,
1288                started: r.get(5)?,
1289                msg_count: r.get(6)?,
1290                kind: r.get(7)?,
1291                preview: None,
1292                summary: None,
1293                tags: None,
1294                archived: r.get(11)?,
1295                account: None,
1296            },
1297            role: r.get(8)?,
1298            snippet: r.get(9)?,
1299            i: r.get::<_, i64>(13)?.max(0) as usize,
1300        })
1301    })?;
1302    let mut out = rows.collect::<rusqlite::Result<Vec<_>>>()?;
1303    crate::account_link::annotate(out.iter_mut().map(|h| &mut h.row));
1304    Ok(out)
1305}
1306
1307/// Substring search for queries too short for the trigram FTS index (1-2
1308/// chars, e.g. the Korean words 회사 / 검색). The trigram tokenizer needs >=3
1309/// chars, so these terms are unindexable; we fall back to a LIKE scan of
1310/// messages.text. Returns the same `Hit` shape as `search` so callers are
1311/// agnostic to which path ran.
1312///
1313/// Perf: this is a table scan, used ONLY for short queries (the >=3 path stays
1314/// on FTS). We cap the candidate rows scanned (SCAN_CAP) ordered newest-first
1315/// so a very common 2-char term cannot walk an unbounded table; the tradeoff is
1316/// that a session whose only match is older than the newest SCAN_CAP hits can be
1317/// missed. Narrow to a >=3-char term to use the exact FTS path instead. LIKE has
1318/// no rank, so results are ordered by recency (newest session first).
1319pub fn search_like(
1320    conn: &Connection,
1321    query: &str,
1322    limit: usize,
1323    tool: Option<&str>,
1324    project: Option<&str>,
1325) -> Result<Vec<Hit>> {
1326    const SCAN_CAP: i64 = 50_000;
1327
1328    // NFC so a decomposed query (macOS Korean) matches NFC-stored text, then
1329    // escape LIKE metacharacters ('\' first so an escape char is literal).
1330    let q = crate::util::nfc(query.trim());
1331    let pattern = format!(
1332        "%{}%",
1333        q.replace('\\', "\\\\")
1334            .replace('%', "\\%")
1335            .replace('_', "\\_")
1336    );
1337
1338    let mut sql = String::from(
1339        "SELECT f.session_id, f.tool, f.path, f.project, f.title, f.started, f.msg_count, f.kind,
1340                x.role, x.text, (f.archived_at IS NOT NULL), max(x.mid) AS mid
1341         FROM (SELECT m.session_id AS sid, m.role AS role, m.text AS text, m.id AS mid
1342               FROM messages m
1343               WHERE m.text LIKE ?1 ESCAPE '\\'
1344               ORDER BY m.id DESC LIMIT ?2) x
1345         JOIN files f ON f.session_id = x.sid
1346         WHERE 1=1",
1347    );
1348    let mut args: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(pattern), Box::new(SCAN_CAP)];
1349    if let Some(t) = tool {
1350        sql.push_str(" AND f.tool = ?");
1351        args.push(Box::new(t.to_string()));
1352    }
1353    if let Some(p) = project {
1354        sql.push_str(" AND f.project LIKE ?");
1355        args.push(Box::new(format!("%{}%", crate::util::nfc(p))));
1356    }
1357    // One row per session (its newest matching message), sessions newest-first.
1358    // `max(x.mid)` is the only aggregate, so the bare columns come from that
1359    // same newest matching message. The outer query turns its id into the
1360    // message's position within the session (see `search`).
1361    sql.push_str(" GROUP BY f.session_id");
1362    let sql = format!(
1363        "SELECT g.*,
1364                (SELECT COUNT(*) FROM messages m2
1365                  WHERE m2.session_id = g.session_id AND m2.id < g.mid) AS i
1366         FROM ({sql}) g ORDER BY g.mid DESC LIMIT {limit}"
1367    );
1368
1369    let mut stmt = conn.prepare(&sql)?;
1370    let rows = stmt.query_map(
1371        rusqlite::params_from_iter(args.iter().map(|b| b.as_ref())),
1372        |r| {
1373            let role: String = r.get(8)?;
1374            let text: String = r.get(9)?;
1375            Ok(Hit {
1376                row: SessionRow {
1377                    session_id: r.get(0)?,
1378                    tool: r.get(1)?,
1379                    path: r.get(2)?,
1380                    project: r.get(3)?,
1381                    title: r.get(4)?,
1382                    started: r.get(5)?,
1383                    msg_count: r.get(6)?,
1384                    kind: r.get(7)?,
1385                    preview: None,
1386                    summary: None,
1387                    tags: None,
1388                    archived: r.get(10)?,
1389                    account: None,
1390                },
1391                role,
1392                snippet: snippet_around(&text, &q),
1393                i: r.get::<_, i64>(12)?.max(0) as usize,
1394            })
1395        },
1396    )?;
1397    let mut out = rows.collect::<rusqlite::Result<Vec<_>>>()?;
1398    crate::account_link::annotate(out.iter_mut().map(|h| &mut h.row));
1399    Ok(out)
1400}
1401
1402/// Build a snippet for a LIKE hit: a window around the first (case-insensitive,
1403/// NFC) match of `needle` in `text`, with the match wrapped in \u{2}..\u{3} -
1404/// the same delimiters snippet() emits - so the CLI's ANSI swap and the web UI
1405/// render LIKE hits identically to FTS hits. Matching is on chars, not bytes,
1406/// so multibyte CJK is never sliced mid-codepoint.
1407fn snippet_around(text: &str, needle: &str) -> String {
1408    const WINDOW: usize = 36;
1409    let hay_chars: Vec<char> = text.to_lowercase().chars().collect();
1410    let nee_chars: Vec<char> = needle.to_lowercase().chars().collect();
1411    let chars: Vec<char> = text.chars().collect();
1412
1413    let match_at = if nee_chars.is_empty() {
1414        None
1415    } else {
1416        hay_chars
1417            .windows(nee_chars.len())
1418            .position(|w| w == nee_chars.as_slice())
1419    };
1420    let Some(start) = match_at else {
1421        return chars
1422            .iter()
1423            .take(WINDOW * 2)
1424            .collect::<String>()
1425            .replace('\n', " ");
1426    };
1427    let end = start + nee_chars.len();
1428    let lo = start.saturating_sub(WINDOW);
1429    let hi = (end + WINDOW).min(chars.len());
1430
1431    let mut out = String::new();
1432    if lo > 0 {
1433        out.push('\u{2026}');
1434    }
1435    out.extend(&chars[lo..start]);
1436    out.push('\u{2}');
1437    out.extend(&chars[start..end]);
1438    out.push('\u{3}');
1439    out.extend(&chars[end..hi]);
1440    if hi < chars.len() {
1441        out.push('\u{2026}');
1442    }
1443    out.replace('\n', " ")
1444}
1445
1446/// Escape LIKE metacharacters ('\' first) so an id/path fragment like "%" or
1447/// "_" can't turn a prefix match into a wildcard that resolves every session.
1448fn escape_like(s: &str) -> String {
1449    s.replace('\\', "\\\\")
1450        .replace('%', "\\%")
1451        .replace('_', "\\_")
1452}
1453
1454/// The SELECT column list every id-resolution query shares, in the order
1455/// [`map_resolve_row`] reads.
1456const RESOLVE_COLS: &str = "session_id, tool, path, project, title, started, msg_count, kind";
1457
1458/// Map a resolve-query row to a [`SessionRow`]. Callers must SELECT
1459/// [`RESOLVE_COLS`] followed by `{SUMMARY_SQL}, {TAGS_SQL}, (archived_at IS NOT NULL)`.
1460fn map_resolve_row(r: &rusqlite::Row) -> rusqlite::Result<SessionRow> {
1461    Ok(SessionRow {
1462        session_id: r.get(0)?,
1463        tool: r.get(1)?,
1464        path: r.get(2)?,
1465        project: r.get(3)?,
1466        title: r.get(4)?,
1467        started: r.get(5)?,
1468        msg_count: r.get(6)?,
1469        kind: r.get(7)?,
1470        preview: None,
1471        summary: r.get(8)?,
1472        tags: r.get(9)?,
1473        archived: r.get(10)?,
1474        account: None,
1475    })
1476}
1477
1478/// Resolve a (possibly abbreviated) session id to its file row(s). Matches on
1479/// the sessionwiki short id (prefix) AND on the tool's own native id (the codex
1480/// rollout / claude transcript UUID, full or prefix), so a harness "tower" row -
1481/// which knows only the native id - can be reopened directly. Short-id behavior
1482/// is unchanged: the native scan only runs for native-shaped queries, and never
1483/// displaces an existing short-id match on a plain-hex prefix (see
1484/// [`looks_like_native_prefix`]).
1485pub fn resolve(conn: &Connection, id_prefix: &str) -> Result<Vec<SessionRow>> {
1486    let mut out = resolve_by_short_id(conn, id_prefix)?;
1487
1488    // Native-id join. A plain-hex prefix stays short-id-only when it already
1489    // matched something (no new ambiguity); a dashed prefix is unambiguously a
1490    // UUID (short ids have no dashes) so it always broadens to the native scan.
1491    let native_ok =
1492        looks_like_native_prefix(id_prefix) && (out.is_empty() || id_prefix.contains('-'));
1493    if native_ok {
1494        for row in resolve_by_native_id(conn, id_prefix)? {
1495            if out.len() >= 10 {
1496                break;
1497            }
1498            if out.iter().all(|r| r.session_id != row.session_id) {
1499                out.push(row);
1500            }
1501        }
1502    }
1503    Ok(out)
1504}
1505
1506/// Prefix match on the sessionwiki short id (the historical `resolve`).
1507fn resolve_by_short_id(conn: &Connection, id_prefix: &str) -> Result<Vec<SessionRow>> {
1508    let mut stmt = conn.prepare(&format!(
1509        "SELECT {RESOLVE_COLS}, {SUMMARY_SQL}, {TAGS_SQL}, (archived_at IS NOT NULL)
1510         FROM files f WHERE session_id LIKE ?1 ESCAPE '\\' LIMIT 10",
1511    ))?;
1512    let pattern = format!("{}%", escape_like(id_prefix));
1513    let rows = stmt.query_map(params![pattern], map_resolve_row)?;
1514    Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
1515}
1516
1517/// Match rows whose native id (derived from the filename) starts with `prefix`.
1518/// The native id is a substring of the stored path, so a coarse `path LIKE
1519/// '%prefix%'` yields a bounded superset which we then confirm per row - a
1520/// planted path fragment can never satisfy the exact `native_id_of` check.
1521fn resolve_by_native_id(conn: &Connection, prefix: &str) -> Result<Vec<SessionRow>> {
1522    let mut stmt = conn.prepare(&format!(
1523        "SELECT {RESOLVE_COLS}, {SUMMARY_SQL}, {TAGS_SQL}, (archived_at IS NOT NULL)
1524         FROM files f WHERE path LIKE ?1 ESCAPE '\\' LIMIT 500",
1525    ))?;
1526    let pattern = format!("%{}%", escape_like(prefix));
1527    let want = prefix.to_ascii_lowercase();
1528    let rows = stmt.query_map(params![pattern], map_resolve_row)?;
1529    let mut out = Vec::new();
1530    for row in rows {
1531        let row = row?;
1532        if native_id_of(&row.path).is_some_and(|n| n.starts_with(&want)) {
1533            out.push(row);
1534            if out.len() >= 10 {
1535                break;
1536            }
1537        }
1538    }
1539    Ok(out)
1540}
1541
1542/// Locate a session file directly on disk by its NATIVE id (codex rollout UUID
1543/// or claude transcript UUID), full or prefix, WITHOUT the index. This is the
1544/// live-session path: a session started moments ago may not be indexed yet, but
1545/// its file already exists under the tool's store root, so `session_window` /
1546/// `show` can still open it in one call. Scans only the codex and claude roots -
1547/// the two tools whose native id a harness tower knows - and returns the first
1548/// (tool, path) whose filename-derived native id matches. None if nothing on
1549/// disk matches (or the query is not native-shaped).
1550pub fn locate_by_native_id(prefix: &str) -> Option<(String, PathBuf)> {
1551    if !looks_like_native_prefix(prefix) {
1552        return None;
1553    }
1554    let want = prefix.to_ascii_lowercase();
1555    for name in ["claude-code", "codex"] {
1556        let Some(adapter) = adapters::by_name(name) else {
1557            continue;
1558        };
1559        let Some(root) = adapter.root() else { continue };
1560        if !root.exists() {
1561            continue;
1562        }
1563        let hit = walkdir::WalkDir::new(&root)
1564            .into_iter()
1565            .filter_map(std::result::Result::ok)
1566            .find(|e| {
1567                e.file_type().is_file()
1568                    && e.path().extension().is_some_and(|x| x == "jsonl")
1569                    && native_id_of(&e.path().to_string_lossy())
1570                        .is_some_and(|n| n.starts_with(&want))
1571            });
1572        if let Some(e) = hit {
1573            return Some((name.to_string(), e.into_path()));
1574        }
1575    }
1576    None
1577}
1578
1579/// A minimal, un-indexed [`SessionRow`] for a live session located on disk by
1580/// [`locate_by_native_id`]. The real path and tool drive a direct parse (via
1581/// `load_session`); the metadata fields are placeholders the window/show render
1582/// path does not consult (it reads the parsed transcript). The `session_id`
1583/// matches the id the adapter would assign, so a later sync reconciles cleanly.
1584pub fn live_row(tool: String, path: PathBuf) -> SessionRow {
1585    let path = path.to_string_lossy().into_owned();
1586    SessionRow {
1587        session_id: crate::util::short_id(&path),
1588        tool,
1589        path,
1590        project: String::new(),
1591        title: String::new(),
1592        started: None,
1593        msg_count: 0,
1594        kind: "main".into(),
1595        preview: None,
1596        summary: None,
1597        tags: None,
1598        archived: false,
1599        account: None,
1600    }
1601}
1602
1603/// Store (or replace) the cached synopsis for a session. The synopsis comes from
1604/// the user's own LLM over the raw transcript, so it can echo a secret - redact
1605/// before it lands in this durable table.
1606pub fn set_summary(conn: &Connection, session_id: &str, summary: &str) -> Result<()> {
1607    conn.execute(
1608        "INSERT OR REPLACE INTO summaries(session_id, summary, created)
1609         VALUES (?1, ?2, datetime('now'))",
1610        params![session_id, crate::redact::redact(summary).as_ref()],
1611    )?;
1612    Ok(())
1613}
1614
1615/// Most recent main sessions that have no cached summary yet.
1616pub fn unsummarized(
1617    conn: &Connection,
1618    limit: usize,
1619    tool: Option<&str>,
1620) -> Result<Vec<SessionRow>> {
1621    let mut sql = format!(
1622        "SELECT session_id, tool, path, project, title, started, msg_count, kind, {PREVIEW_SQL}, {SUMMARY_SQL}, {TAGS_SQL}, (archived_at IS NOT NULL)
1623         FROM files f
1624         WHERE kind = 'main' AND NOT EXISTS
1625               (SELECT 1 FROM summaries s WHERE s.session_id = f.session_id)",
1626    );
1627    let mut args: Vec<String> = Vec::new();
1628    if let Some(t) = tool {
1629        sql.push_str(" AND tool = ?");
1630        args.push(t.to_string());
1631    }
1632    sql.push_str(&format!(" ORDER BY started DESC LIMIT {limit}"));
1633    let mut stmt = conn.prepare(&sql)?;
1634    let rows = stmt.query_map(rusqlite::params_from_iter(args), |r| {
1635        Ok(SessionRow {
1636            session_id: r.get(0)?,
1637            tool: r.get(1)?,
1638            path: r.get(2)?,
1639            project: r.get(3)?,
1640            title: r.get(4)?,
1641            started: r.get(5)?,
1642            msg_count: r.get(6)?,
1643            kind: r.get(7)?,
1644            preview: r.get(8)?,
1645            summary: r.get(9)?,
1646            tags: r.get(10)?,
1647            archived: r.get(11)?,
1648            account: None,
1649        })
1650    })?;
1651    Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
1652}
1653
1654// --- curation (the editable wiki layer) ---
1655
1656/// One canonical form per tag: trimmed, lowercased, NFC-normalized - the same
1657/// normalization every other indexed string gets, so a tag typed in decomposed
1658/// form (macOS IME, some CJK inputs) matches on add, filter, and remove alike.
1659fn norm_tag(tag: &str) -> String {
1660    crate::util::nfc(&tag.trim().to_lowercase())
1661}
1662
1663pub fn add_tag(conn: &Connection, session_id: &str, tag: &str) -> Result<()> {
1664    // Tags are joined with ',' at read time and the JSON/web contract splits on
1665    // it, so a comma inside a tag would corrupt the array into two elements.
1666    // Reject it (and the empty tag) at the input boundary.
1667    let tag = norm_tag(tag);
1668    if tag.is_empty() || tag.contains(',') {
1669        anyhow::bail!("a tag must be non-empty and contain no commas");
1670    }
1671    conn.execute(
1672        "INSERT OR IGNORE INTO tags(session_id, tag) VALUES (?1, ?2)",
1673        params![session_id, tag],
1674    )?;
1675    Ok(())
1676}
1677
1678pub fn remove_tag(conn: &Connection, session_id: &str, tag: &str) -> Result<usize> {
1679    Ok(conn.execute(
1680        "DELETE FROM tags WHERE session_id = ?1 AND tag = ?2",
1681        params![session_id, norm_tag(tag)],
1682    )?)
1683}
1684
1685pub fn set_note(conn: &Connection, session_id: &str, note: &str) -> Result<()> {
1686    conn.execute(
1687        "INSERT OR REPLACE INTO notes(session_id, note, updated)
1688         VALUES (?1, ?2, datetime('now'))",
1689        params![session_id, note],
1690    )?;
1691    Ok(())
1692}
1693
1694pub fn note_for(conn: &Connection, session_id: &str) -> Result<Option<String>> {
1695    Ok(conn
1696        .query_row(
1697            "SELECT note FROM notes WHERE session_id = ?1",
1698            params![session_id],
1699            |r| r.get(0),
1700        )
1701        .ok())
1702}
1703
1704/// All tags in use, with how many sessions carry each.
1705pub fn tag_counts(conn: &Connection) -> Result<Vec<(String, i64)>> {
1706    let mut stmt =
1707        conn.prepare("SELECT tag, count(*) FROM tags GROUP BY tag ORDER BY count(*) DESC, tag")?;
1708    let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?;
1709    Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
1710}
1711
1712// --- provenance: sessions <-> the code they produced ---
1713
1714/// Files a session edited or created, in the order it first touched them.
1715pub fn files_for(conn: &Connection, session_id: &str) -> Result<Vec<String>> {
1716    let mut stmt = conn.prepare("SELECT path FROM touched WHERE session_id = ?1 ORDER BY rowid")?;
1717    let rows = stmt.query_map(params![session_id], |r| r.get(0))?;
1718    Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
1719}
1720
1721/// One recorded edit to a file - the evidence layer behind `touched`. `matched_path`
1722/// is the absolute stored path the query resolved to.
1723#[derive(Debug, Serialize)]
1724pub struct FileEdit {
1725    pub session_id: String,
1726    pub kind: String,
1727    pub ts: Option<String>,
1728    pub snippet: String,
1729    pub matched_path: String,
1730}
1731
1732/// The concrete edits made to a file, newest first - the evidence for "why does
1733/// this file look like this". Resolves a relative path against the absolute one
1734/// on disk by suffix, the same match `sessions_for_file` uses, so
1735/// `edits_for("src/auth.rs")` finds `/home/me/proj/src/auth.rs`.
1736/// A `usize` limit clamped to a positive `i64` for SQLite: a value past i64::MAX
1737/// wraps negative, which SQLite reads as "unlimited" - defeating the memory bound.
1738fn sql_limit(n: usize) -> i64 {
1739    i64::try_from(n).unwrap_or(i64::MAX)
1740}
1741
1742pub fn edits_for(conn: &Connection, query: &str, limit: usize) -> Result<Vec<FileEdit>> {
1743    let q = crate::util::nfc(query.trim().trim_start_matches("./"));
1744    // Escape LIKE metacharacters so a caller-supplied `%`/`_` matches literally.
1745    let esc = q
1746        .replace('\\', "\\\\")
1747        .replace('%', "\\%")
1748        .replace('_', "\\_");
1749    let suffix = format!("%/{esc}");
1750    let mut stmt = conn.prepare(
1751        "SELECT session_id, kind, ts, snippet, path
1752         FROM edits
1753         WHERE path = ?1 OR path LIKE ?2 ESCAPE '\\'
1754            OR (length(?1) > length(path)
1755                AND substr(?1, -length(path)) = path
1756                AND substr(?1, -length(path)-1, 1) = '/')
1757         ORDER BY ts DESC, rowid DESC LIMIT ?3",
1758    )?;
1759    let rows = stmt.query_map(params![q, suffix, sql_limit(limit)], |r| {
1760        Ok(FileEdit {
1761            session_id: r.get(0)?,
1762            kind: r.get(1)?,
1763            ts: r.get(2)?,
1764            snippet: r.get(3)?,
1765            matched_path: r.get(4)?,
1766        })
1767    })?;
1768    Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
1769}
1770
1771/// A single session's edits to a file, newest first - scoped by session so no
1772/// session's edits can be starved by another's under a shared cap. Uses the SAME
1773/// suffix path match as `sessions_for_file`, so a session that edited the file
1774/// under more than one spelling (abs + relative) contributes ALL its edits, not
1775/// just the one spelling `sessions_for_file`'s GROUP BY happened to pick.
1776pub fn edits_for_session(
1777    conn: &Connection,
1778    session_id: &str,
1779    query: &str,
1780    limit: usize,
1781) -> Result<Vec<FileEdit>> {
1782    let q = crate::util::nfc(query.trim().trim_start_matches("./"));
1783    let esc = q
1784        .replace('\\', "\\\\")
1785        .replace('%', "\\%")
1786        .replace('_', "\\_");
1787    let suffix = format!("%/{esc}");
1788    let mut stmt = conn.prepare(
1789        "SELECT session_id, kind, ts, snippet, path
1790         FROM edits
1791         WHERE session_id = ?1
1792           AND (path = ?2 OR path LIKE ?3 ESCAPE '\\'
1793                OR (length(?2) > length(path)
1794                    AND substr(?2, -length(path)) = path
1795                    AND substr(?2, -length(path)-1, 1) = '/'))
1796         ORDER BY ts DESC, rowid DESC LIMIT ?4",
1797    )?;
1798    let rows = stmt.query_map(params![session_id, q, suffix, sql_limit(limit)], |r| {
1799        Ok(FileEdit {
1800            session_id: r.get(0)?,
1801            kind: r.get(1)?,
1802            ts: r.get(2)?,
1803            snippet: r.get(3)?,
1804            matched_path: r.get(4)?,
1805        })
1806    })?;
1807    Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
1808}
1809
1810/// One session's slice of a file's history: its metadata plus the edits it made
1811/// to this file.
1812#[derive(Serialize)]
1813pub struct SessionEvidence {
1814    pub session: SessionRow,
1815    pub edits: Vec<FileEdit>,
1816}
1817
1818/// A file's full evidence chain - the sessions that edited it, newest first,
1819/// each carrying its own edits. What the file-history page renders.
1820#[derive(Serialize)]
1821pub struct FileHistory {
1822    pub path: String,
1823    pub sessions: Vec<SessionEvidence>,
1824}
1825
1826/// Assemble a file's evidence chain: the sessions that touched it (with metadata,
1827/// newest first) joined to each session's recorded edits. Sessions that touched
1828/// the file but carry no structured edits (other adapters, archived sessions)
1829/// appear with an empty `edits` list - the touch is still evidence.
1830pub fn evidence_for(conn: &Connection, path: &str, limit: usize) -> Result<FileHistory> {
1831    let sessions = sessions_for_file(conn, path, limit)?;
1832    // Fetch edits PER session (scoped by session, matched by the SAME query path
1833    // so every spelling counts), so one session's edits are never starved by
1834    // another's - and `limit == 0` does no edit query.
1835    const PER_SESSION_EDIT_CAP: usize = 500;
1836    let sessions = sessions
1837        .into_iter()
1838        .map(|(session, _matched)| {
1839            let edits = edits_for_session(conn, &session.session_id, path, PER_SESSION_EDIT_CAP)?;
1840            Ok(SessionEvidence { session, edits })
1841        })
1842        .collect::<Result<Vec<_>>>()?;
1843    Ok(FileHistory {
1844        path: path.to_string(),
1845        sessions,
1846    })
1847}
1848
1849/// Sessions that touched a file, newest first - the reverse provenance link.
1850/// Matches either the exact stored path or any stored path ending in the
1851/// query, so a relative `src/auth.rs` finds `/home/me/proj/src/auth.rs`. The
1852/// matched stored path is returned alongside each session.
1853/// The file name to retry with when a full path traces to nothing.
1854///
1855/// Folders get renamed. A session recorded `~/Project/lunch/diag.py`; the folder
1856/// is `~/Project/slack` now, so tracing by the path the file has TODAY matched
1857/// nothing and reported that no session had touched it - about a file whose
1858/// whole history was in the index under its old directory.
1859///
1860/// The name is the part that survives a move. `None` when there is nothing to
1861/// fall back to: a bare name would just repeat the same miss, and a trailing
1862/// slash names a directory rather than a file.
1863pub fn basename_fallback(query: &str) -> Option<String> {
1864    let q = query.trim();
1865    if q.is_empty() || q.ends_with('/') {
1866        return None;
1867    }
1868    let (head, name) = q.rsplit_once('/')?;
1869    (!head.is_empty() && !name.is_empty()).then(|| name.to_string())
1870}
1871
1872pub fn sessions_for_file(
1873    conn: &Connection,
1874    query: &str,
1875    limit: usize,
1876) -> Result<Vec<(SessionRow, String)>> {
1877    let q = crate::util::nfc(query.trim().trim_start_matches("./"));
1878    // Escape LIKE metacharacters: the query is a caller-supplied path (the MCP
1879    // trace_file arg included), so a bare `%` must match a literal `%`, not act
1880    // as a wildcard that enumerates the whole index.
1881    let esc = q
1882        .replace('\\', "\\\\")
1883        .replace('%', "\\%")
1884        .replace('_', "\\_");
1885    let suffix = format!("%/{esc}");
1886    let mut stmt = conn.prepare(&format!(
1887        "SELECT f.session_id, f.tool, f.path, f.project, f.title, f.started, f.msg_count, f.kind,
1888                {SUMMARY_SQL}, {TAGS_SQL}, t.path, (f.archived_at IS NOT NULL)
1889         FROM touched t JOIN files f ON f.session_id = t.session_id
1890         WHERE t.path = ?1 OR t.path LIKE ?2 ESCAPE '\\'
1891            OR (length(?1) > length(t.path)
1892                AND substr(?1, -length(t.path)) = t.path
1893                AND substr(?1, -length(t.path)-1, 1) = '/')
1894         GROUP BY f.session_id
1895         ORDER BY f.started DESC, f.session_id LIMIT ?3"
1896    ))?;
1897    let rows = stmt.query_map(params![q, suffix, sql_limit(limit)], |r| {
1898        Ok((
1899            SessionRow {
1900                session_id: r.get(0)?,
1901                tool: r.get(1)?,
1902                path: r.get(2)?,
1903                project: r.get(3)?,
1904                title: r.get(4)?,
1905                started: r.get(5)?,
1906                msg_count: r.get(6)?,
1907                kind: r.get(7)?,
1908                preview: None,
1909                summary: r.get(8)?,
1910                tags: r.get(9)?,
1911                archived: r.get(11)?,
1912                account: None,
1913            },
1914            r.get::<_, String>(10)?,
1915        ))
1916    })?;
1917    let mut out = rows.collect::<rusqlite::Result<Vec<_>>>()?;
1918    crate::account_link::annotate(out.iter_mut().map(|(r, _)| r));
1919    Ok(out)
1920}
1921
1922/// Like `sessions_for_file` but returns the start/end epoch window and project
1923/// that blame's commit->session attribution needs. Matches both an exact stored
1924/// path and any stored path ending in the query suffix, so a repo-relative query
1925/// (e.g. `src/auth.rs`) catches Claude Code's absolute touched paths and Codex's
1926/// relative ones alike (NFC-normalized).
1927pub fn sessions_touching(
1928    conn: &Connection,
1929    query: &str,
1930) -> Result<Vec<crate::blame::TouchingSession>> {
1931    let q = crate::util::nfc(query.trim().trim_start_matches("./"));
1932    let suffix = format!("%/{q}");
1933    let mut stmt = conn.prepare(
1934        "SELECT f.session_id, f.tool, f.title, f.project, f.started, f.ended, (f.archived_at IS NOT NULL)
1935         FROM touched t JOIN files f ON f.session_id = t.session_id
1936         WHERE t.path = ?1 OR t.path LIKE ?2
1937         GROUP BY f.session_id",
1938    )?;
1939    let rows = stmt.query_map(params![q, suffix], |r| {
1940        let started: Option<String> = r.get(4)?;
1941        let ended: Option<String> = r.get(5)?;
1942        Ok(crate::blame::TouchingSession {
1943            session_id: r.get(0)?,
1944            tool: r.get(1)?,
1945            title: r.get(2)?,
1946            project: r.get(3)?,
1947            started: started.as_deref().and_then(to_epoch),
1948            ended: ended.as_deref().and_then(to_epoch),
1949            archived: r.get(6)?,
1950        })
1951    })?;
1952    Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
1953}
1954
1955/// Parse a stored timestamp (RFC3339) to epoch seconds; None if unparseable.
1956fn to_epoch(s: &str) -> Option<i64> {
1957    chrono::DateTime::parse_from_rfc3339(s)
1958        .ok()
1959        .map(|d| d.timestamp())
1960}
1961
1962// --- archive: serving and forgetting sessions whose originals are gone ---
1963
1964/// The display name for a tool this binary's adapter registry does not know.
1965///
1966/// A program that embeds this crate can register its own adapters, so rows in
1967/// the index may name a tool the standalone binary has never heard of. Those
1968/// rows used to print as "unknown". `Session.tool` is `&'static str`, so the
1969/// row's own string has to outlive the call: intern it once and leak it. The
1970/// set of tool names is small and fixed by the tools a user actually runs, so
1971/// the leak is bounded by that, not by the number of sessions.
1972fn interned_tool(name: &str) -> &'static str {
1973    use std::collections::HashSet;
1974    use std::sync::{Mutex, OnceLock};
1975    static NAMES: OnceLock<Mutex<HashSet<&'static str>>> = OnceLock::new();
1976    let mut names = NAMES
1977        .get_or_init(|| Mutex::new(HashSet::new()))
1978        .lock()
1979        .unwrap_or_else(std::sync::PoisonError::into_inner);
1980    if let Some(existing) = names.get(name) {
1981        return existing;
1982    }
1983    let leaked: &'static str = Box::leak(name.to_owned().into_boxed_str());
1984    names.insert(leaked);
1985    leaked
1986}
1987
1988/// Reconstruct an archived or external-adapter session from its indexed copy.
1989/// This retained transcript omits per-message timestamps and full tool I/O,
1990/// which were never indexed.
1991pub fn session_from_index(conn: &Connection, row: &SessionRow) -> Result<crate::model::Session> {
1992    use crate::model::{Message, Role};
1993    let mut stmt =
1994        conn.prepare("SELECT role, text FROM messages WHERE session_id = ?1 ORDER BY id")?;
1995    let messages: Vec<Message> = stmt
1996        .query_map(params![row.session_id], |r| {
1997            let role: String = r.get(0)?;
1998            let text: String = r.get(1)?;
1999            Ok(Message {
2000                role: match role.as_str() {
2001                    "user" => Role::User,
2002                    "assistant" => Role::Assistant,
2003                    _ => Role::Tool,
2004                },
2005                text,
2006                ts: None,
2007            })
2008        })?
2009        .collect::<rusqlite::Result<_>>()?;
2010    drop(stmt);
2011
2012    let tool = adapters::by_name(&row.tool)
2013        .map(|a| a.name())
2014        .unwrap_or_else(|| interned_tool(&row.tool));
2015    let started = row
2016        .started
2017        .as_deref()
2018        .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
2019        .map(|t| t.with_timezone(&chrono::Utc));
2020    Ok(crate::model::Session {
2021        id: row.session_id.clone(),
2022        tool,
2023        // A shared-store key carries a U+001F separator in the stored path;
2024        // render it human-readable (`<db>#<id>`) so it never leaks into show /
2025        // brief / web. Real file paths never contain U+001F, so this is a no-op
2026        // for every other adapter.
2027        path: std::path::PathBuf::from(row.path.replace('\u{1f}', "#")),
2028        project: row.project.clone(),
2029        started,
2030        ended: started,
2031        title: row.title.clone(),
2032        subagent: row.kind == "sub",
2033        messages,
2034        touched: files_for(conn, &row.session_id)?,
2035        edits: Vec::new(),
2036    })
2037}
2038
2039/// Permanently remove a session from the index AND the archive - the only way
2040/// to undo archiving for a session the user genuinely wants gone. Curation for
2041/// it (tags/notes/summary) goes too, since the session no longer exists here.
2042/// All-or-nothing: a crash mid-forget must not leave the FTS index out of sync
2043/// with `messages`, nor an `archive` row that would resurrect it on rebuild.
2044pub fn forget(conn: &mut Connection, session_id: &str) -> Result<()> {
2045    let tx = conn.transaction()?;
2046    delete_session_msgs(&tx, session_id)?;
2047    for table in [
2048        "files",
2049        "touched",
2050        "edits",
2051        "archive",
2052        "summaries",
2053        "tags",
2054        "notes",
2055    ] {
2056        tx.execute(
2057            &format!("DELETE FROM {table} WHERE session_id = ?1"),
2058            params![session_id],
2059        )?;
2060    }
2061    tx.commit()?;
2062    Ok(())
2063}
2064
2065// --- related sessions (backlinks) ---
2066
2067/// Sessions related to `session_id`. A session is most usefully "related" to
2068/// the others about the same codebase, so same-project sessions are the spine;
2069/// sessions sharing a user tag are layered on as explicit links. Both are
2070/// indexed lookups, so this is instant even over a large store - the earlier
2071/// full-text-on-title approach was both slow and noisy (generic title words
2072/// like "session" matched everything).
2073pub fn related(conn: &Connection, session_id: &str, limit: usize) -> Result<Vec<SessionRow>> {
2074    let Some(target) = resolve(conn, session_id)?.into_iter().next() else {
2075        return Ok(vec![]);
2076    };
2077    let target_tags: Vec<String> = target
2078        .tags
2079        .as_deref()
2080        .map(|t| t.split(',').map(String::from).collect())
2081        .unwrap_or_default();
2082
2083    let mut out: Vec<SessionRow> = Vec::new();
2084    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
2085    seen.insert(target.session_id.clone());
2086
2087    // 1. same project (exact), most recent first - the same-context spine.
2088    if !target.project.is_empty() {
2089        let sql = format!(
2090            "SELECT session_id, tool, path, project, title, started, msg_count, kind,
2091                    {PREVIEW_SQL}, {SUMMARY_SQL}, {TAGS_SQL}, (archived_at IS NOT NULL)
2092             FROM files f
2093             WHERE kind = 'main' AND project = ?1 AND session_id != ?2
2094             ORDER BY started DESC LIMIT ?3"
2095        );
2096        let mut stmt = conn.prepare(&sql)?;
2097        let rows = stmt.query_map(
2098            params![target.project, target.session_id, limit as i64 + 1],
2099            map_row,
2100        )?;
2101        for row in rows {
2102            let row = row?;
2103            if seen.insert(row.session_id.clone()) {
2104                out.push(row);
2105            }
2106        }
2107    }
2108
2109    // 2. sessions that edited a file this one also edited - the strongest
2110    //    signal that two sessions are about the same work, and one no other
2111    //    session viewer has, since it comes from the provenance link.
2112    if out.len() < limit {
2113        let sql = format!(
2114            "SELECT DISTINCT f.session_id, f.tool, f.path, f.project, f.title, f.started,
2115                    f.msg_count, f.kind, {PREVIEW_SQL}, {SUMMARY_SQL}, {TAGS_SQL}, (f.archived_at IS NOT NULL)
2116             FROM touched a
2117             JOIN touched b ON a.path = b.path AND b.session_id != a.session_id
2118             JOIN files f ON f.session_id = b.session_id
2119             WHERE a.session_id = ?1 AND f.kind = 'main'
2120             ORDER BY f.started DESC LIMIT 50"
2121        );
2122        let mut stmt = conn.prepare(&sql)?;
2123        let rows = stmt.query_map(params![target.session_id], map_row)?;
2124        for row in rows {
2125            let row = row?;
2126            if seen.insert(row.session_id.clone()) {
2127                out.push(row);
2128                if out.len() >= limit {
2129                    break;
2130                }
2131            }
2132        }
2133    }
2134
2135    // 3. sessions that share a tag with the target (explicit wiki links).
2136    if out.len() < limit && !target_tags.is_empty() {
2137        let placeholders = target_tags
2138            .iter()
2139            .map(|_| "?")
2140            .collect::<Vec<_>>()
2141            .join(",");
2142        let sql = format!(
2143            "SELECT DISTINCT f.session_id, f.tool, f.path, f.project, f.title, f.started,
2144                    f.msg_count, f.kind, {PREVIEW_SQL}, {SUMMARY_SQL}, {TAGS_SQL}, (f.archived_at IS NOT NULL)
2145             FROM files f JOIN tags t ON t.session_id = f.session_id
2146             WHERE f.kind = 'main' AND t.tag IN ({placeholders})
2147             ORDER BY f.started DESC LIMIT 50"
2148        );
2149        let mut stmt = conn.prepare(&sql)?;
2150        let rows = stmt.query_map(rusqlite::params_from_iter(&target_tags), map_row)?;
2151        for row in rows {
2152            let row = row?;
2153            if seen.insert(row.session_id.clone()) {
2154                out.push(row);
2155                if out.len() >= limit {
2156                    break;
2157                }
2158            }
2159        }
2160    }
2161
2162    out.truncate(limit);
2163    crate::account_link::annotate(out.iter_mut());
2164    Ok(out)
2165}
2166
2167/// Row mapper for the full session-list column set.
2168fn map_row(r: &rusqlite::Row) -> rusqlite::Result<SessionRow> {
2169    Ok(SessionRow {
2170        session_id: r.get(0)?,
2171        tool: r.get(1)?,
2172        path: r.get(2)?,
2173        project: r.get(3)?,
2174        title: r.get(4)?,
2175        started: r.get(5)?,
2176        msg_count: r.get(6)?,
2177        kind: r.get(7)?,
2178        preview: r.get(8)?,
2179        summary: r.get(9)?,
2180        tags: r.get(10)?,
2181        archived: r.get(11)?,
2182        account: None,
2183    })
2184}
2185
2186// --- session engineering: management views ---
2187
2188pub struct ProjectRow {
2189    pub project: String,
2190    pub sessions: i64,
2191    pub messages: i64,
2192    pub oldest: Option<String>,
2193    pub newest: Option<String>,
2194}
2195
2196/// One row per project (a wiki "category" page), busiest first.
2197pub fn projects(conn: &Connection) -> Result<Vec<ProjectRow>> {
2198    let mut stmt = conn.prepare(
2199        "SELECT project, count(*), coalesce(sum(msg_count), 0), min(started), max(started)
2200         FROM files WHERE kind = 'main' AND project != ''
2201         GROUP BY project ORDER BY count(*) DESC, max(started) DESC",
2202    )?;
2203    let rows = stmt.query_map([], |r| {
2204        Ok(ProjectRow {
2205            project: r.get(0)?,
2206            sessions: r.get(1)?,
2207            messages: r.get(2)?,
2208            oldest: r.get(3)?,
2209            newest: r.get(4)?,
2210        })
2211    })?;
2212    Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
2213}
2214
2215pub struct Stats {
2216    pub per_tool: Vec<(String, i64, i64)>, // tool, sessions, messages
2217    pub per_month: Vec<(String, i64)>,     // YYYY-MM, sessions
2218    pub total_sessions: i64,
2219    pub total_messages: i64,
2220    pub projects: i64,
2221    pub tags: i64,
2222    pub summarized: i64,
2223    /// Distinct files linked to at least one session (provenance coverage).
2224    pub files: i64,
2225    /// Sessions kept after the tool deleted their originals (archive mode).
2226    pub archived: i64,
2227}
2228
2229pub fn stats(conn: &Connection) -> Result<Stats> {
2230    let mut per_tool_stmt = conn.prepare(
2231        "SELECT tool, count(*), coalesce(sum(msg_count),0) FROM files WHERE kind='main'
2232         GROUP BY tool ORDER BY count(*) DESC",
2233    )?;
2234    let per_tool = per_tool_stmt
2235        .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
2236        .collect::<rusqlite::Result<Vec<_>>>()?;
2237
2238    let mut per_month_stmt = conn.prepare(
2239        "SELECT substr(started,1,7) AS ym, count(*) FROM files
2240         WHERE kind='main' AND started IS NOT NULL
2241         GROUP BY ym ORDER BY ym DESC LIMIT 12",
2242    )?;
2243    let per_month = per_month_stmt
2244        .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
2245        .collect::<rusqlite::Result<Vec<_>>>()?;
2246
2247    let one = |sql: &str| -> Result<i64> { Ok(conn.query_row(sql, [], |r| r.get(0))?) };
2248    Ok(Stats {
2249        per_tool,
2250        per_month,
2251        total_sessions: one("SELECT count(*) FROM files WHERE kind='main'")?,
2252        total_messages: one("SELECT coalesce(sum(msg_count),0) FROM files WHERE kind='main'")?,
2253        projects: one(
2254            "SELECT count(DISTINCT project) FROM files WHERE kind='main' AND project!=''",
2255        )?,
2256        tags: one("SELECT count(DISTINCT tag) FROM tags")?,
2257        summarized: one("SELECT count(*) FROM summaries")?,
2258        files: one("SELECT count(DISTINCT path) FROM touched")?,
2259        archived: one("SELECT count(*) FROM files WHERE archived_at IS NOT NULL")?,
2260    })
2261}
2262
2263#[cfg(test)]
2264mod native_id_tests {
2265    use super::*;
2266
2267    // Realistic native store paths: a Codex rollout (uuid trails a timestamp) and
2268    // a Claude Code transcript (uuid IS the filename), plus a subagent transcript.
2269    const CODEX: &str = "/home/u/.codex/sessions/2025/05/13/rollout-2025-05-13T18-19-30-0a000000-0000-4000-8000-000000000001.jsonl";
2270    const CODEX_UUID: &str = "0a000000-0000-4000-8000-000000000001";
2271    const CLAUDE: &str =
2272        "/home/u/.claude/projects/-home-u-proj/1b111111-1111-4111-8111-111111111111.jsonl";
2273    const CLAUDE_UUID: &str = "1b111111-1111-4111-8111-111111111111";
2274    const SUBAGENT: &str = "/home/u/.claude/projects/-x/1b111111-1111-4111-8111-111111111111/subagents/agent-2c222222-2222-4222-8222-222222222222.jsonl";
2275    const SUBAGENT_UUID: &str = "2c222222-2222-4222-8222-222222222222";
2276
2277    fn mem() -> Connection {
2278        let c = Connection::open_in_memory().unwrap();
2279        c.execute_batch(
2280            "CREATE TABLE files(
2281                 path TEXT PRIMARY KEY, mtime INTEGER NOT NULL DEFAULT 0,
2282                 size INTEGER NOT NULL DEFAULT 0, session_id TEXT NOT NULL,
2283                 tool TEXT NOT NULL, project TEXT NOT NULL DEFAULT '',
2284                 title TEXT NOT NULL DEFAULT '', started TEXT, ended TEXT,
2285                 msg_count INTEGER NOT NULL DEFAULT 0,
2286                 kind TEXT NOT NULL DEFAULT 'main', archived_at TEXT);
2287             CREATE TABLE summaries(session_id TEXT PRIMARY KEY, summary TEXT NOT NULL, created TEXT NOT NULL);
2288             CREATE TABLE tags(session_id TEXT NOT NULL, tag TEXT NOT NULL, PRIMARY KEY(session_id, tag));",
2289        )
2290        .unwrap();
2291        c
2292    }
2293
2294    /// Insert a files row and return the sessionwiki short id it was keyed by.
2295    fn seed(c: &Connection, tool: &str, path: &str) -> String {
2296        let sid = crate::util::short_id(path);
2297        c.execute(
2298            "INSERT INTO files(path, session_id, tool, msg_count) VALUES(?1,?2,?3,3)",
2299            params![path, sid, tool],
2300        )
2301        .unwrap();
2302        sid
2303    }
2304
2305    #[test]
2306    fn native_id_extracted_per_tool() {
2307        assert_eq!(native_id_of(CODEX).as_deref(), Some(CODEX_UUID));
2308        assert_eq!(native_id_of(CLAUDE).as_deref(), Some(CLAUDE_UUID));
2309        // A subagent transcript resolves to its OWN uuid (scanned from the file
2310        // name), not the parent uuid in the directory above it.
2311        assert_eq!(native_id_of(SUBAGENT).as_deref(), Some(SUBAGENT_UUID));
2312        // Files with no uuid in the name have no native id (not every tool keys
2313        // sessions this way).
2314        assert_eq!(native_id_of("/x/opencode.db#session-42"), None);
2315    }
2316
2317    #[test]
2318    fn native_id_uppercase_is_normalized_to_lowercase() {
2319        let p = "/a/b/AB000000-0000-4000-8000-0000000000FF.jsonl";
2320        assert_eq!(
2321            native_id_of(p).as_deref(),
2322            Some("ab000000-0000-4000-8000-0000000000ff")
2323        );
2324    }
2325
2326    #[test]
2327    fn resolve_by_full_native_id() {
2328        let c = mem();
2329        let sid = seed(&c, "codex", CODEX);
2330        let hits = resolve(&c, CODEX_UUID).unwrap();
2331        assert_eq!(hits.len(), 1, "full native uuid resolves");
2332        assert_eq!(hits[0].session_id, sid);
2333    }
2334
2335    #[test]
2336    fn resolve_by_native_prefix_hex_and_dashed() {
2337        let c = mem();
2338        let sid = seed(&c, "claude-code", CLAUDE);
2339        // First-group (8 hex) prefix.
2340        let a = resolve(&c, "1b111111").unwrap();
2341        assert_eq!(a.len(), 1, "8-hex native prefix resolves");
2342        assert_eq!(a[0].session_id, sid);
2343        // Dashed prefix past the first group.
2344        let b = resolve(&c, "1b111111-1111").unwrap();
2345        assert_eq!(b.len(), 1, "dashed native prefix resolves");
2346        assert_eq!(b[0].session_id, sid);
2347    }
2348
2349    #[test]
2350    fn resolve_still_matches_short_id_unchanged() {
2351        let c = mem();
2352        let sid = seed(&c, "codex", CODEX);
2353        // Full short id and a short-id prefix both resolve (existing behavior).
2354        assert_eq!(resolve(&c, &sid).unwrap().len(), 1);
2355        assert_eq!(resolve(&c, &sid[..6]).unwrap()[0].session_id, sid);
2356    }
2357
2358    #[test]
2359    fn short_id_lookup_does_not_run_the_native_scan() {
2360        // A 12-hex, dash-free string is short-id-shaped and must never trigger a
2361        // native scan (which would be a needless path scan and could add a
2362        // spurious collision). Guard the gate that governs it directly.
2363        assert!(!looks_like_native_prefix("abcdef012345"));
2364        // ... while genuine native shapes do pass.
2365        assert!(looks_like_native_prefix("0a000000"));
2366        assert!(looks_like_native_prefix(
2367            "0a000000-0000-4000-8000-000000000001"
2368        ));
2369        assert!(looks_like_native_prefix("0a000000-0000"));
2370        // Non-hex, too short, or empty never look native.
2371        assert!(!looks_like_native_prefix("zzz"));
2372        assert!(!looks_like_native_prefix("a1"));
2373        assert!(!looks_like_native_prefix(""));
2374    }
2375
2376    #[test]
2377    fn native_prefix_never_hides_an_existing_short_id_match() {
2378        // A plain-hex prefix that already matched a short id stays short-id-only:
2379        // even if some other session's native uuid also starts with those hex
2380        // digits, the plain-hex query keeps the established (short-id) result.
2381        let c = mem();
2382        // Seed a session whose SHORT id begins with the same 8 hex as another
2383        // session's native uuid, and the native one too.
2384        let native_path =
2385            "/home/u/.codex/sessions/2025/01/01/rollout-2025-01-01T00-00-00-deadbeef-0000-4000-8000-000000000009.jsonl";
2386        seed(&c, "codex", native_path);
2387        // Force a files row whose short id we control to start with "deadbeef".
2388        c.execute(
2389            "INSERT INTO files(path, session_id, tool, msg_count) VALUES('/synthetic', 'deadbeef1234', 'codex', 1)",
2390            [],
2391        )
2392        .unwrap();
2393        let hits = resolve(&c, "deadbeef").unwrap();
2394        // Short id matched, so the query stays short-id-only: exactly the one row.
2395        assert_eq!(hits.len(), 1);
2396        assert_eq!(hits[0].session_id, "deadbeef1234");
2397    }
2398
2399    #[test]
2400    fn session_row_serializes_native_id_not_path() {
2401        let row = SessionRow {
2402            session_id: "abc123def456".into(),
2403            tool: "codex".into(),
2404            path: CODEX.into(),
2405            project: "proj".into(),
2406            title: "t".into(),
2407            started: None,
2408            msg_count: 3,
2409            kind: "main".into(),
2410            preview: None,
2411            summary: None,
2412            tags: None,
2413            archived: false,
2414            account: None,
2415        };
2416        let v = serde_json::to_value(&row).unwrap();
2417        assert_eq!(v["id"], "abc123def456");
2418        assert_eq!(v["native_id"], CODEX_UUID, "native_id present in JSON");
2419        assert!(
2420            v.get("path").is_none(),
2421            "the absolute path is never serialized"
2422        );
2423        // A pathless/uuid-less session serializes native_id as null, never a guess.
2424        let mut row2 = row;
2425        row2.path = "/x/opencode.db#s1".into();
2426        let v2 = serde_json::to_value(&row2).unwrap();
2427        assert!(v2["native_id"].is_null());
2428    }
2429}
2430
2431#[cfg(test)]
2432mod edits_tests {
2433    use super::*;
2434
2435    fn conn_with_edits() -> Connection {
2436        let c = Connection::open_in_memory().unwrap();
2437        c.execute_batch(
2438            "CREATE TABLE edits(session_id TEXT NOT NULL, path TEXT NOT NULL,
2439                 kind TEXT NOT NULL, ts TEXT, snippet TEXT NOT NULL);
2440             CREATE INDEX idx_edits_path ON edits(path);",
2441        )
2442        .unwrap();
2443        c
2444    }
2445
2446    fn add(c: &Connection, sid: &str, path: &str, kind: &str, ts: &str, snip: &str) {
2447        c.execute(
2448            "INSERT INTO edits(session_id, path, kind, ts, snippet) VALUES(?1,?2,?3,?4,?5)",
2449            params![sid, path, kind, ts, snip],
2450        )
2451        .unwrap();
2452    }
2453
2454    #[test]
2455    fn edits_for_returns_a_files_edits_by_suffix_newest_first() {
2456        let c = conn_with_edits();
2457        add(
2458            &c,
2459            "s1",
2460            "/home/me/proj/src/auth.rs",
2461            "edit",
2462            "2026-06-08T10:00:00Z",
2463            "let a = 1;",
2464        );
2465        add(
2466            &c,
2467            "s2",
2468            "/home/me/proj/src/auth.rs",
2469            "write",
2470            "2026-06-09T10:00:00Z",
2471            "fn main() {}",
2472        );
2473        add(
2474            &c,
2475            "s3",
2476            "/home/me/proj/src/other.rs",
2477            "edit",
2478            "2026-06-10T10:00:00Z",
2479            "nope",
2480        );
2481
2482        // A relative path finds the absolute stored path by suffix, like `trace`.
2483        let hits = edits_for(&c, "src/auth.rs", 50).unwrap();
2484
2485        assert_eq!(hits.len(), 2, "both auth.rs edits, not other.rs");
2486        assert_eq!(hits[0].kind, "write", "newest edit first");
2487        assert!(hits[0].snippet.contains("fn main()"));
2488        assert_eq!(hits[1].kind, "edit");
2489    }
2490
2491    #[test]
2492    fn index_one_persists_a_sessions_edits() {
2493        use crate::model::{EditEvent, EditKind, Session};
2494        let mut c = Connection::open_in_memory().unwrap();
2495        create_cache_schema(&c).unwrap();
2496
2497        let session = Session {
2498            id: "sx".into(),
2499            tool: "claude-code",
2500            path: "/store/sx.jsonl".into(),
2501            project: "/proj".into(),
2502            started: None,
2503            ended: None,
2504            title: "t".into(),
2505            subagent: false,
2506            messages: vec![],
2507            touched: vec!["/proj/src/auth.rs".into()],
2508            edits: vec![EditEvent {
2509                path: "/proj/src/auth.rs".into(),
2510                kind: EditKind::Write,
2511                snippet: "fn main() {}".into(),
2512                ts: None,
2513            }],
2514        };
2515
2516        let tx = c.transaction().unwrap();
2517        index_one(&tx, &session, "/store/sx.jsonl", 0, 0).unwrap();
2518        tx.commit().unwrap();
2519
2520        let hits = edits_for(&c, "src/auth.rs", 50).unwrap();
2521        assert_eq!(hits.len(), 1, "the session's one edit was persisted");
2522        assert_eq!(hits[0].session_id, "sx");
2523        assert_eq!(hits[0].kind, "write");
2524        assert!(hits[0].snippet.contains("fn main()"));
2525    }
2526
2527    #[test]
2528    fn forget_removes_a_sessions_edits() {
2529        let mut c = Connection::open_in_memory().unwrap();
2530        create_cache_schema(&c).unwrap();
2531        c.execute(
2532            "INSERT INTO files(path, session_id, tool, mtime, size) VALUES('/store/s.jsonl','s1','claude-code',0,0)",
2533            [],
2534        )
2535        .unwrap();
2536        c.execute(
2537            "INSERT INTO edits(session_id,path,kind,ts,snippet) VALUES('s1','/proj/a.rs','write',NULL,'x')",
2538            [],
2539        )
2540        .unwrap();
2541        assert_eq!(edits_for(&c, "a.rs", 10).unwrap().len(), 1);
2542
2543        forget(&mut c, "s1").unwrap();
2544
2545        assert!(
2546            edits_for(&c, "a.rs", 10).unwrap().is_empty(),
2547            "forget must remove the session's edits, not orphan them"
2548        );
2549    }
2550
2551    #[test]
2552    fn edits_for_is_deterministic_when_timestamps_tie() {
2553        let c = conn_with_edits();
2554        add(&c, "s1", "/p/a.rs", "edit", "2026-01-01T00:00:00Z", "first");
2555        add(
2556            &c,
2557            "s2",
2558            "/p/a.rs",
2559            "write",
2560            "2026-01-01T00:00:00Z",
2561            "second",
2562        );
2563        let hits = edits_for(&c, "a.rs", 10).unwrap();
2564        // Equal ts -> deterministic tie-break by rowid DESC (latest insert first).
2565        assert_eq!(hits[0].snippet, "second");
2566        assert_eq!(hits[1].snippet, "first");
2567    }
2568
2569    #[test]
2570    fn edits_for_session_returns_only_that_sessions_edits() {
2571        let c = conn_with_edits();
2572        add(
2573            &c,
2574            "s1",
2575            "/p/a.rs",
2576            "edit",
2577            "2026-01-01T00:00:00Z",
2578            "s1-edit",
2579        );
2580        add(
2581            &c,
2582            "s2",
2583            "/p/a.rs",
2584            "write",
2585            "2026-02-01T00:00:00Z",
2586            "s2-edit",
2587        );
2588        // Scoped by exact session + path, so one session's edits can never be
2589        // starved by another's under a shared cap.
2590        let hits = edits_for_session(&c, "s1", "/p/a.rs", 10).unwrap();
2591        assert_eq!(hits.len(), 1);
2592        assert_eq!(hits[0].snippet, "s1-edit");
2593    }
2594
2595    #[test]
2596    fn edits_for_session_matches_every_spelling_of_the_path() {
2597        let c = conn_with_edits();
2598        // One session edited the file under two path spellings (abs + relative).
2599        add(
2600            &c,
2601            "s1",
2602            "/proj/src/auth.rs",
2603            "edit",
2604            "2026-01-01T00:00:00Z",
2605            "abs",
2606        );
2607        add(
2608            &c,
2609            "s1",
2610            "src/auth.rs",
2611            "write",
2612            "2026-01-02T00:00:00Z",
2613            "rel",
2614        );
2615        add(
2616            &c,
2617            "s2",
2618            "/other/auth.rs",
2619            "edit",
2620            "2026-01-03T00:00:00Z",
2621            "different-file",
2622        );
2623        // Suffix match scoped to s1 must catch BOTH spellings - never miss edits
2624        // just because sessions_for_file's GROUP BY picked the other spelling.
2625        let hits = edits_for_session(&c, "s1", "src/auth.rs", 10).unwrap();
2626        assert_eq!(hits.len(), 2, "all of s1's edits to the file, any spelling");
2627    }
2628
2629    #[test]
2630    fn index_redacts_secrets_in_messages_and_edit_snippets() {
2631        use crate::model::{EditEvent, EditKind, Message, Role, Session};
2632        let mut c = Connection::open_in_memory().unwrap();
2633        create_cache_schema(&c).unwrap();
2634        let session = Session {
2635            id: "sx".into(),
2636            tool: "claude-code",
2637            path: "/s.jsonl".into(),
2638            project: "/p".into(),
2639            started: None,
2640            ended: None,
2641            title: "title with AKIAIOSFODNN7EXAMPLE in it".into(),
2642            subagent: false,
2643            messages: vec![Message {
2644                role: Role::User,
2645                text: "my key is sk-abcdef012345678901234567890123 ok".into(),
2646                ts: None,
2647            }],
2648            touched: vec!["/p/a.rs".into()],
2649            edits: vec![EditEvent {
2650                path: "/p/a.rs".into(),
2651                kind: EditKind::Write,
2652                snippet: "const T = \"ghp_016C7f9aBcDeFgHiJkLmNoPqRsTuVwXyZ012\";".into(),
2653                ts: None,
2654            }],
2655        };
2656        let tx = c.transaction().unwrap();
2657        index_one(&tx, &session, "/s.jsonl", 0, 0).unwrap();
2658        tx.commit().unwrap();
2659
2660        let msg: String = c
2661            .query_row("SELECT text FROM messages WHERE session_id='sx'", [], |r| {
2662                r.get(0)
2663            })
2664            .unwrap();
2665        assert!(!msg.contains("sk-abcdef"), "message secret redacted: {msg}");
2666        assert!(msg.contains("[redacted:openai]"), "{msg}");
2667        let snip = edits_for(&c, "a.rs", 10).unwrap()[0].snippet.clone();
2668        assert!(!snip.contains("ghp_016C"), "edit secret redacted: {snip}");
2669        assert!(snip.contains("[redacted:github]"), "{snip}");
2670        // Title is durable (copied into archives) - must be redacted too.
2671        let title: String = c
2672            .query_row("SELECT title FROM files WHERE session_id='sx'", [], |r| {
2673                r.get(0)
2674            })
2675            .unwrap();
2676        assert!(
2677            !title.contains("AKIAIOSFODNN7EXAMPLE"),
2678            "title secret redacted: {title}"
2679        );
2680        // LLM synopsis can echo a secret into the durable summaries table.
2681        set_summary(
2682            &c,
2683            "sx",
2684            "we set sk-abcdef012345678901234567890123 as the key",
2685        )
2686        .unwrap();
2687        let sum: String = c
2688            .query_row(
2689                "SELECT summary FROM summaries WHERE session_id='sx'",
2690                [],
2691                |r| r.get(0),
2692            )
2693            .unwrap();
2694        assert!(!sum.contains("sk-abcdef"), "summary secret redacted: {sum}");
2695    }
2696
2697    #[test]
2698    fn evidence_for_assembles_sessions_with_their_edits_newest_first() {
2699        use crate::model::{EditEvent, EditKind, Session};
2700        let mut c = Connection::open_in_memory().unwrap();
2701        create_cache_schema(&c).unwrap();
2702
2703        for (sid, store, started, kind, snip) in [
2704            (
2705                "old",
2706                "/store/old.jsonl",
2707                "2026-06-01T00:00:00Z",
2708                EditKind::Edit,
2709                "v1",
2710            ),
2711            (
2712                "new",
2713                "/store/new.jsonl",
2714                "2026-06-09T00:00:00Z",
2715                EditKind::Write,
2716                "v2",
2717            ),
2718        ] {
2719            let started = chrono::DateTime::parse_from_rfc3339(started)
2720                .unwrap()
2721                .with_timezone(&chrono::Utc);
2722            let session = Session {
2723                id: sid.into(),
2724                tool: "claude-code",
2725                path: store.into(),
2726                project: "/proj".into(),
2727                started: Some(started),
2728                ended: None,
2729                title: format!("{sid} title"),
2730                subagent: false,
2731                messages: vec![],
2732                touched: vec!["/proj/src/a.rs".into()],
2733                edits: vec![EditEvent {
2734                    path: "/proj/src/a.rs".into(),
2735                    kind,
2736                    snippet: snip.into(),
2737                    ts: None,
2738                }],
2739            };
2740            let tx = c.transaction().unwrap();
2741            index_one(&tx, &session, store, 0, 0).unwrap();
2742            tx.commit().unwrap();
2743        }
2744
2745        let hist = evidence_for(&c, "src/a.rs", 50).unwrap();
2746        assert_eq!(hist.path, "src/a.rs");
2747        assert_eq!(hist.sessions.len(), 2, "both sessions that edited the file");
2748        assert_eq!(
2749            hist.sessions[0].session.session_id, "new",
2750            "newest session first"
2751        );
2752        assert_eq!(hist.sessions[0].edits.len(), 1);
2753        assert_eq!(hist.sessions[0].edits[0].snippet, "v2");
2754        assert_eq!(hist.sessions[1].session.session_id, "old");
2755    }
2756}
2757
2758#[cfg(test)]
2759mod moved_file_tests {
2760    use super::*;
2761
2762    /// Folders get renamed. A session recorded `~/Project/lunch/diag.py`; the
2763    /// folder is now `~/Project/slack`, so tracing the file by the path it has
2764    /// TODAY found nothing and said "no session touched a file matching" - about
2765    /// a file whose whole history was sitting in the index under its old name.
2766    ///
2767    /// The basename is the part that survives a move, so a full path that finds
2768    /// nothing falls back to it, and the caller is told the match was by name so
2769    /// it can say the folder has moved.
2770    #[test]
2771    fn a_renamed_folder_still_traces_by_file_name() {
2772        assert_eq!(
2773            basename_fallback("/Users/b/Project/slack/diag.py").as_deref(),
2774            Some("diag.py"),
2775            "a full path falls back to its file name"
2776        );
2777        // Already a bare name: there is nothing to fall back to, and retrying
2778        // the same query would just repeat the miss.
2779        assert_eq!(basename_fallback("diag.py"), None);
2780        assert_eq!(basename_fallback(""), None);
2781        // A trailing slash names a directory, not a file to trace.
2782        assert_eq!(basename_fallback("/Users/b/Project/slack/"), None);
2783    }
2784}
2785
2786#[cfg(test)]
2787mod legacy_migration_tests {
2788    use super::*;
2789
2790    /// The migration looked for the old directories under `dirs::data_dir()`
2791    /// no matter where the index was actually going, and then RENAMED what it
2792    /// found into that destination. With `SESSIONWIKI_DATA` pointed at a temp
2793    /// dir - which eight test files do - a machine still holding
2794    /// `~/.local/share/sessiondex` would have had its real index moved into
2795    /// that temp dir and deleted with it. The comment above says the tags,
2796    /// notes and summaries in there are not rebuildable.
2797    #[test]
2798    fn a_legacy_index_is_only_looked_for_beside_the_new_one() {
2799        let under_home = std::path::Path::new("/home/someone/.local/share/sessionwiki");
2800        let got = legacy_candidates(under_home);
2801        assert_eq!(
2802            got,
2803            vec![
2804                std::path::PathBuf::from("/home/someone/.local/share/sessiondex"),
2805                std::path::PathBuf::from("/home/someone/.local/share/session-atlas"),
2806            ],
2807            "the normal case must keep working"
2808        );
2809
2810        let redirected = std::path::Path::new("/tmp/sessionwiki-test-xyz");
2811        for c in legacy_candidates(redirected) {
2812            assert!(
2813                c.starts_with("/tmp"),
2814                "a redirected run reached outside its own tree: {}",
2815                c.display()
2816            );
2817        }
2818    }
2819
2820    #[test]
2821    fn a_destination_with_no_parent_offers_nothing_to_migrate() {
2822        assert!(legacy_candidates(std::path::Path::new("/")).is_empty());
2823    }
2824}
2825
2826#[cfg(test)]
2827mod embedder_hook_tests {
2828    use super::*;
2829    use crate::adapters::{Adapter, Discovered, Store};
2830    use crate::model::{Message, Role, Session};
2831    use std::path::Path;
2832
2833    /// A shared-store adapter an embedding program could supply: it lists only
2834    /// the keys under its own prefix and reconciles only that prefix.
2835    struct FakeStore {
2836        keys: Vec<(String, i64)>,
2837        scope: Option<String>,
2838    }
2839
2840    impl Adapter for FakeStore {
2841        fn name(&self) -> &'static str {
2842            "mjolnir"
2843        }
2844        fn root(&self) -> Option<PathBuf> {
2845            // Any existing directory: the reconciliation guard only asks
2846            // whether the store root is still there.
2847            Some(std::env::current_dir().unwrap())
2848        }
2849        fn discover(&self) -> Discovered {
2850            Discovered {
2851                files: Vec::new(),
2852                had_error: false,
2853            }
2854        }
2855        fn parse(&self, _path: &Path) -> Result<Session> {
2856            anyhow::bail!("shared store")
2857        }
2858        fn store(&self) -> Option<Store> {
2859            Some(Store {
2860                keys: self.keys.clone(),
2861                files: Vec::new(),
2862                had_error: false,
2863            })
2864        }
2865        fn parse_key(&self, key: &str) -> Result<Session> {
2866            Ok(Session {
2867                id: key.rsplit('/').next().unwrap().to_string(),
2868                tool: "mjolnir",
2869                path: PathBuf::from(key),
2870                project: "/proj".into(),
2871                started: None,
2872                ended: None,
2873                title: "a restored session".into(),
2874                subagent: false,
2875                messages: vec![Message {
2876                    role: Role::User,
2877                    text: "make the tests green".into(),
2878                    ts: None,
2879                }],
2880                touched: vec![],
2881                edits: vec![],
2882            })
2883        }
2884        fn reconcile_scope(&self) -> Option<String> {
2885            self.scope.clone()
2886        }
2887    }
2888
2889    fn insert_live_row(c: &Connection, path: &str, sid: &str) {
2890        c.execute(
2891            "INSERT INTO files(path, session_id, tool, mtime, size, project, title, msg_count, kind)
2892             VALUES(?1, ?2, 'mjolnir', 0, 0, '/proj', 't', 1, 'session')",
2893            params![path, sid],
2894        )
2895        .unwrap();
2896        c.execute(
2897            "INSERT INTO messages(session_id, role, text) VALUES(?1,'user','hello')",
2898            params![sid],
2899        )
2900        .unwrap();
2901    }
2902
2903    fn archived_at(c: &Connection, path: &str) -> Option<String> {
2904        c.query_row(
2905            "SELECT archived_at FROM files WHERE path = ?1",
2906            params![path],
2907            |r| r.get(0),
2908        )
2909        .unwrap()
2910    }
2911
2912    /// Two installations of one tool share a tool name and one index. A sync
2913    /// driven by the first must not archive the second's rows just because it
2914    /// never lists them.
2915    #[test]
2916    fn reconcile_scope_limits_archiving_to_the_adapters_own_keys() {
2917        let mut c = Connection::open_in_memory().unwrap();
2918        create_cache_schema(&c).unwrap();
2919        insert_live_row(&c, "/data/one/sess-a", "sa");
2920        insert_live_row(&c, "/data/two/sess-b", "sb");
2921
2922        let adapters: Vec<Box<dyn Adapter>> = vec![Box::new(FakeStore {
2923            keys: Vec::new(),
2924            scope: Some("/data/one/".to_string()),
2925        })];
2926        sync_with(&mut c, &adapters, None).unwrap();
2927
2928        assert!(
2929            archived_at(&c, "/data/one/sess-a").is_some(),
2930            "the in-scope row the adapter no longer lists must be archived"
2931        );
2932        assert!(
2933            archived_at(&c, "/data/two/sess-b").is_none(),
2934            "the other installation's row must be left live"
2935        );
2936    }
2937
2938    /// Without a scope the adapter still speaks for every row of its tool.
2939    #[test]
2940    fn an_unscoped_adapter_still_archives_every_row_of_its_tool() {
2941        let mut c = Connection::open_in_memory().unwrap();
2942        create_cache_schema(&c).unwrap();
2943        insert_live_row(&c, "/data/one/sess-a", "sa");
2944        insert_live_row(&c, "/data/two/sess-b", "sb");
2945
2946        let adapters: Vec<Box<dyn Adapter>> = vec![Box::new(FakeStore {
2947            keys: Vec::new(),
2948            scope: None,
2949        })];
2950        sync_with(&mut c, &adapters, None).unwrap();
2951
2952        assert!(archived_at(&c, "/data/one/sess-a").is_some());
2953        assert!(archived_at(&c, "/data/two/sess-b").is_some());
2954    }
2955
2956    /// The point of `sync_with`: an embedding program indexes its own sessions
2957    /// with its own adapter, which is in no built-in registry.
2958    #[test]
2959    fn sync_with_indexes_a_session_from_a_supplied_adapter() {
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_eq!(rows.len(), 1, "the supplied adapter's session was indexed");
2971        assert_eq!(rows[0].session_id, "sess-a");
2972        assert_eq!(rows[0].title, "a restored session");
2973        assert_eq!(rows[0].msg_count, 1);
2974        assert!(!rows[0].archived, "a listed session stays live");
2975    }
2976
2977    /// A row whose tool only an embedder's adapter knows still shows that
2978    /// tool's name, rather than the "unknown" the registry lookup used to give.
2979    #[test]
2980    fn a_session_keeps_its_own_tool_name_when_no_adapter_is_registered() {
2981        let mut c = Connection::open_in_memory().unwrap();
2982        create_cache_schema(&c).unwrap();
2983
2984        let adapters: Vec<Box<dyn Adapter>> = vec![Box::new(FakeStore {
2985            keys: vec![("/data/one/sess-a".to_string(), 42)],
2986            scope: Some("/data/one/".to_string()),
2987        })];
2988        sync_with(&mut c, &adapters, None).unwrap();
2989
2990        let rows = recent(&c, 10, Some("mjolnir"), None, None, false).unwrap();
2991        assert!(
2992            crate::adapters::by_name("mjolnir").is_none(),
2993            "the built-in registry must not know this tool, or the test proves nothing"
2994        );
2995        let session = session_from_index(&c, &rows[0]).unwrap();
2996        assert_eq!(session.tool, "mjolnir");
2997    }
2998
2999    /// The interner hands back one leaked string per name, however often it is
3000    /// asked, so the leak is bounded by the number of tool names.
3001    #[test]
3002    fn interning_a_tool_name_twice_yields_the_same_string() {
3003        let first = interned_tool("a-tool-no-adapter-knows");
3004        let second = interned_tool(&String::from("a-tool-no-adapter-knows"));
3005        assert_eq!(first, "a-tool-no-adapter-knows");
3006        assert!(std::ptr::eq(first, second));
3007    }
3008}