use std::path::PathBuf;
use directories::{BaseDirs, ProjectDirs};
pub const BOOKMARKS_FILE: &str = "bookmarks.toml";
pub const RECENT_DIRS_FILE: &str = "recent_dirs.toml";
pub const LOG_FILE: &str = "trail.log";
fn project_dirs() -> Option<ProjectDirs> {
ProjectDirs::from("", "", "trail")
}
pub fn data_dir() -> PathBuf {
project_dirs()
.map(|dirs| dirs.data_dir().to_path_buf())
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default())
}
pub fn config_dir() -> Option<PathBuf> {
project_dirs().map(|dirs| dirs.config_dir().to_path_buf())
}
pub fn bookmarks_file() -> PathBuf {
data_dir().join(BOOKMARKS_FILE)
}
pub fn recent_dirs_file() -> PathBuf {
data_dir().join(RECENT_DIRS_FILE)
}
pub fn state_file() -> PathBuf {
data_dir().join(crate::config::last_used::STATE_FILE_NAME)
}
pub fn log_file() -> Option<PathBuf> {
std::env::temp_dir()
.canonicalize()
.ok()
.map(|dir| dir.join(LOG_FILE))
}
pub fn binary() -> Option<PathBuf> {
std::env::current_exe().ok()
}
pub fn wrapper_candidates() -> Vec<PathBuf> {
let mut candidates = Vec::new();
if cfg!(windows) {
if let Some(local) = std::env::var_os("LOCALAPPDATA") {
let bin = PathBuf::from(local).join("trail").join("bin");
candidates.push(bin.join("trail.ps1"));
}
if let Some(scoop) = std::env::var_os("SCOOP") {
candidates.push(
PathBuf::from(scoop)
.join("apps")
.join("trail")
.join("current")
.join("shell")
.join("trail.ps1"),
);
}
}
if let Some(base) = BaseDirs::new() {
let home = base.home_dir();
let shell_dir = home
.join(".local")
.join("share")
.join("trail")
.join("shell");
candidates.push(shell_dir.join("trail.bash"));
candidates.push(shell_dir.join("trail.zsh"));
candidates.push(
home.join(".config")
.join("fish")
.join("functions")
.join("trail.fish"),
);
}
if !cfg!(windows) {
candidates.push(PathBuf::from("/usr/share/trail/shell/trail.bash"));
candidates.push(PathBuf::from("/opt/homebrew/share/trail/shell/trail.bash"));
candidates.push(PathBuf::from("/usr/local/share/trail/shell/trail.bash"));
}
candidates
}
pub fn report() -> String {
let mut lines = vec![
format!("trail {}", crate::version::LONG_VERSION),
String::new(),
];
lines.push("Binary".to_owned());
match binary() {
Some(path) => lines.push(entry(&path)),
None => lines.push(" (could not be determined)".to_owned()),
}
lines.push(String::new());
lines.push("Configuration — yours; no uninstall removes it unless asked".to_owned());
match config_dir() {
Some(dir) => lines.push(entry(&dir)),
None => lines.push(" (no home directory; plugins resolve against the cwd)".to_owned()),
}
let state = state_file();
match crate::config::last_used::remembered(&state) {
Some(path) => lines.push(format!(" remembered --config: {}", path.display())),
None => lines.push(" remembered --config: none".to_owned()),
}
lines.push(String::new());
lines.push("Data — yours; no uninstall removes it unless asked".to_owned());
lines.push(entry(&data_dir()));
lines.push(entry(&bookmarks_file()));
lines.push(entry(&recent_dirs_file()));
lines.push(entry(&state));
lines.push(String::new());
lines.push("Log".to_owned());
match log_file() {
Some(path) => lines.push(entry(&path)),
None => lines.push(" (temp directory unavailable; logging is off)".to_owned()),
}
lines.push(String::new());
lines.push("Shell wrappers — conventional locations, not an exhaustive list".to_owned());
let candidates = wrapper_candidates();
let found = candidates.iter().filter(|p| p.exists()).count();
for path in &candidates {
if path.exists() {
lines.push(entry(path));
}
}
if found == 0 {
lines.push(" none found in the conventional locations".to_owned());
}
lines.push(String::new());
lines.push("To remove Trail, see the Uninstalling section of".to_owned());
lines.push("https://github.com/WeedonSctt/trail/blob/main/docs/installation.md".to_owned());
lines.join("\n")
}
fn entry(path: &std::path::Path) -> String {
let mark = if path.exists() { "" } else { " (absent)" };
format!(" {}{}", display_path(path), mark)
}
fn display_path(path: &std::path::Path) -> String {
crate::pathfmt::display(path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn data_files_sit_inside_the_data_directory() {
let dir = data_dir();
assert!(bookmarks_file().starts_with(&dir));
assert!(recent_dirs_file().starts_with(&dir));
assert!(state_file().starts_with(&dir));
}
#[test]
fn data_files_use_the_documented_names() {
assert!(bookmarks_file().ends_with(BOOKMARKS_FILE));
assert!(recent_dirs_file().ends_with(RECENT_DIRS_FILE));
assert!(state_file().ends_with(crate::config::last_used::STATE_FILE_NAME));
}
#[test]
fn the_report_names_every_section_a_user_needs() {
let report = report();
for heading in ["Binary", "Configuration", "Data", "Log", "Shell wrappers"] {
assert!(
report.contains(heading),
"report is missing the {heading} section:\n{report}"
);
}
}
#[test]
fn the_report_points_at_the_uninstall_documentation() {
assert!(report().contains("installation.md"));
}
#[test]
fn the_report_never_shows_an_extended_length_prefix() {
assert!(
!report().contains(r"\\?\"),
"report leaked a verbatim prefix"
);
}
#[test]
fn display_path_strips_only_the_verbatim_prefix() {
assert_eq!(
display_path(std::path::Path::new(r"\\?\C:\tmp\trail.log")),
r"C:\tmp\trail.log"
);
assert_eq!(
display_path(std::path::Path::new("/tmp/trail.log")),
"/tmp/trail.log"
);
}
#[test]
fn wrapper_candidates_are_absolute() {
for path in wrapper_candidates() {
assert!(
path.is_absolute(),
"candidate must be absolute so the report is unambiguous: {}",
path.display()
);
}
}
}