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