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}