1use crate::parser::models::{StepResult, StepStatus};
5use crate::runners::shell::{ShellConfig, ShellRunner};
6use crate::tasks::cache::{CachedTask, TaskCache, TaskCacheError};
7
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use std::time::{Duration, Instant};
11use thiserror::Error;
12
13#[derive(Debug, Error)]
15pub enum TaskRunnerError {
16 #[error("Task cache error: {0}")]
17 CacheError(#[from] TaskCacheError),
18
19 #[error("Task execution failed: {0}")]
20 ExecutionFailed(String),
21
22 #[error("Unsupported task execution type: {0}")]
23 UnsupportedExecution(String),
24
25 #[error("Missing required input: {0}")]
26 MissingInput(String),
27
28 #[error("IO error: {0}")]
29 IoError(#[from] std::io::Error),
30}
31
32pub struct TaskRunner {
34 cache: TaskCache,
36 shell_runner: ShellRunner,
38 node_path: Option<PathBuf>,
40 powershell_path: Option<PathBuf>,
42}
43
44impl TaskRunner {
45 pub fn new(cache_dir: PathBuf) -> Self {
47 Self {
48 cache: TaskCache::with_cache_dir(cache_dir),
49 shell_runner: ShellRunner::new(),
50 node_path: find_node_path(),
51 powershell_path: find_powershell_path(),
52 }
53 }
54
55 pub fn with_cache(cache: TaskCache) -> Self {
57 Self {
58 cache,
59 shell_runner: ShellRunner::new(),
60 node_path: find_node_path(),
61 powershell_path: find_powershell_path(),
62 }
63 }
64
65 pub fn with_node_path(mut self, path: impl AsRef<Path>) -> Self {
67 self.node_path = Some(path.as_ref().to_path_buf());
68 self
69 }
70
71 pub fn with_powershell_path(mut self, path: impl AsRef<Path>) -> Self {
73 self.powershell_path = Some(path.as_ref().to_path_buf());
74 self
75 }
76
77 pub fn cache(&self) -> &TaskCache {
79 &self.cache
80 }
81
82 pub async fn execute_task(
84 &self,
85 task_ref: &str,
86 inputs: &HashMap<String, String>,
87 env: &HashMap<String, String>,
88 working_dir: &Path,
89 ) -> Result<StepResult, TaskRunnerError> {
90 let start = Instant::now();
91
92 let task = self.cache.get_task(task_ref).await?;
94
95 self.validate_inputs(&task, inputs)?;
97
98 let merged_inputs = self.merge_inputs(&task, inputs);
100
101 let result = self
103 .execute_task_impl(&task, &merged_inputs, env, working_dir)
104 .await;
105
106 let duration = start.elapsed();
107
108 match result {
109 Ok(mut step_result) => {
110 step_result.duration = duration;
111 Ok(step_result)
112 }
113 Err(e) => Ok(StepResult {
114 step_name: None,
115 display_name: task.manifest.friendly_name.clone(),
116 status: StepStatus::Failed,
117 output: String::new(),
118 error: Some(e.to_string()),
119 duration,
120 exit_code: None,
121 outputs: HashMap::new(),
122 }),
123 }
124 }
125
126 fn validate_inputs(
128 &self,
129 task: &CachedTask,
130 inputs: &HashMap<String, String>,
131 ) -> Result<(), TaskRunnerError> {
132 for input in &task.manifest.inputs {
133 if input.required.unwrap_or(false) {
134 let name = &input.name;
135
136 if !inputs.contains_key(name) && input.default_value.is_none() {
138 let has_alias = input
140 .aliases
141 .as_ref()
142 .map(|aliases| aliases.iter().any(|a| inputs.contains_key(a)))
143 .unwrap_or(false);
144
145 if !has_alias {
146 if let Some(rule) = &input.visible_rule {
148 if let Some((check_input, _)) = rule.split_once('=') {
151 let check_input = check_input.trim().trim_end_matches('!').trim();
152 if !inputs.contains_key(check_input) {
153 continue; }
155 }
156 }
157
158 return Err(TaskRunnerError::MissingInput(name.clone()));
159 }
160 }
161 }
162 }
163
164 Ok(())
165 }
166
167 fn merge_inputs(
169 &self,
170 task: &CachedTask,
171 inputs: &HashMap<String, String>,
172 ) -> HashMap<String, String> {
173 let mut merged = task.manifest.default_values();
174
175 for (key, value) in inputs {
177 merged.insert(key.clone(), value.clone());
178 }
179
180 merged
181 }
182
183 async fn execute_task_impl(
185 &self,
186 task: &CachedTask,
187 inputs: &HashMap<String, String>,
188 env: &HashMap<String, String>,
189 working_dir: &Path,
190 ) -> Result<StepResult, TaskRunnerError> {
191 match task.name.as_str() {
193 "Bash" => self.execute_bash_task(inputs, env, working_dir).await,
194 "PowerShell" => self.execute_powershell_task(inputs, env, working_dir).await,
195 "CmdLine" => self.execute_cmdline_task(inputs, env, working_dir).await,
196 _ => {
197 self.execute_generic_task(task, inputs, env, working_dir)
199 .await
200 }
201 }
202 }
203
204 async fn execute_bash_task(
206 &self,
207 inputs: &HashMap<String, String>,
208 env: &HashMap<String, String>,
209 working_dir: &Path,
210 ) -> Result<StepResult, TaskRunnerError> {
211 let target_type = inputs
212 .get("targetType")
213 .map(|s| s.as_str())
214 .unwrap_or("inline");
215
216 let script = match target_type {
217 "inline" => inputs
218 .get("script")
219 .ok_or_else(|| TaskRunnerError::MissingInput("script".to_string()))?
220 .clone(),
221 "filePath" => {
222 let file_path = inputs
223 .get("filePath")
224 .ok_or_else(|| TaskRunnerError::MissingInput("filePath".to_string()))?;
225
226 let script_path = if Path::new(file_path).is_absolute() {
228 PathBuf::from(file_path)
229 } else {
230 working_dir.join(file_path)
231 };
232
233 std::fs::read_to_string(&script_path)?
234 }
235 _ => {
236 return Err(TaskRunnerError::ExecutionFailed(format!(
237 "Unknown targetType: {}",
238 target_type
239 )))
240 }
241 };
242
243 let config = ShellConfig {
244 working_dir: inputs.get("workingDirectory").cloned(),
245 fail_on_stderr: inputs
246 .get("failOnStderr")
247 .map(|s| s == "true")
248 .unwrap_or(false),
249 ..Default::default()
250 };
251
252 let output = self
253 .shell_runner
254 .run_bash(&script, env, working_dir, &config)
255 .await;
256
257 Ok(self.shell_runner.to_step_result(
258 output,
259 None,
260 Some("Bash".to_string()),
261 config.fail_on_stderr,
262 Duration::ZERO, ))
264 }
265
266 async fn execute_powershell_task(
268 &self,
269 inputs: &HashMap<String, String>,
270 env: &HashMap<String, String>,
271 working_dir: &Path,
272 ) -> Result<StepResult, TaskRunnerError> {
273 let target_type = inputs
274 .get("targetType")
275 .map(|s| s.as_str())
276 .unwrap_or("inline");
277 let use_pwsh = inputs.get("pwsh").map(|s| s == "true").unwrap_or(false);
278
279 let script = match target_type {
280 "inline" => inputs
281 .get("script")
282 .ok_or_else(|| TaskRunnerError::MissingInput("script".to_string()))?
283 .clone(),
284 "filePath" => {
285 let file_path = inputs
286 .get("filePath")
287 .ok_or_else(|| TaskRunnerError::MissingInput("filePath".to_string()))?;
288
289 let script_path = if Path::new(file_path).is_absolute() {
290 PathBuf::from(file_path)
291 } else {
292 working_dir.join(file_path)
293 };
294
295 std::fs::read_to_string(&script_path)?
296 }
297 _ => {
298 return Err(TaskRunnerError::ExecutionFailed(format!(
299 "Unknown targetType: {}",
300 target_type
301 )))
302 }
303 };
304
305 let config = ShellConfig {
306 working_dir: inputs.get("workingDirectory").cloned(),
307 fail_on_stderr: inputs
308 .get("failOnStderr")
309 .map(|s| s == "true")
310 .unwrap_or(false),
311 error_action_preference: inputs.get("errorActionPreference").cloned(),
312 ..Default::default()
313 };
314
315 let output = if use_pwsh {
316 self.shell_runner
317 .run_pwsh(&script, env, working_dir, &config)
318 .await
319 } else {
320 self.shell_runner
321 .run_powershell(&script, env, working_dir, &config)
322 .await
323 };
324
325 Ok(self.shell_runner.to_step_result(
326 output,
327 None,
328 Some("PowerShell".to_string()),
329 config.fail_on_stderr,
330 Duration::ZERO,
331 ))
332 }
333
334 async fn execute_cmdline_task(
336 &self,
337 inputs: &HashMap<String, String>,
338 env: &HashMap<String, String>,
339 working_dir: &Path,
340 ) -> Result<StepResult, TaskRunnerError> {
341 let script = inputs
342 .get("script")
343 .ok_or_else(|| TaskRunnerError::MissingInput("script".to_string()))?;
344
345 let config = ShellConfig {
346 working_dir: inputs.get("workingDirectory").cloned(),
347 fail_on_stderr: inputs
348 .get("failOnStderr")
349 .map(|s| s == "true")
350 .unwrap_or(false),
351 ..Default::default()
352 };
353
354 let output = self
355 .shell_runner
356 .run_script(script, env, working_dir, &config)
357 .await;
358
359 Ok(self.shell_runner.to_step_result(
360 output,
361 None,
362 Some("Command Line".to_string()),
363 config.fail_on_stderr,
364 Duration::ZERO,
365 ))
366 }
367
368 async fn execute_generic_task(
370 &self,
371 task: &CachedTask,
372 inputs: &HashMap<String, String>,
373 env: &HashMap<String, String>,
374 working_dir: &Path,
375 ) -> Result<StepResult, TaskRunnerError> {
376 let exec = task.manifest.primary_execution().ok_or_else(|| {
377 TaskRunnerError::UnsupportedExecution("No execution defined".to_string())
378 })?;
379
380 let target_path = task.path.join(&exec.target);
381
382 if !target_path.exists() {
383 return Err(TaskRunnerError::ExecutionFailed(format!(
384 "Task target not found: {}",
385 target_path.display()
386 )));
387 }
388
389 if task.manifest.is_node_task() {
391 self.execute_node_task(&target_path, task, inputs, env, working_dir)
392 .await
393 } else if task.manifest.is_powershell_task() {
394 self.execute_ps_task(&target_path, task, inputs, env, working_dir)
395 .await
396 } else {
397 Err(TaskRunnerError::UnsupportedExecution(format!(
398 "Unknown execution type for task: {}",
399 task.name
400 )))
401 }
402 }
403
404 async fn execute_node_task(
406 &self,
407 target: &Path,
408 task: &CachedTask,
409 inputs: &HashMap<String, String>,
410 env: &HashMap<String, String>,
411 working_dir: &Path,
412 ) -> Result<StepResult, TaskRunnerError> {
413 let node_path = self
414 .node_path
415 .as_ref()
416 .ok_or_else(|| TaskRunnerError::ExecutionFailed("Node.js not found".to_string()))?;
417
418 let mut task_env = env.clone();
420
421 for (key, value) in inputs {
423 let env_key = format!("INPUT_{}", key.to_uppercase().replace([' ', '.'], "_"));
424 task_env.insert(env_key, value.clone());
425 }
426
427 task_env.insert(
429 "AGENT_TEMPDIRECTORY".to_string(),
430 std::env::temp_dir().to_string_lossy().to_string(),
431 );
432 task_env.insert(
433 "AGENT_WORKFOLDER".to_string(),
434 working_dir.to_string_lossy().to_string(),
435 );
436 task_env.insert(
437 "SYSTEM_DEFAULTWORKINGDIRECTORY".to_string(),
438 working_dir.to_string_lossy().to_string(),
439 );
440
441 let script = format!("{} {}", node_path.display(), target.display());
443
444 let config = ShellConfig {
445 working_dir: Some(task.path.to_string_lossy().to_string()),
446 ..Default::default()
447 };
448
449 let output = self
450 .shell_runner
451 .run_script(&script, &task_env, working_dir, &config)
452 .await;
453
454 Ok(self.shell_runner.to_step_result(
455 output,
456 None,
457 task.manifest.friendly_name.clone(),
458 false,
459 Duration::ZERO,
460 ))
461 }
462
463 async fn execute_ps_task(
465 &self,
466 target: &Path,
467 task: &CachedTask,
468 inputs: &HashMap<String, String>,
469 env: &HashMap<String, String>,
470 working_dir: &Path,
471 ) -> Result<StepResult, TaskRunnerError> {
472 let mut task_env = env.clone();
474
475 for (key, value) in inputs {
477 let env_key = format!("INPUT_{}", key.to_uppercase().replace([' ', '.'], "_"));
478 task_env.insert(env_key, value.clone());
479 }
480
481 task_env.insert(
483 "AGENT_TEMPDIRECTORY".to_string(),
484 std::env::temp_dir().to_string_lossy().to_string(),
485 );
486 task_env.insert(
487 "SYSTEM_DEFAULTWORKINGDIRECTORY".to_string(),
488 working_dir.to_string_lossy().to_string(),
489 );
490
491 let script = format!("& '{}' ", target.display());
493
494 let config = ShellConfig {
495 working_dir: Some(task.path.to_string_lossy().to_string()),
496 ..Default::default()
497 };
498
499 let output = self
500 .shell_runner
501 .run_pwsh(&script, &task_env, working_dir, &config)
502 .await;
503
504 Ok(self.shell_runner.to_step_result(
505 output,
506 None,
507 task.manifest.friendly_name.clone(),
508 false,
509 Duration::ZERO,
510 ))
511 }
512}
513
514fn find_node_path() -> Option<PathBuf> {
516 let candidates = if cfg!(target_os = "windows") {
518 vec![
519 "node.exe",
520 "C:\\Program Files\\nodejs\\node.exe",
521 "C:\\Program Files (x86)\\nodejs\\node.exe",
522 ]
523 } else {
524 vec![
525 "node",
526 "/usr/bin/node",
527 "/usr/local/bin/node",
528 "/opt/homebrew/bin/node",
529 ]
530 };
531
532 for candidate in candidates {
533 let path = PathBuf::from(candidate);
534 if path.exists() || which::which(candidate).is_ok() {
535 return Some(path);
536 }
537 }
538
539 which::which("node").ok()
541}
542
543fn find_powershell_path() -> Option<PathBuf> {
545 let candidates = if cfg!(target_os = "windows") {
547 vec![
548 "pwsh.exe",
549 "powershell.exe",
550 "C:\\Program Files\\PowerShell\\7\\pwsh.exe",
551 "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
552 ]
553 } else {
554 vec![
555 "pwsh",
556 "/usr/bin/pwsh",
557 "/usr/local/bin/pwsh",
558 "/opt/microsoft/powershell/7/pwsh",
559 ]
560 };
561
562 for candidate in candidates {
563 let path = PathBuf::from(candidate);
564 if path.exists() || which::which(candidate).is_ok() {
565 return Some(path);
566 }
567 }
568
569 which::which("pwsh").ok()
570}
571
572#[cfg(test)]
573mod tests {
574 use super::*;
575 use tempfile::TempDir;
576
577 fn create_test_runner() -> (TaskRunner, TempDir) {
578 let temp_dir = TempDir::new().unwrap();
579 let runner = TaskRunner::new(temp_dir.path().to_path_buf());
580 (runner, temp_dir)
581 }
582
583 #[tokio::test]
584 async fn test_execute_bash_task_inline() {
585 let (runner, _temp_dir) = create_test_runner();
586 let mut inputs = HashMap::new();
587 inputs.insert("targetType".to_string(), "inline".to_string());
588 inputs.insert(
589 "script".to_string(),
590 "echo 'Hello from Bash task'".to_string(),
591 );
592
593 let env = HashMap::new();
594 let working_dir = std::env::current_dir().unwrap();
595
596 let result = runner.execute_bash_task(&inputs, &env, &working_dir).await;
597
598 if let Ok(step_result) = result {
600 if step_result.status == StepStatus::Succeeded {
601 assert!(step_result.output.contains("Hello from Bash task"));
602 }
603 }
604 }
605
606 #[tokio::test]
607 async fn test_execute_cmdline_task() {
608 let (runner, _temp_dir) = create_test_runner();
609 let mut inputs = HashMap::new();
610 inputs.insert("script".to_string(), "echo Hello from CmdLine".to_string());
611
612 let env = HashMap::new();
613 let working_dir = std::env::current_dir().unwrap();
614
615 let result = runner
616 .execute_cmdline_task(&inputs, &env, &working_dir)
617 .await
618 .unwrap();
619
620 assert_eq!(result.status, StepStatus::Succeeded);
621 assert!(result.output.contains("Hello from CmdLine"));
622 }
623
624 #[test]
625 fn test_merge_inputs() {
626 let (runner, _temp_dir) = create_test_runner();
627
628 let task = CachedTask {
630 name: "Test".to_string(),
631 version: "1".to_string(),
632 path: PathBuf::from("/tmp/test"),
633 manifest: crate::tasks::manifest::TaskManifest {
634 id: "test".to_string(),
635 name: "Test".to_string(),
636 friendly_name: None,
637 description: None,
638 help_url: None,
639 help_mark_down: None,
640 category: None,
641 visibility: None,
642 runs_on: None,
643 author: None,
644 version: crate::tasks::manifest::TaskVersion {
645 major: 1,
646 minor: 0,
647 patch: 0,
648 },
649 minimum_agent_version: None,
650 instance_name_format: None,
651 groups: None,
652 inputs: vec![crate::tasks::manifest::TaskInput {
653 name: "input1".to_string(),
654 input_type: None,
655 label: None,
656 default_value: Some("default_value".to_string()),
657 required: None,
658 help_mark_down: None,
659 group_name: None,
660 visible_rule: None,
661 options: None,
662 properties: None,
663 validation: None,
664 aliases: None,
665 }],
666 output_variables: None,
667 execution: None,
668 pre_job_execution: None,
669 post_job_execution: None,
670 data_source_bindings: None,
671 messages: None,
672 restrictions: None,
673 demands: None,
674 },
675 };
676
677 let mut inputs = HashMap::new();
678 inputs.insert("input2".to_string(), "custom_value".to_string());
679
680 let merged = runner.merge_inputs(&task, &inputs);
681
682 assert_eq!(merged.get("input1"), Some(&"default_value".to_string()));
683 assert_eq!(merged.get("input2"), Some(&"custom_value".to_string()));
684 }
685}