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