1use super::report;
2use crate::config::Config;
3#[cfg(test)]
4use crate::install_core::manifest::{AppEntry, AppManifest};
5use crate::presentation::{LifecycleReporter, PresentationEvent, TerminalRenderer};
6use anyhow::Result;
7#[cfg(test)]
8use shine_core::lifecycle::LifecycleEffect;
9use shine_core::lifecycle::LifecycleOperation;
10use shine_core::lifecycle::LifecycleResultV1;
11use shine_core::runtime::{AppPlanRequest, PlanningInputVersions};
12
13pub async fn handle_uninstall(
14 config: &Config,
15 category: Option<&str>,
16 force: bool,
17 purge: bool,
18 dry_run: bool,
19) -> Result<()> {
20 handle_uninstall_approved(config, category, force, purge, dry_run, true).await
21}
22
23pub async fn handle_uninstall_approved(
24 config: &Config,
25 category: Option<&str>,
26 force: bool,
27 purge: bool,
28 dry_run: bool,
29 yes: bool,
30) -> Result<()> {
31 let mut renderer = TerminalRenderer::stdio();
32 handle_uninstall_with_reporter(config, category, force, purge, dry_run, yes, &mut renderer)
33 .await
34 .map(|_| ())
35}
36
37#[cfg(test)]
38pub(crate) async fn handle_uninstall_with_result(
39 config: &Config,
40 category: Option<&str>,
41 force: bool,
42 purge: bool,
43 dry_run: bool,
44) -> Result<LifecycleResultV1> {
45 let mut renderer = TerminalRenderer::stdio();
46 handle_uninstall_with_reporter(config, category, force, purge, dry_run, true, &mut renderer)
47 .await
48}
49
50async fn handle_uninstall_with_reporter(
51 config: &Config,
52 category: Option<&str>,
53 force: bool,
54 purge: bool,
55 dry_run: bool,
56 yes: bool,
57 reporter: &mut dyn LifecycleReporter,
58) -> Result<LifecycleResultV1> {
59 if dry_run {
60 reporter.emit(PresentationEvent::stdout(report::dry_run_header_text()));
61 }
62 let plan_request = AppPlanRequest {
63 operation: LifecycleOperation::Uninstall,
64 target: category.map(str::to_string),
65 force,
66 purge,
67 prune_stale: false,
68 input_versions: PlanningInputVersions::default(),
69 };
70 let reviewed = if dry_run {
71 None
72 } else {
73 crate::lifecycle_plan::review_plans(
74 config,
75 [crate::lifecycle_plan::LifecyclePlanRequest::app(
76 plan_request,
77 config,
78 )],
79 yes,
80 )
81 .await?
82 .into_iter()
83 .next()
84 };
85 let runtime = if let Some(reviewed) = &reviewed {
86 crate::lifecycle_plan::prepare_runtime(config, reviewed).await?
87 } else {
88 crate::core_runtime::from_config(config).await?
89 };
90 let mut observer = UninstallObserver { reporter };
91 let mut interaction = crate::presentation::TerminalInteraction;
92 let core_report = if let Some(reviewed) = &reviewed {
93 runtime
94 .uninstall_apps_approved(
95 match &reviewed.request {
96 crate::lifecycle_plan::LifecyclePlanRequest::App(request) => request.clone(),
97 _ => unreachable!("reviewed App Plan"),
98 },
99 &reviewed.approval,
100 &mut observer,
101 &mut interaction,
102 )
103 .await?
104 } else {
105 runtime
106 .preview_uninstall_apps(
107 shine_core::runtime::AppUninstallLifecycleRequest {
108 target: category.map(str::to_string),
109 dry_run,
110 force,
111 purge,
112 },
113 &mut observer,
114 &mut interaction,
115 )
116 .await?
117 };
118 if let Some(category) = category.filter(|_| core_report.files.is_empty()) {
119 observer
120 .reporter
121 .emit(PresentationEvent::stdout(report::no_installed_files_text(
122 category,
123 )));
124 return Ok(core_report.lifecycle);
125 }
126 let mut removed = 0usize;
127 let mut restored = 0usize;
128 let mut user_modified = 0usize;
129 let mut skipped = 0usize;
130 for file in &core_report.files {
131 match file.action {
132 shine_core::runtime::AppFileAction::Removed => {
133 observer
134 .reporter
135 .emit(PresentationEvent::stdout(report::removed_text(
136 config,
137 &file.destination,
138 )));
139 removed += 1;
140 }
141 shine_core::runtime::AppFileAction::Restored => {
142 let backup = file
143 .backup
144 .as_ref()
145 .expect("Core restored App backup report");
146 observer.reporter.emit(PresentationEvent::stdout(
147 report::removed_with_restore_text(config, &file.destination, backup),
148 ));
149 removed += 1;
150 restored += 1;
151 }
152 shine_core::runtime::AppFileAction::ForceRemoved => {
153 observer
154 .reporter
155 .emit(PresentationEvent::stdout(report::force_removed_text(
156 &file.destination,
157 )));
158 removed += 1;
159 }
160 shine_core::runtime::AppFileAction::ForceRestored => {
161 let backup = file
162 .backup
163 .as_ref()
164 .expect("Core force-restored App backup report");
165 observer.reporter.emit(PresentationEvent::stdout(
166 report::force_removed_with_restore_text(&file.destination, backup),
167 ));
168 removed += 1;
169 restored += 1;
170 }
171 shine_core::runtime::AppFileAction::Missing => {
172 observer.reporter.emit(PresentationEvent::stdout(
173 report::uninstall_not_found_text(config, &file.destination),
174 ));
175 skipped += 1;
176 }
177 shine_core::runtime::AppFileAction::UserModified => {
178 observer
179 .reporter
180 .emit(PresentationEvent::stdout(report::user_modified_kept_text(
181 config,
182 &file.destination,
183 )));
184 user_modified += 1;
185 }
186 shine_core::runtime::AppFileAction::PreviewRemove => {
187 observer
188 .reporter
189 .emit(PresentationEvent::stdout(report::uninstall_dry_run_text(
190 config,
191 &file.destination,
192 )));
193 skipped += 1;
194 }
195 shine_core::runtime::AppFileAction::Failed => {
196 let error = anyhow::anyhow!(
197 file.error
198 .clone()
199 .unwrap_or_else(|| "App uninstall failed".to_string())
200 );
201 observer
202 .reporter
203 .emit(PresentationEvent::stderr(report::uninstall_error_text(
204 config,
205 &file.destination,
206 &error,
207 )));
208 }
209 _ => {}
210 }
211 }
212 if purge && !config.is_external_presets {
213 observer
214 .reporter
215 .emit(PresentationEvent::stdout(match category {
216 Some(category) => report::purge_category_text(category),
217 None => report::purge_all_text(),
218 }));
219 }
220 let summary_parts = report::uninstall_summary_parts(removed, restored, user_modified, skipped);
221 observer.reporter.emit(PresentationEvent::BlankLine);
222 observer
223 .reporter
224 .emit(PresentationEvent::stdout(report::done_summary_text(
225 &summary_parts,
226 )));
227 Ok(core_report.lifecycle)
228}
229
230struct UninstallObserver<'a> {
231 reporter: &'a mut dyn LifecycleReporter,
232}
233
234impl shine_core::runtime::RuntimeObserver for UninstallObserver<'_> {
235 fn emit(&mut self, event: shine_core::runtime::RuntimeEvent) {
236 if let shine_core::runtime::RuntimeEvent::Warning {
237 code,
238 target,
239 detail,
240 } = event
241 {
242 let category = target
243 .as_deref()
244 .and_then(|value| value.strip_prefix("app/"))
245 .unwrap_or("app");
246 if code == "app_artifact_permission_required" {
247 self.reporter.emit(PresentationEvent::stdout(format!(" {} {category}: artifact teardown skipped (run `shine trust grant app/{category}` after review; manual: shine app artifact remove {category})", report::symbol("!"))));
248 } else {
249 self.reporter.emit(PresentationEvent::stderr(format!(
250 " {} {category}: artifact teardown failed: {detail}",
251 report::symbol("!")
252 )));
253 }
254 }
255 }
256}
257
258#[cfg(test)]
259mod tests {
260 #![allow(clippy::await_holding_lock)]
261 #[cfg(unix)]
262 use super::super::install::handle_install;
263 use super::*;
264 use crate::install_core::manifest::AppInstallStrategy;
265 #[cfg(unix)]
266 use crate::test_support::env_lock;
267 use tokio::fs;
268
269 async fn make_temp_dir() -> std::path::PathBuf {
270 crate::test_support::make_temp_dir("shine-apps").await
271 }
272
273 #[cfg(unix)]
274 async fn write_external_sample_app(dir: &std::path::Path, body: &[u8]) {
275 let cat_dir = dir.join("presets/app/sample");
276 fs::create_dir_all(&cat_dir).await.unwrap();
277 let manifest = "description = \"Sample app\"\ndest = \"~/.config/sample\"\n\n[permissions]\nschema_version = 1\n\n[[files]]\nsource = \"daemon.jsonc\"\ntarget = \"daemon.json\"\ntransforms = [\"template\", \"jsonc-to-json\"]\n".to_string();
278 fs::write(cat_dir.join("shine.toml"), manifest)
279 .await
280 .unwrap();
281 fs::write(cat_dir.join("daemon.jsonc"), body).await.unwrap();
282 }
283
284 #[cfg(unix)]
285 #[tokio::test(flavor = "current_thread")]
286 async fn uninstall_dry_run_leaves_everything_intact() {
287 let _admin_guard = crate::test_support::admin_category_test_lock().await;
288 let _guard = env_lock();
289 let dir = make_temp_dir().await;
290 unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
292
293 let config = Config::new_for_test(&dir);
294 fs::create_dir_all(config.presets_dir()).await.unwrap();
295 fs::create_dir_all(config.shine_dir()).await.unwrap();
296
297 handle_install(&config, Some("git"), false, false)
298 .await
299 .unwrap();
300
301 let manifest_before = AppManifest::load(&shine_core::runtime::RealHost, config.shine_dir())
302 .await
303 .unwrap();
304 let count_before = manifest_before.entries.len();
305
306 let result = handle_uninstall_with_result(&config, Some("git"), false, false, true)
307 .await
308 .unwrap();
309 assert!(result.dry_run);
310 assert_eq!(
311 result
312 .outcomes
313 .iter()
314 .filter(|outcome| {
315 outcome.effects
316 == vec![
317 LifecycleEffect::ResourceRemovePreviewed,
318 LifecycleEffect::ReceiptRemovePreviewed,
319 ]
320 })
321 .count(),
322 count_before
323 );
324 assert!(
325 result
326 .outcomes
327 .iter()
328 .filter(|outcome| {
329 outcome.resource.as_deref() != Some("artifact:teardown")
330 && outcome.resource.as_deref() != Some("preset-cache")
331 })
332 .all(|outcome| {
333 outcome.effects
334 == vec![
335 LifecycleEffect::ResourceRemovePreviewed,
336 LifecycleEffect::ReceiptRemovePreviewed,
337 ]
338 })
339 );
340
341 let manifest_after = AppManifest::load(&shine_core::runtime::RealHost, config.shine_dir())
342 .await
343 .unwrap();
344 assert_eq!(
345 manifest_after.entries.len(),
346 count_before,
347 "dry-run must not modify manifest"
348 );
349 for entry in &manifest_before.entries {
350 assert!(
351 entry.destination.exists(),
352 "dry-run must not remove installed files"
353 );
354 }
355
356 unsafe { std::env::remove_var("HOME") };
358 fs::remove_dir_all(&dir).await.unwrap();
359 }
360
361 #[cfg(unix)]
362 #[tokio::test(flavor = "current_thread")]
363 async fn uninstall_force_removes_user_modified_file_and_manifest_entry() {
364 let _guard = env_lock();
365 let dir = make_temp_dir().await;
366 unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
368
369 write_external_sample_app(&dir, b"{\n \"debug\": true\n}\n").await;
370 let mut config = Config::new_for_test(&dir);
371 config.is_external_presets = true;
372 fs::create_dir_all(config.shine_dir()).await.unwrap();
373
374 handle_install(&config, Some("sample"), false, false)
375 .await
376 .unwrap();
377 let dest = dir.join(".config/sample/daemon.json");
378 fs::write(&dest, b"{\"debug\": false}\n").await.unwrap();
379
380 let result = handle_uninstall_with_result(&config, Some("sample"), true, false, false)
381 .await
382 .unwrap();
383 assert_eq!(result.summary().changed, 1);
384 assert!(
385 result.outcomes[0]
386 .effects
387 .contains(&LifecycleEffect::UserModificationOverridden)
388 );
389
390 let manifest_after = AppManifest::load(&shine_core::runtime::RealHost, config.shine_dir())
391 .await
392 .unwrap();
393 assert!(
394 manifest_after.entries.is_empty(),
395 "force uninstall should remove manifest entry"
396 );
397 assert!(
398 !dest.exists(),
399 "force uninstall should remove modified file"
400 );
401
402 unsafe { std::env::remove_var("HOME") };
404 fs::remove_dir_all(&dir).await.unwrap();
405 }
406
407 #[cfg(unix)]
408 #[tokio::test(flavor = "current_thread")]
409 async fn uninstall_specific_category_only_removes_that_category() {
410 let _admin_guard = crate::test_support::admin_category_test_lock().await;
411 let _guard = env_lock();
412 let dir = make_temp_dir().await;
413 unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
415
416 let config = Config::new_for_test(&dir);
417 fs::create_dir_all(config.presets_dir()).await.unwrap();
418 fs::create_dir_all(config.shine_dir()).await.unwrap();
419
420 handle_install(&config, Some("git"), false, false)
422 .await
423 .unwrap();
424 handle_install(&config, Some("starship"), false, false)
425 .await
426 .unwrap();
427 let manifest_all = AppManifest::load(&shine_core::runtime::RealHost, config.shine_dir())
428 .await
429 .unwrap();
430 let total = manifest_all.entries.len();
431 assert!(total > 0, "need at least one installed entry");
432
433 let first_category = manifest_all
435 .entries
436 .iter()
437 .find_map(|e| {
438 e.source
439 .strip_prefix("app/")
440 .and_then(|s| s.split('/').next())
441 .map(|s| s.to_string())
442 })
443 .expect("no category found in manifest");
444
445 let category_count = manifest_all
446 .entries
447 .iter()
448 .filter(|e| e.source.starts_with(&format!("app/{first_category}/")))
449 .count();
450
451 let result =
453 handle_uninstall_with_result(&config, Some(&first_category), false, false, false)
454 .await
455 .unwrap();
456 assert!(
457 result
458 .outcomes
459 .iter()
460 .all(|outcome| outcome.target == format!("app/{first_category}"))
461 );
462
463 let manifest_after = AppManifest::load(&shine_core::runtime::RealHost, config.shine_dir())
464 .await
465 .unwrap();
466 assert_eq!(
467 manifest_after.entries.len(),
468 total - category_count,
469 "only entries for '{first_category}' should be removed"
470 );
471 let prefix = format!("app/{first_category}/");
473 assert!(
474 manifest_after
475 .entries
476 .iter()
477 .all(|e| !e.source.starts_with(&prefix)),
478 "uninstalled category must not appear in manifest"
479 );
480
481 unsafe { std::env::remove_var("HOME") };
483 fs::remove_dir_all(&dir).await.unwrap();
484 }
485
486 #[tokio::test]
487 async fn structured_uninstall_preserves_user_modified_resource_and_receipt() {
488 let dir = make_temp_dir().await;
489 let mut config = Config::new_for_test(&dir);
490 config.is_external_presets = true;
491 fs::create_dir_all(config.shine_dir()).await.unwrap();
492 let destination = dir.join("destination/config.toml");
493 fs::create_dir_all(destination.parent().unwrap())
494 .await
495 .unwrap();
496 fs::write(&destination, b"user change\n").await.unwrap();
497 let manifest = AppManifest {
498 entries: vec![AppEntry {
499 source: "app/sample/config.toml".to_string(),
500 destination: destination.clone(),
501 backup: None,
502 content_hash: crate::install_core::hash_content(b"installed\n"),
503 install_strategy: AppInstallStrategy::Copy,
504 uses_env: false,
505 requires_admin: false,
506 }],
507 ..AppManifest::default()
508 };
509 manifest
510 .save(&shine_core::runtime::RealHost, config.shine_dir())
511 .await
512 .unwrap();
513
514 let result = handle_uninstall_with_result(&config, None, false, false, false)
515 .await
516 .unwrap();
517
518 assert_eq!(result.summary().preserved, 1);
519 assert_eq!(
520 result.outcomes[0].effects,
521 vec![LifecycleEffect::UserResourcePreserved]
522 );
523 assert_eq!(fs::read(&destination).await.unwrap(), b"user change\n");
524 assert_eq!(
525 AppManifest::load(&shine_core::runtime::RealHost, config.shine_dir())
526 .await
527 .unwrap()
528 .entries
529 .len(),
530 1
531 );
532 fs::remove_dir_all(&dir).await.unwrap();
533 }
534
535 #[tokio::test]
536 async fn structured_uninstall_reports_stale_receipt_cleanup_as_change() {
537 let dir = make_temp_dir().await;
538 let mut config = Config::new_for_test(&dir);
539 config.is_external_presets = true;
540 fs::create_dir_all(config.shine_dir()).await.unwrap();
541 let manifest = AppManifest {
542 entries: vec![AppEntry {
543 source: "app/sample/missing.toml".to_string(),
544 destination: dir.join("destination/missing.toml"),
545 backup: None,
546 content_hash: crate::install_core::hash_content(b"installed\n"),
547 install_strategy: AppInstallStrategy::Copy,
548 uses_env: false,
549 requires_admin: false,
550 }],
551 ..AppManifest::default()
552 };
553 manifest
554 .save(&shine_core::runtime::RealHost, config.shine_dir())
555 .await
556 .unwrap();
557
558 let result = handle_uninstall_with_result(&config, None, false, false, false)
559 .await
560 .unwrap();
561
562 assert_eq!(result.summary().changed, 1);
563 assert_eq!(
564 result.outcomes[0].effects,
565 vec![LifecycleEffect::ReceiptRemoved]
566 );
567 assert!(
568 AppManifest::load(&shine_core::runtime::RealHost, config.shine_dir())
569 .await
570 .unwrap()
571 .entries
572 .is_empty()
573 );
574 fs::remove_dir_all(&dir).await.unwrap();
575 }
576
577 #[tokio::test]
578 async fn embedded_category_and_global_purge_record_cache_and_manifest_effects() {
579 let dir = make_temp_dir().await;
580 let config = Config::new_for_test(&dir);
581 let category_cache = config.presets_dir().join("app/git");
582 fs::create_dir_all(&category_cache).await.unwrap();
583 fs::write(category_cache.join("orphan"), b"cache")
584 .await
585 .unwrap();
586 let manifest = AppManifest {
587 entries: vec![AppEntry {
588 source: "app/git/gitconfig".to_string(),
589 destination: dir.join("missing-gitconfig"),
590 backup: None,
591 content_hash: 1,
592 install_strategy: AppInstallStrategy::Copy,
593 uses_env: false,
594 requires_admin: false,
595 }],
596 ..AppManifest::default()
597 };
598 manifest
599 .save(&shine_core::runtime::RealHost, config.shine_dir())
600 .await
601 .unwrap();
602
603 let category = handle_uninstall_with_result(&config, Some("git"), false, true, false)
604 .await
605 .unwrap();
606 let category_purge = category
607 .outcomes
608 .iter()
609 .find(|outcome| outcome.resource.as_deref() == Some("purge"))
610 .unwrap();
611 assert_eq!(category_purge.target, "app/git");
612 assert!(
613 category_purge
614 .effects
615 .contains(&LifecycleEffect::CachePurged)
616 );
617
618 let global_cache = config.presets_dir().join("app/other");
619 fs::create_dir_all(&global_cache).await.unwrap();
620 fs::write(global_cache.join("orphan"), b"cache")
621 .await
622 .unwrap();
623 let global = handle_uninstall_with_result(&config, None, false, true, false)
624 .await
625 .unwrap();
626 let global_purge = global
627 .outcomes
628 .iter()
629 .find(|outcome| outcome.target == "app" && outcome.resource.as_deref() == Some("purge"))
630 .unwrap();
631 assert!(global_purge.effects.contains(&LifecycleEffect::CachePurged));
632 assert!(
633 global_purge
634 .effects
635 .contains(&LifecycleEffect::ReceiptRemoved)
636 );
637 assert!(!config.shine_dir().join("app-manifest.toml").exists());
638 fs::remove_dir_all(&dir).await.unwrap();
639 }
640
641 #[cfg(unix)]
642 #[tokio::test(flavor = "current_thread")]
643 async fn uninstall_unknown_category_returns_early() {
644 let _guard = env_lock();
645 let dir = make_temp_dir().await;
646 unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
648
649 let config = Config::new_for_test(&dir);
650 fs::create_dir_all(config.presets_dir()).await.unwrap();
651 fs::create_dir_all(config.shine_dir()).await.unwrap();
652
653 handle_uninstall(&config, Some("nonexistent"), false, false, false)
655 .await
656 .unwrap();
657
658 unsafe { std::env::remove_var("HOME") };
660 fs::remove_dir_all(&dir).await.unwrap();
661 }
662}