use semver::Version;
use serde::Deserialize;
#[derive(Deserialize, Debug, Clone, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum SkillLanceDbLogLevel {
Off,
#[default]
Info,
Warning,
}
impl SkillLanceDbLogLevel {
pub fn as_str(&self) -> &'static str {
match self {
Self::Off => "off",
Self::Info => "info",
Self::Warning => "warning",
}
}
}
#[derive(Deserialize, Debug, Clone, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum SkillSqliteLogLevel {
Off,
#[default]
Info,
Warning,
}
impl SkillSqliteLogLevel {
pub fn as_str(&self) -> &'static str {
match self {
Self::Off => "off",
Self::Info => "info",
Self::Warning => "warning",
}
}
}
#[derive(Deserialize, Debug, Clone, Default)]
pub struct SkillLanceDbMeta {
#[serde(default)]
pub enable: bool,
#[serde(default)]
pub log_level: SkillLanceDbLogLevel,
#[serde(default)]
pub slow_log_enabled: bool,
#[serde(default = "default_lancedb_slow_log_threshold_ms")]
pub slow_log_threshold_ms: u64,
}
#[derive(Deserialize, Debug, Clone, Default)]
pub struct SkillSqliteMeta {
#[serde(default)]
pub enable: bool,
#[serde(default)]
pub log_level: SkillSqliteLogLevel,
#[serde(default)]
pub slow_log_enabled: bool,
#[serde(default = "default_sqlite_slow_log_threshold_ms")]
pub slow_log_threshold_ms: u64,
}
fn default_lancedb_slow_log_threshold_ms() -> u64 {
800
}
fn default_sqlite_slow_log_threshold_ms() -> u64 {
500
}
#[derive(Deserialize, Debug, Clone, Default)]
pub struct SkillHelpNodeMeta {
#[serde(default)]
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub file: String,
}
#[derive(Deserialize, Debug, Clone, Default)]
pub struct SkillHelpMeta {
#[serde(default)]
pub main: SkillHelpNodeMeta,
#[serde(default)]
pub topics: Vec<SkillHelpNodeMeta>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct SkillParam {
pub name: String,
#[serde(rename = "type")]
pub param_type: String,
pub description: String,
#[serde(default)]
pub required: bool,
}
#[derive(Deserialize, Debug, Clone)]
pub struct SkillToolMeta {
pub name: String,
#[serde(default)]
pub description: String,
pub lua_entry: String,
pub lua_module: String,
#[serde(default)]
pub parameters: Vec<SkillParam>,
#[serde(default)]
pub help: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct SkillMeta {
pub name: String,
pub version: String,
#[serde(default = "default_skill_enable_flag")]
pub enable: bool,
#[serde(default)]
pub debug: bool,
#[serde(default)]
pub lancedb: SkillLanceDbMeta,
#[serde(default)]
pub sqlite: SkillSqliteMeta,
#[serde(default)]
pub entries: Vec<SkillToolMeta>,
#[serde(default)]
pub help: SkillHelpMeta,
#[serde(skip)]
resolved_skill_id: String,
}
pub fn is_valid_luaskills_identifier(value: &str) -> bool {
let trimmed = value.trim();
if trimmed.is_empty() {
return false;
}
let mut chars = trimmed.chars();
let Some(first_char) = chars.next() else {
return false;
};
if !first_char.is_ascii_lowercase() {
return false;
}
if trimmed.ends_with('-') {
return false;
}
chars.all(|character| {
character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-'
})
}
pub fn validate_luaskills_identifier(value: &str, label: &str) -> Result<(), String> {
if is_valid_luaskills_identifier(value) {
return Ok(());
}
Err(format!("{label} must match ^[a-z]([a-z0-9-]*[a-z0-9])?$"))
}
pub fn validate_luaskills_version(value: &str, label: &str) -> Result<(), String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(format!("{label} must not be empty"));
}
Version::parse(trimmed)
.map(|_| ())
.map_err(|error| format!("{label} must be a valid semantic version: {}", error))
}
fn default_skill_enable_flag() -> bool {
true
}
impl SkillMeta {
pub fn effective_lancedb(&self) -> SkillLanceDbMeta {
self.lancedb.clone()
}
pub fn effective_sqlite(&self) -> SkillSqliteMeta {
self.sqlite.clone()
}
pub fn bind_directory_skill_id(&mut self, skill_id: String) {
self.resolved_skill_id = skill_id;
}
pub fn effective_skill_id(&self) -> &str {
self.resolved_skill_id.trim()
}
pub fn version(&self) -> &str {
self.version.trim()
}
pub fn is_enabled(&self) -> bool {
self.enable
}
pub fn entries(&self) -> impl Iterator<Item = &SkillToolMeta> {
self.entries.iter()
}
pub fn tool_base_name(&self, tool: &SkillToolMeta) -> String {
format!("{}-{}", self.effective_skill_id(), tool.name.trim())
}
pub fn find_tool_by_local_name(&self, tool_name: &str) -> Option<&SkillToolMeta> {
self.entries().find(|tool| tool.name.trim() == tool_name)
}
pub fn main_help(&self) -> &SkillHelpNodeMeta {
&self.help.main
}
pub fn help_topics(&self) -> impl Iterator<Item = &SkillHelpNodeMeta> {
self.help.topics.iter()
}
pub fn find_help_topic(&self, topic_name: &str) -> Option<&SkillHelpNodeMeta> {
self.help_topics()
.find(|topic| topic.name.trim() == topic_name)
}
pub fn entries_for_help_topic<'a>(
&'a self,
topic_name: &'a str,
) -> impl Iterator<Item = &'a SkillToolMeta> + 'a {
self.entries()
.filter(move |tool| tool.help.trim() == topic_name)
}
}
#[cfg(test)]
mod tests {
use super::{
is_valid_luaskills_identifier, validate_luaskills_identifier, validate_luaskills_version,
};
#[test]
fn valid_luaskills_identifiers_are_accepted() {
assert!(is_valid_luaskills_identifier("vulcan-codekit"));
assert!(is_valid_luaskills_identifier("codekit2"));
assert!(is_valid_luaskills_identifier("vulcan-runtime-tools"));
}
#[test]
fn invalid_luaskills_identifiers_are_rejected() {
for candidate in [
"",
"2codekit",
"Vulcan-codekit",
"vulcan_codekit",
"vulcan-codekit-",
"__demo",
] {
assert!(!is_valid_luaskills_identifier(candidate));
assert!(validate_luaskills_identifier(candidate, "skill_id").is_err());
}
}
#[test]
fn semantic_skill_versions_are_validated() {
assert!(validate_luaskills_version("0.1.0", "version").is_ok());
assert!(validate_luaskills_version("1.2.3-beta.1", "version").is_ok());
assert!(validate_luaskills_version("", "version").is_err());
assert!(validate_luaskills_version("v1.0.0", "version").is_err());
assert!(validate_luaskills_version("1", "version").is_err());
}
}