use std::fs;
use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
use crate::error::{CliError, Result};
use crate::frontmatter::{self, Frontmatter};
#[derive(Debug, Clone)]
pub struct Skill {
pub name: String,
pub source_path: PathBuf,
pub frontmatter: Frontmatter,
pub body: String,
pub sha256: String,
}
impl Skill {
pub fn from_path(path: &Path) -> Result<Self> {
let bytes = fs::read(path)?;
let contents = String::from_utf8_lossy(&bytes).into_owned();
let (frontmatter, body) = frontmatter::parse(&contents);
let name = path
.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.ok_or_else(|| {
CliError::Validation(format!(
"cannot derive skill name from path: {}",
path.display()
))
})?
.to_string();
Ok(Self {
name,
source_path: path.to_path_buf(),
frontmatter,
body,
sha256: sha256_hex(&bytes),
})
}
pub fn discover_in(skills_home: &Path) -> Result<Vec<Self>> {
let mut out = Vec::new();
if !skills_home.exists() {
return Ok(out);
}
let entries = fs::read_dir(skills_home)?;
for entry in entries {
let entry = entry?;
let file_type = entry.file_type()?;
if !file_type.is_dir() {
continue;
}
let skill_md = entry.path().join("SKILL.md");
if skill_md.is_file() {
out.push(Self::from_path(&skill_md)?);
}
}
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(out)
}
}
fn sha256_hex(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
let mut out = String::with_capacity(digest.len() * 2);
for byte in digest.iter() {
use std::fmt::Write;
let _ = write!(out, "{byte:02x}");
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn tempdir(name: &str) -> PathBuf {
let base = std::env::temp_dir().join(format!("beck-skill-tests-{name}"));
let _ = fs::remove_dir_all(&base);
fs::create_dir_all(&base).unwrap();
base
}
fn write_skill(root: &Path, name: &str, contents: &str) -> PathBuf {
let dir = root.join(name);
fs::create_dir_all(&dir).unwrap();
let path = dir.join("SKILL.md");
fs::write(&path, contents).unwrap();
path
}
const SAMPLE: &str = "---\n\
name: caveman\n\
description: ultra compressed communication\n\
tags: [compression, style]\n\
---\n\
body goes here\n";
#[test]
fn from_path_parses_fixture() {
let dir = tempdir("from-path");
let path = write_skill(&dir, "caveman", SAMPLE);
let skill = Skill::from_path(&path).expect("load");
assert_eq!(skill.name, "caveman");
assert_eq!(skill.source_path, path);
assert_eq!(
skill.frontmatter.description.as_deref(),
Some("ultra compressed communication")
);
assert_eq!(skill.frontmatter.tags.as_ref().map(|v| v.len()), Some(2));
assert!(skill.body.contains("body goes here"));
assert_eq!(skill.sha256.len(), 64);
assert!(
skill
.sha256
.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
);
}
#[test]
fn from_path_tolerates_missing_frontmatter() {
let dir = tempdir("no-fm");
let path = write_skill(&dir, "plain", "just a body\n");
let skill = Skill::from_path(&path).expect("load");
assert_eq!(skill.name, "plain");
assert!(skill.frontmatter.description.is_none());
assert!(skill.frontmatter.tags.is_none());
assert_eq!(skill.body, "just a body\n");
}
#[test]
fn from_path_hashes_raw_bytes_deterministically() {
let dir = tempdir("hash");
let path_a = write_skill(&dir, "a", "same contents\n");
let path_b = write_skill(&dir, "b", "same contents\n");
let a = Skill::from_path(&path_a).unwrap();
let b = Skill::from_path(&path_b).unwrap();
assert_eq!(a.sha256, b.sha256);
let path_c = write_skill(&dir, "c", "different contents\n");
let c = Skill::from_path(&path_c).unwrap();
assert_ne!(a.sha256, c.sha256);
}
#[test]
fn from_path_errors_on_missing_file() {
let dir = tempdir("missing");
let path = dir.join("nope").join("SKILL.md");
let err = Skill::from_path(&path).expect_err("should fail");
assert!(matches!(err, CliError::Io(_)));
}
#[test]
fn discover_in_finds_top_level_skills() {
let dir = tempdir("discover");
write_skill(&dir, "alpha", "a\n");
write_skill(&dir, "beta", "b\n");
write_skill(&dir, "gamma", SAMPLE);
fs::write(dir.join("README.md"), b"not a skill").unwrap();
fs::create_dir_all(dir.join("empty-dir")).unwrap();
let skills = Skill::discover_in(&dir).unwrap();
assert_eq!(skills.len(), 3);
assert_eq!(skills[0].name, "alpha");
assert_eq!(skills[1].name, "beta");
assert_eq!(skills[2].name, "gamma");
}
#[test]
fn discover_in_handles_missing_home() {
let dir = tempdir("missing-home");
let nonexistent = dir.join("not-created");
let skills = Skill::discover_in(&nonexistent).unwrap();
assert!(skills.is_empty());
}
#[test]
fn sha256_hex_is_lowercase_and_64_chars() {
let out = sha256_hex(b"hello");
assert_eq!(out.len(), 64);
assert!(
out.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
);
assert_eq!(
out,
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
);
}
}