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