Skip to main content

cli/apps/
uninstall.rs

1use super::report::{
2    print_force_removed, print_force_removed_with_restore, print_removed,
3    print_removed_with_restore, print_uninstall_dry_run, print_uninstall_error,
4    print_uninstall_not_found, print_user_modified_kept,
5};
6use super::{metadata, resolve_install_destination, uninstall_app_entry};
7use crate::colors;
8use crate::config::Config;
9use crate::install_core::manifest::{AppEntry, AppManifest};
10use crate::output;
11use anyhow::{Context, Result};
12use file_ops::UninstallOutcome;
13use std::collections::{BTreeMap, BTreeSet};
14use std::path::PathBuf;
15
16use crate::install_core::file_ops;
17
18pub async fn handle_uninstall(
19    config: &Config,
20    category: Option<&str>,
21    force: bool,
22    purge: bool,
23    dry_run: bool,
24) -> Result<()> {
25    if dry_run {
26        println!("{}", colors::dim("[dry-run] No files will be modified."));
27    }
28
29    let mut manifest = AppManifest::load(config.shine_dir()).await?;
30
31    let entries: Vec<_> = if let Some(cat) = category {
32        let filtered = uninstall_entries_for_category(config, &manifest, cat).await?;
33        if filtered.is_empty() {
34            println!(
35                "{}",
36                colors::dim(&format!("No installed files found for category '{cat}'."))
37            );
38            return Ok(());
39        }
40        filtered
41    } else {
42        manifest.entries.clone()
43    };
44
45    // Run each involved category's artifact teardown (best-effort) *before*
46    // removing its files, so a teardown script sees the same on-disk state
47    // `build` saw. Loaded from metadata (still present until `remove_prefix`
48    // below); a metadata-load failure just skips teardown, never blocks removal.
49    let involved_categories: BTreeSet<String> = entries
50        .iter()
51        .filter_map(|entry| super::app_category_from_source(&entry.source))
52        .collect();
53    if !involved_categories.is_empty() {
54        let categories = metadata::load_active_categories(config, category)
55            .await
56            .unwrap_or_default();
57        for cat in &categories {
58            if involved_categories.contains(&cat.name) {
59                super::build::run_teardown_for_uninstall(config, cat, dry_run).await;
60            }
61        }
62    }
63
64    let mut removed = 0usize;
65    let mut restored = 0usize;
66    let mut user_modified = 0usize;
67    let mut skipped = 0usize;
68
69    for entry in &entries {
70        match uninstall_app_entry(entry, dry_run, force).await {
71            Ok(UninstallOutcome::Removed) => {
72                print_removed(config, &entry.destination);
73                manifest.remove_by_dest(&entry.destination);
74                removed += 1;
75            }
76            Ok(UninstallOutcome::RestoredBackup { backup }) => {
77                print_removed_with_restore(config, &entry.destination, &backup);
78                manifest.remove_by_dest(&entry.destination);
79                removed += 1;
80                restored += 1;
81            }
82            Ok(UninstallOutcome::ForceRemoved) => {
83                print_force_removed(&entry.destination);
84                manifest.remove_by_dest(&entry.destination);
85                removed += 1;
86            }
87            Ok(UninstallOutcome::ForceRestoredBackup { backup }) => {
88                print_force_removed_with_restore(&entry.destination, &backup);
89                manifest.remove_by_dest(&entry.destination);
90                removed += 1;
91                restored += 1;
92            }
93            Ok(UninstallOutcome::NotFound) => {
94                print_uninstall_not_found(config, &entry.destination);
95                manifest.remove_by_dest(&entry.destination);
96                skipped += 1;
97            }
98            Ok(UninstallOutcome::UserModified) => {
99                print_user_modified_kept(config, &entry.destination);
100                user_modified += 1;
101            }
102            Ok(UninstallOutcome::DryRun) => {
103                print_uninstall_dry_run(config, &entry.destination);
104                skipped += 1;
105            }
106            Err(e) => {
107                print_uninstall_error(config, &entry.destination, &e);
108            }
109        }
110    }
111
112    if !dry_run {
113        manifest.save(config.shine_dir()).await?;
114    }
115
116    // Only clean up extracted preset files when using embedded presets.
117    // For external presets the presets_dir is user-managed and must not be touched.
118    if !config.is_external_presets {
119        let remove_prefix_key = match category {
120            Some(cat) => format!("app/{cat}"),
121            None => "app".to_string(),
122        };
123        let _remove_report =
124            crate::presets::remove_prefix(&remove_prefix_key, config.presets_dir(), dry_run)
125                .await?;
126
127        if purge && !dry_run {
128            if let Some(cat) = category {
129                let cat_dir = config.presets_dir().join("app").join(cat);
130                if cat_dir.exists() {
131                    tokio::fs::remove_dir_all(&cat_dir).await.with_context(|| {
132                        format!(
133                            "removing app category presets directory: {}",
134                            cat_dir.display()
135                        )
136                    })?;
137                }
138                println!(
139                    "  {}  {}",
140                    colors::symbol("✓"),
141                    colors::dim(&format!("app/{cat} presets directory purged")),
142                );
143            } else {
144                let app_dir = config.presets_dir().join("app");
145                if app_dir.exists() {
146                    tokio::fs::remove_dir_all(&app_dir).await.with_context(|| {
147                        format!("removing app presets directory: {}", app_dir.display())
148                    })?;
149                }
150                let manifest_path = config.shine_dir().join("app-manifest.toml");
151                if manifest_path.exists() {
152                    tokio::fs::remove_file(&manifest_path)
153                        .await
154                        .context("removing app manifest")?;
155                }
156                println!(
157                    "  {}  {}",
158                    colors::symbol("✓"),
159                    colors::dim("app presets directory and manifest purged"),
160                );
161            }
162        }
163    }
164
165    let mut summary_parts: Vec<String> = Vec::new();
166    if removed > 0 {
167        let restore_note = if restored > 0 {
168            format!(", {restored} backups restored")
169        } else {
170            String::new()
171        };
172        summary_parts.push(colors::green(&format!("{removed} removed{restore_note}")));
173    }
174    if user_modified > 0 {
175        summary_parts.push(colors::yellow(&format!(
176            "{user_modified} user-modified (kept)"
177        )));
178    }
179    if skipped > 0 {
180        summary_parts.push(colors::dim(&format!("{skipped} skipped")));
181    }
182    output::footer("Done", &summary_parts);
183
184    Ok(())
185}
186
187async fn uninstall_entries_for_category(
188    config: &Config,
189    manifest: &AppManifest,
190    category: &str,
191) -> Result<Vec<AppEntry>> {
192    let prefix = format!("app/{category}/");
193    let mut entries_by_dest: BTreeMap<PathBuf, AppEntry> = manifest
194        .entries
195        .iter()
196        .filter(|entry| entry.source.starts_with(&prefix))
197        .map(|entry| (entry.destination.clone(), entry.clone()))
198        .collect();
199
200    let categories = metadata::load_active_categories(config, Some(category)).await?;
201
202    for cat in categories.iter().filter(|cat| cat.name == category) {
203        append_manifest_entries_for_category_destinations(
204            config,
205            manifest,
206            cat,
207            &mut entries_by_dest,
208        );
209    }
210
211    Ok(entries_by_dest.into_values().collect())
212}
213
214fn append_manifest_entries_for_category_destinations(
215    config: &Config,
216    manifest: &AppManifest,
217    category: &metadata::AppCategory,
218    entries_by_dest: &mut BTreeMap<PathBuf, AppEntry>,
219) {
220    for file in &category.files {
221        let Ok(destination) = resolve_install_destination(category, file, config) else {
222            continue;
223        };
224        if let Some(entry) = manifest.find_by_dest(&destination) {
225            entries_by_dest
226                .entry(entry.destination.clone())
227                .or_insert_with(|| entry.clone());
228        }
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    #![allow(clippy::await_holding_lock)]
235    use super::super::install::handle_install;
236    use super::*;
237    use crate::apps::metadata::{AppCategory, AppDestinationRoot, AppFile, AppListMode};
238    use crate::install_core::manifest::AppInstallStrategy;
239    #[cfg(unix)]
240    use crate::test_support::env_lock;
241    use tokio::fs;
242
243    async fn make_temp_dir() -> std::path::PathBuf {
244        crate::test_support::make_temp_dir("shine-apps").await
245    }
246
247    #[cfg(unix)]
248    async fn write_external_sample_app(dir: &std::path::Path, body: &[u8]) {
249        let cat_dir = dir.join("presets/app/sample");
250        fs::create_dir_all(&cat_dir).await.unwrap();
251        let manifest = "description = \"Sample app\"\ndest = \"~/.config/sample\"\n\n[[files]]\nsource = \"daemon.jsonc\"\ntarget = \"daemon.json\"\ntransforms = [\"template\", \"jsonc-to-json\"]\n".to_string();
252        fs::write(cat_dir.join("shine.toml"), manifest)
253            .await
254            .unwrap();
255        fs::write(cat_dir.join("daemon.jsonc"), body).await.unwrap();
256    }
257
258    #[cfg(unix)]
259    #[tokio::test(flavor = "current_thread")]
260    async fn uninstall_dry_run_leaves_everything_intact() {
261        let _admin_guard = crate::test_support::admin_category_test_lock().await;
262        let _guard = env_lock();
263        let dir = make_temp_dir().await;
264        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
265        unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
266
267        let config = Config::new_for_test(&dir);
268        fs::create_dir_all(config.presets_dir()).await.unwrap();
269        fs::create_dir_all(config.shine_dir()).await.unwrap();
270
271        handle_install(&config, None, false, false).await.unwrap();
272
273        let manifest_before = AppManifest::load(config.shine_dir()).await.unwrap();
274        let count_before = manifest_before.entries.len();
275
276        handle_uninstall(&config, None, false, false, true)
277            .await
278            .unwrap();
279
280        let manifest_after = AppManifest::load(config.shine_dir()).await.unwrap();
281        assert_eq!(
282            manifest_after.entries.len(),
283            count_before,
284            "dry-run must not modify manifest"
285        );
286        for entry in &manifest_before.entries {
287            assert!(
288                entry.destination.exists(),
289                "dry-run must not remove installed files"
290            );
291        }
292
293        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
294        unsafe { std::env::remove_var("HOME") };
295        fs::remove_dir_all(&dir).await.unwrap();
296    }
297
298    #[tokio::test]
299    async fn uninstall_category_selection_matches_current_destination() {
300        let dir = make_temp_dir().await;
301        let config = Config::new_for_test(&dir);
302        let destination_root = dir.join(".docker");
303        let destination = destination_root.join("daemon.json");
304        let category = AppCategory {
305            name: "docker-engine".to_string(),
306            description: None,
307            destination_root: Some(destination_root.display().to_string()),
308            files: vec![AppFile {
309                source_rel: PathBuf::from("daemon.jsonc"),
310                target_rel: PathBuf::from("daemon.json"),
311                destination_root: Some(AppDestinationRoot::Path(
312                    destination_root.display().to_string(),
313                )),
314                description: None,
315                display_name: None,
316                legacy_dest_annotation: None,
317                transforms: vec![],
318                install_strategy: AppInstallStrategy::Copy,
319                requires_admin: false,
320                restart_hint: None,
321                generator: None,
322            }],
323            list_mode: AppListMode::Files,
324            post_upgrade: Vec::new(),
325            post_install: Vec::new(),
326            uses_metadata: true,
327            has_explicit_files: true,
328            artifact: None,
329        };
330        let manifest = AppManifest {
331            entries: vec![AppEntry {
332                source: "app/docker/daemon.jsonc".to_string(),
333                destination: destination.clone(),
334                backup: None,
335                content_hash: 42,
336                install_strategy: AppInstallStrategy::Copy,
337                uses_env: false,
338                requires_admin: false,
339            }],
340        };
341        let mut entries_by_dest = BTreeMap::new();
342
343        append_manifest_entries_for_category_destinations(
344            &config,
345            &manifest,
346            &category,
347            &mut entries_by_dest,
348        );
349
350        assert!(
351            entries_by_dest.contains_key(&destination),
352            "category uninstall should find legacy manifest entries by current destination"
353        );
354
355        fs::remove_dir_all(&dir).await.unwrap();
356    }
357
358    #[cfg(unix)]
359    #[tokio::test(flavor = "current_thread")]
360    async fn uninstall_force_removes_user_modified_file_and_manifest_entry() {
361        let _guard = env_lock();
362        let dir = make_temp_dir().await;
363        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
364        unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
365
366        write_external_sample_app(&dir, b"{\n  \"debug\": true\n}\n").await;
367        let mut config = Config::new_for_test(&dir);
368        config.is_external_presets = true;
369        fs::create_dir_all(config.shine_dir()).await.unwrap();
370
371        handle_install(&config, Some("sample"), false, false)
372            .await
373            .unwrap();
374        let dest = dir.join(".config/sample/daemon.json");
375        fs::write(&dest, b"{\"debug\": false}\n").await.unwrap();
376
377        handle_uninstall(&config, Some("sample"), true, false, false)
378            .await
379            .unwrap();
380
381        let manifest_after = AppManifest::load(config.shine_dir()).await.unwrap();
382        assert!(
383            manifest_after.entries.is_empty(),
384            "force uninstall should remove manifest entry"
385        );
386        assert!(
387            !dest.exists(),
388            "force uninstall should remove modified file"
389        );
390
391        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
392        unsafe { std::env::remove_var("HOME") };
393        fs::remove_dir_all(&dir).await.unwrap();
394    }
395
396    #[cfg(unix)]
397    #[tokio::test(flavor = "current_thread")]
398    async fn uninstall_specific_category_only_removes_that_category() {
399        let _admin_guard = crate::test_support::admin_category_test_lock().await;
400        let _guard = env_lock();
401        let dir = make_temp_dir().await;
402        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
403        unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
404
405        let config = Config::new_for_test(&dir);
406        fs::create_dir_all(config.presets_dir()).await.unwrap();
407        fs::create_dir_all(config.shine_dir()).await.unwrap();
408
409        // Install all categories
410        handle_install(&config, None, false, false).await.unwrap();
411        let manifest_all = AppManifest::load(config.shine_dir()).await.unwrap();
412        let total = manifest_all.entries.len();
413        assert!(total > 0, "need at least one installed entry");
414
415        // Find a category that was installed
416        let first_category = manifest_all
417            .entries
418            .iter()
419            .find_map(|e| {
420                e.source
421                    .strip_prefix("app/")
422                    .and_then(|s| s.split('/').next())
423                    .map(|s| s.to_string())
424            })
425            .expect("no category found in manifest");
426
427        let category_count = manifest_all
428            .entries
429            .iter()
430            .filter(|e| e.source.starts_with(&format!("app/{first_category}/")))
431            .count();
432
433        // Uninstall only that category
434        handle_uninstall(&config, Some(&first_category), false, false, false)
435            .await
436            .unwrap();
437
438        let manifest_after = AppManifest::load(config.shine_dir()).await.unwrap();
439        assert_eq!(
440            manifest_after.entries.len(),
441            total - category_count,
442            "only entries for '{first_category}' should be removed"
443        );
444        // No remaining entry belongs to the uninstalled category
445        let prefix = format!("app/{first_category}/");
446        assert!(
447            manifest_after
448                .entries
449                .iter()
450                .all(|e| !e.source.starts_with(&prefix)),
451            "uninstalled category must not appear in manifest"
452        );
453
454        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
455        unsafe { std::env::remove_var("HOME") };
456        fs::remove_dir_all(&dir).await.unwrap();
457    }
458
459    #[cfg(unix)]
460    #[tokio::test(flavor = "current_thread")]
461    async fn uninstall_unknown_category_returns_early() {
462        let _guard = env_lock();
463        let dir = make_temp_dir().await;
464        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
465        unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
466
467        let config = Config::new_for_test(&dir);
468        fs::create_dir_all(config.presets_dir()).await.unwrap();
469        fs::create_dir_all(config.shine_dir()).await.unwrap();
470
471        // Nothing installed — uninstalling a specific category should succeed silently
472        handle_uninstall(&config, Some("nonexistent"), false, false, false)
473            .await
474            .unwrap();
475
476        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
477        unsafe { std::env::remove_var("HOME") };
478        fs::remove_dir_all(&dir).await.unwrap();
479    }
480}