use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use super::types::ResourceKind;
pub const RUNTIME_CONFIG_VERSION: u32 = 1;
pub const RUNTIME_CONFIG_FILE: &str = "runtime.json";
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RuntimeConfig {
#[serde(default = "default_version")]
pub version: u32,
#[serde(default)]
pub disabled: BTreeMap<String, HashSet<ResourceKind>>,
}
fn default_version() -> u32 {
RUNTIME_CONFIG_VERSION
}
impl RuntimeConfig {
pub fn new() -> Self {
Self {
version: RUNTIME_CONFIG_VERSION,
disabled: BTreeMap::new(),
}
}
pub fn global_path() -> Result<PathBuf> {
let base =
dirs::home_dir().context("Cannot determine home directory for RuntimeConfig path")?;
Ok(base.join(".oxi").join(RUNTIME_CONFIG_FILE))
}
pub fn read_or_default(path: &Path) -> Self {
match Self::read(path) {
Ok(cfg) => cfg,
Err(_) => Self::new(),
}
}
pub fn read(path: &Path) -> Result<Self> {
if !path.exists() {
return Ok(Self::new());
}
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read RuntimeConfig at {}", path.display()))?;
let cfg: Self = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse RuntimeConfig at {}", path.display()))?;
Ok(cfg)
}
pub fn write(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create parent dir for {}", path.display()))?;
}
let content =
serde_json::to_string_pretty(self).context("Failed to serialize RuntimeConfig")?;
let tmp = path.with_extension(format!(
"tmp.{}.{}",
std::process::id(),
uuid::Uuid::new_v4().simple()
));
fs::write(&tmp, content)
.with_context(|| format!("Failed to write temp RuntimeConfig {}", tmp.display()))?;
match fs::rename(&tmp, path) {
Ok(()) => Ok(()),
Err(e) => {
let _ = fs::remove_file(&tmp);
Err(e).with_context(|| {
format!("Failed to rename RuntimeConfig to {}", path.display())
})
}
}
}
pub fn is_empty(&self) -> bool {
self.disabled.values().all(|set| set.is_empty())
}
pub fn is_disabled(&self, package: &str, kind: ResourceKind) -> bool {
self.disabled
.get(package)
.is_some_and(|set| set.contains(&kind))
}
pub fn enabled_kinds(&self, package: &str) -> Vec<ResourceKind> {
let disabled = self.disabled.get(package);
ResourceKind::ALL
.iter()
.copied()
.filter(|k| !disabled.is_some_and(|set| set.contains(k)))
.collect()
}
pub fn disable(&mut self, package: impl Into<String>, kind: ResourceKind) {
let entry = self.disabled.entry(package.into()).or_default();
entry.insert(kind);
}
pub fn enable(&mut self, package: &str, kind: ResourceKind) {
if let Some(set) = self.disabled.get_mut(package) {
set.remove(&kind);
if set.is_empty() {
self.disabled.remove(package);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp_path(name: &str) -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(name);
(dir, path)
}
#[test]
fn defaults_to_all_enabled() {
let cfg = RuntimeConfig::new();
assert!(!cfg.is_disabled("lodash", ResourceKind::Skill));
assert_eq!(cfg.enabled_kinds("lodash").len(), ResourceKind::ALL.len());
assert!(cfg.is_empty());
}
#[test]
fn disable_then_enable_round_trips() {
let mut cfg = RuntimeConfig::new();
cfg.disable("lodash", ResourceKind::Skill);
cfg.disable("lodash", ResourceKind::Theme);
assert!(cfg.is_disabled("lodash", ResourceKind::Skill));
assert!(!cfg.is_disabled("lodash", ResourceKind::Extension));
cfg.enable("lodash", ResourceKind::Skill);
assert!(!cfg.is_disabled("lodash", ResourceKind::Skill));
assert!(cfg.is_disabled("lodash", ResourceKind::Theme));
assert!(!cfg.is_empty());
cfg.enable("lodash", ResourceKind::Theme);
assert!(cfg.is_empty(), "fully empty entry must drop the key");
}
#[test]
fn write_then_read_round_trips() {
let (_tmp, path) = tmp_path("runtime.json");
let mut cfg = RuntimeConfig::new();
cfg.disable("@foo/oxi-tools", ResourceKind::Extension);
cfg.write(&path).unwrap();
let loaded = RuntimeConfig::read(&path).unwrap();
assert!(loaded.is_disabled("@foo/oxi-tools", ResourceKind::Extension));
assert!(!loaded.is_disabled("@foo/oxi-tools", ResourceKind::Theme));
}
#[test]
fn missing_file_yields_empty_config() {
let (_tmp, path) = tmp_path("does-not-exist.json");
let cfg = RuntimeConfig::read(&path).unwrap();
assert!(cfg.is_empty());
}
#[test]
fn corrupt_file_returns_empty_via_read_or_default() {
let (_tmp, path) = tmp_path("runtime.json");
fs::write(&path, b"not valid json").unwrap();
assert!(RuntimeConfig::read(&path).is_err());
let cfg = RuntimeConfig::read_or_default(&path);
assert!(cfg.is_empty());
}
#[test]
fn write_is_atomic_via_temp_then_rename() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("runtime.json");
let cfg = RuntimeConfig::new();
cfg.write(&path).unwrap();
let stale: Vec<_> = fs::read_dir(dir.path())
.unwrap()
.flatten()
.filter(|e| {
e.file_name().to_string_lossy().contains("tmp.")
&& e.file_name().to_string_lossy() != "runtime.json"
})
.collect();
assert!(
stale.is_empty(),
"atomic write must leave no tmp. files behind; saw {:?}",
stale.iter().map(|e| e.path()).collect::<Vec<_>>()
);
}
}