Skip to main content

oxi/storage/packages/
overrides.rs

1//! `ProjectPluginOverrides` — project-scoped force on/off for resources.
2//!
3//! Stored under `<project>/.oxi/plugin-overrides.json`. Distinct from the
4//! user-level `RuntimeConfig`: project overrides take precedence so that
5//! `oxi` policy can force a resource ON or OFF regardless of what the user
6//! toggled globally. The combined resolution order is:
7//!
8//! 1. Project `Forced::On` / `Forced::Off` (highest, applies to both
9//!    installed and would-be-disabled resources)
10//! 2. User-level `RuntimeConfig::disabled`
11//! 3. Default — resource is enabled
12//!
13//! File format:
14//! ```json
15//! {
16//!   "version": 1,
17//!   "forced": {
18//!     "@foo/oxi-tools": {
19//!       "extension": "off",
20//!       "skill": "on"
21//!     },
22//!     "lodash": { "prompt": "off" }
23//!   }
24//! }
25//! ```
26
27use anyhow::{Context, Result};
28use serde::{Deserialize, Serialize};
29use std::collections::BTreeMap;
30use std::fs;
31use std::path::{Path, PathBuf};
32
33use super::types::ResourceKind;
34
35/// Schema version for `ProjectPluginOverrides`.
36pub const OVERRIDES_VERSION: u32 = 1;
37
38/// Default filename under `<project>/.oxi/`.
39pub const OVERRIDES_FILE: &str = "plugin-overrides.json";
40
41/// Tri-state project force for a single (package, kind) pair.
42///
43/// `On` / `Off` take precedence over user-level `RuntimeConfig.disabled`
44/// and over the default (`enabled`).
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
46#[serde(rename_all = "snake_case")]
47pub enum ForceState {
48    /// Force the resource enabled (project overrides user-disable).
49    On,
50    /// Force the resource disabled (project overrides default-on).
51    Off,
52}
53
54impl std::fmt::Display for ForceState {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        match self {
57            ForceState::On => write!(f, "on"),
58            ForceState::Off => write!(f, "off"),
59        }
60    }
61}
62
63/// Map from package name to per-kind `ForceState`. Keys present in the
64/// map override anything below them in the precedence chain.
65pub type ForceMap = BTreeMap<String, BTreeMap<ResourceKind, ForceState>>;
66
67/// Per-project plugin override file.
68///
69/// A project is "the directory containing `.oxi/`". Path is supplied by
70/// the caller (typically `PackageManager::project_dir`); the file itself
71/// is optional — missing-file is the no-overrides case.
72#[derive(Debug, Clone, Default, Serialize, Deserialize)]
73pub struct ProjectPluginOverrides {
74    /// Schema version.
75    #[serde(default = "default_version")]
76    pub version: u32,
77    /// Per-package, per-kind force map.
78    #[serde(default)]
79    pub forced: ForceMap,
80}
81
82fn default_version() -> u32 {
83    OVERRIDES_VERSION
84}
85
86impl ProjectPluginOverrides {
87    /// Empty overrides (everything follows `RuntimeConfig`/default).
88    pub fn new() -> Self {
89        Self {
90            version: OVERRIDES_VERSION,
91            forced: BTreeMap::new(),
92        }
93    }
94
95    /// Canonical path: `<project_dir>/.oxi/<OVERRIDES_FILE>`.
96    pub fn project_path(project_dir: &Path) -> PathBuf {
97        project_dir.join(".oxi").join(OVERRIDES_FILE)
98    }
99
100    /// Strict read — missing file is `Ok(Empty)`, corrupt file is an
101    /// error so Doctor can surface it (callers wanting best-effort use
102    /// `read_or_default`).
103    pub fn read(path: &Path) -> Result<Self> {
104        if !path.exists() {
105            return Ok(Self::new());
106        }
107        let content = fs::read_to_string(path)
108            .with_context(|| format!("Failed to read overrides at {}", path.display()))?;
109        let cfg: Self = serde_json::from_str(&content)
110            .with_context(|| format!("Failed to parse overrides at {}", path.display()))?;
111        Ok(cfg)
112    }
113
114    /// Lenient read: missing or corrupt -> empty overrides. Doctor is
115    /// the structured channel for surfacing corruption.
116    pub fn read_or_default(path: &Path) -> Self {
117        Self::read(path).unwrap_or_default()
118    }
119
120    /// Atomic write matching the `RuntimeConfig::write` policy.
121    pub fn write(&self, path: &Path) -> Result<()> {
122        if let Some(parent) = path.parent() {
123            fs::create_dir_all(parent)
124                .with_context(|| format!("Failed to create parent dir for {}", path.display()))?;
125        }
126        let content =
127            serde_json::to_string_pretty(self).context("Failed to serialize overrides")?;
128        let tmp = path.with_extension(format!(
129            "tmp.{}.{}",
130            std::process::id(),
131            uuid::Uuid::new_v4().simple()
132        ));
133        fs::write(&tmp, content)
134            .with_context(|| format!("Failed to write tmp overrides {}", tmp.display()))?;
135        match fs::rename(&tmp, path) {
136            Ok(()) => Ok(()),
137            Err(e) => {
138                let _ = fs::remove_file(&tmp);
139                Err(e).with_context(|| format!("Failed to rename overrides to {}", path.display()))
140            }
141        }
142    }
143
144    /// Number of (package, kind) override entries.
145    pub fn entry_count(&self) -> usize {
146        self.forced.values().map(|m| m.len()).sum()
147    }
148
149    /// True when no overrides are set.
150    pub fn is_empty(&self) -> bool {
151        self.forced.values().all(|m| m.is_empty())
152    }
153
154    /// The forced state for `(package, kind)`, if any.
155    pub fn forced_state(&self, package: &str, kind: ResourceKind) -> Option<ForceState> {
156        self.forced.get(package).and_then(|m| m.get(&kind)).copied()
157    }
158
159    /// Force a kind on under a package (project layer can rescue a
160    /// resource the user disabled globally).
161    pub fn force_on(&mut self, package: impl Into<String>, kind: ResourceKind) {
162        self.forced
163            .entry(package.into())
164            .or_default()
165            .insert(kind, ForceState::On);
166    }
167
168    /// Force a kind off under a package.
169    pub fn force_off(&mut self, package: impl Into<String>, kind: ResourceKind) {
170        self.forced
171            .entry(package.into())
172            .or_default()
173            .insert(kind, ForceState::Off);
174    }
175
176    /// Drop the override for `(package, kind)`. Keys with empty inner
177    /// maps are also removed so the persisted file stays minimal.
178    pub fn clear(&mut self, package: &str, kind: ResourceKind) {
179        if let Some(inner) = self.forced.get_mut(package) {
180            inner.remove(&kind);
181            if inner.is_empty() {
182                self.forced.remove(package);
183            }
184        }
185    }
186}
187
188/// Resolve the enabled flag for `(package, kind)` under the layered
189/// precedence: project-force > runtime-disable > default(true).
190///
191/// Pure, single-purpose helper used by `PackageManager::resolve_with_config`
192/// and the Doctor summary views. Pulled out so it's testable without
193/// touching the filesystem.
194pub fn resolve_enabled(
195    package: &str,
196    kind: ResourceKind,
197    overrides: Option<&ProjectPluginOverrides>,
198    runtime: Option<&RuntimeConfig>,
199) -> bool {
200    if let Some(o) = overrides
201        && let Some(state) = o.forced_state(package, kind)
202    {
203        return match state {
204            ForceState::On => true,
205            ForceState::Off => false,
206        };
207    }
208    if let Some(r) = runtime
209        && r.is_disabled(package, kind)
210    {
211        return false;
212    }
213    true
214}
215
216// Reference `RuntimeConfig` for callers using `resolve_enabled` from
217// sibling modules — keeps the import surface obvious without creating a
218// cycle.
219use super::runtime_config::RuntimeConfig;
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn defaults_are_enabled() {
227        let o = ProjectPluginOverrides::new();
228        assert!(o.is_empty());
229        assert_eq!(o.entry_count(), 0);
230    }
231
232    #[test]
233    fn force_on_and_force_off_track_separately() {
234        let mut o = ProjectPluginOverrides::new();
235        o.force_on("@foo/oxi-tools", ResourceKind::Extension);
236        o.force_off("@foo/oxi-tools", ResourceKind::Skill);
237        o.force_off("lodash", ResourceKind::Prompt);
238
239        assert_eq!(o.entry_count(), 3);
240        assert_eq!(
241            o.forced_state("@foo/oxi-tools", ResourceKind::Extension),
242            Some(ForceState::On)
243        );
244        assert_eq!(
245            o.forced_state("@foo/oxi-tools", ResourceKind::Skill),
246            Some(ForceState::Off)
247        );
248        assert_eq!(
249            o.forced_state("lodash", ResourceKind::Prompt),
250            Some(ForceState::Off)
251        );
252        assert_eq!(o.forced_state("lodash", ResourceKind::Theme), None);
253    }
254
255    #[test]
256    fn clear_drops_empty_outer_keys() {
257        let mut o = ProjectPluginOverrides::new();
258        o.force_on("pkg", ResourceKind::Skill);
259        o.clear("pkg", ResourceKind::Skill);
260        assert!(o.is_empty());
261        assert_eq!(o.entry_count(), 0);
262    }
263
264    #[test]
265    fn write_then_read_round_trips() {
266        let dir = tempfile::tempdir().unwrap();
267        let path = dir.path().join("plugin-overrides.json");
268        let mut o = ProjectPluginOverrides::new();
269        o.force_on("lodash", ResourceKind::Skill);
270        o.force_off("@scope/x", ResourceKind::Extension);
271        o.write(&path).unwrap();
272
273        let loaded = ProjectPluginOverrides::read(&path).unwrap();
274        assert_eq!(loaded.entry_count(), 2);
275        assert_eq!(
276            loaded.forced_state("lodash", ResourceKind::Skill),
277            Some(ForceState::On)
278        );
279        assert_eq!(
280            loaded.forced_state("@scope/x", ResourceKind::Extension),
281            Some(ForceState::Off)
282        );
283    }
284
285    #[test]
286    fn missing_file_reads_as_empty() {
287        let dir = tempfile::tempdir().unwrap();
288        let path = dir.path().join("nope.json");
289        let o = ProjectPluginOverrides::read(&path).unwrap();
290        assert!(o.is_empty());
291    }
292
293    #[test]
294    fn corrupt_file_reads_as_empty_via_lenient_helper() {
295        let dir = tempfile::tempdir().unwrap();
296        let path = dir.path().join("plugin-overrides.json");
297        fs::write(&path, b"{ not valid").unwrap();
298        assert!(ProjectPluginOverrides::read(&path).is_err());
299        let o = ProjectPluginOverrides::read_or_default(&path);
300        assert!(o.is_empty());
301    }
302
303    // ── Precedence tests ─────────────────────────────────────────────
304
305    fn build_layers() -> (ProjectPluginOverrides, RuntimeConfig) {
306        let mut o = ProjectPluginOverrides::new();
307        let mut r = RuntimeConfig::new();
308        // pkg-A: project forces Off on extension; user also disabled (project wins → off)
309        o.force_off("pkg-A", ResourceKind::Extension);
310        r.disable("pkg-A", ResourceKind::Extension);
311        // pkg-B: project forces On on skill; user disabled (project wins → on)
312        o.force_on("pkg-B", ResourceKind::Skill);
313        r.disable("pkg-B", ResourceKind::Skill);
314        // pkg-C: project has no entry; user disabled (only runtime wins → off)
315        r.disable("pkg-C", ResourceKind::Prompt);
316        // pkg-D: project forces Off on extension; user has no entry (project wins → off)
317        o.force_off("pkg-D", ResourceKind::Extension);
318        // pkg-E: project forces On; user has no entry (project wins → on)
319        o.force_on("pkg-E", ResourceKind::Theme);
320        // pkg-F: project && user say nothing (default on)
321        (o, r)
322    }
323
324    #[test]
325    fn precedence_project_force_off_overrides_user_disable() {
326        let (o, r) = build_layers();
327        // pkg-A: both layers disable; project-force off sticks.
328        assert!(!resolve_enabled(
329            "pkg-A",
330            ResourceKind::Extension,
331            Some(&o),
332            Some(&r),
333        ));
334    }
335
336    #[test]
337    fn precedence_project_force_on_overrides_user_disable() {
338        let (o, r) = build_layers();
339        // pkg-B: user disables, project forces on. Project wins — enabled.
340        assert!(resolve_enabled(
341            "pkg-B",
342            ResourceKind::Skill,
343            Some(&o),
344            Some(&r),
345        ));
346    }
347
348    #[test]
349    fn precedence_user_disable_holds_when_no_project_entry() {
350        let (o, r) = build_layers();
351        // pkg-C has no project entry; runtime disables prompt.
352        assert!(!resolve_enabled(
353            "pkg-C",
354            ResourceKind::Prompt,
355            Some(&o),
356            Some(&r),
357        ));
358    }
359
360    #[test]
361    fn precedence_project_force_off_without_user_entry() {
362        let (o, r) = build_layers();
363        assert!(!resolve_enabled(
364            "pkg-D",
365            ResourceKind::Extension,
366            Some(&o),
367            Some(&r),
368        ));
369    }
370
371    #[test]
372    fn precedence_project_force_on_without_user_entry() {
373        let (o, r) = build_layers();
374        assert!(resolve_enabled(
375            "pkg-E",
376            ResourceKind::Theme,
377            Some(&o),
378            Some(&r),
379        ));
380    }
381
382    #[test]
383    fn precedence_default_enabled_when_neither_layer_speaks() {
384        let (o, r) = build_layers();
385        assert!(resolve_enabled(
386            "pkg-F",
387            ResourceKind::Skill,
388            Some(&o),
389            Some(&r),
390        ));
391    }
392
393    #[test]
394    fn precedence_falls_back_to_runtime_when_no_overrides() {
395        let mut r = RuntimeConfig::new();
396        r.disable("only-user", ResourceKind::Theme);
397        assert!(!resolve_enabled(
398            "only-user",
399            ResourceKind::Theme,
400            None,
401            Some(&r),
402        ));
403        // Different (package, kind) pair, no override, no disable: default on.
404        assert!(resolve_enabled(
405            "only-user",
406            ResourceKind::Skill,
407            None,
408            Some(&r),
409        ));
410    }
411
412    #[test]
413    fn precedence_default_enabled_when_no_layers_at_all() {
414        assert!(resolve_enabled("any", ResourceKind::Skill, None, None));
415    }
416}