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