claude-plugin-validate 0.1.1

CLI validator for Claude Code plugin manifests and plugin content schemas.
Documentation
use super::common::{is_rel_json_path, obj_get, push_issue, validate_string_map};
use crate::ValidationIssue;
use serde_json::{Map, Value};

pub(super) fn validate_lsp_servers_field(
    root: &Map<String, Value>,
    issues: &mut Vec<ValidationIssue>,
) {
    let Some(value) = obj_get(root, "lspServers") else {
        return;
    };

    validate_lsp_servers_value("lspServers", value, issues);
}

fn validate_lsp_servers_value(path: &str, value: &Value, issues: &mut Vec<ValidationIssue>) {
    if let Some(s) = value.as_str() {
        if !is_rel_json_path(s) {
            push_issue(
                issues,
                path,
                "invalid_string",
                "Path must start with ./ and end with .json",
            );
        }
        return;
    }

    if let Some(arr) = value.as_array() {
        for (idx, item) in arr.iter().enumerate() {
            validate_lsp_servers_value(&format!("{path}.{idx}"), item, issues);
        }
        return;
    }

    let Some(map) = value.as_object() else {
        push_issue(
            issues,
            path,
            "invalid_type",
            "lspServers must be path | map | array",
        );
        return;
    };

    for (server_name, server_cfg) in map {
        validate_lsp_server_config(&format!("{path}.{server_name}"), server_cfg, issues);
    }
}

fn validate_lsp_server_config(path: &str, value: &Value, issues: &mut Vec<ValidationIssue>) {
    let Some(obj) = value.as_object() else {
        push_issue(
            issues,
            path,
            "invalid_type",
            "LSP server config must be an object",
        );
        return;
    };

    match obj.get("command").and_then(Value::as_str) {
        Some(cmd) if !cmd.is_empty() => {
            if cmd.contains(' ') && !cmd.starts_with('/') {
                push_issue(
                    issues,
                    &format!("{path}.command"),
                    "custom",
                    "Command should not contain spaces. Use args for arguments.",
                );
            }
        }
        _ => push_issue(
            issues,
            &format!("{path}.command"),
            "missing_required",
            "command must be a non-empty string",
        ),
    }

    if let Some(args) = obj.get("args") {
        let Some(arr) = args.as_array() else {
            push_issue(
                issues,
                &format!("{path}.args"),
                "invalid_type",
                "args must be an array of non-empty strings",
            );
            return;
        };

        for (idx, item) in arr.iter().enumerate() {
            match item.as_str() {
                Some(s) if !s.is_empty() => {}
                _ => push_issue(
                    issues,
                    &format!("{path}.args.{idx}"),
                    "invalid_type",
                    "arg must be a non-empty string",
                ),
            }
        }
    }

    match obj.get("extensionToLanguage").and_then(Value::as_object) {
        Some(ext_map) if !ext_map.is_empty() => {
            for (ext, lang) in ext_map {
                if !ext.starts_with('.') {
                    push_issue(
                        issues,
                        &format!("{path}.extensionToLanguage.{ext}"),
                        "invalid_string",
                        "Extension key must start with dot",
                    );
                }
                if lang.as_str().map(|s| s.is_empty()).unwrap_or(true) {
                    push_issue(
                        issues,
                        &format!("{path}.extensionToLanguage.{ext}"),
                        "invalid_type",
                        "Language must be a non-empty string",
                    );
                }
            }
        }
        _ => push_issue(
            issues,
            &format!("{path}.extensionToLanguage"),
            "missing_required",
            "extensionToLanguage must be a non-empty map",
        ),
    }

    if let Some(transport) = obj.get("transport") {
        match transport.as_str() {
            Some("stdio") | Some("socket") => {}
            Some(_) => push_issue(
                issues,
                &format!("{path}.transport"),
                "invalid_enum_value",
                "transport must be stdio or socket",
            ),
            None => push_issue(
                issues,
                &format!("{path}.transport"),
                "invalid_type",
                "transport must be a string",
            ),
        }
    }

    if let Some(v) = obj.get("env") {
        validate_string_map(&format!("{path}.env"), v, issues);
    }

    if let Some(v) = obj.get("workspaceFolder")
        && !v.is_string()
    {
        push_issue(
            issues,
            &format!("{path}.workspaceFolder"),
            "invalid_type",
            "workspaceFolder must be a string",
        );
    }

    for pos_int_key in ["startupTimeout", "shutdownTimeout"] {
        if let Some(v) = obj.get(pos_int_key) {
            match v.as_i64() {
                Some(i) if i > 0 => {}
                Some(_) => push_issue(
                    issues,
                    &format!("{path}.{pos_int_key}"),
                    "too_small",
                    &format!("{pos_int_key} must be > 0"),
                ),
                None => push_issue(
                    issues,
                    &format!("{path}.{pos_int_key}"),
                    "invalid_type",
                    &format!("{pos_int_key} must be an integer"),
                ),
            }
        }
    }

    if let Some(v) = obj.get("restartOnCrash")
        && !v.is_boolean()
    {
        push_issue(
            issues,
            &format!("{path}.restartOnCrash"),
            "invalid_type",
            "restartOnCrash must be a boolean",
        );
    }

    if let Some(v) = obj.get("maxRestarts") {
        match v.as_i64() {
            Some(i) if i >= 0 => {}
            Some(_) => push_issue(
                issues,
                &format!("{path}.maxRestarts"),
                "too_small",
                "maxRestarts must be >= 0",
            ),
            None => push_issue(
                issues,
                &format!("{path}.maxRestarts"),
                "invalid_type",
                "maxRestarts must be an integer",
            ),
        }
    }
}