lean_ctx/core/addons/
store.rs1use std::collections::BTreeMap;
9use std::path::PathBuf;
10
11use serde::{Deserialize, Serialize};
12
13use super::capabilities::AddonCapabilities;
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct InstalledAddon {
18 pub name: String,
19 pub version: String,
20 pub source: String,
22 pub gateway_server: String,
24 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub granted_capabilities: Option<AddonCapabilities>,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub content_hash: Option<String>,
34}
35
36#[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 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 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 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}