use std::fmt;
use std::path::PathBuf;
#[derive(Debug, Clone)]
pub struct SkillInfo {
pub name: String,
pub description: String,
pub path: PathBuf,
pub tags: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct LoadedSkill {
pub info: SkillInfo,
pub content: String,
pub triggers: Vec<String>,
pub enabled_by_default: bool,
}
impl LoadedSkill {
#[must_use]
pub fn name(&self) -> &str {
&self.info.name
}
#[must_use]
pub fn description(&self) -> &str {
&self.info.description
}
#[must_use]
pub fn instructions(&self) -> &str {
&self.content
}
#[must_use]
pub fn has_triggers(&self) -> bool {
!self.triggers.is_empty()
}
#[must_use]
pub fn matches_trigger(&self, text: &str) -> bool {
let text_lower = text.to_lowercase();
self.triggers
.iter()
.any(|t| text_lower.contains(&t.to_lowercase()))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SkillsError {
LoadFailed {
path: PathBuf,
reason: String,
},
NotFound {
name: String,
},
InvalidFormat {
path: PathBuf,
reason: String,
},
PathNotFound {
path: PathBuf,
},
}
impl fmt::Display for SkillsError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::LoadFailed { path, reason } => {
write!(
f,
"failed to load skill from {}: {}",
path.display(),
reason
)
}
Self::NotFound { name } => {
write!(f, "skill '{}' not found", name)
}
Self::InvalidFormat { path, reason } => {
write!(f, "invalid skill format in {}: {}", path.display(), reason)
}
Self::PathNotFound { path } => {
write!(f, "skill path not found: {}", path.display())
}
}
}
}
impl std::error::Error for SkillsError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn skill_info_creation() {
let info = SkillInfo {
name: "test-skill".to_string(),
description: "A test skill".to_string(),
path: PathBuf::from("/path/to/skill.md"),
tags: vec!["test".to_string(), "example".to_string()],
};
assert_eq!(info.name, "test-skill");
assert_eq!(info.description, "A test skill");
assert_eq!(info.tags.len(), 2);
}
#[test]
fn loaded_skill_accessors() {
let skill = LoadedSkill {
info: SkillInfo {
name: "code-review".to_string(),
description: "Review code changes".to_string(),
path: PathBuf::from("/skills/code-review.md"),
tags: vec!["code".to_string()],
},
content: "# Code Review Instructions\n\nReview the code...".to_string(),
triggers: vec!["review".to_string(), "code review".to_string()],
enabled_by_default: true,
};
assert_eq!(skill.name(), "code-review");
assert_eq!(skill.description(), "Review code changes");
assert!(skill.instructions().contains("Code Review"));
assert!(skill.has_triggers());
assert!(skill.matches_trigger("please review this code"));
assert!(!skill.matches_trigger("unrelated text"));
}
#[test]
fn skill_without_triggers() {
let skill = LoadedSkill {
info: SkillInfo {
name: "basic".to_string(),
description: "Basic skill".to_string(),
path: PathBuf::from("/skills/basic.md"),
tags: vec![],
},
content: "Instructions".to_string(),
triggers: vec![],
enabled_by_default: false,
};
assert!(!skill.has_triggers());
assert!(!skill.matches_trigger("anything"));
}
#[test]
fn skills_error_display() {
let err = SkillsError::LoadFailed {
path: PathBuf::from("/path/to/skill.md"),
reason: "file not readable".to_string(),
};
assert!(err.to_string().contains("failed to load"));
assert!(err.to_string().contains("/path/to/skill.md"));
let err = SkillsError::NotFound {
name: "missing-skill".to_string(),
};
assert!(err.to_string().contains("not found"));
assert!(err.to_string().contains("missing-skill"));
let err = SkillsError::InvalidFormat {
path: PathBuf::from("/bad.md"),
reason: "missing frontmatter".to_string(),
};
assert!(err.to_string().contains("invalid skill format"));
let err = SkillsError::PathNotFound {
path: PathBuf::from("/nonexistent"),
};
assert!(err.to_string().contains("path not found"));
}
}