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/// One installed addon and the gateway server it owns.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct InstalledAddon {
19    pub name: String,
20    pub version: String,
21    /// Where it came from: `"registry"` or `"local"`.
22    pub source: String,
23    /// The `[[gateway.servers]]` entry this addon installed.
24    pub gateway_server: String,
25    /// The capabilities the user consented to at install (P1). `None` for
26    /// addons installed before the capability model / without a declaration —
27    /// a record of the granted permissions, for audit and later re-prompting.
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub granted_capabilities: Option<AddonCapabilities>,
30    /// Integrity lock (P2): content hash of the gateway wiring pinned at install.
31    /// `None` for addons installed before integrity pinning. Re-verified by
32    /// [`super::integrity::verify_all`] to detect post-install drift.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub content_hash: Option<String>,
35    /// Bootstrap receipt (#1105): the package a `[install]` block provisioned,
36    /// so `remove` can uninstall exactly what `add` installed. `None` for addons
37    /// with no bootstrap (ephemeral runners or already-present binaries).
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub install: Option<InstallReceipt>,
40}
41
42/// The on-disk installed-addons index.
43#[derive(Debug, Clone, Default, Serialize, Deserialize)]
44pub struct InstalledStore {
45    #[serde(default)]
46    pub addons: BTreeMap<String, InstalledAddon>,
47}
48
49fn store_path() -> Result<PathBuf, String> {
50    Ok(crate::core::data_dir::lean_ctx_data_dir()?
51        .join("addons")
52        .join("installed.json"))
53}
54
55impl InstalledStore {
56    /// Load the store, or an empty one if it does not exist / is unreadable.
57    pub fn load() -> Self {
58        let Ok(path) = store_path() else {
59            return Self::default();
60        };
61        match std::fs::read_to_string(&path) {
62            Ok(raw) if !raw.trim().is_empty() => serde_json::from_str(&raw).unwrap_or_default(),
63            _ => Self::default(),
64        }
65    }
66
67    /// Persist the store (creating the `addons/` dir as needed).
68    pub fn save(&self) -> Result<(), String> {
69        let path = store_path()?;
70        if let Some(parent) = path.parent() {
71            std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
72        }
73        let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
74        std::fs::write(&path, json).map_err(|e| e.to_string())
75    }
76
77    pub fn get(&self, name: &str) -> Option<&InstalledAddon> {
78        self.addons.get(name)
79    }
80
81    /// Installed addons, sorted by name (`BTreeMap` iteration order).
82    pub fn list(&self) -> Vec<&InstalledAddon> {
83        self.addons.values().collect()
84    }
85
86    pub fn upsert(&mut self, addon: InstalledAddon) {
87        self.addons.insert(addon.name.clone(), addon);
88    }
89
90    pub fn remove(&mut self, name: &str) -> Option<InstalledAddon> {
91        self.addons.remove(name)
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use crate::core::data_dir::isolated_data_dir;
99
100    fn sample(name: &str) -> InstalledAddon {
101        InstalledAddon {
102            name: name.to_string(),
103            version: "1.0.0".into(),
104            source: "registry".into(),
105            gateway_server: name.to_string(),
106            granted_capabilities: None,
107            content_hash: None,
108            install: None,
109        }
110    }
111
112    #[test]
113    fn round_trips_through_disk() {
114        let _data = isolated_data_dir();
115        assert!(InstalledStore::load().list().is_empty());
116
117        let mut store = InstalledStore::default();
118        store.upsert(sample("alpha"));
119        store.upsert(sample("beta"));
120        store.save().expect("save");
121
122        let reloaded = InstalledStore::load();
123        assert_eq!(reloaded.list().len(), 2);
124        assert!(reloaded.get("alpha").is_some());
125
126        let mut reloaded = reloaded;
127        assert!(reloaded.remove("alpha").is_some());
128        reloaded.save().expect("save");
129        assert!(InstalledStore::load().get("alpha").is_none());
130        assert!(InstalledStore::load().get("beta").is_some());
131    }
132}