Skip to main content

cli/apps/
uninstall.rs

1use super::report;
2use crate::config::Config;
3#[cfg(test)]
4use crate::install_core::manifest::{AppEntry, AppManifest};
5use crate::presentation::{LifecycleReporter, PresentationEvent, TerminalRenderer};
6use anyhow::Result;
7#[cfg(test)]
8use shine_core::lifecycle::LifecycleEffect;
9use shine_core::lifecycle::LifecycleOperation;
10use shine_core::lifecycle::LifecycleResultV1;
11use shine_core::runtime::{AppPlanRequest, PlanningInputVersions};
12
13pub async fn handle_uninstall(
14    config: &Config,
15    category: Option<&str>,
16    force: bool,
17    purge: bool,
18    dry_run: bool,
19) -> Result<()> {
20    handle_uninstall_approved(config, category, force, purge, dry_run, true).await
21}
22
23pub async fn handle_uninstall_approved(
24    config: &Config,
25    category: Option<&str>,
26    force: bool,
27    purge: bool,
28    dry_run: bool,
29    yes: bool,
30) -> Result<()> {
31    let mut renderer = TerminalRenderer::stdio();
32    handle_uninstall_with_reporter(config, category, force, purge, dry_run, yes, &mut renderer)
33        .await
34        .map(|_| ())
35}
36
37#[cfg(test)]
38pub(crate) async fn handle_uninstall_with_result(
39    config: &Config,
40    category: Option<&str>,
41    force: bool,
42    purge: bool,
43    dry_run: bool,
44) -> Result<LifecycleResultV1> {
45    let mut renderer = TerminalRenderer::stdio();
46    handle_uninstall_with_reporter(config, category, force, purge, dry_run, true, &mut renderer)
47        .await
48}
49
50async fn handle_uninstall_with_reporter(
51    config: &Config,
52    category: Option<&str>,
53    force: bool,
54    purge: bool,
55    dry_run: bool,
56    yes: bool,
57    reporter: &mut dyn LifecycleReporter,
58) -> Result<LifecycleResultV1> {
59    if dry_run {
60        reporter.emit(PresentationEvent::stdout(report::dry_run_header_text()));
61    }
62    let plan_request = AppPlanRequest {
63        operation: LifecycleOperation::Uninstall,
64        target: category.map(str::to_string),
65        force,
66        purge,
67        prune_stale: false,
68        input_versions: PlanningInputVersions::default(),
69    };
70    let reviewed = if dry_run {
71        None
72    } else {
73        crate::lifecycle_plan::review_plans(
74            config,
75            [crate::lifecycle_plan::LifecyclePlanRequest::app(
76                plan_request,
77                config,
78            )],
79            yes,
80        )
81        .await?
82        .into_iter()
83        .next()
84    };
85    let runtime = if let Some(reviewed) = &reviewed {
86        crate::lifecycle_plan::prepare_runtime(config, reviewed).await?
87    } else {
88        crate::core_runtime::from_config(config).await?
89    };
90    let mut observer = UninstallObserver { reporter };
91    let mut interaction = crate::presentation::TerminalInteraction;
92    let core_report = if let Some(reviewed) = reviewed {
93        match crate::lifecycle_plan::execute_reviewed(
94            config,
95            runtime,
96            reviewed,
97            shine_core::frontend::ExecutionOptions::default(),
98            &mut observer,
99            &mut interaction,
100        )
101        .await?
102        {
103            shine_core::frontend::OperationDetails::App(report) => *report,
104            _ => unreachable!("reviewed operation result type"),
105        }
106    } else {
107        runtime
108            .preview_uninstall_apps(
109                shine_core::runtime::AppUninstallLifecycleRequest {
110                    target: category.map(str::to_string),
111                    dry_run,
112                    force,
113                    purge,
114                },
115                &mut observer,
116                &mut interaction,
117            )
118            .await?
119    };
120    if let Some(category) = category.filter(|_| core_report.files.is_empty()) {
121        observer
122            .reporter
123            .emit(PresentationEvent::stdout(report::no_installed_files_text(
124                category,
125            )));
126        return Ok(core_report.lifecycle);
127    }
128    let mut removed = 0usize;
129    let mut restored = 0usize;
130    let mut user_modified = 0usize;
131    let mut skipped = 0usize;
132    for file in &core_report.files {
133        match file.action {
134            shine_core::runtime::AppFileAction::Removed => {
135                observer
136                    .reporter
137                    .emit(PresentationEvent::stdout(report::removed_text(
138                        config,
139                        &file.destination,
140                    )));
141                removed += 1;
142            }
143            shine_core::runtime::AppFileAction::Restored => {
144                let backup = file
145                    .backup
146                    .as_ref()
147                    .expect("Core restored App backup report");
148                observer.reporter.emit(PresentationEvent::stdout(
149                    report::removed_with_restore_text(config, &file.destination, backup),
150                ));
151                removed += 1;
152                restored += 1;
153            }
154            shine_core::runtime::AppFileAction::ForceRemoved => {
155                observer
156                    .reporter
157                    .emit(PresentationEvent::stdout(report::force_removed_text(
158                        &file.destination,
159                    )));
160                removed += 1;
161            }
162            shine_core::runtime::AppFileAction::ForceRestored => {
163                let backup = file
164                    .backup
165                    .as_ref()
166                    .expect("Core force-restored App backup report");
167                observer.reporter.emit(PresentationEvent::stdout(
168                    report::force_removed_with_restore_text(&file.destination, backup),
169                ));
170                removed += 1;
171                restored += 1;
172            }
173            shine_core::runtime::AppFileAction::Missing => {
174                observer.reporter.emit(PresentationEvent::stdout(
175                    report::uninstall_not_found_text(config, &file.destination),
176                ));
177                skipped += 1;
178            }
179            shine_core::runtime::AppFileAction::UserModified => {
180                observer
181                    .reporter
182                    .emit(PresentationEvent::stdout(report::user_modified_kept_text(
183                        config,
184                        &file.destination,
185                    )));
186                user_modified += 1;
187            }
188            shine_core::runtime::AppFileAction::PreviewRemove => {
189                observer
190                    .reporter
191                    .emit(PresentationEvent::stdout(report::uninstall_dry_run_text(
192                        config,
193                        &file.destination,
194                    )));
195                skipped += 1;
196            }
197            shine_core::runtime::AppFileAction::Failed => {
198                let error = anyhow::anyhow!(
199                    file.error
200                        .clone()
201                        .unwrap_or_else(|| "App uninstall failed".to_string())
202                );
203                observer
204                    .reporter
205                    .emit(PresentationEvent::stderr(report::uninstall_error_text(
206                        config,
207                        &file.destination,
208                        &error,
209                    )));
210            }
211            _ => {}
212        }
213    }
214    if purge && !config.is_external_presets {
215        observer
216            .reporter
217            .emit(PresentationEvent::stdout(match category {
218                Some(category) => report::purge_category_text(category),
219                None => report::purge_all_text(),
220            }));
221    }
222    let summary_parts = report::uninstall_summary_parts(removed, restored, user_modified, skipped);
223    observer.reporter.emit(PresentationEvent::BlankLine);
224    observer
225        .reporter
226        .emit(PresentationEvent::stdout(report::done_summary_text(
227            &summary_parts,
228        )));
229    Ok(core_report.lifecycle)
230}
231
232struct UninstallObserver<'a> {
233    reporter: &'a mut dyn LifecycleReporter,
234}
235
236impl shine_core::runtime::RuntimeObserver for UninstallObserver<'_> {
237    fn emit(&mut self, event: shine_core::runtime::RuntimeEvent) {
238        if let shine_core::runtime::RuntimeEvent::Warning {
239            code,
240            target,
241            detail,
242        } = event
243        {
244            let category = target
245                .as_deref()
246                .and_then(|value| value.strip_prefix("app/"))
247                .unwrap_or("app");
248            if code == "app_artifact_permission_required" {
249                self.reporter.emit(PresentationEvent::stdout(format!("  {} {category}: artifact teardown skipped (run `shine trust grant app/{category}` after review; manual: shine app artifact remove {category})", report::symbol("!"))));
250            } else {
251                self.reporter.emit(PresentationEvent::stderr(format!(
252                    "  {} {category}: artifact teardown failed: {detail}",
253                    report::symbol("!")
254                )));
255            }
256        }
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    #![allow(clippy::await_holding_lock)]
263    #[cfg(unix)]
264    use super::super::install::handle_install;
265    use super::*;
266    use crate::install_core::manifest::AppInstallStrategy;
267    #[cfg(unix)]
268    use crate::test_support::env_lock;
269    use tokio::fs;
270
271    async fn make_temp_dir() -> std::path::PathBuf {
272        crate::test_support::make_temp_dir("shine-apps").await
273    }
274
275    #[cfg(unix)]
276    async fn write_external_sample_app(dir: &std::path::Path, body: &[u8]) {
277        let cat_dir = dir.join("presets/app/sample");
278        fs::create_dir_all(&cat_dir).await.unwrap();
279        let manifest = "description = \"Sample app\"\ndest = \"~/.config/sample\"\n\n[permissions]\nschema_version = 1\n\n[[files]]\nsource = \"daemon.jsonc\"\ntarget = \"daemon.json\"\ntransforms = [\"template\", \"jsonc-to-json\"]\n".to_string();
280        fs::write(cat_dir.join("shine.toml"), manifest)
281            .await
282            .unwrap();
283        fs::write(cat_dir.join("daemon.jsonc"), body).await.unwrap();
284    }
285
286    #[cfg(unix)]
287    #[tokio::test(flavor = "current_thread")]
288    async fn uninstall_dry_run_leaves_everything_intact() {
289        let _admin_guard = crate::test_support::admin_category_test_lock().await;
290        let _guard = env_lock();
291        let dir = make_temp_dir().await;
292        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
293        unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
294
295        let config = Config::new_for_test(&dir);
296        fs::create_dir_all(config.presets_dir()).await.unwrap();
297        fs::create_dir_all(config.shine_dir()).await.unwrap();
298
299        handle_install(&config, Some("git"), false, false)
300            .await
301            .unwrap();
302
303        let manifest_before = AppManifest::load(&shine_core::runtime::RealHost, config.shine_dir())
304            .await
305            .unwrap();
306        let count_before = manifest_before.entries.len();
307
308        let result = handle_uninstall_with_result(&config, Some("git"), false, false, true)
309            .await
310            .unwrap();
311        assert!(result.dry_run);
312        assert_eq!(
313            result
314                .outcomes
315                .iter()
316                .filter(|outcome| {
317                    outcome.effects
318                        == vec![
319                            LifecycleEffect::ResourceRemovePreviewed,
320                            LifecycleEffect::ReceiptRemovePreviewed,
321                        ]
322                })
323                .count(),
324            count_before
325        );
326        assert!(
327            result
328                .outcomes
329                .iter()
330                .filter(|outcome| {
331                    outcome.resource.as_deref() != Some("artifact:teardown")
332                        && outcome.resource.as_deref() != Some("preset-cache")
333                })
334                .all(|outcome| {
335                    outcome.effects
336                        == vec![
337                            LifecycleEffect::ResourceRemovePreviewed,
338                            LifecycleEffect::ReceiptRemovePreviewed,
339                        ]
340                })
341        );
342
343        let manifest_after = AppManifest::load(&shine_core::runtime::RealHost, config.shine_dir())
344            .await
345            .unwrap();
346        assert_eq!(
347            manifest_after.entries.len(),
348            count_before,
349            "dry-run must not modify manifest"
350        );
351        for entry in &manifest_before.entries {
352            assert!(
353                entry.destination.exists(),
354                "dry-run must not remove installed files"
355            );
356        }
357
358        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
359        unsafe { std::env::remove_var("HOME") };
360        fs::remove_dir_all(&dir).await.unwrap();
361    }
362
363    #[cfg(unix)]
364    #[tokio::test(flavor = "current_thread")]
365    async fn uninstall_force_removes_user_modified_file_and_manifest_entry() {
366        let _guard = env_lock();
367        let dir = make_temp_dir().await;
368        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
369        unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
370
371        write_external_sample_app(&dir, b"{\n  \"debug\": true\n}\n").await;
372        let mut config = Config::new_for_test(&dir);
373        config.is_external_presets = true;
374        fs::create_dir_all(config.shine_dir()).await.unwrap();
375
376        handle_install(&config, Some("sample"), false, false)
377            .await
378            .unwrap();
379        let dest = dir.join(".config/sample/daemon.json");
380        fs::write(&dest, b"{\"debug\": false}\n").await.unwrap();
381
382        let result = handle_uninstall_with_result(&config, Some("sample"), true, false, false)
383            .await
384            .unwrap();
385        assert_eq!(result.summary().changed, 1);
386        assert!(
387            result.outcomes[0]
388                .effects
389                .contains(&LifecycleEffect::UserModificationOverridden)
390        );
391
392        let manifest_after = AppManifest::load(&shine_core::runtime::RealHost, config.shine_dir())
393            .await
394            .unwrap();
395        assert!(
396            manifest_after.entries.is_empty(),
397            "force uninstall should remove manifest entry"
398        );
399        assert!(
400            !dest.exists(),
401            "force uninstall should remove modified file"
402        );
403
404        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
405        unsafe { std::env::remove_var("HOME") };
406        fs::remove_dir_all(&dir).await.unwrap();
407    }
408
409    #[cfg(unix)]
410    #[tokio::test(flavor = "current_thread")]
411    async fn uninstall_specific_category_only_removes_that_category() {
412        let _admin_guard = crate::test_support::admin_category_test_lock().await;
413        let _guard = env_lock();
414        let dir = make_temp_dir().await;
415        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
416        unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
417
418        let config = Config::new_for_test(&dir);
419        fs::create_dir_all(config.presets_dir()).await.unwrap();
420        fs::create_dir_all(config.shine_dir()).await.unwrap();
421
422        // Install two categories so targeted removal can prove isolation.
423        handle_install(&config, Some("git"), false, false)
424            .await
425            .unwrap();
426        handle_install(&config, Some("starship"), false, false)
427            .await
428            .unwrap();
429        let manifest_all = AppManifest::load(&shine_core::runtime::RealHost, config.shine_dir())
430            .await
431            .unwrap();
432        let total = manifest_all.entries.len();
433        assert!(total > 0, "need at least one installed entry");
434
435        // Find a category that was installed
436        let first_category = manifest_all
437            .entries
438            .iter()
439            .find_map(|e| {
440                e.source
441                    .strip_prefix("app/")
442                    .and_then(|s| s.split('/').next())
443                    .map(|s| s.to_string())
444            })
445            .expect("no category found in manifest");
446
447        let category_count = manifest_all
448            .entries
449            .iter()
450            .filter(|e| e.source.starts_with(&format!("app/{first_category}/")))
451            .count();
452
453        // Uninstall only that category
454        let result =
455            handle_uninstall_with_result(&config, Some(&first_category), false, false, false)
456                .await
457                .unwrap();
458        assert!(
459            result
460                .outcomes
461                .iter()
462                .all(|outcome| outcome.target == format!("app/{first_category}"))
463        );
464
465        let manifest_after = AppManifest::load(&shine_core::runtime::RealHost, config.shine_dir())
466            .await
467            .unwrap();
468        assert_eq!(
469            manifest_after.entries.len(),
470            total - category_count,
471            "only entries for '{first_category}' should be removed"
472        );
473        // No remaining entry belongs to the uninstalled category
474        let prefix = format!("app/{first_category}/");
475        assert!(
476            manifest_after
477                .entries
478                .iter()
479                .all(|e| !e.source.starts_with(&prefix)),
480            "uninstalled category must not appear in manifest"
481        );
482
483        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
484        unsafe { std::env::remove_var("HOME") };
485        fs::remove_dir_all(&dir).await.unwrap();
486    }
487
488    #[tokio::test]
489    async fn structured_uninstall_preserves_user_modified_resource_and_receipt() {
490        let dir = make_temp_dir().await;
491        let mut config = Config::new_for_test(&dir);
492        config.is_external_presets = true;
493        fs::create_dir_all(config.shine_dir()).await.unwrap();
494        let destination = dir.join("destination/config.toml");
495        fs::create_dir_all(destination.parent().unwrap())
496            .await
497            .unwrap();
498        fs::write(&destination, b"user change\n").await.unwrap();
499        let manifest = AppManifest {
500            entries: vec![AppEntry {
501                source: "app/sample/config.toml".to_string(),
502                destination: destination.clone(),
503                backup: None,
504                content_hash: crate::install_core::hash_content(b"installed\n"),
505                install_strategy: AppInstallStrategy::Copy,
506                uses_env: false,
507                requires_admin: false,
508            }],
509            ..AppManifest::default()
510        };
511        manifest
512            .save(&shine_core::runtime::RealHost, config.shine_dir())
513            .await
514            .unwrap();
515
516        let result = handle_uninstall_with_result(&config, None, false, false, false)
517            .await
518            .unwrap();
519
520        assert_eq!(result.summary().preserved, 1);
521        assert_eq!(
522            result.outcomes[0].effects,
523            vec![LifecycleEffect::UserResourcePreserved]
524        );
525        assert_eq!(fs::read(&destination).await.unwrap(), b"user change\n");
526        assert_eq!(
527            AppManifest::load(&shine_core::runtime::RealHost, config.shine_dir())
528                .await
529                .unwrap()
530                .entries
531                .len(),
532            1
533        );
534        fs::remove_dir_all(&dir).await.unwrap();
535    }
536
537    #[tokio::test]
538    async fn structured_uninstall_reports_stale_receipt_cleanup_as_change() {
539        let dir = make_temp_dir().await;
540        let mut config = Config::new_for_test(&dir);
541        config.is_external_presets = true;
542        fs::create_dir_all(config.shine_dir()).await.unwrap();
543        let manifest = AppManifest {
544            entries: vec![AppEntry {
545                source: "app/sample/missing.toml".to_string(),
546                destination: dir.join("destination/missing.toml"),
547                backup: None,
548                content_hash: crate::install_core::hash_content(b"installed\n"),
549                install_strategy: AppInstallStrategy::Copy,
550                uses_env: false,
551                requires_admin: false,
552            }],
553            ..AppManifest::default()
554        };
555        manifest
556            .save(&shine_core::runtime::RealHost, config.shine_dir())
557            .await
558            .unwrap();
559
560        let result = handle_uninstall_with_result(&config, None, false, false, false)
561            .await
562            .unwrap();
563
564        assert_eq!(result.summary().changed, 1);
565        assert_eq!(
566            result.outcomes[0].effects,
567            vec![LifecycleEffect::ReceiptRemoved]
568        );
569        assert!(
570            AppManifest::load(&shine_core::runtime::RealHost, config.shine_dir())
571                .await
572                .unwrap()
573                .entries
574                .is_empty()
575        );
576        fs::remove_dir_all(&dir).await.unwrap();
577    }
578
579    #[tokio::test]
580    async fn embedded_category_and_global_purge_record_cache_and_manifest_effects() {
581        let dir = make_temp_dir().await;
582        let config = Config::new_for_test(&dir);
583        let category_cache = config.presets_dir().join("app/git");
584        fs::create_dir_all(&category_cache).await.unwrap();
585        fs::write(category_cache.join("orphan"), b"cache")
586            .await
587            .unwrap();
588        let manifest = AppManifest {
589            entries: vec![AppEntry {
590                source: "app/git/gitconfig".to_string(),
591                destination: dir.join("missing-gitconfig"),
592                backup: None,
593                content_hash: 1,
594                install_strategy: AppInstallStrategy::Copy,
595                uses_env: false,
596                requires_admin: false,
597            }],
598            ..AppManifest::default()
599        };
600        manifest
601            .save(&shine_core::runtime::RealHost, config.shine_dir())
602            .await
603            .unwrap();
604
605        let category = handle_uninstall_with_result(&config, Some("git"), false, true, false)
606            .await
607            .unwrap();
608        let category_purge = category
609            .outcomes
610            .iter()
611            .find(|outcome| outcome.resource.as_deref() == Some("purge"))
612            .unwrap();
613        assert_eq!(category_purge.target, "app/git");
614        assert!(
615            category_purge
616                .effects
617                .contains(&LifecycleEffect::CachePurged)
618        );
619
620        let global_cache = config.presets_dir().join("app/other");
621        fs::create_dir_all(&global_cache).await.unwrap();
622        fs::write(global_cache.join("orphan"), b"cache")
623            .await
624            .unwrap();
625        let global = handle_uninstall_with_result(&config, None, false, true, false)
626            .await
627            .unwrap();
628        let global_purge = global
629            .outcomes
630            .iter()
631            .find(|outcome| outcome.target == "app" && outcome.resource.as_deref() == Some("purge"))
632            .unwrap();
633        assert!(global_purge.effects.contains(&LifecycleEffect::CachePurged));
634        assert!(
635            global_purge
636                .effects
637                .contains(&LifecycleEffect::ReceiptRemoved)
638        );
639        assert!(!config.shine_dir().join("app-manifest.toml").exists());
640        fs::remove_dir_all(&dir).await.unwrap();
641    }
642
643    #[cfg(unix)]
644    #[tokio::test(flavor = "current_thread")]
645    async fn uninstall_unknown_category_returns_early() {
646        let _guard = env_lock();
647        let dir = make_temp_dir().await;
648        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
649        unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
650
651        let config = Config::new_for_test(&dir);
652        fs::create_dir_all(config.presets_dir()).await.unwrap();
653        fs::create_dir_all(config.shine_dir()).await.unwrap();
654
655        // Nothing installed — uninstalling a specific category should succeed silently
656        handle_uninstall(&config, Some("nonexistent"), false, false, false)
657            .await
658            .unwrap();
659
660        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
661        unsafe { std::env::remove_var("HOME") };
662        fs::remove_dir_all(&dir).await.unwrap();
663    }
664}