1use std::path::PathBuf;
20
21use directories::{BaseDirs, ProjectDirs};
22
23pub const BOOKMARKS_FILE: &str = "bookmarks.toml";
25
26pub const RECENT_DIRS_FILE: &str = "recent_dirs.toml";
28
29pub const LOG_FILE: &str = "trail.log";
35
36fn project_dirs() -> Option<ProjectDirs> {
38 ProjectDirs::from("", "", "trail")
39}
40
41pub 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
53pub fn config_dir() -> Option<PathBuf> {
58 project_dirs().map(|dirs| dirs.config_dir().to_path_buf())
59}
60
61pub fn bookmarks_file() -> PathBuf {
63 data_dir().join(BOOKMARKS_FILE)
64}
65
66pub fn recent_dirs_file() -> PathBuf {
68 data_dir().join(RECENT_DIRS_FILE)
69}
70
71pub fn state_file() -> PathBuf {
73 data_dir().join(crate::config::last_used::STATE_FILE_NAME)
74}
75
76pub fn log_file() -> Option<PathBuf> {
81 std::env::temp_dir()
82 .canonicalize()
83 .ok()
84 .map(|dir| dir.join(LOG_FILE))
85}
86
87pub fn binary() -> Option<PathBuf> {
89 std::env::current_exe().ok()
90}
91
92pub 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 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
146pub 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
210fn entry(path: &std::path::Path) -> String {
212 let mark = if path.exists() { "" } else { " (absent)" };
213 format!(" {}{}", display_path(path), mark)
214}
215
216fn 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 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}