use crate::{
config::LintConfig,
diagnostics::{Diagnostic, Fix},
rules::{Validator, ValidatorMetadata},
};
use rust_i18n::t;
use std::path::Path;
const RULE_IDS: &[&str] = &[
"CC-PL-001",
"CC-PL-002",
"CC-PL-003",
"CC-PL-004",
"CC-PL-005",
"CC-PL-006",
"CC-PL-007",
"CC-PL-008",
"CC-PL-009",
"CC-PL-010",
"CC-PL-011",
"CC-PL-012",
"CC-PL-013",
"CC-PL-014",
];
pub struct PluginValidator;
impl Validator for PluginValidator {
fn metadata(&self) -> ValidatorMetadata {
ValidatorMetadata {
name: self.name(),
rule_ids: RULE_IDS,
}
}
fn validate(&self, path: &Path, content: &str, config: &LintConfig) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
if !config.rules().plugins {
return diagnostics;
}
let plugin_dir = path.parent();
let is_in_claude_plugin = plugin_dir
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.map(|n| n == ".claude-plugin")
.unwrap_or(false);
if config.is_rule_enabled("CC-PL-001") && !is_in_claude_plugin {
diagnostics.push(
Diagnostic::error(
path.to_path_buf(),
1,
0,
"CC-PL-001",
t!("rules.cc_pl_001.message"),
)
.with_suggestion(t!("rules.cc_pl_001.suggestion")),
);
}
#[allow(clippy::collapsible_if)]
if config.is_rule_enabled("CC-PL-002") && is_in_claude_plugin {
if let Some(plugin_dir) = plugin_dir {
let fs = config.fs();
let disallowed = ["skills", "agents", "hooks", "commands"];
for entry in disallowed {
if fs.exists(&plugin_dir.join(entry)) {
diagnostics.push(
Diagnostic::error(
path.to_path_buf(),
1,
0,
"CC-PL-002",
t!("rules.cc_pl_002.message", component = entry),
)
.with_suggestion(t!("rules.cc_pl_002.suggestion")),
);
}
}
}
}
let raw_value: serde_json::Value = match serde_json::from_str(content) {
Ok(v) => v,
Err(e) => {
if config.is_rule_enabled("CC-PL-006") {
diagnostics.push(
Diagnostic::error(
path.to_path_buf(),
1,
0,
"CC-PL-006",
t!("rules.cc_pl_006.message", error = e.to_string()),
)
.with_suggestion(t!("rules.cc_pl_006.suggestion")),
);
}
return diagnostics;
}
};
if config.is_rule_enabled("CC-PL-004") {
check_required_field(&raw_value, "name", path, diagnostics.as_mut());
check_recommended_field(&raw_value, "description", path, diagnostics.as_mut());
check_recommended_field(&raw_value, "version", path, diagnostics.as_mut());
}
if config.is_rule_enabled("CC-PL-005") {
if let Some(name) = raw_value.get("name").and_then(|v| v.as_str()) {
if name.trim().is_empty() {
let mut diagnostic = Diagnostic::error(
path.to_path_buf(),
1,
0,
"CC-PL-005",
t!("rules.cc_pl_005.message"),
)
.with_suggestion(t!("rules.cc_pl_005.suggestion"));
if let Some((start, end, _)) =
find_unique_json_string_value_range(content, "name")
{
diagnostic = diagnostic.with_fix(Fix::replace(
start,
end,
"my-plugin",
"Set plugin name to 'my-plugin'",
false,
));
}
diagnostics.push(diagnostic);
}
}
}
let pl_007_enabled = config.is_rule_enabled("CC-PL-007");
let pl_008_enabled = config.is_rule_enabled("CC-PL-008");
if pl_007_enabled || pl_008_enabled {
let path_fields = ["commands", "agents", "skills", "hooks"];
for field in path_fields {
if pl_007_enabled {
check_component_paths(&raw_value, field, path, content, &mut diagnostics);
}
if pl_008_enabled {
check_component_inside_claude_plugin(&raw_value, field, path, &mut diagnostics);
}
}
}
if config.is_rule_enabled("CC-PL-009") {
if let Some(author) = raw_value.get("author") {
if author.is_object() {
let name_empty = author
.get("name")
.and_then(|n| n.as_str())
.map(|n| n.trim().is_empty())
.unwrap_or(true);
if name_empty {
diagnostics.push(
Diagnostic::warning(
path.to_path_buf(),
1,
0,
"CC-PL-009",
t!("rules.cc_pl_009.message"),
)
.with_suggestion(t!("rules.cc_pl_009.suggestion")),
);
}
} else {
diagnostics.push(
Diagnostic::warning(
path.to_path_buf(),
1,
0,
"CC-PL-009",
t!("rules.cc_pl_009.message"),
)
.with_suggestion(t!("rules.cc_pl_009.suggestion")),
);
}
}
}
if config.is_rule_enabled("CC-PL-010") {
if let Some(homepage_val) = raw_value.get("homepage") {
match homepage_val.as_str() {
Some(homepage) => {
if !homepage.is_empty() && !is_valid_url(homepage) {
diagnostics.push(
Diagnostic::warning(
path.to_path_buf(),
1,
0,
"CC-PL-010",
t!("rules.cc_pl_010.message", url = homepage),
)
.with_suggestion(t!("rules.cc_pl_010.suggestion")),
);
}
}
None => {
let val_str = homepage_val.to_string();
diagnostics.push(
Diagnostic::warning(
path.to_path_buf(),
1,
0,
"CC-PL-010",
t!("rules.cc_pl_010.message", url = val_str.as_str()),
)
.with_suggestion(t!("rules.cc_pl_010.suggestion")),
);
}
}
}
}
if config.is_rule_enabled("CC-PL-003") {
if let Some(version) = raw_value.get("version").and_then(|v| v.as_str()) {
let trimmed = version.trim();
if !trimmed.is_empty() && !is_valid_semver(trimmed) {
let mut diagnostic = Diagnostic::error(
path.to_path_buf(),
1,
0,
"CC-PL-003",
t!("rules.cc_pl_003.message", version = version),
)
.with_suggestion(t!("rules.cc_pl_003.suggestion"));
let normalized = normalize_partial_semver(trimmed);
if let Some(norm) = &normalized {
if norm != trimmed {
if let Some((start, end)) =
crate::rules::find_unique_json_string_value_span(
content, "version", version,
)
{
diagnostic = diagnostic.with_fix(Fix::replace(
start,
end,
norm.as_str(),
format!("Normalize version to '{}'", norm),
false,
));
}
}
}
diagnostics.push(diagnostic);
}
}
}
if config.is_rule_enabled("CC-PL-011") {
if let Some(lsp_servers) = raw_value.get("lspServers") {
if let Some(obj) = lsp_servers.as_object() {
for (server_name, server_val) in obj {
if let Some(server_obj) = server_val.as_object() {
if !server_obj.contains_key("command") {
diagnostics.push(
Diagnostic::error(
path.to_path_buf(),
1,
0,
"CC-PL-011",
format!(
"LSP server '{}' is missing required field 'command'",
server_name
),
)
.with_suggestion(format!(
"Add a 'command' string to LSP server '{}'",
server_name
)),
);
}
if !server_obj.contains_key("extensionToLanguage") {
diagnostics.push(
Diagnostic::error(
path.to_path_buf(),
1,
0,
"CC-PL-011",
format!(
"LSP server '{}' is missing required field 'extensionToLanguage'",
server_name
),
)
.with_suggestion(format!(
"Add an 'extensionToLanguage' mapping to LSP server '{}'",
server_name
)),
);
}
}
}
}
}
}
if config.is_rule_enabled("CC-PL-012") {
if let Some(user_config) = raw_value.get("userConfig") {
if let Some(obj) = user_config.as_object() {
for key in obj.keys() {
if !is_valid_identifier(key) {
diagnostics.push(
Diagnostic::warning(
path.to_path_buf(),
1,
0,
"CC-PL-012",
format!(
"Invalid userConfig key '{}': must be a valid identifier (alphanumeric and underscores, not starting with a number)",
key
),
)
.with_suggestion(format!(
"Rename '{}' to a valid identifier (e.g., 'my_config_key')",
key
)),
);
}
}
}
}
}
if config.is_rule_enabled("CC-PL-013") {
if let Some(channels) = raw_value.get("channels") {
if let Some(arr) = channels.as_array() {
let mcp_server_keys: Vec<String> = raw_value
.get("mcpServers")
.and_then(|v| v.as_object())
.map(|obj| obj.keys().cloned().collect())
.unwrap_or_default();
for (i, entry) in arr.iter().enumerate() {
match entry.get("server").and_then(|v| v.as_str()) {
None => {
diagnostics.push(
Diagnostic::warning(
path.to_path_buf(),
1,
0,
"CC-PL-013",
format!(
"channels[{}] is missing required 'server' field",
i
),
)
.with_suggestion(
"Add a 'server' field referencing a key in 'mcpServers'"
.to_string(),
),
);
}
Some(server_ref) => {
if !mcp_server_keys.is_empty()
&& !mcp_server_keys.contains(&server_ref.to_string())
{
diagnostics.push(
Diagnostic::warning(
path.to_path_buf(),
1,
0,
"CC-PL-013",
format!(
"channels[{}] references server '{}' which is not defined in 'mcpServers'",
i, server_ref
),
)
.with_suggestion(format!(
"Add '{}' to 'mcpServers' or fix the server reference",
server_ref
)),
);
}
}
}
}
}
}
}
if config.is_rule_enabled("CC-PL-014") {
if let Some(agents) = raw_value.get("agents") {
let unsupported_fields = ["hooks", "mcpServers", "permissionMode"];
let agent_entries: Vec<(&str, &serde_json::Value)> =
if let Some(obj) = agents.as_object() {
obj.iter().map(|(k, v)| (k.as_str(), v)).collect()
} else if let Some(arr) = agents.as_array() {
arr.iter()
.map(|v| {
let name = v.get("name").and_then(|n| n.as_str()).unwrap_or("");
(name, v)
})
.collect()
} else {
vec![]
};
for (agent_name, agent_val) in agent_entries {
if let Some(agent_obj) = agent_val.as_object() {
for field in unsupported_fields {
if agent_obj.contains_key(field) {
let label = if agent_name.is_empty() {
"Plugin agent".to_string()
} else {
format!("Plugin agent '{}'", agent_name)
};
diagnostics.push(
Diagnostic::warning(
path.to_path_buf(),
1,
0,
"CC-PL-014",
format!(
"{} uses unsupported field '{}' (ignored for plugin agents)",
label, field
),
)
.with_suggestion(format!(
"Remove '{}' from the plugin agent definition",
field
)),
);
}
}
}
}
}
}
diagnostics
}
}
fn is_field_missing(value: &serde_json::Value, field: &str) -> bool {
match value.get(field) {
Some(v) => !v.is_string() || v.as_str().map(|s| s.trim().is_empty()).unwrap_or(true),
None => true,
}
}
fn check_required_field(
value: &serde_json::Value,
field: &str,
path: &Path,
diagnostics: &mut Vec<Diagnostic>,
) {
if is_field_missing(value, field) {
diagnostics.push(
Diagnostic::error(
path.to_path_buf(),
1,
0,
"CC-PL-004",
t!("rules.cc_pl_004.message", field = field),
)
.with_suggestion(t!("rules.cc_pl_004.suggestion", field = field)),
);
}
}
fn check_recommended_field(
value: &serde_json::Value,
field: &str,
path: &Path,
diagnostics: &mut Vec<Diagnostic>,
) {
if is_field_missing(value, field) {
diagnostics.push(
Diagnostic::warning(
path.to_path_buf(),
1,
0,
"CC-PL-004",
t!("rules.cc_pl_004_recommended.message", field = field),
)
.with_suggestion(t!("rules.cc_pl_004_recommended.suggestion", field = field)),
);
}
}
fn is_valid_semver(version: &str) -> bool {
semver::Version::parse(version).is_ok()
}
fn normalize_partial_semver(version: &str) -> Option<String> {
let parts: Vec<&str> = version.split('.').collect();
match parts.len() {
1 => {
if parts[0].chars().all(|c| c.is_ascii_digit()) {
Some(format!("{}.0.0", parts[0]))
} else {
None
}
}
2 => {
if parts.iter().all(|p| p.chars().all(|c| c.is_ascii_digit())) {
Some(format!("{}.{}.0", parts[0], parts[1]))
} else {
None
}
}
_ => None,
}
}
fn find_unique_json_string_value_range(content: &str, key: &str) -> Option<(usize, usize, String)> {
crate::span_utils::find_unique_json_string_value_range(content, key)
}
fn is_invalid_component_path(p: &str) -> bool {
let trimmed = p.trim();
if trimmed.is_empty() {
return false;
}
if trimmed.starts_with('/') || trimmed.starts_with('\\') {
return true;
}
if trimmed.len() >= 2 {
let bytes = trimmed.as_bytes();
if bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
return true;
}
}
trimmed.split(['/', '\\']).any(|part| part == "..")
}
fn is_autofixable_path(p: &str) -> bool {
let trimmed = p.trim();
!trimmed.is_empty()
&& !trimmed.starts_with("./")
&& !trimmed.starts_with(".\\")
&& !is_invalid_component_path(trimmed)
}
fn path_inside_claude_plugin(p: &str) -> bool {
let trimmed = p.trim();
let normalized = trimmed
.strip_prefix("./")
.or_else(|| trimmed.strip_prefix(".\\"))
.unwrap_or(trimmed);
normalized.starts_with(".claude-plugin/")
|| normalized.starts_with(".claude-plugin\\")
|| normalized == ".claude-plugin"
}
fn extract_paths(value: &serde_json::Value) -> Vec<String> {
match value {
serde_json::Value::String(s) => vec![s.clone()],
serde_json::Value::Array(arr) => arr
.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect(),
_ => vec![],
}
}
fn check_component_paths(
raw_value: &serde_json::Value,
field: &str,
path: &Path,
content: &str,
diagnostics: &mut Vec<Diagnostic>,
) {
if let Some(val) = raw_value.get(field) {
for p in extract_paths(val) {
if is_invalid_component_path(&p) {
diagnostics.push(
Diagnostic::error(
path.to_path_buf(),
1,
0,
"CC-PL-007",
t!("rules.cc_pl_007.message", field = field, path = p.as_str()),
)
.with_suggestion(t!("rules.cc_pl_007.suggestion")),
);
} else if is_autofixable_path(&p) {
let mut diagnostic = Diagnostic::error(
path.to_path_buf(),
1,
0,
"CC-PL-007",
t!("rules.cc_pl_007.message", field = field, path = p.as_str()),
)
.with_suggestion(t!("rules.cc_pl_007.suggestion"));
if let Some((start, end, _)) = find_unique_json_string_value_range(content, field) {
let fixed = format!("./{}", p.trim());
diagnostic = diagnostic.with_fix(Fix::replace(
start,
end,
&fixed,
format!("Prepend './' to path: '{}'", p.trim()),
true,
));
}
diagnostics.push(diagnostic);
}
}
}
}
fn check_component_inside_claude_plugin(
raw_value: &serde_json::Value,
field: &str,
path: &Path,
diagnostics: &mut Vec<Diagnostic>,
) {
if let Some(val) = raw_value.get(field) {
for p in extract_paths(val) {
if path_inside_claude_plugin(&p) {
diagnostics.push(
Diagnostic::error(
path.to_path_buf(),
1,
0,
"CC-PL-008",
t!("rules.cc_pl_008.message", field = field, path = p.as_str()),
)
.with_suggestion(t!("rules.cc_pl_008.suggestion")),
);
}
}
}
}
fn is_valid_url(url: &str) -> bool {
url.starts_with("http://") || url.starts_with("https://")
}
fn is_valid_identifier(s: &str) -> bool {
if s.is_empty() {
return false;
}
let mut chars = s.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::LintConfig;
use std::fs;
use tempfile::TempDir;
fn write_plugin(path: &Path, content: &str) {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, content).unwrap();
}
#[test]
fn test_cc_pl_001_manifest_not_in_claude_plugin() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test-plugin","description":"desc","version":"1.0.0"}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(diagnostics.iter().any(|d| d.rule == "CC-PL-001"));
}
#[test]
fn test_cc_pl_002_components_in_claude_plugin() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test-plugin","description":"desc","version":"1.0.0"}"#,
);
fs::create_dir_all(temp.path().join(".claude-plugin").join("skills")).unwrap();
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(diagnostics.iter().any(|d| d.rule == "CC-PL-002"));
}
#[test]
fn test_cc_pl_003_invalid_semver() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test-plugin","description":"desc","version":"1.0"}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(diagnostics.iter().any(|d| d.rule == "CC-PL-003"));
}
#[test]
fn test_cc_pl_003_has_fix() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
let content = r#"{"name":"test-plugin","description":"desc","version":"1.0"}"#;
write_plugin(&plugin_path, content);
let validator = PluginValidator;
let diagnostics = validator.validate(&plugin_path, content, &LintConfig::default());
let cc_pl_003: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "CC-PL-003")
.collect();
assert_eq!(cc_pl_003.len(), 1);
assert!(
cc_pl_003[0].has_fixes(),
"CC-PL-003 should have auto-fix for partial semver"
);
let fix = &cc_pl_003[0].fixes[0];
assert!(!fix.safe, "CC-PL-003 fix should be unsafe");
assert_eq!(
fix.replacement, "1.0.0",
"Fix should normalize '1.0' to '1.0.0'"
);
}
#[test]
fn test_cc_pl_003_valid_prerelease_version() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test-plugin","description":"desc","version":"4.0.0-rc.1"}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(!diagnostics.iter().any(|d| d.rule == "CC-PL-003"));
}
#[test]
fn test_cc_pl_003_valid_build_metadata() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test-plugin","description":"desc","version":"1.0.0+build.123"}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(!diagnostics.iter().any(|d| d.rule == "CC-PL-003"));
}
#[test]
fn test_cc_pl_003_skips_empty_version() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test-plugin","description":"desc","version":""}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
let pl_004 = diagnostics
.iter()
.find(|d| d.rule == "CC-PL-004")
.expect("CC-PL-004 should be reported for empty version");
assert_eq!(
pl_004.level,
crate::diagnostics::DiagnosticLevel::Warning,
"Empty version should be a warning, not an error"
);
assert!(!diagnostics.iter().any(|d| d.rule == "CC-PL-003"));
}
#[test]
fn test_cc_pl_004_missing_recommended_fields() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(&plugin_path, r#"{"name":"test-plugin"}"#);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
let pl_004: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "CC-PL-004")
.collect();
assert_eq!(
pl_004.len(),
2,
"Should warn for missing description and version"
);
for d in &pl_004 {
assert_eq!(
d.level,
crate::diagnostics::DiagnosticLevel::Warning,
"Missing description/version should be warnings, not errors"
);
}
}
#[test]
fn test_cc_pl_005_empty_name() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":" ","description":"desc","version":"1.0.0"}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
let cc_pl_005 = diagnostics
.iter()
.find(|d| d.rule == "CC-PL-005")
.expect("CC-PL-005 should be reported");
assert!(cc_pl_005.has_fixes());
let fix = &cc_pl_005.fixes[0];
assert_eq!(fix.replacement, "my-plugin");
assert!(!fix.safe);
}
#[test]
fn test_cc_pl_006_invalid_json() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(&plugin_path, r#"{ invalid json }"#);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
let parse_errors: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "CC-PL-006")
.collect();
assert_eq!(parse_errors.len(), 1);
assert!(parse_errors[0].message.contains("Failed to parse"));
}
#[test]
fn test_cc_pl_006_truncated_json() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(&plugin_path, r#"{"name":"test"#);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(diagnostics.iter().any(|d| d.rule == "CC-PL-006"));
}
#[test]
fn test_cc_pl_006_empty_file() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(&plugin_path, "");
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(diagnostics.iter().any(|d| d.rule == "CC-PL-006"));
}
#[test]
fn test_cc_pl_006_valid_json_no_error() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0"}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(!diagnostics.iter().any(|d| d.rule == "CC-PL-006"));
}
#[test]
fn test_cc_pl_006_disabled() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(&plugin_path, r#"{ invalid }"#);
let mut config = LintConfig::default();
config.rules_mut().disabled_rules = vec!["CC-PL-006".to_string()];
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&config,
);
assert!(!diagnostics.iter().any(|d| d.rule == "CC-PL-006"));
}
#[test]
fn test_cc_pl_001_valid_location_no_error() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0"}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(!diagnostics.iter().any(|d| d.rule == "CC-PL-001"));
}
#[test]
fn test_cc_pl_001_disabled() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0"}"#,
);
let mut config = LintConfig::default();
config.rules_mut().disabled_rules = vec!["CC-PL-001".to_string()];
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&config,
);
assert!(!diagnostics.iter().any(|d| d.rule == "CC-PL-001"));
}
#[test]
fn test_cc_pl_002_no_components_no_error() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0"}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(!diagnostics.iter().any(|d| d.rule == "CC-PL-002"));
}
#[test]
fn test_cc_pl_002_multiple_components() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0"}"#,
);
fs::create_dir_all(temp.path().join(".claude-plugin").join("skills")).unwrap();
fs::create_dir_all(temp.path().join(".claude-plugin").join("agents")).unwrap();
fs::create_dir_all(temp.path().join(".claude-plugin").join("hooks")).unwrap();
fs::create_dir_all(temp.path().join(".claude-plugin").join("commands")).unwrap();
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
let pl_002_errors: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "CC-PL-002")
.collect();
assert_eq!(pl_002_errors.len(), 4);
}
#[test]
fn test_cc_pl_004_all_fields_present_no_error() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"A test plugin","version":"1.0.0"}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(!diagnostics.iter().any(|d| d.rule == "CC-PL-004"));
}
#[test]
fn test_cc_pl_004_empty_string_values() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"","version":""}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
let pl_004_warnings: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "CC-PL-004")
.collect();
assert_eq!(pl_004_warnings.len(), 2);
for d in &pl_004_warnings {
assert_eq!(
d.level,
crate::diagnostics::DiagnosticLevel::Warning,
"Empty description/version should be warnings"
);
}
}
#[test]
fn test_cc_pl_004_missing_name_is_error() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(&plugin_path, r#"{"description":"d","version":"1.0.0"}"#);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
let name_error = diagnostics
.iter()
.find(|d| {
d.rule == "CC-PL-004" && d.level == crate::diagnostics::DiagnosticLevel::Error
})
.expect("CC-PL-004 error should be reported for missing name");
assert!(
name_error.message.contains("name"),
"Error message should mention 'name'"
);
}
#[test]
fn test_cc_pl_004_only_name_present_no_errors() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(&plugin_path, r#"{"name":"test"}"#);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(
!diagnostics
.iter()
.any(|d| d.rule == "CC-PL-004"
&& d.level == crate::diagnostics::DiagnosticLevel::Error),
"With name present, there should be zero CC-PL-004 errors"
);
let warnings: Vec<_> = diagnostics
.iter()
.filter(|d| {
d.rule == "CC-PL-004" && d.level == crate::diagnostics::DiagnosticLevel::Warning
})
.collect();
assert_eq!(
warnings.len(),
2,
"Should have warnings for missing description and version"
);
assert!(
warnings.iter().any(|d| d.message.contains("description")),
"Should mention 'description' in warning"
);
assert!(
warnings.iter().any(|d| d.message.contains("version")),
"Should mention 'version' in warning"
);
}
#[test]
fn test_cc_pl_004_non_string_name_is_error() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":123,"description":"desc","version":"1.0.0"}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
let name_error = diagnostics.iter().find(|d| {
d.rule == "CC-PL-004" && d.level == crate::diagnostics::DiagnosticLevel::Error
});
assert!(
name_error.is_some(),
"Non-string name should trigger CC-PL-004 error"
);
}
#[test]
fn test_cc_pl_004_non_string_recommended_fields_are_warnings() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":123,"version":true}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
let warnings: Vec<_> = diagnostics
.iter()
.filter(|d| {
d.rule == "CC-PL-004" && d.level == crate::diagnostics::DiagnosticLevel::Warning
})
.collect();
assert_eq!(
warnings.len(),
2,
"Non-string description and version should trigger warnings"
);
}
#[test]
fn test_cc_pl_003_skips_when_version_absent() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(&plugin_path, r#"{"name":"test"}"#);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(
!diagnostics.iter().any(|d| d.rule == "CC-PL-003"),
"CC-PL-003 should not fire when version is absent"
);
}
#[test]
fn test_cc_pl_003_fires_despite_non_string_description() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":123,"version":"1.0"}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(
diagnostics.iter().any(|d| d.rule == "CC-PL-003"),
"CC-PL-003 should fire for invalid semver even with non-string description"
);
assert!(
diagnostics.iter().any(|d| d.rule == "CC-PL-004"
&& d.level == crate::diagnostics::DiagnosticLevel::Warning),
"CC-PL-004 warning should fire for non-string description"
);
}
#[test]
fn test_cc_pl_004_disabled_via_config() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(&plugin_path, r#"{}"#);
let mut config = LintConfig::default();
config.rules_mut().disabled_rules = vec!["CC-PL-004".to_string()];
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&config,
);
assert!(
!diagnostics.iter().any(|d| d.rule == "CC-PL-004"),
"CC-PL-004 should not fire when disabled"
);
}
#[test]
fn test_cc_pl_004_whitespace_only_name_is_error() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":" ","description":"desc","version":"1.0.0"}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(
diagnostics
.iter()
.any(|d| d.rule == "CC-PL-004"
&& d.level == crate::diagnostics::DiagnosticLevel::Error),
"Whitespace-only name should trigger CC-PL-004 error"
);
}
#[test]
fn test_cc_pl_004_whitespace_only_recommended_fields() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":" ","version":" "}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
let warnings: Vec<_> = diagnostics
.iter()
.filter(|d| {
d.rule == "CC-PL-004" && d.level == crate::diagnostics::DiagnosticLevel::Warning
})
.collect();
assert_eq!(
warnings.len(),
2,
"Whitespace-only description and version should trigger warnings"
);
}
#[test]
fn test_cc_pl_005_non_empty_name_no_error() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"my-plugin","description":"desc","version":"1.0.0"}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(!diagnostics.iter().any(|d| d.rule == "CC-PL-005"));
}
#[test]
fn test_config_disabled_plugins_category() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join("plugin.json");
write_plugin(&plugin_path, r#"{ invalid json }"#);
let mut config = LintConfig::default();
config.rules_mut().plugins = false;
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&config,
);
assert!(diagnostics.is_empty());
}
#[test]
fn test_cc_pl_007_absolute_path() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0","commands":"/usr/local/bin/cmd"}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(diagnostics.iter().any(|d| d.rule == "CC-PL-007"));
}
#[test]
fn test_cc_pl_007_windows_absolute_path() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0","skills":"C:\\Users\\skills"}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(diagnostics.iter().any(|d| d.rule == "CC-PL-007"));
}
#[test]
fn test_cc_pl_007_traversal_path() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0","agents":"../outside/agents"}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(diagnostics.iter().any(|d| d.rule == "CC-PL-007"));
}
#[test]
fn test_cc_pl_007_embedded_traversal() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0","hooks":"./valid/../escape"}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(diagnostics.iter().any(|d| d.rule == "CC-PL-007"));
}
#[test]
fn test_cc_pl_007_array_of_paths() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0","skills":["./valid","../invalid"]}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
let pl_007: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "CC-PL-007")
.collect();
assert_eq!(
pl_007.len(),
1,
"Only the invalid path should trigger CC-PL-007"
);
}
#[test]
fn test_cc_pl_007_valid_relative_path_no_error() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0","commands":"./commands/"}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(!diagnostics.iter().any(|d| d.rule == "CC-PL-007"));
}
#[test]
fn test_cc_pl_007_no_path_fields_no_error() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0"}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(!diagnostics.iter().any(|d| d.rule == "CC-PL-007"));
}
#[test]
fn test_cc_pl_007_disabled() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0","commands":"/absolute"}"#,
);
let mut config = LintConfig::default();
config.rules_mut().disabled_rules = vec!["CC-PL-007".to_string()];
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&config,
);
assert!(!diagnostics.iter().any(|d| d.rule == "CC-PL-007"));
}
#[test]
fn test_cc_pl_008_path_inside_claude_plugin() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0","agents":".claude-plugin/agents"}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(diagnostics.iter().any(|d| d.rule == "CC-PL-008"));
}
#[test]
fn test_cc_pl_008_array_with_mixed_paths() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0","skills":["./valid",".claude-plugin/invalid"]}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(diagnostics.iter().any(|d| d.rule == "CC-PL-008"));
}
#[test]
fn test_cc_pl_008_valid_path_no_error() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0","skills":"./skills/"}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(!diagnostics.iter().any(|d| d.rule == "CC-PL-008"));
}
#[test]
fn test_cc_pl_008_disabled() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0","agents":".claude-plugin/agents"}"#,
);
let mut config = LintConfig::default();
config.rules_mut().disabled_rules = vec!["CC-PL-008".to_string()];
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&config,
);
assert!(!diagnostics.iter().any(|d| d.rule == "CC-PL-008"));
}
#[test]
fn test_cc_pl_009_empty_author_name() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0","author":{"name":""}}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(diagnostics.iter().any(|d| d.rule == "CC-PL-009"));
}
#[test]
fn test_cc_pl_009_whitespace_author_name() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0","author":{"name":" "}}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(diagnostics.iter().any(|d| d.rule == "CC-PL-009"));
}
#[test]
fn test_cc_pl_009_missing_author_name() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0","author":{"email":"a@b.com"}}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(diagnostics.iter().any(|d| d.rule == "CC-PL-009"));
}
#[test]
fn test_cc_pl_009_author_not_object() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0","author":"just a string"}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(diagnostics.iter().any(|d| d.rule == "CC-PL-009"));
}
#[test]
fn test_cc_pl_009_valid_author_no_error() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0","author":{"name":"Test Author"}}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(!diagnostics.iter().any(|d| d.rule == "CC-PL-009"));
}
#[test]
fn test_cc_pl_009_no_author_no_error() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0"}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(!diagnostics.iter().any(|d| d.rule == "CC-PL-009"));
}
#[test]
fn test_cc_pl_009_disabled() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0","author":{"name":""}}"#,
);
let mut config = LintConfig::default();
config.rules_mut().disabled_rules = vec!["CC-PL-009".to_string()];
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&config,
);
assert!(!diagnostics.iter().any(|d| d.rule == "CC-PL-009"));
}
#[test]
fn test_cc_pl_010_invalid_url() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0","homepage":"not-a-url"}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(diagnostics.iter().any(|d| d.rule == "CC-PL-010"));
}
#[test]
fn test_cc_pl_010_ftp_url() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0","homepage":"ftp://example.com"}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(diagnostics.iter().any(|d| d.rule == "CC-PL-010"));
}
#[test]
fn test_cc_pl_010_valid_https_url_no_error() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0","homepage":"https://example.com"}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(!diagnostics.iter().any(|d| d.rule == "CC-PL-010"));
}
#[test]
fn test_cc_pl_010_valid_http_url_no_error() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0","homepage":"http://example.com"}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(!diagnostics.iter().any(|d| d.rule == "CC-PL-010"));
}
#[test]
fn test_cc_pl_010_no_homepage_no_error() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0"}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(!diagnostics.iter().any(|d| d.rule == "CC-PL-010"));
}
#[test]
fn test_cc_pl_010_empty_homepage_no_error() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0","homepage":""}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(!diagnostics.iter().any(|d| d.rule == "CC-PL-010"));
}
#[test]
fn test_cc_pl_010_disabled() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0","homepage":"not-a-url"}"#,
);
let mut config = LintConfig::default();
config.rules_mut().disabled_rules = vec!["CC-PL-010".to_string()];
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&config,
);
assert!(!diagnostics.iter().any(|d| d.rule == "CC-PL-010"));
}
#[test]
fn test_cc_pl_007_windows_forward_slash_absolute() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0","commands":"C:/Users/skills"}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(
diagnostics.iter().any(|d| d.rule == "CC-PL-007"),
"C:/ forward-slash Windows paths should be detected"
);
}
#[test]
fn test_cc_pl_007_trailing_traversal() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0","hooks":"./foo/.."}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(
diagnostics.iter().any(|d| d.rule == "CC-PL-007"),
"Trailing /.. should be detected as traversal"
);
}
#[test]
fn test_cc_pl_007_mixed_slash_traversal() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0","agents":"./foo/..\\bar"}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(
diagnostics.iter().any(|d| d.rule == "CC-PL-007"),
"Mixed slash traversal should be detected"
);
}
#[test]
fn test_cc_pl_007_autofixable_path() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0","commands":"commands/run"}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
let pl_007: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "CC-PL-007")
.collect();
assert_eq!(
pl_007.len(),
1,
"Missing ./ prefix should trigger CC-PL-007"
);
assert!(!pl_007[0].fixes.is_empty(), "Should have a safe autofix");
assert!(pl_007[0].fixes[0].safe, "Autofix should be safe");
assert!(
pl_007[0].fixes[0].replacement.starts_with("./"),
"Autofix should prepend ./"
);
}
#[test]
fn test_cc_pl_008_dot_slash_prefix_bypass() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0","agents":"./.claude-plugin/agents"}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(
diagnostics.iter().any(|d| d.rule == "CC-PL-008"),
"./.claude-plugin/ should still be detected"
);
}
#[test]
fn test_cc_pl_010_non_string_homepage() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0","homepage":123}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(
diagnostics.iter().any(|d| d.rule == "CC-PL-010"),
"Non-string homepage should trigger CC-PL-010"
);
}
#[test]
fn test_cc_pl_006_has_suggestion() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(&plugin_path, r#"{ invalid json }"#);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
let cc_pl_006: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "CC-PL-006")
.collect();
assert_eq!(cc_pl_006.len(), 1);
assert!(
cc_pl_006[0].suggestion.is_some(),
"CC-PL-006 should have a suggestion"
);
assert!(
cc_pl_006[0]
.suggestion
.as_ref()
.unwrap()
.contains("Validate JSON syntax"),
"CC-PL-006 suggestion should mention JSON syntax"
);
}
#[test]
fn test_cc_pl_011_missing_command() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0","lspServers":{"myServer":{"extensionToLanguage":{".rs":"rust"}}}}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
let pl_011: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "CC-PL-011")
.collect();
assert_eq!(pl_011.len(), 1);
assert!(
pl_011[0].message.contains("command"),
"Should mention missing 'command'"
);
}
#[test]
fn test_cc_pl_011_missing_extension_to_language() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0","lspServers":{"myServer":{"command":"rust-analyzer"}}}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
let pl_011: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "CC-PL-011")
.collect();
assert_eq!(pl_011.len(), 1);
assert!(
pl_011[0].message.contains("extensionToLanguage"),
"Should mention missing 'extensionToLanguage'"
);
}
#[test]
fn test_cc_pl_011_missing_both_fields() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0","lspServers":{"myServer":{}}}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
let pl_011: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "CC-PL-011")
.collect();
assert_eq!(pl_011.len(), 2, "Should report both missing fields");
}
#[test]
fn test_cc_pl_011_valid_lsp_server_no_error() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0","lspServers":{"myServer":{"command":"rust-analyzer","extensionToLanguage":{".rs":"rust"}}}}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(!diagnostics.iter().any(|d| d.rule == "CC-PL-011"));
}
#[test]
fn test_cc_pl_011_no_lsp_servers_no_error() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0"}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(!diagnostics.iter().any(|d| d.rule == "CC-PL-011"));
}
#[test]
fn test_cc_pl_012_key_starts_with_number() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0","userConfig":{"1invalid":{"type":"string"}}}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
let pl_012: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "CC-PL-012")
.collect();
assert_eq!(pl_012.len(), 1);
assert!(
pl_012[0].message.contains("1invalid"),
"Should mention the invalid key"
);
}
#[test]
fn test_cc_pl_012_key_with_special_chars() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0","userConfig":{"my-key":{"type":"string"},"my.key":{"type":"number"}}}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
let pl_012: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "CC-PL-012")
.collect();
assert_eq!(
pl_012.len(),
2,
"Both keys with special characters should be flagged"
);
}
#[test]
fn test_cc_pl_012_valid_keys_no_error() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0","userConfig":{"valid_key":{},"_also_valid":{},"camelCase123":{}}}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(!diagnostics.iter().any(|d| d.rule == "CC-PL-012"));
}
#[test]
fn test_cc_pl_012_no_user_config_no_error() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0"}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(!diagnostics.iter().any(|d| d.rule == "CC-PL-012"));
}
#[test]
fn test_cc_pl_013_missing_server() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0","mcpServers":{"myMcp":{}},"channels":[{"type":"inject"}]}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
let pl_013: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "CC-PL-013")
.collect();
assert_eq!(pl_013.len(), 1);
assert!(
pl_013[0].message.contains("missing"),
"Should mention missing 'server' field"
);
}
#[test]
fn test_cc_pl_013_server_not_in_mcp_servers() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0","mcpServers":{"myMcp":{}},"channels":[{"server":"nonexistent"}]}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
let pl_013: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "CC-PL-013")
.collect();
assert_eq!(pl_013.len(), 1);
assert!(
pl_013[0].message.contains("nonexistent"),
"Should mention the unresolved server reference"
);
}
#[test]
fn test_cc_pl_013_valid_server_reference_no_error() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0","mcpServers":{"myMcp":{}},"channels":[{"server":"myMcp"}]}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(!diagnostics.iter().any(|d| d.rule == "CC-PL-013"));
}
#[test]
fn test_cc_pl_013_no_channels_no_error() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0"}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(!diagnostics.iter().any(|d| d.rule == "CC-PL-013"));
}
#[test]
fn test_cc_pl_014_agent_with_hooks() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0","agents":{"myAgent":{"hooks":{"preCommit":"echo test"}}}}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
let pl_014: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "CC-PL-014")
.collect();
assert_eq!(pl_014.len(), 1);
assert!(
pl_014[0].message.contains("hooks"),
"Should mention unsupported 'hooks' field"
);
}
#[test]
fn test_cc_pl_014_agent_with_multiple_unsupported() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0","agents":{"myAgent":{"hooks":{},"mcpServers":{},"permissionMode":"full"}}}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
let pl_014: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "CC-PL-014")
.collect();
assert_eq!(pl_014.len(), 3, "Should flag all three unsupported fields");
}
#[test]
fn test_cc_pl_014_agent_without_unsupported_no_error() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0","agents":{"myAgent":{"name":"Agent","description":"A test agent"}}}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(!diagnostics.iter().any(|d| d.rule == "CC-PL-014"));
}
#[test]
fn test_cc_pl_014_no_agents_no_error() {
let temp = TempDir::new().unwrap();
let plugin_path = temp.path().join(".claude-plugin").join("plugin.json");
write_plugin(
&plugin_path,
r#"{"name":"test","description":"desc","version":"1.0.0"}"#,
);
let validator = PluginValidator;
let diagnostics = validator.validate(
&plugin_path,
&fs::read_to_string(&plugin_path).unwrap(),
&LintConfig::default(),
);
assert!(!diagnostics.iter().any(|d| d.rule == "CC-PL-014"));
}
}