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, PartialEq, Eq, Serialize, Deserialize)]
21pub struct ArtifactReceipt {
22 pub platform: String,
24 pub url: String,
26 pub sha256: String,
28 pub path: String,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct InstalledAddon {
35 pub name: String,
36 pub version: String,
37 pub source: String,
39 pub gateway_server: String,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub granted_capabilities: Option<AddonCapabilities>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub content_hash: Option<String>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub install: Option<InstallReceipt>,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub artifact: Option<ArtifactReceipt>,
61}
62
63#[derive(Debug, Clone, Default, Serialize, Deserialize)]
65pub struct InstalledStore {
66 #[serde(default)]
67 pub addons: BTreeMap<String, InstalledAddon>,
68}
69
70fn store_path() -> Result<PathBuf, String> {
71 Ok(crate::core::data_dir::lean_ctx_data_dir()?
72 .join("addons")
73 .join("installed.json"))
74}
75
76impl InstalledStore {
77 pub fn load() -> Self {
79 let Ok(path) = store_path() else {
80 return Self::default();
81 };
82 match std::fs::read_to_string(&path) {
83 Ok(raw) if !raw.trim().is_empty() => serde_json::from_str(&raw).unwrap_or_default(),
84 _ => Self::default(),
85 }
86 }
87
88 pub fn save(&self) -> Result<(), String> {
90 let path = store_path()?;
91 if let Some(parent) = path.parent() {
92 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
93 }
94 let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
95 std::fs::write(&path, json).map_err(|e| e.to_string())
96 }
97
98 pub fn get(&self, name: &str) -> Option<&InstalledAddon> {
99 self.addons.get(name)
100 }
101
102 pub fn list(&self) -> Vec<&InstalledAddon> {
104 self.addons.values().collect()
105 }
106
107 pub fn upsert(&mut self, addon: InstalledAddon) {
108 self.addons.insert(addon.name.clone(), addon);
109 }
110
111 pub fn remove(&mut self, name: &str) -> Option<InstalledAddon> {
112 self.addons.remove(name)
113 }
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119 use crate::core::data_dir::isolated_data_dir;
120
121 fn sample(name: &str) -> InstalledAddon {
122 InstalledAddon {
123 name: name.to_string(),
124 version: "1.0.0".into(),
125 source: "registry".into(),
126 gateway_server: name.to_string(),
127 granted_capabilities: None,
128 content_hash: None,
129 install: None,
130 artifact: None,
131 }
132 }
133
134 #[test]
135 fn round_trips_through_disk() {
136 let _data = isolated_data_dir();
137 assert!(InstalledStore::load().list().is_empty());
138
139 let mut store = InstalledStore::default();
140 store.upsert(sample("alpha"));
141 store.upsert(sample("beta"));
142 store.save().expect("save");
143
144 let reloaded = InstalledStore::load();
145 assert_eq!(reloaded.list().len(), 2);
146 assert!(reloaded.get("alpha").is_some());
147
148 let mut reloaded = reloaded;
149 assert!(reloaded.remove("alpha").is_some());
150 reloaded.save().expect("save");
151 assert!(InstalledStore::load().get("alpha").is_none());
152 assert!(InstalledStore::load().get("beta").is_some());
153 }
154}