ricecoder_commands/
executor.rs

1use crate::error::{CommandError, Result};
2use crate::template::TemplateProcessor;
3use crate::types::{CommandContext, CommandDefinition, CommandExecutionResult};
4use std::collections::HashMap;
5use std::process::Command;
6use std::time::Instant;
7
8/// Command executor for running shell commands
9pub struct CommandExecutor;
10
11impl CommandExecutor {
12    /// Execute a command with the given context
13    pub fn execute(
14        command_def: &CommandDefinition,
15        context: &CommandContext,
16    ) -> Result<CommandExecutionResult> {
17        // Validate that the command is enabled
18        if !command_def.enabled {
19            return Err(CommandError::ExecutionFailed(format!(
20                "Command is disabled: {}",
21                command_def.id
22            )));
23        }
24
25        // Process the command template with arguments
26        let processed_command =
27            TemplateProcessor::process(&command_def.command, &context.arguments)?;
28
29        // Start timing
30        let start = Instant::now();
31
32        // Execute the command
33        let output = if cfg!(target_os = "windows") {
34            Command::new("cmd")
35                .args(["/C", &processed_command])
36                .current_dir(&context.cwd)
37                .envs(&context.env)
38                .output()
39                .map_err(|e| CommandError::ExecutionFailed(e.to_string()))?
40        } else {
41            Command::new("sh")
42                .args(["-c", &processed_command])
43                .current_dir(&context.cwd)
44                .envs(&context.env)
45                .output()
46                .map_err(|e| CommandError::ExecutionFailed(e.to_string()))?
47        };
48
49        let duration = start.elapsed();
50
51        // Extract output
52        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
53        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
54        let exit_code = output.status.code().unwrap_or(-1);
55
56        // Check for timeout
57        if command_def.timeout_seconds > 0 && duration.as_secs() > command_def.timeout_seconds {
58            return Err(CommandError::ExecutionFailed(format!(
59                "Command execution timed out after {} seconds",
60                command_def.timeout_seconds
61            )));
62        }
63
64        Ok(CommandExecutionResult::new(&command_def.id, exit_code)
65            .with_stdout(stdout)
66            .with_stderr(stderr)
67            .with_duration(duration.as_millis() as u64))
68    }
69
70    /// Execute a command and return only the output
71    pub fn execute_and_get_output(
72        command_def: &CommandDefinition,
73        context: &CommandContext,
74    ) -> Result<String> {
75        let result = Self::execute(command_def, context)?;
76
77        if result.success {
78            Ok(result.stdout)
79        } else {
80            Err(CommandError::ExecutionFailed(format!(
81                "Command failed with exit code {}: {}",
82                result.exit_code, result.stderr
83            )))
84        }
85    }
86
87    /// Execute a command and return both stdout and stderr
88    pub fn execute_and_get_all_output(
89        command_def: &CommandDefinition,
90        context: &CommandContext,
91    ) -> Result<(String, String)> {
92        let result = Self::execute(command_def, context)?;
93        Ok((result.stdout, result.stderr))
94    }
95
96    /// Validate command arguments against the command definition
97    pub fn validate_arguments(
98        command_def: &CommandDefinition,
99        arguments: &HashMap<String, String>,
100    ) -> Result<()> {
101        for arg_def in &command_def.arguments {
102            if arg_def.required && !arguments.contains_key(&arg_def.name) {
103                return Err(CommandError::InvalidArgument(format!(
104                    "Missing required argument: {}",
105                    arg_def.name
106                )));
107            }
108
109            if let Some(value) = arguments.get(&arg_def.name) {
110                // Validate against pattern if provided
111                if let Some(pattern) = &arg_def.validation_pattern {
112                    let regex = regex::Regex::new(pattern)?;
113                    if !regex.is_match(value) {
114                        return Err(CommandError::InvalidArgument(format!(
115                            "Argument '{}' does not match pattern: {}",
116                            arg_def.name, pattern
117                        )));
118                    }
119                }
120            }
121        }
122
123        Ok(())
124    }
125
126    /// Build a context with default values for missing arguments
127    pub fn build_context_with_defaults(
128        command_def: &CommandDefinition,
129        mut arguments: HashMap<String, String>,
130        cwd: String,
131    ) -> Result<CommandContext> {
132        // Fill in default values for missing arguments
133        for arg_def in &command_def.arguments {
134            if !arguments.contains_key(&arg_def.name) {
135                if let Some(default) = &arg_def.default {
136                    arguments.insert(arg_def.name.clone(), default.clone());
137                }
138            }
139        }
140
141        Ok(CommandContext {
142            cwd,
143            env: std::env::vars().collect(),
144            arguments,
145        })
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use crate::types::{ArgumentType, CommandArgument};
153
154    #[test]
155    fn test_validate_arguments_success() {
156        let mut cmd = CommandDefinition::new("test", "Test", "echo {{name}}");
157        cmd.arguments.push(
158            CommandArgument::new("name", ArgumentType::String)
159                .with_required(true)
160                .with_description("User name"),
161        );
162
163        let mut args = HashMap::new();
164        args.insert("name".to_string(), "Alice".to_string());
165
166        assert!(CommandExecutor::validate_arguments(&cmd, &args).is_ok());
167    }
168
169    #[test]
170    fn test_validate_arguments_missing_required() {
171        let mut cmd = CommandDefinition::new("test", "Test", "echo {{name}}");
172        cmd.arguments.push(
173            CommandArgument::new("name", ArgumentType::String)
174                .with_required(true)
175                .with_description("User name"),
176        );
177
178        let args = HashMap::new();
179        assert!(CommandExecutor::validate_arguments(&cmd, &args).is_err());
180    }
181
182    #[test]
183    fn test_validate_arguments_with_pattern() {
184        let mut cmd = CommandDefinition::new("test", "Test", "echo {{email}}");
185        cmd.arguments.push(
186            CommandArgument::new("email", ArgumentType::String)
187                .with_required(true)
188                .with_validation_pattern(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"),
189        );
190
191        let mut args = HashMap::new();
192        args.insert("email".to_string(), "invalid-email".to_string());
193        assert!(CommandExecutor::validate_arguments(&cmd, &args).is_err());
194
195        args.insert("email".to_string(), "test@example.com".to_string());
196        assert!(CommandExecutor::validate_arguments(&cmd, &args).is_ok());
197    }
198
199    #[test]
200    fn test_build_context_with_defaults() {
201        let mut cmd = CommandDefinition::new("test", "Test", "echo {{name}}");
202        cmd.arguments.push(
203            CommandArgument::new("name", ArgumentType::String)
204                .with_default("Guest")
205                .with_description("User name"),
206        );
207
208        let args = HashMap::new();
209        let context =
210            CommandExecutor::build_context_with_defaults(&cmd, args, ".".to_string()).unwrap();
211
212        assert_eq!(context.arguments.get("name").unwrap(), "Guest");
213    }
214
215    #[test]
216    fn test_build_context_override_defaults() {
217        let mut cmd = CommandDefinition::new("test", "Test", "echo {{name}}");
218        cmd.arguments.push(
219            CommandArgument::new("name", ArgumentType::String)
220                .with_default("Guest")
221                .with_description("User name"),
222        );
223
224        let mut args = HashMap::new();
225        args.insert("name".to_string(), "Alice".to_string());
226        let context =
227            CommandExecutor::build_context_with_defaults(&cmd, args, ".".to_string()).unwrap();
228
229        assert_eq!(context.arguments.get("name").unwrap(), "Alice");
230    }
231
232    #[test]
233    fn test_execute_simple_command() {
234        let cmd = CommandDefinition::new("test", "Test", "echo hello");
235        let context = CommandContext {
236            cwd: ".".to_string(),
237            env: std::env::vars().collect(),
238            arguments: HashMap::new(),
239        };
240
241        let result = CommandExecutor::execute(&cmd, &context).unwrap();
242        assert!(result.success);
243        assert!(result.stdout.contains("hello"));
244    }
245
246    #[test]
247    fn test_execute_disabled_command() {
248        let mut cmd = CommandDefinition::new("test", "Test", "echo hello");
249        cmd.enabled = false;
250
251        let context = CommandContext {
252            cwd: ".".to_string(),
253            env: std::env::vars().collect(),
254            arguments: HashMap::new(),
255        };
256
257        assert!(CommandExecutor::execute(&cmd, &context).is_err());
258    }
259
260    #[test]
261    fn test_execute_with_template() {
262        let cmd = CommandDefinition::new("test", "Test", "echo {{message}}");
263        let mut args = HashMap::new();
264        args.insert("message".to_string(), "Hello World".to_string());
265
266        let context = CommandContext {
267            cwd: ".".to_string(),
268            env: std::env::vars().collect(),
269            arguments: args,
270        };
271
272        let result = CommandExecutor::execute(&cmd, &context).unwrap();
273        assert!(result.success);
274        assert!(result.stdout.contains("Hello World"));
275    }
276}