lean_ctx/core/addons/
store.rs1use std::collections::BTreeMap;
9use std::path::PathBuf;
10
11use serde::{Deserialize, Serialize};
12
13use super::bootstrap::InstallReceipt;
14use super::capabilities::AddonCapabilities;
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct InstalledAddon {
19 pub name: String,
20 pub version: String,
21 pub source: String,
23 pub gateway_server: String,
25 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub granted_capabilities: Option<AddonCapabilities>,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub content_hash: Option<String>,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub install: Option<InstallReceipt>,
40}
41
42#[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 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 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 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}