use super::types::{
CommandType, CommandValidation, ValidationIssue, VariableContext, VariableReference,
};
use crate::cook::workflow::WorkflowStep;
use regex::Regex;
use std::time::Duration;
use tracing::debug;
pub struct CommandValidator {
variable_regex: Regex,
}
impl CommandValidator {
pub fn new() -> Self {
Self {
variable_regex: Regex::new(r"\$\{([^}]+)\}").expect("Invalid regex pattern"),
}
}
pub fn validate_command(&self, command: &WorkflowStep) -> CommandValidation {
debug!("Validating command: {:?}", command);
let command_type = self.get_command_type(command);
let mut issues = Vec::new();
let mut valid = true;
self.validate_command_structure(command, &mut issues, &mut valid);
let variable_references = self.extract_variables(command);
self.validate_variable_references(&variable_references, &mut issues);
self.validate_command_syntax(command, &mut issues, &mut valid);
let estimated_duration = self.estimate_duration(command);
CommandValidation {
command_type,
valid,
issues,
variable_references,
estimated_duration,
}
}
pub fn validate_commands(&self, commands: &[WorkflowStep]) -> Vec<CommandValidation> {
commands
.iter()
.map(|cmd| self.validate_command(cmd))
.collect()
}
fn get_command_type(&self, command: &WorkflowStep) -> CommandType {
if command.claude.is_some() {
CommandType::Claude
} else if command.shell.is_some() {
CommandType::Shell
} else if command.foreach.is_some() {
CommandType::Foreach
} else {
CommandType::Shell }
}
fn validate_command_structure(
&self,
command: &WorkflowStep,
issues: &mut Vec<ValidationIssue>,
valid: &mut bool,
) {
let mut command_count = 0;
if command.claude.is_some() {
command_count += 1;
if let Some(cmd) = &command.claude {
if !cmd.starts_with('/') && !cmd.is_empty() {
issues.push(ValidationIssue::Warning(
"Claude command should start with '/' for slash commands".to_string(),
));
}
if cmd.trim().is_empty() {
issues.push(ValidationIssue::Error(
"Claude command cannot be empty".to_string(),
));
*valid = false;
}
}
}
if command.shell.is_some() {
command_count += 1;
if let Some(cmd) = &command.shell {
if cmd.trim().is_empty() {
issues.push(ValidationIssue::Error(
"Shell command cannot be empty".to_string(),
));
*valid = false;
}
if self.check_dangerous_shell_command(cmd, issues) {
*valid = false;
}
}
}
if command.foreach.is_some() {
command_count += 1;
}
if command_count == 0 {
issues.push(ValidationIssue::Error(
"Command must specify one of: claude, shell, or foreach".to_string(),
));
*valid = false;
} else if command_count > 1 {
issues.push(ValidationIssue::Error(
"Command cannot specify multiple types simultaneously".to_string(),
));
*valid = false;
}
}
fn check_dangerous_shell_command(&self, cmd: &str, issues: &mut Vec<ValidationIssue>) -> bool {
let dangerous_patterns = [
("rm -rf /", "Dangerous recursive delete from root"),
("rm -rf /*", "Dangerous recursive delete of all files"),
(":(){ :|:& };:", "Fork bomb detected"),
("dd if=/dev/zero", "Dangerous disk write operation"),
("mkfs", "Filesystem formatting command"),
("> /dev/sda", "Direct disk write"),
];
let mut found_dangerous = false;
for (pattern, warning) in dangerous_patterns.iter() {
if cmd.contains(pattern) {
issues.push(ValidationIssue::Error(format!("{}: {}", warning, pattern)));
found_dangerous = true;
}
}
if cmd.starts_with("sudo") || cmd.contains("| sudo") {
issues.push(ValidationIssue::Warning(
"Command uses sudo which may require interactive authentication".to_string(),
));
}
found_dangerous
}
fn extract_variables(&self, command: &WorkflowStep) -> Vec<VariableReference> {
let mut variables = Vec::new();
if let Some(cmd) = &command.claude {
variables.extend(self.extract_from_string(cmd));
}
if let Some(cmd) = &command.shell {
variables.extend(self.extract_from_string(cmd));
}
variables
}
fn extract_from_string(&self, text: &str) -> Vec<VariableReference> {
self.variable_regex
.captures_iter(text)
.map(|cap| VariableReference {
name: cap[1].to_string(),
context: self.determine_context(&cap[1]),
})
.collect()
}
fn determine_context(&self, var_name: &str) -> VariableContext {
if var_name.starts_with("item.") {
VariableContext::Item
} else if var_name.starts_with("map.") {
VariableContext::Map
} else if var_name.starts_with("setup.") {
VariableContext::Setup
} else if var_name.starts_with("shell.") {
VariableContext::Shell
} else if var_name.starts_with("merge.") {
VariableContext::Merge
} else {
VariableContext::Unknown
}
}
fn validate_variable_references(
&self,
references: &[VariableReference],
issues: &mut Vec<ValidationIssue>,
) {
for var_ref in references {
match var_ref.context {
VariableContext::Unknown => {
issues.push(ValidationIssue::Warning(format!(
"Variable '{}' has unknown context - may not be available",
var_ref.name
)));
}
_ => {
debug!(
"Variable {} has context {:?}",
var_ref.name, var_ref.context
);
}
}
}
}
fn validate_command_syntax(
&self,
command: &WorkflowStep,
issues: &mut Vec<ValidationIssue>,
valid: &mut bool,
) {
if let Some(cmd) = &command.shell {
let single_quotes = cmd.matches('\'').count();
let double_quotes = cmd.matches('"').count();
if single_quotes % 2 != 0 {
issues.push(ValidationIssue::Error(
"Unclosed single quote in shell command".to_string(),
));
*valid = false;
}
if double_quotes % 2 != 0 {
issues.push(ValidationIssue::Error(
"Unclosed double quote in shell command".to_string(),
));
*valid = false;
}
if cmd.contains("$((") && !cmd.contains("))") {
issues.push(ValidationIssue::Warning(
"Possible unclosed arithmetic expansion in shell command".to_string(),
));
}
}
if let Some(cmd) = &command.claude {
if cmd.starts_with('/') {
let parts: Vec<&str> = cmd.split_whitespace().collect();
if parts.is_empty() {
issues.push(ValidationIssue::Error(
"Claude slash command is incomplete".to_string(),
));
*valid = false;
}
}
}
}
fn estimate_duration(&self, command: &WorkflowStep) -> Duration {
if command.claude.is_some() {
Duration::from_secs(60)
} else if let Some(shell_cmd) = &command.shell {
if shell_cmd.contains("npm install") || shell_cmd.contains("cargo build") {
Duration::from_secs(120)
} else if shell_cmd.contains("test") || shell_cmd.contains("pytest") {
Duration::from_secs(60)
} else {
Duration::from_secs(10)
}
} else if command.foreach.is_some() {
Duration::from_secs(120)
} else {
Duration::from_secs(30)
}
}
}
impl Default for CommandValidator {
fn default() -> Self {
Self::new()
}
}