nika_engine/ast/
skill_def.rs1use serde::Deserialize;
37use std::path::{Path, PathBuf};
38
39use super::pkg_resolver::PkgUri;
40use crate::error::NikaError;
41
42pub fn resolve_skill_path(skill_path: &str, base_dir: &Path) -> Result<PathBuf, NikaError> {
69 if skill_path.starts_with("pkg:") {
70 let uri = PkgUri::parse(skill_path)?;
72 uri.resolve()
73 } else {
74 let path = Path::new(skill_path);
76 if path.is_absolute() {
77 Ok(path.to_path_buf())
78 } else {
79 Ok(base_dir.join(path))
80 }
81 }
82}
83
84pub fn is_pkg_uri(skill_path: &str) -> bool {
95 skill_path.starts_with("pkg:")
96}
97
98pub type SkillDef = String;
102
103#[derive(Debug, Clone, Deserialize, PartialEq)]
107#[serde(untagged)]
108pub enum SkillRef {
109 Single(String),
111
112 Multiple(Vec<String>),
114}
115
116impl SkillRef {
117 pub fn names(&self) -> Vec<&str> {
119 match self {
120 SkillRef::Single(name) => vec![name.as_str()],
121 SkillRef::Multiple(names) => names.iter().map(|s| s.as_str()).collect(),
122 }
123 }
124
125 pub fn contains(&self, skill_name: &str) -> bool {
127 match self {
128 SkillRef::Single(name) => name == skill_name,
129 SkillRef::Multiple(names) => names.iter().any(|n| n == skill_name),
130 }
131 }
132
133 pub fn len(&self) -> usize {
135 match self {
136 SkillRef::Single(_) => 1,
137 SkillRef::Multiple(names) => names.len(),
138 }
139 }
140
141 pub fn is_empty(&self) -> bool {
143 match self {
144 SkillRef::Single(_) => false,
145 SkillRef::Multiple(names) => names.is_empty(),
146 }
147 }
148}
149
150impl Default for SkillRef {
151 fn default() -> Self {
152 SkillRef::Multiple(Vec::new())
153 }
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159 use crate::serde_yaml;
160
161 #[test]
162 fn test_skill_def_is_string() {
163 let skill: SkillDef = "./skills/seo-writer.skill.md".to_string();
164 assert!(skill.ends_with(".skill.md"));
165 }
166
167 #[test]
168 fn test_skill_ref_single() {
169 let yaml = r#""seo""#;
170 let skill_ref: SkillRef = serde_yaml::from_str(yaml).unwrap();
171 assert!(matches!(skill_ref, SkillRef::Single(_)));
172 assert_eq!(skill_ref.names(), vec!["seo"]);
173 assert!(skill_ref.contains("seo"));
174 assert!(!skill_ref.contains("brand"));
175 assert_eq!(skill_ref.len(), 1);
176 assert!(!skill_ref.is_empty());
177 }
178
179 #[test]
180 fn test_skill_ref_multiple() {
181 let yaml = r#"["seo", "brand", "tone"]"#;
182 let skill_ref: SkillRef = serde_yaml::from_str(yaml).unwrap();
183 assert!(matches!(skill_ref, SkillRef::Multiple(_)));
184 assert_eq!(skill_ref.names(), vec!["seo", "brand", "tone"]);
185 assert!(skill_ref.contains("seo"));
186 assert!(skill_ref.contains("brand"));
187 assert!(!skill_ref.contains("unknown"));
188 assert_eq!(skill_ref.len(), 3);
189 assert!(!skill_ref.is_empty());
190 }
191
192 #[test]
193 fn test_skill_ref_empty_multiple() {
194 let yaml = r#"[]"#;
195 let skill_ref: SkillRef = serde_yaml::from_str(yaml).unwrap();
196 assert!(matches!(skill_ref, SkillRef::Multiple(_)));
197 assert!(skill_ref.names().is_empty());
198 assert_eq!(skill_ref.len(), 0);
199 assert!(skill_ref.is_empty());
200 }
201
202 #[test]
203 fn test_skill_ref_default() {
204 let skill_ref = SkillRef::default();
205 assert!(skill_ref.is_empty());
206 assert_eq!(skill_ref.len(), 0);
207 }
208
209 #[test]
210 fn test_skill_ref_in_context() {
211 #[derive(Debug, Deserialize)]
213 struct TestTask {
214 skill: Option<SkillRef>,
215 skills: Option<SkillRef>,
216 }
217
218 let yaml = r#"
220skill: seo
221"#;
222 let task: TestTask = serde_yaml::from_str(yaml).unwrap();
223 assert!(task.skill.is_some());
224 assert!(task.skill.as_ref().unwrap().contains("seo"));
225
226 let yaml = r#"
228skills: [seo, brand]
229"#;
230 let task: TestTask = serde_yaml::from_str(yaml).unwrap();
231 assert!(task.skills.is_some());
232 assert_eq!(task.skills.as_ref().unwrap().len(), 2);
233 }
234
235 #[test]
236 fn test_is_pkg_uri_with_pkg_prefix() {
237 assert!(is_pkg_uri("pkg:@supernovae/skills@1.0.0/rust.md"));
238 assert!(is_pkg_uri("pkg:skills/seo.md"));
239 assert!(is_pkg_uri("pkg:@scope/name/path.md"));
240 }
241
242 #[test]
243 fn test_is_pkg_uri_with_local_path() {
244 assert!(!is_pkg_uri("./skills/local.skill.md"));
245 assert!(!is_pkg_uri("../skills/up.skill.md"));
246 assert!(!is_pkg_uri("/absolute/path/skill.md"));
247 assert!(!is_pkg_uri("relative/skill.md"));
248 }
249
250 #[test]
251 fn test_resolve_skill_path_local_relative() {
252 let base_dir = Path::new("/project");
253 let result = resolve_skill_path("./skills/seo.skill.md", base_dir).unwrap();
254 assert_eq!(result, PathBuf::from("/project/./skills/seo.skill.md"));
255 }
256
257 #[test]
258 fn test_resolve_skill_path_local_absolute() {
259 let base_dir = Path::new("/project");
260 let result = resolve_skill_path("/absolute/path/skill.md", base_dir).unwrap();
261 assert_eq!(result, PathBuf::from("/absolute/path/skill.md"));
262 }
263
264 #[test]
265 fn test_resolve_skill_path_pkg_uri() {
266 let base_dir = Path::new("/project");
267 let result = resolve_skill_path("pkg:@supernovae/skills@1.0.0/rust.md", base_dir).unwrap();
268 let expected = dirs::home_dir()
269 .unwrap()
270 .join(".nika/packages/@supernovae/skills/1.0.0/rust.md");
271 assert_eq!(result, expected);
272 }
273
274 #[test]
275 fn test_resolve_skill_path_pkg_uri_no_version() {
276 let base_dir = Path::new("/project");
277 let result = resolve_skill_path("pkg:@supernovae/skills/rust.md", base_dir).unwrap();
278 let expected = dirs::home_dir()
279 .unwrap()
280 .join(".nika/packages/@supernovae/skills/latest/rust.md");
281 assert_eq!(result, expected);
282 }
283
284 #[test]
285 fn test_resolve_skill_path_pkg_uri_no_scope() {
286 let base_dir = Path::new("/project");
287 let result = resolve_skill_path("pkg:skills/seo.md", base_dir).unwrap();
288 let expected = dirs::home_dir()
289 .unwrap()
290 .join(".nika/packages/@default/skills/latest/seo.md");
291 assert_eq!(result, expected);
292 }
293
294 #[test]
295 fn test_resolve_skill_path_invalid_pkg_uri() {
296 let base_dir = Path::new("/project");
297 let result = resolve_skill_path("pkg:", base_dir);
298 assert!(result.is_err());
299 }
300}