Skip to main content

podbox/wizard/
shell.rs

1/// Information about the host shell, resolved at startup.
2pub struct ShellInfo {
3    pub bin_name: String,
4    pub full_path: String,
5    pub package_name: String,
6    pub detected: bool,
7}
8
9/// Detect the host shell from $SHELL.
10pub fn detect_host_shell() -> ShellInfo {
11    detect_host_shell_from(std::env::var("SHELL").ok().as_deref())
12}
13
14pub(super) fn detect_host_shell_from(shell_path: Option<&str>) -> ShellInfo {
15    match shell_path {
16        Some(path) if !path.is_empty() => {
17            let bin = std::path::Path::new(path)
18                .file_name()
19                .map(|s| s.to_string_lossy().to_string())
20                .unwrap_or_default();
21            if bin.is_empty() || bin == "sh" || bin == "dash" {
22                return fallback_shell();
23            }
24            let mut info = shell_info_from_bin(&bin);
25            info.detected = true;
26            info
27        }
28        _ => fallback_shell(),
29    }
30}
31
32fn fallback_shell() -> ShellInfo {
33    ShellInfo {
34        bin_name: "fish".into(),
35        full_path: "/usr/bin/fish".into(),
36        package_name: "fish".into(),
37        detected: false,
38    }
39}
40
41pub(super) fn shell_info_from_bin(bin: &str) -> ShellInfo {
42    match bin {
43        "fish" => ShellInfo {
44            bin_name: "fish".into(),
45            full_path: "/usr/bin/fish".into(),
46            package_name: "fish".into(),
47            detected: false,
48        },
49        "bash" => ShellInfo {
50            bin_name: "bash".into(),
51            full_path: "/bin/bash".into(),
52            package_name: "bash".into(),
53            detected: false,
54        },
55        "zsh" => ShellInfo {
56            bin_name: "zsh".into(),
57            full_path: "/bin/zsh".into(),
58            package_name: "zsh".into(),
59            detected: false,
60        },
61        "nu" | "nushell" => ShellInfo {
62            bin_name: "nu".into(),
63            full_path: "/usr/bin/nu".into(),
64            package_name: "nushell".into(),
65            detected: false,
66        },
67        other => ShellInfo {
68            bin_name: other.into(),
69            full_path: format!("/usr/bin/{other}"),
70            package_name: other.into(),
71            detected: false,
72        },
73    }
74}
75
76/// Apply shell defaults to a config loaded from a profile.
77pub fn apply_shell_defaults(config: &mut crate::config::Config, shell: &ShellInfo) {
78    if config.container.shell.trim().is_empty() {
79        config.container.shell.clone_from(&shell.full_path);
80    }
81    if !config
82        .image
83        .packages
84        .install
85        .iter()
86        .any(|p| p == &shell.package_name)
87    {
88        config
89            .image
90            .packages
91            .install
92            .push(shell.package_name.clone());
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    #[test]
101    fn detect_fish_from_path() {
102        let info = detect_host_shell_from(Some("/usr/bin/fish"));
103        assert_eq!(info.bin_name, "fish");
104        assert_eq!(info.full_path, "/usr/bin/fish");
105        assert_eq!(info.package_name, "fish");
106        assert!(info.detected);
107    }
108
109    #[test]
110    fn detect_zsh_from_path() {
111        let info = detect_host_shell_from(Some("/bin/zsh"));
112        assert_eq!(info.bin_name, "zsh");
113        assert!(info.detected);
114    }
115
116    #[test]
117    fn fallback_on_dash() {
118        let info = detect_host_shell_from(Some("/bin/dash"));
119        assert_eq!(info.bin_name, "fish");
120        assert!(!info.detected);
121    }
122
123    #[test]
124    fn fallback_on_empty_shell() {
125        let info = detect_host_shell_from(None);
126        assert_eq!(info.bin_name, "fish");
127        assert!(!info.detected);
128    }
129
130    #[test]
131    fn fallback_on_sh() {
132        let info = detect_host_shell_from(Some("/bin/sh"));
133        assert_eq!(info.bin_name, "fish");
134        assert!(!info.detected);
135    }
136
137    #[test]
138    fn nushell_binary_maps_to_nushell_package() {
139        let info = detect_host_shell_from(Some("/usr/bin/nu"));
140        assert_eq!(info.bin_name, "nu");
141        assert_eq!(info.package_name, "nushell");
142    }
143
144    #[test]
145    fn shell_info_unknown_binary() {
146        let info = shell_info_from_bin("tcsh");
147        assert_eq!(info.bin_name, "tcsh");
148        assert_eq!(info.full_path, "/usr/bin/tcsh");
149        assert_eq!(info.package_name, "tcsh");
150    }
151
152    #[test]
153    fn detect_host_shell_is_idempotent() {
154        let a = detect_host_shell_from(Some("/usr/bin/fish"));
155        let b = detect_host_shell_from(Some("/usr/bin/fish"));
156        assert_eq!(a.bin_name, b.bin_name);
157        assert_eq!(a.full_path, b.full_path);
158        assert_eq!(a.package_name, b.package_name);
159    }
160
161    #[test]
162    fn apply_shell_adds_package_when_missing() {
163        let toml = r#"
164[image]
165base = "fedora:41"
166name = "testenv"
167packages = { install = ["fastfetch"] }
168[container]
169name = "testenv"
170home = "~/containers/testenv"
171"#;
172        let mut cfg: crate::config::Config = toml::from_str(toml).unwrap();
173        let shell = ShellInfo {
174            bin_name: "zsh".into(),
175            full_path: "/bin/zsh".into(),
176            package_name: "zsh".into(),
177            detected: true,
178        };
179        apply_shell_defaults(&mut cfg, &shell);
180        assert!(cfg.image.packages.install.contains(&"zsh".to_string()));
181        assert_eq!(
182            cfg.container.shell, "fish",
183            "should not override existing shell"
184        );
185    }
186
187    #[test]
188    fn apply_shell_fills_empty_shell() {
189        let toml = r#"
190[image]
191base = "fedora:41"
192name = "testenv"
193[container]
194name = "testenv"
195home = "~/containers/testenv"
196"#;
197        let mut cfg: crate::config::Config = toml::from_str(toml).unwrap();
198        cfg.container.shell.clear();
199        let shell = ShellInfo {
200            bin_name: "zsh".into(),
201            full_path: "/bin/zsh".into(),
202            package_name: "zsh".into(),
203            detected: true,
204        };
205        apply_shell_defaults(&mut cfg, &shell);
206        assert_eq!(cfg.container.shell, "/bin/zsh", "should fill empty shell");
207        assert!(cfg.image.packages.install.contains(&"zsh".to_string()));
208    }
209
210    #[test]
211    fn apply_shell_no_duplicate_when_present() {
212        let toml = r#"
213[image]
214base = "fedora:41"
215name = "testenv"
216packages = { install = ["fish", "fastfetch"] }
217[container]
218name = "testenv"
219home = "~/containers/testenv"
220"#;
221        let mut cfg: crate::config::Config = toml::from_str(toml).unwrap();
222        let shell = ShellInfo {
223            bin_name: "fish".into(),
224            full_path: "/usr/bin/fish".into(),
225            package_name: "fish".into(),
226            detected: true,
227        };
228        apply_shell_defaults(&mut cfg, &shell);
229        let fish_count = cfg
230            .image
231            .packages
232            .install
233            .iter()
234            .filter(|s| s.as_str() == "fish")
235            .count();
236        assert_eq!(fish_count, 1);
237    }
238
239    #[test]
240    fn tty_guard_logic_is_correct() {
241        // is_tty() is tested in codegen::distros — placeholder kept for symmetry
242    }
243}