oxi/storage/packages/
runtime_config.rs1use anyhow::{Context, Result};
24use serde::{Deserialize, Serialize};
25use std::collections::{BTreeMap, HashSet};
26use std::fs;
27use std::path::{Path, PathBuf};
28
29use super::types::ResourceKind;
30
31pub const RUNTIME_CONFIG_VERSION: u32 = 1;
33
34pub const RUNTIME_CONFIG_FILE: &str = "runtime.json";
36
37#[derive(Debug, Clone, Default, Serialize, Deserialize)]
43pub struct RuntimeConfig {
44 #[serde(default = "default_version")]
46 pub version: u32,
47 #[serde(default)]
49 pub disabled: BTreeMap<String, HashSet<ResourceKind>>,
50}
51
52fn default_version() -> u32 {
53 RUNTIME_CONFIG_VERSION
54}
55
56impl RuntimeConfig {
57 pub fn new() -> Self {
59 Self {
60 version: RUNTIME_CONFIG_VERSION,
61 disabled: BTreeMap::new(),
62 }
63 }
64
65 pub fn global_path() -> Result<PathBuf> {
67 let base =
68 dirs::home_dir().context("Cannot determine home directory for RuntimeConfig path")?;
69 Ok(base.join(".oxi").join(RUNTIME_CONFIG_FILE))
70 }
71
72 pub fn read_or_default(path: &Path) -> Self {
76 match Self::read(path) {
77 Ok(cfg) => cfg,
78 Err(_) => Self::new(),
79 }
80 }
81
82 pub fn read(path: &Path) -> Result<Self> {
84 if !path.exists() {
85 return Ok(Self::new());
86 }
87 let content = fs::read_to_string(path)
88 .with_context(|| format!("Failed to read RuntimeConfig at {}", path.display()))?;
89 let cfg: Self = serde_json::from_str(&content)
90 .with_context(|| format!("Failed to parse RuntimeConfig at {}", path.display()))?;
91 Ok(cfg)
92 }
93
94 pub fn write(&self, path: &Path) -> Result<()> {
97 if let Some(parent) = path.parent() {
98 fs::create_dir_all(parent)
99 .with_context(|| format!("Failed to create parent dir for {}", path.display()))?;
100 }
101 let content =
102 serde_json::to_string_pretty(self).context("Failed to serialize RuntimeConfig")?;
103 let tmp = path.with_extension(format!(
104 "tmp.{}.{}",
105 std::process::id(),
106 uuid::Uuid::new_v4().simple()
107 ));
108 fs::write(&tmp, content)
109 .with_context(|| format!("Failed to write temp RuntimeConfig {}", tmp.display()))?;
110 match fs::rename(&tmp, path) {
111 Ok(()) => Ok(()),
112 Err(e) => {
113 let _ = fs::remove_file(&tmp);
114 Err(e).with_context(|| {
115 format!("Failed to rename RuntimeConfig to {}", path.display())
116 })
117 }
118 }
119 }
120
121 pub fn is_empty(&self) -> bool {
123 self.disabled.values().all(|set| set.is_empty())
124 }
125
126 pub fn is_disabled(&self, package: &str, kind: ResourceKind) -> bool {
128 self.disabled
129 .get(package)
130 .is_some_and(|set| set.contains(&kind))
131 }
132
133 pub fn enabled_kinds(&self, package: &str) -> Vec<ResourceKind> {
136 let disabled = self.disabled.get(package);
137 ResourceKind::ALL
138 .iter()
139 .copied()
140 .filter(|k| !disabled.is_some_and(|set| set.contains(k)))
141 .collect()
142 }
143
144 pub fn disable(&mut self, package: impl Into<String>, kind: ResourceKind) {
146 let entry = self.disabled.entry(package.into()).or_default();
147 entry.insert(kind);
148 }
149
150 pub fn enable(&mut self, package: &str, kind: ResourceKind) {
153 if let Some(set) = self.disabled.get_mut(package) {
154 set.remove(&kind);
155 if set.is_empty() {
156 self.disabled.remove(package);
157 }
158 }
159 }
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165
166 fn tmp_path(name: &str) -> (tempfile::TempDir, PathBuf) {
167 let dir = tempfile::tempdir().unwrap();
168 let path = dir.path().join(name);
169 (dir, path)
170 }
171
172 #[test]
173 fn defaults_to_all_enabled() {
174 let cfg = RuntimeConfig::new();
175 assert!(!cfg.is_disabled("lodash", ResourceKind::Skill));
176 assert_eq!(cfg.enabled_kinds("lodash").len(), ResourceKind::ALL.len());
177 assert!(cfg.is_empty());
178 }
179
180 #[test]
181 fn disable_then_enable_round_trips() {
182 let mut cfg = RuntimeConfig::new();
183 cfg.disable("lodash", ResourceKind::Skill);
184 cfg.disable("lodash", ResourceKind::Theme);
185 assert!(cfg.is_disabled("lodash", ResourceKind::Skill));
186 assert!(!cfg.is_disabled("lodash", ResourceKind::Extension));
187
188 cfg.enable("lodash", ResourceKind::Skill);
189 assert!(!cfg.is_disabled("lodash", ResourceKind::Skill));
190 assert!(cfg.is_disabled("lodash", ResourceKind::Theme));
191 assert!(!cfg.is_empty());
192
193 cfg.enable("lodash", ResourceKind::Theme);
194 assert!(cfg.is_empty(), "fully empty entry must drop the key");
195 }
196
197 #[test]
198 fn write_then_read_round_trips() {
199 let (_tmp, path) = tmp_path("runtime.json");
200 let mut cfg = RuntimeConfig::new();
201 cfg.disable("@foo/oxi-tools", ResourceKind::Extension);
202 cfg.write(&path).unwrap();
203
204 let loaded = RuntimeConfig::read(&path).unwrap();
205 assert!(loaded.is_disabled("@foo/oxi-tools", ResourceKind::Extension));
206 assert!(!loaded.is_disabled("@foo/oxi-tools", ResourceKind::Theme));
207 }
208
209 #[test]
210 fn missing_file_yields_empty_config() {
211 let (_tmp, path) = tmp_path("does-not-exist.json");
212 let cfg = RuntimeConfig::read(&path).unwrap();
213 assert!(cfg.is_empty());
214 }
215
216 #[test]
217 fn corrupt_file_returns_empty_via_read_or_default() {
218 let (_tmp, path) = tmp_path("runtime.json");
219 fs::write(&path, b"not valid json").unwrap();
220 assert!(RuntimeConfig::read(&path).is_err());
222 let cfg = RuntimeConfig::read_or_default(&path);
223 assert!(cfg.is_empty());
224 }
225
226 #[test]
227 fn write_is_atomic_via_temp_then_rename() {
228 let dir = tempfile::tempdir().unwrap();
229 let path = dir.path().join("runtime.json");
230 let cfg = RuntimeConfig::new();
231 cfg.write(&path).unwrap();
232 let stale: Vec<_> = fs::read_dir(dir.path())
234 .unwrap()
235 .flatten()
236 .filter(|e| {
237 e.file_name().to_string_lossy().contains("tmp.")
238 && e.file_name().to_string_lossy() != "runtime.json"
239 })
240 .collect();
241 assert!(
242 stale.is_empty(),
243 "atomic write must leave no tmp. files behind; saw {:?}",
244 stale.iter().map(|e| e.path()).collect::<Vec<_>>()
245 );
246 }
247}