Skip to main content

pi/
session_picker.rs

1//! Session picker TUI for selecting from available sessions.
2//!
3//! Provides an interactive list for choosing which session to resume.
4
5use 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
20/// Format a timestamp for display.
21pub 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/// Truncate a session id by character count for display.
29#[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/// The session picker TUI model.
42#[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    /// Create a new session picker.
56    #[allow(clippy::missing_const_for_fn)] // sessions: Vec cannot be const
57    #[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    /// Get the selected session path after the picker completes.
108    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    /// Check if the picker was cancelled.
115    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)] // Required by Model trait
125    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        // Header
219        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            // Column headers
235            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            // Session rows
248            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        // Help text
278        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
294/// List sessions for the current working directory using the session index.
295pub 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
302/// Run the session picker and return the selected session.
303pub 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        // Only one session, just open it
314        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    // Run the TUI
324    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        // Navigate down
691        picker.update(key_msg(KeyType::Down, vec![]));
692        assert_eq!(picker.selected, 1);
693
694        // Navigate up
695        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        // Navigate down with 'j'
728        picker.update(key_msg(KeyType::Runes, vec!['j']));
729        assert_eq!(picker.selected, 1);
730
731        // Navigate up with 'k'
732        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    // ── selected_path when nothing chosen ──────────────────────────────
905
906    #[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    // ── with_theme constructor ─────────────────────────────────────────
914
915    #[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    // ── delete last session causes quit ────────────────────────────────
926
927    #[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()); // quit command issued
939    }
940
941    // ── Esc during delete prompt cancels prompt ────────────────────────
942
943    #[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    // ── enter on empty list still returns quit ─────────────────────────
959
960    #[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()); // quit
965        assert!(picker.selected_path().is_none());
966    }
967
968    // ── ctrl-d on empty list is a noop ─────────────────────────────────
969
970    #[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    // ── build_meta_from_file ──────────────────────────────────────────
978
979    #[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    // ── is_session_file_path additional cases ──────────────────────────
1055
1056    #[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    // ── scan_sessions_on_disk ──────────────────────────────────────────
1064
1065    #[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        // Also create a non-session file that should be ignored
1076        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    // ── with_theme_and_root constructor ────────────────────────────────
1385
1386    #[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    // ── delete adjusts selection when at end ───────────────────────────
1395
1396    #[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        // Navigate to second item
1407        picker.update(key_msg(KeyType::Down, vec![]));
1408        assert_eq!(picker.selected, 1);
1409
1410        // Delete it
1411        picker.update(key_msg(KeyType::CtrlD, vec![]));
1412        picker.update(key_msg(KeyType::Runes, vec!['y']));
1413
1414        // Selection should clamp back to 0
1415        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            /// `truncate_session_id` never returns more chars than requested.
1579            #[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            /// `truncate_session_id` is a prefix of the original.
1586            #[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            /// `truncate_session_id` with max >= len returns the whole string.
1593            #[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            /// `truncate_session_id` with max=0 returns empty.
1601            #[test]
1602            fn truncate_zero_is_empty(s in "\\PC{1,20}") {
1603                assert_eq!(truncate_session_id(&s, 0), "");
1604            }
1605
1606            /// `format_time` never panics on arbitrary strings.
1607            #[test]
1608            fn format_time_never_panics(ts in "\\PC{0,40}") {
1609                let _ = format_time(&ts);
1610            }
1611
1612            /// Valid RFC3339 timestamps format to YYYY-MM-DD HH:MM.
1613            #[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            /// Invalid timestamps are returned as-is.
1628            #[test]
1629            fn format_time_invalid_passthrough(s in "[a-z]{5,15}") {
1630                assert_eq!(format_time(&s), s);
1631            }
1632
1633            /// `is_session_file_path` accepts .jsonl files.
1634            #[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            /// `is_session_file_path` rejects random extensions.
1641            #[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            /// `is_session_file_path` rejects files without extensions.
1652            #[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            /// `truncate_session_id` handles multi-byte unicode.
1658            #[test]
1659            fn truncate_unicode(max in 0..10usize) {
1660                let s = "\u{1F600}\u{1F601}\u{1F602}\u{1F603}\u{1F604}"; // 5 emoji
1661                let result = truncate_session_id(s, max);
1662                assert!(result.chars().count() <= max);
1663                assert!(s.starts_with(result));
1664            }
1665
1666            /// Truncation is idempotent for a fixed limit.
1667            #[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            /// Valid RFC3339 formatting is fixed-width (`YYYY-MM-DD HH:MM`).
1675            #[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}