use crate::lua_skill::validate_luaskills_identifier;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
use std::io::Write;
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
#[cfg(windows)]
use windows_sys::Win32::Storage::FileSystem::ReplaceFileW;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SkillConfigEntry {
pub skill_id: String,
pub key: String,
pub value: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
struct SkillConfigDocument {
#[serde(default)]
skills: BTreeMap<String, BTreeMap<String, String>>,
}
#[derive(Debug)]
pub struct SkillConfigStore {
explicit_file_path: Option<PathBuf>,
default_runtime_root: Mutex<Option<PathBuf>>,
}
impl SkillConfigStore {
pub fn new(explicit_file_path: Option<PathBuf>) -> Result<Self, String> {
let explicit_file_path = explicit_file_path
.map(|path| resolve_explicit_skill_config_file_path(&path))
.transpose()?;
Ok(Self {
explicit_file_path,
default_runtime_root: Mutex::new(None),
})
}
pub fn has_explicit_file_path(&self) -> bool {
self.explicit_file_path.is_some()
}
pub fn set_default_runtime_root(&self, runtime_root: &Path) -> Result<(), String> {
let mut guard = self
.default_runtime_root
.lock()
.map_err(|_| "skill config runtime-root lock poisoned".to_string())?;
*guard = Some(runtime_root.to_path_buf());
Ok(())
}
pub fn file_path(&self) -> Result<PathBuf, String> {
if let Some(path) = self.explicit_file_path.as_ref() {
return Ok(path.clone());
}
let guard = self
.default_runtime_root
.lock()
.map_err(|_| "skill config runtime-root lock poisoned".to_string())?;
let runtime_root = guard.as_ref().ok_or_else(|| {
"skill config file path is unresolved; set host_options.skill_config_file_path or load at least one skill root first".to_string()
})?;
Ok(runtime_root.join("config").join("skill_config.json"))
}
pub fn list_entries(&self, skill_id: Option<&str>) -> Result<Vec<SkillConfigEntry>, String> {
let document = self.with_document_read(|document| Ok(document.clone()))?;
match skill_id {
Some(skill_id) => {
let normalized_skill_id = validate_skill_config_skill_id(skill_id)?;
Ok(document
.skills
.get(&normalized_skill_id)
.into_iter()
.flat_map(|items| {
items.iter().map(|(key, value)| SkillConfigEntry {
skill_id: normalized_skill_id.clone(),
key: key.clone(),
value: value.clone(),
})
})
.collect())
}
None => Ok(document
.skills
.iter()
.flat_map(|(skill_id, items)| {
items.iter().map(|(key, value)| SkillConfigEntry {
skill_id: skill_id.clone(),
key: key.clone(),
value: value.clone(),
})
})
.collect()),
}
}
pub fn list_skill_values(&self, skill_id: &str) -> Result<BTreeMap<String, String>, String> {
let normalized_skill_id = validate_skill_config_skill_id(skill_id)?;
let document = self.with_document_read(|document| Ok(document.clone()))?;
Ok(document
.skills
.get(&normalized_skill_id)
.cloned()
.unwrap_or_default())
}
pub fn get_value(&self, skill_id: &str, key: &str) -> Result<Option<String>, String> {
let normalized_skill_id = validate_skill_config_skill_id(skill_id)?;
let normalized_key = validate_skill_config_key(key)?;
self.with_document_read(|document| {
Ok(document
.skills
.get(&normalized_skill_id)
.and_then(|items| items.get(&normalized_key))
.cloned())
})
}
pub fn has_value(&self, skill_id: &str, key: &str) -> Result<bool, String> {
Ok(self.get_value(skill_id, key)?.is_some())
}
pub fn set_value(&self, skill_id: &str, key: &str, value: &str) -> Result<(), String> {
let normalized_skill_id = validate_skill_config_skill_id(skill_id)?;
let normalized_key = validate_skill_config_key(key)?;
self.with_document_mut(|document| {
document
.skills
.entry(normalized_skill_id)
.or_default()
.insert(normalized_key, value.to_string());
Ok(())
})
}
pub fn delete_value(&self, skill_id: &str, key: &str) -> Result<bool, String> {
let normalized_skill_id = validate_skill_config_skill_id(skill_id)?;
let normalized_key = validate_skill_config_key(key)?;
self.with_document_mut(|document| {
let deleted = document
.skills
.get_mut(&normalized_skill_id)
.and_then(|items| items.remove(&normalized_key))
.is_some();
if let Some(items) = document.skills.get(&normalized_skill_id) {
if items.is_empty() {
document.skills.remove(&normalized_skill_id);
}
}
Ok(deleted)
})
}
fn with_document_read<T, F>(&self, action: F) -> Result<T, String>
where
F: FnOnce(&SkillConfigDocument) -> Result<T, String>,
{
let file_path = self.file_path()?;
let path_lock = shared_skill_config_path_lock(&file_path)?;
let _path_guard = path_lock
.lock()
.map_err(|_| "skill config shared io lock poisoned".to_string())?;
let document = self.read_document_from(&file_path)?;
action(&document)
}
fn with_document_mut<T, F>(&self, action: F) -> Result<T, String>
where
F: FnOnce(&mut SkillConfigDocument) -> Result<T, String>,
{
let file_path = self.file_path()?;
let path_lock = shared_skill_config_path_lock(&file_path)?;
let _path_guard = path_lock
.lock()
.map_err(|_| "skill config shared io lock poisoned".to_string())?;
let mut document = self.read_document_from(&file_path)?;
let result = action(&mut document)?;
self.write_document_to(&file_path, &document)?;
Ok(result)
}
fn read_document_from(&self, file_path: &Path) -> Result<SkillConfigDocument, String> {
if !file_path.exists() {
return Ok(SkillConfigDocument::default());
}
let text = fs::read_to_string(&file_path).map_err(|error| {
format!(
"failed to read skill config file '{}': {}",
file_path.display(),
error
)
})?;
serde_json::from_str::<SkillConfigDocument>(&text).map_err(|error| {
format!(
"failed to parse skill config file '{}': {}",
file_path.display(),
error
)
})
}
fn write_document_to(
&self,
file_path: &Path,
document: &SkillConfigDocument,
) -> Result<(), String> {
let parent = file_path.parent().ok_or_else(|| {
format!(
"skill config file '{}' has no parent directory",
file_path.display()
)
})?;
fs::create_dir_all(parent).map_err(|error| {
format!(
"failed to create skill config directory '{}': {}",
parent.display(),
error
)
})?;
let serialized = serde_json::to_vec_pretty(document)
.map_err(|error| format!("failed to serialize skill config document: {}", error))?;
let temp_path = file_path.with_extension("json.tmp");
{
let mut file = fs::File::create(&temp_path).map_err(|error| {
format!(
"failed to create skill config temp file '{}': {}",
temp_path.display(),
error
)
})?;
file.write_all(&serialized).map_err(|error| {
format!(
"failed to write skill config temp file '{}': {}",
temp_path.display(),
error
)
})?;
file.flush().map_err(|error| {
format!(
"failed to flush skill config temp file '{}': {}",
temp_path.display(),
error
)
})?;
file.sync_all().map_err(|error| {
format!(
"failed to sync skill config temp file '{}': {}",
temp_path.display(),
error
)
})?;
}
replace_file_atomically(&temp_path, &file_path).map_err(|error| {
format!(
"failed to promote skill config temp file '{}' to '{}': {}",
temp_path.display(),
file_path.display(),
error
)
})
}
}
fn skill_config_lock_registry() -> &'static Mutex<BTreeMap<PathBuf, Arc<Mutex<()>>>> {
static REGISTRY: OnceLock<Mutex<BTreeMap<PathBuf, Arc<Mutex<()>>>>> = OnceLock::new();
REGISTRY.get_or_init(|| Mutex::new(BTreeMap::new()))
}
fn skill_config_lock_key(file_path: &Path) -> Result<PathBuf, String> {
let resolved_path = if file_path.is_absolute() {
file_path.to_path_buf()
} else {
std::env::current_dir()
.map(|cwd| cwd.join(file_path))
.map_err(|error| {
format!(
"failed to resolve current directory for skill config lock: {}",
error
)
})?
};
Ok(normalize_skill_config_lock_identity_path(
&normalize_skill_config_lock_path(&resolved_path),
))
}
fn resolve_explicit_skill_config_file_path(file_path: &Path) -> Result<PathBuf, String> {
let resolved_path = if file_path.is_absolute() {
file_path.to_path_buf()
} else {
std::env::current_dir()
.map(|cwd| cwd.join(file_path))
.map_err(|error| {
format!(
"failed to resolve current directory for explicit skill config path: {}",
error
)
})?
};
Ok(normalize_skill_config_lock_path(&resolved_path))
}
fn normalize_skill_config_lock_path(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
let mut can_pop_normal = false;
for component in path.components() {
match component {
Component::Prefix(prefix) => {
normalized.push(prefix.as_os_str());
can_pop_normal = false;
}
Component::RootDir => {
normalized.push(component.as_os_str());
can_pop_normal = false;
}
Component::CurDir => {}
Component::ParentDir => {
if can_pop_normal && normalized.pop() {
can_pop_normal = !matches!(
normalized.components().next_back(),
Some(Component::Prefix(_)) | Some(Component::RootDir) | None
);
} else if !path.is_absolute() {
normalized.push(component.as_os_str());
can_pop_normal = false;
}
}
Component::Normal(part) => {
normalized.push(part);
can_pop_normal = true;
}
}
}
normalized
}
fn normalize_skill_config_lock_identity_path(path: &Path) -> PathBuf {
#[cfg(windows)]
{
return normalize_windows_skill_config_lock_identity_path(path);
}
#[cfg(not(windows))]
{
path.to_path_buf()
}
}
#[cfg(windows)]
fn normalize_windows_skill_config_lock_identity_path(path: &Path) -> PathBuf {
let rendered = path.to_string_lossy();
let without_verbatim = if let Some(stripped) = rendered.strip_prefix(r"\\?\UNC\") {
format!(r"\\{}", stripped)
} else if let Some(stripped) = rendered.strip_prefix(r"\\?\") {
stripped.to_string()
} else {
rendered.into_owned()
};
PathBuf::from(without_verbatim.to_lowercase())
}
fn shared_skill_config_path_lock(file_path: &Path) -> Result<Arc<Mutex<()>>, String> {
let lock_key = skill_config_lock_key(file_path)?;
let mut registry = skill_config_lock_registry()
.lock()
.map_err(|_| "skill config lock registry poisoned".to_string())?;
Ok(registry
.entry(lock_key)
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone())
}
fn validate_skill_config_skill_id(skill_id: &str) -> Result<String, String> {
let normalized = skill_id.trim();
validate_luaskills_identifier(normalized, "skill_id")
.map(|_| normalized.to_string())
.map_err(|error| format!("invalid skill config skill_id: {}", error))
}
fn validate_skill_config_key(key: &str) -> Result<String, String> {
let normalized = key.trim();
if normalized.is_empty() {
return Err("skill config key must not be empty".to_string());
}
Ok(normalized.to_string())
}
fn replace_file_atomically(
temp_path: &Path,
destination_path: &Path,
) -> Result<(), std::io::Error> {
#[cfg(windows)]
{
use std::os::windows::ffi::OsStrExt;
if !destination_path.exists() {
return fs::rename(temp_path, destination_path);
}
let destination_wide: Vec<u16> = destination_path
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
let temp_wide: Vec<u16> = temp_path
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
let replaced = unsafe {
ReplaceFileW(
destination_wide.as_ptr(),
temp_wide.as_ptr(),
std::ptr::null(),
0,
std::ptr::null_mut(),
std::ptr::null_mut(),
)
};
if replaced == 0 {
return Err(std::io::Error::last_os_error());
}
Ok(())
}
#[cfg(not(windows))]
{
fs::rename(temp_path, destination_path)
}
}
#[cfg(test)]
mod tests {
use super::{SkillConfigEntry, SkillConfigStore, shared_skill_config_path_lock};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
fn unique_temp_runtime_root(label: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time before unix epoch")
.as_nanos();
std::env::temp_dir().join(format!("luaskills_skill_config_{}_{}", label, nonce))
}
#[test]
fn skill_config_store_resolves_default_path_from_runtime_root() {
let runtime_root = unique_temp_runtime_root("default_path");
let store = SkillConfigStore::new(None).expect("create default path store");
store
.set_default_runtime_root(&runtime_root)
.expect("set runtime root");
assert_eq!(
store.file_path().expect("resolve config path"),
runtime_root.join("config").join("skill_config.json")
);
}
#[test]
fn skill_config_store_updates_default_path_when_runtime_root_changes() {
let first_root = unique_temp_runtime_root("default_path_first");
let second_root = unique_temp_runtime_root("default_path_second");
let store = SkillConfigStore::new(None).expect("create update path store");
store
.set_default_runtime_root(&first_root)
.expect("set first runtime root");
store
.set_default_runtime_root(&second_root)
.expect("set second runtime root");
assert_eq!(
store.file_path().expect("resolve updated config path"),
second_root.join("config").join("skill_config.json")
);
}
#[test]
fn skill_config_store_persists_values_in_explicit_file() {
let runtime_root = unique_temp_runtime_root("persist");
let file_path = runtime_root.join("custom").join("skill_config.json");
let store = SkillConfigStore::new(Some(file_path.clone())).expect("create explicit store");
store
.set_value("demo-skill", "api_token", "sk-123")
.expect("set config value");
assert_eq!(
store
.get_value("demo-skill", "api_token")
.expect("get config value"),
Some("sk-123".to_string())
);
assert!(file_path.exists());
let reloaded =
SkillConfigStore::new(Some(file_path)).expect("create reloaded explicit store");
assert_eq!(
reloaded
.get_value("demo-skill", "api_token")
.expect("reload config value"),
Some("sk-123".to_string())
);
}
#[test]
fn skill_config_store_lists_flattened_entries() {
let runtime_root = unique_temp_runtime_root("list");
let file_path = runtime_root.join("custom").join("skill_config.json");
let store = SkillConfigStore::new(Some(file_path)).expect("create flattened-list store");
store
.set_value("alpha-skill", "api_token", "alpha-token")
.expect("set alpha token");
store
.set_value("beta-skill", "endpoint", "https://example.test")
.expect("set beta endpoint");
assert_eq!(
store.list_entries(None).expect("list entries"),
vec![
SkillConfigEntry {
skill_id: "alpha-skill".to_string(),
key: "api_token".to_string(),
value: "alpha-token".to_string(),
},
SkillConfigEntry {
skill_id: "beta-skill".to_string(),
key: "endpoint".to_string(),
value: "https://example.test".to_string(),
},
]
);
}
#[test]
fn skill_config_store_lists_one_skill_value_map() {
let runtime_root = unique_temp_runtime_root("skill_map");
let file_path = runtime_root.join("custom").join("skill_config.json");
let store = SkillConfigStore::new(Some(file_path)).expect("create skill-map store");
store
.set_value("demo-skill", "api_token", "sk-123")
.expect("set api token");
store
.set_value("demo-skill", "endpoint", "https://example.test")
.expect("set endpoint");
let mut expected = BTreeMap::new();
expected.insert("api_token".to_string(), "sk-123".to_string());
expected.insert("endpoint".to_string(), "https://example.test".to_string());
assert_eq!(
store
.list_skill_values("demo-skill")
.expect("list one skill values"),
expected
);
}
#[test]
fn skill_config_store_delete_prunes_empty_skill_namespace() {
let runtime_root = unique_temp_runtime_root("delete");
let file_path = runtime_root.join("custom").join("skill_config.json");
let store = SkillConfigStore::new(Some(file_path.clone())).expect("create delete store");
store
.set_value("demo-skill", "api_token", "sk-123")
.expect("set api token");
assert!(
store
.delete_value("demo-skill", "api_token")
.expect("delete api token")
);
assert_eq!(
store
.get_value("demo-skill", "api_token")
.expect("read deleted value"),
None
);
let persisted =
fs::read_to_string(file_path).expect("skill config file should still be readable");
assert_eq!(persisted.trim(), "{\n \"skills\": {}\n}");
}
#[test]
fn skill_config_store_uses_process_wide_lock_per_effective_path() {
let runtime_root = unique_temp_runtime_root("shared_lock");
let file_path = runtime_root.join("custom").join("skill_config.json");
let first_lock =
shared_skill_config_path_lock(&file_path).expect("resolve first shared lock");
let second_lock =
shared_skill_config_path_lock(&file_path).expect("resolve second shared lock");
assert!(Arc::ptr_eq(&first_lock, &second_lock));
}
#[test]
fn skill_config_store_freezes_relative_explicit_path_at_creation_time() {
let relative_path = PathBuf::from("config").join("skill_config.json");
let expected_path = std::env::current_dir()
.expect("resolve current directory")
.join(&relative_path);
let store = SkillConfigStore::new(Some(relative_path))
.expect("create relative explicit-path store");
assert_eq!(
store.file_path().expect("resolve frozen explicit path"),
expected_path
);
}
#[test]
fn skill_config_store_normalizes_equivalent_paths_for_shared_lock() {
let runtime_root = unique_temp_runtime_root("shared_lock_normalized");
let file_path = runtime_root.join("custom").join("skill_config.json");
let alias_path = runtime_root
.join("custom")
.join(".")
.join("child")
.join("..")
.join("skill_config.json");
let first_lock =
shared_skill_config_path_lock(&file_path).expect("resolve canonical shared lock");
let second_lock =
shared_skill_config_path_lock(&alias_path).expect("resolve alias shared lock");
assert!(Arc::ptr_eq(&first_lock, &second_lock));
}
#[cfg(windows)]
#[test]
fn skill_config_store_normalizes_windows_aliases_for_shared_lock() {
let runtime_root = unique_temp_runtime_root("shared_lock_windows_alias");
let canonical_path = runtime_root.join("custom").join("skill_config.json");
let canonical_text = canonical_path.to_string_lossy().into_owned();
let drive_letter = canonical_text
.chars()
.next()
.expect("canonical windows path should have a drive letter");
let alias_text = format!(
"{}{}",
drive_letter.to_ascii_lowercase(),
&canonical_text[drive_letter.len_utf8()..]
);
let verbatim_alias = format!(r"\\?\{}", alias_text);
let first_lock =
shared_skill_config_path_lock(&canonical_path).expect("resolve canonical shared lock");
let second_lock = shared_skill_config_path_lock(Path::new(&verbatim_alias))
.expect("resolve windows alias shared lock");
assert!(Arc::ptr_eq(&first_lock, &second_lock));
}
}