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_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}