use super::{Diagnostic, LintResult};
use crate::error::Result;
use crate::frontmatter::{self, RawFrontmatter};
use lazy_regex::{Lazy, Regex, lazy_regex};
use std::collections::HashSet;
use std::path::Path;
static NAME_FORMAT_RE: Lazy<Regex> = lazy_regex!(r"^[a-z][a-z0-9-]*[a-z0-9]$|^[a-z]$");
static TRIGGER_RE: Lazy<Regex> =
lazy_regex!(r"(?i)(use when|when to use|use for|triggers on|triggers:|activate when)");
const KNOWN_FIELDS: &[&str] = &["name", "description", "allowed-tools"];
pub fn parse_frontmatter(content: &str) -> Result<(Option<RawFrontmatter>, bool)> {
let result = frontmatter::parse_lenient(content);
Ok((result.frontmatter, result.valid_delimiters))
}
pub fn lint_frontmatter(
content: &str,
file_path: &Path,
skill_path: &Path,
dir_name: &str,
result: &mut LintResult,
) -> Result<()> {
let relative_path = file_path.strip_prefix(skill_path).unwrap_or(file_path);
let parse_result = frontmatter::parse_lenient(content);
if !parse_result.valid_delimiters {
let msg = if !content.starts_with("---") {
"missing frontmatter: file does not start with ---"
} else {
"missing frontmatter: no closing --- found"
};
result.add(
Diagnostic::error("SKL100", "frontmatter-valid", msg)
.with_file(relative_path)
.with_line(1),
);
return Ok(());
}
let Some(fm) = parse_result.frontmatter else {
result.add(
Diagnostic::error(
"SKL100",
"frontmatter-valid",
"failed to parse frontmatter YAML",
)
.with_file(relative_path)
.with_line(1),
);
return Ok(());
};
if fm.name.is_none() {
result.add(
Diagnostic::error("SKL101", "name-required", "missing required field 'name'")
.with_file(relative_path),
);
}
if let Some(ref name_val) = fm.name {
if !name_val.is_empty() && !NAME_FORMAT_RE.is_match(name_val) {
result.add(
Diagnostic::error(
"SKL102",
"name-format",
format!(
"name '{}' contains invalid characters (must be lowercase a-z, 0-9, hyphens)",
name_val
),
)
.with_file(relative_path),
);
}
if name_val.is_empty() {
result.add(
Diagnostic::error("SKL103", "name-length", "name is empty")
.with_file(relative_path),
);
} else if name_val.len() > 64 {
result.add(
Diagnostic::error(
"SKL103",
"name-length",
format!("name exceeds 64 characters ({} chars)", name_val.len()),
)
.with_file(relative_path),
);
}
if !name_val.is_empty() && name_val != dir_name {
result.add(
Diagnostic::warning(
"SKL104",
"name-match-dir",
format!(
"name '{}' does not match directory '{}'",
name_val, dir_name
),
)
.with_file(relative_path),
);
}
}
if fm.description.is_none() {
result.add(
Diagnostic::error(
"SKL105",
"description-required",
"missing required field 'description'",
)
.with_file(relative_path),
);
}
if let Some(ref desc_val) = fm.description {
if desc_val.trim().is_empty() {
result.add(
Diagnostic::error("SKL106", "description-nonempty", "description is empty")
.with_file(relative_path),
);
}
if desc_val.len() > 1024 {
result.add(
Diagnostic::warning(
"SKL107",
"description-length",
format!(
"description exceeds 1024 characters ({} chars)",
desc_val.len()
),
)
.with_file(relative_path),
);
}
if !desc_val.trim().is_empty() && !TRIGGER_RE.is_match(desc_val) {
result.add(
Diagnostic::warning(
"SKL108",
"description-triggers",
"description lacks activation trigger (missing 'Use when' or similar)",
)
.with_file(relative_path),
);
}
}
let known_set: HashSet<&str> = KNOWN_FIELDS.iter().copied().collect();
for key in fm.extra.keys() {
if !known_set.contains(key.as_str()) {
result.add(
Diagnostic::warning(
"SKL109",
"frontmatter-known",
format!("unknown frontmatter field '{}'", key),
)
.with_file(relative_path),
);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_frontmatter_valid() {
let content = r#"---
name: my-skill
description: "A skill"
---
# Content
"#;
let result = frontmatter::parse_lenient(content);
assert!(result.valid_delimiters);
let fm = result.frontmatter.expect("should parse");
assert_eq!(fm.name, Some("my-skill".to_string()));
assert_eq!(fm.description, Some("A skill".to_string()));
}
#[test]
fn test_parse_frontmatter_missing_open() {
let content = "# No frontmatter";
let result = frontmatter::parse_lenient(content);
assert!(!result.valid_delimiters);
}
#[test]
fn test_parse_frontmatter_missing_close() {
let content = "---\nname: test\n# No close";
let result = frontmatter::parse_lenient(content);
assert!(!result.valid_delimiters);
}
#[test]
fn test_name_format_regex() {
assert!(NAME_FORMAT_RE.is_match("a"));
assert!(NAME_FORMAT_RE.is_match("ab"));
assert!(NAME_FORMAT_RE.is_match("my-skill"));
assert!(NAME_FORMAT_RE.is_match("skill123"));
assert!(NAME_FORMAT_RE.is_match("my-skill-2"));
assert!(NAME_FORMAT_RE.is_match("a1"));
assert!(!NAME_FORMAT_RE.is_match(""));
assert!(!NAME_FORMAT_RE.is_match("My-Skill")); assert!(!NAME_FORMAT_RE.is_match("-skill")); assert!(!NAME_FORMAT_RE.is_match("skill-")); assert!(!NAME_FORMAT_RE.is_match("skill_name")); assert!(!NAME_FORMAT_RE.is_match("1skill")); }
#[test]
fn test_trigger_regex() {
assert!(TRIGGER_RE.is_match("Use when working with files"));
assert!(TRIGGER_RE.is_match("use when testing"));
assert!(TRIGGER_RE.is_match("When to use: for testing"));
assert!(TRIGGER_RE.is_match("Triggers on file changes"));
assert!(TRIGGER_RE.is_match("triggers: file changes"));
assert!(TRIGGER_RE.is_match("Activate when needed"));
assert!(TRIGGER_RE.is_match("Use for testing"));
assert!(!TRIGGER_RE.is_match("A simple description"));
assert!(!TRIGGER_RE.is_match("This helps with testing"));
}
#[test]
fn test_lint_missing_name() {
let content = r#"---
description: "A skill"
---
"#;
let mut result = LintResult::new("test".to_string(), std::path::PathBuf::new());
lint_frontmatter(
content,
std::path::Path::new("SKILL.md"),
std::path::Path::new("."),
"test",
&mut result,
)
.expect("write test file");
assert!(result.diagnostics.iter().any(|d| d.rule_id == "SKL101"));
}
#[test]
fn test_lint_invalid_name_format() {
let content = r#"---
name: My-Skill
description: "A skill"
---
"#;
let mut result = LintResult::new("test".to_string(), std::path::PathBuf::new());
lint_frontmatter(
content,
std::path::Path::new("SKILL.md"),
std::path::Path::new("."),
"test",
&mut result,
)
.expect("write test file");
assert!(result.diagnostics.iter().any(|d| d.rule_id == "SKL102"));
}
#[test]
fn test_lint_name_too_long() {
let long_name = "a".repeat(65);
let content = format!(
r#"---
name: {}
description: "A skill"
---
"#,
long_name
);
let mut result = LintResult::new("test".to_string(), std::path::PathBuf::new());
lint_frontmatter(
&content,
std::path::Path::new("SKILL.md"),
std::path::Path::new("."),
"test",
&mut result,
)
.expect("write test file");
assert!(result.diagnostics.iter().any(|d| d.rule_id == "SKL103"));
}
#[test]
fn test_lint_missing_trigger() {
let content = r#"---
name: test
description: "A simple description without trigger"
---
"#;
let mut result = LintResult::new("test".to_string(), std::path::PathBuf::new());
lint_frontmatter(
content,
std::path::Path::new("SKILL.md"),
std::path::Path::new("."),
"test",
&mut result,
)
.expect("write test file");
assert!(result.diagnostics.iter().any(|d| d.rule_id == "SKL108"));
}
#[test]
fn test_lint_unknown_field() {
let content = r#"---
name: test
description: "Use when testing"
unknown_field: value
---
"#;
let mut result = LintResult::new("test".to_string(), std::path::PathBuf::new());
lint_frontmatter(
content,
std::path::Path::new("SKILL.md"),
std::path::Path::new("."),
"test",
&mut result,
)
.expect("write test file");
assert!(result.diagnostics.iter().any(|d| d.rule_id == "SKL109"));
}
#[test]
fn test_lint_allowed_tools_not_unknown() {
let content = r#"---
name: test
description: "Use when testing"
allowed-tools: Read, Write
---
"#;
let mut result = LintResult::new("test".to_string(), std::path::PathBuf::new());
lint_frontmatter(
content,
std::path::Path::new("SKILL.md"),
std::path::Path::new("."),
"test",
&mut result,
)
.expect("write test file");
assert!(!result.diagnostics.iter().any(|d| d.rule_id == "SKL109"));
}
}