use etcetera::base_strategy::{BaseStrategy, choose_base_strategy};
use std::path::PathBuf;
use std::sync::OnceLock;
use crate::path::home_dir;
pub fn home_dir_required() -> Result<PathBuf, std::io::Error> {
home_dir().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
"Cannot determine home directory. Set $HOME (Unix) or $USERPROFILE (Windows)",
)
})
}
const TEST_NU_VENDOR_AUTOLOAD_ENV: &str = "WORKTRUNK_TEST_NU_VENDOR_AUTOLOAD_DIR";
#[derive(Clone, Default)]
struct NuDirs {
vendor_autoload: Option<PathBuf>,
default_config: Option<PathBuf>,
}
fn parse_nu_path(line: &str) -> Option<PathBuf> {
let path = PathBuf::from(line.trim());
(!path.as_os_str().is_empty()).then_some(path)
}
fn nu_dirs() -> NuDirs {
static CACHE: OnceLock<NuDirs> = OnceLock::new();
CACHE
.get_or_init(|| {
if let Some(dir) = std::env::var_os(TEST_NU_VENDOR_AUTOLOAD_ENV) {
return NuDirs {
vendor_autoload: parse_nu_path(&dir.to_string_lossy()),
default_config: None,
};
}
let Some(output) = crate::shell_exec::Cmd::new("nu")
.args([
"-c",
"print ($nu.vendor-autoload-dirs | last); print $nu.default-config-dir",
])
.run()
.ok()
.filter(|o| o.status.success())
else {
return NuDirs::default();
};
let stdout = String::from_utf8_lossy(&output.stdout);
let mut lines = stdout.lines();
NuDirs {
vendor_autoload: lines.next().and_then(parse_nu_path),
default_config: lines.next().and_then(parse_nu_path),
}
})
.clone()
}
fn nushell_data_dir_fallback(home: &std::path::Path) -> PathBuf {
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
let path = PathBuf::from(xdg);
if path.is_absolute() {
return path.join("nushell");
}
}
dirs::data_dir()
.unwrap_or_else(|| home.join(".local").join("share"))
.join("nushell")
}
fn nushell_vendor_autoload_dir(
home: &std::path::Path,
queried: Option<&std::path::Path>,
) -> PathBuf {
match queried {
Some(dir) => dir.to_path_buf(),
None => nushell_data_dir_fallback(home)
.join("vendor")
.join("autoload"),
}
}
fn legacy_nushell_autoload_dirs(
home: &std::path::Path,
default_config: Option<&std::path::Path>,
) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = Vec::new();
if let Some(dir) = default_config {
dirs.push(dir.to_path_buf());
}
if let Ok(xdg_config) = std::env::var("XDG_CONFIG_HOME") {
dirs.push(PathBuf::from(xdg_config).join("nushell"));
}
dirs.push(home.join(".config").join("nushell"));
if let Ok(strategy) = choose_base_strategy() {
dirs.push(strategy.config_dir().join("nushell"));
}
dirs.into_iter()
.map(|d| d.join("vendor").join("autoload"))
.collect()
}
fn nushell_autoload_candidates(home: &std::path::Path) -> Vec<PathBuf> {
let dirs = nu_dirs();
let mut candidates = vec![nushell_vendor_autoload_dir(
home,
dirs.vendor_autoload.as_deref(),
)];
candidates.push(
nushell_data_dir_fallback(home)
.join("vendor")
.join("autoload"),
);
candidates.extend(legacy_nushell_autoload_dirs(
home,
dirs.default_config.as_deref(),
));
let mut seen = std::collections::HashSet::new();
candidates.retain(|p| seen.insert(p.clone()));
candidates
}
pub fn powershell_profile_paths(home: &std::path::Path) -> Vec<PathBuf> {
#[cfg(windows)]
{
let docs = dirs::document_dir().unwrap_or_else(|| home.join("Documents"));
vec![
docs.join("PowerShell")
.join("Microsoft.PowerShell_profile.ps1"),
docs.join("WindowsPowerShell")
.join("Microsoft.PowerShell_profile.ps1"),
]
}
#[cfg(not(windows))]
{
vec![
home.join(".config")
.join("powershell")
.join("Microsoft.PowerShell_profile.ps1"),
]
}
}
pub fn config_paths(shell: super::Shell, cmd: &str) -> Result<Vec<PathBuf>, std::io::Error> {
let home = home_dir_required()?;
Ok(match shell {
super::Shell::Bash => {
vec![home.join(".bashrc")]
}
super::Shell::Zsh => {
let zdotdir = std::env::var("ZDOTDIR")
.map(PathBuf::from)
.unwrap_or_else(|_| home.clone());
vec![zdotdir.join(".zshrc")]
}
super::Shell::Fish => {
vec![
home.join(".config")
.join("fish")
.join("functions")
.join(format!("{}.fish", cmd)),
]
}
super::Shell::Nushell => {
nushell_autoload_candidates(&home)
.into_iter()
.map(|autoload_dir| autoload_dir.join(format!("{}.nu", cmd)))
.collect()
}
super::Shell::PowerShell => powershell_profile_paths(&home),
})
}
pub fn legacy_fish_conf_d_path(cmd: &str) -> Result<PathBuf, std::io::Error> {
let home = home_dir_required()?;
Ok(home
.join(".config")
.join("fish")
.join("conf.d")
.join(format!("{}.fish", cmd)))
}
pub fn completion_path(shell: super::Shell, cmd: &str) -> Result<PathBuf, std::io::Error> {
let home = home_dir_required()?;
let strategy = choose_base_strategy().ok();
Ok(match shell {
super::Shell::Bash => {
let data_home = strategy
.as_ref()
.map(|s| s.data_dir())
.unwrap_or_else(|| home.join(".local").join("share"));
data_home
.join("bash-completion")
.join("completions")
.join(cmd)
}
super::Shell::Zsh => home.join(".zfunc").join(format!("_{}", cmd)),
super::Shell::Fish => {
let config_home = strategy
.as_ref()
.map(|s| s.config_dir())
.unwrap_or_else(|| home.join(".config"));
config_home
.join("fish")
.join("completions")
.join(format!("{}.fish", cmd))
}
super::Shell::Nushell => {
let dirs = nu_dirs();
nushell_vendor_autoload_dir(&home, dirs.vendor_autoload.as_deref())
.join(format!("{}.nu", cmd))
}
super::Shell::PowerShell => {
home.join(format!(".{}-powershell-completions", cmd))
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_nu_path() {
assert_eq!(
parse_nu_path("/home/user/.local/share/nushell/vendor/autoload\n"),
Some(PathBuf::from(
"/home/user/.local/share/nushell/vendor/autoload"
))
);
assert_eq!(parse_nu_path(" /a/b "), Some(PathBuf::from("/a/b")));
assert_eq!(parse_nu_path(""), None);
assert_eq!(parse_nu_path(" \n"), None);
}
#[test]
fn test_nushell_vendor_autoload_dir_prefers_queried() {
let home = PathBuf::from("/home/user");
let queried = PathBuf::from("/opt/nu/vendor/autoload");
assert_eq!(
nushell_vendor_autoload_dir(&home, Some(&queried)),
queried,
"the path nu reports should be used verbatim"
);
}
#[test]
fn test_nushell_vendor_autoload_dir_fallback_under_data_dir() {
let home = PathBuf::from("/home/user");
let dir = nushell_vendor_autoload_dir(&home, None);
assert!(
dir.ends_with("nushell/vendor/autoload"),
"fallback should be under <data>/nushell/vendor/autoload: {dir:?}"
);
}
#[test]
fn test_legacy_nushell_autoload_dirs_are_config_rooted() {
let home = PathBuf::from("/home/user");
let default_config = PathBuf::from("/home/user/.config/nushell");
let dirs = legacy_nushell_autoload_dirs(&home, Some(&default_config));
assert!(
dirs.contains(&default_config.join("vendor").join("autoload")),
"should include the queried default-config-dir: {dirs:?}"
);
assert!(
dirs.contains(&home.join(".config/nushell/vendor/autoload")),
"should include ~/.config/nushell: {dirs:?}"
);
assert!(
dirs.iter().all(|p| p.ends_with("vendor/autoload")),
"all legacy dirs should end with vendor/autoload: {dirs:?}"
);
}
#[test]
fn test_nushell_autoload_candidates_write_target_first_and_unique() {
let home = PathBuf::from("/home/user");
let candidates = nushell_autoload_candidates(&home);
assert!(!candidates.is_empty(), "must return at least one candidate");
assert!(
candidates[0].ends_with("vendor/autoload"),
"write target should be a vendor/autoload dir: {:?}",
candidates[0]
);
let unique: std::collections::HashSet<_> = candidates.iter().collect();
assert_eq!(
candidates.len(),
unique.len(),
"candidates must not contain duplicates: {candidates:?}"
);
assert!(
candidates
.iter()
.any(|p| p == &home.join(".config/nushell/vendor/autoload")),
"legacy ~/.config/nushell location must be a candidate: {candidates:?}"
);
}
}