Skip to main content

pi/
session_index.rs

1//! SQLite session index (derived from JSONL sessions).
2
3use crate::config::Config;
4use crate::error::{Error, Result};
5use crate::session::{Session, SessionEntry, SessionHeader};
6use fs4::fs_std::FileExt;
7use serde::Deserialize;
8use sqlmodel_core::Value;
9use sqlmodel_sqlite::{OpenFlags, SqliteConfig, SqliteConnection};
10use std::borrow::Borrow;
11use std::fs::{self, File};
12use std::io::{BufRead, BufReader};
13use std::path::{Path, PathBuf};
14use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
15
16#[derive(Debug, Clone)]
17pub struct SessionMeta {
18    pub path: String,
19    pub id: String,
20    pub cwd: String,
21    pub timestamp: String,
22    pub message_count: u64,
23    pub last_modified_ms: i64,
24    pub size_bytes: u64,
25    pub name: Option<String>,
26}
27
28#[derive(Debug, Clone)]
29pub struct SessionIndex {
30    db_path: PathBuf,
31    lock_path: PathBuf,
32}
33
34impl SessionIndex {
35    pub fn new() -> Self {
36        let root = Config::sessions_dir();
37        Self::for_sessions_root(&root)
38    }
39
40    pub fn for_sessions_root(root: &Path) -> Self {
41        Self {
42            db_path: root.join("session-index.sqlite"),
43            lock_path: root.join("session-index.lock"),
44        }
45    }
46
47    pub fn index_session(&self, session: &Session) -> Result<()> {
48        let Some(path) = session.path.as_ref() else {
49            return Ok(());
50        };
51
52        let meta = build_meta(path, &session.header, &session.entries)?;
53        self.upsert_meta(meta)
54    }
55
56    /// Update index metadata for an already-persisted session snapshot.
57    ///
58    /// This avoids requiring a full `Session` clone when callers already have
59    /// header + aggregate entry stats.
60    pub fn index_session_snapshot(
61        &self,
62        path: &Path,
63        header: &SessionHeader,
64        message_count: u64,
65        name: Option<String>,
66    ) -> Result<()> {
67        let (last_modified_ms, size_bytes) = file_stats(path)?;
68        let meta = SessionMeta {
69            path: path.display().to_string(),
70            id: header.id.clone(),
71            cwd: header.cwd.clone(),
72            timestamp: header.timestamp.clone(),
73            message_count,
74            last_modified_ms,
75            size_bytes,
76            name,
77        };
78        self.upsert_meta(meta)
79    }
80
81    fn upsert_meta(&self, meta: SessionMeta) -> Result<()> {
82        self.with_lock(|conn| {
83            init_schema(conn)?;
84            let message_count = sqlite_i64_from_u64("message_count", meta.message_count)?;
85            let size_bytes = sqlite_i64_from_u64("size_bytes", meta.size_bytes)?;
86            conn.execute_sync(
87                "INSERT INTO sessions (path,id,cwd,timestamp,message_count,last_modified_ms,size_bytes,name)
88                 VALUES (?1,?2,?3,?4,?5,?6,?7,?8)
89                 ON CONFLICT(path) DO UPDATE SET
90                   id=excluded.id,
91                   cwd=excluded.cwd,
92                   timestamp=excluded.timestamp,
93                   message_count=excluded.message_count,
94                   last_modified_ms=excluded.last_modified_ms,
95                   size_bytes=excluded.size_bytes,
96                   name=excluded.name",
97                &[
98                    Value::Text(meta.path),
99                    Value::Text(meta.id),
100                    Value::Text(meta.cwd),
101                    Value::Text(meta.timestamp),
102                    Value::BigInt(message_count),
103                    Value::BigInt(meta.last_modified_ms),
104                    Value::BigInt(size_bytes),
105                    meta.name.map_or(Value::Null, Value::Text),
106                ],
107            ).map_err(|e| Error::session(format!("Insert failed: {e}")))?;
108
109            conn.execute_sync(
110                "INSERT INTO meta (key,value) VALUES ('last_sync_epoch_ms', ?1)
111                 ON CONFLICT(key) DO UPDATE SET value=excluded.value",
112                &[Value::Text(current_epoch_ms())],
113            ).map_err(|e| Error::session(format!("Meta update failed: {e}")))?;
114            Ok(())
115        })
116    }
117
118    pub fn list_sessions(&self, cwd: Option<&str>) -> Result<Vec<SessionMeta>> {
119        self.with_lock(|conn| {
120            init_schema(conn)?;
121
122            let (sql, params): (&str, Vec<Value>) = cwd.map_or_else(
123                || {
124                    (
125                        "SELECT path,id,cwd,timestamp,message_count,last_modified_ms,size_bytes,name
126                         FROM sessions ORDER BY last_modified_ms DESC",
127                        vec![],
128                    )
129                },
130                |cwd| {
131                    (
132                        "SELECT path,id,cwd,timestamp,message_count,last_modified_ms,size_bytes,name
133                         FROM sessions WHERE cwd=?1 ORDER BY last_modified_ms DESC",
134                        vec![Value::Text(cwd.to_string())],
135                    )
136                },
137            );
138
139            let rows = conn
140                .query_sync(sql, &params)
141                .map_err(|e| Error::session(format!("Query failed: {e}")))?;
142
143            let mut result = Vec::new();
144            for row in rows {
145                result.push(row_to_meta(&row)?);
146            }
147            Ok(result)
148        })
149    }
150
151    pub fn delete_session_path(&self, path: &Path) -> Result<()> {
152        let path = path.to_string_lossy().to_string();
153        self.with_lock(|conn| {
154            init_schema(conn)?;
155            conn.execute_sync("DELETE FROM sessions WHERE path=?1", &[Value::Text(path)])
156                .map_err(|e| Error::session(format!("Delete failed: {e}")))?;
157            Ok(())
158        })
159    }
160
161    pub fn reindex_all(&self) -> Result<()> {
162        let sessions_root = self.sessions_root();
163        if !sessions_root.exists() {
164            return Ok(());
165        }
166
167        let mut metas = Vec::new();
168        for entry in walk_sessions(sessions_root) {
169            let Ok(path) = entry else { continue };
170            if let Ok(meta) = build_meta_from_file(&path) {
171                metas.push(meta);
172            }
173        }
174
175        self.with_lock(|conn| {
176            init_schema(conn)?;
177            conn.execute_sync("DELETE FROM sessions", &[])
178                .map_err(|e| Error::session(format!("Delete failed: {e}")))?;
179
180            for meta in metas {
181                let message_count = sqlite_i64_from_u64("message_count", meta.message_count)?;
182                let size_bytes = sqlite_i64_from_u64("size_bytes", meta.size_bytes)?;
183                conn.execute_sync(
184                    "INSERT INTO sessions (path,id,cwd,timestamp,message_count,last_modified_ms,size_bytes,name)
185                     VALUES (?1,?2,?3,?4,?5,?6,?7,?8)",
186                    &[
187                        Value::Text(meta.path),
188                        Value::Text(meta.id),
189                        Value::Text(meta.cwd),
190                        Value::Text(meta.timestamp),
191                        Value::BigInt(message_count),
192                        Value::BigInt(meta.last_modified_ms),
193                        Value::BigInt(size_bytes),
194                        meta.name.map_or(Value::Null, Value::Text),
195                    ],
196                ).map_err(|e| Error::session(format!("Insert failed: {e}")))?;
197            }
198
199            conn.execute_sync(
200                "INSERT INTO meta (key,value) VALUES ('last_sync_epoch_ms', ?1)
201                 ON CONFLICT(key) DO UPDATE SET value=excluded.value",
202                &[Value::Text(current_epoch_ms())],
203            ).map_err(|e| Error::session(format!("Meta update failed: {e}")))?;
204            Ok(())
205        })
206    }
207
208    /// Check whether the on-disk index is stale enough to reindex.
209    pub fn should_reindex(&self, max_age: Duration) -> bool {
210        if !self.db_path.exists() {
211            return true;
212        }
213        let Ok(meta) = fs::metadata(&self.db_path) else {
214            return true;
215        };
216        let Ok(modified) = meta.modified() else {
217            return true;
218        };
219        let age = SystemTime::now()
220            .duration_since(modified)
221            .unwrap_or_default();
222        age > max_age
223    }
224
225    /// Reindex the session database if the index is stale.
226    pub fn reindex_if_stale(&self, max_age: Duration) -> Result<bool> {
227        if !self.should_reindex(max_age) {
228            return Ok(false);
229        }
230        self.reindex_all()?;
231        Ok(true)
232    }
233
234    fn with_lock<T>(&self, f: impl FnOnce(&SqliteConnection) -> Result<T>) -> Result<T> {
235        if let Some(parent) = self.db_path.parent() {
236            fs::create_dir_all(parent)?;
237        }
238        let lock_file = File::options()
239            .read(true)
240            .write(true)
241            .create(true)
242            .truncate(false)
243            .open(&self.lock_path)?;
244        let _lock = lock_file_guard(&lock_file, Duration::from_secs(5))?;
245
246        let config = SqliteConfig::file(self.db_path.to_string_lossy())
247            .flags(OpenFlags::create_read_write())
248            .busy_timeout(5000);
249
250        let conn = SqliteConnection::open(&config)
251            .map_err(|e| Error::session(format!("SQLite open: {e}")))?;
252
253        // Set pragmas for performance
254        conn.execute_raw("PRAGMA journal_mode = WAL")
255            .map_err(|e| Error::session(format!("PRAGMA journal_mode: {e}")))?;
256        conn.execute_raw("PRAGMA synchronous = NORMAL")
257            .map_err(|e| Error::session(format!("PRAGMA synchronous: {e}")))?;
258        conn.execute_raw("PRAGMA wal_autocheckpoint = 1000")
259            .map_err(|e| Error::session(format!("PRAGMA wal_autocheckpoint: {e}")))?;
260        conn.execute_raw("PRAGMA foreign_keys = ON")
261            .map_err(|e| Error::session(format!("PRAGMA foreign_keys: {e}")))?;
262
263        f(&conn)
264    }
265
266    fn sessions_root(&self) -> &Path {
267        self.db_path.parent().unwrap_or_else(|| Path::new("."))
268    }
269}
270
271impl Default for SessionIndex {
272    fn default() -> Self {
273        Self::new()
274    }
275}
276
277/// Queue (currently immediate) index update for a persisted session snapshot.
278///
279/// Callers use this helper from save paths where index freshness is
280/// best-effort and must not fail the underlying session write.
281pub(crate) fn enqueue_session_index_snapshot_update(
282    sessions_root: &Path,
283    path: &Path,
284    header: &SessionHeader,
285    message_count: u64,
286    name: Option<String>,
287) {
288    if let Err(err) = SessionIndex::for_sessions_root(sessions_root).index_session_snapshot(
289        path,
290        header,
291        message_count,
292        name,
293    ) {
294        tracing::warn!(
295            sessions_root = %sessions_root.display(),
296            path = %path.display(),
297            error = %err,
298            "Failed to update session index snapshot"
299        );
300    }
301}
302
303fn init_schema(conn: &SqliteConnection) -> Result<()> {
304    conn.execute_raw(
305        "CREATE TABLE IF NOT EXISTS sessions (
306            path TEXT PRIMARY KEY,
307            id TEXT NOT NULL,
308            cwd TEXT NOT NULL,
309            timestamp TEXT NOT NULL,
310            message_count INTEGER NOT NULL,
311            last_modified_ms INTEGER NOT NULL,
312            size_bytes INTEGER NOT NULL,
313            name TEXT
314        )",
315    )
316    .map_err(|e| Error::session(format!("Create sessions table: {e}")))?;
317
318    conn.execute_raw(
319        "CREATE TABLE IF NOT EXISTS meta (
320            key TEXT PRIMARY KEY,
321            value TEXT NOT NULL
322        )",
323    )
324    .map_err(|e| Error::session(format!("Create meta table: {e}")))?;
325
326    Ok(())
327}
328
329fn sqlite_i64_from_u64(field: &str, value: u64) -> Result<i64> {
330    i64::try_from(value)
331        .map_err(|_| Error::session(format!("{field} exceeds SQLite INTEGER range: {value}")))
332}
333
334fn row_to_meta(row: &sqlmodel_core::Row) -> Result<SessionMeta> {
335    Ok(SessionMeta {
336        path: row
337            .get_named("path")
338            .map_err(|e| Error::session(format!("get path: {e}")))?,
339        id: row
340            .get_named("id")
341            .map_err(|e| Error::session(format!("get id: {e}")))?,
342        cwd: row
343            .get_named("cwd")
344            .map_err(|e| Error::session(format!("get cwd: {e}")))?,
345        timestamp: row
346            .get_named("timestamp")
347            .map_err(|e| Error::session(format!("get timestamp: {e}")))?,
348        message_count: u64::try_from(
349            row.get_named::<i64>("message_count")
350                .map_err(|e| Error::session(format!("get message_count: {e}")))?,
351        )
352        .unwrap_or(0),
353        last_modified_ms: row
354            .get_named("last_modified_ms")
355            .map_err(|e| Error::session(format!("get last_modified_ms: {e}")))?,
356        size_bytes: u64::try_from(
357            row.get_named::<i64>("size_bytes")
358                .map_err(|e| Error::session(format!("get size_bytes: {e}")))?,
359        )
360        .unwrap_or(0),
361        name: row
362            .get_named::<Option<String>>("name")
363            .map_err(|e| Error::session(format!("get name: {e}")))?,
364    })
365}
366
367fn build_meta(
368    path: &Path,
369    header: &SessionHeader,
370    entries: &[SessionEntry],
371) -> Result<SessionMeta> {
372    let (message_count, name) = session_stats(entries);
373    let (last_modified_ms, size_bytes) = file_stats(path)?;
374    Ok(SessionMeta {
375        path: path.display().to_string(),
376        id: header.id.clone(),
377        cwd: header.cwd.clone(),
378        timestamp: header.timestamp.clone(),
379        message_count,
380        last_modified_ms,
381        size_bytes,
382        name,
383    })
384}
385
386pub(crate) fn build_meta_from_file(path: &Path) -> Result<SessionMeta> {
387    match path.extension().and_then(|ext| ext.to_str()) {
388        Some("jsonl") => build_meta_from_jsonl(path),
389        #[cfg(feature = "sqlite-sessions")]
390        Some("sqlite") => build_meta_from_sqlite(path),
391        _ => build_meta_from_jsonl(path),
392    }
393}
394
395#[derive(Deserialize)]
396struct PartialEntry {
397    #[serde(default)]
398    r#type: String,
399    #[serde(default)]
400    name: Option<String>,
401}
402
403fn build_meta_from_jsonl(path: &Path) -> Result<SessionMeta> {
404    let file = File::open(path)
405        .map_err(|err| Error::session(format!("Read session file {}: {err}", path.display())))?;
406    let reader = BufReader::new(file);
407    let mut lines = reader.lines();
408
409    let header_line = lines
410        .next()
411        .ok_or_else(|| Error::session(format!("Empty session file {}", path.display())))?
412        .map_err(|err| Error::session(format!("Read session header {}: {err}", path.display())))?;
413
414    let header: SessionHeader = serde_json::from_str(&header_line)
415        .map_err(|err| Error::session(format!("Parse session header {}: {err}", path.display())))?;
416
417    let mut message_count = 0u64;
418    let mut name = None;
419
420    for line in lines {
421        let line = line.map_err(|err| {
422            Error::session(format!("Read session entry line {}: {err}", path.display()))
423        })?;
424        if let Ok(entry) = serde_json::from_str::<PartialEntry>(&line) {
425            match entry.r#type.as_str() {
426                "message" => message_count += 1,
427                "session_info" => {
428                    if entry.name.is_some() {
429                        name = entry.name;
430                    }
431                }
432                _ => {}
433            }
434        }
435    }
436
437    let meta = fs::metadata(path)?;
438    let size_bytes = meta.len();
439    let modified = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
440    let millis = modified
441        .duration_since(UNIX_EPOCH)
442        .unwrap_or_default()
443        .as_millis();
444    let last_modified_ms = i64::try_from(millis).unwrap_or(i64::MAX);
445
446    Ok(SessionMeta {
447        path: path.display().to_string(),
448        id: header.id,
449        cwd: header.cwd,
450        timestamp: header.timestamp,
451        message_count,
452        last_modified_ms,
453        size_bytes,
454        name,
455    })
456}
457
458#[cfg(feature = "sqlite-sessions")]
459fn build_meta_from_sqlite(path: &Path) -> Result<SessionMeta> {
460    let meta = futures::executor::block_on(async {
461        crate::session_sqlite::load_session_meta(path).await
462    })?;
463    let header = meta.header;
464    let (last_modified_ms, size_bytes) = file_stats(path)?;
465
466    Ok(SessionMeta {
467        path: path.display().to_string(),
468        id: header.id,
469        cwd: header.cwd,
470        timestamp: header.timestamp,
471        message_count: meta.message_count,
472        last_modified_ms,
473        size_bytes,
474        name: meta.name,
475    })
476}
477
478fn session_stats<T>(entries: &[T]) -> (u64, Option<String>)
479where
480    T: Borrow<SessionEntry>,
481{
482    let mut message_count = 0u64;
483    let mut name = None;
484    for entry in entries {
485        match entry.borrow() {
486            SessionEntry::Message(_) => message_count += 1,
487            SessionEntry::SessionInfo(info) => {
488                if info.name.is_some() {
489                    name.clone_from(&info.name);
490                }
491            }
492            _ => {}
493        }
494    }
495    (message_count, name)
496}
497
498fn file_stats(path: &Path) -> Result<(i64, u64)> {
499    let meta = fs::metadata(path)?;
500    let size = meta.len();
501    let modified = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
502    let millis = modified
503        .duration_since(UNIX_EPOCH)
504        .unwrap_or_default()
505        .as_millis();
506    let ms = i64::try_from(millis).unwrap_or(i64::MAX);
507    Ok((ms, size))
508}
509
510fn is_session_file_path(path: &Path) -> bool {
511    match path.extension().and_then(|ext| ext.to_str()) {
512        Some("jsonl") => true,
513        #[cfg(feature = "sqlite-sessions")]
514        Some("sqlite") => true,
515        _ => false,
516    }
517}
518
519pub(crate) fn walk_sessions(root: &Path) -> Vec<std::io::Result<PathBuf>> {
520    let mut out = Vec::new();
521    let mut stack = vec![root.to_path_buf()];
522
523    while let Some(dir) = stack.pop() {
524        if let Ok(entries) = fs::read_dir(&dir) {
525            for entry in entries.flatten() {
526                let path = entry.path();
527                let Ok(file_type) = entry.file_type() else {
528                    continue;
529                };
530
531                if file_type.is_dir() {
532                    stack.push(path);
533                } else if file_type.is_symlink() {
534                    // Allow symlinks to files, but skip symlinked directories to avoid cycles
535                    if let Ok(meta) = fs::metadata(&path) {
536                        if meta.is_file() && is_session_file_path(&path) {
537                            out.push(Ok(path));
538                        }
539                    }
540                } else if is_session_file_path(&path) {
541                    out.push(Ok(path));
542                }
543            }
544        }
545    }
546    out
547}
548
549fn current_epoch_ms() -> String {
550    chrono::Utc::now().timestamp_millis().to_string()
551}
552
553fn lock_file_guard(file: &File, timeout: Duration) -> Result<LockGuard<'_>> {
554    let start = Instant::now();
555    loop {
556        if matches!(FileExt::try_lock_exclusive(file), Ok(true)) {
557            return Ok(LockGuard { file });
558        }
559
560        if start.elapsed() >= timeout {
561            return Err(Error::session(
562                "Timed out waiting for session index lock".to_string(),
563            ));
564        }
565
566        std::thread::sleep(Duration::from_millis(50));
567    }
568}
569
570#[derive(Debug)]
571struct LockGuard<'a> {
572    file: &'a File,
573}
574
575impl Drop for LockGuard<'_> {
576    fn drop(&mut self) {
577        let _ = FileExt::unlock(self.file);
578    }
579}
580
581#[cfg(test)]
582#[path = "../tests/common/mod.rs"]
583mod test_common;
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588
589    use super::test_common::TestHarness;
590    use crate::model::UserContent;
591    use crate::session::{EntryBase, MessageEntry, SessionInfoEntry, SessionMessage};
592    use pretty_assertions::assert_eq;
593    use proptest::prelude::*;
594    use proptest::string::string_regex;
595    use std::collections::HashMap;
596    use std::fs;
597    use std::time::Duration;
598
599    fn write_session_jsonl(path: &Path, header: &SessionHeader, entries: &[SessionEntry]) {
600        let mut jsonl = String::new();
601        jsonl.push_str(&serde_json::to_string(header).expect("serialize session header"));
602        jsonl.push('\n');
603        for entry in entries {
604            jsonl.push_str(&serde_json::to_string(entry).expect("serialize session entry"));
605            jsonl.push('\n');
606        }
607        fs::write(path, jsonl).expect("write session jsonl");
608    }
609
610    fn make_header(id: &str, cwd: &str) -> SessionHeader {
611        let mut header = SessionHeader::new();
612        header.id = id.to_string();
613        header.cwd = cwd.to_string();
614        header
615    }
616
617    fn make_user_entry(parent_id: Option<String>, id: &str, text: &str) -> SessionEntry {
618        SessionEntry::Message(MessageEntry {
619            base: EntryBase::new(parent_id, id.to_string()),
620            message: SessionMessage::User {
621                content: UserContent::Text(text.to_string()),
622                timestamp: Some(chrono::Utc::now().timestamp_millis()),
623            },
624        })
625    }
626
627    fn make_session_info_entry(
628        parent_id: Option<String>,
629        id: &str,
630        name: Option<&str>,
631    ) -> SessionEntry {
632        SessionEntry::SessionInfo(SessionInfoEntry {
633            base: EntryBase::new(parent_id, id.to_string()),
634            name: name.map(ToString::to_string),
635        })
636    }
637
638    fn read_meta_last_sync_epoch_ms(index: &SessionIndex) -> String {
639        index
640            .with_lock(|conn| {
641                init_schema(conn)?;
642                let rows = conn
643                    .query_sync(
644                        "SELECT value FROM meta WHERE key='last_sync_epoch_ms' LIMIT 1",
645                        &[],
646                    )
647                    .map_err(|err| Error::session(format!("Query meta failed: {err}")))?;
648                let row = rows
649                    .into_iter()
650                    .next()
651                    .ok_or_else(|| Error::session("Missing meta row".to_string()))?;
652                row.get_named::<String>("value")
653                    .map_err(|err| Error::session(format!("get meta value: {err}")))
654            })
655            .expect("read meta.last_sync_epoch_ms")
656    }
657
658    #[derive(Debug, Clone)]
659    struct ArbitraryMetaRow {
660        id: String,
661        cwd: String,
662        timestamp: String,
663        message_count: i64,
664        last_modified_ms: i64,
665        size_bytes: i64,
666        name: Option<String>,
667    }
668
669    fn ident_strategy() -> impl Strategy<Value = String> {
670        string_regex("[a-z0-9_-]{1,16}").expect("valid identifier regex")
671    }
672
673    fn cwd_strategy() -> impl Strategy<Value = String> {
674        prop_oneof![
675            Just("cwd-a".to_string()),
676            Just("cwd-b".to_string()),
677            string_regex("[a-z0-9_./-]{1,20}").expect("valid cwd regex"),
678        ]
679    }
680
681    fn timestamp_strategy() -> impl Strategy<Value = String> {
682        string_regex("[0-9TZ:.-]{10,32}").expect("valid timestamp regex")
683    }
684
685    fn optional_name_strategy() -> impl Strategy<Value = Option<String>> {
686        prop::option::of(string_regex("[A-Za-z0-9 _.:-]{0,32}").expect("valid name regex"))
687    }
688
689    fn arbitrary_meta_row_strategy() -> impl Strategy<Value = ArbitraryMetaRow> {
690        (
691            ident_strategy(),
692            cwd_strategy(),
693            timestamp_strategy(),
694            any::<i64>(),
695            any::<i64>(),
696            any::<i64>(),
697            optional_name_strategy(),
698        )
699            .prop_map(
700                |(id, cwd, timestamp, message_count, last_modified_ms, size_bytes, name)| {
701                    ArbitraryMetaRow {
702                        id,
703                        cwd,
704                        timestamp,
705                        message_count,
706                        last_modified_ms,
707                        size_bytes,
708                        name,
709                    }
710                },
711            )
712    }
713
714    #[test]
715    fn index_session_on_in_memory_session_is_noop() {
716        let harness = TestHarness::new("index_session_on_in_memory_session_is_noop");
717        let root = harness.temp_path("sessions");
718        fs::create_dir_all(&root).expect("create root dir");
719        let index = SessionIndex::for_sessions_root(&root);
720        let session = Session::in_memory();
721
722        index
723            .index_session(&session)
724            .expect("index in-memory session");
725
726        harness
727            .log()
728            .info_ctx("verify", "No index files created", |ctx| {
729                ctx.push(("db_path".into(), index.db_path.display().to_string()));
730                ctx.push(("lock_path".into(), index.lock_path.display().to_string()));
731            });
732        assert!(!index.db_path.exists());
733        assert!(!index.lock_path.exists());
734    }
735
736    #[test]
737    fn index_session_inserts_row_and_updates_meta() {
738        let harness = TestHarness::new("index_session_inserts_row_and_updates_meta");
739        let root = harness.temp_path("sessions");
740        fs::create_dir_all(&root).expect("create root dir");
741        let index = SessionIndex::for_sessions_root(&root);
742
743        let session_path = harness.temp_path("sessions/project/a.jsonl");
744        fs::create_dir_all(session_path.parent().expect("parent")).expect("create session dir");
745        fs::write(&session_path, "hello").expect("write session file");
746
747        let mut session = Session::in_memory();
748        session.header = make_header("id-a", "cwd-a");
749        session.path = Some(session_path.clone());
750        session.entries.push(make_user_entry(None, "m1", "hi"));
751
752        index.index_session(&session).expect("index session");
753
754        let sessions = index.list_sessions(Some("cwd-a")).expect("list sessions");
755        assert_eq!(sessions.len(), 1);
756        assert_eq!(sessions[0].id, "id-a");
757        assert_eq!(sessions[0].cwd, "cwd-a");
758        assert_eq!(sessions[0].message_count, 1);
759        assert_eq!(sessions[0].path, session_path.display().to_string());
760
761        let meta_value = read_meta_last_sync_epoch_ms(&index);
762        harness
763            .log()
764            .info_ctx("verify", "meta.last_sync_epoch_ms present", |ctx| {
765                ctx.push(("value".into(), meta_value.clone()));
766            });
767        assert!(
768            meta_value.parse::<i64>().is_ok(),
769            "Expected meta value to be an integer epoch ms"
770        );
771    }
772
773    #[test]
774    fn index_session_updates_existing_row() {
775        let harness = TestHarness::new("index_session_updates_existing_row");
776        let root = harness.temp_path("sessions");
777        fs::create_dir_all(&root).expect("create root dir");
778        let index = SessionIndex::for_sessions_root(&root);
779
780        let session_path = harness.temp_path("sessions/project/update.jsonl");
781        fs::create_dir_all(session_path.parent().expect("parent")).expect("create session dir");
782        fs::write(&session_path, "first").expect("write session file");
783
784        let mut session = Session::in_memory();
785        session.header = make_header("id-update", "cwd-update");
786        session.path = Some(session_path.clone());
787        session.entries.push(make_user_entry(None, "m1", "hi"));
788
789        index
790            .index_session(&session)
791            .expect("index session first time");
792        let first_meta = index
793            .list_sessions(Some("cwd-update"))
794            .expect("list sessions")[0]
795            .clone();
796        let first_sync = read_meta_last_sync_epoch_ms(&index);
797
798        std::thread::sleep(Duration::from_millis(10));
799        fs::write(&session_path, "second-longer").expect("rewrite session file");
800        session
801            .entries
802            .push(make_user_entry(Some("m1".to_string()), "m2", "again"));
803
804        index
805            .index_session(&session)
806            .expect("index session second time");
807        let second_meta = index
808            .list_sessions(Some("cwd-update"))
809            .expect("list sessions")[0]
810            .clone();
811        let second_sync = read_meta_last_sync_epoch_ms(&index);
812
813        harness.log().info_ctx("verify", "row updated", |ctx| {
814            ctx.push((
815                "first_message_count".into(),
816                first_meta.message_count.to_string(),
817            ));
818            ctx.push((
819                "second_message_count".into(),
820                second_meta.message_count.to_string(),
821            ));
822            ctx.push(("first_size".into(), first_meta.size_bytes.to_string()));
823            ctx.push(("second_size".into(), second_meta.size_bytes.to_string()));
824            ctx.push(("first_sync".into(), first_sync.clone()));
825            ctx.push(("second_sync".into(), second_sync.clone()));
826        });
827
828        assert_eq!(second_meta.message_count, 2);
829        assert!(second_meta.size_bytes >= first_meta.size_bytes);
830        assert!(second_meta.last_modified_ms >= first_meta.last_modified_ms);
831        assert!(second_sync.parse::<i64>().unwrap_or(0) >= first_sync.parse::<i64>().unwrap_or(0));
832    }
833
834    #[test]
835    fn list_sessions_orders_by_last_modified_desc() {
836        let harness = TestHarness::new("list_sessions_orders_by_last_modified_desc");
837        let root = harness.temp_path("sessions");
838        fs::create_dir_all(&root).expect("create root dir");
839        let index = SessionIndex::for_sessions_root(&root);
840
841        let path_a = harness.temp_path("sessions/project/a.jsonl");
842        fs::create_dir_all(path_a.parent().expect("parent")).expect("create dirs");
843        fs::write(&path_a, "a").expect("write file a");
844
845        let mut session_a = Session::in_memory();
846        session_a.header = make_header("id-a", "cwd-a");
847        session_a.path = Some(path_a);
848        session_a.entries.push(make_user_entry(None, "m1", "a"));
849        index.index_session(&session_a).expect("index a");
850
851        std::thread::sleep(Duration::from_millis(10));
852
853        let path_b = harness.temp_path("sessions/project/b.jsonl");
854        fs::create_dir_all(path_b.parent().expect("parent")).expect("create dirs");
855        fs::write(&path_b, "bbbbb").expect("write file b");
856
857        let mut session_b = Session::in_memory();
858        session_b.header = make_header("id-b", "cwd-b");
859        session_b.path = Some(path_b);
860        session_b.entries.push(make_user_entry(None, "m1", "b"));
861        index.index_session(&session_b).expect("index b");
862
863        let sessions = index.list_sessions(None).expect("list sessions");
864        harness
865            .log()
866            .info("verify", format!("listed {} sessions", sessions.len()));
867        assert!(sessions.len() >= 2);
868        assert_eq!(sessions[0].id, "id-b");
869        assert_eq!(sessions[1].id, "id-a");
870        assert!(sessions[0].last_modified_ms >= sessions[1].last_modified_ms);
871    }
872
873    #[test]
874    fn list_sessions_filters_by_cwd() {
875        let harness = TestHarness::new("list_sessions_filters_by_cwd");
876        let root = harness.temp_path("sessions");
877        fs::create_dir_all(&root).expect("create root dir");
878        let index = SessionIndex::for_sessions_root(&root);
879
880        for (id, cwd) in [("id-a", "cwd-a"), ("id-b", "cwd-b")] {
881            let path = harness.temp_path(format!("sessions/project/{id}.jsonl"));
882            fs::create_dir_all(path.parent().expect("parent")).expect("create dirs");
883            fs::write(&path, id).expect("write session file");
884
885            let mut session = Session::in_memory();
886            session.header = make_header(id, cwd);
887            session.path = Some(path);
888            session.entries.push(make_user_entry(None, "m1", id));
889            index.index_session(&session).expect("index session");
890        }
891
892        let only_a = index
893            .list_sessions(Some("cwd-a"))
894            .expect("list sessions cwd-a");
895        assert_eq!(only_a.len(), 1);
896        assert_eq!(only_a[0].id, "id-a");
897    }
898
899    #[test]
900    fn reindex_all_is_noop_when_sessions_root_missing() {
901        let harness = TestHarness::new("reindex_all_is_noop_when_sessions_root_missing");
902        let missing_root = harness.temp_path("does-not-exist");
903        let index = SessionIndex::for_sessions_root(&missing_root);
904
905        index.reindex_all().expect("reindex_all");
906        assert!(!index.db_path.exists());
907        assert!(!index.lock_path.exists());
908    }
909
910    #[test]
911    fn reindex_all_rebuilds_index_from_disk() {
912        let harness = TestHarness::new("reindex_all_rebuilds_index_from_disk");
913        let root = harness.temp_path("sessions");
914        fs::create_dir_all(&root).expect("create root dir");
915        let index = SessionIndex::for_sessions_root(&root);
916
917        let path = harness.temp_path("sessions/project/reindex.jsonl");
918        fs::create_dir_all(path.parent().expect("parent")).expect("create dirs");
919
920        let header = make_header("id-reindex", "cwd-reindex");
921        let entries = vec![
922            make_user_entry(None, "m1", "hello"),
923            make_session_info_entry(Some("m1".to_string()), "info1", Some("My Session")),
924            make_user_entry(Some("info1".to_string()), "m2", "world"),
925        ];
926        write_session_jsonl(&path, &header, &entries);
927
928        index.reindex_all().expect("reindex_all");
929
930        let sessions = index
931            .list_sessions(Some("cwd-reindex"))
932            .expect("list sessions");
933        assert_eq!(sessions.len(), 1);
934        assert_eq!(sessions[0].id, "id-reindex");
935        assert_eq!(sessions[0].message_count, 2);
936        assert_eq!(sessions[0].name.as_deref(), Some("My Session"));
937
938        let meta_value = read_meta_last_sync_epoch_ms(&index);
939        harness.log().info_ctx("verify", "meta updated", |ctx| {
940            ctx.push(("value".into(), meta_value.clone()));
941        });
942        assert!(meta_value.parse::<i64>().unwrap_or(0) > 0);
943    }
944
945    #[test]
946    fn reindex_all_skips_invalid_jsonl_files() {
947        let harness = TestHarness::new("reindex_all_skips_invalid_jsonl_files");
948        let root = harness.temp_path("sessions");
949        fs::create_dir_all(&root).expect("create root dir");
950        let index = SessionIndex::for_sessions_root(&root);
951
952        let good = harness.temp_path("sessions/project/good.jsonl");
953        fs::create_dir_all(good.parent().expect("parent")).expect("create dirs");
954        let header = make_header("id-good", "cwd-good");
955        let entries = vec![make_user_entry(None, "m1", "ok")];
956        write_session_jsonl(&good, &header, &entries);
957
958        let bad = harness.temp_path("sessions/project/bad.jsonl");
959        fs::write(&bad, "not-json\n{").expect("write bad jsonl");
960
961        index.reindex_all().expect("reindex_all should succeed");
962        let sessions = index.list_sessions(None).expect("list sessions");
963        assert_eq!(sessions.len(), 1);
964        assert_eq!(sessions[0].id, "id-good");
965    }
966
967    #[test]
968    fn build_meta_from_file_returns_session_error_on_invalid_header() {
969        let harness =
970            TestHarness::new("build_meta_from_file_returns_session_error_on_invalid_header");
971        let path = harness.temp_path("bad_header.jsonl");
972        fs::write(&path, "not json\n").expect("write bad header");
973
974        let err = build_meta_from_file(&path).expect_err("expected error");
975        harness.log().info("verify", format!("error: {err}"));
976
977        assert!(
978            matches!(err, Error::Session(ref msg) if msg.contains("Parse session header")),
979            "Expected Error::Session containing Parse session header, got {err:?}",
980        );
981    }
982
983    #[test]
984    fn build_meta_from_file_returns_session_error_on_empty_file() {
985        let harness = TestHarness::new("build_meta_from_file_returns_session_error_on_empty_file");
986        let path = harness.temp_path("empty.jsonl");
987        fs::write(&path, "").expect("write empty");
988
989        let err = build_meta_from_file(&path).expect_err("expected error");
990        if let Error::Session(msg) = &err {
991            harness.log().info("verify", msg.clone());
992        }
993        assert!(
994            matches!(err, Error::Session(ref msg) if msg.contains("Empty session file")),
995            "Expected Error::Session containing Empty session file, got {err:?}",
996        );
997    }
998
999    #[test]
1000    fn list_sessions_returns_session_error_when_db_path_is_directory() {
1001        let harness =
1002            TestHarness::new("list_sessions_returns_session_error_when_db_path_is_directory");
1003        let root = harness.temp_path("sessions");
1004        fs::create_dir_all(&root).expect("create root dir");
1005
1006        let db_dir = root.join("session-index.sqlite");
1007        fs::create_dir_all(&db_dir).expect("create db dir to force sqlite open failure");
1008
1009        let index = SessionIndex::for_sessions_root(&root);
1010        let err = index.list_sessions(None).expect_err("expected error");
1011        if let Error::Session(msg) = &err {
1012            harness.log().info("verify", msg.clone());
1013        }
1014        assert!(
1015            matches!(err, Error::Session(ref msg) if msg.contains("SQLite open")),
1016            "Expected Error::Session containing SQLite open, got {err:?}",
1017        );
1018    }
1019
1020    #[test]
1021    fn lock_file_guard_prevents_concurrent_access() {
1022        let harness = TestHarness::new("lock_file_guard_prevents_concurrent_access");
1023        let path = harness.temp_path("lockfile.lock");
1024        fs::write(&path, "").expect("create lock file");
1025
1026        let file1 = File::options()
1027            .read(true)
1028            .write(true)
1029            .open(&path)
1030            .expect("open file1");
1031        let file2 = File::options()
1032            .read(true)
1033            .write(true)
1034            .open(&path)
1035            .expect("open file2");
1036
1037        let guard1 = lock_file_guard(&file1, Duration::from_millis(50)).expect("acquire lock");
1038        let err =
1039            lock_file_guard(&file2, Duration::from_millis(50)).expect_err("expected lock timeout");
1040        drop(guard1);
1041
1042        assert!(
1043            matches!(err, Error::Session(ref msg) if msg.contains("Timed out")),
1044            "Expected Error::Session containing Timed out, got {err:?}",
1045        );
1046
1047        let _guard2 =
1048            lock_file_guard(&file2, Duration::from_millis(50)).expect("lock after release");
1049    }
1050
1051    #[test]
1052    fn should_reindex_returns_true_when_db_missing() {
1053        let harness = TestHarness::new("should_reindex_returns_true_when_db_missing");
1054        let root = harness.temp_path("sessions");
1055        fs::create_dir_all(&root).expect("create root dir");
1056        let index = SessionIndex::for_sessions_root(&root);
1057
1058        assert!(index.should_reindex(Duration::from_secs(60)));
1059    }
1060
1061    // ── session_stats ────────────────────────────────────────────────
1062
1063    #[test]
1064    fn session_stats_empty_entries() {
1065        let (count, name) = session_stats::<SessionEntry>(&[]);
1066        assert_eq!(count, 0);
1067        assert!(name.is_none());
1068    }
1069
1070    #[test]
1071    fn session_stats_counts_messages_only() {
1072        let entries = vec![
1073            make_user_entry(None, "m1", "hello"),
1074            make_session_info_entry(Some("m1".to_string()), "info1", None),
1075            make_user_entry(Some("info1".to_string()), "m2", "world"),
1076        ];
1077        let (count, name) = session_stats(&entries);
1078        assert_eq!(count, 2);
1079        assert!(name.is_none());
1080    }
1081
1082    #[test]
1083    fn session_stats_extracts_last_name() {
1084        let entries = vec![
1085            make_session_info_entry(None, "info1", Some("First Name")),
1086            make_user_entry(Some("info1".to_string()), "m1", "msg"),
1087            make_session_info_entry(Some("m1".to_string()), "info2", Some("Final Name")),
1088        ];
1089        let (count, name) = session_stats(&entries);
1090        assert_eq!(count, 1);
1091        assert_eq!(name.as_deref(), Some("Final Name"));
1092    }
1093
1094    #[test]
1095    fn session_stats_name_not_overwritten_by_none() {
1096        let entries = vec![
1097            make_session_info_entry(None, "info1", Some("My Session")),
1098            make_session_info_entry(Some("info1".to_string()), "info2", None),
1099        ];
1100        let (_, name) = session_stats(&entries);
1101        // None doesn't overwrite previous name because of `if info.name.is_some()`
1102        assert_eq!(name.as_deref(), Some("My Session"));
1103    }
1104
1105    // ── file_stats ──────────────────────────────────────────────────
1106
1107    #[test]
1108    fn file_stats_returns_size_and_mtime() {
1109        let harness = TestHarness::new("file_stats_returns_size_and_mtime");
1110        let path = harness.temp_path("test_file.txt");
1111        fs::write(&path, "hello world").expect("write");
1112
1113        let (last_modified_ms, size_bytes) = file_stats(&path).expect("file_stats");
1114        assert_eq!(size_bytes, 11); // "hello world" = 11 bytes
1115        assert!(last_modified_ms > 0, "Expected positive modification time");
1116    }
1117
1118    #[test]
1119    fn file_stats_missing_file_returns_error() {
1120        let err = file_stats(Path::new("/nonexistent/file.txt"));
1121        assert!(err.is_err());
1122    }
1123
1124    // ── is_session_file_path ────────────────────────────────────────
1125
1126    #[test]
1127    fn is_session_file_path_jsonl() {
1128        assert!(is_session_file_path(Path::new("session.jsonl")));
1129        assert!(is_session_file_path(Path::new("/foo/bar/test.jsonl")));
1130    }
1131
1132    #[test]
1133    fn is_session_file_path_non_session() {
1134        assert!(!is_session_file_path(Path::new("session.txt")));
1135        assert!(!is_session_file_path(Path::new("session.json")));
1136        assert!(!is_session_file_path(Path::new("session")));
1137    }
1138
1139    // ── walk_sessions ───────────────────────────────────────────────
1140
1141    #[test]
1142    fn walk_sessions_finds_jsonl_files_recursively() {
1143        let harness = TestHarness::new("walk_sessions_finds_jsonl_files_recursively");
1144        let root = harness.temp_path("sessions");
1145        fs::create_dir_all(root.join("project")).expect("create dirs");
1146
1147        fs::write(root.join("a.jsonl"), "").expect("write");
1148        fs::write(root.join("project/b.jsonl"), "").expect("write");
1149        fs::write(root.join("not_session.txt"), "").expect("write");
1150
1151        let paths = walk_sessions(&root);
1152        let ok_paths: Vec<_> = paths
1153            .into_iter()
1154            .filter_map(std::result::Result::ok)
1155            .collect();
1156        assert_eq!(ok_paths.len(), 2);
1157        assert!(ok_paths.iter().any(|p| p.ends_with("a.jsonl")));
1158        assert!(ok_paths.iter().any(|p| p.ends_with("b.jsonl")));
1159    }
1160
1161    #[test]
1162    fn walk_sessions_empty_dir() {
1163        let harness = TestHarness::new("walk_sessions_empty_dir");
1164        let root = harness.temp_path("sessions");
1165        fs::create_dir_all(&root).expect("create dirs");
1166
1167        let paths = walk_sessions(&root);
1168        assert!(paths.is_empty());
1169    }
1170
1171    #[test]
1172    fn walk_sessions_nonexistent_dir() {
1173        let paths = walk_sessions(Path::new("/nonexistent/path"));
1174        assert!(paths.is_empty());
1175    }
1176
1177    // ── current_epoch_ms ────────────────────────────────────────────
1178
1179    #[test]
1180    fn current_epoch_ms_is_valid_number() {
1181        let ms = current_epoch_ms();
1182        let parsed: i64 = ms.parse().expect("should be valid i64");
1183        assert!(parsed > 0, "Epoch ms should be positive");
1184        // Should be after 2020-01-01
1185        assert!(parsed > 1_577_836_800_000, "Epoch ms should be after 2020");
1186    }
1187
1188    // ── delete_session_path ─────────────────────────────────────────
1189
1190    #[test]
1191    fn delete_session_path_removes_row() {
1192        let harness = TestHarness::new("delete_session_path_removes_row");
1193        let root = harness.temp_path("sessions");
1194        fs::create_dir_all(&root).expect("create root dir");
1195        let index = SessionIndex::for_sessions_root(&root);
1196
1197        let session_path = harness.temp_path("sessions/project/del.jsonl");
1198        fs::create_dir_all(session_path.parent().expect("parent")).expect("create dirs");
1199        fs::write(&session_path, "data").expect("write");
1200
1201        let mut session = Session::in_memory();
1202        session.header = make_header("id-del", "cwd-del");
1203        session.path = Some(session_path.clone());
1204        session.entries.push(make_user_entry(None, "m1", "hi"));
1205        index.index_session(&session).expect("index session");
1206
1207        let before = index.list_sessions(None).expect("list before");
1208        assert_eq!(before.len(), 1);
1209
1210        index
1211            .delete_session_path(&session_path)
1212            .expect("delete session path");
1213
1214        let after = index.list_sessions(None).expect("list after");
1215        assert!(after.is_empty());
1216    }
1217
1218    #[test]
1219    fn delete_session_path_noop_when_not_exists() {
1220        let harness = TestHarness::new("delete_session_path_noop_when_not_exists");
1221        let root = harness.temp_path("sessions");
1222        fs::create_dir_all(&root).expect("create root dir");
1223        let index = SessionIndex::for_sessions_root(&root);
1224
1225        // Delete a path that was never indexed — should succeed without error
1226        index
1227            .delete_session_path(Path::new("/nonexistent/session.jsonl"))
1228            .expect("delete nonexistent should succeed");
1229    }
1230
1231    // ── should_reindex ──────────────────────────────────────────────
1232
1233    #[test]
1234    fn should_reindex_returns_false_when_db_is_fresh() {
1235        let harness = TestHarness::new("should_reindex_returns_false_when_db_is_fresh");
1236        let root = harness.temp_path("sessions");
1237        fs::create_dir_all(&root).expect("create root dir");
1238        let index = SessionIndex::for_sessions_root(&root);
1239
1240        // Create the db by indexing a session
1241        let session_path = harness.temp_path("sessions/project/fresh.jsonl");
1242        fs::create_dir_all(session_path.parent().expect("parent")).expect("create dirs");
1243        fs::write(&session_path, "data").expect("write");
1244
1245        let mut session = Session::in_memory();
1246        session.header = make_header("id-fresh", "cwd-fresh");
1247        session.path = Some(session_path);
1248        session.entries.push(make_user_entry(None, "m1", "hi"));
1249        index.index_session(&session).expect("index session");
1250
1251        // DB just created — should not need reindex for large max_age
1252        assert!(!index.should_reindex(Duration::from_secs(3600)));
1253    }
1254
1255    // ── reindex_if_stale ────────────────────────────────────────────
1256
1257    #[test]
1258    fn reindex_if_stale_returns_false_when_fresh() {
1259        let harness = TestHarness::new("reindex_if_stale_returns_false_when_fresh");
1260        let root = harness.temp_path("sessions");
1261        fs::create_dir_all(&root).expect("create root dir");
1262        let index = SessionIndex::for_sessions_root(&root);
1263
1264        // Create a session file on disk
1265        let session_path = harness.temp_path("sessions/project/stale_test.jsonl");
1266        fs::create_dir_all(session_path.parent().expect("parent")).expect("create dirs");
1267        let header = make_header("id-stale", "cwd-stale");
1268        let entries = vec![make_user_entry(None, "m1", "msg")];
1269        write_session_jsonl(&session_path, &header, &entries);
1270
1271        // First reindex (no db exists yet)
1272        let result = index
1273            .reindex_if_stale(Duration::from_secs(3600))
1274            .expect("reindex");
1275        assert!(result, "First reindex should return true (no db)");
1276
1277        // Second call with large max_age should return false (fresh)
1278        let result = index
1279            .reindex_if_stale(Duration::from_secs(3600))
1280            .expect("reindex");
1281        assert!(!result, "Second reindex should return false (fresh)");
1282    }
1283
1284    #[test]
1285    fn reindex_if_stale_returns_true_when_stale() {
1286        let harness = TestHarness::new("reindex_if_stale_returns_true_when_stale");
1287        let root = harness.temp_path("sessions");
1288        fs::create_dir_all(&root).expect("create root dir");
1289        let index = SessionIndex::for_sessions_root(&root);
1290
1291        // Create a session on disk
1292        let session_path = harness.temp_path("sessions/project/stale.jsonl");
1293        fs::create_dir_all(session_path.parent().expect("parent")).expect("create dirs");
1294        let header = make_header("id-stale2", "cwd-stale2");
1295        let entries = vec![make_user_entry(None, "m1", "msg")];
1296        write_session_jsonl(&session_path, &header, &entries);
1297
1298        // Reindex with zero max_age — always stale
1299        let result = index.reindex_if_stale(Duration::ZERO).expect("reindex");
1300        assert!(result, "Should reindex with zero max_age");
1301    }
1302
1303    // ── build_meta ──────────────────────────────────────────────────
1304
1305    #[test]
1306    fn build_meta_from_file_returns_correct_fields() {
1307        let harness = TestHarness::new("build_meta_from_file_returns_correct_fields");
1308        let path = harness.temp_path("test_session.jsonl");
1309        let header = make_header("id-bm", "cwd-bm");
1310        let entries = vec![
1311            make_user_entry(None, "m1", "hello"),
1312            make_user_entry(Some("m1".to_string()), "m2", "world"),
1313            make_session_info_entry(Some("m2".to_string()), "info1", Some("Named Session")),
1314        ];
1315        write_session_jsonl(&path, &header, &entries);
1316
1317        let meta = build_meta_from_file(&path).expect("build_meta_from_file");
1318        assert_eq!(meta.id, "id-bm");
1319        assert_eq!(meta.cwd, "cwd-bm");
1320        assert_eq!(meta.message_count, 2);
1321        assert_eq!(meta.name.as_deref(), Some("Named Session"));
1322        assert!(meta.size_bytes > 0);
1323        assert!(meta.last_modified_ms > 0);
1324        assert!(meta.path.contains("test_session.jsonl"));
1325    }
1326
1327    // ── for_sessions_root path construction ─────────────────────────
1328
1329    #[test]
1330    fn for_sessions_root_constructs_correct_paths() {
1331        let root = Path::new("/home/user/.pi/sessions");
1332        let index = SessionIndex::for_sessions_root(root);
1333        assert_eq!(
1334            index.db_path,
1335            PathBuf::from("/home/user/.pi/sessions/session-index.sqlite")
1336        );
1337        assert_eq!(
1338            index.lock_path,
1339            PathBuf::from("/home/user/.pi/sessions/session-index.lock")
1340        );
1341    }
1342
1343    // ── sessions_root accessor ──────────────────────────────────────
1344
1345    #[test]
1346    fn sessions_root_returns_parent_of_db_path() {
1347        let root = Path::new("/home/user/.pi/sessions");
1348        let index = SessionIndex::for_sessions_root(root);
1349        assert_eq!(index.sessions_root(), root);
1350    }
1351
1352    // ── reindex_all clears old rows ─────────────────────────────────
1353
1354    #[test]
1355    fn reindex_all_replaces_stale_rows() {
1356        let harness = TestHarness::new("reindex_all_replaces_stale_rows");
1357        let root = harness.temp_path("sessions");
1358        fs::create_dir_all(root.join("project")).expect("create dirs");
1359
1360        // Index two sessions manually
1361        let index = SessionIndex::for_sessions_root(&root);
1362
1363        let path_a = harness.temp_path("sessions/project/a.jsonl");
1364        let header_a = make_header("id-a", "cwd-a");
1365        write_session_jsonl(&path_a, &header_a, &[make_user_entry(None, "m1", "a")]);
1366
1367        let path_b = harness.temp_path("sessions/project/b.jsonl");
1368        let header_b = make_header("id-b", "cwd-b");
1369        write_session_jsonl(&path_b, &header_b, &[make_user_entry(None, "m1", "b")]);
1370
1371        // Index both
1372        index.reindex_all().expect("reindex_all");
1373        let all = index.list_sessions(None).expect("list all");
1374        assert_eq!(all.len(), 2);
1375
1376        // Now delete one file on disk and reindex
1377        fs::remove_file(&path_a).expect("remove file");
1378        index.reindex_all().expect("reindex_all after delete");
1379        let all = index.list_sessions(None).expect("list after reindex");
1380        assert_eq!(all.len(), 1);
1381        assert_eq!(all[0].id, "id-b");
1382    }
1383
1384    // ── Session with multiple info entries ───────────────────────────
1385
1386    #[test]
1387    fn index_session_with_session_name() {
1388        let harness = TestHarness::new("index_session_with_session_name");
1389        let root = harness.temp_path("sessions");
1390        fs::create_dir_all(&root).expect("create root dir");
1391        let index = SessionIndex::for_sessions_root(&root);
1392
1393        let session_path = harness.temp_path("sessions/project/named.jsonl");
1394        fs::create_dir_all(session_path.parent().expect("parent")).expect("create dirs");
1395        fs::write(&session_path, "data").expect("write");
1396
1397        let mut session = Session::in_memory();
1398        session.header = make_header("id-named", "cwd-named");
1399        session.path = Some(session_path);
1400        session.entries.push(make_user_entry(None, "m1", "hi"));
1401        session.entries.push(make_session_info_entry(
1402            Some("m1".to_string()),
1403            "info1",
1404            Some("My Project"),
1405        ));
1406
1407        index.index_session(&session).expect("index session");
1408
1409        let sessions = index.list_sessions(None).expect("list");
1410        assert_eq!(sessions.len(), 1);
1411        assert_eq!(sessions[0].name.as_deref(), Some("My Project"));
1412    }
1413
1414    // ── Multiple cwd filtering ──────────────────────────────────────
1415
1416    #[test]
1417    fn list_sessions_no_cwd_returns_all() {
1418        let harness = TestHarness::new("list_sessions_no_cwd_returns_all");
1419        let root = harness.temp_path("sessions");
1420        fs::create_dir_all(&root).expect("create root dir");
1421        let index = SessionIndex::for_sessions_root(&root);
1422
1423        for (id, cwd) in [("id-x", "cwd-x"), ("id-y", "cwd-y"), ("id-z", "cwd-z")] {
1424            let path = harness.temp_path(format!("sessions/project/{id}.jsonl"));
1425            fs::create_dir_all(path.parent().expect("parent")).expect("create dirs");
1426            fs::write(&path, id).expect("write");
1427
1428            let mut session = Session::in_memory();
1429            session.header = make_header(id, cwd);
1430            session.path = Some(path);
1431            session.entries.push(make_user_entry(None, "m1", id));
1432            index.index_session(&session).expect("index session");
1433        }
1434
1435        let all = index.list_sessions(None).expect("list all");
1436        assert_eq!(all.len(), 3);
1437    }
1438
1439    // ── build_meta_from_jsonl with entries having parse errors ───────
1440
1441    #[test]
1442    fn build_meta_from_jsonl_skips_bad_entry_lines() {
1443        let harness = TestHarness::new("build_meta_from_jsonl_skips_bad_entry_lines");
1444        let path = harness.temp_path("mixed.jsonl");
1445
1446        let header = make_header("id-mixed", "cwd-mixed");
1447        let good_entry = make_user_entry(None, "m1", "good");
1448        let mut content = serde_json::to_string(&header).expect("ser header");
1449        content.push('\n');
1450        content.push_str(&serde_json::to_string(&good_entry).expect("ser entry"));
1451        content.push('\n');
1452        content.push_str("not valid json\n");
1453        content.push_str(
1454            &serde_json::to_string(&make_user_entry(Some("m1".to_string()), "m2", "another"))
1455                .expect("ser entry"),
1456        );
1457        content.push('\n');
1458
1459        fs::write(&path, content).expect("write");
1460
1461        let meta = build_meta_from_jsonl(&path).expect("build_meta");
1462        // Bad line is skipped, so we get 2 messages
1463        assert_eq!(meta.message_count, 2);
1464    }
1465
1466    #[test]
1467    fn build_meta_from_jsonl_errors_on_invalid_utf8_entry_line() {
1468        let harness = TestHarness::new("build_meta_from_jsonl_errors_on_invalid_utf8_entry_line");
1469        let path = harness.temp_path("invalid_utf8.jsonl");
1470
1471        let header = make_header("id-invalid", "cwd-invalid");
1472        let mut bytes = serde_json::to_vec(&header).expect("serialize header");
1473        bytes.push(b'\n');
1474        bytes.extend_from_slice(br#"{"type":"message","message":{"role":"user","content":"ok"}}"#);
1475        bytes.push(b'\n');
1476        bytes.extend_from_slice(&[0xFF, 0xFE, b'\n']);
1477
1478        fs::write(&path, bytes).expect("write");
1479
1480        let err = build_meta_from_jsonl(&path).expect_err("invalid utf8 should error");
1481        assert!(
1482            matches!(err, Error::Session(ref msg) if msg.contains("Read session entry line")),
1483            "Expected entry line read error, got {err:?}"
1484        );
1485    }
1486
1487    #[test]
1488    fn index_session_snapshot_rejects_message_count_over_i64_max() {
1489        let harness = TestHarness::new("index_session_snapshot_rejects_message_count_over_i64_max");
1490        let root = harness.temp_path("sessions");
1491        fs::create_dir_all(root.join("project")).expect("create project dir");
1492        let index = SessionIndex::for_sessions_root(&root);
1493
1494        let path = root.join("project").join("overflow.jsonl");
1495        fs::write(&path, "").expect("write session payload");
1496
1497        let header = make_header("id-overflow", "cwd-overflow");
1498        let err = index
1499            .index_session_snapshot(&path, &header, (i64::MAX as u64) + 1, None)
1500            .expect_err("out-of-range message_count should error");
1501        assert!(
1502            matches!(err, Error::Session(ref msg) if msg.contains("message_count exceeds SQLite INTEGER range")),
1503            "expected out-of-range message_count error, got {err:?}"
1504        );
1505    }
1506
1507    proptest! {
1508        #![proptest_config(ProptestConfig { cases: 128, .. ProptestConfig::default() })]
1509
1510        #[test]
1511        fn proptest_list_sessions_handles_arbitrary_sql_rows(
1512            rows in prop::collection::vec(arbitrary_meta_row_strategy(), 1..16)
1513        ) {
1514            let harness = TestHarness::new("proptest_list_sessions_handles_arbitrary_sql_rows");
1515            let root = harness.temp_path("sessions");
1516            fs::create_dir_all(&root).expect("create root dir");
1517            let index = SessionIndex::for_sessions_root(&root);
1518
1519            let expected_by_path: HashMap<String, ArbitraryMetaRow> = rows
1520                .iter()
1521                .cloned()
1522                .enumerate()
1523                .map(|(idx, row)| (format!("/tmp/pi-session-index-{idx}.jsonl"), row))
1524                .collect();
1525
1526            index
1527                .with_lock(|conn| {
1528                    init_schema(conn)?;
1529                    conn.execute_sync("DELETE FROM sessions", &[])
1530                        .map_err(|err| Error::session(format!("delete sessions: {err}")))?;
1531
1532                    for (idx, row) in rows.iter().enumerate() {
1533                        let path = format!("/tmp/pi-session-index-{idx}.jsonl");
1534                        conn.execute_sync(
1535                            "INSERT INTO sessions (path,id,cwd,timestamp,message_count,last_modified_ms,size_bytes,name)
1536                             VALUES (?1,?2,?3,?4,?5,?6,?7,?8)",
1537                            &[
1538                                Value::Text(path),
1539                                Value::Text(row.id.clone()),
1540                                Value::Text(row.cwd.clone()),
1541                                Value::Text(row.timestamp.clone()),
1542                                Value::BigInt(row.message_count),
1543                                Value::BigInt(row.last_modified_ms),
1544                                Value::BigInt(row.size_bytes),
1545                                row.name.clone().map_or(Value::Null, Value::Text),
1546                            ],
1547                        )
1548                        .map_err(|err| Error::session(format!("insert session row {idx}: {err}")))?;
1549                    }
1550
1551                    Ok(())
1552                })
1553                .expect("seed session rows");
1554
1555            let listed = index.list_sessions(None).expect("list all sessions");
1556            prop_assert_eq!(listed.len(), rows.len());
1557            for pair in listed.windows(2) {
1558                prop_assert!(pair[0].last_modified_ms >= pair[1].last_modified_ms);
1559            }
1560
1561            for meta in &listed {
1562                let expected = expected_by_path
1563                    .get(&meta.path)
1564                    .expect("expected row should exist");
1565                prop_assert_eq!(&meta.id, &expected.id);
1566                prop_assert_eq!(&meta.cwd, &expected.cwd);
1567                prop_assert_eq!(&meta.timestamp, &expected.timestamp);
1568                prop_assert_eq!(meta.message_count, u64::try_from(expected.message_count).unwrap_or(0));
1569                prop_assert_eq!(meta.size_bytes, u64::try_from(expected.size_bytes).unwrap_or(0));
1570                prop_assert_eq!(&meta.name, &expected.name);
1571            }
1572
1573            let filtered = index
1574                .list_sessions(Some("cwd-a"))
1575                .expect("list cwd-a sessions");
1576            let expected_filtered = rows.iter().filter(|row| row.cwd == "cwd-a").count();
1577            prop_assert_eq!(filtered.len(), expected_filtered);
1578            prop_assert!(filtered.iter().all(|meta| meta.cwd == "cwd-a"));
1579            for pair in filtered.windows(2) {
1580                prop_assert!(pair[0].last_modified_ms >= pair[1].last_modified_ms);
1581            }
1582        }
1583    }
1584
1585    proptest! {
1586        #![proptest_config(ProptestConfig { cases: 128, .. ProptestConfig::default() })]
1587
1588        #[test]
1589        fn proptest_index_session_snapshot_roundtrip_metadata(
1590            id in ident_strategy(),
1591            cwd in cwd_strategy(),
1592            timestamp in timestamp_strategy(),
1593            message_count in any::<u64>(),
1594            name in optional_name_strategy(),
1595            content in prop::collection::vec(any::<u8>(), 0..256)
1596        ) {
1597            let harness = TestHarness::new("proptest_index_session_snapshot_roundtrip_metadata");
1598            let root = harness.temp_path("sessions");
1599            fs::create_dir_all(root.join("project")).expect("create project dir");
1600            let index = SessionIndex::for_sessions_root(&root);
1601
1602            let path = root.join("project").join(format!("{id}.jsonl"));
1603            fs::write(&path, &content).expect("write session payload");
1604
1605            let mut header = make_header(&id, &cwd);
1606            header.timestamp = timestamp.clone();
1607            let index_result = index.index_session_snapshot(&path, &header, message_count, name.clone());
1608            if message_count > i64::MAX as u64 {
1609                prop_assert!(
1610                    index_result.is_err(),
1611                    "expected out-of-range message_count to fail indexing"
1612                );
1613            } else {
1614                index_result.expect("index snapshot");
1615
1616                let listed = index
1617                    .list_sessions(Some(&cwd))
1618                    .expect("list sessions for cwd");
1619                prop_assert_eq!(listed.len(), 1);
1620
1621                let meta = &listed[0];
1622                let expected_count = message_count;
1623                prop_assert_eq!(&meta.id, &id);
1624                prop_assert_eq!(&meta.cwd, &cwd);
1625                prop_assert_eq!(&meta.timestamp, &timestamp);
1626                prop_assert_eq!(&meta.path, &path.display().to_string());
1627                prop_assert_eq!(meta.message_count, expected_count);
1628                prop_assert_eq!(meta.size_bytes, content.len() as u64);
1629                prop_assert_eq!(&meta.name, &name);
1630                prop_assert!(meta.last_modified_ms >= 0);
1631
1632                let other_cwd = index
1633                    .list_sessions(Some("definitely-not-this-cwd"))
1634                    .expect("list sessions for unmatched cwd");
1635                prop_assert!(other_cwd.is_empty());
1636            }
1637        }
1638    }
1639}