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 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 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 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 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 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 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 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 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 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 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 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 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 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 handle_uninstall(&config, Some("nonexistent"), false, false, false)
470 .await
471 .unwrap();
472
473 unsafe { std::env::remove_var("HOME") };
475 fs::remove_dir_all(&dir).await.unwrap();
476 }
477}