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