1#[cfg(test)]
2use super::profile::remove_path_from_shell_config;
3use super::report::{
4 shell_cache_remove_summary_parts, style_dim, style_symbol, unlink_report_summary_parts,
5};
6use crate::config::Config;
7use crate::output;
8use crate::presentation::{LifecycleReporter, PresentationEvent, TerminalRenderer};
9use anyhow::Result;
10use shine_core::lifecycle::LifecycleOperation;
11use shine_core::lifecycle::LifecycleResultV1;
12#[cfg(test)]
13use shine_core::lifecycle::{LifecycleEffect, LifecycleStatus};
14use shine_core::runtime::{PlanningInputVersions, ShellPlanRequest};
15
16pub async fn handle_uninstall(
17 config: &Config,
18 target: Option<&str>,
19 purge: bool,
20 dry_run: bool,
21) -> Result<()> {
22 handle_uninstall_approved(config, target, purge, dry_run, true).await
23}
24
25pub async fn handle_uninstall_approved(
26 config: &Config,
27 target: Option<&str>,
28 purge: bool,
29 dry_run: bool,
30 yes: bool,
31) -> Result<()> {
32 let mut renderer = TerminalRenderer::stdio();
33 handle_uninstall_with_reporter(config, target, purge, dry_run, yes, &mut renderer)
34 .await
35 .map(|_| ())
36}
37
38#[cfg(test)]
39pub(crate) async fn handle_uninstall_with_result(
40 config: &Config,
41 target: Option<&str>,
42 purge: bool,
43 dry_run: bool,
44) -> Result<LifecycleResultV1> {
45 let mut renderer = TerminalRenderer::stdio();
46 handle_uninstall_with_reporter(config, target, purge, dry_run, true, &mut renderer).await
47}
48
49async fn handle_uninstall_with_reporter(
50 config: &Config,
51 target: Option<&str>,
52 purge: bool,
53 dry_run: bool,
54 yes: bool,
55 reporter: &mut dyn LifecycleReporter,
56) -> Result<LifecycleResultV1> {
57 for line in crate::config::presets_note_lines(config) {
58 reporter.emit(PresentationEvent::stdout(line));
59 }
60 if dry_run {
61 reporter.emit(PresentationEvent::stdout(style_dim(
62 "[dry-run] No files will be modified.",
63 )));
64 }
65 let reviewed = if dry_run {
66 None
67 } else {
68 crate::lifecycle_plan::review_plans(
69 config,
70 [crate::lifecycle_plan::LifecyclePlanRequest::shell(
71 ShellPlanRequest {
72 operation: LifecycleOperation::Uninstall,
73 target: target.map(str::to_string),
74 force: false,
75 purge,
76 input_versions: PlanningInputVersions::default(),
77 },
78 config,
79 )],
80 yes,
81 )
82 .await?
83 .into_iter()
84 .next()
85 };
86 let runtime = if let Some(reviewed) = &reviewed {
87 crate::lifecycle_plan::prepare_runtime(config, reviewed).await?
88 } else {
89 crate::core_runtime::from_config(config).await?
90 };
91 let core_report = if let Some(reviewed) = reviewed {
92 match crate::lifecycle_plan::execute_reviewed(
93 config,
94 runtime,
95 reviewed,
96 shine_core::frontend::ExecutionOptions::default(),
97 &mut shine_core::runtime::NullObserver,
98 &mut crate::presentation::TerminalInteraction,
99 )
100 .await?
101 {
102 shine_core::frontend::OperationDetails::ShellUninstall(report) => *report,
103 _ => unreachable!("reviewed operation result type"),
104 }
105 } else {
106 runtime
107 .preview_uninstall_shells(shine_core::runtime::ShellUninstallRequest {
108 target: target.map(str::to_string),
109 dry_run,
110 purge,
111 })
112 .await?
113 };
114 reporter.emit(PresentationEvent::stdout(output::summary_line_text(
115 "Bin Links",
116 &unlink_report_summary_parts(&core_report.links),
117 )));
118 if !config.is_external_presets {
119 reporter.emit(PresentationEvent::stdout(output::summary_line_text(
120 "Shell Presets",
121 &shell_cache_remove_summary_parts(&core_report.cache),
122 )));
123 }
124 if purge && !dry_run && !config.is_external_presets {
125 reporter.emit(PresentationEvent::stdout(format!(
126 " {} {}",
127 style_symbol("✓"),
128 style_dim("managed directories purged (if empty)"),
129 )));
130 }
131 if let Some(profile) = &core_report.profile {
132 for path in &profile.config_paths {
133 reporter.emit(PresentationEvent::stdout(format!(
134 "Shell config ({}): shine entry removed",
135 crate::path_display::format_home(path, &config.home_dir)
136 )));
137 }
138 if let Some(path) = &profile.managed_profile {
139 reporter.emit(PresentationEvent::stdout(format!(
140 "Shell profile ({}): removed",
141 crate::path_display::format_home(path, &config.home_dir)
142 )));
143 }
144 }
145 Ok(core_report.lifecycle)
146}
147
148#[cfg(test)]
149mod tests {
150 use super::super::ShellType;
151 use super::super::install::handle_install;
152 use super::super::profile::{append_path_to_shell_config, managed_shell_profile_path};
153 use super::*;
154 use std::path::PathBuf;
155 use tokio::fs;
156
157 async fn make_temp_dir() -> PathBuf {
158 crate::test_support::make_temp_dir("shine-shell").await
159 }
160
161 #[tokio::test]
162 async fn command_scoped_uninstall_preserves_installed_sibling() {
163 let dir = make_temp_dir().await;
164 let config = Config::new_for_test(&dir);
165 fs::create_dir_all(config.bin_dir()).await.unwrap();
166
167 handle_install(&config, Some("utils/shine-env-export"), false)
168 .await
169 .unwrap();
170 handle_install(&config, Some("utils/shine-theme-sync"), false)
171 .await
172 .unwrap();
173 handle_uninstall(&config, Some("utils/shine-env-export"), false, false)
174 .await
175 .unwrap();
176
177 let removed = crate::bin_links::command_path_for_name(
178 config.bin_dir(),
179 std::ffi::OsStr::new("shine-env-export"),
180 );
181 let sibling = crate::bin_links::command_path_for_name(
182 config.bin_dir(),
183 std::ffi::OsStr::new("shine-theme-sync"),
184 );
185 assert!(!removed.exists());
186 assert!(sibling.exists());
187
188 let manifest =
189 crate::shells::deployment::ShellManifest::load(&shine_core::runtime::RealHost, &config)
190 .await
191 .unwrap();
192 assert!(manifest.find("shell/utils/shine-env-export").is_none());
193 assert!(manifest.find("shell/utils/shine-theme-sync").is_some());
194
195 let profile = fs::read_to_string(managed_shell_profile_path(&config))
196 .await
197 .unwrap();
198 assert!(!profile.contains(&wrapper_marker("shine-env-export", &config.shell_type)));
199 assert!(profile.contains(&wrapper_marker("shine-theme-sync", &config.shell_type)));
200
201 fs::remove_dir_all(&dir).await.unwrap();
202 }
203
204 #[tokio::test]
205 async fn command_scoped_uninstall_preserves_shared_rendered_file() {
206 let dir = make_temp_dir().await;
207 let mut config = Config::new_for_test(&dir);
208 config.is_external_presets = true;
209 let source_extension = if config.shell_type == ShellType::PowerShell {
210 "ps1"
211 } else {
212 "sh"
213 };
214 let source = format!("shared.{source_extension}");
215 let category = dir.join("presets/shell/custom");
216 fs::create_dir_all(&category).await.unwrap();
217 fs::write(
218 category.join("shine.toml"),
219 format!(
220 "[[files]]\nsource = \"{source}\"\ntarget = \"one\"\ntransforms = [\"template\"]\n[files.permissions]\nschema_version = 1\n\n[[files]]\nsource = \"{source}\"\ntarget = \"two\"\ntransforms = [\"template\"]\n[files.permissions]\nschema_version = 1\n"
221 ),
222 )
223 .await
224 .unwrap();
225 fs::write(category.join(&source), b"echo shared\n")
226 .await
227 .unwrap();
228 fs::create_dir_all(config.bin_dir()).await.unwrap();
229
230 handle_install(&config, Some("custom/one"), false)
231 .await
232 .unwrap();
233 handle_install(&config, Some("custom/two"), false)
234 .await
235 .unwrap();
236 let rendered = config.rendered_dir().join("shell/custom").join(source);
237 assert!(rendered.exists());
238
239 handle_uninstall(&config, Some("custom/one"), false, false)
240 .await
241 .unwrap();
242
243 assert!(
244 rendered.exists(),
245 "installed sibling still uses rendered file"
246 );
247 assert!(
248 crate::bin_links::command_path_for_name(config.bin_dir(), std::ffi::OsStr::new("two"))
249 .exists()
250 );
251 let manifest =
252 crate::shells::deployment::ShellManifest::load(&shine_core::runtime::RealHost, &config)
253 .await
254 .unwrap();
255 assert!(manifest.find("shell/custom/one").is_none());
256 assert_eq!(
257 manifest.find("shell/custom/two").unwrap().rendered_path,
258 rendered
259 );
260
261 fs::remove_dir_all(&dir).await.unwrap();
262 }
263
264 #[tokio::test]
265 async fn command_scoped_uninstall_dry_run_preserves_launcher_and_manifest() {
266 let dir = make_temp_dir().await;
267 let config = Config::new_for_test(&dir);
268 fs::create_dir_all(config.bin_dir()).await.unwrap();
269 handle_install(&config, Some("utils/shine-env-export"), false)
270 .await
271 .unwrap();
272
273 let result =
274 handle_uninstall_with_result(&config, Some("utils/shine-env-export"), false, true)
275 .await
276 .unwrap();
277
278 assert_eq!(result.outcomes[0].status, LifecycleStatus::Previewed);
279 assert!(
280 result.outcomes[0]
281 .effects
282 .contains(&LifecycleEffect::CacheRemovePreviewed)
283 );
284
285 let command = crate::bin_links::command_path_for_name(
286 config.bin_dir(),
287 std::ffi::OsStr::new("shine-env-export"),
288 );
289 assert!(command.exists());
290 let manifest =
291 crate::shells::deployment::ShellManifest::load(&shine_core::runtime::RealHost, &config)
292 .await
293 .unwrap();
294 assert!(manifest.find("shell/utils/shine-env-export").is_some());
295
296 fs::remove_dir_all(&dir).await.unwrap();
297 }
298
299 #[tokio::test]
300 async fn command_scoped_uninstall_preserves_foreign_command_entry() {
301 let dir = make_temp_dir().await;
302 let config = Config::new_for_test(&dir);
303 fs::create_dir_all(config.bin_dir()).await.unwrap();
304 handle_install(&config, Some("utils/shine-env-export"), false)
305 .await
306 .unwrap();
307 let command = crate::bin_links::command_path_for_name(
308 config.bin_dir(),
309 std::ffi::OsStr::new("shine-env-export"),
310 );
311 shine_core::runtime::unlink_managed_command_with_host(
312 &shine_core::runtime::RealHost,
313 config.bin_dir(),
314 std::ffi::OsStr::new("shine-env-export"),
315 &[config.presets_dir().join("shell/utils")],
316 false,
317 )
318 .await
319 .unwrap();
320 fs::write(&command, b"user-owned command\n").await.unwrap();
321
322 let update = super::super::install::collect_update_lifecycle_result(&config)
323 .await
324 .unwrap();
325 let pending = update
326 .outcomes
327 .iter()
328 .find(|outcome| outcome.target == "shell/utils/shine-env-export")
329 .unwrap();
330 assert_eq!(pending.status, LifecycleStatus::Conflict);
331
332 let mut separator = crate::output::SectionSeparator::new();
333 let error = super::super::install::handle_upgrade_installed_target_with_result(
334 &config,
335 Some("utils"),
336 false,
337 &mut separator,
338 )
339 .await
340 .unwrap_err();
341 assert!(error.to_string().contains("Plan is blocked"));
342 assert_eq!(
343 fs::read_to_string(&command).await.unwrap(),
344 "user-owned command\n"
345 );
346
347 let result =
348 handle_uninstall_with_result(&config, Some("utils/shine-env-export"), false, false)
349 .await
350 .unwrap();
351
352 assert_eq!(result.outcomes[0].status, LifecycleStatus::Conflict);
353 assert!(
354 result.outcomes[0]
355 .effects
356 .contains(&LifecycleEffect::UserResourcePreserved)
357 );
358
359 assert_eq!(
360 fs::read_to_string(&command).await.unwrap(),
361 "user-owned command\n"
362 );
363 let manifest =
364 crate::shells::deployment::ShellManifest::load(&shine_core::runtime::RealHost, &config)
365 .await
366 .unwrap();
367 assert!(manifest.find("shell/utils/shine-env-export").is_none());
368
369 fs::remove_dir_all(&dir).await.unwrap();
370 }
371
372 fn wrapper_marker(command: &str, shell: &ShellType) -> String {
373 match shell {
374 ShellType::PowerShell => format!("\nfunction {command} {{ . (Join-Path $shineBin"),
375 ShellType::Fish => format!("\nfunction {command}"),
376 _ => format!("\n{command}() {{ source"),
377 }
378 }
379
380 #[cfg(unix)]
381 #[tokio::test]
382 async fn uninstall_purge_removes_managed_dirs_but_not_config() {
383 let dir = make_temp_dir().await;
384 let config = Config::new_for_test(&dir);
385 fs::create_dir_all(config.presets_dir()).await.unwrap();
386 fs::create_dir_all(config.bin_dir()).await.unwrap();
387
388 handle_install(&config, None, false).await.unwrap();
389 handle_uninstall(&config, None, true, false).await.unwrap();
390
391 assert!(!config.bin_dir().exists(), "bin_dir should be purged");
392 assert!(
393 !config.presets_dir().join("shell").exists(),
394 "shell presets dir should be purged"
395 );
396 assert!(
398 config.presets_dir().parent().is_some(),
399 "shine root still accessible"
400 );
401
402 fs::remove_dir_all(&dir).await.unwrap();
403 }
404
405 #[cfg(unix)]
406 #[tokio::test]
407 async fn uninstall_dry_run_leaves_everything_intact() {
408 let dir = make_temp_dir().await;
409 let config = Config::new_for_test(&dir);
410 fs::create_dir_all(config.presets_dir()).await.unwrap();
411 fs::create_dir_all(config.bin_dir()).await.unwrap();
412
413 handle_install(&config, None, false).await.unwrap();
414 let preset_path = config.presets_dir().join("shell/proxy/set_proxy.sh");
415 assert!(preset_path.exists());
416
417 handle_uninstall(&config, None, false, true).await.unwrap();
418
419 assert!(preset_path.exists(), "dry-run must not remove preset files");
420
421 fs::remove_dir_all(&dir).await.unwrap();
422 }
423
424 #[tokio::test]
425 async fn remove_clears_sentinel_from_shell_config() {
426 let dir = make_temp_dir().await;
427 let config = Config::new_for_test(&dir);
428
429 append_path_to_shell_config(&config, false, &[])
430 .await
431 .unwrap();
432 remove_path_from_shell_config(&config).await.unwrap();
433
434 let config_path =
435 super::super::get_shell_config_path(&config.shell_type, &config.home_dir).unwrap();
436 let content = fs::read_to_string(&config_path).await.unwrap();
437 assert!(
438 !content.contains(super::super::SENTINEL_START),
439 "sentinel should be gone after remove"
440 );
441 }
442
443 #[tokio::test]
444 async fn remove_is_no_op_when_config_missing() {
445 let dir = make_temp_dir().await;
446 let config = Config::new_for_test(&dir);
447 remove_path_from_shell_config(&config).await.unwrap();
449 }
450
451 #[cfg(unix)]
452 #[tokio::test]
453 async fn uninstall_dry_run_does_not_modify_shell_config() {
454 let dir = make_temp_dir().await;
455 let config = Config::new_for_test(&dir);
456 fs::create_dir_all(config.presets_dir()).await.unwrap();
457 fs::create_dir_all(config.bin_dir()).await.unwrap();
458
459 handle_install(&config, None, false).await.unwrap();
460 let config_path =
461 super::super::get_shell_config_path(&config.shell_type, &config.home_dir).unwrap();
462 let before = fs::read_to_string(&config_path).await.unwrap();
463 let profile_path = managed_shell_profile_path(&config);
464 let profile_before = fs::read_to_string(&profile_path).await.unwrap();
465
466 handle_uninstall(&config, None, false, true).await.unwrap();
467
468 let after = fs::read_to_string(&config_path).await.unwrap();
469 assert_eq!(before, after, "dry-run must not touch shell config");
470 let profile_after = fs::read_to_string(&profile_path).await.unwrap();
471 assert_eq!(
472 profile_before, profile_after,
473 "dry-run must not touch managed shell profile"
474 );
475
476 fs::remove_dir_all(&dir).await.unwrap();
477 }
478
479 #[tokio::test]
480 async fn uninstall_category_keeps_agent_launcher_and_prunes_source_wrappers() {
481 let dir = make_temp_dir().await;
482 let config = Config::new_for_test(&dir);
483 fs::create_dir_all(config.presets_dir()).await.unwrap();
484 fs::create_dir_all(config.bin_dir()).await.unwrap();
485
486 handle_install(&config, Some("agent"), false).await.unwrap();
487 handle_install(&config, Some("proxy"), false).await.unwrap();
488
489 handle_uninstall(&config, Some("proxy"), false, false)
490 .await
491 .unwrap();
492
493 let profile = fs::read_to_string(managed_shell_profile_path(&config))
494 .await
495 .unwrap();
496 assert!(!profile.contains(&wrapper_marker("ccenv", &config.shell_type)));
497 assert!(
498 !profile.contains(&wrapper_marker("setproxy", &config.shell_type)),
499 "removed category wrapper should be pruned: {profile}"
500 );
501 assert!(
502 !profile.contains(&wrapper_marker("usetproxy", &config.shell_type)),
503 "removed category wrapper should be pruned: {profile}"
504 );
505 let ccenv = crate::bin_links::command_path_for_name(
506 config.bin_dir(),
507 std::ffi::OsStr::new("ccenv"),
508 );
509 assert!(ccenv.exists(), "remaining Bun launcher should be kept");
510
511 fs::remove_dir_all(&dir).await.unwrap();
512 }
513
514 #[cfg(unix)]
515 #[tokio::test]
516 async fn external_presets_uninstall_preserves_disk_scripts() {
517 let dir = make_temp_dir().await;
518 let cat_dir = dir.join("presets/shell/custom");
519 fs::create_dir_all(&cat_dir).await.unwrap();
520 let script = cat_dir.join("my_tool.sh");
521 fs::write(&script, b"#!/bin/bash\n# My tool.\necho hi\n")
522 .await
523 .unwrap();
524 fs::write(
525 cat_dir.join("shine.toml"),
526 b"[[files]]\nsource = \"my_tool.sh\"\ntarget = \"my_tool\"\n[files.permissions]\nschema_version = 1\n",
527 )
528 .await
529 .unwrap();
530 use std::os::unix::fs::PermissionsExt;
531 let mut perms = fs::metadata(&script).await.unwrap().permissions();
532 perms.set_mode(perms.mode() | 0o111);
533 fs::set_permissions(&script, perms).await.unwrap();
534
535 let mut config = Config::new_for_test(&dir);
536 config.is_external_presets = true;
537 fs::create_dir_all(config.bin_dir()).await.unwrap();
538
539 handle_install(&config, Some("custom"), false)
540 .await
541 .unwrap();
542 assert!(config.bin_dir().join("my_tool").exists());
543
544 handle_uninstall(&config, Some("custom"), false, false)
545 .await
546 .unwrap();
547
548 assert!(script.exists(), "user script must not be deleted");
550 assert!(
552 !config.bin_dir().join("my_tool").exists(),
553 "bin link should be removed"
554 );
555
556 fs::remove_dir_all(&dir).await.unwrap();
557 }
558}