Skip to main content

cli/shells/
uninstall.rs

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        // config.toml must never be removed by uninstall
397        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        // No install — config file doesn't exist
448        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        // User-owned script must survive uninstall.
549        assert!(script.exists(), "user script must not be deleted");
550        // Bin symlink should be gone.
551        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}