Skip to content

Commit 3782ae1

Browse files
mst-mktCopilotfengmk2
authored
fix(cli): respect ZDOTDIR and XDG_CONFIG_HOME in install, doctor, and implode (#892)
## issue resolves #890 ## details `install.sh` hardcodes `$HOME` for shell config paths, ignoring `ZDOTDIR` (zsh) and `XDG_CONFIG_HOME` (fish). This PR respects these variables across install, doctor, and implode. ## changes - **`install.sh`** Use `${ZDOTDIR:-$HOME}` for zsh and `${XDG_CONFIG_HOME:-$HOME/.config}` for fish. `SHELL_CONFIG_UPDATED` is now an absolute path, and the `source` hint displays `~/...` or the absolute path accordingly. - **`doctor.rs`** Also check `$ZDOTDIR` and `$XDG_CONFIG_HOME` locations when detecting profile configuration. - **`implode.rs`** Also collect and clean profiles from these custom locations during uninstall. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: MK (fengmk2) <fengmk2@gmail.com>
1 parent 708734d commit 3782ae1

3 files changed

Lines changed: 322 additions & 27 deletions

File tree

crates/vite_global_cli/src/commands/env/doctor.rs

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,33 @@ fn check_profile_files(vite_plus_home: &str) -> Option<String> {
476476
}
477477
}
478478

479+
// If ZDOTDIR is set and differs from $HOME, also check $ZDOTDIR/.zshenv and .zshrc
480+
if let Ok(zdotdir) = std::env::var("ZDOTDIR") {
481+
if !zdotdir.is_empty() && zdotdir != home_dir {
482+
for file in [".zshenv", ".zshrc"] {
483+
let path = format!("{zdotdir}/{file}");
484+
if let Ok(content) = std::fs::read_to_string(&path) {
485+
if search_strings.iter().any(|s| content.contains(s)) {
486+
return Some(abbreviate_home(&path));
487+
}
488+
}
489+
}
490+
}
491+
}
492+
493+
// If XDG_CONFIG_HOME is set and differs from default, also check fish conf.d
494+
if let Ok(xdg_config) = std::env::var("XDG_CONFIG_HOME") {
495+
let default_config = format!("{home_dir}/.config");
496+
if !xdg_config.is_empty() && xdg_config != default_config {
497+
let path = format!("{xdg_config}/fish/conf.d/vite-plus.fish");
498+
if let Ok(content) = std::fs::read_to_string(&path) {
499+
if search_strings.iter().any(|s| content.contains(s)) {
500+
return Some(abbreviate_home(&path));
501+
}
502+
}
503+
}
504+
}
505+
479506
None
480507
}
481508

