use std::path::Path;
use anyhow::Result;
use crate::command_safety::command_might_be_dangerous;
use crate::plugins::{PluginError, PluginManifest, PluginResult};
use crate::utils::file_utils::read_file_with_context;
pub struct PluginValidator;
impl PluginValidator {
pub fn validate_manifest(manifest: &PluginManifest) -> PluginResult<()> {
if manifest.name.is_empty() {
return Err(PluginError::ManifestValidationError(
"Plugin name is required".to_string(),
));
}
if !Self::is_valid_plugin_name(&manifest.name) {
return Err(PluginError::ManifestValidationError(
"Plugin name must be in kebab-case (lowercase with hyphens)".to_string(),
));
}
if let Some(version) = &manifest.version
&& !Self::is_valid_version(version)
{
return Err(PluginError::ManifestValidationError(
"Plugin version must follow semantic versioning (e.g., 1.0.0)".to_string(),
));
}
if let Some(author) = &manifest.author
&& author.name.is_empty()
{
return Err(PluginError::ManifestValidationError(
"Plugin author name is required when author is specified".to_string(),
));
}
Ok(())
}
pub async fn validate_plugin_structure(plugin_path: &Path) -> PluginResult<()> {
crate::plugins::PluginTemplate::validate_plugin_structure(plugin_path).await
}
fn is_valid_plugin_name(name: &str) -> bool {
name.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
&& !name.starts_with('-')
&& !name.ends_with('-')
&& !name.is_empty()
}
fn is_valid_version(version: &str) -> bool {
let parts: Vec<&str> = version.split('.').collect();
if parts.len() < 3 {
return false;
}
parts.iter().all(|part| {
let clean_part = part.split('-').next().unwrap_or(part);
clean_part.chars().all(|c| c.is_ascii_digit())
})
}
pub fn validate_plugin_security(manifest: &PluginManifest) -> PluginResult<()> {
if let Some(mcp_servers) = &manifest.mcp_servers {
match mcp_servers {
crate::plugins::manifest::McpServerConfig::Inline(servers) => {
for (name, server) in servers {
if Self::is_dangerous_command(&server.command) {
return Err(PluginError::LoadingError(format!(
"MCP server '{}' uses potentially dangerous command: {}",
name, server.command
)));
}
}
}
crate::plugins::manifest::McpServerConfig::Path(_) => {
}
}
}
Ok(())
}
fn is_dangerous_command(command: &str) -> bool {
command_might_be_dangerous(&[command.to_string()])
}
}
pub struct PluginDebugger;
impl PluginDebugger {
pub fn debug_manifest(manifest: &PluginManifest) -> String {
let mut output = String::new();
output.push_str(&format!("Plugin: {}\n", manifest.name));
output.push_str(&format!(
" Version: {}\n",
manifest.version.as_deref().unwrap_or("not specified")
));
output.push_str(&format!(
" Description: {}\n",
manifest.description.as_deref().unwrap_or("not specified")
));
if let Some(author) = &manifest.author {
output.push_str(&format!(" Author: {}\n", author.name));
if let Some(email) = &author.email {
output.push_str(&format!(" Email: {}\n", email));
}
if let Some(url) = &author.url {
output.push_str(&format!(" URL: {}\n", url));
}
}
if let Some(homepage) = &manifest.homepage {
output.push_str(&format!(" Homepage: {}\n", homepage));
}
if let Some(repository) = &manifest.repository {
output.push_str(&format!(" Repository: {}\n", repository));
}
if let Some(license) = &manifest.license {
output.push_str(&format!(" License: {}\n", license));
}
if let Some(keywords) = &manifest.keywords {
output.push_str(&format!(" Keywords: {}\n", keywords.join(", ")));
}
let commands_count = manifest.commands.as_ref().map_or(0, |c| c.len());
let agents_count = manifest.agents.as_ref().map_or(0, |a| a.len());
let skills_count = manifest.skills.as_ref().map_or(0, |s| s.len());
output.push_str(" Components:\n");
output.push_str(&format!(" Commands: {}\n", commands_count));
output.push_str(&format!(" Agents: {}\n", agents_count));
output.push_str(&format!(" Skills: {}\n", skills_count));
output.push_str(&format!(
" Hooks: {}\n",
if manifest.hooks.is_some() {
"yes"
} else {
"no"
}
));
output.push_str(&format!(
" MCP Servers: {}\n",
if manifest.mcp_servers.is_some() {
"yes"
} else {
"no"
}
));
output.push_str(&format!(
" LSP Servers: {}\n",
if manifest.lsp_servers.is_some() {
"yes"
} else {
"no"
}
));
output
}
pub fn validate_and_debug_manifest(manifest: &PluginManifest) -> Result<String> {
let mut issues = Vec::new();
if let Err(e) = PluginValidator::validate_manifest(manifest) {
issues.push(format!("Validation error: {}", e));
}
if let Err(e) = PluginValidator::validate_plugin_security(manifest) {
issues.push(format!("Security warning: {}", e));
}
let mut output = Self::debug_manifest(manifest);
if !issues.is_empty() {
output.push_str("\nIssues found:\n");
for issue in issues {
output.push_str(&format!(" - {}\n", issue));
}
} else {
output.push_str("\nNo issues found.\n");
}
Ok(output)
}
}
pub async fn handle_plugin_validate(path: &Path) -> Result<()> {
if !path.exists() {
anyhow::bail!("Plugin path does not exist: {}", path.display());
}
let manifest_path = path.join(".vtcode-plugin/plugin.json");
if !manifest_path.exists() {
anyhow::bail!("Plugin manifest not found at: {}", manifest_path.display());
}
let manifest_content = read_file_with_context(&manifest_path, "plugin manifest").await?;
let manifest: PluginManifest = serde_json::from_str(&manifest_content)?;
let validation_output = PluginDebugger::validate_and_debug_manifest(&manifest)?;
tracing::info!(output = %validation_output, "plugin validation result");
Ok(())
}