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    // Shape-agnostic, symmetric with `install`: a workspace that can attach a
29    // read-mem must be able to detach it. Booting the mem-repo-only engine
30    // here while `install` boots the shape-agnostic one would leave a folder
31    // workspace able to install and unable to uninstall.
32    let mut cli_engine = ctx.cli_engine()?;
33    let engine = cli_engine.base_mut();
34
35    // Resolve, and refuse the two wrong-target shapes before any
36    // mutation: unknown names, and writable mems (which have their own
37    // lifecycle verbs).
38    let Some(mount) = engine.mount(&args.name) else {
39        return Err(CliError::new(
40            ExitKind::NotFound,
41            "UNKNOWN_MEM",
42            format!(
43                "no installed read-mem named `{}` — `memstead mem list` shows what is mounted",
44                args.name
45            ),
46        )
47        .with_details(json!({ "mem": args.name }))
48        .into());
49    };
50    if mount.capability != memstead_base::MountCapability::ReadOnly {
51        return Err(CliError::new(
52            ExitKind::Validation,
53            "MEM_NOT_READ_ONLY",
54            format!(
55                "`{}` is a writable mem — uninstall removes installed read-mems only; \
56                 use `memstead mem unregister {}` (keep storage) or \
57                 `memstead mem delete {}` (destroy storage)",
58                args.name, args.name, args.name
59            ),
60        )
61        .with_details(json!({ "mem": args.name }))
62        .into());
63    }
64
65    // Incoming-refs gate, mirroring the delete/unregister posture: a
66    // writable mem's entity that still holds a graph edge into the
67    // read-mem would be left dangling. Same-mem and read-only-mount
68    // referrers are irrelevant here (the whole mem disappears; RO
69    // mounts cannot be rewritten).
70    {
71        use std::collections::BTreeSet;
72        let store = engine.store();
73        let doomed = args.name.as_str();
74        let mut by_source: std::collections::BTreeMap<
75            String,
76            (memstead_base::EntityId, BTreeSet<String>),
77        > = std::collections::BTreeMap::new();
78        for entity in store.all_entities() {
79            if entity.mem != doomed {
80                continue;
81            }
82            for in_edge in store.incoming(&entity.id) {
83                if in_edge.from.mem() == doomed
84                    || !engine.mem_router().is_writable(in_edge.from.mem())
85                {
86                    continue;
87                }
88                by_source
89                    .entry(in_edge.from.to_string())
90                    .or_insert_with(|| (in_edge.from.clone(), BTreeSet::new()))
91                    .1
92                    .insert(in_edge.rel_type.clone());
93            }
94        }
95        if !by_source.is_empty() {
96            let referrers: Vec<memstead_base::ReferrerInfo> = by_source
97                .into_values()
98                .map(|(from, rel_types)| memstead_base::ReferrerInfo {
99                    from_id: from.to_string(),
100                    rel_types: rel_types.into_iter().collect(),
101                    mem: from.mem().to_string(),
102                })
103                .collect();
104            return Err(
105                CliError::from_engine_op(memstead_base::EngineError::MemHasIncomingRefs {
106                    mem: args.name,
107                    referrers,
108                })
109                .into(),
110            );
111        }
112    }
113
114    engine
115        .unregister_read_mount(&args.name)
116        .map_err(|e| anyhow::Error::from(CliError::from_engine_op(e)))?;
117    engine
118        .persist_state()
119        .map_err(|e| anyhow::Error::from(CliError::from_engine_op(e)))?;
120
121    if ctx.json {
122        print_json(&json!({
123            "mem_name": args.name,
124            "unregistered": true,
125            "cache_retained": true,
126        }))?;
127    } else {
128        print_markdown(&format!(
129            "# Uninstalled `{}`\n\n- Mount: unregistered from the workspace\n- Cache: \
130             archive copy retained (shared across workspaces; re-`install` re-registers it)",
131            args.name,
132        ));
133    }
134    Ok(())
135}