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    #[cfg(unix)]
236    use super::super::install::handle_install;
237    use super::*;
238    use crate::apps::metadata::{AppCategory, AppDestinationRoot, AppFile, AppListMode};
239    use crate::install_core::manifest::AppInstallStrategy;
240    #[cfg(unix)]
241    use crate::test_support::env_lock;
242    use tokio::fs;
243
244    async fn make_temp_dir() -> std::path::PathBuf {
245        crate::test_support::make_temp_dir("shine-apps").await
246    }
247
248    #[cfg(unix)]
249    async fn write_external_sample_app(dir: &std::path::Path, body: &[u8]) {
250        let cat_dir = dir.join("presets/app/sample");
251        fs::create_dir_all(&cat_dir).await.unwrap();
252        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();
253        fs::write(cat_dir.join("shine.toml"), manifest)
254            .await
255            .unwrap();
256        fs::write(cat_dir.join("daemon.jsonc"), body).await.unwrap();
257    }
258
259    #[cfg(unix)]
260    #[tokio::test(flavor = "current_thread")]
261    async fn uninstall_dry_run_leaves_everything_intact() {
262        let _admin_guard = crate::test_support::admin_category_test_lock().await;
263        let _guard = env_lock();
264        let dir = make_temp_dir().await;
265        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
266        unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
267
268        let config = Config::new_for_test(&dir);
269        fs::create_dir_all(config.presets_dir()).await.unwrap();
270        fs::create_dir_all(config.shine_dir()).await.unwrap();
271
272        handle_install(&config, None, false, false).await.unwrap();
273
274        let manifest_before = AppManifest::load(config.shine_dir()).await.unwrap();
275        let count_before = manifest_before.entries.len();
276
277        handle_uninstall(&config, None, false, false, true)
278            .await
279            .unwrap();
280
281        let manifest_after = AppManifest::load(config.shine_dir()).await.unwrap();
282        assert_eq!(
283            manifest_after.entries.len(),
284            count_before,
285            "dry-run must not modify manifest"
286        );
287        for entry in &manifest_before.entries {
288            assert!(
289                entry.destination.exists(),
290                "dry-run must not remove installed files"
291            );
292        }
293
294        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
295        unsafe { std::env::remove_var("HOME") };
296        fs::remove_dir_all(&dir).await.unwrap();
297    }
298
299    #[tokio::test]
300    async fn uninstall_category_selection_matches_current_destination() {
301        let dir = make_temp_dir().await;
302        let config = Config::new_for_test(&dir);
303        let destination_root = dir.join(".docker");
304        let destination = destination_root.join("daemon.json");
305        let category = AppCategory {
306            name: "docker-engine".to_string(),
307            description: None,
308            destination_root: Some(destination_root.display().to_string()),
309            files: vec![AppFile {
310                source_rel: PathBuf::from("daemon.jsonc"),
311                target_rel: PathBuf::from("daemon.json"),
312                destination_root: Some(AppDestinationRoot::Path(
313                    destination_root.display().to_string(),
314                )),
315                description: None,
316                display_name: None,
317                legacy_dest_annotation: None,
318                transforms: vec![],
319                install_strategy: AppInstallStrategy::Copy,
320                requires_admin: false,
321                restart_hint: None,
322                generator: None,
323            }],
324            list_mode: AppListMode::Files,
325            post_upgrade: Vec::new(),
326            post_install: Vec::new(),
327            uses_metadata: true,
328            has_explicit_files: true,
329            artifact: None,
330        };
331        let manifest = AppManifest {
332            entries: vec![AppEntry {
333                source: "app/docker/daemon.jsonc".to_string(),
334                destination: destination.clone(),
335                backup: None,
336                content_hash: 42,
337                install_strategy: AppInstallStrategy::Copy,
338                uses_env: false,
339                requires_admin: false,
340            }],
341        };
342        let mut entries_by_dest = BTreeMap::new();
343
344        append_manifest_entries_for_category_destinations(
345            &config,
346            &manifest,
347            &category,
348            &mut entries_by_dest,
349        );
350
351        assert!(
352            entries_by_dest.contains_key(&destination),
353            "category uninstall should find legacy manifest entries by current destination"
354        );
355
356        fs::remove_dir_all(&dir).await.unwrap();
357    }
358
359    #[cfg(unix)]
360    #[tokio::test(flavor = "current_thread")]
361    async fn uninstall_force_removes_user_modified_file_and_manifest_entry() {
362        let _guard = env_lock();
363        let dir = make_temp_dir().await;
364        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
365        unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
366
367        write_external_sample_app(&dir, b"{\n  \"debug\": true\n}\n").await;
368        let mut config = Config::new_for_test(&dir);
369        config.is_external_presets = true;
370        fs::create_dir_all(config.shine_dir()).await.unwrap();
371
372        handle_install(&config, Some("sample"), false, false)
373            .await
374            .unwrap();
375        let dest = dir.join(".config/sample/daemon.json");
376        fs::write(&dest, b"{\"debug\": false}\n").await.unwrap();
377
378        handle_uninstall(&config, Some("sample"), true, false, false)
379            .await
380            .unwrap();
381
382        let manifest_after = AppManifest::load(config.shine_dir()).await.unwrap();
383        assert!(
384            manifest_after.entries.is_empty(),
385            "force uninstall should remove manifest entry"
386        );
387        assert!(
388            !dest.exists(),
389            "force uninstall should remove modified file"
390        );
391
392        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
393        unsafe { std::env::remove_var("HOME") };
394        fs::remove_dir_all(&dir).await.unwrap();
395    }
396
397    #[cfg(unix)]
398    #[tokio::test(flavor = "current_thread")]
399    async fn uninstall_specific_category_only_removes_that_category() {
400        let _admin_guard = crate::test_support::admin_category_test_lock().await;
401        let _guard = env_lock();
402        let dir = make_temp_dir().await;
403        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
404        unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
405
406        let config = Config::new_for_test(&dir);
407        fs::create_dir_all(config.presets_dir()).await.unwrap();
408        fs::create_dir_all(config.shine_dir()).await.unwrap();
409
410        // Install all categories
411        handle_install(&config, None, false, false).await.unwrap();
412        let manifest_all = AppManifest::load(config.shine_dir()).await.unwrap();
413        let total = manifest_all.entries.len();
414        assert!(total > 0, "need at least one installed entry");
415
416        // Find a category that was installed
417        let first_category = manifest_all
418            .entries
419            .iter()
420            .find_map(|e| {
421                e.source
422                    .strip_prefix("app/")
423                    .and_then(|s| s.split('/').next())
424                    .map(|s| s.to_string())
425            })
426            .expect("no category found in manifest");
427
428        let category_count = manifest_all
429            .entries
430            .iter()
431            .filter(|e| e.source.starts_with(&format!("app/{first_category}/")))
432            .count();
433
434        // Uninstall only that category
435        handle_uninstall(&config, Some(&first_category), false, false, false)
436            .await
437            .unwrap();
438
439        let manifest_after = AppManifest::load(config.shine_dir()).await.unwrap();
440        assert_eq!(
441            manifest_after.entries.len(),
442            total - category_count,
443            "only entries for '{first_category}' should be removed"
444        );
445        // No remaining entry belongs to the uninstalled category
446        let prefix = format!("app/{first_category}/");
447        assert!(
448            manifest_after
449                .entries
450                .iter()
451                .all(|e| !e.source.starts_with(&prefix)),
452            "uninstalled category must not appear in manifest"
453        );
454
455        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
456        unsafe { std::env::remove_var("HOME") };
457        fs::remove_dir_all(&dir).await.unwrap();
458    }
459
460    #[cfg(unix)]
461    #[tokio::test(flavor = "current_thread")]
462    async fn uninstall_unknown_category_returns_early() {
463        let _guard = env_lock();
464        let dir = make_temp_dir().await;
465        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
466        unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
467
468        let config = Config::new_for_test(&dir);
469        fs::create_dir_all(config.presets_dir()).await.unwrap();
470        fs::create_dir_all(config.shine_dir()).await.unwrap();
471
472        // Nothing installed — uninstalling a specific category should succeed silently
473        handle_uninstall(&config, Some("nonexistent"), false, false, false)
474            .await
475            .unwrap();
476
477        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
478        unsafe { std::env::remove_var("HOME") };
479        fs::remove_dir_all(&dir).await.unwrap();
480    }
481}