1use crate::file_references::FileReferenceValidator;
7use crate::types::SkillManifest;
8use crate::validation_report::SkillValidationReport;
9use std::path::Path;
10
11pub struct ComprehensiveSkillValidator {
13 strict_mode: bool,
14}
15
16impl ComprehensiveSkillValidator {
17 pub fn new() -> Self {
18 Self { strict_mode: false }
19 }
20
21 pub fn strict() -> Self {
22 Self { strict_mode: true }
23 }
24
25 pub fn validate_manifest(&self, manifest: &SkillManifest, skill_path: &Path) -> SkillValidationReport {
27 let mut report = SkillValidationReport::new(manifest.name.clone(), skill_path.to_path_buf());
28
29 self.validate_name_field(manifest, &mut report);
31
32 self.validate_description_field(manifest, &mut report);
34
35 self.validate_directory_match(manifest, skill_path, &mut report);
37
38 self.validate_optional_fields(manifest, &mut report);
40
41 self.validate_instructions_length(manifest, &mut report);
43
44 report.finalize();
45 report
46 }
47
48 fn validate_name_field(&self, manifest: &SkillManifest, report: &mut SkillValidationReport) {
50 if manifest.name.is_empty() {
52 report.add_error(Some("name".to_string()), "name is required and must not be empty".to_string(), None);
53 return;
54 }
55
56 if manifest.name.len() > 64 {
58 report.add_error(
59 Some("name".to_string()),
60 format!("name exceeds maximum length: {} characters (max 64)", manifest.name.len()),
61 Some("Use a shorter name (1-64 characters)".to_string()),
62 );
63 }
64
65 if !manifest.name.chars().all(|c| c.is_lowercase() || c.is_numeric() || c == '-') {
67 report.add_error(
68 Some("name".to_string()),
69 format!(
70 "name contains invalid characters: '{}'\nMust contain only lowercase letters, numbers, and hyphens",
71 manifest.name
72 ),
73 Some("Use only a-z, 0-9, and hyphens".to_string()),
74 );
75 }
76
77 if manifest.name.contains("--") {
79 report.add_error(
80 Some("name".to_string()),
81 format!("name contains consecutive hyphens: '{}'", manifest.name),
82 Some("Remove consecutive hyphens (--)".to_string()),
83 );
84 }
85
86 if manifest.name.starts_with('-') {
88 report.add_error(
89 Some("name".to_string()),
90 format!("name starts with hyphen: '{}'", manifest.name),
91 Some("Remove the leading hyphen".to_string()),
92 );
93 }
94
95 if manifest.name.ends_with('-') {
97 report.add_error(
98 Some("name".to_string()),
99 format!("name ends with hyphen: '{}'", manifest.name),
100 Some("Remove the trailing hyphen".to_string()),
101 );
102 }
103
104 if manifest.name.contains("anthropic") || manifest.name.contains("claude") {
106 report.add_error(
107 Some("name".to_string()),
108 format!("name contains reserved word: '{}'\nMust not contain 'anthropic' or 'claude'", manifest.name),
109 Some("Choose a different name without these words".to_string()),
110 );
111 }
112 }
113
114 fn validate_description_field(&self, manifest: &SkillManifest, report: &mut SkillValidationReport) {
116 if manifest.description.is_empty() {
117 report.add_error(
118 Some("description".to_string()),
119 "description is required and must not be empty".to_string(),
120 Some("Add a description explaining what the skill does and when to use it".to_string()),
121 );
122 return;
123 }
124
125 if manifest.description.len() > 1024 {
126 report.add_error(
127 Some("description".to_string()),
128 format!("description exceeds maximum length: {} characters (max 1024)", manifest.description.len()),
129 Some("Shorten the description to 1024 characters or less".to_string()),
130 );
131 }
132
133 if manifest.description.len() < 50 {
135 report.add_suggestion(Some("description".to_string()), "Description is very short".to_string());
136 }
137 }
138
139 fn validate_directory_match(
141 &self,
142 manifest: &SkillManifest,
143 skill_path: &Path,
144 report: &mut SkillValidationReport,
145 ) {
146 if let Err(e) = manifest.validate_directory_name_match(skill_path) {
147 report.add_warning(
148 Some("name".to_string()),
149 e.to_string(),
150 Some("Rename the skill directory to match the name field, or rename the skill".to_string()),
151 );
152 }
153 }
154
155 fn validate_optional_fields(&self, manifest: &SkillManifest, report: &mut SkillValidationReport) {
157 if let Some(allowed_tools) = &manifest.allowed_tools {
159 let tools: Vec<&str> = allowed_tools.split_whitespace().collect();
160
161 if tools.len() > 16 {
162 report.add_error(
163 Some("allowed-tools".to_string()),
164 format!("allowed-tools exceeds maximum tool count: {} tools (max 16)", tools.len()),
165 Some("Reduce the number of tools to 16 or fewer".to_string()),
166 );
167 }
168
169 if tools.is_empty() {
170 report.add_error(
171 Some("allowed-tools".to_string()),
172 "allowed-tools must not be empty if specified".to_string(),
173 Some("Either remove the field or add valid tool names".to_string()),
174 );
175 }
176 }
177
178 if let Some(license) = &manifest.license
180 && license.len() > 512
181 {
182 report.add_error(
183 Some("license".to_string()),
184 format!("license exceeds maximum length: {} characters (max 512)", license.len()),
185 Some("Shorten the license field".to_string()),
186 );
187 }
188
189 if let Some(compatibility) = &manifest.compatibility {
191 if compatibility.is_empty() {
192 report.add_error(
193 Some("compatibility".to_string()),
194 "compatibility must not be empty if specified".to_string(),
195 Some("Either remove the field or add meaningful compatibility info".to_string()),
196 );
197 } else if compatibility.len() > 500 {
198 report.add_error(
199 Some("compatibility".to_string()),
200 format!("compatibility exceeds maximum length: {} characters (max 500)", compatibility.len()),
201 Some("Shorten the compatibility field".to_string()),
202 );
203 }
204 }
205
206 if manifest.license.is_none() {
208 report.add_suggestion(Some("license".to_string()), "Consider adding a license field".to_string());
209 }
210
211 if manifest.compatibility.is_none() {
212 report.add_suggestion(
213 Some("compatibility".to_string()),
214 "Consider adding a compatibility field if the skill has specific requirements".to_string(),
215 );
216 }
217
218 if !self.description_has_routing_signals(&manifest.description) {
219 self.report_routing_quality_issue(
220 report,
221 "description",
222 "Description lacks concrete routing signals (inputs/triggers/outputs)",
223 "Rewrite description as routing logic: when to use, when not to use, expected result.",
224 );
225 }
226 }
227
228 fn report_routing_quality_issue(
229 &self,
230 report: &mut SkillValidationReport,
231 field: &str,
232 message: &str,
233 remediation: &str,
234 ) {
235 if self.strict_mode {
236 report.add_error(Some(field.to_string()), message.to_string(), Some(remediation.to_string()));
237 } else {
238 report.add_warning(Some(field.to_string()), message.to_string(), Some(remediation.to_string()));
239 }
240 }
241
242 fn description_has_routing_signals(&self, description: &str) -> bool {
243 let text = description.to_lowercase();
244 let has_trigger_hint =
245 text.contains("when") || text.contains("if ") || text.contains("for ") || text.contains("trigger");
246 let has_output_hint = text.contains("output")
247 || text.contains("returns")
248 || text.contains("result")
249 || text.contains("generat")
250 || text.contains("produ");
251 let has_action_hint = text.contains("analy")
252 || text.contains("extract")
253 || text.contains("transform")
254 || text.contains("validate")
255 || text.contains("summar")
256 || text.contains("convert")
257 || text.contains("clean");
258
259 has_action_hint && (has_trigger_hint || has_output_hint)
260 }
261
262 fn validate_instructions_length(&self, _manifest: &SkillManifest, report: &mut SkillValidationReport) {
264 report.add_suggestion(None, "Keep SKILL.md under 500 lines for optimal context usage".to_string());
266 }
267
268 pub fn validate_file_references(
270 &self,
271 _manifest: &SkillManifest,
272 skill_path: &Path,
273 instructions: &str,
274 report: &mut SkillValidationReport,
275 ) {
276 let skill_root = skill_path.parent().unwrap_or(skill_path);
277 let validator = FileReferenceValidator::new(skill_root.to_path_buf());
278 let reference_errors = validator.validate_references(instructions);
279
280 for error in reference_errors {
281 if self.strict_mode {
283 report.add_error(
284 None,
285 format!("File reference issue: {error}"),
286 Some("Fix the file reference or ensure the referenced file exists".to_string()),
287 );
288 } else {
289 report.add_warning(
290 None,
291 format!("File reference issue: {error}"),
292 Some("Fix the file reference or ensure the referenced file exists".to_string()),
293 );
294 }
295 }
296
297 let valid_refs = validator.list_valid_references();
299 if !valid_refs.is_empty() {
300 let ref_list: Vec<String> = valid_refs.iter().map(|p| p.to_string_lossy().to_string()).collect();
301 report.add_suggestion(
302 None,
303 format!("Found {} valid file references: {}", ref_list.len(), ref_list.join(", ")),
304 );
305 }
306 }
307}
308
309impl Default for ComprehensiveSkillValidator {
310 fn default() -> Self {
311 Self::new()
312 }
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318 use std::path::PathBuf;
319
320 #[test]
321 fn test_comprehensive_validation() {
322 let validator = ComprehensiveSkillValidator::new();
323 let manifest = SkillManifest {
324 name: "test-skill".to_string(),
325 description: "A test skill for validation".to_string(),
326 version: Some("1.0.0".to_string()),
327 author: Some("Test Author".to_string()),
328 allowed_tools: Some("Read Write Bash".to_string()),
329 compatibility: Some("Designed for VT Code".to_string()),
330 ..Default::default()
331 };
332
333 let report = validator.validate_manifest(&manifest, PathBuf::from("/tmp/nonexistent").as_path());
336
337 assert!(report.suggestions.iter().any(|s| s.field == Some("license".to_string())));
339 }
340}