use std::path::{Path, PathBuf};
use crate::error::LorumError;
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct SkillManifest {
pub name: String,
pub description: String,
}
#[derive(Debug, Clone)]
pub struct SkillEntry {
pub manifest: SkillManifest,
pub content: String,
pub dir_path: PathBuf,
}
pub fn global_skills_dir() -> Result<PathBuf, LorumError> {
Ok(crate::config::resolve_config_dir()?
.join("lorum")
.join("skills"))
}
pub fn project_skills_dir(project_root: &Path) -> PathBuf {
project_root.join(".lorum").join("skills")
}
pub fn parse_skill_manifest(content: &str, path: &Path) -> Result<SkillManifest, LorumError> {
let rest = content
.strip_prefix("---")
.ok_or_else(|| LorumError::Other {
message: format!(
"{} must start with '---' frontmatter delimiter",
path.display()
),
})?;
let rest = rest.strip_prefix('\n').ok_or_else(|| LorumError::Other {
message: format!("{} frontmatter must start with '---\\n'", path.display()),
})?;
let end = rest.find("\n---").ok_or_else(|| LorumError::Other {
message: format!("{} frontmatter must be closed with '---'", path.display()),
})?;
let yaml_str = &rest[..end];
serde_yaml::from_str(yaml_str).map_err(|e| LorumError::ConfigParse {
format: "yaml".into(),
path: path.to_path_buf(),
source: Box::new(e),
})
}
pub fn scan_skills_dir(dir: &Path) -> Result<Vec<SkillEntry>, LorumError> {
if !dir.exists() {
return Ok(Vec::new());
}
let mut entries: Vec<SkillEntry> = Vec::new();
let read_dir = std::fs::read_dir(dir).map_err(|e| LorumError::Io { source: e })?;
for entry in read_dir {
let entry = entry.map_err(|e| LorumError::Io { source: e })?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let skill_md = path.join("SKILL.md");
if !skill_md.exists() {
continue;
}
let content = std::fs::read_to_string(&skill_md)?;
let manifest = parse_skill_manifest(&content, &skill_md)?;
entries.push(SkillEntry {
manifest,
content,
dir_path: path,
});
}
entries.sort_by(|a, b| a.manifest.name.cmp(&b.manifest.name));
Ok(entries)
}
pub fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), LorumError> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src).map_err(|e| LorumError::Io { source: e })? {
let entry = entry.map_err(|e| LorumError::Io { source: e })?;
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
let metadata =
std::fs::symlink_metadata(&src_path).map_err(|e| LorumError::Io { source: e })?;
if metadata.file_type().is_symlink() {
continue; }
if src_path.is_dir() {
copy_dir_recursive(&src_path, &dst_path)?;
} else {
std::fs::copy(&src_path, &dst_path)?;
let perms = metadata.permissions();
std::fs::set_permissions(&dst_path, perms).map_err(|e| LorumError::Io { source: e })?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use std::panic;
#[test]
#[serial]
fn global_skills_dir_uses_xdg_config_home() {
let tmp = tempfile::tempdir().unwrap();
let xdg = tmp.path().join("xdg_config");
unsafe {
std::env::set_var("XDG_CONFIG_HOME", &xdg);
}
let result = panic::catch_unwind(|| {
let dir = global_skills_dir().unwrap();
assert_eq!(dir, xdg.join("lorum").join("skills"));
});
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
result.unwrap();
}
#[test]
#[serial]
fn global_skills_dir_falls_back_to_home_dot_config() {
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
let result = panic::catch_unwind(|| {
let dir = global_skills_dir().unwrap();
let home = dirs::home_dir().expect("home dir");
assert_eq!(dir, home.join(".config").join("lorum").join("skills"));
});
result.unwrap();
}
#[test]
fn project_skills_dir_returns_lorum_skills_subdir() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let expected = root.join(".lorum").join("skills");
assert_eq!(project_skills_dir(root), expected);
}
#[test]
fn parse_valid_frontmatter() {
let content =
"---\nname: my-skill\ndescription: \"A test skill\"\n---\n# My Skill\nBody here.\n";
let manifest = parse_skill_manifest(content, Path::new("SKILL.md")).unwrap();
assert_eq!(manifest.name, "my-skill");
assert_eq!(manifest.description, "A test skill");
}
#[test]
fn parse_frontmatter_missing_open() {
let content = "name: my-skill\n---\nBody\n";
assert!(parse_skill_manifest(content, Path::new("SKILL.md")).is_err());
}
#[test]
fn parse_frontmatter_missing_close() {
let content = "---\nname: my-skill\nBody\n";
assert!(parse_skill_manifest(content, Path::new("SKILL.md")).is_err());
}
#[test]
fn parse_frontmatter_missing_newline_after_open() {
let content = "---name: my-skill\n---\nBody\n";
let result = parse_skill_manifest(content, Path::new("SKILL.md"));
assert!(result.is_err());
let err_msg = format!("{}", result.unwrap_err());
assert!(
err_msg.contains("frontmatter must start with '---\\n'"),
"error should mention missing newline: {}",
err_msg
);
}
#[test]
fn scan_empty_dir() {
let dir = tempfile::tempdir().unwrap();
let skills = scan_skills_dir(dir.path()).unwrap();
assert!(skills.is_empty());
}
#[test]
fn scan_dir_with_skills() {
let dir = tempfile::tempdir().unwrap();
let skill_dir = dir.path().join("my-skill");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: my-skill\ndescription: \"Test\"\n---\n# Body\n",
)
.unwrap();
let skills = scan_skills_dir(dir.path()).unwrap();
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].manifest.name, "my-skill");
assert_eq!(skills[0].dir_path, skill_dir);
}
#[test]
fn scan_dir_skips_non_skill_dirs() {
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("no-skill-md");
std::fs::create_dir_all(&sub).unwrap();
let skills = scan_skills_dir(dir.path()).unwrap();
assert!(skills.is_empty());
}
#[test]
fn scan_nonexistent_dir_returns_empty() {
let skills = scan_skills_dir(Path::new("/tmp/no-such-lorum-dir-xyz")).unwrap();
assert!(skills.is_empty());
}
#[test]
fn copy_dir_recursive_copies_all_files() {
let src = tempfile::tempdir().unwrap();
let dst = tempfile::tempdir().unwrap();
std::fs::write(src.path().join("SKILL.md"), "---\nname: t\n---\n").unwrap();
std::fs::create_dir_all(src.path().join("scripts")).unwrap();
std::fs::write(src.path().join("scripts/run.sh"), "#!/bin/sh\necho hi\n").unwrap();
let dst_skill = dst.path().join("t");
copy_dir_recursive(src.path(), &dst_skill).unwrap();
assert!(dst_skill.join("SKILL.md").exists());
assert!(dst_skill.join("scripts/run.sh").exists());
let content = std::fs::read_to_string(dst_skill.join("scripts/run.sh")).unwrap();
assert_eq!(content, "#!/bin/sh\necho hi\n");
}
#[test]
#[cfg(unix)]
fn copy_dir_recursive_skips_symlinks() {
use std::os::unix::fs::symlink;
let src = tempfile::tempdir().unwrap();
let dst = tempfile::tempdir().unwrap();
std::fs::write(src.path().join("SKILL.md"), "---\nname: t\n---\n").unwrap();
symlink(src.path(), src.path().join("loop")).unwrap();
let dst_skill = dst.path().join("t");
copy_dir_recursive(src.path(), &dst_skill).unwrap();
assert!(dst_skill.join("SKILL.md").exists());
assert!(!dst_skill.join("loop").exists());
}
}