1use 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 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, ¶ms)
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 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 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 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
277pub(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 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 #[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 assert_eq!(name.as_deref(), Some("My Session"));
1103 }
1104
1105 #[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); 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 #[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 #[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 #[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 assert!(parsed > 1_577_836_800_000, "Epoch ms should be after 2020");
1186 }
1187
1188 #[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 index
1227 .delete_session_path(Path::new("/nonexistent/session.jsonl"))
1228 .expect("delete nonexistent should succeed");
1229 }
1230
1231 #[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 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 assert!(!index.should_reindex(Duration::from_secs(3600)));
1253 }
1254
1255 #[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 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 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 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 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 let result = index.reindex_if_stale(Duration::ZERO).expect("reindex");
1300 assert!(result, "Should reindex with zero max_age");
1301 }
1302
1303 #[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 #[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 #[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 #[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 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.reindex_all().expect("reindex_all");
1373 let all = index.list_sessions(None).expect("list all");
1374 assert_eq!(all.len(), 2);
1375
1376 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 #[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 #[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 #[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 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, ×tamp);
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}