Skip to main content

trail/
session.rs

1//! Session management: writes `--cwd-file` on normal exit.
2//!
3//! A subprocess cannot change its parent shell's working directory. Trail's
4//! "shell continues in the directory currently displayed" behaviour requires a
5//! shell-side wrapper function that reads the file Trail writes here and calls
6//! `cd` on its contents.
7//!
8//! On **normal** exit (`q` / `Quit` action) Trail calls [`write_cwd_file`] to
9//! record `state.cwd` in the path supplied by `--cwd-file`. On **cancellation**
10//! (`Ctrl-c` / `Esc`-driven quit) the function is not called, so the file is
11//! never written and the shell wrapper falls back to the original directory.
12
13use std::fs;
14use std::io;
15use std::path::{Path, PathBuf};
16
17/// Writes `cwd` as a UTF-8 string to `cwd_file_path`.
18///
19/// Called on normal exit (the `Quit` action) when `--cwd-file` was supplied on
20/// the command line. The shell wrapper reads this file after Trail exits and
21/// calls `cd` if the file exists and contains a valid directory path.
22///
23/// On cancellation (`Ctrl-c` / forced kill) this function is **not** called,
24/// so no file is written and the shell wrapper leaves the user in their
25/// original directory.
26///
27/// # Errors
28///
29/// Returns an [`io::Error`] if the file cannot be created or written. The
30/// caller logs the error at `debug` level and continues with a normal exit —
31/// a failed write here is inconvenient but not catastrophic.
32pub fn write_cwd_file(cwd: &Path, cwd_file_path: &Path) -> io::Result<()> {
33    // Rendering is lossy for a path that is not valid UTF-8, and a `cd` to a
34    // path with replacement characters in it would land somewhere else or
35    // nowhere. Reject it instead.
36    if cwd.to_str().is_none() {
37        return Err(io::Error::new(
38            io::ErrorKind::InvalidData,
39            "cwd is not valid UTF-8",
40        ));
41    }
42
43    // The shell wrapper runs `cd` on whatever this file holds, so it has to be
44    // a path a shell accepts: `pathfmt::display` drops the Windows verbatim
45    // prefix that `canonicalize` leaves on it. No shell wants `\\?\C:\Users\me`.
46    fs::write(cwd_file_path, crate::pathfmt::display(cwd))
47}
48
49#[cfg(test)]
50mod tests {
51    use super::*;
52    use std::path::PathBuf;
53
54    #[test]
55    fn write_cwd_file_round_trips() {
56        let dir = tempfile::tempdir().expect("tempdir");
57        let out = dir.path().join("cwd.txt");
58        let cwd = PathBuf::from("/some/test/path");
59        write_cwd_file(&cwd, &out).expect("write_cwd_file");
60        let contents = std::fs::read_to_string(&out).expect("read");
61        assert_eq!(contents, "/some/test/path");
62    }
63
64    #[test]
65    fn write_cwd_file_creates_file() {
66        let dir = tempfile::tempdir().expect("tempdir");
67        let out = dir.path().join("new_file.txt");
68        assert!(!out.exists());
69        let cwd = PathBuf::from("/tmp");
70        write_cwd_file(&cwd, &out).expect("write");
71        assert!(out.exists());
72    }
73
74    #[test]
75    #[cfg(windows)]
76    fn write_cwd_file_strips_windows_verbatim_prefix() {
77        let dir = tempfile::tempdir().expect("tempdir");
78        let out = dir.path().join("cwd.txt");
79        let cwd = PathBuf::from(r"\\?\C:\Windows\System32");
80        write_cwd_file(&cwd, &out).expect("write_cwd_file");
81        let contents = std::fs::read_to_string(&out).expect("read");
82        assert_eq!(contents, r"C:\Windows\System32");
83    }
84
85    #[test]
86    #[cfg(windows)]
87    fn write_cwd_file_strips_windows_verbatim_unc_prefix() {
88        let dir = tempfile::tempdir().expect("tempdir");
89        let out = dir.path().join("cwd.txt");
90        let cwd = PathBuf::from(r"\\?\UNC\server\share");
91        write_cwd_file(&cwd, &out).expect("write_cwd_file");
92        let contents = std::fs::read_to_string(&out).expect("read");
93        assert_eq!(contents, r"\\server\share");
94    }
95}
96
97use serde::{Deserialize, Serialize};
98
99/// Maximum number of recent directories to keep.
100const MAX_RECENT_DIRS: usize = 50;
101
102/// Persisted recent directories store.
103#[derive(Debug, Default, Serialize, Deserialize)]
104pub struct RecentDirs {
105    /// List of paths, most recent first.
106    pub paths: Vec<PathBuf>,
107}
108
109impl RecentDirs {
110    /// Loads the recent directories from `file_path`.
111    pub fn load(file_path: &Path) -> Self {
112        if let Ok(content) = fs::read_to_string(file_path) {
113            if let Ok(recent) = toml::from_str(&content) {
114                return recent;
115            }
116        }
117        Self::default()
118    }
119
120    /// Saves the recent directories to `file_path`.
121    pub fn save(&self, file_path: &Path) {
122        if let Ok(content) = toml::to_string_pretty(self) {
123            let _ = fs::write(file_path, content);
124        }
125    }
126
127    /// Records a new visit to `path`, bringing it to the top.
128    pub fn visit(&mut self, path: PathBuf) {
129        self.paths.retain(|p| p != &path);
130        self.paths.insert(0, path);
131        self.paths.truncate(MAX_RECENT_DIRS);
132    }
133}