use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use theway_core::{Skill, SkillSource};
pub const OVERRIDES_FILE: &str = "skill-overrides.json";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SkillOverrideEntry {
pub name: String,
pub source: SkillSource,
pub enabled: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SkillOverrides {
#[serde(default)]
pub overrides: Vec<SkillOverrideEntry>,
}
impl SkillOverrides {
pub fn lookup(&self, name: &str, source: SkillSource) -> Option<&SkillOverrideEntry> {
self.overrides
.iter()
.find(|e| e.name == name && e.source == source)
}
pub fn set(&mut self, name: &str, source: SkillSource, enabled: bool) {
if let Some(e) = self
.overrides
.iter_mut()
.find(|e| e.name == name && e.source == source)
{
e.enabled = enabled;
} else {
self.overrides.push(SkillOverrideEntry {
name: name.to_string(),
source,
enabled,
});
}
}
pub fn remove(&mut self, name: &str, source: SkillSource) -> bool {
let before = self.overrides.len();
self.overrides
.retain(|e| !(e.name == name && e.source == source));
self.overrides.len() != before
}
}
pub fn state_path(base_dir: &Path) -> PathBuf {
base_dir.join(OVERRIDES_FILE)
}
pub async fn load(base_dir: &Path) -> SkillOverrides {
let path = state_path(base_dir);
match tokio::fs::read_to_string(&path).await {
Ok(s) => serde_json::from_str(&s).unwrap_or_else(|e| {
tracing::warn!(path = %path.display(), error = %e, "malformed skill-overrides.json; ignoring overlay");
SkillOverrides::default()
}),
Err(_) => SkillOverrides::default(),
}
}
pub async fn save(base_dir: &Path, state: &SkillOverrides) -> std::io::Result<()> {
tokio::fs::create_dir_all(base_dir).await?;
let json = serde_json::to_string_pretty(state)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
let tmp = base_dir.join(format!(
".{OVERRIDES_FILE}.{}.{}.tmp",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
tokio::fs::write(&tmp, json).await?;
if let Err(e) = tokio::fs::rename(&tmp, state_path(base_dir)).await {
let _ = tokio::fs::remove_file(&tmp).await;
return Err(e);
}
Ok(())
}
pub fn apply(state: &SkillOverrides, skills: &mut [Skill]) {
for skill in skills.iter_mut() {
if let Some(entry) = state.lookup(&skill.name, skill.source) {
skill.disable_model_invocation = !entry.enabled;
}
}
}
pub async fn set_and_save(
base_dir: &Path,
name: &str,
source: SkillSource,
enabled: bool,
) -> std::io::Result<SkillOverrides> {
let mut state = load(base_dir).await;
state.set(name, source, enabled);
save(base_dir, &state).await?;
Ok(state)
}
pub async fn remove_and_save(
base_dir: &Path,
name: &str,
source: SkillSource,
) -> std::io::Result<()> {
let mut state = load(base_dir).await;
if state.remove(name, source) {
save(base_dir, &state).await?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn skill(name: &str, source: SkillSource, frontmatter_disabled: bool) -> Skill {
Skill {
name: name.into(),
description: "d".into(),
file_path: format!("/tmp/{name}/SKILL.md"),
content: "body".into(),
disable_model_invocation: frontmatter_disabled,
source,
}
}
#[test]
fn apply_disables_matching_source_name() {
let mut state = SkillOverrides::default();
state.set("foo", SkillSource::User, false); let mut skills = vec![skill("foo", SkillSource::User, false)];
apply(&state, &mut skills);
assert!(
skills[0].disable_model_invocation,
"overlay disable applies"
);
}
#[test]
fn apply_enable_overrides_frontmatter_disable() {
let mut state = SkillOverrides::default();
state.set("foo", SkillSource::User, true); let mut skills = vec![skill("foo", SkillSource::User, true)]; apply(&state, &mut skills);
assert!(
!skills[0].disable_model_invocation,
"explicit enable overrides frontmatter disable"
);
}
#[test]
fn apply_is_source_aware() {
let mut state = SkillOverrides::default();
state.set("foo", SkillSource::User, false);
let mut skills = vec![
skill("foo", SkillSource::User, false),
skill("foo", SkillSource::Project, false),
];
apply(&state, &mut skills);
assert!(skills[0].disable_model_invocation, "user foo disabled");
assert!(
!skills[1].disable_model_invocation,
"project foo must not be affected by a user-scoped disable"
);
}
#[test]
fn no_override_keeps_frontmatter_value() {
let state = SkillOverrides::default();
let mut skills = vec![
skill("a", SkillSource::User, false),
skill("b", SkillSource::User, true),
];
apply(&state, &mut skills);
assert!(!skills[0].disable_model_invocation);
assert!(
skills[1].disable_model_invocation,
"frontmatter disable preserved"
);
}
#[test]
fn set_upserts_not_duplicates() {
let mut state = SkillOverrides::default();
state.set("foo", SkillSource::User, false);
state.set("foo", SkillSource::User, true);
assert_eq!(state.overrides.len(), 1, "same {{source,name}} upserts");
assert!(state.overrides[0].enabled);
}
#[test]
fn remove_drops_matching_entry_and_is_source_aware() {
let mut state = SkillOverrides::default();
state.set("foo", SkillSource::User, false);
state.set("foo", SkillSource::Project, false);
assert!(state.remove("foo", SkillSource::User));
assert!(state.lookup("foo", SkillSource::User).is_none());
assert!(state.lookup("foo", SkillSource::Project).is_some());
assert!(!state.remove("foo", SkillSource::User));
}
#[tokio::test]
async fn remove_and_save_clears_entry_on_disk() {
let dir = tempfile::tempdir().unwrap();
set_and_save(dir.path(), "foo", SkillSource::User, false)
.await
.unwrap();
remove_and_save(dir.path(), "foo", SkillSource::User)
.await
.unwrap();
let reloaded = load(dir.path()).await;
assert!(reloaded.lookup("foo", SkillSource::User).is_none());
}
#[tokio::test]
async fn save_then_load_round_trips() {
let dir = tempfile::tempdir().unwrap();
let mut state = SkillOverrides::default();
state.set("foo", SkillSource::Project, false);
state.set("bar", SkillSource::User, true);
save(dir.path(), &state).await.unwrap();
let loaded = load(dir.path()).await;
assert_eq!(loaded.overrides.len(), 2);
assert_eq!(
loaded
.lookup("foo", SkillSource::Project)
.map(|e| e.enabled),
Some(false)
);
assert_eq!(
loaded.lookup("bar", SkillSource::User).map(|e| e.enabled),
Some(true)
);
let mut entries = tokio::fs::read_dir(dir.path()).await.unwrap();
let mut names = Vec::new();
while let Some(e) = entries.next_entry().await.unwrap() {
names.push(e.file_name().into_string().unwrap_or_default());
}
assert_eq!(names, vec![OVERRIDES_FILE.to_string()]);
}
#[tokio::test]
async fn missing_file_is_empty_overlay() {
let dir = tempfile::tempdir().unwrap();
let loaded = load(dir.path()).await;
assert!(loaded.overrides.is_empty());
}
#[tokio::test]
async fn malformed_file_is_treated_as_empty() {
let dir = tempfile::tempdir().unwrap();
tokio::fs::write(state_path(dir.path()), "{ not valid json")
.await
.unwrap();
let loaded = load(dir.path()).await;
assert!(
loaded.overrides.is_empty(),
"malformed overlay must not break skill loading"
);
}
#[tokio::test]
async fn set_and_save_persists() {
let dir = tempfile::tempdir().unwrap();
let state = set_and_save(dir.path(), "foo", SkillSource::User, false)
.await
.unwrap();
assert_eq!(state.overrides.len(), 1);
let reloaded = load(dir.path()).await;
assert_eq!(
reloaded.lookup("foo", SkillSource::User).map(|e| e.enabled),
Some(false)
);
}
}