1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
pub use std::path::*;
use crate::dirs;
pub trait PathExt {
/// replaces $HOME with "~"
fn display_user(&self) -> String;
fn mount(&self, on: &Path) -> PathBuf;
fn is_empty(&self) -> bool;
}
impl PathExt for Path {
fn display_user(&self) -> String {
let home = dirs::HOME.to_string_lossy();
let home_str: &str = home.as_ref();
match cfg!(unix) && self.starts_with(home_str) && home != "/" {
true => self.to_string_lossy().replacen(home_str, "~", 1),
false => self.to_string_lossy().to_string(),
}
}
fn mount(&self, on: &Path) -> PathBuf {
if PathExt::is_empty(self) {
on.to_path_buf()
} else {
on.join(self)
}
}
fn is_empty(&self) -> bool {
self.as_os_str().is_empty()
}
}
/// Convert a Windows-style path list (`;`-separated, drive-letter prefix, `\` or `/`
/// separator) into a Git Bash / MSYS Unix-style path list (`:`-separated, `/c/...`
/// prefix, `/` separator).
///
/// Pure Rust, no subprocess. Designed for the case where mise on Windows spawns a
/// POSIX shell (`bash -c`, `sh -c`, ...) for a task — that shell uses PATH itself to
/// resolve commands, and cannot read `C:\foo;D:\bar`.
///
/// Conversion rules per entry, applied independently:
///
/// - `<drive>:[\\/]...` (canonical Windows drive path) → `/<drive lowercase>/<rest with `/` separator>`
/// - already-Unix entries (start with `/`) → pass through unchanged
/// - empty entries (e.g. trailing `;`) → preserved as empty
/// - UNC (`\\?\...`, `\\server\share\...`) → pass through unchanged. bash will fail
/// to use them, which matches what would happen without conversion.
/// - other entries (relative paths, bare names, drive-relative `C:foo`, etc.) →
/// `\` is replaced with `/` so that bash can resolve entries like
/// `node_modules\.bin` or `.\bin` injected by tools that emit Windows separators.
///
/// Out of scope (kept narrow per maintainer guidance — see PR description / `_context/`):
///
/// - Cygwin's `/etc/fstab` mount table
/// - Cygwin's `/cygdrive/c/` prefix (Git Bash uses `/c/`, which is the dominant case)
/// - Git Bash's "magic" mount of `/usr` to its install dir — `/c/Program Files/Git/usr/bin`
/// is resolved by bash to the same executable as `/usr/bin`, so no remapping is needed
/// for PATH-resolution to succeed.
#[cfg_attr(not(windows), allow(dead_code))]
pub fn windows_path_list_to_unix(path_list: &str) -> String {
let mut out = String::with_capacity(path_list.len());
let mut first = true;
for entry in path_list.split(WINDOWS_PATH_SEP) {
if !first {
out.push(':');
}
append_single_windows_path_to_unix(&mut out, entry);
first = false;
}
out
}
#[cfg_attr(not(windows), allow(dead_code))]
const WINDOWS_PATH_SEP: char = ';';
#[cfg_attr(not(windows), allow(dead_code))]
fn append_single_windows_path_to_unix(out: &mut String, entry: &str) {
if entry.is_empty() {
return;
}
// Already-Unix entries and UNC paths are passed through verbatim.
if entry.starts_with('/') || entry.starts_with("\\\\") {
out.push_str(entry);
return;
}
let bytes = entry.as_bytes();
let is_canonical_drive = bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& (bytes[2] == b'\\' || bytes[2] == b'/');
let rest = if is_canonical_drive {
// C:\foo → /c/foo : emit `/<drive lowercase>` then the tail with `\` → `/`.
out.push('/');
out.push((bytes[0] as char).to_ascii_lowercase());
&entry[2..]
} else {
// Other shapes (relative paths, bare names, `C:foo`) — keep as-is but
// still translate `\` → `/` so bash can resolve them.
entry
};
for c in rest.chars() {
out.push(if c == '\\' { '/' } else { c });
}
}
/// Returns true if `program` is the path or basename of a POSIX-style shell that
/// expects a Unix-style PATH. Used on Windows to decide whether to convert the
/// child's PATH before spawning.
///
/// Matches by basename (case-insensitive, `.exe` stripped) against a fixed list.
/// Splits on both `/` and `\` so the result is the same regardless of the host
/// `Path` separator — important since this is unit-tested on Linux/macOS too.
/// Does not stat the file — input may be a bare name like `"bash"` that resolves
/// later via the launcher's PATH search.
#[cfg_attr(not(windows), allow(dead_code))]
pub fn is_posix_shell_program(program: &Path) -> bool {
const POSIX_SHELLS: &[&str] = &["bash", "sh", "zsh", "fish", "ksh", "dash"];
let Some(s) = program.to_str() else {
return false;
};
let basename = s.rsplit(['/', '\\']).next().unwrap_or(s);
let stem = match basename.rsplit_once('.') {
Some((stem, ext)) if ext.eq_ignore_ascii_case("exe") => stem,
_ => basename,
};
let stem_lower = stem.to_ascii_lowercase();
POSIX_SHELLS.iter().any(|name| *name == stem_lower)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_windows_path_list_to_unix_basic() {
assert_eq!(windows_path_list_to_unix(r"C:\foo;D:\bar"), "/c/foo:/d/bar");
}
#[test]
fn test_windows_path_list_to_unix_forward_slash() {
assert_eq!(windows_path_list_to_unix("C:/foo;D:/bar"), "/c/foo:/d/bar");
}
#[test]
fn test_windows_path_list_to_unix_mixed_separators() {
assert_eq!(
windows_path_list_to_unix(r"C:\foo\bar;D:/baz/qux"),
"/c/foo/bar:/d/baz/qux"
);
}
#[test]
fn test_windows_path_list_to_unix_passthrough_unix_entries() {
assert_eq!(
windows_path_list_to_unix("/usr/bin;C:\\foo;/c/bar"),
"/usr/bin:/c/foo:/c/bar"
);
}
#[test]
fn test_windows_path_list_to_unix_passthrough_unc() {
// UNC entries are passed through verbatim (they contain `:` themselves,
// so we cannot split the result on `:` to inspect entries — bash receives
// the whole string and will fail to use the UNC entry, which matches what
// would happen without conversion).
assert_eq!(
windows_path_list_to_unix(r"\\?\C:\foo;C:\bar"),
r"\\?\C:\foo:/c/bar"
);
}
#[test]
fn test_windows_path_list_to_unix_empty_entries() {
assert_eq!(windows_path_list_to_unix("C:\\foo;"), "/c/foo:");
assert_eq!(windows_path_list_to_unix(";C:\\foo"), ":/c/foo");
assert_eq!(windows_path_list_to_unix(""), "");
}
#[test]
fn test_windows_path_list_to_unix_drive_letter_case() {
assert_eq!(windows_path_list_to_unix(r"C:\foo"), "/c/foo");
assert_eq!(windows_path_list_to_unix(r"c:\foo"), "/c/foo");
}
#[test]
fn test_windows_path_list_to_unix_program_files_with_spaces() {
assert_eq!(
windows_path_list_to_unix(r"C:\Program Files\Git\bin"),
"/c/Program Files/Git/bin"
);
}
#[test]
fn test_windows_path_list_to_unix_bare_drive_letter_passthrough() {
// Bare "C:" or "C:foo" (relative-to-drive) is unrecognized — pass through.
assert_eq!(windows_path_list_to_unix("C:"), "C:");
assert_eq!(windows_path_list_to_unix("C:foo"), "C:foo");
}
#[test]
fn test_windows_path_list_to_unix_relative_paths_with_backslashes() {
// mise can inject relative entries via `[env] _.path = ["./node_modules/.bin"]`,
// and tools that emit Windows separators may produce backslash forms. bash
// does not treat `\` as a separator, so we translate `\` → `/` for non-UNC,
// non-canonical-drive entries too.
assert_eq!(
windows_path_list_to_unix(r"node_modules\.bin"),
"node_modules/.bin"
);
assert_eq!(windows_path_list_to_unix(r".\bin"), "./bin");
assert_eq!(
windows_path_list_to_unix(r"node_modules\.bin;C:\tools\bin"),
"node_modules/.bin:/c/tools/bin"
);
}
#[test]
fn test_windows_path_list_to_unix_single_entry() {
assert_eq!(windows_path_list_to_unix(r"C:\foo"), "/c/foo");
}
#[test]
fn test_is_posix_shell_program() {
assert!(is_posix_shell_program(Path::new("bash")));
assert!(is_posix_shell_program(Path::new("bash.exe")));
assert!(is_posix_shell_program(Path::new("BASH.EXE")));
assert!(is_posix_shell_program(Path::new(
r"C:\Program Files\Git\bin\bash.exe"
)));
assert!(is_posix_shell_program(Path::new("/usr/bin/bash")));
assert!(is_posix_shell_program(Path::new("sh")));
assert!(is_posix_shell_program(Path::new("zsh")));
assert!(is_posix_shell_program(Path::new("fish")));
assert!(!is_posix_shell_program(Path::new("cmd")));
assert!(!is_posix_shell_program(Path::new("cmd.exe")));
assert!(!is_posix_shell_program(Path::new("powershell")));
assert!(!is_posix_shell_program(Path::new("pwsh.exe")));
assert!(!is_posix_shell_program(Path::new("rustc")));
assert!(!is_posix_shell_program(Path::new("")));
}
}