Skip to main content

cli/shells/
mod.rs

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