1use std::fs;
4use std::path::{Path, PathBuf};
5
6use crate::error::LoadError;
7use crate::skill::Skill;
8
9#[derive(Debug, Clone)]
31pub struct SkillDirectory {
32 path: PathBuf,
33 skill: Skill,
34}
35
36impl SkillDirectory {
37 pub fn load(path: impl AsRef<Path>) -> Result<Self, LoadError> {
61 let path = path.as_ref();
62 let path_str = path.display().to_string();
63
64 if !path.exists() {
66 return Err(LoadError::DirectoryNotFound { path: path_str });
67 }
68
69 if !path.is_dir() {
70 return Err(LoadError::DirectoryNotFound { path: path_str });
71 }
72
73 let skill_file = path.join("SKILL.md");
75 if !skill_file.exists() {
76 return Err(LoadError::SkillFileNotFound { path: path_str });
77 }
78
79 let content = fs::read_to_string(&skill_file).map_err(|e| LoadError::IoError {
80 path: skill_file.display().to_string(),
81 kind: e.kind(),
82 message: e.to_string(),
83 })?;
84
85 let skill = Skill::parse(&content)?;
87
88 let dir_name = path
90 .file_name()
91 .and_then(|n| n.to_str())
92 .unwrap_or_default();
93
94 if skill.name().as_str() != dir_name {
95 return Err(LoadError::NameMismatch {
96 directory_name: dir_name.to_string(),
97 skill_name: skill.name().as_str().to_string(),
98 });
99 }
100
101 Ok(Self {
102 path: path.to_path_buf(),
103 skill,
104 })
105 }
106
107 #[must_use]
109 pub fn path(&self) -> &Path {
110 &self.path
111 }
112
113 #[must_use]
115 pub const fn skill(&self) -> &Skill {
116 &self.skill
117 }
118
119 #[must_use]
121 pub fn has_scripts(&self) -> bool {
122 self.path.join("scripts").is_dir()
123 }
124
125 #[must_use]
127 pub fn has_references(&self) -> bool {
128 self.path.join("references").is_dir()
129 }
130
131 #[must_use]
133 pub fn has_assets(&self) -> bool {
134 self.path.join("assets").is_dir()
135 }
136
137 pub fn scripts(&self) -> Result<Vec<PathBuf>, LoadError> {
145 list_files_in_subdir(&self.path, "scripts")
146 }
147
148 pub fn references(&self) -> Result<Vec<PathBuf>, LoadError> {
156 list_files_in_subdir(&self.path, "references")
157 }
158
159 pub fn assets(&self) -> Result<Vec<PathBuf>, LoadError> {
167 list_files_in_subdir(&self.path, "assets")
168 }
169
170 pub fn read_reference(&self, name: &str) -> Result<String, LoadError> {
189 let file_path = self.path.join("references").join(name);
190 read_file_as_string(&file_path)
191 }
192
193 pub fn read_script(&self, name: &str) -> Result<String, LoadError> {
202 let file_path = self.path.join("scripts").join(name);
203 read_file_as_string(&file_path)
204 }
205
206 pub fn read_asset(&self, name: &str) -> Result<Vec<u8>, LoadError> {
225 let file_path = self.path.join("assets").join(name);
226 read_file_as_bytes(&file_path)
227 }
228
229 pub fn read_asset_string(&self, name: &str) -> Result<String, LoadError> {
236 let file_path = self.path.join("assets").join(name);
237 read_file_as_string(&file_path)
238 }
239}
240
241fn list_files_in_subdir(base_path: &Path, subdir: &str) -> Result<Vec<PathBuf>, LoadError> {
243 let dir_path = base_path.join(subdir);
244
245 if !dir_path.exists() {
246 return Ok(Vec::new());
247 }
248
249 let entries = fs::read_dir(&dir_path).map_err(|e| LoadError::IoError {
250 path: dir_path.display().to_string(),
251 kind: e.kind(),
252 message: e.to_string(),
253 })?;
254
255 let mut files = Vec::new();
256 for entry in entries {
257 let entry = entry.map_err(|e| LoadError::IoError {
258 path: dir_path.display().to_string(),
259 kind: e.kind(),
260 message: e.to_string(),
261 })?;
262 let path = entry.path();
263 if path.is_file() {
264 files.push(path);
265 }
266 }
267
268 files.sort();
270 Ok(files)
271}
272
273fn read_file_as_string(path: &Path) -> Result<String, LoadError> {
275 let path_str = path.display().to_string();
276
277 if !path.exists() {
278 return Err(LoadError::FileNotFound { path: path_str });
279 }
280
281 fs::read_to_string(path).map_err(|e| LoadError::IoError {
282 path: path_str,
283 kind: e.kind(),
284 message: e.to_string(),
285 })
286}
287
288fn read_file_as_bytes(path: &Path) -> Result<Vec<u8>, LoadError> {
290 let path_str = path.display().to_string();
291
292 if !path.exists() {
293 return Err(LoadError::FileNotFound { path: path_str });
294 }
295
296 fs::read(path).map_err(|e| LoadError::IoError {
297 path: path_str,
298 kind: e.kind(),
299 message: e.to_string(),
300 })
301}
302
303#[cfg(test)]
304#[allow(clippy::unwrap_used, clippy::expect_used)]
305mod tests {
306 use super::*;
307 use tempfile::TempDir;
308
309 fn create_skill_dir(temp: &TempDir, name: &str, content: &str) -> PathBuf {
310 let skill_dir = temp.path().join(name);
311 fs::create_dir(&skill_dir).unwrap();
312 fs::write(skill_dir.join("SKILL.md"), content).unwrap();
313 skill_dir
314 }
315
316 fn minimal_skill_content(name: &str) -> String {
317 format!(
318 r#"---
319name: {name}
320description: Test skill.
321---
322# Instructions
323"#
324 )
325 }
326
327 #[test]
328 fn loads_valid_skill_directory() {
329 let temp = TempDir::new().unwrap();
330 let skill_dir = create_skill_dir(&temp, "my-skill", &minimal_skill_content("my-skill"));
331
332 let result = SkillDirectory::load(&skill_dir);
333 assert!(result.is_ok(), "Expected Ok, got: {:?}", result);
334 let dir = result.unwrap();
335 assert_eq!(dir.skill().name().as_str(), "my-skill");
336 }
337
338 #[test]
339 fn rejects_nonexistent_directory() {
340 let result = SkillDirectory::load("/nonexistent/path");
341 assert!(matches!(result, Err(LoadError::DirectoryNotFound { .. })));
342 }
343
344 #[test]
345 fn rejects_missing_skill_file() {
346 let temp = TempDir::new().unwrap();
347 let skill_dir = temp.path().join("empty-skill");
348 fs::create_dir(&skill_dir).unwrap();
349
350 let result = SkillDirectory::load(&skill_dir);
351 assert!(matches!(result, Err(LoadError::SkillFileNotFound { .. })));
352 }
353
354 #[test]
355 fn detects_name_mismatch() {
356 let temp = TempDir::new().unwrap();
357 let skill_dir = create_skill_dir(&temp, "my-skill", &minimal_skill_content("other-name"));
358
359 let result = SkillDirectory::load(&skill_dir);
360 assert!(matches!(
361 result,
362 Err(LoadError::NameMismatch {
363 directory_name,
364 skill_name,
365 }) if directory_name == "my-skill" && skill_name == "other-name"
366 ));
367 }
368
369 #[test]
370 fn has_scripts_returns_false_without_scripts_dir() {
371 let temp = TempDir::new().unwrap();
372 let skill_dir = create_skill_dir(&temp, "my-skill", &minimal_skill_content("my-skill"));
373
374 let dir = SkillDirectory::load(&skill_dir).unwrap();
375 assert!(!dir.has_scripts());
376 }
377
378 #[test]
379 fn has_scripts_returns_true_with_scripts_dir() {
380 let temp = TempDir::new().unwrap();
381 let skill_dir = create_skill_dir(&temp, "my-skill", &minimal_skill_content("my-skill"));
382 fs::create_dir(skill_dir.join("scripts")).unwrap();
383
384 let dir = SkillDirectory::load(&skill_dir).unwrap();
385 assert!(dir.has_scripts());
386 }
387
388 #[test]
389 fn lists_scripts() {
390 let temp = TempDir::new().unwrap();
391 let skill_dir = create_skill_dir(&temp, "my-skill", &minimal_skill_content("my-skill"));
392 let scripts_dir = skill_dir.join("scripts");
393 fs::create_dir(&scripts_dir).unwrap();
394 fs::write(scripts_dir.join("run.sh"), "#!/bin/bash").unwrap();
395 fs::write(scripts_dir.join("build.py"), "# Python").unwrap();
396
397 let dir = SkillDirectory::load(&skill_dir).unwrap();
398 let scripts = dir.scripts().unwrap();
399 assert_eq!(scripts.len(), 2);
400 }
401
402 #[test]
403 fn scripts_returns_empty_without_scripts_dir() {
404 let temp = TempDir::new().unwrap();
405 let skill_dir = create_skill_dir(&temp, "my-skill", &minimal_skill_content("my-skill"));
406
407 let dir = SkillDirectory::load(&skill_dir).unwrap();
408 let scripts = dir.scripts().unwrap();
409 assert!(scripts.is_empty());
410 }
411
412 #[test]
413 fn has_references_works() {
414 let temp = TempDir::new().unwrap();
415 let skill_dir = create_skill_dir(&temp, "my-skill", &minimal_skill_content("my-skill"));
416
417 let dir = SkillDirectory::load(&skill_dir).unwrap();
418 assert!(!dir.has_references());
419
420 fs::create_dir(skill_dir.join("references")).unwrap();
421 let dir = SkillDirectory::load(&skill_dir).unwrap();
422 assert!(dir.has_references());
423 }
424
425 #[test]
426 fn has_assets_works() {
427 let temp = TempDir::new().unwrap();
428 let skill_dir = create_skill_dir(&temp, "my-skill", &minimal_skill_content("my-skill"));
429
430 let dir = SkillDirectory::load(&skill_dir).unwrap();
431 assert!(!dir.has_assets());
432
433 fs::create_dir(skill_dir.join("assets")).unwrap();
434 let dir = SkillDirectory::load(&skill_dir).unwrap();
435 assert!(dir.has_assets());
436 }
437
438 #[test]
439 fn reads_reference_file() {
440 let temp = TempDir::new().unwrap();
441 let skill_dir = create_skill_dir(&temp, "my-skill", &minimal_skill_content("my-skill"));
442 let refs_dir = skill_dir.join("references");
443 fs::create_dir(&refs_dir).unwrap();
444 fs::write(refs_dir.join("REFERENCE.md"), "# Reference\n\nContent").unwrap();
445
446 let dir = SkillDirectory::load(&skill_dir).unwrap();
447 let content = dir.read_reference("REFERENCE.md").unwrap();
448 assert!(content.contains("# Reference"));
449 }
450
451 #[test]
452 fn read_reference_returns_error_for_missing_file() {
453 let temp = TempDir::new().unwrap();
454 let skill_dir = create_skill_dir(&temp, "my-skill", &minimal_skill_content("my-skill"));
455
456 let dir = SkillDirectory::load(&skill_dir).unwrap();
457 let result = dir.read_reference("nonexistent.md");
458 assert!(matches!(result, Err(LoadError::FileNotFound { .. })));
459 }
460
461 #[test]
462 fn reads_asset_as_bytes() {
463 let temp = TempDir::new().unwrap();
464 let skill_dir = create_skill_dir(&temp, "my-skill", &minimal_skill_content("my-skill"));
465 let assets_dir = skill_dir.join("assets");
466 fs::create_dir(&assets_dir).unwrap();
467 fs::write(assets_dir.join("data.bin"), [0x00, 0x01, 0x02]).unwrap();
468
469 let dir = SkillDirectory::load(&skill_dir).unwrap();
470 let bytes = dir.read_asset("data.bin").unwrap();
471 assert_eq!(bytes, vec![0x00, 0x01, 0x02]);
472 }
473
474 #[test]
475 fn reads_asset_as_string() {
476 let temp = TempDir::new().unwrap();
477 let skill_dir = create_skill_dir(&temp, "my-skill", &minimal_skill_content("my-skill"));
478 let assets_dir = skill_dir.join("assets");
479 fs::create_dir(&assets_dir).unwrap();
480 fs::write(assets_dir.join("template.txt"), "Hello, World!").unwrap();
481
482 let dir = SkillDirectory::load(&skill_dir).unwrap();
483 let content = dir.read_asset_string("template.txt").unwrap();
484 assert_eq!(content, "Hello, World!");
485 }
486
487 #[test]
488 fn reads_script_file() {
489 let temp = TempDir::new().unwrap();
490 let skill_dir = create_skill_dir(&temp, "my-skill", &minimal_skill_content("my-skill"));
491 let scripts_dir = skill_dir.join("scripts");
492 fs::create_dir(&scripts_dir).unwrap();
493 fs::write(scripts_dir.join("run.sh"), "#!/bin/bash\necho hello").unwrap();
494
495 let dir = SkillDirectory::load(&skill_dir).unwrap();
496 let content = dir.read_script("run.sh").unwrap();
497 assert!(content.contains("#!/bin/bash"));
498 }
499
500 #[test]
501 fn path_returns_directory_path() {
502 let temp = TempDir::new().unwrap();
503 let skill_dir = create_skill_dir(&temp, "my-skill", &minimal_skill_content("my-skill"));
504
505 let dir = SkillDirectory::load(&skill_dir).unwrap();
506 assert_eq!(dir.path(), skill_dir);
507 }
508}