cc_plugin_validator/
component.rs1use crate::{ComponentValidation, ValidationIssue};
2
3pub fn validate_component_markdown(
4 file_path: &str,
5 content: &str,
6 file_type: &str,
7) -> ComponentValidation {
8 let mut errors = Vec::new();
9 let mut warnings = Vec::new();
10
11 let frontmatter = extract_frontmatter(content);
12 let Some(frontmatter_text) = frontmatter else {
13 warnings.push(issue(
14 "frontmatter",
15 "missing_frontmatter",
16 "No frontmatter block found. Add YAML frontmatter between --- delimiters at the top of the file to set description and other metadata.",
17 ));
18 return ComponentValidation {
19 path: file_path.to_string(),
20 errors,
21 warnings,
22 };
23 };
24
25 let parsed: serde_yaml::Value = match serde_yaml::from_str(frontmatter_text) {
26 Ok(v) => v,
27 Err(err) => {
28 errors.push(issue(
29 "frontmatter",
30 "invalid_yaml",
31 &format!(
32 "YAML frontmatter failed to parse: {err}. At runtime this {file_type} loads with empty metadata (all frontmatter fields silently dropped)."
33 ),
34 ));
35 return ComponentValidation {
36 path: file_path.to_string(),
37 errors,
38 warnings,
39 };
40 }
41 };
42
43 let Some(map) = parsed.as_mapping() else {
44 errors.push(issue(
45 "frontmatter",
46 "invalid_type",
47 "Frontmatter must be a YAML mapping (key: value pairs).",
48 ));
49 return ComponentValidation {
50 path: file_path.to_string(),
51 errors,
52 warnings,
53 };
54 };
55
56 match get_yaml_value(map, "description") {
57 None => warnings.push(issue(
58 "description",
59 "missing_description",
60 &format!(
61 "No description in frontmatter. A description helps users and Claude understand when to use this {file_type}."
62 ),
63 )),
64 Some(v) => {
65 if !is_yaml_scalar(v) {
66 errors.push(issue(
67 "description",
68 "invalid_type",
69 "description must be a scalar (string/number/bool/null).",
70 ));
71 }
72 }
73 }
74
75 if let Some(v) = get_yaml_value(map, "name")
76 && !is_yaml_null(v)
77 && !matches!(v, serde_yaml::Value::String(_))
78 {
79 errors.push(issue("name", "invalid_type", "name must be a string."));
80 }
81
82 if let Some(v) = get_yaml_value(map, "allowed-tools") {
83 let ok = match v {
84 serde_yaml::Value::String(_) => true,
85 serde_yaml::Value::Sequence(seq) => seq
86 .iter()
87 .all(|item| matches!(item, serde_yaml::Value::String(_))),
88 serde_yaml::Value::Null => true,
89 _ => false,
90 };
91 if !ok {
92 errors.push(issue(
93 "allowed-tools",
94 "invalid_type",
95 "allowed-tools must be a string or array of strings.",
96 ));
97 }
98 }
99
100 if let Some(v) = get_yaml_value(map, "shell")
101 && !is_yaml_null(v)
102 {
103 match v {
104 serde_yaml::Value::String(s) => {
105 let norm = s.trim().to_lowercase();
106 if norm != "bash" && norm != "powershell" {
107 errors.push(issue(
108 "shell",
109 "invalid_enum_value",
110 &format!("shell must be 'bash' or 'powershell', got '{s}'."),
111 ));
112 }
113 }
114 _ => errors.push(issue("shell", "invalid_type", "shell must be a string.")),
115 }
116 }
117
118 ComponentValidation {
119 path: file_path.to_string(),
120 errors,
121 warnings,
122 }
123}
124
125fn extract_frontmatter(content: &str) -> Option<&str> {
126 if !content.starts_with("---\n") {
127 return None;
128 }
129 let rest = &content[4..];
130 let end = rest.find("\n---\n")?;
131 Some(&rest[..end])
132}
133
134fn get_yaml_value<'a>(map: &'a serde_yaml::Mapping, key: &str) -> Option<&'a serde_yaml::Value> {
135 map.get(serde_yaml::Value::String(key.to_string()))
136}
137
138fn is_yaml_scalar(v: &serde_yaml::Value) -> bool {
139 matches!(
140 v,
141 serde_yaml::Value::Null
142 | serde_yaml::Value::Bool(_)
143 | serde_yaml::Value::Number(_)
144 | serde_yaml::Value::String(_)
145 )
146}
147
148fn is_yaml_null(v: &serde_yaml::Value) -> bool {
149 matches!(v, serde_yaml::Value::Null)
150}
151
152fn issue(path: &str, code: &str, message: &str) -> ValidationIssue {
153 ValidationIssue {
154 path: path.to_string(),
155 code: code.to_string(),
156 message: message.to_string(),
157 }
158}