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, 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                description: None,
312                display_name: None,
313                legacy_dest_annotation: None,
314                transforms: vec![],
315                install_strategy: AppInstallStrategy::Copy,
316                requires_admin: false,
317                restart_hint: None,
318                generator: None,
319            }],
320            list_mode: AppListMode::Files,
321            post_upgrade: Vec::new(),
322            post_install: Vec::new(),
323            uses_metadata: true,
324            has_explicit_files: true,
325            artifact: None,
326        };
327        let manifest = AppManifest {
328            entries: vec![AppEntry {
329                source: "app/docker/daemon.jsonc".to_string(),
330                destination: destination.clone(),
331                backup: None,
332                content_hash: 42,
333                install_strategy: AppInstallStrategy::Copy,
334                uses_env: false,
335                requires_admin: false,
336            }],
337        };
338        let mut entries_by_dest = BTreeMap::new();
339
340        append_manifest_entries_for_category_destinations(
341            &config,
342            &manifest,
343            &category,
344            &mut entries_by_dest,
345        );
346
347        assert!(
348            entries_by_dest.contains_key(&destination),
349            "category uninstall should find legacy manifest entries by current destination"
350        );
351
352        fs::remove_dir_all(&dir).await.unwrap();
353    }
354
355    #[cfg(unix)]
356    #[tokio::test(flavor = "current_thread")]
357    async fn uninstall_force_removes_user_modified_file_and_manifest_entry() {
358        let _guard = env_lock();
359        let dir = make_temp_dir().await;
360        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
361        unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
362
363        write_external_sample_app(&dir, b"{\n  \"debug\": true\n}\n").await;
364        let mut config = Config::new_for_test(&dir);
365        config.is_external_presets = true;
366        fs::create_dir_all(config.shine_dir()).await.unwrap();
367
368        handle_install(&config, Some("sample"), false, false)
369            .await
370            .unwrap();
371        let dest = dir.join(".config/sample/daemon.json");
372        fs::write(&dest, b"{\"debug\": false}\n").await.unwrap();
373
374        handle_uninstall(&config, Some("sample"), true, false, false)
375            .await
376            .unwrap();
377
378        let manifest_after = AppManifest::load(config.shine_dir()).await.unwrap();
379        assert!(
380            manifest_after.entries.is_empty(),
381            "force uninstall should remove manifest entry"
382        );
383        assert!(
384            !dest.exists(),
385            "force uninstall should remove modified file"
386        );
387
388        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
389        unsafe { std::env::remove_var("HOME") };
390        fs::remove_dir_all(&dir).await.unwrap();
391    }
392
393    #[cfg(unix)]
394    #[tokio::test(flavor = "current_thread")]
395    async fn uninstall_specific_category_only_removes_that_category() {
396        let _admin_guard = crate::test_support::admin_category_test_lock().await;
397        let _guard = env_lock();
398        let dir = make_temp_dir().await;
399        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
400        unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
401
402        let config = Config::new_for_test(&dir);
403        fs::create_dir_all(config.presets_dir()).await.unwrap();
404        fs::create_dir_all(config.shine_dir()).await.unwrap();
405
406        // Install all categories
407        handle_install(&config, None, false, false).await.unwrap();
408        let manifest_all = AppManifest::load(config.shine_dir()).await.unwrap();
409        let total = manifest_all.entries.len();
410        assert!(total > 0, "need at least one installed entry");
411
412        // Find a category that was installed
413        let first_category = manifest_all
414            .entries
415            .iter()
416            .find_map(|e| {
417                e.source
418                    .strip_prefix("app/")
419                    .and_then(|s| s.split('/').next())
420                    .map(|s| s.to_string())
421            })
422            .expect("no category found in manifest");
423
424        let category_count = manifest_all
425            .entries
426            .iter()
427            .filter(|e| e.source.starts_with(&format!("app/{first_category}/")))
428            .count();
429
430        // Uninstall only that category
431        handle_uninstall(&config, Some(&first_category), false, false, false)
432            .await
433            .unwrap();
434
435        let manifest_after = AppManifest::load(config.shine_dir()).await.unwrap();
436        assert_eq!(
437            manifest_after.entries.len(),
438            total - category_count,
439            "only entries for '{first_category}' should be removed"
440        );
441        // No remaining entry belongs to the uninstalled category
442        let prefix = format!("app/{first_category}/");
443        assert!(
444            manifest_after
445                .entries
446                .iter()
447                .all(|e| !e.source.starts_with(&prefix)),
448            "uninstalled category must not appear in manifest"
449        );
450
451        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
452        unsafe { std::env::remove_var("HOME") };
453        fs::remove_dir_all(&dir).await.unwrap();
454    }
455
456    #[cfg(unix)]
457    #[tokio::test(flavor = "current_thread")]
458    async fn uninstall_unknown_category_returns_early() {
459        let _guard = env_lock();
460        let dir = make_temp_dir().await;
461        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
462        unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
463
464        let config = Config::new_for_test(&dir);
465        fs::create_dir_all(config.presets_dir()).await.unwrap();
466        fs::create_dir_all(config.shine_dir()).await.unwrap();
467
468        // Nothing installed — uninstalling a specific category should succeed silently
469        handle_uninstall(&config, Some("nonexistent"), false, false, false)
470            .await
471            .unwrap();
472
473        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
474        unsafe { std::env::remove_var("HOME") };
475        fs::remove_dir_all(&dir).await.unwrap();
476    }
477}