use std::collections::HashMap;
use std::path::PathBuf;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::common::{ContentSource, Index, Named, SourceType, ToolRestricted};
use crate::hooks::HookRule;
use super::processing;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SkillIndex {
pub name: String,
pub description: String,
#[serde(default)]
pub triggers: Vec<String>,
#[serde(default)]
pub allowed_tools: Vec<String>,
pub source: ContentSource,
#[serde(default)]
pub source_type: SourceType,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub argument_hint: Option<String>,
#[serde(default)]
pub disable_model_invocation: bool,
#[serde(default = "default_true")]
pub user_invocable: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hooks: Option<HashMap<String, Vec<HookRule>>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
base_dir_override: Option<PathBuf>,
}
use crate::common::serde_defaults::default_true;
impl SkillIndex {
pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
Self {
name: name.into(),
description: description.into(),
triggers: Vec::new(),
allowed_tools: Vec::new(),
source: ContentSource::default(),
source_type: SourceType::default(),
model: None,
argument_hint: None,
disable_model_invocation: false,
user_invocable: true,
context: None,
agent: None,
hooks: None,
base_dir_override: None,
}
}
pub fn base_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.base_dir_override = Some(dir.into());
self
}
pub fn triggers(mut self, triggers: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.triggers = triggers.into_iter().map(Into::into).collect();
self
}
pub fn allowed_tools(mut self, tools: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.allowed_tools = tools.into_iter().map(Into::into).collect();
self
}
pub fn source(mut self, source: ContentSource) -> Self {
self.source = source;
self
}
pub fn source_type(mut self, source_type: SourceType) -> Self {
self.source_type = source_type;
self
}
pub fn model(mut self, model: impl Into<String>) -> Self {
self.model = Some(model.into());
self
}
pub fn argument_hint(mut self, hint: impl Into<String>) -> Self {
self.argument_hint = Some(hint.into());
self
}
pub fn matches_triggers(&self, input: &str) -> bool {
let input_lower = input.to_lowercase();
self.triggers
.iter()
.any(|trigger| input_lower.contains(&trigger.to_lowercase()))
}
pub fn matches_command(&self, input: &str) -> bool {
if let Some(cmd) = input.strip_prefix('/') {
let cmd_lower = cmd.split_whitespace().next().unwrap_or("").to_lowercase();
self.name.to_lowercase() == cmd_lower
} else {
false
}
}
pub fn get_base_dir(&self) -> Option<PathBuf> {
self.base_dir_override
.clone()
.or_else(|| self.source.base_dir())
}
pub fn resolve_path(&self, relative: &str) -> Option<PathBuf> {
self.get_base_dir().map(|base| base.join(relative))
}
pub async fn load_content_with_resolved_paths(&self) -> crate::Result<String> {
let content = self.load_content().await?;
if let Some(base_dir) = self.get_base_dir() {
Ok(processing::resolve_markdown_paths(&content, &base_dir))
} else {
Ok(content)
}
}
pub fn substitute_args(content: &str, args: Option<&str>) -> String {
processing::substitute_args(content, args.unwrap_or(""))
}
pub async fn execute(&self, arguments: &str, content: &str) -> String {
let base_dir = self
.get_base_dir()
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
let content = processing::strip_frontmatter(content);
let content = processing::process_bash_backticks(content, &base_dir).await;
let content = processing::process_file_references(&content, &base_dir).await;
let content = processing::resolve_markdown_paths(&content, &base_dir);
processing::substitute_args(&content, arguments)
}
}
impl Named for SkillIndex {
fn name(&self) -> &str {
&self.name
}
}
impl ToolRestricted for SkillIndex {
fn allowed_tools(&self) -> &[String] {
&self.allowed_tools
}
}
#[async_trait]
impl Index for SkillIndex {
fn source(&self) -> &ContentSource {
&self.source
}
fn source_type(&self) -> SourceType {
self.source_type
}
fn to_summary_line(&self) -> String {
let tools_str = if self.allowed_tools.is_empty() {
String::new()
} else {
format!(" [tools: {}]", self.allowed_tools.join(", "))
};
format!("- {}: {}{}", self.name, self.description, tools_str)
}
fn description(&self) -> &str {
&self.description
}
}
#[cfg(test)]
mod tests {
use super::processing;
use super::*;
#[test]
fn test_skill_index_creation() {
let skill = SkillIndex::new("commit", "Create a git commit with conventional format")
.triggers(["git commit", "commit changes"])
.source_type(SourceType::User);
assert_eq!(skill.name, "commit");
assert!(skill.matches_triggers("I want to git commit these changes"));
assert!(!skill.matches_triggers("deploy the application"));
}
#[test]
fn test_command_matching() {
let skill = SkillIndex::new("commit", "Create a git commit");
assert!(skill.matches_command("/commit"));
assert!(skill.matches_command("/commit -m 'message'"));
assert!(!skill.matches_command("/other"));
assert!(!skill.matches_command("commit"));
}
#[test]
fn test_summary_line() {
let skill = SkillIndex::new("test", "A test skill").source_type(SourceType::Project);
let summary = skill.to_summary_line();
assert!(summary.contains("test"));
assert!(summary.contains("A test skill"));
}
#[test]
fn test_summary_line_with_tools() {
let skill = SkillIndex::new("reader", "Read files only").allowed_tools(["Read", "Grep"]);
let summary = skill.to_summary_line();
assert!(summary.contains("[tools: Read, Grep]"));
}
#[test]
fn test_substitute_args() {
let content = "Do something with $ARGUMENTS and ${ARGUMENTS}";
let result = SkillIndex::substitute_args(content, Some("test args"));
assert_eq!(result, "Do something with test args and test args");
}
#[tokio::test]
async fn test_load_content() {
let skill = SkillIndex::new("test", "Test skill")
.source(ContentSource::in_memory("Full skill content here"));
let content = skill.load_content().await.unwrap();
assert_eq!(content, "Full skill content here");
}
#[test]
fn test_priority() {
let builtin = SkillIndex::new("a", "").source_type(SourceType::Builtin);
let user = SkillIndex::new("b", "").source_type(SourceType::User);
let project = SkillIndex::new("c", "").source_type(SourceType::Project);
assert!(project.priority() > user.priority());
assert!(user.priority() > builtin.priority());
}
#[test]
fn test_resolve_markdown_paths() {
let content = r#"# Review Process
Check [style-guide.md](style-guide.md) for standards.
Also see [docs/api.md](docs/api.md).
External: [Rust Docs](https://doc.rust-lang.org)
Absolute: [config](/etc/config.md)"#;
let resolved =
processing::resolve_markdown_paths(content, std::path::Path::new("/skills/test"));
assert!(resolved.contains("[style-guide.md](/skills/test/style-guide.md)"));
assert!(resolved.contains("[docs/api.md](/skills/test/docs/api.md)"));
assert!(resolved.contains("[Rust Docs](https://doc.rust-lang.org)"));
assert!(resolved.contains("[config](/etc/config.md)"));
}
#[test]
fn test_substitute_args_positional() {
let content = "File: $1, Action: $2, All: $ARGUMENTS";
let result = SkillIndex::substitute_args(content, Some("main.rs build"));
assert_eq!(result, "File: main.rs, Action: build, All: main.rs build");
}
#[tokio::test]
async fn test_execute() {
let skill = SkillIndex::new("test", "Test skill")
.source(ContentSource::in_memory("Process: $ARGUMENTS"));
let content = skill.load_content().await.unwrap();
let result = skill.execute("my argument", &content).await;
assert_eq!(result.trim(), "Process: my argument");
}
}