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
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
//! Check for shell configuration (scuv init).
use super::super::types::{Check, CheckResult};
/// Check for shell configuration (scuv init).
pub(super) struct ShellCheck;
impl Check for ShellCheck {
fn id(&self) -> &'static str {
"shell"
}
fn name(&self) -> &'static str {
"shell configuration"
}
fn run(&self) -> Vec<CheckResult> {
let home = match dirs::home_dir() {
Some(h) => h,
None => {
return vec![CheckResult::error(
self.id(),
self.name(),
"could not determine home directory",
)];
}
};
// Detect current shell from $SHELL environment variable
let shell = std::env::var("SHELL").unwrap_or_default();
let shell_name = std::path::Path::new(&shell)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_lowercase();
// Determine config files to check based on shell
let config_files: Vec<(&str, std::path::PathBuf)> = match shell_name.as_str() {
"zsh" => vec![("zsh", home.join(".zshrc"))],
"bash" => {
// macOS uses .bash_profile, Linux uses .bashrc
if cfg!(target_os = "macos") {
vec![
("bash", home.join(".bash_profile")),
("bash", home.join(".bashrc")),
]
} else {
vec![("bash", home.join(".bashrc"))]
}
}
_ => {
// Unknown shell - check both common configs
return vec![
CheckResult::warn(
self.id(),
self.name(),
format!("unsupported shell: {}", shell_name),
)
.with_details("Supported shells: bash, zsh")
.with_suggestion("Manual setup may be required"),
];
}
};
let shell_type = if shell_name == "zsh" { "zsh" } else { "bash" };
// Check if any config file contains scuv init. A legacy-only
// `scoop init` line does NOT count as configured: the deprecated
// `scoop` shell function is defined *inside* `scuv init`'s own
// output (see shell/bash.rs, shell/zsh.rs), so it only exists once
// that init line has already run successfully in the session. An rc
// file that still invokes `eval "$(scoop init ...)"` calls the
// `scoop` *binary* directly — which no longer ships after upgrade —
// so the eval fails at shell startup and integration never loads.
// That must be flagged as a warning, not treated as configured.
//
// DEPRECATION(0.16.0): drop the legacy branch once the shim window
// closes.
for (_shell_type, config_path) in &config_files {
if config_path.exists() {
match std::fs::read_to_string(config_path) {
Ok(content) => {
if content.contains("scuv init") {
return vec![
CheckResult::ok(self.id(), self.name())
.with_details(format!("found in {}", config_path.display())),
];
}
if content.contains("scoop init") {
return vec![
CheckResult::warn(
self.id(),
self.name(),
"shell config still references the removed `scoop` command (init line fails at startup)",
)
.with_details(format!("found in {}", config_path.display()))
.with_suggestion(format!(
"Replace with: eval \"$(scuv init {})\"",
shell_type
)),
];
}
}
Err(_) => {
return vec![CheckResult::warn(
self.id(),
self.name(),
format!("could not read {}", config_path.display()),
)];
}
}
}
}
// No scuv init (or legacy scoop init) found
let config_file = if shell_name == "zsh" {
"~/.zshrc"
} else if cfg!(target_os = "macos") {
"~/.bash_profile"
} else {
"~/.bashrc"
};
vec![
CheckResult::error(
self.id(),
self.name(),
"scuv init not found in shell config",
)
.with_suggestion(format!(
"Add to {}: eval \"$(scuv init {})\"",
config_file, shell_type
)),
]
}
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
// ==========================================================================
// ShellCheck: scuv init vs. legacy scoop init in rc files
//
// A legacy-only `scoop init` line must warn, not pass — the deprecated
// `scoop` shell function is defined by `scuv init`'s own output (see
// shell/bash.rs), so it doesn't exist yet when an rc file's
// `eval "$(scoop init ...)"` line runs at shell startup; that line
// invokes the (now-removed) `scoop` binary directly and fails.
// ==========================================================================
/// Write `content` to both `.bash_profile` and `.bashrc` so the test is
/// deterministic regardless of which one `ShellCheck` consults on the
/// host OS (macOS checks `.bash_profile` first, Linux only `.bashrc`).
fn write_bash_rc(home: &std::path::Path, content: &str) {
std::fs::write(home.join(".bash_profile"), content).unwrap();
std::fs::write(home.join(".bashrc"), content).unwrap();
}
#[test]
#[serial]
fn shell_check_is_ok_when_current_scuv_init_line_present() {
let home_tmp = tempfile::tempdir().unwrap();
write_bash_rc(home_tmp.path(), "eval \"$(scuv init bash)\"\n");
let _g = crate::test_utils::env_guard(&[
("SHELL", Some("/bin/bash")),
("HOME", Some(home_tmp.path().to_str().unwrap())),
]);
let results = ShellCheck.run();
assert_eq!(results.len(), 1, "got {results:#?}");
assert!(results[0].is_ok(), "expected Ok, got {:#?}", results[0]);
assert_eq!(results[0].id, "shell");
assert_eq!(results[0].name, "shell configuration");
}
/// Pins the exact branch order in `ShellCheck::run`: a `scuv init` match
/// must short-circuit before the legacy-only warning branch is even
/// reached, so an rc file that still has an old, now-inert comment or
/// leftover `scoop init` reference alongside a working `scuv init` line
/// is not incorrectly flagged.
#[test]
#[serial]
fn shell_check_ok_when_both_scuv_and_legacy_scoop_init_present() {
let home_tmp = tempfile::tempdir().unwrap();
write_bash_rc(
home_tmp.path(),
"# old: eval \"$(scoop init bash)\"\neval \"$(scuv init bash)\"\n",
);
let _g = crate::test_utils::env_guard(&[
("SHELL", Some("/bin/bash")),
("HOME", Some(home_tmp.path().to_str().unwrap())),
]);
let results = ShellCheck.run();
assert_eq!(results.len(), 1, "got {results:#?}");
assert!(results[0].is_ok(), "expected Ok, got {:#?}", results[0]);
}
#[test]
#[serial]
fn shell_check_warns_on_legacy_only_scoop_init_line() {
let home_tmp = tempfile::tempdir().unwrap();
write_bash_rc(home_tmp.path(), "eval \"$(scoop init bash)\"\n");
let _g = crate::test_utils::env_guard(&[
("SHELL", Some("/bin/bash")),
("HOME", Some(home_tmp.path().to_str().unwrap())),
]);
let results = ShellCheck.run();
assert_eq!(results.len(), 1, "got {results:#?}");
assert!(
results[0].is_warning(),
"expected Warning, got {:#?}",
results[0]
);
let suggestion = results[0].suggestion.as_deref().unwrap_or_default();
assert!(
suggestion.contains("scuv init"),
"suggestion should point at scuv init, got: {suggestion}"
);
}
// ==========================================================================
// ShellCheck zsh branch (the `shell_name == "zsh"` boundary): the
// suggestion must name `scuv init zsh`, not bash, and read .zshrc.
// ==========================================================================
#[test]
#[serial]
fn shell_check_zsh_warns_on_legacy_only_line_with_zsh_suggestion() {
let home_tmp = tempfile::tempdir().unwrap();
std::fs::write(
home_tmp.path().join(".zshrc"),
"eval \"$(scoop init zsh)\"\n",
)
.unwrap();
let _g = crate::test_utils::env_guard(&[
("SHELL", Some("/bin/zsh")),
("HOME", Some(home_tmp.path().to_str().unwrap())),
]);
let results = ShellCheck.run();
assert_eq!(results.len(), 1, "got {results:#?}");
assert!(results[0].is_warning(), "got {results:#?}");
let suggestion = results[0].suggestion.as_deref().unwrap_or_default();
assert!(
suggestion.contains("scuv init zsh"),
"zsh shell must get a zsh suggestion, got {suggestion:?}"
);
}
#[test]
#[serial]
fn shell_check_zsh_no_init_errors_with_zshrc_suggestion() {
// Empty `.zshrc` (no scuv/scoop init) reaches the "no init found" tail,
// which selects the config-file hint by shell name. A `== -> !=` mutant
// on the `shell_name == "zsh"` boundary would pick the bash file here.
let home_tmp = tempfile::tempdir().unwrap();
std::fs::write(home_tmp.path().join(".zshrc"), "").unwrap();
let _g = crate::test_utils::env_guard(&[
("SHELL", Some("/bin/zsh")),
("HOME", Some(home_tmp.path().to_str().unwrap())),
]);
let results = ShellCheck.run();
assert_eq!(results.len(), 1, "got {results:#?}");
// no init line present -> error path selects config file by shell name.
// A `== -> !=` mutant would pick the bash file for zsh.
assert!(results[0].is_error());
assert!(
results[0]
.suggestion
.as_deref()
.unwrap_or("")
.contains(".zshrc"),
"zsh no-init suggestion must name ~/.zshrc, got {:?}",
results[0].suggestion
);
}
#[test]
#[serial]
fn shell_check_zsh_is_ok_with_current_scuv_init_line() {
let home_tmp = tempfile::tempdir().unwrap();
std::fs::write(
home_tmp.path().join(".zshrc"),
"eval \"$(scuv init zsh)\"\n",
)
.unwrap();
let _g = crate::test_utils::env_guard(&[
("SHELL", Some("/bin/zsh")),
("HOME", Some(home_tmp.path().to_str().unwrap())),
]);
let results = ShellCheck.run();
assert_eq!(results.len(), 1, "got {results:#?}");
assert!(results[0].is_ok(), "got {results:#?}");
}
}