Skip to main content

trail/
paths.rs

1//! Every filesystem location Trail owns, resolved in one place.
2//!
3//! Three call sites used to resolve `ProjectDirs::from("", "", "trail")`
4//! independently — `main`, `plugin::bookmarks` and `plugin` — which meant the
5//! answer to "where does Trail keep my bookmarks?" was spread across the
6//! codebase and could drift. Everything now goes through this module, and
7//! [`report`] prints exactly what it resolves, so `trail --paths`, the
8//! uninstall scripts and the running program cannot disagree about where
9//! anything lives.
10//!
11//! # Why this matters for uninstalling
12//!
13//! The paths are platform-dependent and not guessable: the same install puts
14//! data in `~/.local/share/trail` on Linux, `~/Library/Application
15//! Support/trail` on macOS and `%APPDATA%\trail\data` on Windows. A user who
16//! wants Trail gone needs that list, and reading it out of the binary is the
17//! only way to be sure it matches the build they are actually running.
18
19use std::path::PathBuf;
20
21use directories::{BaseDirs, ProjectDirs};
22
23/// Bookmark store, inside [`data_dir`].
24pub const BOOKMARKS_FILE: &str = "bookmarks.toml";
25
26/// Recent-directory store, inside [`data_dir`].
27pub const RECENT_DIRS_FILE: &str = "recent_dirs.toml";
28
29/// Log file, written into the system temp directory.
30///
31/// Trail owns the alternate screen, so log output can never go to stdout; it
32/// goes here instead. Temp is deliberate — the log is a debugging aid, not
33/// state worth preserving, and the OS reclaims it.
34pub const LOG_FILE: &str = "trail.log";
35
36/// The platform's project directories for Trail, if the OS reports a home.
37fn project_dirs() -> Option<ProjectDirs> {
38    ProjectDirs::from("", "", "trail")
39}
40
41/// Directory holding everything Trail persists between runs: bookmarks,
42/// recent directories, and the remembered `--config` path.
43///
44/// Falls back to the current directory when the platform will not name a home
45/// directory, so persistence degrades to "beside the user" rather than being
46/// dropped silently.
47pub fn data_dir() -> PathBuf {
48    project_dirs()
49        .map(|dirs| dirs.data_dir().to_path_buf())
50        .unwrap_or_else(|| std::env::current_dir().unwrap_or_default())
51}
52
53/// Directory Trail reads user configuration and Lua plugins from.
54///
55/// `None` when the platform will not name a home directory, in which case
56/// plugins are resolved relative to the current directory instead.
57pub fn config_dir() -> Option<PathBuf> {
58    project_dirs().map(|dirs| dirs.config_dir().to_path_buf())
59}
60
61/// Path to the bookmark store.
62pub fn bookmarks_file() -> PathBuf {
63    data_dir().join(BOOKMARKS_FILE)
64}
65
66/// Path to the recent-directory store.
67pub fn recent_dirs_file() -> PathBuf {
68    data_dir().join(RECENT_DIRS_FILE)
69}
70
71/// Path to the file recording the config last loaded with `--config`.
72pub fn state_file() -> PathBuf {
73    data_dir().join(crate::config::last_used::STATE_FILE_NAME)
74}
75
76/// Path to the log file, or `None` if the temp directory cannot be resolved.
77///
78/// Mirrors the resolution in `main`, including the `canonicalize` step, so the
79/// reported path is the one actually written to.
80pub fn log_file() -> Option<PathBuf> {
81    std::env::temp_dir()
82        .canonicalize()
83        .ok()
84        .map(|dir| dir.join(LOG_FILE))
85}
86
87/// Path to the running executable, or `None` if the OS will not report it.
88pub fn binary() -> Option<PathBuf> {
89    std::env::current_exe().ok()
90}
91
92/// Conventional locations of the shell wrappers, for the current platform.
93///
94/// Trail does not install these — the install scripts and the package managers
95/// do, and any of them can be pointed somewhere else. This is therefore a list
96/// of places worth *looking*, which [`report`] annotates with what it finds,
97/// not a claim about where they must be.
98pub fn wrapper_candidates() -> Vec<PathBuf> {
99    let mut candidates = Vec::new();
100
101    if cfg!(windows) {
102        if let Some(local) = std::env::var_os("LOCALAPPDATA") {
103            let bin = PathBuf::from(local).join("trail").join("bin");
104            candidates.push(bin.join("trail.ps1"));
105        }
106        if let Some(scoop) = std::env::var_os("SCOOP") {
107            candidates.push(
108                PathBuf::from(scoop)
109                    .join("apps")
110                    .join("trail")
111                    .join("current")
112                    .join("shell")
113                    .join("trail.ps1"),
114            );
115        }
116    }
117
118    if let Some(base) = BaseDirs::new() {
119        let home = base.home_dir();
120        let shell_dir = home
121            .join(".local")
122            .join("share")
123            .join("trail")
124            .join("shell");
125        candidates.push(shell_dir.join("trail.bash"));
126        candidates.push(shell_dir.join("trail.zsh"));
127        candidates.push(
128            home.join(".config")
129                .join("fish")
130                .join("functions")
131                .join("trail.fish"),
132        );
133    }
134
135    if !cfg!(windows) {
136        // Package-manager prefixes: AUR installs to /usr/share, Homebrew to
137        // its own prefix, which differs between Apple Silicon and Intel.
138        candidates.push(PathBuf::from("/usr/share/trail/shell/trail.bash"));
139        candidates.push(PathBuf::from("/opt/homebrew/share/trail/shell/trail.bash"));
140        candidates.push(PathBuf::from("/usr/local/share/trail/shell/trail.bash"));
141    }
142
143    candidates
144}
145
146/// Renders the `--paths` report.
147///
148/// Every path Trail reads or writes, annotated with whether it currently
149/// exists, followed by a pointer at the uninstall documentation. Printed
150/// before the terminal is put into raw mode, so it is ordinary stdout output
151/// that can be piped or redirected.
152pub fn report() -> String {
153    let mut lines = vec![
154        format!("trail {}", crate::version::LONG_VERSION),
155        String::new(),
156    ];
157
158    lines.push("Binary".to_owned());
159    match binary() {
160        Some(path) => lines.push(entry(&path)),
161        None => lines.push("  (could not be determined)".to_owned()),
162    }
163    lines.push(String::new());
164
165    lines.push("Configuration  — yours; no uninstall removes it unless asked".to_owned());
166    match config_dir() {
167        Some(dir) => lines.push(entry(&dir)),
168        None => lines.push("  (no home directory; plugins resolve against the cwd)".to_owned()),
169    }
170    let state = state_file();
171    match crate::config::last_used::remembered(&state) {
172        Some(path) => lines.push(format!("  remembered --config: {}", path.display())),
173        None => lines.push("  remembered --config: none".to_owned()),
174    }
175    lines.push(String::new());
176
177    lines.push("Data  — yours; no uninstall removes it unless asked".to_owned());
178    lines.push(entry(&data_dir()));
179    lines.push(entry(&bookmarks_file()));
180    lines.push(entry(&recent_dirs_file()));
181    lines.push(entry(&state));
182    lines.push(String::new());
183
184    lines.push("Log".to_owned());
185    match log_file() {
186        Some(path) => lines.push(entry(&path)),
187        None => lines.push("  (temp directory unavailable; logging is off)".to_owned()),
188    }
189    lines.push(String::new());
190
191    lines.push("Shell wrappers  — conventional locations, not an exhaustive list".to_owned());
192    let candidates = wrapper_candidates();
193    let found = candidates.iter().filter(|p| p.exists()).count();
194    for path in &candidates {
195        if path.exists() {
196            lines.push(entry(path));
197        }
198    }
199    if found == 0 {
200        lines.push("  none found in the conventional locations".to_owned());
201    }
202    lines.push(String::new());
203
204    lines.push("To remove Trail, see the Uninstalling section of".to_owned());
205    lines.push("https://github.com/WeedonSctt/trail/blob/main/docs/installation.md".to_owned());
206
207    lines.join("\n")
208}
209
210/// Formats one path line, marking whether it is present on disk.
211fn entry(path: &std::path::Path) -> String {
212    let mark = if path.exists() { "" } else { "  (absent)" };
213    format!("  {}{}", display_path(path), mark)
214}
215
216/// Renders a path for human consumption.
217///
218/// `canonicalize` returns Windows paths in extended-length form (`\\?\C:\…`),
219/// which is correct, accepted by the API, and unreadable in a report — and
220/// would be pasted straight into a shell by anyone following the uninstall
221/// docs. See [`crate::pathfmt`] for why dropping the prefix is safe here and
222/// conditional everywhere the path goes back to the filesystem.
223fn display_path(path: &std::path::Path) -> String {
224    crate::pathfmt::display(path)
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn data_files_sit_inside_the_data_directory() {
233        let dir = data_dir();
234        assert!(bookmarks_file().starts_with(&dir));
235        assert!(recent_dirs_file().starts_with(&dir));
236        assert!(state_file().starts_with(&dir));
237    }
238
239    #[test]
240    fn data_files_use_the_documented_names() {
241        assert!(bookmarks_file().ends_with(BOOKMARKS_FILE));
242        assert!(recent_dirs_file().ends_with(RECENT_DIRS_FILE));
243        assert!(state_file().ends_with(crate::config::last_used::STATE_FILE_NAME));
244    }
245
246    #[test]
247    fn the_report_names_every_section_a_user_needs() {
248        let report = report();
249        for heading in ["Binary", "Configuration", "Data", "Log", "Shell wrappers"] {
250            assert!(
251                report.contains(heading),
252                "report is missing the {heading} section:\n{report}"
253            );
254        }
255    }
256
257    #[test]
258    fn the_report_points_at_the_uninstall_documentation() {
259        assert!(report().contains("installation.md"));
260    }
261
262    #[test]
263    fn the_report_never_shows_an_extended_length_prefix() {
264        // `\\?\C:\…` is what canonicalize hands back on Windows, and it must
265        // not reach a report a user is meant to read paths out of.
266        assert!(
267            !report().contains(r"\\?\"),
268            "report leaked a verbatim prefix"
269        );
270    }
271
272    #[test]
273    fn display_path_strips_only_the_verbatim_prefix() {
274        assert_eq!(
275            display_path(std::path::Path::new(r"\\?\C:\tmp\trail.log")),
276            r"C:\tmp\trail.log"
277        );
278        assert_eq!(
279            display_path(std::path::Path::new("/tmp/trail.log")),
280            "/tmp/trail.log"
281        );
282    }
283
284    #[test]
285    fn wrapper_candidates_are_absolute() {
286        for path in wrapper_candidates() {
287            assert!(
288                path.is_absolute(),
289                "candidate must be absolute so the report is unambiguous: {}",
290                path.display()
291            );
292        }
293    }
294}