1use super::install::installed_source_commands;
2use super::profile::{
3 remove_managed_shell_profile, remove_path_from_shell_config, write_managed_shell_profile,
4};
5use super::report::{remove_report_summary_parts, unlink_report_summary_parts};
6use crate::config::Config;
7use crate::output;
8use anyhow::{Context, Result, bail};
9use std::ffi::OsStr;
10
11pub async fn handle_uninstall(
12 config: &Config,
13 target: Option<&str>,
14 purge: bool,
15 dry_run: bool,
16) -> Result<()> {
17 crate::config::print_presets_note(config);
18 if dry_run {
19 println!(
20 "{}",
21 crate::colors::dim("[dry-run] No files will be modified.")
22 );
23 }
24 let selection = target
25 .map(super::metadata::parse_lifecycle_target)
26 .transpose()?;
27 if let Some(selection) = selection
28 && let Some(command) = selection.command
29 {
30 return handle_uninstall_command(config, selection.category, command, purge, dry_run).await;
31 }
32 let category = selection.map(|selection| selection.category);
33
34 let managed_presets_root = match category {
36 Some(cat) => config.presets_dir().join("shell").join(cat),
37 None => config.presets_dir().to_path_buf(),
38 };
39 let managed_rendered_root = match category {
40 Some(cat) => config.rendered_dir().join("shell").join(cat),
41 None => config.rendered_dir().join("shell"),
42 };
43 let prefix = match category {
44 Some(cat) => format!("shell/{cat}"),
45 None => "shell".to_owned(),
46 };
47
48 let unlink_presets =
50 crate::bin_links::unlink_managed(config.bin_dir(), &managed_presets_root, dry_run).await?;
51 let unlink_rendered =
52 crate::bin_links::unlink_managed(config.bin_dir(), &managed_rendered_root, dry_run).await?;
53 let managed_installed_root = match category {
54 Some(cat) => config.installed_shell_dir().join(cat),
55 None => config.installed_shell_dir(),
56 };
57 let unlink_installed =
58 crate::bin_links::unlink_managed(config.bin_dir(), &managed_installed_root, dry_run)
59 .await?;
60 let unlink_report = crate::bin_links::UnlinkReport {
61 removed: [
62 unlink_presets.removed,
63 unlink_rendered.removed,
64 unlink_installed.removed,
65 ]
66 .concat(),
67 skipped: [
68 unlink_presets.skipped,
69 unlink_rendered.skipped,
70 unlink_installed.skipped,
71 ]
72 .concat(),
73 };
74 output::summary_line("Bin Links", &unlink_report_summary_parts(&unlink_report));
75
76 if !config.is_external_presets {
79 let remove_report =
80 crate::presets::remove_prefix(&prefix, config.presets_dir(), dry_run).await?;
81 output::summary_line(
82 "Shell Presets",
83 &remove_report_summary_parts(&remove_report),
84 );
85 }
86
87 if purge && !dry_run && !config.is_external_presets {
90 let purge_dir = match category {
91 Some(cat) => config.presets_dir().join("shell").join(cat),
92 None => config.presets_dir().join("shell"),
93 };
94 if purge_dir.exists() {
95 tokio::fs::remove_dir_all(&purge_dir)
96 .await
97 .with_context(|| format!("removing presets directory: {purge_dir:?}"))?;
98 }
99 if category.is_none() {
100 let _ = tokio::fs::remove_dir(config.presets_dir()).await;
102 let _ = tokio::fs::remove_dir(config.bin_dir()).await;
103 }
104 println!(
105 " {} {}",
106 crate::colors::symbol("✓"),
107 crate::colors::dim("managed directories purged (if empty)"),
108 );
109 }
110
111 if !dry_run && managed_rendered_root.exists() {
113 tokio::fs::remove_dir_all(&managed_rendered_root)
114 .await
115 .with_context(|| {
116 format!("removing rendered dir: {}", managed_rendered_root.display())
117 })?;
118 }
119
120 if !dry_run && managed_installed_root.exists() {
121 tokio::fs::remove_dir_all(&managed_installed_root)
122 .await
123 .with_context(|| {
124 format!(
125 "removing installed shell snapshot: {}",
126 managed_installed_root.display()
127 )
128 })?;
129 }
130
131 if !dry_run {
132 let mut manifest = super::deployment::ShellManifest::load(config).await?;
133 if let Some(category) = category {
134 manifest.remove_category(category);
135 } else {
136 manifest.entries.clear();
137 }
138 manifest.save(config).await?;
139 }
140
141 if !dry_run {
142 if category.is_none() {
143 remove_path_from_shell_config(config).await?;
145 remove_managed_shell_profile(config).await?;
146 } else {
147 let remaining_source_commands = installed_source_commands(config).await?;
148 write_managed_shell_profile(config, &remaining_source_commands).await?;
149 }
150 }
151
152 Ok(())
153}
154
155async fn handle_uninstall_command(
156 config: &Config,
157 category: &str,
158 command: &str,
159 purge: bool,
160 dry_run: bool,
161) -> Result<()> {
162 let mut manifest = super::deployment::ShellManifest::load(config).await?;
163 let canonical = format!("shell/{category}/{command}");
164 let manifest_entry = manifest.find(&canonical).cloned();
165 let active_target = match super::metadata::load_active_target(
166 config,
167 super::metadata::ShellTarget {
168 category,
169 command: Some(command),
170 },
171 )
172 .await
173 {
174 Ok(categories) => Some(categories),
175 Err(error) if manifest_entry.is_none() => return Err(error),
176 Err(_) => None,
177 };
178
179 let mut managed_roots = vec![
180 config.presets_dir().join("shell").join(category),
181 config.rendered_dir().join("shell").join(category),
182 config.installed_shell_dir().join(category),
183 ];
184 if let Some(overlay) = config.active_presets_overlay_dir() {
185 managed_roots.push(overlay.join("shell").join(category));
186 }
187 if let Some(entry) = &manifest_entry {
188 managed_roots.push(entry.source_path.clone());
192 managed_roots.push(entry.rendered_path.clone());
193 }
194 let unlink_report = crate::bin_links::unlink_managed_command(
195 config.bin_dir(),
196 OsStr::new(command),
197 &managed_roots,
198 dry_run,
199 )
200 .await?;
201 if manifest_entry.is_none() && unlink_report.removed.is_empty() {
202 if unlink_report.skipped.is_empty() {
203 bail!("shell command is not installed: {category}/{command}");
204 }
205 bail!(
206 "shell command entry is not managed by Shine: {}",
207 unlink_report.skipped[0].display()
208 );
209 }
210 output::summary_line("Bin Links", &unlink_report_summary_parts(&unlink_report));
211
212 let other_manifest_entry = manifest
213 .entries
214 .iter()
215 .any(|entry| entry.category == category && entry.command != command);
216 let other_link_exists =
217 match super::metadata::load_active_categories(config, Some(category)).await {
218 Ok(categories) => categories
219 .iter()
220 .flat_map(|category| &category.files)
221 .any(|file| {
222 file.command_name != command
223 && crate::bin_links::command_path_for_name(
224 config.bin_dir(),
225 OsStr::new(&file.command_name),
226 )
227 .exists()
228 }),
229 Err(_) => false,
230 };
231 let category_still_installed = other_manifest_entry || other_link_exists;
232
233 if !dry_run {
234 let rendered_path = manifest_entry
235 .as_ref()
236 .map(|entry| entry.rendered_path.clone())
237 .or_else(|| {
238 active_target.and_then(|categories| {
239 categories.first().and_then(|category| {
240 category.files.first().map(|file| {
241 super::deployment::rendered_path(
242 config,
243 &category.name,
244 &file.source_rel,
245 )
246 })
247 })
248 })
249 });
250 if let Some(path) = rendered_path
251 && path.starts_with(config.rendered_dir())
252 && !manifest.entries.iter().any(|entry| {
253 (entry.category != category || entry.command != command)
254 && entry.rendered_path == path
255 })
256 {
257 remove_file_if_present(&path).await?;
258 }
259 manifest.remove_target(category, command);
260 manifest.save(config).await?;
261 }
262
263 if !category_still_installed {
264 if !config.is_external_presets {
265 let report = crate::presets::remove_prefix(
266 &format!("shell/{category}"),
267 config.presets_dir(),
268 dry_run,
269 )
270 .await?;
271 output::summary_line("Shell Presets", &remove_report_summary_parts(&report));
272 }
273 if !dry_run {
274 remove_dir_if_present(&config.rendered_dir().join("shell").join(category)).await?;
275 remove_dir_if_present(&config.installed_shell_dir().join(category)).await?;
276 }
277 }
278
279 if purge && !dry_run && !config.is_external_presets && !category_still_installed {
280 let _ = tokio::fs::remove_dir(config.presets_dir().join("shell")).await;
281 let _ = tokio::fs::remove_dir(config.presets_dir()).await;
282 let _ = tokio::fs::remove_dir(config.bin_dir()).await;
283 }
284
285 if !dry_run {
286 let remaining_source_commands = installed_source_commands(config).await?;
287 write_managed_shell_profile(config, &remaining_source_commands).await?;
288 }
289 Ok(())
290}
291
292async fn remove_file_if_present(path: &std::path::Path) -> Result<()> {
293 match tokio::fs::remove_file(path).await {
294 Ok(()) => Ok(()),
295 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
296 Err(error) => Err(error).with_context(|| format!("removing {}", path.display())),
297 }
298}
299
300async fn remove_dir_if_present(path: &std::path::Path) -> Result<()> {
301 match tokio::fs::remove_dir_all(path).await {
302 Ok(()) => Ok(()),
303 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
304 Err(error) => Err(error).with_context(|| format!("removing {}", path.display())),
305 }
306}
307
308#[cfg(test)]
309mod tests {
310 use super::super::ShellType;
311 use super::super::install::handle_install;
312 use super::super::profile::{append_path_to_shell_config, managed_shell_profile_path};
313 use super::*;
314 use std::path::PathBuf;
315 use tokio::fs;
316
317 async fn make_temp_dir() -> PathBuf {
318 crate::test_support::make_temp_dir("shine-shell").await
319 }
320
321 #[tokio::test]
322 async fn command_scoped_uninstall_preserves_installed_sibling() {
323 let dir = make_temp_dir().await;
324 let config = Config::new_for_test(&dir);
325 fs::create_dir_all(config.bin_dir()).await.unwrap();
326
327 handle_install(&config, Some("utils/shine-env-export"), false)
328 .await
329 .unwrap();
330 handle_install(&config, Some("utils/shine-theme-sync"), false)
331 .await
332 .unwrap();
333 handle_uninstall(&config, Some("utils/shine-env-export"), false, false)
334 .await
335 .unwrap();
336
337 let removed = crate::bin_links::command_path_for_name(
338 config.bin_dir(),
339 std::ffi::OsStr::new("shine-env-export"),
340 );
341 let sibling = crate::bin_links::command_path_for_name(
342 config.bin_dir(),
343 std::ffi::OsStr::new("shine-theme-sync"),
344 );
345 assert!(!removed.exists());
346 assert!(sibling.exists());
347
348 let manifest = crate::shells::deployment::ShellManifest::load(&config)
349 .await
350 .unwrap();
351 assert!(manifest.find("shell/utils/shine-env-export").is_none());
352 assert!(manifest.find("shell/utils/shine-theme-sync").is_some());
353
354 let profile = fs::read_to_string(managed_shell_profile_path(&config))
355 .await
356 .unwrap();
357 assert!(!profile.contains(&wrapper_marker("shine-env-export", &config.shell_type)));
358 assert!(profile.contains(&wrapper_marker("shine-theme-sync", &config.shell_type)));
359
360 fs::remove_dir_all(&dir).await.unwrap();
361 }
362
363 #[tokio::test]
364 async fn command_scoped_uninstall_preserves_shared_rendered_file() {
365 let dir = make_temp_dir().await;
366 let category = dir.join("presets/shell/custom");
367 fs::create_dir_all(&category).await.unwrap();
368 fs::write(
369 category.join("shine.toml"),
370 b"[[files]]\nsource = \"shared.sh\"\ntarget = \"one\"\ntransforms = [\"template\"]\n\n[[files]]\nsource = \"shared.sh\"\ntarget = \"two\"\ntransforms = [\"template\"]\n",
371 )
372 .await
373 .unwrap();
374 fs::write(category.join("shared.sh"), b"#!/bin/sh\necho shared\n")
375 .await
376 .unwrap();
377 let mut config = Config::new_for_test(&dir);
378 config.is_external_presets = true;
379 fs::create_dir_all(config.bin_dir()).await.unwrap();
380
381 handle_install(&config, Some("custom/one"), false)
382 .await
383 .unwrap();
384 handle_install(&config, Some("custom/two"), false)
385 .await
386 .unwrap();
387 let rendered = config.rendered_dir().join("shell/custom/shared.sh");
388 assert!(rendered.exists());
389
390 handle_uninstall(&config, Some("custom/one"), false, false)
391 .await
392 .unwrap();
393
394 assert!(
395 rendered.exists(),
396 "installed sibling still uses rendered file"
397 );
398 assert!(
399 crate::bin_links::command_path_for_name(config.bin_dir(), std::ffi::OsStr::new("two"))
400 .exists()
401 );
402 let manifest = crate::shells::deployment::ShellManifest::load(&config)
403 .await
404 .unwrap();
405 assert!(manifest.find("shell/custom/one").is_none());
406 assert_eq!(
407 manifest.find("shell/custom/two").unwrap().rendered_path,
408 rendered
409 );
410
411 fs::remove_dir_all(&dir).await.unwrap();
412 }
413
414 #[tokio::test]
415 async fn command_scoped_uninstall_dry_run_preserves_launcher_and_manifest() {
416 let dir = make_temp_dir().await;
417 let config = Config::new_for_test(&dir);
418 fs::create_dir_all(config.bin_dir()).await.unwrap();
419 handle_install(&config, Some("utils/shine-env-export"), false)
420 .await
421 .unwrap();
422
423 handle_uninstall(&config, Some("utils/shine-env-export"), false, true)
424 .await
425 .unwrap();
426
427 let command = crate::bin_links::command_path_for_name(
428 config.bin_dir(),
429 std::ffi::OsStr::new("shine-env-export"),
430 );
431 assert!(command.exists());
432 let manifest = crate::shells::deployment::ShellManifest::load(&config)
433 .await
434 .unwrap();
435 assert!(manifest.find("shell/utils/shine-env-export").is_some());
436
437 fs::remove_dir_all(&dir).await.unwrap();
438 }
439
440 #[tokio::test]
441 async fn command_scoped_uninstall_preserves_foreign_command_entry() {
442 let dir = make_temp_dir().await;
443 let config = Config::new_for_test(&dir);
444 fs::create_dir_all(config.bin_dir()).await.unwrap();
445 handle_install(&config, Some("utils/shine-env-export"), false)
446 .await
447 .unwrap();
448 let command = crate::bin_links::command_path_for_name(
449 config.bin_dir(),
450 std::ffi::OsStr::new("shine-env-export"),
451 );
452 crate::bin_links::unlink_managed_command(
453 config.bin_dir(),
454 std::ffi::OsStr::new("shine-env-export"),
455 &[config.presets_dir().join("shell/utils")],
456 false,
457 )
458 .await
459 .unwrap();
460 fs::write(&command, b"user-owned command\n").await.unwrap();
461
462 handle_uninstall(&config, Some("utils/shine-env-export"), false, false)
463 .await
464 .unwrap();
465
466 assert_eq!(
467 fs::read_to_string(&command).await.unwrap(),
468 "user-owned command\n"
469 );
470 let manifest = crate::shells::deployment::ShellManifest::load(&config)
471 .await
472 .unwrap();
473 assert!(manifest.find("shell/utils/shine-env-export").is_none());
474
475 fs::remove_dir_all(&dir).await.unwrap();
476 }
477
478 fn wrapper_marker(command: &str, shell: &ShellType) -> String {
479 match shell {
480 ShellType::PowerShell => format!("\nfunction {command} {{ . (Join-Path $shineBin"),
481 ShellType::Fish => format!("\nfunction {command}"),
482 _ => format!("\n{command}() {{ source"),
483 }
484 }
485
486 #[cfg(unix)]
487 #[tokio::test]
488 async fn uninstall_purge_removes_managed_dirs_but_not_config() {
489 let dir = make_temp_dir().await;
490 let config = Config::new_for_test(&dir);
491 fs::create_dir_all(config.presets_dir()).await.unwrap();
492 fs::create_dir_all(config.bin_dir()).await.unwrap();
493
494 handle_install(&config, None, false).await.unwrap();
495 handle_uninstall(&config, None, true, false).await.unwrap();
496
497 assert!(!config.bin_dir().exists(), "bin_dir should be purged");
498 assert!(
499 !config.presets_dir().join("shell").exists(),
500 "shell presets dir should be purged"
501 );
502 assert!(
504 config.presets_dir().parent().is_some(),
505 "shine root still accessible"
506 );
507
508 fs::remove_dir_all(&dir).await.unwrap();
509 }
510
511 #[cfg(unix)]
512 #[tokio::test]
513 async fn uninstall_dry_run_leaves_everything_intact() {
514 let dir = make_temp_dir().await;
515 let config = Config::new_for_test(&dir);
516 fs::create_dir_all(config.presets_dir()).await.unwrap();
517 fs::create_dir_all(config.bin_dir()).await.unwrap();
518
519 handle_install(&config, None, false).await.unwrap();
520 let preset_path = config.presets_dir().join("shell/proxy/set_proxy.sh");
521 assert!(preset_path.exists());
522
523 handle_uninstall(&config, None, false, true).await.unwrap();
524
525 assert!(preset_path.exists(), "dry-run must not remove preset files");
526
527 fs::remove_dir_all(&dir).await.unwrap();
528 }
529
530 #[tokio::test]
531 async fn remove_clears_sentinel_from_shell_config() {
532 let dir = make_temp_dir().await;
533 let config = Config::new_for_test(&dir);
534
535 append_path_to_shell_config(&config, false, &[])
536 .await
537 .unwrap();
538 remove_path_from_shell_config(&config).await.unwrap();
539
540 let config_path =
541 super::super::get_shell_config_path(&config.shell_type, &config.home_dir).unwrap();
542 let content = fs::read_to_string(&config_path).await.unwrap();
543 assert!(
544 !content.contains(super::super::SENTINEL_START),
545 "sentinel should be gone after remove"
546 );
547 }
548
549 #[tokio::test]
550 async fn remove_is_no_op_when_config_missing() {
551 let dir = make_temp_dir().await;
552 let config = Config::new_for_test(&dir);
553 remove_path_from_shell_config(&config).await.unwrap();
555 }
556
557 #[cfg(unix)]
558 #[tokio::test]
559 async fn uninstall_dry_run_does_not_modify_shell_config() {
560 let dir = make_temp_dir().await;
561 let config = Config::new_for_test(&dir);
562 fs::create_dir_all(config.presets_dir()).await.unwrap();
563 fs::create_dir_all(config.bin_dir()).await.unwrap();
564
565 handle_install(&config, None, false).await.unwrap();
566 let config_path =
567 super::super::get_shell_config_path(&config.shell_type, &config.home_dir).unwrap();
568 let before = fs::read_to_string(&config_path).await.unwrap();
569 let profile_path = managed_shell_profile_path(&config);
570 let profile_before = fs::read_to_string(&profile_path).await.unwrap();
571
572 handle_uninstall(&config, None, false, true).await.unwrap();
573
574 let after = fs::read_to_string(&config_path).await.unwrap();
575 assert_eq!(before, after, "dry-run must not touch shell config");
576 let profile_after = fs::read_to_string(&profile_path).await.unwrap();
577 assert_eq!(
578 profile_before, profile_after,
579 "dry-run must not touch managed shell profile"
580 );
581
582 fs::remove_dir_all(&dir).await.unwrap();
583 }
584
585 #[tokio::test]
586 async fn uninstall_category_keeps_agent_launcher_and_prunes_source_wrappers() {
587 let dir = make_temp_dir().await;
588 let config = Config::new_for_test(&dir);
589 fs::create_dir_all(config.presets_dir()).await.unwrap();
590 fs::create_dir_all(config.bin_dir()).await.unwrap();
591
592 handle_install(&config, Some("agent"), false).await.unwrap();
593 handle_install(&config, Some("proxy"), false).await.unwrap();
594
595 handle_uninstall(&config, Some("proxy"), false, false)
596 .await
597 .unwrap();
598
599 let profile = fs::read_to_string(managed_shell_profile_path(&config))
600 .await
601 .unwrap();
602 assert!(!profile.contains(&wrapper_marker("ccenv", &config.shell_type)));
603 assert!(
604 !profile.contains(&wrapper_marker("setproxy", &config.shell_type)),
605 "removed category wrapper should be pruned: {profile}"
606 );
607 assert!(
608 !profile.contains(&wrapper_marker("usetproxy", &config.shell_type)),
609 "removed category wrapper should be pruned: {profile}"
610 );
611 let ccenv = crate::bin_links::command_path_for_name(
612 config.bin_dir(),
613 std::ffi::OsStr::new("ccenv"),
614 );
615 assert!(ccenv.exists(), "remaining Bun launcher should be kept");
616
617 fs::remove_dir_all(&dir).await.unwrap();
618 }
619
620 #[cfg(unix)]
621 #[tokio::test]
622 async fn external_presets_uninstall_preserves_disk_scripts() {
623 let dir = make_temp_dir().await;
624 let cat_dir = dir.join("presets/shell/custom");
625 fs::create_dir_all(&cat_dir).await.unwrap();
626 let script = cat_dir.join("my_tool.sh");
627 fs::write(&script, b"#!/bin/bash\n# My tool.\necho hi\n")
628 .await
629 .unwrap();
630 use std::os::unix::fs::PermissionsExt;
631 let mut perms = fs::metadata(&script).await.unwrap().permissions();
632 perms.set_mode(perms.mode() | 0o111);
633 fs::set_permissions(&script, perms).await.unwrap();
634
635 let mut config = Config::new_for_test(&dir);
636 config.is_external_presets = true;
637 fs::create_dir_all(config.bin_dir()).await.unwrap();
638
639 handle_install(&config, Some("custom"), false)
640 .await
641 .unwrap();
642 assert!(config.bin_dir().join("my_tool").exists());
643
644 handle_uninstall(&config, Some("custom"), false, false)
645 .await
646 .unwrap();
647
648 assert!(script.exists(), "user script must not be deleted");
650 assert!(
652 !config.bin_dir().join("my_tool").exists(),
653 "bin link should be removed"
654 );
655
656 fs::remove_dir_all(&dir).await.unwrap();
657 }
658}