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