#[derive(Debug, Clone, PartialEq)]
pub struct SkillSpec {
pub name: String,
pub description: String,
pub content: String,
pub tags: Vec<String>,
pub related: Vec<String>,
pub body: String,
}
const MAX_NAME_LEN: usize = 256;
const MAX_CONTENT_LEN: usize = 100_000;
pub fn parse_skill_spec(content: &str, dir_name: &str) -> Option<SkillSpec> {
let (frontmatter, body) = split_frontmatter(content)?;
let body = body.trim().to_string();
if body.is_empty() {
return None;
}
let yaml = parse_yaml_frontmatter(&frontmatter)?;
let name = yaml_scalar(&yaml, "name")
.filter(|n| !n.is_empty())
.unwrap_or_else(|| dir_name.to_string());
let description = yaml_scalar(&yaml, "description")
.map(|s| s.trim_end().to_string())
.unwrap_or_default();
let tags = yaml_list(&yaml["tags"])
.or_else(|| yaml_list(&yaml["metadata"]["dirge"]["tags"]))
.unwrap_or_default();
let related = yaml_list(&yaml["related_skills"])
.or_else(|| yaml_list(&yaml["metadata"]["dirge"]["related_skills"]))
.unwrap_or_default();
Some(SkillSpec {
name,
description,
content: content.to_string(),
tags,
related,
body,
})
}
pub fn validate_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("Skill name must not be empty".to_string());
}
if name.len() > MAX_NAME_LEN {
return Err(format!(
"Skill name too long ({} bytes, max {})",
name.len(),
MAX_NAME_LEN
));
}
if name.starts_with('.') {
return Err("Skill name must not start with '.'".to_string());
}
for c in name.chars() {
if c == '/' || c == '\\' {
return Err("Skill name must not contain path separators".to_string());
}
if c.is_control() {
return Err("Skill name must not contain control characters".to_string());
}
}
Ok(())
}
pub fn validate_content_size(content: &str) -> Result<(), String> {
if content.len() > MAX_CONTENT_LEN {
return Err(format!(
"Skill content too large ({} chars, max {})",
content.len(),
MAX_CONTENT_LEN
));
}
Ok(())
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn build_frontmatter(name: &str, description: &str, tags: &[String]) -> String {
let mut fm = String::from("---\n");
fm.push_str(&format!("name: {}\n", name));
if !description.is_empty() {
fm.push_str(&format!("description: {}\n", description));
}
if !tags.is_empty() {
fm.push_str("metadata:\n");
fm.push_str(" dirge:\n");
fm.push_str(" tags: [");
fm.push_str(
&tags
.iter()
.map(|t| t.as_str())
.collect::<Vec<_>>()
.join(", "),
);
fm.push_str("]\n");
}
fm.push_str("---\n\n");
fm
}
fn split_frontmatter(content: &str) -> Option<(String, String)> {
let content = content
.strip_prefix("---\n")
.or_else(|| content.strip_prefix("---\r\n"))?;
let (fm, body) = if let Some(pos) = content.find("\n---") {
let (a, b) = content.split_at(pos);
(a.to_string(), b[4..].to_string())
} else if let Some(pos) = content.find("\r\n---") {
let (a, b) = content.split_at(pos);
(a.to_string(), b[5..].to_string())
} else {
return None;
};
Some((fm, body))
}
use yaml_rust2::{Yaml, YamlLoader};
fn parse_yaml_frontmatter(frontmatter: &str) -> Option<Yaml> {
let mut docs = YamlLoader::load_from_str(frontmatter).ok()?;
if docs.is_empty() {
return Some(Yaml::Hash(Default::default()));
}
Some(docs.remove(0))
}
fn yaml_scalar(yaml: &Yaml, key: &str) -> Option<String> {
yaml[key].as_str().map(|s| s.to_string())
}
fn yaml_list(node: &Yaml) -> Option<Vec<String>> {
match node {
Yaml::BadValue | Yaml::Null => None,
Yaml::Array(items) => Some(
items
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect(),
),
Yaml::String(s) if !s.is_empty() => Some(vec![s.clone()]),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_name_passes() {
assert!(validate_name("project-build").is_ok());
assert!(validate_name("rust-best-practices").is_ok());
assert!(validate_name("a").is_ok());
}
#[test]
fn empty_name_rejected() {
assert!(validate_name("").is_err());
}
#[test]
fn uppercase_name_accepted() {
assert!(validate_name("Project-Build").is_ok());
}
#[test]
fn underscore_and_space_accepted() {
assert!(validate_name("project_build").is_ok());
assert!(validate_name("project build").is_ok());
}
#[test]
fn leading_trailing_hyphen_accepted() {
assert!(validate_name("-project").is_ok());
assert!(validate_name("project-").is_ok());
}
#[test]
fn too_long_name_rejected() {
let long = "a".repeat(MAX_NAME_LEN + 1);
assert!(validate_name(&long).is_err());
}
#[test]
fn skill_name_accepts_unicode() {
assert!(validate_name("日本語スキル").is_ok());
assert!(validate_name("café-skill").is_ok());
}
#[test]
fn skill_name_accepts_dots_after_first_char() {
assert!(validate_name("skill.v2").is_ok());
assert!(validate_name("a.b.c").is_ok());
}
#[test]
fn skill_name_rejects_path_separator() {
assert!(validate_name("foo/bar").is_err());
assert!(validate_name("foo\\bar").is_err());
}
#[test]
fn skill_name_rejects_control_chars() {
assert!(validate_name("foo\x01bar").is_err());
assert!(validate_name("foo\0bar").is_err());
assert!(validate_name("foo\nbar").is_err());
}
#[test]
fn skill_name_rejects_leading_dot() {
assert!(validate_name(".hidden").is_err());
assert!(validate_name(".").is_err());
}
#[test]
fn parse_valid_skill() {
let content = "---\nname: project-build\ndescription: Build commands\n---\n\nRun `cargo build` to compile.\n";
let spec = parse_skill_spec(content, "fallback").unwrap();
assert_eq!(spec.name, "project-build");
assert_eq!(spec.description, "Build commands");
assert!(spec.body.contains("cargo build"));
}
#[test]
fn parse_falls_back_to_dir_name() {
let content = "---\ndescription: no name field\n---\n\nbody here\n";
let spec = parse_skill_spec(content, "dir-name").unwrap();
assert_eq!(spec.name, "dir-name");
}
#[test]
fn parse_rejects_empty_body() {
let content = "---\nname: test\n---\n \n";
assert!(parse_skill_spec(content, "dir").is_none());
}
#[test]
fn parse_no_frontmatter_returns_none() {
assert!(parse_skill_spec("just body", "dir").is_none());
}
#[test]
fn parse_extracts_tags() {
let content =
"---\nname: s\nmetadata:\n dirge:\n tags: [build, rust, cargo]\n---\n\nbody\n";
let spec = parse_skill_spec(content, "s").unwrap();
assert_eq!(spec.tags, vec!["build", "rust", "cargo"]);
}
#[test]
fn frontmatter_with_empty_name_defaults_to_dir() {
let content = "---\nname:\ndescription: desc\n---\n\nbody\n";
let spec = parse_skill_spec(content, "dir-name").unwrap();
assert_eq!(spec.name, "dir-name");
}
#[test]
fn content_size_under_limit() {
assert!(validate_content_size("short").is_ok());
}
#[test]
fn content_size_over_limit() {
let big = "x".repeat(100_001);
assert!(validate_content_size(&big).is_err());
}
#[test]
fn build_frontmatter_includes_name_and_description() {
let fm = build_frontmatter("my-skill", "Does things", &[]);
assert!(fm.contains("name: my-skill"));
assert!(fm.contains("description: Does things"));
assert!(fm.starts_with("---\n"));
assert!(fm.ends_with("---\n\n"));
}
#[test]
fn build_frontmatter_includes_tags() {
let fm = build_frontmatter("s", "", &["rust".into(), "build".into()]);
assert!(fm.contains("tags: [rust, build]"));
}
#[test]
fn yaml_empty_list_for_missing_key() {
let yaml = parse_yaml_frontmatter("name: foo\n").unwrap();
assert!(yaml_list(&yaml["tags"]).is_none());
}
#[test]
fn yaml_single_scalar_promoted_to_list() {
let yaml = parse_yaml_frontmatter("tags: rust\n").unwrap();
assert_eq!(yaml_list(&yaml["tags"]), Some(vec!["rust".to_string()]));
}
#[test]
fn parse_skill_spec_handles_multi_line_description() {
let content =
"---\nname: s\ndescription: |\n Multi-line text\n continues here\n---\n\nbody\n";
let spec = parse_skill_spec(content, "s").unwrap();
assert_eq!(spec.description, "Multi-line text\ncontinues here");
}
#[test]
fn parse_skill_spec_handles_folded_description() {
let content = "---\nname: s\ndescription: >\n First line\n second line\n---\n\nbody\n";
let spec = parse_skill_spec(content, "s").unwrap();
assert_eq!(spec.description, "First line second line");
}
#[test]
fn parse_skill_spec_handles_nested_map() {
let content = "---\nname: s\ntools: { allowed: [read], denied: [write] }\ndescription: ok\n---\n\nbody\n";
let spec = parse_skill_spec(content, "s").unwrap();
assert_eq!(spec.name, "s");
assert_eq!(spec.description, "ok");
}
#[test]
fn parse_skill_spec_handles_flow_array() {
let content = "---\nname: s\ntags: [a, b, c]\n---\n\nbody\n";
let spec = parse_skill_spec(content, "s").unwrap();
assert_eq!(spec.tags, vec!["a", "b", "c"]);
}
#[test]
fn parse_skill_spec_handles_quoted_string_with_colon() {
let content = "---\nname: s\ndescription: \"foo: bar: baz\"\n---\n\nbody\n";
let spec = parse_skill_spec(content, "s").unwrap();
assert_eq!(spec.description, "foo: bar: baz");
}
#[test]
fn parse_skill_spec_handles_block_list() {
let content = "---\nname: s\ntags:\n - alpha\n - beta\n - gamma\n---\n\nbody\n";
let spec = parse_skill_spec(content, "s").unwrap();
assert_eq!(spec.tags, vec!["alpha", "beta", "gamma"]);
}
}