Skip to main content

memstead_cli/commands/
uninstall.rs

1//! `memstead uninstall <name>` — the symmetric removal for
2//! `memstead install`: unregister an installed read-mem's
3//! workspace-level mount. Registration-only by default — the global
4//! cache copy is shared across workspaces and survives (a later
5//! `install` of the same archive re-registers without a download).
6
7use clap::Parser;
8use serde_json::json;
9
10use crate::CliError;
11use crate::output::{ExitKind, print_json, print_markdown};
12use crate::setup::CliContext;
13
14/// Remove an installed read-mem's workspace-level mount. The global
15/// cache copy survives; re-`install` re-registers it. Refuses while
16/// entities in writable mems still hold graph edges into the read-mem
17/// (`MEM_HAS_INCOMING_REFS` naming each referrer — remove those edges
18/// first), and refuses writable mems (`MEM_NOT_READ_ONLY` — that is
19/// `memstead mem delete` / `mem unregister` business).
20#[derive(Parser, Debug)]
21pub struct Args {
22    /// The installed read-mem's name (the archive's internal name, as
23    /// shown by `memstead mem list`).
24    pub name: String,
25}
26
27pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
28    let mut engine = crate::setup::full_engine(ctx)?;
29
30    // Resolve, and refuse the two wrong-target shapes before any
31    // mutation: unknown names, and writable mems (which have their own
32    // lifecycle verbs).
33    let Some(mount) = engine.mount(&args.name) else {
34        return Err(CliError::new(
35            ExitKind::NotFound,
36            "UNKNOWN_MEM",
37            format!(
38                "no installed read-mem named `{}` — `memstead mem list` shows what is mounted",
39                args.name
40            ),
41        )
42        .with_details(json!({ "mem": args.name }))
43        .into());
44    };
45    if mount.capability != memstead_base::MountCapability::ReadOnly {
46        return Err(CliError::new(
47            ExitKind::Validation,
48            "MEM_NOT_READ_ONLY",
49            format!(
50                "`{}` is a writable mem — uninstall removes installed read-mems only; \
51                 use `memstead mem unregister {}` (keep storage) or \
52                 `memstead mem delete {}` (destroy storage)",
53                args.name, args.name, args.name
54            ),
55        )
56        .with_details(json!({ "mem": args.name }))
57        .into());
58    }
59
60    // Incoming-refs gate, mirroring the delete/unregister posture: a
61    // writable mem's entity that still holds a graph edge into the
62    // read-mem would be left dangling. Same-mem and read-only-mount
63    // referrers are irrelevant here (the whole mem disappears; RO
64    // mounts cannot be rewritten).
65    {
66        use std::collections::BTreeSet;
67        let store = engine.store();
68        let doomed = args.name.as_str();
69        let mut by_source: std::collections::BTreeMap<
70            String,
71            (memstead_base::EntityId, BTreeSet<String>),
72        > = std::collections::BTreeMap::new();
73        for entity in store.all_entities() {
74            if entity.mem != doomed {
75                continue;
76            }
77            for in_edge in store.incoming(&entity.id) {
78                if in_edge.from.mem() == doomed
79                    || !engine.mem_router().is_writable(in_edge.from.mem())
80                {
81                    continue;
82                }
83                by_source
84                    .entry(in_edge.from.to_string())
85                    .or_insert_with(|| (in_edge.from.clone(), BTreeSet::new()))
86                    .1
87                    .insert(in_edge.rel_type.clone());
88            }
89        }
90        if !by_source.is_empty() {
91            let referrers: Vec<memstead_base::ReferrerInfo> = by_source
92                .into_values()
93                .map(|(from, rel_types)| memstead_base::ReferrerInfo {
94                    from_id: from.to_string(),
95                    rel_types: rel_types.into_iter().collect(),
96                    mem: from.mem().to_string(),
97                })
98                .collect();
99            return Err(
100                CliError::from_engine_op(memstead_base::EngineError::MemHasIncomingRefs {
101                    mem: args.name,
102                    referrers,
103                })
104                .into(),
105            );
106        }
107    }
108
109    engine
110        .unregister_read_mount(&args.name)
111        .map_err(|e| anyhow::Error::from(CliError::from_engine_op(e)))?;
112    engine
113        .persist_state()
114        .map_err(|e| anyhow::Error::from(CliError::from_engine_op(e)))?;
115
116    if ctx.json {
117        print_json(&json!({
118            "mem_name": args.name,
119            "unregistered": true,
120            "cache_retained": true,
121        }))?;
122    } else {
123        print_markdown(&format!(
124            "# Uninstalled `{}`\n\n- Mount: unregistered from the workspace\n- Cache: \
125             archive copy retained (shared across workspaces; re-`install` re-registers it)",
126            args.name,
127        ));
128    }
129    Ok(())
130}