Skip to main content

lean_ctx/core/addons/
store.rs

1//! Installed-addon state: `<data_dir>/addons/installed.json`.
2//!
3//! Records which addons are installed and the gateway server each one owns, so
4//! `remove` can cleanly unwire exactly what `add` wired. State only — config
5//! (the live `[[gateway.servers]]`) remains the single source of truth for what
6//! actually runs.
7
8use std::collections::BTreeMap;
9use std::path::PathBuf;
10
11use serde::{Deserialize, Serialize};
12
13use super::bootstrap::InstallReceipt;
14use super::capabilities::AddonCapabilities;
15
16/// Receipt of a managed prebuilt binary installed from a `[artifacts]` entry
17/// (GH #724/#725, Phase 1): what was downloaded, where it lives, and the
18/// SHA-256 the gateway pins at spawn. `doctor` re-verifies it; `remove` and
19/// `update` clean up exactly what was installed.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub struct ArtifactReceipt {
22    /// Rust target triple the asset was resolved for.
23    pub platform: String,
24    /// Download URL the binary came from.
25    pub url: String,
26    /// SHA-256 pin (also mirrored into the gateway server's `binary_sha256`).
27    pub sha256: String,
28    /// Absolute managed path the gateway spawns.
29    pub path: String,
30}
31
32/// One installed addon and the gateway server it owns.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct InstalledAddon {
35    pub name: String,
36    pub version: String,
37    /// Where it came from: `"registry"` or `"local"`.
38    pub source: String,
39    /// The `[[gateway.servers]]` entry this addon installed.
40    pub gateway_server: String,
41    /// The capabilities the user consented to at install (P1). `None` for
42    /// addons installed before the capability model / without a declaration —
43    /// a record of the granted permissions, for audit and later re-prompting.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub granted_capabilities: Option<AddonCapabilities>,
46    /// Integrity lock (P2): content hash of the gateway wiring pinned at install.
47    /// `None` for addons installed before integrity pinning. Re-verified by
48    /// [`super::integrity::verify_all`] to detect post-install drift.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub content_hash: Option<String>,
51    /// Bootstrap receipt (#1105): the package a `[install]` block provisioned,
52    /// so `remove` can uninstall exactly what `add` installed. `None` for addons
53    /// with no bootstrap (ephemeral runners or already-present binaries).
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub install: Option<InstallReceipt>,
56    /// Managed-binary receipt (GH #725): the prebuilt artifact `add` installed
57    /// into the managed bin dir. `None` for addons resolved via `PATH`,
58    /// bootstrap, or an ephemeral runner.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub artifact: Option<ArtifactReceipt>,
61}
62
63/// The on-disk installed-addons index.
64#[derive(Debug, Clone, Default, Serialize, Deserialize)]
65pub struct InstalledStore {
66    #[serde(default)]
67    pub addons: BTreeMap<String, InstalledAddon>,
68}
69
70fn store_path() -> Result<PathBuf, String> {
71    Ok(crate::core::data_dir::lean_ctx_data_dir()?
72        .join("addons")
73        .join("installed.json"))
74}
75
76impl InstalledStore {
77    /// Load the store, or an empty one if it does not exist / is unreadable.
78    pub fn load() -> Self {
79        let Ok(path) = store_path() else {
80            return Self::default();
81        };
82        match std::fs::read_to_string(&path) {
83            Ok(raw) if !raw.trim().is_empty() => serde_json::from_str(&raw).unwrap_or_default(),
84            _ => Self::default(),
85        }
86    }
87
88    /// Persist the store (creating the `addons/` dir as needed).
89    pub fn save(&self) -> Result<(), String> {
90        let path = store_path()?;
91        if let Some(parent) = path.parent() {
92            std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
93        }
94        let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
95        std::fs::write(&path, json).map_err(|e| e.to_string())
96    }
97
98    pub fn get(&self, name: &str) -> Option<&InstalledAddon> {
99        self.addons.get(name)
100    }
101
102    /// Installed addons, sorted by name (`BTreeMap` iteration order).
103    pub fn list(&self) -> Vec<&InstalledAddon> {
104        self.addons.values().collect()
105    }
106
107    pub fn upsert(&mut self, addon: InstalledAddon) {
108        self.addons.insert(addon.name.clone(), addon);
109    }
110
111    pub fn remove(&mut self, name: &str) -> Option<InstalledAddon> {
112        self.addons.remove(name)
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use crate::core::data_dir::isolated_data_dir;
120
121    fn sample(name: &str) -> InstalledAddon {
122        InstalledAddon {
123            name: name.to_string(),
124            version: "1.0.0".into(),
125            source: "registry".into(),
126            gateway_server: name.to_string(),
127            granted_capabilities: None,
128            content_hash: None,
129            install: None,
130            artifact: None,
131        }
132    }
133
134    #[test]
135    fn round_trips_through_disk() {
136        let _data = isolated_data_dir();
137        assert!(InstalledStore::load().list().is_empty());
138
139        let mut store = InstalledStore::default();
140        store.upsert(sample("alpha"));
141        store.upsert(sample("beta"));
142        store.save().expect("save");
143
144        let reloaded = InstalledStore::load();
145        assert_eq!(reloaded.list().len(), 2);
146        assert!(reloaded.get("alpha").is_some());
147
148        let mut reloaded = reloaded;
149        assert!(reloaded.remove("alpha").is_some());
150        reloaded.save().expect("save");
151        assert!(InstalledStore::load().get("alpha").is_none());
152        assert!(InstalledStore::load().get("beta").is_some());
153    }
154}