@@ -766,4 +793,99 @@ mod tests {
766793
assert_eq!(abbreviate_home("/usr/local/bin"), "/usr/local/bin");
767794
}
768795
}
796+
797+
/// Guard for env vars used by profile file tests.
798+
#[cfg(not(windows))]
799+
struct ProfileEnvGuard {
800+
original_home: Option<std::ffi::OsString>,
801+
original_zdotdir: Option<std::ffi::OsString>,
802+
original_xdg_config: Option<std::ffi::OsString>,
803+
}
804+
805+
#[cfg(not(windows))]
806+
impl ProfileEnvGuard {
807+
fn new(
808+
home: &std::path::Path,
809+
zdotdir: Option<&std::path::Path>,
810+
xdg_config: Option<&std::path::Path>,
811+
) -> Self {
812+
let guard = Self {
813+
original_home: std::env::var_os("HOME"),
814+
original_zdotdir: std::env::var_os("ZDOTDIR"),
815+
original_xdg_config: std::env::var_os("XDG_CONFIG_HOME"),
816+
};
817+
unsafe {
818+
std::env::set_var("HOME", home);
819+
match zdotdir {
820+
Some(v) => std::env::set_var("ZDOTDIR", v),
821+
None => std::env::remove_var("ZDOTDIR"),
822+
}
823+
match xdg_config {
824+
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
825+
None => std::env::remove_var("XDG_CONFIG_HOME"),
826+
}
827+
}
828+
guard
829+
}
830+
}
831+
832+
#[cfg(not(windows))]
833+
impl Drop for ProfileEnvGuard {
834+
fn drop(&mut self) {
835+
unsafe {
836+
match &self.original_home {
837+
Some(v) => std::env::set_var("HOME", v),
838+
None => std::env::remove_var("HOME"),
839+
}
840+
match &self.original_zdotdir {
841+
Some(v) => std::env::set_var("ZDOTDIR", v),
842+
None => std::env::remove_var("ZDOTDIR"),
843+
}
844+
match &self.original_xdg_config {
845+
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
846+
None => std::env::remove_var("XDG_CONFIG_HOME"),
847+
}
848+
}
849+
}
850+
}
851+
852+
#[test]
853+
#[serial]
854+
#[cfg(not(windows))]
855+
fn test_check_profile_files_finds_zdotdir() {
856+
let temp = TempDir::new().unwrap();
857+
let fake_home = temp.path().join("home");
858+
let zdotdir = temp.path().join("zdotdir");
859+
std::fs::create_dir_all(&fake_home).unwrap();
860+
std::fs::create_dir_all(&zdotdir).unwrap();
861+
862+
std::fs::write(zdotdir.join(".zshenv"), ". \"$HOME/.vite-plus/env\"\n").unwrap();
863+
864+
let _guard = ProfileEnvGuard::new(&fake_home, Some(&zdotdir), None);
865+
866+
let result = check_profile_files("$HOME/.vite-plus");
867+
assert!(result.is_some(), "Should find .zshenv in ZDOTDIR");
868+
assert!(result.unwrap().ends_with(".zshenv"));
869+
}
870+
871+
#[test]
872+
#[serial]
873+
#[cfg(not(windows))]
874+
fn test_check_profile_files_finds_xdg_fish() {
875+
let temp = TempDir::new().unwrap();
876+
let fake_home = temp.path().join("home");
877+
let xdg_config = temp.path().join("xdg_config");
878+
let fish_dir = xdg_config.join("fish/conf.d");
879+
std::fs::create_dir_all(&fake_home).unwrap();
880+
std::fs::create_dir_all(&fish_dir).unwrap();
881+
882+
std::fs::write(fish_dir.join("vite-plus.fish"), "source \"$HOME/.vite-plus/env.fish\"\n")
883+
.unwrap();
884+
885+
let _guard = ProfileEnvGuard::new(&fake_home, None, Some(&xdg_config));
886+
887+
let result = check_profile_files("$HOME/.vite-plus");
888+
assert!(result.is_some(), "Should find vite-plus.fish in XDG_CONFIG_HOME");
889+
assert!(result.unwrap().contains("vite-plus.fish"));
890+
}
769891
}

crates/vite_global_cli/src/commands/implode.rs

