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