use super::manifest::SkillManifest;
use super::mcp;
use super::types::{Category, ContentMode, TriggerKind};
use std::fmt;
#[derive(Debug, PartialEq, Eq)]
pub enum ValidationError {
InvalidName(String),
InvalidVersion(String),
InvalidPublisher(String),
NoContentMode,
MultipleContentModes,
ContentModeMismatch {
category: Category,
mode: ContentMode,
},
TriggerMissingPattern(TriggerKind),
EmptyAbstract,
McpRequirements(usize, String),
}
impl fmt::Display for ValidationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
use ValidationError::*;
match self {
InvalidName(n) => write!(f, "invalid skill name '{n}' (must match [a-z0-9-]{{1,64}})"),
InvalidVersion(v) => write!(f, "invalid version '{v}' (expected MAJOR.MINOR.PATCH)"),
InvalidPublisher(p) => write!(
f,
"invalid publisher '{p}' (expected 'human:<n>' or 'agent:<id>')"
),
NoContentMode => write!(
f,
"content must populate exactly one of: context / procedure / command"
),
MultipleContentModes => write!(
f,
"content must populate only one of: context / procedure / command"
),
ContentModeMismatch { category, mode } => {
write!(
f,
"category {category:?} does not match content mode {mode:?}"
)
}
TriggerMissingPattern(k) => write!(f, "trigger '{k:?}' requires a `pattern` field"),
EmptyAbstract => write!(f, "content.abstract must not be empty"),
McpRequirements(idx, msg) => {
write!(f, "mcp_requirements[{idx}]: {msg}")
}
}
}
}
impl std::error::Error for ValidationError {}
pub fn validate(m: &SkillManifest) -> Result<(), ValidationError> {
validate_name(&m.name)?;
validate_version(&m.version)?;
validate_publisher(&m.publisher)?;
if m.content.r#abstract.trim().is_empty() {
return Err(ValidationError::EmptyAbstract);
}
let mode = m.content.mode().ok_or_else(|| {
let populated = [
m.content.context.is_some(),
m.content.procedure.is_some(),
m.content.command.is_some(),
]
.iter()
.filter(|b| **b)
.count();
if populated > 1 {
ValidationError::MultipleContentModes
} else {
ValidationError::NoContentMode
}
})?;
if !mode_matches_category(m.category, mode) {
return Err(ValidationError::ContentModeMismatch {
category: m.category,
mode,
});
}
for t in &m.triggers {
if matches!(t.kind, TriggerKind::Command | TriggerKind::Keyword) && t.pattern.is_none() {
return Err(ValidationError::TriggerMissingPattern(t.kind));
}
}
if let Err((idx, msg)) = mcp::validate_requirements(&m.mcp_requirements) {
return Err(ValidationError::McpRequirements(idx, msg));
}
if let Some(proc) = &m.content.procedure {
for (idx, step) in proc.steps.iter().enumerate() {
if let Some(hint) = &step.tool_hint
&& hint.is_empty()
{
return Err(ValidationError::McpRequirements(
idx,
"tool_hint must not be empty when present".into(),
));
}
if let Some(intent) = &step.intent
&& intent.is_empty()
{
return Err(ValidationError::McpRequirements(
idx,
"intent must not be empty when present".into(),
));
}
}
}
Ok(())
}
fn validate_name(name: &str) -> Result<(), ValidationError> {
if name.is_empty() || name.len() > 64 {
return Err(ValidationError::InvalidName(name.into()));
}
if !name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(ValidationError::InvalidName(name.into()));
}
if name.starts_with('-') || name.ends_with('-') {
return Err(ValidationError::InvalidName(name.into()));
}
Ok(())
}
fn validate_version(v: &str) -> Result<(), ValidationError> {
let parts: Vec<&str> = v.split('.').collect();
if parts.len() != 3
|| parts
.iter()
.any(|p| p.is_empty() || !p.chars().all(|c| c.is_ascii_digit()))
{
return Err(ValidationError::InvalidVersion(v.into()));
}
Ok(())
}
fn validate_publisher(p: &str) -> Result<(), ValidationError> {
let (kind, rest) = p
.split_once(':')
.ok_or_else(|| ValidationError::InvalidPublisher(p.into()))?;
if rest.is_empty() {
return Err(ValidationError::InvalidPublisher(p.into()));
}
match kind {
"human" | "agent" => Ok(()),
_ => Err(ValidationError::InvalidPublisher(p.into())),
}
}
fn mode_matches_category(cat: Category, mode: ContentMode) -> bool {
matches!(
(cat, mode),
(Category::Workflow, ContentMode::Workflow)
| (Category::Command, ContentMode::Command)
| (Category::Context, ContentMode::Context)
| (Category::Meta, ContentMode::Context)
)
}
#[cfg(test)]
mod tests {
use super::super::parser::parse_canonical;
use super::*;
const VALID: &str = r#"
name: demo
version: 1.0.0
publisher: human:test
description: d
category: context
content:
abstract: hi
context: body
"#;
#[test]
fn valid_manifest_passes() {
let m = parse_canonical(VALID).unwrap();
validate(&m).unwrap();
}
#[test]
fn rejects_uppercase_name() {
let mut m = parse_canonical(VALID).unwrap();
m.name = "Demo".into();
assert!(matches!(validate(&m), Err(ValidationError::InvalidName(_))));
}
#[test]
fn rejects_bad_version() {
let mut m = parse_canonical(VALID).unwrap();
m.version = "1.0".into();
assert!(matches!(
validate(&m),
Err(ValidationError::InvalidVersion(_))
));
}
#[test]
fn rejects_bad_publisher() {
let mut m = parse_canonical(VALID).unwrap();
m.publisher = "anon".into();
assert!(matches!(
validate(&m),
Err(ValidationError::InvalidPublisher(_))
));
}
#[test]
fn rejects_category_mode_mismatch() {
let yaml = r#"
name: demo
version: 1.0.0
publisher: human:test
description: d
category: workflow
content:
abstract: hi
context: oops
"#;
let m = parse_canonical(yaml).unwrap();
assert!(matches!(
validate(&m),
Err(ValidationError::ContentModeMismatch { .. })
));
}
#[test]
fn valid_mcp_requirements_passes() {
let yaml = r#"
name: demo
version: 1.0.0
publisher: human:test
description: d
category: workflow
content:
abstract: hi
procedure:
steps:
- description: test
mcp_requirements:
- tool_pattern: "browser.*"
capability: network_http
"#;
let m = parse_canonical(yaml).unwrap();
validate(&m).unwrap();
}
#[test]
fn empty_mcp_requirements_passes() {
let yaml = r#"
name: demo
version: 1.0.0
publisher: human:test
description: d
category: context
content:
abstract: hi
context: body
"#;
let m = parse_canonical(yaml).unwrap();
validate(&m).unwrap();
}
#[test]
fn rejects_duplicate_mcp_requirements() {
let yaml = r#"
name: demo
version: 1.0.0
publisher: human:test
description: d
category: workflow
content:
abstract: hi
procedure:
steps:
- description: test
mcp_requirements:
- tool_pattern: "fs.*"
capability: read_file
- tool_pattern: "fs.*"
capability: read_file
"#;
let m = parse_canonical(yaml).unwrap();
assert!(matches!(
validate(&m),
Err(ValidationError::McpRequirements(1, _))
));
}
#[test]
fn rejects_empty_mcp_pattern() {
let yaml = r#"
name: demo
version: 1.0.0
publisher: human:test
description: d
category: workflow
content:
abstract: hi
procedure:
steps:
- description: test
mcp_requirements:
- tool_pattern: ""
capability: read_file
"#;
let m = parse_canonical(yaml).unwrap();
assert!(matches!(
validate(&m),
Err(ValidationError::McpRequirements(0, _))
));
}
#[test]
fn command_trigger_requires_pattern() {
let yaml = r#"
name: demo
version: 1.0.0
publisher: human:test
description: d
category: context
content:
abstract: hi
context: body
triggers:
- type: command
"#;
let m = parse_canonical(yaml).unwrap();
assert!(matches!(
validate(&m),
Err(ValidationError::TriggerMissingPattern(TriggerKind::Command))
));
}
}