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 let param_values = validate_arguments(recipe, args)?;
62
63 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 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 let substituted_body = substitute_parameters(&recipe.body, ¶m_values, &justfile.variables)?;
99
100 let mut recipe_result = execute_commands(&substituted_body, working_dir, recipe_name)?;
102
103 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 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 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 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 for (name, value) in param_values {
185 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 for (name, value) in variables {
195 let pattern_with_spaces = format!("{{{{ {name} }}}}");
197 let pattern_without_spaces = format!("{{{{{name}}}}}");
198
199 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 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 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 let (quiet, command_line) = if let Some(stripped) = command_line.strip_prefix('@') {
238 (true, stripped)
239 } else {
240 (false, command_line)
241 };
242
243 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 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 let exit_code = output.status.code().unwrap_or(-1);
275 if exit_code != 0 {
276 final_exit_code = exit_code;
277 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, ¶m_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, ¶m_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}