1use std::cmp::Reverse;
6use std::collections::HashMap;
7use std::fmt::Write;
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use bubbletea::{Cmd, KeyMsg, KeyType, Message, Program, quit};
12
13use crate::config::Config;
14use crate::error::{Error, Result};
15use crate::session::{Session, encode_cwd};
16use crate::session_index::session_file_stats;
17use crate::session_index::{SessionIndex, SessionMeta, build_meta_from_file, is_session_file_path};
18use crate::theme::{Theme, TuiStyles};
19
20pub fn format_time(timestamp: &str) -> String {
22 chrono::DateTime::parse_from_rfc3339(timestamp).map_or_else(
23 |_| timestamp.to_string(),
24 |dt| dt.format("%Y-%m-%d %H:%M").to_string(),
25 )
26}
27
28#[must_use]
30pub fn truncate_session_id(session_id: &str, max_chars: usize) -> &str {
31 if max_chars == 0 {
32 return "";
33 }
34 let end = session_id
35 .char_indices()
36 .nth(max_chars)
37 .map_or(session_id.len(), |(idx, _)| idx);
38 &session_id[..end]
39}
40
41#[derive(bubbletea::Model)]
43pub struct SessionPicker {
44 sessions: Vec<SessionMeta>,
45 selected: usize,
46 chosen: Option<usize>,
47 cancelled: bool,
48 confirm_delete: Option<usize>,
49 status_message: Option<String>,
50 sessions_root: Option<PathBuf>,
51 styles: TuiStyles,
52}
53
54impl SessionPicker {
55 #[allow(clippy::missing_const_for_fn)] #[must_use]
58 pub fn new(sessions: Vec<SessionMeta>) -> Self {
59 let theme = Theme::dark();
60 let styles = theme.tui_styles();
61 Self {
62 sessions,
63 selected: 0,
64 chosen: None,
65 cancelled: false,
66 confirm_delete: None,
67 status_message: None,
68 sessions_root: None,
69 styles,
70 }
71 }
72
73 #[must_use]
74 pub fn with_theme(sessions: Vec<SessionMeta>, theme: &Theme) -> Self {
75 let styles = theme.tui_styles();
76 Self {
77 sessions,
78 selected: 0,
79 chosen: None,
80 cancelled: false,
81 confirm_delete: None,
82 status_message: None,
83 sessions_root: None,
84 styles,
85 }
86 }
87
88 #[must_use]
89 pub fn with_theme_and_root(
90 sessions: Vec<SessionMeta>,
91 theme: &Theme,
92 sessions_root: PathBuf,
93 ) -> Self {
94 let styles = theme.tui_styles();
95 Self {
96 sessions,
97 selected: 0,
98 chosen: None,
99 cancelled: false,
100 confirm_delete: None,
101 status_message: None,
102 sessions_root: Some(sessions_root),
103 styles,
104 }
105 }
106
107 pub fn selected_path(&self) -> Option<&str> {
109 self.chosen
110 .and_then(|i| self.sessions.get(i))
111 .map(|s| s.path.as_str())
112 }
113
114 pub const fn was_cancelled(&self) -> bool {
116 self.cancelled
117 }
118
119 #[allow(clippy::unused_self, clippy::missing_const_for_fn)]
120 fn init(&self) -> Option<Cmd> {
121 None
122 }
123
124 #[allow(clippy::needless_pass_by_value)] pub fn update(&mut self, msg: Message) -> Option<Cmd> {
126 if let Some(key) = msg.downcast_ref::<KeyMsg>() {
127 if self.confirm_delete.is_some() {
128 return self.handle_delete_prompt(key);
129 }
130 match key.key_type {
131 KeyType::Up if self.selected > 0 => {
132 self.selected -= 1;
133 }
134 KeyType::Down if self.selected < self.sessions.len().saturating_sub(1) => {
135 self.selected += 1;
136 }
137 KeyType::Runes if key.runes == ['k'] && self.selected > 0 => {
138 self.selected -= 1;
139 }
140 KeyType::Runes
141 if key.runes == ['j']
142 && self.selected < self.sessions.len().saturating_sub(1) =>
143 {
144 self.selected += 1;
145 }
146 KeyType::Enter => {
147 if !self.sessions.is_empty() {
148 self.chosen = Some(self.selected);
149 }
150 return Some(quit());
151 }
152 KeyType::Esc | KeyType::CtrlC => {
153 self.cancelled = true;
154 return Some(quit());
155 }
156 KeyType::Runes if key.runes == ['q'] => {
157 self.cancelled = true;
158 return Some(quit());
159 }
160 KeyType::CtrlD if !self.sessions.is_empty() => {
161 self.confirm_delete = Some(self.selected);
162 self.status_message = Some("Delete session? Press y/n to confirm.".to_string());
163 }
164 _ => {}
165 }
166 }
167 None
168 }
169
170 fn handle_delete_prompt(&mut self, key: &KeyMsg) -> Option<Cmd> {
171 match key.key_type {
172 KeyType::Runes if key.runes == ['y'] || key.runes == ['Y'] => {
173 if let Some(index) = self.confirm_delete.take() {
174 if let Err(err) = self.delete_session_at(index) {
175 self.status_message = Some(err.to_string());
176 } else {
177 self.status_message = Some("Session deleted.".to_string());
178 if self.sessions.is_empty() {
179 self.cancelled = true;
180 return Some(quit());
181 }
182 }
183 }
184 }
185 KeyType::Runes if key.runes == ['n'] || key.runes == ['N'] => {
186 self.confirm_delete = None;
187 self.status_message = None;
188 }
189 KeyType::Esc | KeyType::CtrlC => {
190 self.confirm_delete = None;
191 self.status_message = None;
192 }
193 _ => {}
194 }
195 None
196 }
197
198 fn delete_session_at(&mut self, index: usize) -> Result<()> {
199 let Some(meta) = self.sessions.get(index) else {
200 return Ok(());
201 };
202 let path = PathBuf::from(&meta.path);
203 delete_session_file(&path)?;
204 if let Some(root) = self.sessions_root.as_ref() {
205 let index = SessionIndex::for_sessions_root(root);
206 let _ = index.delete_session_path(&path);
207 }
208 self.sessions.remove(index);
209 if self.selected >= self.sessions.len() {
210 self.selected = self.sessions.len().saturating_sub(1);
211 }
212 Ok(())
213 }
214
215 pub fn view(&self) -> String {
216 let mut output = String::new();
217
218 let _ = writeln!(
220 output,
221 "\n {}\n",
222 self.styles.title.render("Select a session to resume")
223 );
224
225 if self.sessions.is_empty() {
226 let _ = writeln!(
227 output,
228 " {}",
229 self.styles
230 .muted
231 .render("No sessions found for this project.")
232 );
233 } else {
234 let _ = writeln!(
236 output,
237 " {:<20} {:<30} {:<8} {}",
238 self.styles.muted_bold.render("Time"),
239 self.styles.muted_bold.render("Name"),
240 self.styles.muted_bold.render("Messages"),
241 self.styles.muted_bold.render("Session ID")
242 );
243 output.push_str(" ");
244 output.push_str(&"-".repeat(78));
245 output.push('\n');
246
247 for (i, session) in self.sessions.iter().enumerate() {
249 let is_selected = i == self.selected;
250
251 let prefix = if is_selected { ">" } else { " " };
252 let time = format_time(&session.timestamp);
253 let name = session
254 .name
255 .as_deref()
256 .unwrap_or("-")
257 .chars()
258 .take(28)
259 .collect::<String>();
260 let messages = session.message_count.to_string();
261 let id = truncate_session_id(&session.id, 8);
262
263 let _ = writeln!(
264 output,
265 "{prefix} {}",
266 if is_selected {
267 self.styles
268 .selection
269 .render(&format!(" {time:<20} {name:<30} {messages:<8} {id}"))
270 } else {
271 format!(" {time:<20} {name:<30} {messages:<8} {id}")
272 }
273 );
274 }
275 }
276
277 output.push('\n');
279 let _ = writeln!(
280 output,
281 " {}",
282 self.styles
283 .muted
284 .render("↑/↓/j/k: navigate Enter: select Ctrl+D: delete Esc/q: cancel")
285 );
286 if let Some(message) = &self.status_message {
287 let _ = writeln!(output, " {}", self.styles.warning_bold.render(message));
288 }
289
290 output
291 }
292}
293
294pub fn list_sessions_for_cwd() -> Vec<SessionMeta> {
296 let Ok(cwd) = std::env::current_dir() else {
297 return Vec::new();
298 };
299 list_sessions_for_project(&cwd, None)
300}
301
302pub async fn pick_session(override_dir: Option<&Path>) -> Option<Session> {
304 let cwd = std::env::current_dir().ok()?;
305 let base_dir = override_dir.map_or_else(Config::sessions_dir, PathBuf::from);
306 let sessions = list_sessions_for_project(&cwd, override_dir);
307
308 if sessions.is_empty() {
309 return None;
310 }
311
312 if sessions.len() == 1 {
313 let mut session = Session::open(&sessions[0].path).await.ok()?;
315 session.session_dir = Some(base_dir);
316 return Some(session);
317 }
318
319 let config = Config::load().unwrap_or_default();
320 let theme = Theme::resolve(&config, &cwd);
321 let picker = SessionPicker::with_theme_and_root(sessions, &theme, base_dir.clone());
322
323 let result = Program::new(picker).with_alt_screen().run();
325
326 match result {
327 Ok(picker) => {
328 if picker.was_cancelled() {
329 return None;
330 }
331
332 if let Some(path) = picker.selected_path() {
333 let mut session = Session::open(path).await.ok()?;
334 session.session_dir = Some(base_dir);
335 Some(session)
336 } else {
337 None
338 }
339 }
340 Err(_) => None,
341 }
342}
343
344pub fn list_sessions_for_project(cwd: &Path, override_dir: Option<&Path>) -> Vec<SessionMeta> {
345 let base_dir = override_dir.map_or_else(Config::sessions_dir, PathBuf::from);
346 let project_session_dir = base_dir.join(encode_cwd(cwd));
347 let cwd_key = cwd.display().to_string();
348 let index = SessionIndex::for_sessions_root(&base_dir);
349 let mut sessions = index.list_sessions(Some(&cwd_key)).unwrap_or_default();
350 let project_session_dir_missing = indexed_session_path_is_missing(&project_session_dir);
351
352 if !project_session_dir_missing && sessions.is_empty() && index.reindex_all().is_ok() {
353 sessions = index.list_sessions(Some(&cwd_key)).unwrap_or_default();
354 }
355
356 let mut missing_paths = Vec::new();
357 let mut by_path = HashMap::new();
358 for meta in sessions {
359 let path = PathBuf::from(&meta.path);
360 if indexed_session_path_is_missing(&path) {
361 missing_paths.push(path);
362 } else {
363 by_path.insert(meta.path.clone(), meta);
364 }
365 }
366
367 for path in &missing_paths {
368 let _ = index.delete_session_path(path);
369 }
370
371 if project_session_dir_missing {
372 return Vec::new();
373 }
374
375 let scanned = scan_sessions_on_disk(&project_session_dir, &by_path);
376 for path in &scanned.failed_paths {
377 let _ = index.delete_session_path(path);
378 by_path.remove(&path.display().to_string());
379 }
380
381 for meta in scanned.metas {
382 let _ = index.upsert_session_meta(meta.clone());
383 by_path.insert(meta.path.clone(), meta);
384 }
385
386 sessions = by_path.into_values().collect();
387 sessions.sort_by_key(|m| Reverse(m.last_modified_ms));
388 sessions.truncate(50);
389 sessions
390}
391
392fn indexed_session_path_is_missing(path: &Path) -> bool {
393 match path.try_exists() {
394 Ok(exists) => !exists,
395 Err(err) => {
396 tracing::warn!(
397 path = %path.display(),
398 error = %err,
399 "Failed to determine whether indexed session path exists; deferring prune"
400 );
401 false
402 }
403 }
404}
405
406struct ScanSessionsResult {
407 metas: Vec<SessionMeta>,
408 failed_paths: Vec<PathBuf>,
409}
410
411#[cfg(test)]
412thread_local! {
413 static SESSION_SCAN_PARSE_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
414}
415
416#[cfg(test)]
417fn reset_session_scan_parse_count() {
418 SESSION_SCAN_PARSE_COUNT.with(|count| count.set(0));
419}
420
421#[cfg(test)]
422fn take_session_scan_parse_count() -> usize {
423 SESSION_SCAN_PARSE_COUNT.with(|count| {
424 let value = count.get();
425 count.set(0);
426 value
427 })
428}
429
430fn build_scanned_meta(path: &Path) -> crate::error::Result<SessionMeta> {
431 #[cfg(test)]
432 SESSION_SCAN_PARSE_COUNT.with(|count| count.set(count.get().saturating_add(1)));
433
434 build_meta_from_file(path)
435}
436
437fn cached_meta_matches_disk(meta: &SessionMeta, path: &Path) -> bool {
438 let Ok((last_modified_ms, size_bytes)) = session_file_stats(path) else {
439 return false;
440 };
441 meta.last_modified_ms == last_modified_ms && meta.size_bytes == size_bytes
442}
443
444fn scan_sessions_on_disk(
445 project_session_dir: &Path,
446 cached_by_path: &HashMap<String, SessionMeta>,
447) -> ScanSessionsResult {
448 let mut out = Vec::new();
449 let mut failed_paths = Vec::new();
450 let Ok(entries) = fs::read_dir(project_session_dir) else {
451 return ScanSessionsResult {
452 metas: out,
453 failed_paths,
454 };
455 };
456
457 for entry in entries.flatten() {
458 let path = entry.path();
459 if is_session_file_path(&path) {
460 let path_key = path.display().to_string();
461 if cached_by_path
462 .get(&path_key)
463 .is_some_and(|meta| cached_meta_matches_disk(meta, &path))
464 {
465 continue;
466 }
467
468 match build_scanned_meta(&path) {
469 Ok(meta) => out.push(meta),
470 Err(_) => failed_paths.push(path),
471 }
472 }
473 }
474
475 ScanSessionsResult {
476 metas: out,
477 failed_paths,
478 }
479}
480
481pub(crate) fn delete_session_file(path: &Path) -> Result<()> {
482 delete_session_file_with_trash_cmd(path, "trash")
483}
484
485fn delete_session_file_with_trash_cmd(path: &Path, trash_cmd: &str) -> Result<()> {
486 if try_trash_with_cmd(path, trash_cmd) {
487 remove_sqlite_sidecars_best_effort(path, trash_cmd);
488 remove_sidecar_dir_best_effort(&crate::session_store_v2::v2_sidecar_path(path), trash_cmd);
489 return Ok(());
490 }
491
492 match fs::remove_file(path) {
493 Ok(()) => {}
494 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
495 Err(err) => {
496 return Err(Error::session(format!(
497 "Failed to delete session {}: {err}",
498 path.display()
499 )));
500 }
501 }
502
503 remove_sqlite_sidecars_best_effort(path, trash_cmd);
504 remove_sidecar_dir_best_effort(&crate::session_store_v2::v2_sidecar_path(path), trash_cmd);
505 Ok(())
506}
507
508fn sqlite_auxiliary_paths(path: &Path) -> [PathBuf; 2] {
509 ["-wal", "-shm"].map(|suffix| {
510 let mut candidate = path.as_os_str().to_os_string();
511 candidate.push(suffix);
512 PathBuf::from(candidate)
513 })
514}
515
516#[cfg(feature = "sqlite-sessions")]
517fn remove_sqlite_sidecars_best_effort(path: &Path, trash_cmd: &str) {
518 if path.extension().and_then(|ext| ext.to_str()) == Some("sqlite") {
519 for auxiliary_path in sqlite_auxiliary_paths(path) {
520 if !auxiliary_path.exists() {
521 continue;
522 }
523 if try_trash_with_cmd(&auxiliary_path, trash_cmd) {
524 continue;
525 }
526 if let Err(err) = fs::remove_file(&auxiliary_path) {
527 if err.kind() != std::io::ErrorKind::NotFound {
528 tracing::warn!(
529 path = %auxiliary_path.display(),
530 error = %err,
531 "Failed to remove SQLite sidecar"
532 );
533 }
534 }
535 }
536 }
537}
538
539#[cfg(not(feature = "sqlite-sessions"))]
540const fn remove_sqlite_sidecars_best_effort(_path: &Path, _trash_cmd: &str) {}
541
542fn remove_sidecar_dir_best_effort(sidecar_path: &Path, trash_cmd: &str) {
543 if !sidecar_path.exists() {
544 return;
545 }
546
547 if try_trash_with_cmd(sidecar_path, trash_cmd) {
548 return;
549 }
550
551 if let Err(err) = fs::remove_dir_all(sidecar_path) {
552 tracing::warn!(
553 path = %sidecar_path.display(),
554 error = %err,
555 "Failed to remove session sidecar"
556 );
557 }
558}
559
560fn try_trash_with_cmd(path: &Path, trash_cmd: &str) -> bool {
561 match std::process::Command::new(trash_cmd)
562 .arg(path)
563 .stdin(std::process::Stdio::null())
564 .status()
565 {
566 Ok(status) if status.success() => true,
567 Ok(status) => {
568 tracing::warn!(
569 path = %path.display(),
570 exit = status.code().unwrap_or(-1),
571 "trash command failed; falling back to direct file removal"
572 );
573 false
574 }
575 Err(err) if err.kind() == std::io::ErrorKind::NotFound => false,
576 Err(err) => {
577 tracing::warn!(
578 path = %path.display(),
579 error = %err,
580 "trash command invocation failed; falling back to direct file removal"
581 );
582 false
583 }
584 }
585}
586
587#[cfg(test)]
588mod tests {
589 use super::*;
590 use crate::session::SessionHeader;
591
592 #[cfg(feature = "sqlite-sessions")]
593 use crate::model::UserContent;
594 #[cfg(feature = "sqlite-sessions")]
595 use crate::session::{SessionMessage, SessionStoreKind};
596 #[cfg(feature = "sqlite-sessions")]
597 use asupersync::runtime::RuntimeBuilder;
598 use sqlmodel_core::Value;
599 use sqlmodel_sqlite::{OpenFlags, SqliteConfig, SqliteConnection};
600 #[cfg(feature = "sqlite-sessions")]
601 use std::future::Future;
602
603 fn make_meta(path: &Path) -> SessionMeta {
604 SessionMeta {
605 path: path.display().to_string(),
606 id: "sess".to_string(),
607 cwd: "/tmp".to_string(),
608 timestamp: "2025-01-15T10:00:00.000Z".to_string(),
609 message_count: 1,
610 last_modified_ms: 1000,
611 size_bytes: 100,
612 name: None,
613 }
614 }
615
616 fn key_msg(key_type: KeyType, runes: Vec<char>) -> Message {
617 Message::new(KeyMsg {
618 key_type,
619 runes,
620 alt: false,
621 paste: false,
622 })
623 }
624
625 #[cfg(feature = "sqlite-sessions")]
626 fn run_async<T>(future: impl Future<Output = T>) -> T {
627 let runtime = RuntimeBuilder::current_thread()
628 .build()
629 .expect("build runtime");
630 runtime.block_on(future)
631 }
632
633 #[test]
634 fn test_format_time() {
635 let ts = "2025-01-15T10:30:00.000Z";
636 let formatted = format_time(ts);
637 assert!(formatted.contains("2025-01-15"));
638 assert!(formatted.contains("10:30"));
639 }
640
641 #[test]
642 fn test_format_time_invalid_returns_input() {
643 let ts = "not-a-timestamp";
644 assert_eq!(format_time(ts), ts);
645 }
646
647 #[test]
648 fn truncate_session_id_handles_unicode_boundaries() {
649 assert_eq!(truncate_session_id("abcdefghijk", 8), "abcdefgh");
650 assert_eq!(truncate_session_id("αβγδεζηθικ", 8), "αβγδεζηθ");
651 }
652
653 #[test]
654 fn test_is_session_file_path() {
655 assert!(is_session_file_path(Path::new("/tmp/sess.jsonl")));
656 assert!(!is_session_file_path(Path::new("/tmp/sess.txt")));
657 assert!(!is_session_file_path(Path::new("/tmp/noext")));
658 #[cfg(feature = "sqlite-sessions")]
659 assert!(is_session_file_path(Path::new("/tmp/sess.sqlite")));
660 }
661
662 #[test]
663 fn test_session_picker_navigation() {
664 let sessions = vec![
665 SessionMeta {
666 path: "/test/a.jsonl".to_string(),
667 id: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee".to_string(),
668 cwd: "/test".to_string(),
669 timestamp: "2025-01-15T10:00:00.000Z".to_string(),
670 message_count: 1,
671 last_modified_ms: 1000,
672 size_bytes: 100,
673 name: None,
674 },
675 SessionMeta {
676 path: "/test/b.jsonl".to_string(),
677 id: "bbbbbbbb-cccc-dddd-eeee-ffffffffffff".to_string(),
678 cwd: "/test".to_string(),
679 timestamp: "2025-01-15T11:00:00.000Z".to_string(),
680 message_count: 2,
681 last_modified_ms: 2000,
682 size_bytes: 200,
683 name: Some("Test session".to_string()),
684 },
685 ];
686
687 let mut picker = SessionPicker::new(sessions);
688 assert_eq!(picker.selected, 0);
689
690 picker.update(key_msg(KeyType::Down, vec![]));
692 assert_eq!(picker.selected, 1);
693
694 picker.update(key_msg(KeyType::Up, vec![]));
696 assert_eq!(picker.selected, 0);
697 }
698
699 #[test]
700 fn test_session_picker_vim_keys() {
701 let sessions = vec![
702 SessionMeta {
703 path: "/test/a.jsonl".to_string(),
704 id: "aaaaaaaa".to_string(),
705 cwd: "/test".to_string(),
706 timestamp: "2025-01-15T10:00:00.000Z".to_string(),
707 message_count: 1,
708 last_modified_ms: 1000,
709 size_bytes: 100,
710 name: None,
711 },
712 SessionMeta {
713 path: "/test/b.jsonl".to_string(),
714 id: "bbbbbbbb".to_string(),
715 cwd: "/test".to_string(),
716 timestamp: "2025-01-15T11:00:00.000Z".to_string(),
717 message_count: 2,
718 last_modified_ms: 2000,
719 size_bytes: 200,
720 name: None,
721 },
722 ];
723
724 let mut picker = SessionPicker::new(sessions);
725 assert_eq!(picker.selected, 0);
726
727 picker.update(key_msg(KeyType::Runes, vec!['j']));
729 assert_eq!(picker.selected, 1);
730
731 picker.update(key_msg(KeyType::Runes, vec!['k']));
733 assert_eq!(picker.selected, 0);
734 }
735
736 #[test]
737 fn session_picker_delete_prompt_and_cancel() {
738 let tmp = tempfile::tempdir().expect("tempdir");
739 let session_path = tmp.path().join("sess.jsonl");
740 fs::write(&session_path, "test").expect("write session");
741
742 let sessions = vec![make_meta(&session_path)];
743 let mut picker = SessionPicker::new(sessions);
744
745 picker.update(key_msg(KeyType::CtrlD, vec![]));
746 assert!(picker.confirm_delete.is_some());
747
748 picker.update(key_msg(KeyType::Runes, vec!['n']));
749 assert!(picker.confirm_delete.is_none());
750 assert!(session_path.exists());
751 }
752
753 #[test]
754 fn session_picker_delete_confirm_removes_file() {
755 let tmp = tempfile::tempdir().expect("tempdir");
756 let session_path = tmp.path().join("sess.jsonl");
757 fs::write(&session_path, "test").expect("write session");
758
759 let sessions = vec![make_meta(&session_path)];
760 let mut picker = SessionPicker::new(sessions);
761
762 picker.update(key_msg(KeyType::CtrlD, vec![]));
763
764 picker.update(key_msg(KeyType::Runes, vec!['y']));
765
766 assert!(!session_path.exists());
767 assert!(picker.sessions.is_empty());
768 }
769
770 #[test]
771 fn session_picker_navigation_bounds() {
772 let sessions = vec![
773 SessionMeta {
774 path: "/test/a.jsonl".to_string(),
775 id: "aaaaaaaa".to_string(),
776 cwd: "/test".to_string(),
777 timestamp: "2025-01-15T10:00:00.000Z".to_string(),
778 message_count: 1,
779 last_modified_ms: 1000,
780 size_bytes: 100,
781 name: None,
782 },
783 SessionMeta {
784 path: "/test/b.jsonl".to_string(),
785 id: "bbbbbbbb".to_string(),
786 cwd: "/test".to_string(),
787 timestamp: "2025-01-15T11:00:00.000Z".to_string(),
788 message_count: 2,
789 last_modified_ms: 2000,
790 size_bytes: 200,
791 name: None,
792 },
793 ];
794
795 let mut picker = SessionPicker::new(sessions);
796 picker.update(key_msg(KeyType::Up, vec![]));
797 assert_eq!(picker.selected, 0);
798
799 picker.update(key_msg(KeyType::Down, vec![]));
800 picker.update(key_msg(KeyType::Down, vec![]));
801 assert_eq!(picker.selected, 1);
802 }
803
804 #[test]
805 fn session_picker_enter_selects_current_session() {
806 let sessions = vec![
807 SessionMeta {
808 path: "/test/a.jsonl".to_string(),
809 id: "aaaaaaaa".to_string(),
810 cwd: "/test".to_string(),
811 timestamp: "2025-01-15T10:00:00.000Z".to_string(),
812 message_count: 1,
813 last_modified_ms: 1000,
814 size_bytes: 100,
815 name: None,
816 },
817 SessionMeta {
818 path: "/test/b.jsonl".to_string(),
819 id: "bbbbbbbb".to_string(),
820 cwd: "/test".to_string(),
821 timestamp: "2025-01-15T11:00:00.000Z".to_string(),
822 message_count: 2,
823 last_modified_ms: 2000,
824 size_bytes: 200,
825 name: Some("chosen".to_string()),
826 },
827 ];
828
829 let mut picker = SessionPicker::new(sessions);
830 picker.update(key_msg(KeyType::Down, vec![]));
831 picker.update(key_msg(KeyType::Enter, vec![]));
832 assert_eq!(picker.selected_path(), Some("/test/b.jsonl"));
833 assert!(!picker.was_cancelled());
834 }
835
836 #[test]
837 fn session_picker_cancel_keys_mark_cancelled() {
838 let sessions = vec![SessionMeta {
839 path: "/test/a.jsonl".to_string(),
840 id: "aaaaaaaa".to_string(),
841 cwd: "/test".to_string(),
842 timestamp: "2025-01-15T10:00:00.000Z".to_string(),
843 message_count: 1,
844 last_modified_ms: 1000,
845 size_bytes: 100,
846 name: None,
847 }];
848
849 let mut esc_picker = SessionPicker::new(sessions.clone());
850 esc_picker.update(key_msg(KeyType::Esc, vec![]));
851 assert!(esc_picker.was_cancelled());
852
853 let mut q_picker = SessionPicker::new(sessions.clone());
854 q_picker.update(key_msg(KeyType::Runes, vec!['q']));
855 assert!(q_picker.was_cancelled());
856
857 let mut ctrl_c_picker = SessionPicker::new(sessions);
858 ctrl_c_picker.update(key_msg(KeyType::CtrlC, vec![]));
859 assert!(ctrl_c_picker.was_cancelled());
860 }
861
862 #[test]
863 fn session_picker_view_empty_and_populated_states() {
864 let empty_picker = SessionPicker::new(Vec::new());
865 let empty_view = empty_picker.view();
866 assert!(empty_view.contains("Select a session to resume"));
867 assert!(empty_view.contains("No sessions found for this project."));
868
869 let sessions = vec![SessionMeta {
870 path: "/test/a.jsonl".to_string(),
871 id: "aaaaaaaa-bbbb".to_string(),
872 cwd: "/test".to_string(),
873 timestamp: "2025-01-15T10:00:00.000Z".to_string(),
874 message_count: 3,
875 last_modified_ms: 1000,
876 size_bytes: 100,
877 name: Some("demo".to_string()),
878 }];
879 let mut populated = SessionPicker::new(sessions);
880 populated.update(key_msg(KeyType::CtrlD, vec![]));
881 let view = populated.view();
882 assert!(view.contains("Messages"));
883 assert!(view.contains("Session ID"));
884 assert!(view.contains("Delete session? Press y/n to confirm."));
885 }
886
887 #[test]
888 fn session_picker_view_handles_non_ascii_session_ids() {
889 let sessions = vec![SessionMeta {
890 path: "/test/u.jsonl".to_string(),
891 id: "αβγδεζηθι".to_string(),
892 cwd: "/test".to_string(),
893 timestamp: "2025-01-15T10:00:00.000Z".to_string(),
894 message_count: 1,
895 last_modified_ms: 1000,
896 size_bytes: 100,
897 name: Some("unicode".to_string()),
898 }];
899
900 let view = SessionPicker::new(sessions).view();
901 assert!(view.contains("αβγδεζηθ"));
902 }
903
904 #[test]
907 fn selected_path_returns_none_when_no_selection() {
908 let picker = SessionPicker::new(vec![make_meta(Path::new("/tmp/a.jsonl"))]);
909 assert!(picker.selected_path().is_none());
910 assert!(!picker.was_cancelled());
911 }
912
913 #[test]
916 fn with_theme_constructor_sets_initial_state() {
917 let theme = Theme::dark();
918 let sessions = vec![make_meta(Path::new("/tmp/a.jsonl"))];
919 let picker = SessionPicker::with_theme(sessions, &theme);
920 assert_eq!(picker.selected, 0);
921 assert!(!picker.was_cancelled());
922 assert!(picker.selected_path().is_none());
923 }
924
925 #[test]
928 fn delete_last_session_sets_cancelled_true() {
929 let tmp = tempfile::tempdir().expect("tempdir");
930 let session_path = tmp.path().join("only.jsonl");
931 fs::write(&session_path, "test").expect("write");
932
933 let mut picker = SessionPicker::new(vec![make_meta(&session_path)]);
934
935 picker.update(key_msg(KeyType::CtrlD, vec![]));
936 let cmd = picker.update(key_msg(KeyType::Runes, vec!['y']));
937 assert!(picker.was_cancelled());
938 assert!(cmd.is_some()); }
940
941 #[test]
944 fn esc_cancels_delete_prompt() {
945 let tmp = tempfile::tempdir().expect("tempdir");
946 let session_path = tmp.path().join("sess.jsonl");
947 fs::write(&session_path, "test").expect("write");
948
949 let mut picker = SessionPicker::new(vec![make_meta(&session_path)]);
950 picker.update(key_msg(KeyType::CtrlD, vec![]));
951 assert!(picker.confirm_delete.is_some());
952
953 picker.update(key_msg(KeyType::Esc, vec![]));
954 assert!(picker.confirm_delete.is_none());
955 assert!(picker.status_message.is_none());
956 }
957
958 #[test]
961 fn enter_on_empty_list_returns_quit() {
962 let mut picker = SessionPicker::new(Vec::new());
963 let cmd = picker.update(key_msg(KeyType::Enter, vec![]));
964 assert!(cmd.is_some()); assert!(picker.selected_path().is_none());
966 }
967
968 #[test]
971 fn ctrl_d_on_empty_list_is_noop() {
972 let mut picker = SessionPicker::new(Vec::new());
973 picker.update(key_msg(KeyType::CtrlD, vec![]));
974 assert!(picker.confirm_delete.is_none());
975 }
976
977 #[test]
980 fn build_meta_from_file_parses_session_file() {
981 let tmp = tempfile::tempdir().expect("tempdir");
982 let session_path = tmp.path().join("test.jsonl");
983 let mut header = SessionHeader::new();
984 header.id = "abc123".to_string();
985 header.cwd = "/work".to_string();
986 header.timestamp = "2025-06-01T12:00:00.000Z".to_string();
987 let msg1 = serde_json::json!({
988 "type": "message",
989 "timestamp": "2025-06-01T12:00:01.000Z",
990 "message": {"role": "user", "content": "hi"}
991 });
992 let msg2 = serde_json::json!({
993 "type": "message",
994 "timestamp": "2025-06-01T12:00:02.000Z",
995 "message": {"role": "user", "content": "hello again"}
996 });
997 let info = serde_json::json!({
998 "type": "session_info",
999 "timestamp": "2025-06-01T12:00:03.000Z",
1000 "name": "My Session"
1001 });
1002 let content = format!(
1003 "{}\n{}\n{}\n{}",
1004 serde_json::to_string(&header).unwrap(),
1005 serde_json::to_string(&msg1).unwrap(),
1006 serde_json::to_string(&msg2).unwrap(),
1007 serde_json::to_string(&info).unwrap(),
1008 );
1009 fs::write(&session_path, content).expect("write");
1010
1011 let meta = build_meta_from_file(&session_path).expect("parse meta");
1012 assert_eq!(meta.id, "abc123");
1013 assert_eq!(meta.cwd, "/work");
1014 assert_eq!(meta.message_count, 2);
1015 assert_eq!(meta.name.as_deref(), Some("My Session"));
1016 assert!(meta.size_bytes > 0);
1017 }
1018
1019 #[test]
1020 fn build_meta_from_file_rejects_semantically_invalid_header() {
1021 let tmp = tempfile::tempdir().expect("tempdir");
1022 let session_path = tmp.path().join("invalid.jsonl");
1023 let header = serde_json::json!({
1024 "type": "header",
1025 "id": "abc123",
1026 "cwd": "/work",
1027 "timestamp": "2025-06-01T12:00:00.000Z"
1028 });
1029 fs::write(
1030 &session_path,
1031 format!(
1032 "{}\n",
1033 serde_json::to_string(&header).expect("serialize header")
1034 ),
1035 )
1036 .expect("write");
1037
1038 let err = build_meta_from_file(&session_path).expect_err("invalid header should fail");
1039 assert!(
1040 matches!(err, crate::error::Error::Session(ref msg) if msg.contains("Invalid session header")),
1041 "expected invalid session header error, got {err:?}"
1042 );
1043 }
1044
1045 #[test]
1046 fn build_meta_from_file_empty_file_returns_error() {
1047 let tmp = tempfile::tempdir().expect("tempdir");
1048 let session_path = tmp.path().join("empty.jsonl");
1049 fs::write(&session_path, "").expect("write");
1050
1051 assert!(build_meta_from_file(&session_path).is_err());
1052 }
1053
1054 #[test]
1057 fn is_session_file_path_rejects_common_non_session_extensions() {
1058 assert!(!is_session_file_path(Path::new("/tmp/file.json")));
1059 assert!(!is_session_file_path(Path::new("/tmp/file.md")));
1060 assert!(!is_session_file_path(Path::new("/tmp/file.rs")));
1061 }
1062
1063 #[test]
1066 fn scan_sessions_on_disk_finds_valid_session_files() {
1067 let tmp = tempfile::tempdir().expect("tempdir");
1068 let session_path = tmp.path().join("session.jsonl");
1069 let mut header = SessionHeader::new();
1070 header.id = "scan-test".to_string();
1071 header.cwd = "/work".to_string();
1072 header.timestamp = "2025-06-01T12:00:00.000Z".to_string();
1073 fs::write(&session_path, serde_json::to_string(&header).unwrap()).expect("write");
1074
1075 fs::write(tmp.path().join("notes.txt"), "not a session").expect("write");
1077
1078 let found = scan_sessions_on_disk(tmp.path(), &HashMap::new());
1079 assert_eq!(found.metas.len(), 1);
1080 assert_eq!(found.metas[0].id, "scan-test");
1081 assert!(found.failed_paths.is_empty());
1082 }
1083
1084 #[test]
1085 fn scan_sessions_on_disk_nonexistent_dir_returns_empty() {
1086 let found = scan_sessions_on_disk(Path::new("/nonexistent/dir"), &HashMap::new());
1087 assert!(found.metas.is_empty());
1088 assert!(found.failed_paths.is_empty());
1089 }
1090
1091 #[test]
1092 fn scan_sessions_on_disk_skips_unchanged_cached_rows() {
1093 let tmp = tempfile::tempdir().expect("tempdir");
1094 let session_path = tmp.path().join("session.jsonl");
1095 let mut header = SessionHeader::new();
1096 header.id = "cached-scan".to_string();
1097 header.cwd = "/work".to_string();
1098 header.timestamp = "2025-06-01T12:00:00.000Z".to_string();
1099 fs::write(&session_path, serde_json::to_string(&header).unwrap()).expect("write");
1100
1101 let cached = build_meta_from_file(&session_path).expect("cached meta");
1102 let mut cached_by_path = HashMap::new();
1103 cached_by_path.insert(cached.path.clone(), cached);
1104
1105 reset_session_scan_parse_count();
1106 let found = scan_sessions_on_disk(tmp.path(), &cached_by_path);
1107
1108 assert!(found.metas.is_empty());
1109 assert!(found.failed_paths.is_empty());
1110 assert_eq!(take_session_scan_parse_count(), 0);
1111 }
1112
1113 #[test]
1114 fn list_sessions_for_project_prefers_scanned_meta_when_cached_row_is_stale() {
1115 let tmp = tempfile::tempdir().expect("tempdir");
1116 let base_dir = tmp.path().join("sessions");
1117 let cwd = tmp.path().join("repo");
1118 let project_dir = base_dir.join(encode_cwd(&cwd));
1119 fs::create_dir_all(&project_dir).expect("create project sessions");
1120
1121 let session_path = project_dir.join("stale-index.jsonl");
1122 let mut header = SessionHeader::new();
1123 header.id = "stale-index".to_string();
1124 header.cwd = cwd.display().to_string();
1125 header.timestamp = "2025-06-01T12:00:00.000Z".to_string();
1126
1127 let content = format!(
1128 "{}\n{{\"type\":\"message\"}}\n{{\"type\":\"message\"}}\n{{\"type\":\"session_info\",\"name\":\"Fresh name\"}}\n",
1129 serde_json::to_string(&header).expect("serialize header"),
1130 );
1131 fs::write(&session_path, content).expect("write session");
1132
1133 let expected = build_meta_from_file(&session_path).expect("load fresh meta");
1134 let index = SessionIndex::for_sessions_root(&base_dir);
1135 index.reindex_all().expect("seed session index");
1136
1137 let db_path = base_dir.join("session-index.sqlite");
1138 let config = SqliteConfig::file(db_path.to_string_lossy())
1139 .flags(OpenFlags::create_read_write())
1140 .busy_timeout(5000);
1141 let conn = SqliteConnection::open(&config).expect("open session index sqlite");
1142 conn.execute_sync(
1143 "UPDATE sessions
1144 SET message_count=?1, size_bytes=?2, name=?3
1145 WHERE path=?4",
1146 &[
1147 Value::BigInt(0),
1148 Value::BigInt(
1149 i64::try_from(expected.size_bytes.saturating_sub(1)).expect("size fits in i64"),
1150 ),
1151 Value::Text("Stale name".to_string()),
1152 Value::Text(session_path.display().to_string()),
1153 ],
1154 )
1155 .expect("corrupt cached row");
1156
1157 let sessions = list_sessions_for_project(&cwd, Some(&base_dir));
1158 assert_eq!(sessions.len(), 1);
1159
1160 let session = &sessions[0];
1161 assert_eq!(session.path, session_path.display().to_string());
1162 assert_eq!(session.message_count, expected.message_count);
1163 assert_eq!(session.size_bytes, expected.size_bytes);
1164 assert_eq!(session.name, expected.name);
1165 assert_eq!(session.last_modified_ms, expected.last_modified_ms);
1166 }
1167
1168 #[test]
1169 fn list_sessions_for_project_refreshes_index_after_changed_disk_session() {
1170 let tmp = tempfile::tempdir().expect("tempdir");
1171 let base_dir = tmp.path().join("sessions");
1172 let cwd = tmp.path().join("repo");
1173 let project_dir = base_dir.join(encode_cwd(&cwd));
1174 fs::create_dir_all(&project_dir).expect("create project sessions");
1175
1176 let session_path = project_dir.join("steady-state.jsonl");
1177 let mut header = SessionHeader::new();
1178 header.id = "steady-state".to_string();
1179 header.cwd = cwd.display().to_string();
1180 header.timestamp = "2025-06-01T12:00:00.000Z".to_string();
1181
1182 let initial = format!(
1183 "{}\n{{\"type\":\"message\"}}\n{{\"type\":\"session_info\",\"name\":\"Initial\"}}\n",
1184 serde_json::to_string(&header).expect("serialize header"),
1185 );
1186 fs::write(&session_path, initial).expect("write initial session");
1187
1188 let index = SessionIndex::for_sessions_root(&base_dir);
1189 index.reindex_all().expect("seed session index");
1190
1191 let refreshed = format!(
1192 "{}\n{{\"type\":\"message\"}}\n{{\"type\":\"message\"}}\n{{\"type\":\"session_info\",\"name\":\"Refreshed\"}}\n",
1193 serde_json::to_string(&header).expect("serialize header"),
1194 );
1195 fs::write(&session_path, refreshed).expect("write refreshed session");
1196
1197 reset_session_scan_parse_count();
1198 let sessions = list_sessions_for_project(&cwd, Some(&base_dir));
1199 assert_eq!(take_session_scan_parse_count(), 1);
1200 assert_eq!(sessions.len(), 1);
1201 assert_eq!(sessions[0].message_count, 2);
1202 assert_eq!(sessions[0].name.as_deref(), Some("Refreshed"));
1203
1204 let indexed = index
1205 .list_sessions(Some(&cwd.display().to_string()))
1206 .expect("list indexed sessions");
1207 assert_eq!(indexed.len(), 1);
1208 assert_eq!(indexed[0].message_count, 2);
1209 assert_eq!(indexed[0].name.as_deref(), Some("Refreshed"));
1210
1211 reset_session_scan_parse_count();
1212 let steady_state = list_sessions_for_project(&cwd, Some(&base_dir));
1213 assert_eq!(take_session_scan_parse_count(), 0);
1214 assert_eq!(steady_state.len(), 1);
1215 assert_eq!(steady_state[0].message_count, 2);
1216 assert_eq!(steady_state[0].name.as_deref(), Some("Refreshed"));
1217 }
1218
1219 #[test]
1220 fn list_sessions_for_project_evicts_cached_row_when_disk_session_is_invalid() {
1221 let tmp = tempfile::tempdir().expect("tempdir");
1222 let base_dir = tmp.path().join("sessions");
1223 let cwd = tmp.path().join("repo");
1224 let project_dir = base_dir.join(encode_cwd(&cwd));
1225 fs::create_dir_all(&project_dir).expect("create project sessions");
1226
1227 let session_path = project_dir.join("stale-invalid.jsonl");
1228 let mut header = SessionHeader::new();
1229 header.id = "stale-invalid".to_string();
1230 header.cwd = cwd.display().to_string();
1231 header.timestamp = "2025-06-01T12:00:00.000Z".to_string();
1232 fs::write(
1233 &session_path,
1234 format!(
1235 "{}\n{{\"type\":\"message\"}}\n",
1236 serde_json::to_string(&header).expect("serialize header"),
1237 ),
1238 )
1239 .expect("write session");
1240
1241 let index = SessionIndex::for_sessions_root(&base_dir);
1242 index.reindex_all().expect("seed session index");
1243
1244 let invalid_header = serde_json::json!({
1245 "type": "header",
1246 "id": "stale-invalid",
1247 "cwd": cwd.display().to_string(),
1248 "timestamp": "2025-06-01T12:00:00.000Z"
1249 });
1250 fs::write(
1251 &session_path,
1252 format!(
1253 "{}\n{{\"type\":\"message\"}}\n",
1254 serde_json::to_string(&invalid_header).expect("serialize invalid header"),
1255 ),
1256 )
1257 .expect("corrupt session");
1258
1259 let sessions = list_sessions_for_project(&cwd, Some(&base_dir));
1260 assert!(sessions.is_empty());
1261
1262 let indexed = index
1263 .list_sessions(Some(&cwd.display().to_string()))
1264 .expect("list sessions");
1265 assert!(indexed.is_empty());
1266 }
1267
1268 #[test]
1269 fn list_sessions_for_project_prunes_index_when_project_dir_is_missing() {
1270 let tmp = tempfile::tempdir().expect("tempdir");
1271 let base_dir = tmp.path().join("sessions");
1272 let cwd = tmp.path().join("repo");
1273 let project_dir = base_dir.join(encode_cwd(&cwd));
1274 fs::create_dir_all(&project_dir).expect("create project sessions");
1275
1276 let session_path = project_dir.join("missing-project-dir.jsonl");
1277 let mut header = SessionHeader::new();
1278 header.id = "missing-project-dir".to_string();
1279 header.cwd = cwd.display().to_string();
1280 header.timestamp = "2025-06-01T12:00:00.000Z".to_string();
1281 fs::write(
1282 &session_path,
1283 format!(
1284 "{}\n{{\"type\":\"message\"}}\n",
1285 serde_json::to_string(&header).expect("serialize header"),
1286 ),
1287 )
1288 .expect("write session");
1289
1290 let index = SessionIndex::for_sessions_root(&base_dir);
1291 index.reindex_all().expect("seed session index");
1292
1293 let moved_project_dir = tmp.path().join("moved-project-dir");
1294 fs::rename(&project_dir, &moved_project_dir).expect("move project dir away");
1295
1296 let sessions = list_sessions_for_project(&cwd, Some(&base_dir));
1297 assert!(sessions.is_empty());
1298
1299 let indexed = index
1300 .list_sessions(Some(&cwd.display().to_string()))
1301 .expect("list indexed sessions");
1302 assert!(indexed.is_empty());
1303 }
1304
1305 #[cfg(unix)]
1306 #[test]
1307 fn list_sessions_for_project_keeps_permission_denied_row_indexed() {
1308 use std::os::unix::fs::PermissionsExt;
1309
1310 let tmp = tempfile::tempdir().expect("tempdir");
1311 let base_dir = tmp.path().join("sessions");
1312 let cwd = tmp.path().join("repo");
1313 let project_dir = base_dir.join(encode_cwd(&cwd));
1314 fs::create_dir_all(&project_dir).expect("create project sessions");
1315
1316 let session_path = project_dir.join("guarded.jsonl");
1317 let mut header = SessionHeader::new();
1318 header.id = "guarded-session".to_string();
1319 header.cwd = cwd.display().to_string();
1320 header.timestamp = "2025-06-01T12:00:00.000Z".to_string();
1321 fs::write(
1322 &session_path,
1323 format!(
1324 "{}\n{{\"type\":\"message\"}}\n",
1325 serde_json::to_string(&header).expect("serialize header"),
1326 ),
1327 )
1328 .expect("write session");
1329
1330 let index = SessionIndex::for_sessions_root(&base_dir);
1331 index.reindex_all().expect("seed session index");
1332
1333 let original_mode = fs::metadata(&project_dir)
1334 .expect("project dir metadata")
1335 .permissions()
1336 .mode();
1337 fs::set_permissions(&project_dir, fs::Permissions::from_mode(0o000))
1338 .expect("chmod project dir");
1339
1340 assert!(
1341 session_path.try_exists().is_err(),
1342 "expected permission-denied path probe for inaccessible project session directory"
1343 );
1344
1345 let sessions = list_sessions_for_project(&cwd, Some(&base_dir));
1346
1347 fs::set_permissions(&project_dir, fs::Permissions::from_mode(original_mode))
1348 .expect("restore project dir permissions");
1349
1350 assert_eq!(sessions.len(), 1);
1351 assert_eq!(sessions[0].path, session_path.display().to_string());
1352
1353 let indexed = index
1354 .list_sessions(Some(&cwd.display().to_string()))
1355 .expect("list indexed sessions");
1356 assert_eq!(indexed.len(), 1);
1357 assert_eq!(indexed[0].path, session_path.display().to_string());
1358 }
1359
1360 #[cfg(feature = "sqlite-sessions")]
1361 #[test]
1362 fn build_meta_from_file_uses_session_file_stats() {
1363 let tmp = tempfile::tempdir().expect("tempdir");
1364 let mut session = Session::create_with_dir_and_store(
1365 Some(tmp.path().to_path_buf()),
1366 SessionStoreKind::Sqlite,
1367 );
1368 session.append_message(SessionMessage::User {
1369 content: UserContent::Text("sqlite".to_string()),
1370 timestamp: Some(0),
1371 });
1372 run_async(async { session.save().await }).expect("save sqlite session");
1373
1374 let session_path = session.path.clone().expect("sqlite session path");
1375 let meta = build_meta_from_file(&session_path).expect("sqlite meta");
1376 let (expected_ms, expected_size) =
1377 session_file_stats(&session_path).expect("sqlite file stats");
1378
1379 assert_eq!(meta.message_count, 1);
1380 assert_eq!(meta.size_bytes, expected_size);
1381 assert_eq!(meta.last_modified_ms, expected_ms);
1382 }
1383
1384 #[test]
1387 fn with_theme_and_root_stores_sessions_root() {
1388 let theme = Theme::dark();
1389 let root = PathBuf::from("/sessions");
1390 let picker = SessionPicker::with_theme_and_root(Vec::new(), &theme, root);
1391 assert!(picker.sessions_root.is_some());
1392 }
1393
1394 #[test]
1397 fn delete_adjusts_selection_when_at_end() {
1398 let tmp = tempfile::tempdir().expect("tempdir");
1399 let path_a = tmp.path().join("a.jsonl");
1400 let path_b = tmp.path().join("b.jsonl");
1401 fs::write(&path_a, "test").expect("write a");
1402 fs::write(&path_b, "test").expect("write b");
1403
1404 let mut picker = SessionPicker::new(vec![make_meta(&path_a), make_meta(&path_b)]);
1405
1406 picker.update(key_msg(KeyType::Down, vec![]));
1408 assert_eq!(picker.selected, 1);
1409
1410 picker.update(key_msg(KeyType::CtrlD, vec![]));
1412 picker.update(key_msg(KeyType::Runes, vec!['y']));
1413
1414 assert_eq!(picker.selected, 0);
1416 assert_eq!(picker.sessions.len(), 1);
1417 }
1418
1419 #[test]
1420 fn delete_session_file_falls_back_when_trash_command_missing() {
1421 let tmp = tempfile::tempdir().expect("tempdir");
1422 let session_path = tmp.path().join("missing-trash-fallback.jsonl");
1423 fs::write(&session_path, "test").expect("write");
1424
1425 let result = delete_session_file_with_trash_cmd(
1426 &session_path,
1427 "__pi_agent_rust_nonexistent_trash_command__",
1428 );
1429 assert!(result.is_ok(), "delete should fall back to remove_file");
1430 assert!(!session_path.exists(), "session file should be deleted");
1431 }
1432
1433 #[cfg(unix)]
1434 #[test]
1435 fn delete_session_file_falls_back_when_trash_exits_non_zero() {
1436 use std::os::unix::fs::PermissionsExt as _;
1437
1438 let tmp = tempfile::tempdir().expect("tempdir");
1439 let session_path = tmp.path().join("failing-trash-fallback.jsonl");
1440 fs::write(&session_path, "test").expect("write");
1441
1442 let trash_script = tmp.path().join("fake-trash.sh");
1443 fs::write(&trash_script, "#!/bin/sh\nexit 2\n").expect("write script");
1444 let mut perms = fs::metadata(&trash_script).expect("metadata").permissions();
1445 perms.set_mode(0o755);
1446 fs::set_permissions(&trash_script, perms).expect("chmod");
1447
1448 let trash_cmd = trash_script.to_string_lossy();
1449 let result = delete_session_file_with_trash_cmd(&session_path, &trash_cmd);
1450 assert!(result.is_ok(), "delete should fall back to remove_file");
1451 assert!(!session_path.exists(), "session file should be deleted");
1452 }
1453
1454 #[cfg(unix)]
1455 #[test]
1456 fn delete_session_file_succeeds_when_trash_deleted_file_then_failed() {
1457 use std::os::unix::fs::PermissionsExt as _;
1458
1459 let tmp = tempfile::tempdir().expect("tempdir");
1460 let session_path = tmp.path().join("trash-deleted-then-failed.jsonl");
1461 fs::write(&session_path, "test").expect("write");
1462
1463 let trash_script = tmp.path().join("fake-trash-delete-then-fail.sh");
1464 fs::write(
1465 &trash_script,
1466 format!("#!/bin/sh\nrm -f \"{}\"\nexit 2\n", session_path.display()),
1467 )
1468 .expect("write script");
1469 let mut perms = fs::metadata(&trash_script).expect("metadata").permissions();
1470 perms.set_mode(0o755);
1471 fs::set_permissions(&trash_script, perms).expect("chmod");
1472
1473 let trash_cmd = trash_script.to_string_lossy();
1474 let result = delete_session_file_with_trash_cmd(&session_path, &trash_cmd);
1475 assert!(
1476 result.is_ok(),
1477 "delete should be idempotent when file is already gone"
1478 );
1479 assert!(!session_path.exists(), "session file should remain deleted");
1480 }
1481
1482 #[cfg(feature = "sqlite-sessions")]
1483 #[test]
1484 fn delete_sqlite_session_removes_wal_and_shm_sidecars() {
1485 let tmp = tempfile::tempdir().expect("tempdir");
1486 let session_path = tmp.path().join("sqlite-session.sqlite");
1487 let [wal_path, shm_path] = sqlite_auxiliary_paths(&session_path);
1488 fs::write(&session_path, "db").expect("write sqlite session");
1489 fs::write(&wal_path, "wal").expect("write sqlite wal");
1490 fs::write(&shm_path, "shm").expect("write sqlite shm");
1491
1492 let result = delete_session_file_with_trash_cmd(
1493 &session_path,
1494 "__pi_agent_rust_nonexistent_trash_command__",
1495 );
1496 assert!(result.is_ok(), "delete should fall back to remove_file");
1497 assert!(
1498 !session_path.exists(),
1499 "sqlite session file should be deleted"
1500 );
1501 assert!(!wal_path.exists(), "sqlite wal sidecar should be deleted");
1502 assert!(!shm_path.exists(), "sqlite shm sidecar should be deleted");
1503 }
1504
1505 #[cfg(feature = "sqlite-sessions")]
1506 #[test]
1507 fn delete_sqlite_session_preserves_sidecars_when_primary_delete_fails() {
1508 let tmp = tempfile::tempdir().expect("tempdir");
1509 let session_path = tmp.path().join("delete-fails.sqlite");
1510 let [wal_path, shm_path] = sqlite_auxiliary_paths(&session_path);
1511 fs::create_dir(&session_path).expect("create directory in place of sqlite session");
1512 fs::write(&wal_path, "wal").expect("write sqlite wal");
1513 fs::write(&shm_path, "shm").expect("write sqlite shm");
1514
1515 let result = delete_session_file_with_trash_cmd(
1516 &session_path,
1517 "__pi_agent_rust_nonexistent_trash_command__",
1518 );
1519 assert!(
1520 result.is_err(),
1521 "directory-backed sqlite session path should fail deletion"
1522 );
1523 assert!(
1524 wal_path.exists(),
1525 "wal sidecar must be preserved on primary delete failure"
1526 );
1527 assert!(
1528 shm_path.exists(),
1529 "shm sidecar must be preserved on primary delete failure"
1530 );
1531 }
1532
1533 #[cfg(unix)]
1534 #[test]
1535 fn delete_session_file_preserves_sidecar_when_primary_delete_fails() {
1536 use std::os::unix::fs::PermissionsExt as _;
1537
1538 let tmp = tempfile::tempdir().expect("tempdir");
1539 let session_path = tmp.path().join("delete-fails.jsonl");
1540 fs::create_dir(&session_path).expect("create directory in place of session file");
1541
1542 let sidecar_path = crate::session_store_v2::v2_sidecar_path(&session_path);
1543 fs::create_dir_all(&sidecar_path).expect("create sidecar");
1544 fs::write(sidecar_path.join("manifest.json"), "{}\n").expect("write sidecar marker");
1545
1546 let trash_script = tmp.path().join("fake-trash-sidecar-only.sh");
1547 fs::write(
1548 &trash_script,
1549 r#"#!/bin/sh
1550case "$1" in
1551 *.v2) mv "$1" "$1.trashed"; exit 0 ;;
1552 *) exit 2 ;;
1553esac
1554"#,
1555 )
1556 .expect("write script");
1557 let mut perms = fs::metadata(&trash_script).expect("metadata").permissions();
1558 perms.set_mode(0o755);
1559 fs::set_permissions(&trash_script, perms).expect("chmod");
1560
1561 let trash_cmd = trash_script.to_string_lossy();
1562 let result = delete_session_file_with_trash_cmd(&session_path, &trash_cmd);
1563 assert!(
1564 result.is_err(),
1565 "directory-backed session path should fail deletion"
1566 );
1567 assert!(
1568 sidecar_path.exists(),
1569 "sidecar must be preserved when the main session path could not be deleted"
1570 );
1571 }
1572
1573 mod proptest_session_picker {
1574 use super::*;
1575 use proptest::prelude::*;
1576
1577 proptest! {
1578 #[test]
1580 fn truncate_respects_limit(s in "[a-z0-9\\-]{1,40}", max in 0..50usize) {
1581 let result = truncate_session_id(&s, max);
1582 assert!(result.chars().count() <= max);
1583 }
1584
1585 #[test]
1587 fn truncate_is_prefix(s in "[a-z0-9\\-]{1,40}", max in 1..50usize) {
1588 let result = truncate_session_id(&s, max);
1589 assert!(s.starts_with(result));
1590 }
1591
1592 #[test]
1594 fn truncate_large_limit_identity(s in "[a-z0-9\\-]{1,20}") {
1595 let len = s.chars().count();
1596 let result = truncate_session_id(&s, len + 10);
1597 assert_eq!(result, s.as_str());
1598 }
1599
1600 #[test]
1602 fn truncate_zero_is_empty(s in "\\PC{1,20}") {
1603 assert_eq!(truncate_session_id(&s, 0), "");
1604 }
1605
1606 #[test]
1608 fn format_time_never_panics(ts in "\\PC{0,40}") {
1609 let _ = format_time(&ts);
1610 }
1611
1612 #[test]
1614 fn format_time_valid_rfc3339(
1615 year in 2020..2030u32,
1616 month in 1..12u32,
1617 day in 1..28u32,
1618 hour in 0..23u32,
1619 min in 0..59u32
1620 ) {
1621 let ts = format!("{year}-{month:02}-{day:02}T{hour:02}:{min:02}:00Z");
1622 let result = format_time(&ts);
1623 assert!(result.contains(&format!("{year}-{month:02}-{day:02}")));
1624 assert!(result.contains(&format!("{hour:02}:{min:02}")));
1625 }
1626
1627 #[test]
1629 fn format_time_invalid_passthrough(s in "[a-z]{5,15}") {
1630 assert_eq!(format_time(&s), s);
1631 }
1632
1633 #[test]
1635 fn is_session_file_path_accepts_jsonl(name in "[a-z]{1,10}") {
1636 let path = format!("/tmp/{name}.jsonl");
1637 assert!(is_session_file_path(Path::new(&path)));
1638 }
1639
1640 #[test]
1642 fn is_session_file_path_rejects_other(
1643 name in "[a-z]{1,10}",
1644 ext in "[a-z]{1,5}"
1645 ) {
1646 prop_assume!(ext != "jsonl" && ext != "sqlite");
1647 let path = format!("/tmp/{name}.{ext}");
1648 assert!(!is_session_file_path(Path::new(&path)));
1649 }
1650
1651 #[test]
1653 fn is_session_file_path_rejects_no_ext(name in "[a-z]{1,10}") {
1654 assert!(!is_session_file_path(Path::new(&format!("/tmp/{name}"))));
1655 }
1656
1657 #[test]
1659 fn truncate_unicode(max in 0..10usize) {
1660 let s = "\u{1F600}\u{1F601}\u{1F602}\u{1F603}\u{1F604}"; let result = truncate_session_id(s, max);
1662 assert!(result.chars().count() <= max);
1663 assert!(s.starts_with(result));
1664 }
1665
1666 #[test]
1668 fn truncate_idempotent(s in "\\PC{1,40}", max in 0..40usize) {
1669 let once = truncate_session_id(&s, max);
1670 let twice = truncate_session_id(once, max);
1671 assert_eq!(once, twice);
1672 }
1673
1674 #[test]
1676 fn format_time_valid_rfc3339_fixed_width(
1677 year in 2020..2030u32,
1678 month in 1..12u32,
1679 day in 1..28u32,
1680 hour in 0..23u32,
1681 min in 0..59u32
1682 ) {
1683 let ts = format!("{year}-{month:02}-{day:02}T{hour:02}:{min:02}:00Z");
1684 let result = format_time(&ts);
1685 assert_eq!(result.len(), 16);
1686 }
1687 }
1688 }
1689}