Skip to main content

cc_plugin_validator/
component.rs

1use 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}