Skip to main content

oxi/storage/packages/
runtime_config.rs

1//! `RuntimeConfig` — per-package enabled/disabled toggles for installed packages.
2//!
3//! Persisted as JSON under `~/.oxi/runtime.json`. Loaded on demand and
4//! applied to `PackageManager::resolve_with_config` so a user can keep a
5//! package installed but flip individual resource kinds off without
6//! uninstalling the package.
7//!
8//! The structure is intentionally minimal: a `BTreeMap<package_name,
9//! HashSet<ResourceKind>>` where an empty set for a package means
10//! "package untouched" (all kinds enabled). Atomic writes mirror the
11//! store/fs_util conventions so a crash never leaves a half-written file.
12//!
13//! File format (`runtime.json`):
14//! ```json
15//! {
16//!   "version": 1,
17//!   "disabled": {
18//!     "@foo/oxi-tools": ["extension"],
19//!     "lodash": ["prompt", "theme"]
20//!   }
21//! }
22//! ```
23use 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
31/// On-disk schema version for `RuntimeConfig`.
32pub const RUNTIME_CONFIG_VERSION: u32 = 1;
33
34/// Default filename under `~/.oxi`.
35pub const RUNTIME_CONFIG_FILE: &str = "runtime.json";
36
37/// Per-package disabled-resource toggle.
38///
39/// An empty disabled-set for a package is equivalent to "package fully
40/// enabled" (the entry can be omitted on disk). Lookups are case-sensitive
41/// — package names already come from manifests and are unique.
42#[derive(Debug, Clone, Default, Serialize, Deserialize)]
43pub struct RuntimeConfig {
44    /// Schema version.
45    #[serde(default = "default_version")]
46    pub version: u32,
47    /// Map of package-name -> set of disabled `ResourceKind`s.
48    #[serde(default)]
49    pub disabled: BTreeMap<String, HashSet<ResourceKind>>,
50}
51
52fn default_version() -> u32 {
53    RUNTIME_CONFIG_VERSION
54}
55
56impl RuntimeConfig {
57    /// Empty config (everything enabled).
58    pub fn new() -> Self {
59        Self {
60            version: RUNTIME_CONFIG_VERSION,
61            disabled: BTreeMap::new(),
62        }
63    }
64
65    /// Resolve the canonical `~/.oxi/runtime.json` path.
66    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    /// Read from `path`. Returns `RuntimeConfig::new()` if the file is absent
73    /// or unreadable-as-our-format (a corrupt file is treated as empty so
74    /// the CLI keeps booting; Doctor surfaces the corruption).
75    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    /// Strict read — returns an error on missing or malformed file.
83    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    /// Atomic write. Mirrors `crate::store::fs_util::atomic_write`'s
95    /// temp-file + rename policy so concurrent writers don't tear the file.
96    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    /// True when nothing is disabled.
122    pub fn is_empty(&self) -> bool {
123        self.disabled.values().all(|set| set.is_empty())
124    }
125
126    /// True when this config disables `kind` for `package`.
127    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    /// All resource kinds still enabled for `package` (a kind is enabled
134    /// unless explicitly disabled).
135    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    /// Disable a single kind under a package; idempotent.
145    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    /// Re-enable a single kind. If the entry becomes empty, the map key is
151    /// dropped so a subsequent write produces the smallest possible file.
152    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        // Strict read surfaces the error; read_or_default swallows it.
221        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        // No leftover temp files in the parent directory.
233        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}