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 #[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 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 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 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 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 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 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 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 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 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 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 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 handle_uninstall(&config, Some("nonexistent"), false, false, false)
474 .await
475 .unwrap();
476
477 unsafe { std::env::remove_var("HOME") };
479 fs::remove_dir_all(&dir).await.unwrap();
480 }
481}