Lines changed: 177 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use std::{
77

88
use directories::BaseDirs;
99
use owo_colors::OwoColorize;
10-
use vite_path::AbsolutePathBuf;
10+
use vite_path::{AbsolutePath, AbsolutePathBuf};
1111
use vite_shared::output;
1212
use vite_str::Str;
1313

@@ -23,6 +23,14 @@ const SHELL_PROFILES: &[(&str, bool)] = &[
2323
(".config/fish/conf.d/vite-plus.fish", true),
2424
];
2525

26+
/// Abbreviate a path for display: replace `$HOME` prefix with `~`.
27+
fn abbreviate_home_path(path: &AbsolutePath, user_home: &AbsolutePath) -> Str {
28+
match path.strip_prefix(user_home) {
29+
Ok(Some(suffix)) => vite_str::format!("~/{suffix}"),
30+
_ => Str::from(path.to_string()),
31+
}
32+
}
33+
2634
/// Comment marker written by the install script above the sourcing line.
2735
const VITE_PLUS_COMMENT: &str = "# Vite+ bin";
2836

@@ -97,26 +105,52 @@ enum AffectedProfileKind {
97105
/// Content is cached so we don't need to re-read during cleaning.
98106
fn collect_affected_profiles(user_home: &AbsolutePathBuf) -> Vec<AffectedProfile> {
99107
let mut affected = Vec::new();
100-
for &(name, is_snippet) in SHELL_PROFILES {
101-
let path = user_home.join(name);
108+
109+
// Build full list of (display_name, path, is_snippet) from the base set
110+
let mut profiles: Vec<(Str, AbsolutePathBuf, bool)> = SHELL_PROFILES
111+
.iter()
112+
.map(|&(name, is_snippet)| {
113+
(vite_str::format!("~/{name}"), user_home.join(name), is_snippet)
114+
})
115+
.collect();
116+
117+
// If ZDOTDIR is set and differs from $HOME, also check there.
118+
if let Ok(zdotdir) = std::env::var("ZDOTDIR")
119+
&& let Some(zdotdir_path) = AbsolutePathBuf::new(zdotdir.into())
120+
&& zdotdir_path != *user_home
121+
{
122+
for name in [".zshenv", ".zshrc"] {
123+
let path = zdotdir_path.join(name);
124+
let display = abbreviate_home_path(&path, user_home);
125+
profiles.push((display, path, false));
126+
}
127+
}
128+
129+
// If XDG_CONFIG_HOME is set and differs from $HOME/.config, also check there.
130+
if let Ok(xdg_config) = std::env::var("XDG_CONFIG_HOME")
131+
&& let Some(xdg_path) = AbsolutePathBuf::new(xdg_config.into())
132+
&& xdg_path != user_home.join(".config")
133+
{
134+
let path = xdg_path.join("fish/conf.d/vite-plus.fish");
135+
let display = abbreviate_home_path(&path, user_home);
136+
profiles.push((display, path, true));
137+
}
138+
139+
for (name, path, is_snippet) in profiles {
102140
// For snippets, check if the file exists only
103141
if is_snippet {
104-
if let Some(true) = std::fs::exists(&path).ok() {
105-
affected.push(AffectedProfile {
106-
name: Str::from(name),
107-
path,
108-
kind: AffectedProfileKind::Snippet,
109-
})
142+
if let Ok(true) = std::fs::exists(&path) {
143+
affected.push(AffectedProfile { name, path, kind: AffectedProfileKind::Snippet })
110144
}
111145
continue;
112146
}
113147
// Read directly — if the file doesn't exist, read_to_string returns Err
114-
// which is_ok_and handles gracefully (no redundant exists() check).
148+
// which .ok().filter() handles gracefully (no redundant exists() check).
115149
if let Some(content) =
116150
std::fs::read_to_string(&path).ok().filter(|c| has_vite_plus_lines(c))
117151
{
118152
affected.push(AffectedProfile {
119-
name: Str::from(name),
153+
name,
120154
path,
121155
kind: AffectedProfileKind::Main { content: Str::from(content) },
122156
});
@@ -144,7 +178,7 @@ fn confirm_implode(
144178
if !affected_profiles.is_empty() {
145179
output::raw(" Shell profiles to clean:");
146180
for profile in affected_profiles {
147-
output::raw(&vite_str::format!(" - ~/{}", profile.name));
181+
output::raw(&vite_str::format!(" - {}", profile.name));
148182
}
149183
}
150184
output::raw("");
@@ -171,16 +205,16 @@ fn clean_affected_profiles(affected_profiles: &[AffectedProfile]) {
171205
AffectedProfileKind::Main { content } => {
172206
let cleaned = remove_vite_plus_lines(content);
173207
match std::fs::write(&profile.path, cleaned.as_bytes()) {
174-
Ok(()) => output::success(&vite_str::format!("Cleaned ~/{}", profile.name)),
208+
Ok(()) => output::success(&vite_str::format!("Cleaned {}", profile.name)),
175209
Err(e) => {
176-
output::warn(&vite_str::format!("Failed to clean ~/{}: {e}", profile.name));
210+
output::warn(&vite_str::format!("Failed to clean {}: {e}", profile.name));
177211
}
178212
}
179213
}
180214
AffectedProfileKind::Snippet => match std::fs::remove_file(&profile.path) {
181-
Ok(()) => output::success(&vite_str::format!("Removed ~/{}", profile.name)),
215+
Ok(()) => output::success(&vite_str::format!("Removed {}", profile.name)),
182216
Err(e) => {
183-
output::warn(&vite_str::format!("Failed to remove ~/{}: {e}", profile.name));
217+
output::warn(&vite_str::format!("Failed to remove {}: {e}", profile.name));
184218
}
185219
},
186220
}
@@ -336,6 +370,9 @@ fn remove_windows_path_entry(bin_path: &vite_path::AbsolutePath) -> std::io::Res
336370

337371
#[cfg(test)]
338372
mod tests {
373+
#[cfg(not(windows))]
374+
use serial_test::serial;
375+
339376
use super::*;
340377

341378
#[test]
@@ -420,6 +457,130 @@ mod tests {
420457
assert!(script.contains("timeout /T 1 /NOBREAK"));
421458
}
422459

460+
#[test]
461+
#[cfg(not(windows))]
462+
fn test_abbreviate_home_path() {
463+
let home = AbsolutePathBuf::new("/home/user".into()).unwrap();
464+
// Under home → ~/...
465+
let under = AbsolutePathBuf::new("/home/user/.zshrc".into()).unwrap();
466+
assert_eq!(&*abbreviate_home_path(&under, &home), "~/.zshrc");
467+
// Outside home → absolute path as-is
468+
let outside = AbsolutePathBuf::new("/opt/zdotdir/.zshenv".into()).unwrap();
469+
assert_eq!(&*abbreviate_home_path(&outside, &home), "/opt/zdotdir/.zshenv");
470+
}
471+
472+
#[test]
473+
#[serial]
474+
#[cfg(not(windows))]
475+
fn test_collect_affected_profiles() {
476+
let temp_dir = tempfile::tempdir().unwrap();
477+
let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
478+
479+
// Clear ZDOTDIR/XDG_CONFIG_HOME so the test environment doesn't affect results
480+
let _guard = ProfileEnvGuard::new(None, None);
481+
482+
// Main profile with vite-plus line
483+
std::fs::write(home.join(".zshrc"), ". \"$HOME/.vite-plus/env\"\n").unwrap();
484+
// Unrelated profile (should be ignored)
485+
std::fs::write(home.join(".bashrc"), "export PATH=/usr/bin\n").unwrap();
486+
// Snippet file (just needs to exist)
487+
let fish_dir = home.join(".config/fish/conf.d");
488+
std::fs::create_dir_all(&fish_dir).unwrap();
489+
std::fs::write(fish_dir.join("vite-plus.fish"), "source ~/.vite-plus/env.fish\n").unwrap();
490+
491+
let profiles = collect_affected_profiles(&home);
492+
assert_eq!(profiles.len(), 2);
493+
assert!(matches!(&profiles[0].kind, AffectedProfileKind::Main { .. }));
494+
assert!(matches!(&profiles[1].kind, AffectedProfileKind::Snippet));
495+
}
496+
497+
/// Guard that saves and restores ZDOTDIR and XDG_CONFIG_HOME env vars.
498+
#[cfg(not(windows))]
499+
struct ProfileEnvGuard {
500+
original_zdotdir: Option<std::ffi::OsString>,
501+
original_xdg_config: Option<std::ffi::OsString>,
502+
}
503+
504+
#[cfg(not(windows))]
505+
impl ProfileEnvGuard {
506+
fn new(zdotdir: Option<&std::path::Path>, xdg_config: Option<&std::path::Path>) -> Self {
507+
let guard = Self {
508+
original_zdotdir: std::env::var_os("ZDOTDIR"),
509+
original_xdg_config: std::env::var_os("XDG_CONFIG_HOME"),
510+
};
511+
unsafe {
512+
match zdotdir {
513+
Some(v) => std::env::set_var("ZDOTDIR", v),
514+
None => std::env::remove_var("ZDOTDIR"),
515+
}
516+
match xdg_config {
517+
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
518+
None => std::env::remove_var("XDG_CONFIG_HOME"),
519+
}
520+
}
521+
guard
522+
}
523+
}
524+
525+
#[cfg(not(windows))]
526+
impl Drop for ProfileEnvGuard {
527+
fn drop(&mut self) {
528+
unsafe {
529+
match &self.original_zdotdir {
530+
Some(v) => std::env::set_var("ZDOTDIR", v),
531+
None => std::env::remove_var("ZDOTDIR"),
532+
}
533+
match &self.original_xdg_config {
534+
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
535+
None => std::env::remove_var("XDG_CONFIG_HOME"),
536+
}
537+
}
538+
}
539+
}
540+
541+
#[test]
542+
#[serial]
543+
#[cfg(not(windows))]
544+
fn test_collect_affected_profiles_zdotdir() {
545+
let temp_dir = tempfile::tempdir().unwrap();
546+
let home = AbsolutePathBuf::new(temp_dir.path().join("home")).unwrap();
547+
let zdotdir = temp_dir.path().join("zdotdir");
548+
std::fs::create_dir_all(&home).unwrap();
549+
std::fs::create_dir_all(&zdotdir).unwrap();
550+
551+
std::fs::write(zdotdir.join(".zshenv"), ". \"$HOME/.vite-plus/env\"\n").unwrap();
552+
553+
let _guard = ProfileEnvGuard::new(Some(&zdotdir), None);
554+
555+
let profiles = collect_affected_profiles(&home);
556+
let zdotdir_profiles: Vec<_> =
557+
profiles.iter().filter(|p| p.path.as_path().starts_with(&zdotdir)).collect();
558+
assert_eq!(zdotdir_profiles.len(), 1);
559+
assert!(matches!(&zdotdir_profiles[0].kind, AffectedProfileKind::Main { .. }));
560+
}
561+
562+
#[test]
563+
#[serial]
564+
#[cfg(not(windows))]
565+
fn test_collect_affected_profiles_xdg_config() {
566+
let temp_dir = tempfile::tempdir().unwrap();
567+
let home = AbsolutePathBuf::new(temp_dir.path().join("home")).unwrap();
568+
let xdg_config = temp_dir.path().join("xdg_config");
569+
let fish_dir = xdg_config.join("fish/conf.d");
570+
std::fs::create_dir_all(&home).unwrap();
571+
std::fs::create_dir_all(&fish_dir).unwrap();
572+
573+
std::fs::write(fish_dir.join("vite-plus.fish"), "").unwrap();
574+
575+
let _guard = ProfileEnvGuard::new(None, Some(&xdg_config));
576+
577+
let profiles = collect_affected_profiles(&home);
578+
let xdg_profiles: Vec<_> =
579+
profiles.iter().filter(|p| p.path.as_path().starts_with(&xdg_config)).collect();
580+
assert_eq!(xdg_profiles.len(), 1);
581+
assert!(matches!(&xdg_profiles[0].kind, AffectedProfileKind::Snippet));
582+
}
583+
423584
#[test]
424585
fn test_execute_not_installed() {
425586
let temp_dir = tempfile::tempdir().unwrap();

0 commit comments

Comments
 (0)