1use crate::parser::models::{StepResult, StepStatus, Value};
5
6use std::collections::HashMap;
7use std::path::Path;
8use std::process::Stdio;
9use std::time::Duration;
10use tokio::io::{AsyncBufReadExt, BufReader};
11use tokio::process::Command;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum Shell {
16 Default,
18 Bash,
20 Pwsh,
22 PowerShell,
24}
25
26impl Shell {
27 fn get_command(&self) -> (&'static str, &'static [&'static str]) {
29 match self {
30 Shell::Default => {
31 if cfg!(target_os = "windows") {
32 ("cmd", &["/C"])
33 } else {
34 ("sh", &["-c"])
35 }
36 }
37 Shell::Bash => ("bash", &["-c"]),
38 Shell::Pwsh => ("pwsh", &["-NoLogo", "-NoProfile", "-Command"]),
39 Shell::PowerShell => {
40 if cfg!(target_os = "windows") {
41 ("powershell.exe", &["-NoLogo", "-NoProfile", "-Command"])
42 } else {
43 ("pwsh", &["-NoLogo", "-NoProfile", "-Command"])
45 }
46 }
47 }
48 }
49}
50
51#[derive(Debug, Clone, Default)]
53pub struct ShellConfig {
54 pub working_dir: Option<String>,
56 pub fail_on_stderr: bool,
58 pub error_action_preference: Option<String>,
60 pub timeout: Option<Duration>,
62}
63
64#[derive(Debug, Clone, Default)]
66pub struct ShellOutput {
67 pub stdout: String,
69 pub stderr: String,
71 pub exit_code: Option<i32>,
73 pub outputs: HashMap<String, String>,
75 pub variables: HashMap<String, Value>,
77}
78
79pub type OutputCallback = Box<dyn Fn(&str, bool) + Send + Sync>;
81
82pub struct ShellRunner {
84 default_shell: Shell,
86}
87
88impl ShellRunner {
89 pub fn new() -> Self {
91 Self {
92 default_shell: Shell::Default,
93 }
94 }
95
96 pub fn with_default_shell(shell: Shell) -> Self {
98 Self {
99 default_shell: shell,
100 }
101 }
102
103 pub async fn run_script(
105 &self,
106 script: &str,
107 env: &HashMap<String, String>,
108 working_dir: &Path,
109 config: &ShellConfig,
110 ) -> ShellOutput {
111 self.run_with_shell(self.default_shell, script, env, working_dir, config)
112 .await
113 }
114
115 pub async fn run_bash(
117 &self,
118 script: &str,
119 env: &HashMap<String, String>,
120 working_dir: &Path,
121 config: &ShellConfig,
122 ) -> ShellOutput {
123 self.run_with_shell(Shell::Bash, script, env, working_dir, config)
124 .await
125 }
126
127 pub async fn run_pwsh(
129 &self,
130 script: &str,
131 env: &HashMap<String, String>,
132 working_dir: &Path,
133 config: &ShellConfig,
134 ) -> ShellOutput {
135 let script = if let Some(pref) = &config.error_action_preference {
137 format!("$ErrorActionPreference = '{}'\n{}", pref, script)
138 } else {
139 script.to_string()
140 };
141
142 self.run_with_shell(Shell::Pwsh, &script, env, working_dir, config)
143 .await
144 }
145
146 pub async fn run_powershell(
148 &self,
149 script: &str,
150 env: &HashMap<String, String>,
151 working_dir: &Path,
152 config: &ShellConfig,
153 ) -> ShellOutput {
154 let script = if let Some(pref) = &config.error_action_preference {
156 format!("$ErrorActionPreference = '{}'\n{}", pref, script)
157 } else {
158 script.to_string()
159 };
160
161 self.run_with_shell(Shell::PowerShell, &script, env, working_dir, config)
162 .await
163 }
164
165 async fn run_with_shell(
167 &self,
168 shell: Shell,
169 script: &str,
170 env: &HashMap<String, String>,
171 working_dir: &Path,
172 config: &ShellConfig,
173 ) -> ShellOutput {
174 let (shell_cmd, shell_args) = shell.get_command();
175
176 let work_dir = config
178 .working_dir
179 .as_ref()
180 .map(Path::new)
181 .unwrap_or(working_dir);
182
183 let mut cmd = Command::new(shell_cmd);
184 cmd.args(shell_args);
185 cmd.arg(script);
186 cmd.current_dir(work_dir);
187 cmd.envs(env);
188 cmd.stdout(Stdio::piped());
189 cmd.stderr(Stdio::piped());
190
191 let mut child = match cmd.spawn() {
193 Ok(child) => child,
194 Err(e) => {
195 return ShellOutput {
196 stdout: String::new(),
197 stderr: format!("Failed to spawn shell process '{}': {}", shell_cmd, e),
198 exit_code: None,
199 outputs: HashMap::new(),
200 variables: HashMap::new(),
201 };
202 }
203 };
204
205 let stdout = match child.stdout.take() {
206 Some(s) => s,
207 None => {
208 return ShellOutput {
209 stdout: String::new(),
210 stderr: "Internal error: stdout was not piped".to_string(),
211 exit_code: None,
212 outputs: HashMap::new(),
213 variables: HashMap::new(),
214 };
215 }
216 };
217 let stderr = match child.stderr.take() {
218 Some(s) => s,
219 None => {
220 return ShellOutput {
221 stdout: String::new(),
222 stderr: "Internal error: stderr was not piped".to_string(),
223 exit_code: None,
224 outputs: HashMap::new(),
225 variables: HashMap::new(),
226 };
227 }
228 };
229
230 let stdout_reader = BufReader::new(stdout);
232 let stderr_reader = BufReader::new(stderr);
233
234 let stdout_handle = tokio::spawn(async move {
235 let mut lines = stdout_reader.lines();
236 let mut output = String::new();
237 while let Ok(Some(line)) = lines.next_line().await {
238 if !output.is_empty() {
239 output.push('\n');
240 }
241 output.push_str(&line);
242 }
243 output
244 });
245
246 let stderr_handle = tokio::spawn(async move {
247 let mut lines = stderr_reader.lines();
248 let mut output = String::new();
249 while let Ok(Some(line)) = lines.next_line().await {
250 if !output.is_empty() {
251 output.push('\n');
252 }
253 output.push_str(&line);
254 }
255 output
256 });
257
258 let wait_result = if let Some(timeout) = config.timeout {
260 match tokio::time::timeout(timeout, child.wait()).await {
261 Ok(result) => result,
262 Err(_) => {
263 let _ = child.kill().await;
265 return ShellOutput {
266 stdout: stdout_handle.await.unwrap_or_default(),
267 stderr: format!("Process timed out after {:?}", timeout),
268 exit_code: None,
269 outputs: HashMap::new(),
270 variables: HashMap::new(),
271 };
272 }
273 }
274 } else {
275 child.wait().await
276 };
277
278 let exit_code = wait_result.ok().and_then(|s| s.code());
279 let stdout = stdout_handle.await.unwrap_or_default();
280 let stderr = stderr_handle.await.unwrap_or_default();
281
282 let (outputs, variables) = parse_logging_commands(&stdout);
284
285 ShellOutput {
286 stdout,
287 stderr,
288 exit_code,
289 outputs,
290 variables,
291 }
292 }
293
294 pub async fn run_script_streaming(
296 &self,
297 script: &str,
298 env: &HashMap<String, String>,
299 working_dir: &Path,
300 config: &ShellConfig,
301 on_output: OutputCallback,
302 ) -> ShellOutput {
303 self.run_with_shell_streaming(
304 self.default_shell,
305 script,
306 env,
307 working_dir,
308 config,
309 on_output,
310 )
311 .await
312 }
313
314 async fn run_with_shell_streaming(
316 &self,
317 shell: Shell,
318 script: &str,
319 env: &HashMap<String, String>,
320 working_dir: &Path,
321 config: &ShellConfig,
322 on_output: OutputCallback,
323 ) -> ShellOutput {
324 let (shell_cmd, shell_args) = shell.get_command();
325
326 let work_dir = config
327 .working_dir
328 .as_ref()
329 .map(Path::new)
330 .unwrap_or(working_dir);
331
332 let mut cmd = Command::new(shell_cmd);
333 cmd.args(shell_args);
334 cmd.arg(script);
335 cmd.current_dir(work_dir);
336 cmd.envs(env);
337 cmd.stdout(Stdio::piped());
338 cmd.stderr(Stdio::piped());
339
340 let mut child = match cmd.spawn() {
341 Ok(child) => child,
342 Err(e) => {
343 return ShellOutput {
344 stdout: String::new(),
345 stderr: format!("Failed to spawn shell process '{}': {}", shell_cmd, e),
346 exit_code: None,
347 outputs: HashMap::new(),
348 variables: HashMap::new(),
349 };
350 }
351 };
352
353 let stdout = match child.stdout.take() {
354 Some(s) => s,
355 None => {
356 return ShellOutput {
357 stdout: String::new(),
358 stderr: "Internal error: stdout was not piped".to_string(),
359 exit_code: None,
360 outputs: HashMap::new(),
361 variables: HashMap::new(),
362 };
363 }
364 };
365 let stderr = match child.stderr.take() {
366 Some(s) => s,
367 None => {
368 return ShellOutput {
369 stdout: String::new(),
370 stderr: "Internal error: stderr was not piped".to_string(),
371 exit_code: None,
372 outputs: HashMap::new(),
373 variables: HashMap::new(),
374 };
375 }
376 };
377
378 let stdout_reader = BufReader::new(stdout);
379 let stderr_reader = BufReader::new(stderr);
380
381 let on_output = std::sync::Arc::new(on_output);
382 let on_output_stdout = on_output.clone();
383 let on_output_stderr = on_output;
384
385 let stdout_handle = tokio::spawn(async move {
387 let mut lines = stdout_reader.lines();
388 let mut output = String::new();
389 while let Ok(Some(line)) = lines.next_line().await {
390 on_output_stdout(&line, false);
391 if !output.is_empty() {
392 output.push('\n');
393 }
394 output.push_str(&line);
395 }
396 output
397 });
398
399 let stderr_handle = tokio::spawn(async move {
401 let mut lines = stderr_reader.lines();
402 let mut output = String::new();
403 while let Ok(Some(line)) = lines.next_line().await {
404 on_output_stderr(&line, true);
405 if !output.is_empty() {
406 output.push('\n');
407 }
408 output.push_str(&line);
409 }
410 output
411 });
412
413 let wait_result = if let Some(timeout) = config.timeout {
414 match tokio::time::timeout(timeout, child.wait()).await {
415 Ok(result) => result,
416 Err(_) => {
417 let _ = child.kill().await;
418 return ShellOutput {
419 stdout: stdout_handle.await.unwrap_or_default(),
420 stderr: format!("Process timed out after {:?}", timeout),
421 exit_code: None,
422 outputs: HashMap::new(),
423 variables: HashMap::new(),
424 };
425 }
426 }
427 } else {
428 child.wait().await
429 };
430
431 let exit_code = wait_result.ok().and_then(|s| s.code());
432 let stdout = stdout_handle.await.unwrap_or_default();
433 let stderr = stderr_handle.await.unwrap_or_default();
434
435 let (outputs, variables) = parse_logging_commands(&stdout);
436
437 ShellOutput {
438 stdout,
439 stderr,
440 exit_code,
441 outputs,
442 variables,
443 }
444 }
445
446 pub fn to_step_result(
448 &self,
449 output: ShellOutput,
450 step_name: Option<String>,
451 display_name: Option<String>,
452 fail_on_stderr: bool,
453 duration: Duration,
454 ) -> StepResult {
455 let status = if output.exit_code.map(|c| c != 0).unwrap_or(true)
456 || (fail_on_stderr && !output.stderr.is_empty())
457 {
458 StepStatus::Failed
459 } else {
460 StepStatus::Succeeded
461 };
462
463 StepResult {
464 step_name,
465 display_name,
466 status,
467 output: output.stdout,
468 error: if output.stderr.is_empty() {
469 None
470 } else {
471 Some(output.stderr)
472 },
473 duration,
474 exit_code: output.exit_code,
475 outputs: output.outputs,
476 }
477 }
478}
479
480impl Default for ShellRunner {
481 fn default() -> Self {
482 Self::new()
483 }
484}
485
486fn parse_logging_commands(output: &str) -> (HashMap<String, String>, HashMap<String, Value>) {
488 let mut outputs = HashMap::new();
489 let mut variables = HashMap::new();
490
491 for line in output.lines() {
492 if let Some(rest) = line.strip_prefix("##vso[task.setvariable") {
494 if let Some((props, value)) = rest.split_once(']') {
495 let mut var_name = None;
496 let mut is_output = false;
497 let mut is_secret = false;
498
499 for prop in props.split(';') {
500 let prop = prop.trim();
501 if let Some(name) = prop.strip_prefix("variable=") {
502 var_name = Some(name.to_string());
503 } else if prop.eq_ignore_ascii_case("isoutput=true") {
504 is_output = true;
505 } else if prop.eq_ignore_ascii_case("issecret=true") {
506 is_secret = true;
507 }
508 }
509
510 if let Some(name) = var_name {
511 if is_output {
512 outputs.insert(name.clone(), value.to_string());
513 }
514 if !is_secret {
515 variables.insert(name, Value::String(value.to_string()));
516 }
517 }
518 }
519 }
520 else if let Some(rest) = line.strip_prefix("##vso[task.setVariable") {
522 if let Some((props, value)) = rest.split_once(']') {
523 let mut var_name = None;
524 let mut is_output = false;
525 let mut is_secret = false;
526
527 for prop in props.split(';') {
528 let prop = prop.trim();
529 if let Some(name) = prop.strip_prefix("variable=") {
530 var_name = Some(name.to_string());
531 } else if prop.eq_ignore_ascii_case("isoutput=true") {
532 is_output = true;
533 } else if prop.eq_ignore_ascii_case("issecret=true") {
534 is_secret = true;
535 }
536 }
537
538 if let Some(name) = var_name {
539 if is_output {
540 outputs.insert(name.clone(), value.to_string());
541 }
542 if !is_secret {
543 variables.insert(name, Value::String(value.to_string()));
544 }
545 }
546 }
547 }
548 else if let Some(rest) = line.strip_prefix("##vso[task.prependpath]") {
550 let existing = variables
552 .entry("_PREPEND_PATH".to_string())
553 .or_insert_with(|| Value::Array(vec![]));
554 if let Value::Array(arr) = existing {
555 arr.push(Value::String(rest.to_string()));
556 }
557 }
558 else if let Some(rest) = line.strip_prefix("##vso[task.uploadfile]") {
560 let existing = variables
561 .entry("_UPLOAD_FILES".to_string())
562 .or_insert_with(|| Value::Array(vec![]));
563 if let Value::Array(arr) = existing {
564 arr.push(Value::String(rest.to_string()));
565 }
566 }
567 else if let Some(rest) = line.strip_prefix("##vso[build.addbuildtag]") {
571 let existing = variables
572 .entry("_BUILD_TAGS".to_string())
573 .or_insert_with(|| Value::Array(vec![]));
574 if let Value::Array(arr) = existing {
575 arr.push(Value::String(rest.to_string()));
576 }
577 }
578 else if let Some(rest) = line.strip_prefix("##vso[task.complete") {
580 if let Some((props, _message)) = rest.split_once(']') {
581 for prop in props.split(';') {
582 let prop = prop.trim();
583 if let Some(result) = prop.strip_prefix("result=") {
584 variables.insert(
585 "_TASK_RESULT".to_string(),
586 Value::String(result.to_string()),
587 );
588 }
589 }
590 }
591 }
592 }
593
594 (outputs, variables)
595}
596
597#[cfg(test)]
598mod tests {
599 use super::*;
600
601 #[tokio::test]
602 async fn test_shell_runner_echo() {
603 let runner = ShellRunner::new();
604 let env = HashMap::new();
605 let working_dir = std::env::current_dir().unwrap();
606 let config = ShellConfig::default();
607
608 let output = runner
609 .run_script("echo hello", &env, &working_dir, &config)
610 .await;
611
612 assert_eq!(output.exit_code, Some(0));
613 assert!(output.stdout.contains("hello"));
614 assert!(output.stderr.is_empty());
615 }
616
617 #[tokio::test]
618 async fn test_shell_runner_with_env() {
619 let runner = ShellRunner::new();
620 let mut env = HashMap::new();
621 env.insert("MY_VAR".to_string(), "test_value".to_string());
622 let working_dir = std::env::current_dir().unwrap();
623 let config = ShellConfig::default();
624
625 let script = if cfg!(target_os = "windows") {
626 "echo %MY_VAR%"
627 } else {
628 "echo $MY_VAR"
629 };
630
631 let output = runner.run_script(script, &env, &working_dir, &config).await;
632
633 assert_eq!(output.exit_code, Some(0));
634 assert!(output.stdout.contains("test_value"));
635 }
636
637 #[tokio::test]
638 async fn test_shell_runner_bash() {
639 let runner = ShellRunner::new();
640 let env = HashMap::new();
641 let working_dir = std::env::current_dir().unwrap();
642 let config = ShellConfig::default();
643
644 let output = runner
645 .run_bash("echo 'bash test'", &env, &working_dir, &config)
646 .await;
647
648 if output.exit_code == Some(0) {
650 assert!(output.stdout.contains("bash test"));
651 }
652 }
653
654 #[tokio::test]
655 async fn test_shell_runner_exit_code() {
656 let runner = ShellRunner::new();
657 let env = HashMap::new();
658 let working_dir = std::env::current_dir().unwrap();
659 let config = ShellConfig::default();
660
661 let output = runner
662 .run_script("exit 42", &env, &working_dir, &config)
663 .await;
664
665 assert_eq!(output.exit_code, Some(42));
666 }
667
668 #[tokio::test]
669 async fn test_shell_runner_stderr() {
670 let runner = ShellRunner::new();
671 let env = HashMap::new();
672 let working_dir = std::env::current_dir().unwrap();
673 let config = ShellConfig::default();
674
675 let output = runner
676 .run_script("echo error >&2", &env, &working_dir, &config)
677 .await;
678
679 assert_eq!(output.exit_code, Some(0));
680 assert!(output.stderr.contains("error"));
681 }
682
683 #[test]
684 fn test_parse_logging_commands_setvariable() {
685 let output = r#"
686Starting build
687##vso[task.setvariable variable=version]1.0.0
688##vso[task.setvariable variable=output;isoutput=true]result_value
689Build complete
690"#;
691
692 let (outputs, variables) = parse_logging_commands(output);
693
694 assert_eq!(
695 variables.get("version"),
696 Some(&Value::String("1.0.0".to_string()))
697 );
698 assert_eq!(outputs.get("output"), Some(&"result_value".to_string()));
699 assert_eq!(
700 variables.get("output"),
701 Some(&Value::String("result_value".to_string()))
702 );
703 }
704
705 #[test]
706 fn test_parse_logging_commands_secret() {
707 let output = "##vso[task.setvariable variable=password;issecret=true]secretvalue";
708
709 let (outputs, variables) = parse_logging_commands(output);
710
711 assert!(!variables.contains_key("password"));
713 assert!(!outputs.contains_key("password"));
714 }
715
716 #[test]
717 fn test_parse_logging_commands_build_tag() {
718 let output = r#"
719##vso[build.addbuildtag]release
720##vso[build.addbuildtag]v1.0
721"#;
722
723 let (_outputs, variables) = parse_logging_commands(output);
724
725 let tags = variables.get("_BUILD_TAGS").unwrap();
726 if let Value::Array(arr) = tags {
727 assert_eq!(arr.len(), 2);
728 assert_eq!(arr[0], Value::String("release".to_string()));
729 assert_eq!(arr[1], Value::String("v1.0".to_string()));
730 } else {
731 panic!("Expected array");
732 }
733 }
734
735 #[test]
736 fn test_to_step_result_success() {
737 let runner = ShellRunner::new();
738 let output = ShellOutput {
739 stdout: "Success".to_string(),
740 stderr: String::new(),
741 exit_code: Some(0),
742 outputs: HashMap::new(),
743 variables: HashMap::new(),
744 };
745
746 let result = runner.to_step_result(
747 output,
748 Some("test_step".to_string()),
749 Some("Test Step".to_string()),
750 false,
751 Duration::from_secs(1),
752 );
753
754 assert_eq!(result.status, StepStatus::Succeeded);
755 assert_eq!(result.output, "Success");
756 assert!(result.error.is_none());
757 assert_eq!(result.exit_code, Some(0));
758 }
759
760 #[test]
761 fn test_to_step_result_fail_on_stderr() {
762 let runner = ShellRunner::new();
763 let output = ShellOutput {
764 stdout: "Output".to_string(),
765 stderr: "Warning message".to_string(),
766 exit_code: Some(0),
767 outputs: HashMap::new(),
768 variables: HashMap::new(),
769 };
770
771 let result = runner.to_step_result(output, None, None, true, Duration::from_secs(1));
772
773 assert_eq!(result.status, StepStatus::Failed);
774 assert_eq!(result.error, Some("Warning message".to_string()));
775 }
776}