1pub(crate) mod deployment;
2mod install;
3mod links;
4pub mod metadata;
5mod profile;
6mod report;
7mod template;
8mod uninstall;
9
10#[doc(hidden)]
11pub use deployment::handle_render_live;
12pub use install::{
13 handle_completion_install, handle_init_template, handle_install, handle_install_dry_run,
14 handle_upgrade_installed, handle_upgrade_installed_target,
15};
16pub(crate) use metadata::validate_preset_category;
17#[doc(hidden)]
18pub use report::handle_list_with_presets_note;
19pub use report::{ShellUpgradeReport, handle_info, handle_list};
20pub use uninstall::handle_uninstall;
21
22use anyhow::{Result, bail};
23use serde::{Deserialize, Serialize};
24use std::path::{Path, PathBuf};
25use std::str::FromStr;
26
27pub const SENTINEL_START: &str = "# >>> shine >>>";
28const SENTINEL_END: &str = "# <<< shine <<<";
29
30#[derive(Debug)]
31enum PathUpdateStatus {
32 AlreadyConfigured,
33 Updated(PathBuf),
34}
35
36#[derive(Debug)]
37struct ShellConfigUpdate {
38 profile_updated: bool,
39 config_status: PathUpdateStatus,
40}
41
42#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
43pub enum ShellType {
44 Bash,
45 Fish,
46 Zsh,
47 PowerShell,
48 Elvish,
49}
50
51pub fn get_shell() -> Result<ShellType> {
52 match std::env::var("SHELL") {
53 Ok(shell) => shell.parse(),
54 Err(_) if cfg!(windows) => Ok(ShellType::PowerShell),
55 Err(_) => bail!("Could not find $SHELL"),
56 }
57}
58
59pub fn get_shell_config_path(shell_type: &ShellType, home_path: &Path) -> Result<PathBuf> {
60 get_shell_config_paths(shell_type, home_path)?
61 .into_iter()
62 .next()
63 .ok_or_else(|| anyhow::anyhow!("shell config paths should never be empty"))
64}
65
66fn get_shell_config_paths(shell_type: &ShellType, home_path: &Path) -> Result<Vec<PathBuf>> {
67 match shell_type {
68 ShellType::Bash => Ok(vec![home_path.join(".bashrc")]),
69 ShellType::Fish => Ok(vec![home_path.join(".config/fish/config.fish")]),
70 ShellType::Zsh => Ok(vec![home_path.join(".zshrc")]),
71 ShellType::PowerShell => {
72 if cfg!(windows) {
73 Ok(vec![
74 home_path.join("Documents/PowerShell/Microsoft.PowerShell_profile.ps1"),
75 home_path.join("Documents/WindowsPowerShell/Microsoft.PowerShell_profile.ps1"),
76 ])
77 } else {
78 Ok(vec![home_path.join(
79 ".config/powershell/Microsoft.PowerShell_profile.ps1",
80 )])
81 }
82 }
83 ShellType::Elvish => Ok(vec![home_path.join(".config/elvish/rc.elv")]),
84 }
85}
86
87impl FromStr for ShellType {
88 type Err = anyhow::Error;
89 fn from_str(s: &str) -> Result<Self, Self::Err> {
90 let shell_name = s
91 .rsplit(['/', '\\'])
92 .next()
93 .unwrap_or(s)
94 .to_ascii_lowercase();
95 let normalized = shell_name.trim_end_matches(".exe");
96 if normalized == "bash" {
97 Ok(ShellType::Bash)
98 } else if normalized == "fish" {
99 Ok(ShellType::Fish)
100 } else if normalized == "zsh" {
101 Ok(ShellType::Zsh)
102 } else if normalized == "powershell" || normalized == "pwsh" {
103 Ok(ShellType::PowerShell)
104 } else if normalized == "elvish" {
105 Ok(ShellType::Elvish)
106 } else {
107 bail!("Unknown shell item type: {}", s)
108 }
109 }
110}
111
112impl From<ShellType> for &'static str {
113 fn from(value: ShellType) -> Self {
114 match value {
115 ShellType::Bash => "bash",
116 ShellType::Fish => "fish",
117 ShellType::Zsh => "zsh",
118 ShellType::PowerShell => "powershell",
119 ShellType::Elvish => "elvish",
120 }
121 }
122}
123
124impl Default for ShellType {
125 fn default() -> Self {
126 get_shell().unwrap_or(ShellType::Zsh)
127 }
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133 use profile::shell_source_command;
134 use profile::{
135 managed_profile_snippet, powershell_bin_assignment, powershell_quote,
136 remove_sentinel_block, shell_config_snippet,
137 };
138
139 #[test]
140 fn managed_profile_uses_home_relative_bin_path() {
141 let home = PathBuf::from("/home/user");
142 let bin = home.join(".shine/bin");
143 let snippet = managed_profile_snippet(&ShellType::Zsh, &bin, &home, &[]);
144 assert!(
145 snippet.contains("$HOME/.shine/bin"),
146 "should use $HOME: {snippet}"
147 );
148 assert!(!snippet.contains(SENTINEL_START));
149 assert!(!snippet.contains(SENTINEL_END));
150 }
151
152 #[test]
153 fn managed_profile_uses_absolute_bin_path_when_outside_home() {
154 let home = PathBuf::from("/home/user");
155 let bin = PathBuf::from("/opt/shine/bin");
156 let snippet = managed_profile_snippet(&ShellType::Zsh, &bin, &home, &[]);
157 assert!(
158 snippet.contains("/opt/shine/bin"),
159 "should use absolute: {snippet}"
160 );
161 assert!(!snippet.contains("$HOME"));
162 }
163
164 #[test]
165 fn snippet_fish_uses_fish_add_path() {
166 let home = PathBuf::from("/home/user");
167 let bin = home.join("bin");
168 let snippet = managed_profile_snippet(&ShellType::Fish, &bin, &home, &[]);
169 assert!(
170 snippet.contains("fish_add_path"),
171 "fish should use fish_add_path: {snippet}"
172 );
173 }
174
175 #[test]
176 fn snippet_bash_zsh_uses_if_guard() {
177 let home = PathBuf::from("/home/user");
178 let bin = home.join("bin");
179 for shell in [ShellType::Bash, ShellType::Zsh] {
180 let snippet = managed_profile_snippet(&shell, &bin, &home, &[]);
181 assert!(
182 snippet.contains("if [["),
183 "{shell:?} should have if-guard: {snippet}"
184 );
185 assert!(snippet.contains("export PATH="));
186 let shell_name: &'static str = shell.into();
187 assert!(
188 snippet.contains(&format!("COMPLETE={shell_name} shine")),
189 "{shell:?} should register shine completion: {snippet}"
190 );
191 if matches!(shell, ShellType::Zsh) {
192 assert!(
193 snippet.contains("autoload -Uz compinit"),
194 "zsh completion registration should initialize compinit: {snippet}"
195 );
196 assert!(
197 snippet.contains("compinit -i"),
198 "zsh completion registration should avoid insecure-dir prompts: {snippet}"
199 );
200 }
201 }
202 }
203
204 #[test]
205 fn snippet_powershell_registers_completion_but_fish_does_not() {
206 let home = PathBuf::from("/home/user");
207 let bin = home.join("bin");
208
209 let powershell = managed_profile_snippet(&ShellType::PowerShell, &bin, &home, &[]);
210 assert!(
211 powershell.contains("$env:COMPLETE = 'powershell'"),
212 "PowerShell should register shine completion: {powershell}"
213 );
214
215 let fish = managed_profile_snippet(&ShellType::Fish, &bin, &home, &[]);
216 assert!(
217 !fish.contains("COMPLETE=fish shine"),
218 "fish completion should not be changed: {fish}"
219 );
220 assert!(profile::supports_completion_registration(
221 &ShellType::PowerShell
222 ));
223 assert!(!profile::supports_completion_registration(&ShellType::Fish));
224 assert!(!profile::supports_completion_registration(
225 &ShellType::Elvish
226 ));
227 }
228
229 #[test]
230 fn snippet_source_commands_generate_wrapper_functions() {
231 let home = PathBuf::from("/home/user");
232 let bin = home.join(".shine/bin");
233 let cmds = vec!["setproxy".to_string(), "usetproxy".to_string()];
234 for shell in [ShellType::Bash, ShellType::Zsh] {
235 let snippet = managed_profile_snippet(&shell, &bin, &home, &cmds);
236 assert!(
237 snippet.contains("setproxy() { source"),
238 "{shell:?} should have setproxy wrapper: {snippet}"
239 );
240 assert!(
241 snippet.contains("usetproxy() { source"),
242 "{shell:?} should have usetproxy wrapper: {snippet}"
243 );
244 }
245 let fish_snippet = managed_profile_snippet(&ShellType::Fish, &bin, &home, &cmds);
246 assert!(
247 fish_snippet.contains("function setproxy"),
248 "fish should have setproxy function: {fish_snippet}"
249 );
250 let powershell_snippet =
251 managed_profile_snippet(&ShellType::PowerShell, &bin, &home, &cmds);
252 assert!(
253 powershell_snippet.contains("$env:Path"),
254 "PowerShell should update env Path: {powershell_snippet}"
255 );
256 assert!(
257 powershell_snippet.contains("function setproxy"),
258 "PowerShell should have setproxy function: {powershell_snippet}"
259 );
260 assert!(
261 powershell_snippet.contains("Join-Path $shineBin"),
262 "PowerShell wrapper should resolve through shine bin: {powershell_snippet}"
263 );
264 assert!(
265 powershell_snippet.contains("$shineBin = Join-Path $HOME '.shine/bin'"),
266 "PowerShell should expand $HOME when assigning shine bin: {powershell_snippet}"
267 );
268 assert!(
269 !powershell_snippet.contains("$shineBin = '$HOME"),
270 "PowerShell should not keep $HOME as a literal path: {powershell_snippet}"
271 );
272 }
273
274 #[test]
275 fn shell_config_snippet_sources_managed_profile_only() {
276 let home = PathBuf::from("/home/user");
277 let profile = home.join(".shine/shell/profile.sh");
278 let snippet = shell_config_snippet(&ShellType::Zsh, &profile, &home);
279 assert!(snippet.contains(SENTINEL_START));
280 assert!(snippet.contains("source \"$HOME/.shine/shell/profile.sh\""));
281 assert!(!snippet.contains("export PATH"));
282 assert!(!snippet.contains("function setproxy"));
283 }
284
285 #[test]
286 fn source_activation_command_quotes_shell_config_path() {
287 let path = PathBuf::from("/home/user/my config/.zshrc");
288 assert_eq!(
289 shell_source_command(&ShellType::Zsh, &path),
290 "source '/home/user/my config/.zshrc'"
291 );
292 assert_eq!(
293 shell_source_command(&ShellType::PowerShell, &path),
294 ". '/home/user/my config/.zshrc'"
295 );
296 }
297
298 #[test]
299 fn powershell_shell_detection_accepts_pwsh_names() {
300 assert!(matches!("pwsh".parse().unwrap(), ShellType::PowerShell));
301 assert!(matches!("pwsh.exe".parse().unwrap(), ShellType::PowerShell));
302 assert!(matches!(
303 r"C:\Program Files\PowerShell\7\pwsh.exe".parse().unwrap(),
304 ShellType::PowerShell
305 ));
306 assert!(matches!(
307 "powershell".parse().unwrap(),
308 ShellType::PowerShell
309 ));
310 }
311
312 #[test]
313 fn powershell_paths_strip_windows_verbatim_prefix() {
314 let assignment = powershell_bin_assignment(r"\\?\D:\Github\Biulight\shine\.shine\bin");
315 assert!(assignment.contains(r"D:\Github\Biulight\shine\.shine\bin"));
316 assert!(!assignment.contains(r"\\?\"));
317
318 let quoted = powershell_quote(Path::new(r"\\?\D:\Github\Biulight\shine\profile.ps1"));
319 assert_eq!(quoted, r"'D:\Github\Biulight\shine\profile.ps1'");
320 }
321
322 #[cfg(unix)]
323 #[test]
324 fn proxy_scripts_fail_fast_when_not_sourced() {
325 let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
326 let preset_dir = manifest_dir.join("presets/shell/proxy");
327
328 for script in ["set_proxy.sh", "uset_proxy.sh"] {
329 let output = std::process::Command::new("bash")
330 .arg(preset_dir.join(script))
331 .output()
332 .expect("proxy script should run under bash");
333
334 assert!(
335 !output.status.success(),
336 "{script} should fail when executed directly"
337 );
338 let stderr = String::from_utf8_lossy(&output.stderr);
339 assert!(
340 stderr.contains("must be sourced"),
341 "{script} should explain source requirement: {stderr}"
342 );
343 }
344 }
345
346 #[test]
347 fn remove_sentinel_block_strips_block_and_blank_line() {
348 let content = "before\n\n# >>> shine >>>\nexport PATH\n# <<< shine <<<\nafter\n";
349 let cleaned = remove_sentinel_block(content);
350 assert_eq!(cleaned, "before\nafter\n");
351 }
352
353 #[test]
354 fn remove_sentinel_block_no_op_when_absent() {
355 let content = "no sentinel here\n";
356 let cleaned = remove_sentinel_block(content);
357 assert_eq!(cleaned, content);
358 }
359}