Skip to main content

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}