just_mcp_lib/
executor.rs

1use snafu::prelude::*;
2use std::collections::HashMap;
3use std::path::Path;
4use std::process::{Command, Stdio};
5use std::time::Instant;
6
7use crate::{Justfile, Recipe};
8
9#[derive(Debug, Clone, PartialEq)]
10pub struct ExecutionResult {
11    pub stdout: String,
12    pub stderr: String,
13    pub exit_code: i32,
14    pub duration_ms: u64,
15}
16
17#[derive(Debug, Snafu)]
18pub enum ExecutionError {
19    #[snafu(display("Recipe '{}' not found", recipe_name))]
20    RecipeNotFound { recipe_name: String },
21
22    #[snafu(display("Invalid arguments for recipe '{}': {}", recipe_name, message))]
23    InvalidArguments {
24        recipe_name: String,
25        message: String,
26    },
27
28    #[snafu(display(
29        "Dependency '{}' failed for recipe '{}': {}",
30        dependency,
31        recipe_name,
32        source
33    ))]
34    DependencyFailed {
35        recipe_name: String,
36        dependency: String,
37        source: Box<ExecutionError>,
38    },
39
40    #[snafu(display("Execution failed for recipe '{}': {}", recipe_name, source))]
41    ExecutionFailed {
42        recipe_name: String,
43        source: std::io::Error,
44    },
45
46    #[snafu(display("Parameter substitution failed: {}", message))]
47    SubstitutionFailed { message: String },
48}
49
50pub type Result<T> = std::result::Result<T, ExecutionError>;
51
52pub fn execute_recipe(
53    justfile: &Justfile,
54    recipe_name: &str,
55    args: &[String],
56    working_dir: &Path,
57) -> Result<ExecutionResult> {
58    let recipe = find_recipe(justfile, recipe_name)?;
59
60    // Validate arguments against parameters
61    let param_values = validate_arguments(recipe, args)?;
62
63    // Execute dependencies first and collect their output
64    let mut dependency_output = ExecutionResult {
65        stdout: String::new(),
66        stderr: String::new(),
67        exit_code: 0,
68        duration_ms: 0,
69    };
70
71    for dep in &recipe.dependencies {
72        let dep_result = execute_recipe(justfile, dep, &[], working_dir).map_err(|e| {
73            ExecutionError::DependencyFailed {
74                recipe_name: recipe_name.to_string(),
75                dependency: dep.clone(),
76                source: Box::new(e),
77            }
78        })?;
79
80        // Accumulate dependency output
81        if !dependency_output.stdout.is_empty() && !dep_result.stdout.is_empty() {
82            dependency_output.stdout.push('\n');
83        }
84        dependency_output.stdout.push_str(&dep_result.stdout);
85
86        if !dependency_output.stderr.is_empty() && !dep_result.stderr.is_empty() {
87            dependency_output.stderr.push('\n');
88        }
89        dependency_output.stderr.push_str(&dep_result.stderr);
90
91        dependency_output.duration_ms += dep_result.duration_ms;
92        if dep_result.exit_code != 0 {
93            dependency_output.exit_code = dep_result.exit_code;
94        }
95    }
96
97    // Substitute parameters in recipe body
98    let substituted_body = substitute_parameters(&recipe.body, &param_values, &justfile.variables)?;
99
100    // Execute the recipe
101    let mut recipe_result = execute_commands(&substituted_body, working_dir, recipe_name)?;
102
103    // Combine dependency output with recipe output
104    if !dependency_output.stdout.is_empty() {
105        if !recipe_result.stdout.is_empty() {
106            dependency_output.stdout.push('\n');
107        }
108        dependency_output.stdout.push_str(&recipe_result.stdout);
109        recipe_result.stdout = dependency_output.stdout;
110    }
111
112    if !dependency_output.stderr.is_empty() {
113        if !recipe_result.stderr.is_empty() {
114            dependency_output.stderr.push('\n');
115        }
116        dependency_output.stderr.push_str(&recipe_result.stderr);
117        recipe_result.stderr = dependency_output.stderr;
118    }
119
120    recipe_result.duration_ms += dependency_output.duration_ms;
121    if dependency_output.exit_code != 0 {
122        recipe_result.exit_code = dependency_output.exit_code;
123    }
124
125    Ok(recipe_result)
126}
127
128fn find_recipe<'a>(justfile: &'a Justfile, recipe_name: &str) -> Result<&'a Recipe> {
129    justfile
130        .recipes
131        .iter()
132        .find(|r| r.name == recipe_name)
133        .ok_or_else(|| ExecutionError::RecipeNotFound {
134            recipe_name: recipe_name.to_string(),
135        })
136}
137
138fn validate_arguments(recipe: &Recipe, args: &[String]) -> Result<HashMap<String, String>> {
139    let mut param_values = HashMap::new();
140    let params = &recipe.parameters;
141
142    // Check if we have too many arguments
143    if args.len() > params.len() {
144        return Err(ExecutionError::InvalidArguments {
145            recipe_name: recipe.name.clone(),
146            message: format!(
147                "Expected at most {} arguments, got {}",
148                params.len(),
149                args.len()
150            ),
151        });
152    }
153
154    // Process provided arguments
155    for (i, arg) in args.iter().enumerate() {
156        if let Some(param) = params.get(i) {
157            param_values.insert(param.name.clone(), arg.clone());
158        }
159    }
160
161    // Fill in defaults for remaining parameters
162    for param in params.iter().skip(args.len()) {
163        if let Some(ref default_value) = param.default_value {
164            param_values.insert(param.name.clone(), default_value.clone());
165        } else {
166            return Err(ExecutionError::InvalidArguments {
167                recipe_name: recipe.name.clone(),
168                message: format!("Missing required parameter: {}", param.name),
169            });
170        }
171    }
172
173    Ok(param_values)
174}
175
176fn substitute_parameters(
177    body: &str,
178    param_values: &HashMap<String, String>,
179    variables: &HashMap<String, String>,
180) -> Result<String> {
181    let mut result = body.to_string();
182
183    // Substitute recipe parameters (both {{ param_name }} and {{param_name}} formats)
184    for (name, value) in param_values {
185        // Try both with and without spaces
186        let pattern_with_spaces = format!("{{{{ {name} }}}}");
187        let pattern_without_spaces = format!("{{{{{name}}}}}");
188
189        result = result.replace(&pattern_with_spaces, value);
190        result = result.replace(&pattern_without_spaces, value);
191    }
192
193    // Substitute global variables (both {{ var_name }} and {{var_name}} formats)
194    for (name, value) in variables {
195        // Try both with and without spaces
196        let pattern_with_spaces = format!("{{{{ {name} }}}}");
197        let pattern_without_spaces = format!("{{{{{name}}}}}");
198
199        // Remove quotes from variable values for substitution
200        let clean_value = value.trim_matches('"').trim_matches('\'');
201        result = result.replace(&pattern_with_spaces, clean_value);
202        result = result.replace(&pattern_without_spaces, clean_value);
203    }
204
205    // Check for any remaining unsubstituted variables
206    if result.contains("{{") && result.contains("}}") {
207        return Err(ExecutionError::SubstitutionFailed {
208            message: "Unresolved parameter or variable references found".to_string(),
209        });
210    }
211
212    Ok(result)
213}
214
215fn execute_commands(body: &str, working_dir: &Path, recipe_name: &str) -> Result<ExecutionResult> {
216    let start_time = Instant::now();
217    let mut combined_stdout = String::new();
218    let mut combined_stderr = String::new();
219    let mut final_exit_code = 0;
220
221    for line in body.lines() {
222        let trimmed = line.trim();
223        if trimmed.is_empty() || trimmed.starts_with('#') {
224            continue;
225        }
226
227        // Remove leading tabs/spaces from command
228        let command_line = if let Some(stripped) = line.strip_prefix('\t') {
229            stripped
230        } else if let Some(stripped) = line.strip_prefix("    ") {
231            stripped
232        } else {
233            line
234        };
235
236        // Handle special prefixes
237        let (quiet, command_line) = if let Some(stripped) = command_line.strip_prefix('@') {
238            (true, stripped)
239        } else {
240            (false, command_line)
241        };
242
243        // Execute the command
244        let mut cmd = Command::new("sh");
245        cmd.arg("-c")
246            .arg(command_line)
247            .current_dir(working_dir)
248            .stdout(Stdio::piped())
249            .stderr(Stdio::piped());
250
251        let output = cmd.output().with_context(|_| ExecutionFailedSnafu {
252            recipe_name: recipe_name.to_string(),
253        })?;
254
255        // Collect output
256        let stdout = String::from_utf8_lossy(&output.stdout);
257        let stderr = String::from_utf8_lossy(&output.stderr);
258
259        if !stdout.is_empty() && !quiet {
260            if !combined_stdout.is_empty() {
261                combined_stdout.push('\n');
262            }
263            combined_stdout.push_str(&stdout);
264        }
265
266        if !stderr.is_empty() {
267            if !combined_stderr.is_empty() {
268                combined_stderr.push('\n');
269            }
270            combined_stderr.push_str(&stderr);
271        }
272
273        // Update exit code (keep the last non-zero exit code, or stop on first failure)
274        let exit_code = output.status.code().unwrap_or(-1);
275        if exit_code != 0 {
276            final_exit_code = exit_code;
277            // Stop executing remaining commands on failure
278            break;
279        }
280    }
281
282    let duration = start_time.elapsed();
283
284    Ok(ExecutionResult {
285        stdout: combined_stdout,
286        stderr: combined_stderr,
287        exit_code: final_exit_code,
288        duration_ms: duration.as_millis() as u64,
289    })
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use crate::Parameter;
296    use std::collections::HashMap;
297
298    fn create_test_recipe(
299        name: &str,
300        params: Vec<Parameter>,
301        body: &str,
302        deps: Vec<&str>,
303    ) -> Recipe {
304        Recipe {
305            name: name.to_string(),
306            parameters: params,
307            documentation: None,
308            body: body.to_string(),
309            dependencies: deps.iter().map(|s| s.to_string()).collect(),
310        }
311    }
312
313    #[test]
314    fn test_find_recipe() {
315        let recipe = create_test_recipe("build", vec![], "cargo build", vec![]);
316        let justfile = Justfile {
317            recipes: vec![recipe],
318            variables: HashMap::new(),
319        };
320
321        assert!(find_recipe(&justfile, "build").is_ok());
322        assert!(find_recipe(&justfile, "nonexistent").is_err());
323    }
324
325    #[test]
326    fn test_validate_arguments_success() {
327        let params = vec![
328            Parameter {
329                name: "env".to_string(),
330                default_value: None,
331            },
332            Parameter {
333                name: "target".to_string(),
334                default_value: Some("prod".to_string()),
335            },
336        ];
337        let recipe = create_test_recipe("deploy", params, "", vec![]);
338
339        let args = vec!["staging".to_string()];
340        let result = validate_arguments(&recipe, &args).unwrap();
341
342        assert_eq!(result.get("env"), Some(&"staging".to_string()));
343        assert_eq!(result.get("target"), Some(&"prod".to_string()));
344    }
345
346    #[test]
347    fn test_validate_arguments_missing_required() {
348        let params = vec![Parameter {
349            name: "env".to_string(),
350            default_value: None,
351        }];
352        let recipe = create_test_recipe("deploy", params, "", vec![]);
353
354        let args = vec![];
355        let result = validate_arguments(&recipe, &args);
356
357        assert!(result.is_err());
358        assert!(
359            result
360                .unwrap_err()
361                .to_string()
362                .contains("Missing required parameter")
363        );
364    }
365
366    #[test]
367    fn test_substitute_parameters() {
368        let mut param_values = HashMap::new();
369        param_values.insert("env".to_string(), "staging".to_string());
370        param_values.insert("port".to_string(), "8080".to_string());
371
372        let mut variables = HashMap::new();
373        variables.insert("version".to_string(), "\"1.0.0\"".to_string());
374
375        let body = "echo 'Deploying {{ env }} on port {{ port }} version {{ version }}'";
376        let result = substitute_parameters(body, &param_values, &variables).unwrap();
377
378        assert_eq!(
379            result,
380            "echo 'Deploying staging on port 8080 version 1.0.0'"
381        );
382    }
383
384    #[test]
385    fn test_substitute_parameters_unresolved() {
386        let param_values = HashMap::new();
387        let variables = HashMap::new();
388
389        let body = "echo 'Missing {{ unknown_var }}'";
390        let result = substitute_parameters(body, &param_values, &variables);
391
392        assert!(result.is_err());
393        assert!(
394            result
395                .unwrap_err()
396                .to_string()
397                .contains("Unresolved parameter")
398        );
399    }
400}