1pub struct ShellInfo {
3 pub bin_name: String,
4 pub full_path: String,
5 pub package_name: String,
6 pub detected: bool,
7}
8
9pub 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
76pub 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 }
243}