1use 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
35pub const OVERRIDES_VERSION: u32 = 1;
37
38pub const OVERRIDES_FILE: &str = "plugin-overrides.json";
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
46#[serde(rename_all = "snake_case")]
47pub enum ForceState {
48 On,
50 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
63pub type ForceMap = BTreeMap<String, BTreeMap<ResourceKind, ForceState>>;
66
67#[derive(Debug, Clone, Default, Serialize, Deserialize)]
73pub struct ProjectPluginOverrides {
74 #[serde(default = "default_version")]
76 pub version: u32,
77 #[serde(default)]
79 pub forced: ForceMap,
80}
81
82fn default_version() -> u32 {
83 OVERRIDES_VERSION
84}
85
86impl ProjectPluginOverrides {
87 pub fn new() -> Self {
89 Self {
90 version: OVERRIDES_VERSION,
91 forced: BTreeMap::new(),
92 }
93 }
94
95 pub fn project_path(project_dir: &Path) -> PathBuf {
97 project_dir.join(".oxi").join(OVERRIDES_FILE)
98 }
99
100 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 pub fn read_or_default(path: &Path) -> Self {
117 Self::read(path).unwrap_or_default()
118 }
119
120 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 pub fn entry_count(&self) -> usize {
146 self.forced.values().map(|m| m.len()).sum()
147 }
148
149 pub fn is_empty(&self) -> bool {
151 self.forced.values().all(|m| m.is_empty())
152 }
153
154 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 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 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 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
188pub 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
216use 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 fn build_layers() -> (ProjectPluginOverrides, RuntimeConfig) {
306 let mut o = ProjectPluginOverrides::new();
307 let mut r = RuntimeConfig::new();
308 o.force_off("pkg-A", ResourceKind::Extension);
310 r.disable("pkg-A", ResourceKind::Extension);
311 o.force_on("pkg-B", ResourceKind::Skill);
313 r.disable("pkg-B", ResourceKind::Skill);
314 r.disable("pkg-C", ResourceKind::Prompt);
316 o.force_off("pkg-D", ResourceKind::Extension);
318 o.force_on("pkg-E", ResourceKind::Theme);
320 (o, r)
322 }
323
324 #[test]
325 fn precedence_project_force_off_overrides_user_disable() {
326 let (o, r) = build_layers();
327 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 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 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 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}