mod executor;
mod index;
mod index_loader;
mod processing;
mod skill_tool;
pub use executor::{ExecutionMode, SkillExecutionCallback, SkillExecutor};
pub use index::SkillIndex;
pub use index_loader::{SkillFrontmatter, SkillIndexLoader};
pub use skill_tool::SkillTool;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillResult {
pub success: bool,
pub output: String,
pub error: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed_tools: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_dir: Option<PathBuf>,
}
impl SkillResult {
pub fn success(output: impl Into<String>) -> Self {
Self {
success: true,
output: output.into(),
error: None,
allowed_tools: Vec::new(),
model: None,
base_dir: None,
}
}
pub fn error(message: impl Into<String>) -> Self {
Self {
success: false,
output: String::new(),
error: Some(message.into()),
allowed_tools: Vec::new(),
model: None,
base_dir: None,
}
}
pub fn allowed_tools(mut self, tools: Vec<String>) -> Self {
self.allowed_tools = tools;
self
}
pub fn model(mut self, model: Option<String>) -> Self {
self.model = model;
self
}
pub fn base_dir(mut self, dir: Option<PathBuf>) -> Self {
self.base_dir = dir;
self
}
pub fn has_tool_restrictions(&self) -> bool {
!self.allowed_tools.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::common::{ContentSource, SourceType, ToolRestricted};
#[test]
fn test_skill_definition() {
let skill = SkillIndex::new("commit", "Create a git commit")
.source(ContentSource::in_memory(
"Analyze changes and create commit message",
))
.source_type(SourceType::Builtin)
.triggers(["/commit"]);
assert_eq!(skill.name, "commit");
assert_eq!(skill.source_type, SourceType::Builtin);
assert!(skill.matches_triggers("/commit please"));
}
#[test]
fn test_skill_result() {
let success = SkillResult::success("Done!");
assert!(success.success);
assert!(success.error.is_none());
let error = SkillResult::error("Failed");
assert!(!error.success);
assert!(error.error.is_some());
}
#[test]
fn test_skill_allowed_tools() {
let skill = SkillIndex::new("reader", "Read files")
.source(ContentSource::in_memory("Read: $ARGUMENTS"))
.allowed_tools(["Read", "Grep", "Glob"]);
assert!(skill.has_tool_restrictions());
assert!(skill.is_tool_allowed("Read"));
assert!(skill.is_tool_allowed("Grep"));
assert!(!skill.is_tool_allowed("Bash"));
assert!(!skill.is_tool_allowed("Write"));
}
#[test]
fn test_skill_allowed_tools_pattern() {
let skill = SkillIndex::new("git-helper", "Git commands")
.source(ContentSource::in_memory("Git: $ARGUMENTS"))
.allowed_tools(["Bash(git:*)", "Read"]);
assert!(skill.is_tool_allowed("Bash")); assert!(skill.is_tool_allowed("Read"));
assert!(!skill.is_tool_allowed("Write"));
}
#[test]
fn test_skill_no_restrictions() {
let skill =
SkillIndex::new("any", "Any tools").source(ContentSource::in_memory("Do: $ARGUMENTS"));
assert!(!skill.has_tool_restrictions());
assert!(skill.is_tool_allowed("Bash"));
assert!(skill.is_tool_allowed("Read"));
assert!(skill.is_tool_allowed("Anything"));
}
#[test]
fn test_skill_model_override() {
let skill = SkillIndex::new("fast-task", "Quick task")
.source(ContentSource::in_memory("Do: $ARGUMENTS"))
.model("claude-haiku-4-5-20251001");
assert_eq!(skill.model, Some("claude-haiku-4-5-20251001".to_string()));
}
#[test]
fn test_skill_result_with_context() {
let result = SkillResult::success("Output")
.allowed_tools(vec!["Read".to_string(), "Grep".to_string()])
.model(Some("claude-haiku-4-5-20251001".to_string()));
assert!(result.has_tool_restrictions());
assert_eq!(result.allowed_tools, vec!["Read", "Grep"]);
assert_eq!(result.model, Some("claude-haiku-4-5-20251001".to_string()));
}
#[test]
fn test_skill_base_dir() {
let skill = SkillIndex::new("reviewer", "Review code")
.source(ContentSource::file(
"/home/user/.claude/skills/reviewer/skill.md",
))
.base_dir("/home/user/.claude/skills/reviewer");
assert_eq!(
skill.resolve_path("style-guide.md"),
Some(PathBuf::from(
"/home/user/.claude/skills/reviewer/style-guide.md"
))
);
}
#[tokio::test]
async fn test_content_with_resolved_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 skill = SkillIndex::new("test", "Test")
.source(ContentSource::in_memory(content))
.base_dir("/skills/test");
let resolved = skill.load_content_with_resolved_paths().await.unwrap();
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)"));
}
#[tokio::test]
async fn test_content_without_base_dir() {
let skill = SkillIndex::new("test", "Test")
.source(ContentSource::in_memory("See [file.md](file.md)"));
let resolved = skill.load_content_with_resolved_paths().await.unwrap();
assert_eq!(resolved, "See [file.md](file.md)");
}
}