use crate::skills::SkillRegistry;
use crate::tools::ToolRegistry;
use roboticus_core::types::{RiskLevel, SkillManifest};
#[derive(Debug, Clone, serde::Serialize)]
pub struct SkillValidationResult {
pub valid: bool,
pub errors: Vec<String>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct SkillTestResult {
pub would_match: bool,
pub matched_triggers: Vec<String>,
}
pub fn validate_manifest(
manifest: &SkillManifest,
tool_registry: &ToolRegistry,
skill_registry: &SkillRegistry,
) -> SkillValidationResult {
let mut errors = Vec::new();
let mut warnings = Vec::new();
if manifest.name.trim().is_empty() {
errors.push("Skill name must not be empty.".to_string());
}
if manifest.description.trim().is_empty() {
errors.push("Skill description must not be empty.".to_string());
}
if let Some(chain) = &manifest.tool_chain {
let mut chain_risk = RiskLevel::Safe;
for step in chain {
if tool_registry.get(&step.tool_name).is_none() {
errors.push(format!(
"Tool '{}' referenced in tool_chain not found in registry.",
step.tool_name
));
} else if let Some(tool) = tool_registry.get(&step.tool_name)
&& tool.risk_level() > chain_risk
{
chain_risk = tool.risk_level();
}
}
if manifest.risk_level < chain_risk {
warnings.push(format!(
"Skill risk_level ({:?}) is lower than the highest tool in chain ({:?}). \
Consider raising to {:?}.",
manifest.risk_level, chain_risk, chain_risk
));
}
}
validate_triggers(&manifest.triggers.keywords, skill_registry, &mut warnings);
SkillValidationResult {
valid: errors.is_empty(),
errors,
warnings,
}
}
pub fn validate_instruction(
name: &str,
description: &str,
keywords: &[String],
body: &str,
skill_registry: &SkillRegistry,
) -> SkillValidationResult {
let mut errors = Vec::new();
let mut warnings = Vec::new();
if name.trim().is_empty() {
errors.push("Skill name must not be empty.".to_string());
}
if description.trim().is_empty() {
errors.push("Skill description must not be empty.".to_string());
}
if body.trim().is_empty() {
errors.push("Instruction body must not be empty.".to_string());
}
validate_triggers(keywords, skill_registry, &mut warnings);
SkillValidationResult {
valid: errors.is_empty(),
errors,
warnings,
}
}
pub fn test_skill_match(prompt: &str, registry: &SkillRegistry) -> SkillTestResult {
let keywords: Vec<&str> = prompt.split_whitespace().collect();
let matches = registry.match_skills(&keywords);
let matched_triggers: Vec<String> = matches.iter().map(|s| s.name().to_string()).collect();
SkillTestResult {
would_match: !matched_triggers.is_empty(),
matched_triggers,
}
}
pub fn scaffold_toml(name: &str, description: &str, tools: &[String]) -> String {
let name = escape_toml_string(name);
let description = escape_toml_string(description);
let tool_chain = if tools.is_empty() {
String::new()
} else {
let steps: Vec<String> = tools
.iter()
.map(|t| format!(" {{ tool = \"{t}\", params = {{}} }}"))
.collect();
format!("\ntool_chain = [\n{}\n]\n", steps.join(",\n"))
};
let trigger_keywords: Vec<String> = name
.split(|c: char| !c.is_alphanumeric())
.filter(|w| w.len() >= 4)
.map(|w| format!("\"{}\"", w.to_lowercase()))
.collect();
format!(
r#"name = "{name}"
description = "{description}"
kind = "action"
risk_level = "Safe"
version = "0.1.0"
author = "local"
[triggers]
keywords = [{keywords}]
{tool_chain}"#,
name = name,
description = description,
keywords = trigger_keywords.join(", "),
tool_chain = tool_chain.trim_end(),
)
}
pub fn scaffold_markdown(name: &str, description: &str) -> String {
let name = escape_toml_string(name);
let description = escape_toml_string(description);
let trigger_keywords: Vec<String> = name
.split(|c: char| !c.is_alphanumeric())
.filter(|w| w.len() >= 4)
.map(|w| format!("\"{}\"", w.to_lowercase()))
.collect();
format!(
r#"---
name: {name}
description: {description}
triggers:
keywords: [{keywords}]
priority: 5
---
# {name}
Describe what this skill does and how the agent should behave when it activates.
"#,
name = name,
description = description,
keywords = trigger_keywords.join(", "),
)
}
fn escape_toml_string(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
fn validate_triggers(
keywords: &[String],
skill_registry: &SkillRegistry,
warnings: &mut Vec<String>,
) {
if keywords.is_empty() {
warnings.push("No trigger keywords defined — skill will never activate.".to_string());
return;
}
for kw in keywords {
if kw.len() < 4 {
warnings.push(format!(
"Trigger keyword '{}' is very short (<4 chars) and may cause false activations.",
kw
));
}
}
let kw_refs: Vec<&str> = keywords.iter().map(|s| s.as_str()).collect();
let overlapping = skill_registry.match_skills(&kw_refs);
if overlapping.len() > 5 {
warnings.push(format!(
"Triggers overlap with {} existing skills — consider more specific keywords.",
overlapping.len()
));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_empty_name_is_error() {
let result =
validate_instruction("", "desc", &["kw".into()], "body", &SkillRegistry::new());
assert!(!result.valid);
assert!(result.errors.iter().any(|e| e.contains("name")));
}
#[test]
fn validate_empty_body_is_error() {
let result =
validate_instruction("test", "desc", &["kw".into()], "", &SkillRegistry::new());
assert!(!result.valid);
assert!(result.errors.iter().any(|e| e.contains("body")));
}
#[test]
fn validate_no_triggers_warns() {
let result = validate_instruction("test", "desc", &[], "body text", &SkillRegistry::new());
assert!(result.valid);
assert!(result.warnings.iter().any(|w| w.contains("No trigger")));
}
#[test]
fn validate_short_trigger_warns() {
let result = validate_instruction(
"test",
"desc",
&["ab".into()],
"body text",
&SkillRegistry::new(),
);
assert!(result.valid);
assert!(result.warnings.iter().any(|w| w.contains("short")));
}
#[test]
fn test_skill_match_no_registry() {
let result = test_skill_match("hello world", &SkillRegistry::new());
assert!(!result.would_match);
assert!(result.matched_triggers.is_empty());
}
#[test]
fn scaffold_toml_roundtrip() {
let toml = scaffold_toml("my-skill", "Does things", &["read_file".into()]);
assert!(toml.contains("name = \"my-skill\""));
assert!(toml.contains("read_file"));
assert!(toml.contains("kind = \"action\""));
}
#[test]
fn scaffold_markdown_roundtrip() {
let md = scaffold_markdown("my-skill", "Does things");
assert!(md.contains("name: my-skill"));
assert!(md.contains("# my-skill"));
}
struct StubTool {
tool_name: String,
tool_risk: RiskLevel,
}
#[async_trait::async_trait]
impl crate::tools::Tool for StubTool {
fn name(&self) -> &str {
&self.tool_name
}
fn description(&self) -> &str {
"stub"
}
fn risk_level(&self) -> RiskLevel {
self.tool_risk
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({})
}
async fn execute(
&self,
_params: serde_json::Value,
_ctx: &crate::tools::ToolContext,
) -> std::result::Result<crate::tools::ToolResult, crate::tools::ToolError> {
Ok(crate::tools::ToolResult {
output: "ok".into(),
metadata: None,
})
}
}
fn make_manifest(
name: &str,
tools: &[(&str, RiskLevel)],
risk: RiskLevel,
) -> (roboticus_core::types::SkillManifest, ToolRegistry) {
use roboticus_core::types::{SkillKind, SkillTrigger, ToolChainStep};
let mut registry = ToolRegistry::new();
let tool_chain: Vec<ToolChainStep> = tools
.iter()
.map(|(t, r)| {
registry.register(Box::new(StubTool {
tool_name: t.to_string(),
tool_risk: *r,
}));
ToolChainStep {
tool_name: t.to_string(),
params: serde_json::json!({}),
}
})
.collect();
let manifest = roboticus_core::types::SkillManifest {
name: name.to_string(),
description: format!("Test: {name}"),
kind: SkillKind::Structured,
triggers: SkillTrigger {
keywords: vec!["testing".to_string()],
tool_names: vec![],
regex_patterns: vec![],
},
priority: 5,
tool_chain: if tool_chain.is_empty() {
None
} else {
Some(tool_chain)
},
policy_overrides: None,
script_path: None,
risk_level: risk,
version: "0.1.0".to_string(),
author: "test".to_string(),
};
(manifest, registry)
}
#[test]
fn validate_manifest_missing_tools_errors() {
use roboticus_core::types::{SkillKind, SkillTrigger, ToolChainStep};
let manifest = roboticus_core::types::SkillManifest {
name: "test-skill".to_string(),
description: "desc".to_string(),
kind: SkillKind::Structured,
triggers: SkillTrigger {
keywords: vec!["testing".to_string()],
tool_names: vec![],
regex_patterns: vec![],
},
priority: 5,
tool_chain: Some(vec![ToolChainStep {
tool_name: "nonexistent_tool".to_string(),
params: serde_json::json!({}),
}]),
policy_overrides: None,
script_path: None,
risk_level: RiskLevel::Safe,
version: "0.1.0".to_string(),
author: "test".to_string(),
};
let empty_tools = ToolRegistry::new();
let result = validate_manifest(&manifest, &empty_tools, &SkillRegistry::new());
assert!(!result.valid);
assert!(
result
.errors
.iter()
.any(|e| e.contains("nonexistent_tool") && e.contains("not found"))
);
}
#[test]
fn validate_manifest_risk_level_warning() {
let (manifest, registry) = make_manifest(
"risky",
&[("cautious_tool", RiskLevel::Caution)],
RiskLevel::Safe,
);
let result = validate_manifest(&manifest, ®istry, &SkillRegistry::new());
assert!(result.valid); assert!(
result.warnings.iter().any(|w| w.contains("risk_level")),
"should warn about risk mismatch: {:?}",
result.warnings
);
}
#[test]
fn validate_manifest_valid_passes() {
let (manifest, registry) = make_manifest(
"good-skill",
&[("safe_tool", RiskLevel::Safe)],
RiskLevel::Safe,
);
let result = validate_manifest(&manifest, ®istry, &SkillRegistry::new());
assert!(result.valid);
assert!(result.errors.is_empty());
}
#[test]
fn scaffold_toml_no_tools() {
let toml = scaffold_toml("my-skill", "Does things", &[]);
assert!(!toml.contains("tool_chain"));
}
#[test]
fn scaffold_toml_skips_short_words() {
let toml = scaffold_toml("a-big-test", "Does things", &[]);
assert!(toml.contains("\"test\""));
assert!(!toml.contains("\"a\""));
assert!(!toml.contains("\"big\""));
}
}