agent_runtime/uninstall.rs
1//! `agent-runtime uninstall` body. Plan 04 Sprint 2 Task 2.1.
2//!
3//! Uninstall walks the same link-map + overlay pipeline as `install::run`
4//! to discover exactly which symlinks and managed-block surfaces the
5//! installer owns, then reverses them. It never touches anything outside
6//! the link map: backups under `<state_home>/backups/` survive, and so do
7//! product runtime homes' `auth*`, `history*`, `sessions*`, `cache*`, and
8//! `projects*` trees (those are not referenced by the link map at all).
9//!
10//! Idempotence is enforced at the executor: a second uninstall on a home
11//! whose install map has already been removed walks the plan, sees every
12//! action's destination is already absent (or already free of the managed
13//! block), emits `NoOp` for each, and exits successfully without mutating
14//! the filesystem.
15//!
16//! Restore of previously-replaced files is **delegated to** `restore-backups`
17//! (Sprint 2 Task 2.2). This module never reads `<state_home>/backups/`.
18
19pub mod executor;
20pub mod plan;
21
22use std::path::{Path, PathBuf};
23
24pub use executor::{ApplyError, Mode, UninstalledChange};
25pub use plan::{UninstallAction, UninstallPlan};
26
27use crate::install::link_map::{LinkMap, LinkMapError};
28use crate::install::overlay::{self, LinkMapOverlay, OverlaySummary};
29use crate::install::plan::{InstallPlan, PlanError};
30
31/// Top-level error from [`run`]. Mirrors `install::InstallError` so the
32/// CLI can map both surfaces through identical match arms.
33#[derive(Debug, thiserror::Error)]
34pub enum UninstallError {
35 #[error("link-map: {0}")]
36 LinkMap(#[from] LinkMapError),
37 #[error("plan: {0}")]
38 Plan(#[from] PlanError),
39 #[error("apply: {0}")]
40 Apply(#[from] ApplyError),
41}
42
43/// Per-run knobs. Mirrors `install::InstallOptions` so the overlay
44/// resolution path is identical — uninstall must see the same effective
45/// link map the installer wrote, otherwise overlay-added entries get
46/// orphaned. The `tag` knob is deliberately omitted: uninstall never
47/// writes a backup-run marker.
48#[derive(Debug, Clone)]
49pub struct UninstallOptions {
50 pub overlay_enabled: bool,
51 pub overlay_path: Option<PathBuf>,
52}
53
54impl Default for UninstallOptions {
55 /// Defaults: overlay merge on. `derive(Default)` would silently land
56 /// `overlay_enabled = false` and skip overlay-discovered entries.
57 fn default() -> Self {
58 Self {
59 overlay_enabled: true,
60 overlay_path: None,
61 }
62 }
63}
64
65/// Full outcome of one `uninstall::run` cycle. `overlay` is `Some` when
66/// an overlay file was merged before the plan was built; the CLI prints
67/// a one-line operator-visible notice so a silently-consumed overlay
68/// never masks the resulting plan.
69#[derive(Debug)]
70pub struct UninstallOutcome {
71 pub plan: UninstallPlan,
72 pub changes: Vec<UninstalledChange>,
73 pub overlay: Option<OverlaySummary>,
74}
75
76/// Execute one uninstall cycle. Builds the same plan structure the
77/// installer used, then translates each `PlanAction` into a `Remove*`
78/// step and walks the executor. `home` must be absolute (the CLI also
79/// asserts this, but the library gate is defense in depth so direct
80/// callers cannot point uninstall at a relative path).
81pub fn run(
82 product: &str,
83 source_root: &Path,
84 home: &Path,
85 mode: Mode,
86 options: &UninstallOptions,
87) -> Result<UninstallOutcome, UninstallError> {
88 let mut link_map = LinkMap::load(source_root, product)?;
89 let overlay_summary = merge_overlay(&mut link_map, source_root, options)?;
90 // `state_home` is unused by the uninstall executor (no backups are
91 // read or written). Pass an empty path placeholder so we keep the
92 // existing `InstallPlan::build` signature intact.
93 let install_plan = InstallPlan::build(product, source_root, home, Path::new(""), &link_map)?;
94 let plan = UninstallPlan::from_install(&install_plan);
95 let changes = executor::run(&plan, mode)?;
96 Ok(UninstallOutcome {
97 plan,
98 changes,
99 overlay: overlay_summary,
100 })
101}
102
103fn merge_overlay(
104 link_map: &mut LinkMap,
105 source_root: &Path,
106 options: &UninstallOptions,
107) -> Result<Option<OverlaySummary>, UninstallError> {
108 if !options.overlay_enabled {
109 return Ok(None);
110 }
111 let overlay_opt = match options.overlay_path.as_deref() {
112 Some(path) => LinkMapOverlay::load_from(path)?,
113 None => LinkMapOverlay::load_optional(source_root)?,
114 };
115 match overlay_opt {
116 Some(overlay) => {
117 let summary = overlay::apply(link_map, &overlay)?;
118 Ok(Some(summary))
119 }
120 None => Ok(None),
121 }
122}