Skip to main content

cli/shells/
mod.rs

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