1use crate::expression::{ExpressionContext, ExpressionEngine};
6use crate::parser::azure::AzureParser;
7use crate::parser::error::{ParseError, ParseErrorKind, ParseResult};
8use crate::parser::models::*;
9
10use std::collections::HashMap;
11use std::fs;
12use std::path::{Path, PathBuf};
13
14const MAX_TEMPLATE_DEPTH: usize = 50;
16
17#[derive(Debug, Clone)]
19pub struct TemplateError {
20 pub message: String,
21 pub template_path: Option<String>,
22 pub kind: TemplateErrorKind,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum TemplateErrorKind {
27 NotFound,
29 CircularReference,
31 MaxDepthExceeded,
33 InvalidParameter,
35 TypeMismatch,
37 MissingParameter,
39 ParseError,
41 ExpressionError,
43}
44
45impl std::fmt::Display for TemplateError {
46 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47 if let Some(path) = &self.template_path {
48 write!(f, "template error in '{}': {}", path, self.message)
49 } else {
50 write!(f, "template error: {}", self.message)
51 }
52 }
53}
54
55impl std::error::Error for TemplateError {}
56
57impl TemplateError {
58 pub fn new(message: impl Into<String>, kind: TemplateErrorKind) -> Self {
59 Self {
60 message: message.into(),
61 template_path: None,
62 kind,
63 }
64 }
65
66 pub fn with_path(mut self, path: impl Into<String>) -> Self {
67 self.template_path = Some(path.into());
68 self
69 }
70
71 pub fn to_parse_error(&self) -> ParseError {
72 ParseError::new(self.to_string(), 0, 0).with_kind(ParseErrorKind::TemplateError)
73 }
74}
75
76#[derive(Debug, Clone)]
78pub enum TemplateContent {
79 Steps(Vec<Step>),
81 Jobs(Vec<Job>),
83 Stages(Vec<Stage>),
85 Variables(Vec<Variable>),
87 Pipeline(Box<Pipeline>),
89}
90
91#[derive(Debug, Clone)]
93enum RawTemplateContent {
94 Steps(serde_yaml::Value),
95 Jobs(serde_yaml::Value),
96 Stages(serde_yaml::Value),
97 Variables(serde_yaml::Value),
98 Pipeline(String),
99}
100
101#[derive(Debug, Clone)]
103pub struct TemplateFile {
104 pub parameters: Vec<Parameter>,
106 pub content: TemplateContent,
108}
109
110#[derive(Debug, Clone)]
112struct RawTemplateFile {
113 parameters: Vec<Parameter>,
115 content: RawTemplateContent,
117}
118
119#[derive(Debug, Clone)]
121enum TemplateDirective {
122 If(String),
124 ElseIf(String),
126 Else,
128 Each(String, String),
130}
131
132pub struct TemplateEngine {
141 repo_root: PathBuf,
143 resource_repos: HashMap<String, PathBuf>,
145 include_stack: Vec<String>,
147}
148
149impl TemplateEngine {
150 pub fn new(repo_root: PathBuf) -> Self {
152 Self {
153 repo_root,
154 resource_repos: HashMap::new(),
155 include_stack: Vec::new(),
156 }
157 }
158
159 pub fn with_resource_repo(mut self, name: String, path: PathBuf) -> Self {
161 self.resource_repos.insert(name, path);
162 self
163 }
164
165 pub fn resolve_pipeline(&mut self, pipeline: Pipeline) -> ParseResult<Pipeline> {
168 let mut resolved = pipeline;
169
170 if let Some(extends) = resolved.extends.take() {
172 resolved = self.resolve_extends(&extends, resolved)?;
173 }
174
175 resolved.variables = self.resolve_variable_templates(&resolved.variables)?;
177
178 resolved.stages = self.resolve_stage_templates(&resolved.stages)?;
180
181 resolved.jobs = self.resolve_job_templates(&resolved.jobs)?;
183
184 resolved.steps = self.resolve_step_templates(&resolved.steps)?;
186
187 Ok(resolved)
188 }
189
190 fn resolve_extends(&mut self, extends: &Extends, child: Pipeline) -> ParseResult<Pipeline> {
196 let template_path = self.resolve_template_path(&extends.template)?;
197 let canonical = self.canonical_path(&template_path);
198
199 self.push_template(&canonical)?;
200
201 let template_content = fs::read_to_string(&template_path).map_err(|e| {
202 TemplateError::new(
203 format!("failed to read extends template: {}", e),
204 TemplateErrorKind::NotFound,
205 )
206 .with_path(&extends.template)
207 .to_parse_error()
208 })?;
209
210 let mut parent = AzureParser::parse(&template_content).map_err(|e| {
211 ParseError::new(
212 format!(
213 "error in extends template '{}': {}",
214 extends.template, e.message
215 ),
216 e.line,
217 e.column,
218 )
219 .with_kind(ParseErrorKind::TemplateError)
220 })?;
221
222 let params =
224 self.resolve_parameters(&parent.parameters, &extends.parameters, &extends.template)?;
225
226 parent = self.substitute_template_parameters(parent, ¶ms)?;
228
229 let merged = self.merge_extends(parent, child);
231
232 self.pop_template();
233
234 self.resolve_pipeline(merged)
236 }
237
238 fn merge_extends(&self, mut parent: Pipeline, child: Pipeline) -> Pipeline {
240 if child.trigger.is_some() {
242 parent.trigger = child.trigger;
243 }
244 if child.pr.is_some() {
245 parent.pr = child.pr;
246 }
247 if child.schedules.is_some() {
248 parent.schedules = child.schedules;
249 }
250
251 if child.resources.is_some() {
253 parent.resources = child.resources;
254 }
255
256 let mut merged_vars = parent.variables;
258 for var in child.variables {
259 if let Variable::KeyValue { ref name, .. } = var {
260 merged_vars.retain(|v| {
261 if let Variable::KeyValue { name: existing, .. } = v {
262 existing != name
263 } else {
264 true
265 }
266 });
267 }
268 merged_vars.push(var);
269 }
270 parent.variables = merged_vars;
271
272 if child.pool.is_some() {
274 parent.pool = child.pool;
275 }
276
277 if child.name.is_some() {
279 parent.name = child.name;
280 }
281
282 parent
283 }
284
285 fn resolve_variable_templates(&mut self, variables: &[Variable]) -> ParseResult<Vec<Variable>> {
291 let mut resolved = Vec::new();
292
293 for var in variables {
294 match var {
295 Variable::Template {
296 template,
297 parameters,
298 } => {
299 let expanded = self.expand_variable_template(template, parameters)?;
300 resolved.extend(expanded);
301 }
302 other => resolved.push(other.clone()),
303 }
304 }
305
306 Ok(resolved)
307 }
308
309 fn expand_variable_template(
311 &mut self,
312 template_ref: &str,
313 call_params: &HashMap<String, serde_yaml::Value>,
314 ) -> ParseResult<Vec<Variable>> {
315 let raw_template_file = self.load_template_file(template_ref)?;
316
317 let params =
319 self.resolve_parameters(&raw_template_file.parameters, call_params, template_ref)?;
320
321 let engine = self.build_parameter_engine(¶ms);
323 let template_file = self.resolve_raw_template(&raw_template_file, &engine, template_ref)?;
324
325 let result = match template_file.content {
326 TemplateContent::Variables(vars) => {
327 let mut resolved = Vec::new();
329 for var in vars {
330 resolved.push(self.substitute_variable_params(&var, &engine)?);
331 }
332 Ok(resolved)
333 }
334 _ => Err(TemplateError::new(
335 format!(
336 "template '{}' does not contain variables (found {:?} content instead)",
337 template_ref,
338 content_type_name(&template_file.content)
339 ),
340 TemplateErrorKind::TypeMismatch,
341 )
342 .with_path(template_ref)
343 .to_parse_error()),
344 };
345
346 self.pop_template();
347 result
348 }
349
350 fn resolve_stage_templates(&mut self, stages: &[Stage]) -> ParseResult<Vec<Stage>> {
356 let mut resolved = Vec::new();
357
358 for stage in stages {
359 if let Some(template_ref) = &stage.template {
360 let expanded = self.expand_stage_template(template_ref, &stage.parameters)?;
361 resolved.extend(expanded);
362 } else {
363 let mut stage = stage.clone();
364 stage.variables = self.resolve_variable_templates(&stage.variables)?;
366 stage.jobs = self.resolve_job_templates(&stage.jobs)?;
368 resolved.push(stage);
369 }
370 }
371
372 Ok(resolved)
373 }
374
375 fn expand_stage_template(
377 &mut self,
378 template_ref: &str,
379 call_params: &HashMap<String, serde_yaml::Value>,
380 ) -> ParseResult<Vec<Stage>> {
381 let raw_template_file = self.load_template_file(template_ref)?;
382
383 let params =
384 self.resolve_parameters(&raw_template_file.parameters, call_params, template_ref)?;
385
386 let engine = self.build_parameter_engine(¶ms);
388 let template_file = self.resolve_raw_template(&raw_template_file, &engine, template_ref)?;
389
390 let result = match template_file.content {
391 TemplateContent::Stages(stages) => {
392 let mut resolved = Vec::new();
393 for stage in stages {
394 let expanded = self.substitute_stage_params(&stage, &engine)?;
395 let mut expanded_stage = expanded;
397 expanded_stage.variables =
398 self.resolve_variable_templates(&expanded_stage.variables)?;
399 expanded_stage.jobs = self.resolve_job_templates(&expanded_stage.jobs)?;
400 resolved.push(expanded_stage);
401 }
402 Ok(resolved)
403 }
404 _ => Err(TemplateError::new(
405 format!("template '{}' does not contain stages", template_ref),
406 TemplateErrorKind::TypeMismatch,
407 )
408 .with_path(template_ref)
409 .to_parse_error()),
410 };
411
412 self.pop_template();
413 result
414 }
415
416 fn resolve_job_templates(&mut self, jobs: &[Job]) -> ParseResult<Vec<Job>> {
422 let mut resolved = Vec::new();
423
424 for job in jobs {
425 if let Some(template_ref) = &job.template {
426 let expanded = self.expand_job_template(template_ref, &job.parameters)?;
427 resolved.extend(expanded);
428 } else {
429 let mut job = job.clone();
430 job.variables = self.resolve_variable_templates(&job.variables)?;
432 job.steps = self.resolve_step_templates(&job.steps)?;
434 resolved.push(job);
435 }
436 }
437
438 Ok(resolved)
439 }
440
441 fn expand_job_template(
443 &mut self,
444 template_ref: &str,
445 call_params: &HashMap<String, serde_yaml::Value>,
446 ) -> ParseResult<Vec<Job>> {
447 let raw_template_file = self.load_template_file(template_ref)?;
448
449 let params =
450 self.resolve_parameters(&raw_template_file.parameters, call_params, template_ref)?;
451
452 let engine = self.build_parameter_engine(¶ms);
454 let template_file = self.resolve_raw_template(&raw_template_file, &engine, template_ref)?;
455
456 let result = match template_file.content {
457 TemplateContent::Jobs(jobs) => {
458 let mut resolved = Vec::new();
459 for job in jobs {
460 let expanded = self.substitute_job_params(&job, &engine)?;
461 let mut expanded_job = expanded;
463 expanded_job.variables =
464 self.resolve_variable_templates(&expanded_job.variables)?;
465 expanded_job.steps = self.resolve_step_templates(&expanded_job.steps)?;
466 resolved.push(expanded_job);
467 }
468 Ok(resolved)
469 }
470 _ => Err(TemplateError::new(
471 format!("template '{}' does not contain jobs", template_ref),
472 TemplateErrorKind::TypeMismatch,
473 )
474 .with_path(template_ref)
475 .to_parse_error()),
476 };
477
478 self.pop_template();
479 result
480 }
481
482 fn resolve_step_templates(&mut self, steps: &[Step]) -> ParseResult<Vec<Step>> {
488 let mut resolved = Vec::new();
489
490 for step in steps {
491 if let StepAction::Template(template_step) = &step.action {
492 let expanded =
493 self.expand_step_template(&template_step.template, &template_step.parameters)?;
494 resolved.extend(expanded);
495 } else {
496 resolved.push(step.clone());
497 }
498 }
499
500 Ok(resolved)
501 }
502
503 fn expand_step_template(
505 &mut self,
506 template_ref: &str,
507 call_params: &HashMap<String, serde_yaml::Value>,
508 ) -> ParseResult<Vec<Step>> {
509 let raw_template_file = self.load_template_file(template_ref)?;
510
511 let params =
512 self.resolve_parameters(&raw_template_file.parameters, call_params, template_ref)?;
513
514 let engine = self.build_parameter_engine(¶ms);
516 let template_file = self.resolve_raw_template(&raw_template_file, &engine, template_ref)?;
517
518 let result = match template_file.content {
519 TemplateContent::Steps(steps) => {
520 let mut resolved = Vec::new();
521 for step in steps {
522 let expanded = self.substitute_step_params(&step, &engine)?;
523 if let StepAction::Template(nested) = &expanded.action {
525 let nested_steps =
526 self.expand_step_template(&nested.template, &nested.parameters)?;
527 resolved.extend(nested_steps);
528 } else {
529 resolved.push(expanded);
530 }
531 }
532 Ok(resolved)
533 }
534 _ => Err(TemplateError::new(
535 format!("template '{}' does not contain steps", template_ref),
536 TemplateErrorKind::TypeMismatch,
537 )
538 .with_path(template_ref)
539 .to_parse_error()),
540 };
541
542 self.pop_template();
543 result
544 }
545
546 fn load_template_file(&mut self, template_ref: &str) -> ParseResult<RawTemplateFile> {
555 let template_path = self.resolve_template_path(template_ref)?;
556 let canonical = self.canonical_path(&template_path);
557
558 self.push_template(&canonical)?;
559
560 let content = fs::read_to_string(&template_path).map_err(|e| {
561 self.pop_template();
562 TemplateError::new(
563 format!("failed to read template '{}': {}", template_ref, e),
564 TemplateErrorKind::NotFound,
565 )
566 .with_path(template_ref)
567 .to_parse_error()
568 })?;
569
570 let result = self.parse_raw_template_content(template_ref, &content);
571
572 if result.is_err() {
573 self.pop_template();
574 }
575 result
578 }
579
580 fn parse_raw_template_content(
582 &self,
583 template_ref: &str,
584 content: &str,
585 ) -> ParseResult<RawTemplateFile> {
586 let yaml: serde_yaml::Value =
588 serde_yaml::from_str(content).map_err(|e| ParseError::from_yaml_error(&e, content))?;
589
590 let mapping = yaml.as_mapping().ok_or_else(|| {
591 TemplateError::new(
592 format!("template '{}' must be a YAML mapping", template_ref),
593 TemplateErrorKind::ParseError,
594 )
595 .with_path(template_ref)
596 .to_parse_error()
597 })?;
598
599 let parameters = if let Some(params_val) = mapping.get("parameters") {
601 self.parse_template_parameters(params_val)?
602 } else {
603 Vec::new()
604 };
605
606 if let Some(steps_val) = mapping.get("steps") {
608 Ok(RawTemplateFile {
609 parameters,
610 content: RawTemplateContent::Steps(steps_val.clone()),
611 })
612 } else if let Some(jobs_val) = mapping.get("jobs") {
613 Ok(RawTemplateFile {
614 parameters,
615 content: RawTemplateContent::Jobs(jobs_val.clone()),
616 })
617 } else if let Some(stages_val) = mapping.get("stages") {
618 Ok(RawTemplateFile {
619 parameters,
620 content: RawTemplateContent::Stages(stages_val.clone()),
621 })
622 } else if let Some(variables_val) = mapping.get("variables") {
623 Ok(RawTemplateFile {
624 parameters,
625 content: RawTemplateContent::Variables(variables_val.clone()),
626 })
627 } else {
628 Ok(RawTemplateFile {
630 parameters,
631 content: RawTemplateContent::Pipeline(content.to_string()),
632 })
633 }
634 }
635
636 fn resolve_raw_template(
639 &self,
640 raw: &RawTemplateFile,
641 engine: &ExpressionEngine,
642 template_ref: &str,
643 ) -> ParseResult<TemplateFile> {
644 let content = match &raw.content {
645 RawTemplateContent::Steps(yaml_val) => {
646 let processed = self.process_template_expressions(yaml_val, engine)?;
647 let steps: Vec<Step> = serde_yaml::from_value(processed).map_err(|e| {
648 ParseError::new(
649 format!("error parsing steps in template '{}': {}", template_ref, e),
650 0,
651 0,
652 )
653 .with_kind(ParseErrorKind::TemplateError)
654 })?;
655 TemplateContent::Steps(steps)
656 }
657 RawTemplateContent::Jobs(yaml_val) => {
658 let processed = self.process_template_expressions(yaml_val, engine)?;
659 let jobs: Vec<Job> = serde_yaml::from_value(processed).map_err(|e| {
660 ParseError::new(
661 format!("error parsing jobs in template '{}': {}", template_ref, e),
662 0,
663 0,
664 )
665 .with_kind(ParseErrorKind::TemplateError)
666 })?;
667 TemplateContent::Jobs(jobs)
668 }
669 RawTemplateContent::Stages(yaml_val) => {
670 let processed = self.process_template_expressions(yaml_val, engine)?;
671 let stages: Vec<Stage> = serde_yaml::from_value(processed).map_err(|e| {
672 ParseError::new(
673 format!("error parsing stages in template '{}': {}", template_ref, e),
674 0,
675 0,
676 )
677 .with_kind(ParseErrorKind::TemplateError)
678 })?;
679 TemplateContent::Stages(stages)
680 }
681 RawTemplateContent::Variables(yaml_val) => {
682 let processed = self.process_template_expressions(yaml_val, engine)?;
683 let variables: Vec<Variable> = serde_yaml::from_value(processed).map_err(|e| {
684 ParseError::new(
685 format!(
686 "error parsing variables in template '{}': {}",
687 template_ref, e
688 ),
689 0,
690 0,
691 )
692 .with_kind(ParseErrorKind::TemplateError)
693 })?;
694 TemplateContent::Variables(variables)
695 }
696 RawTemplateContent::Pipeline(content_str) => {
697 let pipeline = AzureParser::parse(content_str).map_err(|e| {
698 ParseError::new(
699 format!(
700 "template '{}' is not a valid template file: {}",
701 template_ref, e.message
702 ),
703 0,
704 0,
705 )
706 .with_kind(ParseErrorKind::TemplateError)
707 })?;
708 TemplateContent::Pipeline(Box::new(pipeline))
709 }
710 };
711
712 Ok(TemplateFile {
713 parameters: raw.parameters.clone(),
714 content,
715 })
716 }
717
718 fn parse_template_parameters(
720 &self,
721 params_val: &serde_yaml::Value,
722 ) -> ParseResult<Vec<Parameter>> {
723 match params_val {
724 serde_yaml::Value::Sequence(seq) => {
725 let mut params = Vec::new();
726 for item in seq {
727 let param: Parameter = serde_yaml::from_value(item.clone()).map_err(|e| {
728 ParseError::new(format!("invalid parameter definition: {}", e), 0, 0)
729 .with_kind(ParseErrorKind::TemplateError)
730 })?;
731 params.push(param);
732 }
733 Ok(params)
734 }
735 serde_yaml::Value::Mapping(map) => {
736 let mut params = Vec::new();
738 for (key, value) in map {
739 if let Some(name) = key.as_str() {
740 params.push(Parameter {
741 name: name.to_string(),
742 display_name: None,
743 param_type: ParameterType::String,
744 default: Some(value.clone()),
745 values: None,
746 });
747 }
748 }
749 Ok(params)
750 }
751 _ => Err(
752 ParseError::new("parameters must be a list or mapping", 0, 0)
753 .with_kind(ParseErrorKind::TemplateError),
754 ),
755 }
756 }
757
758 fn resolve_parameters(
764 &self,
765 declared: &[Parameter],
766 provided: &HashMap<String, serde_yaml::Value>,
767 template_ref: &str,
768 ) -> ParseResult<HashMap<String, Value>> {
769 let mut resolved = HashMap::new();
770
771 for param in declared {
772 if let Some(provided_val) = provided.get(¶m.name) {
773 self.validate_parameter_type(
775 ¶m.name,
776 provided_val,
777 ¶m.param_type,
778 template_ref,
779 )?;
780
781 if let Some(allowed) = ¶m.values {
783 if !allowed.iter().any(|v| v == provided_val) {
784 return Err(TemplateError::new(
785 format!("parameter '{}' value not in allowed values", param.name),
786 TemplateErrorKind::InvalidParameter,
787 )
788 .with_path(template_ref)
789 .to_parse_error());
790 }
791 }
792
793 resolved.insert(param.name.clone(), yaml_to_value(provided_val));
794 } else if let Some(default) = ¶m.default {
795 resolved.insert(param.name.clone(), yaml_to_value(default));
797 } else {
798 return Err(TemplateError::new(
800 format!(
801 "required parameter '{}' not provided for template '{}'",
802 param.name, template_ref
803 ),
804 TemplateErrorKind::MissingParameter,
805 )
806 .with_path(template_ref)
807 .to_parse_error());
808 }
809 }
810
811 for (name, value) in provided {
813 if !resolved.contains_key(name) {
814 resolved.insert(name.clone(), yaml_to_value(value));
815 }
816 }
817
818 Ok(resolved)
819 }
820
821 fn validate_parameter_type(
823 &self,
824 name: &str,
825 value: &serde_yaml::Value,
826 param_type: &ParameterType,
827 template_ref: &str,
828 ) -> ParseResult<()> {
829 let valid = match param_type {
830 ParameterType::String => value.is_string() || value.is_number() || value.is_bool(),
831 ParameterType::Number => {
832 value.is_number()
833 || value
834 .as_str()
835 .map(|s| s.parse::<f64>().is_ok())
836 .unwrap_or(false)
837 }
838 ParameterType::Boolean => {
839 value.is_bool()
840 || value
841 .as_str()
842 .map(|s| s == "true" || s == "false")
843 .unwrap_or(false)
844 }
845 ParameterType::Object => value.is_mapping() || value.is_sequence(),
846 ParameterType::Step => value.is_mapping(),
847 ParameterType::StepList => value.is_sequence(),
848 ParameterType::Job => value.is_mapping(),
849 ParameterType::JobList => value.is_sequence(),
850 ParameterType::Stage => value.is_mapping(),
851 ParameterType::StageList => value.is_sequence(),
852 };
853
854 if !valid {
855 Err(TemplateError::new(
856 format!(
857 "parameter '{}' expected type {:?} but got {:?}",
858 name, param_type, value
859 ),
860 TemplateErrorKind::TypeMismatch,
861 )
862 .with_path(template_ref)
863 .to_parse_error())
864 } else {
865 Ok(())
866 }
867 }
868
869 fn build_parameter_engine(&self, params: &HashMap<String, Value>) -> ExpressionEngine {
875 let ctx = ExpressionContext {
876 parameters: params.clone(),
877 ..Default::default()
878 };
879 ExpressionEngine::new(ctx)
880 }
881
882 fn substitute_compile_time(
885 &self,
886 text: &str,
887 engine: &ExpressionEngine,
888 ) -> ParseResult<String> {
889 use crate::expression::lexer::{extract_expressions, ExpressionType};
890
891 let expressions = extract_expressions(text);
892 let mut result = String::new();
893
894 for expr in expressions {
895 match expr {
896 ExpressionType::Text(s) => result.push_str(&s),
897 ExpressionType::CompileTime(expr_str) => {
898 let value = engine.evaluate_compile_time(&expr_str).map_err(|e| {
899 TemplateError::new(
900 format!(
901 "expression error in '${{{{ {} }}}}': {}",
902 expr_str, e.message
903 ),
904 TemplateErrorKind::ExpressionError,
905 )
906 .to_parse_error()
907 })?;
908 result.push_str(&value.as_string());
909 }
910 ExpressionType::Macro(var_name) => {
911 result.push_str(&format!("$({})", var_name));
913 }
914 ExpressionType::Runtime(expr_str) => {
915 result.push_str(&format!("$[ {} ]", expr_str));
917 }
918 }
919 }
920
921 Ok(result)
922 }
923
924 fn substitute_template_parameters(
926 &self,
927 mut pipeline: Pipeline,
928 params: &HashMap<String, Value>,
929 ) -> ParseResult<Pipeline> {
930 let engine = self.build_parameter_engine(params);
931
932 pipeline.variables = pipeline
934 .variables
935 .iter()
936 .map(|v| self.substitute_variable_params(v, &engine))
937 .collect::<ParseResult<Vec<_>>>()?;
938
939 pipeline.stages = pipeline
941 .stages
942 .iter()
943 .map(|s| self.substitute_stage_params(s, &engine))
944 .collect::<ParseResult<Vec<_>>>()?;
945
946 pipeline.jobs = pipeline
948 .jobs
949 .iter()
950 .map(|j| self.substitute_job_params(j, &engine))
951 .collect::<ParseResult<Vec<_>>>()?;
952
953 pipeline.steps = pipeline
955 .steps
956 .iter()
957 .map(|s| self.substitute_step_params(s, &engine))
958 .collect::<ParseResult<Vec<_>>>()?;
959
960 Ok(pipeline)
961 }
962
963 fn substitute_variable_params(
965 &self,
966 var: &Variable,
967 engine: &ExpressionEngine,
968 ) -> ParseResult<Variable> {
969 match var {
970 Variable::KeyValue {
971 name,
972 value,
973 readonly,
974 } => {
975 let new_name = self.substitute_compile_time(name, engine)?;
976 let new_value = self.substitute_compile_time(value, engine)?;
977 Ok(Variable::KeyValue {
978 name: new_name,
979 value: new_value,
980 readonly: *readonly,
981 })
982 }
983 other => Ok(other.clone()),
984 }
985 }
986
987 fn substitute_stage_params(
989 &self,
990 stage: &Stage,
991 engine: &ExpressionEngine,
992 ) -> ParseResult<Stage> {
993 let mut new_stage = stage.clone();
994
995 if let Some(stage_name) = &stage.stage {
996 new_stage.stage = Some(self.substitute_compile_time(stage_name, engine)?);
997 }
998
999 if let Some(display_name) = &stage.display_name {
1000 new_stage.display_name = Some(self.substitute_compile_time(display_name, engine)?);
1001 }
1002
1003 if let Some(condition) = &stage.condition {
1004 new_stage.condition = Some(self.substitute_compile_time(condition, engine)?);
1005 }
1006
1007 new_stage.variables = stage
1009 .variables
1010 .iter()
1011 .map(|v| self.substitute_variable_params(v, engine))
1012 .collect::<ParseResult<Vec<_>>>()?;
1013
1014 new_stage.jobs = stage
1016 .jobs
1017 .iter()
1018 .map(|j| self.substitute_job_params(j, engine))
1019 .collect::<ParseResult<Vec<_>>>()?;
1020
1021 Ok(new_stage)
1022 }
1023
1024 fn substitute_job_params(&self, job: &Job, engine: &ExpressionEngine) -> ParseResult<Job> {
1026 let mut new_job = job.clone();
1027
1028 if let Some(name) = &job.job {
1029 new_job.job = Some(self.substitute_compile_time(name, engine)?);
1030 }
1031
1032 if let Some(display_name) = &job.display_name {
1033 new_job.display_name = Some(self.substitute_compile_time(display_name, engine)?);
1034 }
1035
1036 if let Some(condition) = &job.condition {
1037 new_job.condition = Some(self.substitute_compile_time(condition, engine)?);
1038 }
1039
1040 new_job.variables = job
1042 .variables
1043 .iter()
1044 .map(|v| self.substitute_variable_params(v, engine))
1045 .collect::<ParseResult<Vec<_>>>()?;
1046
1047 new_job.steps = job
1049 .steps
1050 .iter()
1051 .map(|s| self.substitute_step_params(s, engine))
1052 .collect::<ParseResult<Vec<_>>>()?;
1053
1054 Ok(new_job)
1055 }
1056
1057 fn substitute_step_params(&self, step: &Step, engine: &ExpressionEngine) -> ParseResult<Step> {
1059 let mut new_step = step.clone();
1060
1061 if let Some(display_name) = &step.display_name {
1062 new_step.display_name = Some(self.substitute_compile_time(display_name, engine)?);
1063 }
1064
1065 if let Some(condition) = &step.condition {
1066 new_step.condition = Some(self.substitute_compile_time(condition, engine)?);
1067 }
1068
1069 new_step.action = self.substitute_step_action_params(&step.action, engine)?;
1071
1072 let mut new_env = HashMap::new();
1074 for (key, value) in &step.env {
1075 let new_key = self.substitute_compile_time(key, engine)?;
1076 let new_value = self.substitute_compile_time(value, engine)?;
1077 new_env.insert(new_key, new_value);
1078 }
1079 new_step.env = new_env;
1080
1081 Ok(new_step)
1082 }
1083
1084 fn substitute_step_action_params(
1086 &self,
1087 action: &StepAction,
1088 engine: &ExpressionEngine,
1089 ) -> ParseResult<StepAction> {
1090 match action {
1091 StepAction::Script(script_step) => {
1092 let new_script = self.substitute_compile_time(&script_step.script, engine)?;
1093 let new_wd = script_step
1094 .working_directory
1095 .as_ref()
1096 .map(|wd| self.substitute_compile_time(wd, engine))
1097 .transpose()?;
1098 Ok(StepAction::Script(ScriptStep {
1099 script: new_script,
1100 working_directory: new_wd,
1101 fail_on_stderr: script_step.fail_on_stderr,
1102 }))
1103 }
1104 StepAction::Bash(bash_step) => {
1105 let new_script = self.substitute_compile_time(&bash_step.bash, engine)?;
1106 let new_wd = bash_step
1107 .working_directory
1108 .as_ref()
1109 .map(|wd| self.substitute_compile_time(wd, engine))
1110 .transpose()?;
1111 Ok(StepAction::Bash(BashStep {
1112 bash: new_script,
1113 working_directory: new_wd,
1114 fail_on_stderr: bash_step.fail_on_stderr,
1115 }))
1116 }
1117 StepAction::Pwsh(pwsh_step) => {
1118 let new_script = self.substitute_compile_time(&pwsh_step.pwsh, engine)?;
1119 let new_wd = pwsh_step
1120 .working_directory
1121 .as_ref()
1122 .map(|wd| self.substitute_compile_time(wd, engine))
1123 .transpose()?;
1124 Ok(StepAction::Pwsh(PwshStep {
1125 pwsh: new_script,
1126 working_directory: new_wd,
1127 fail_on_stderr: pwsh_step.fail_on_stderr,
1128 error_action_preference: pwsh_step.error_action_preference.clone(),
1129 }))
1130 }
1131 StepAction::PowerShell(ps_step) => {
1132 let new_script = self.substitute_compile_time(&ps_step.powershell, engine)?;
1133 let new_wd = ps_step
1134 .working_directory
1135 .as_ref()
1136 .map(|wd| self.substitute_compile_time(wd, engine))
1137 .transpose()?;
1138 Ok(StepAction::PowerShell(PowerShellStep {
1139 powershell: new_script,
1140 working_directory: new_wd,
1141 fail_on_stderr: ps_step.fail_on_stderr,
1142 error_action_preference: ps_step.error_action_preference.clone(),
1143 }))
1144 }
1145 StepAction::Task(task_step) => {
1146 let new_task = self.substitute_compile_time(&task_step.task, engine)?;
1147 let mut new_inputs = HashMap::new();
1148 for (key, value) in &task_step.inputs {
1149 let new_key = self.substitute_compile_time(key, engine)?;
1150 let new_value = self.substitute_compile_time(value, engine)?;
1151 new_inputs.insert(new_key, new_value);
1152 }
1153 Ok(StepAction::Task(TaskStep {
1154 task: new_task,
1155 inputs: new_inputs,
1156 }))
1157 }
1158 StepAction::Template(template_step) => {
1160 let new_template = self.substitute_compile_time(&template_step.template, engine)?;
1161 let mut new_params = HashMap::new();
1162 for (key, value) in &template_step.parameters {
1163 if let serde_yaml::Value::String(s) = value {
1165 let new_val = self.substitute_compile_time(s, engine)?;
1166 new_params.insert(key.clone(), serde_yaml::Value::String(new_val));
1167 } else {
1168 new_params.insert(key.clone(), value.clone());
1169 }
1170 }
1171 Ok(StepAction::Template(TemplateStep {
1172 template: new_template,
1173 parameters: new_params,
1174 }))
1175 }
1176 other => Ok(other.clone()),
1178 }
1179 }
1180
1181 fn process_template_expressions(
1198 &self,
1199 value: &serde_yaml::Value,
1200 engine: &ExpressionEngine,
1201 ) -> ParseResult<serde_yaml::Value> {
1202 match value {
1203 serde_yaml::Value::Sequence(seq) => {
1204 let mut result = Vec::new();
1205 let mut chain_active = false; let mut chain_taken = false; for item in seq {
1212 let directive = self.extract_directive(item);
1214
1215 match &directive {
1216 Some((TemplateDirective::If(_), _)) => {
1217 chain_active = true;
1219 chain_taken = false;
1220 }
1221 Some((TemplateDirective::ElseIf(_), _))
1222 | Some((TemplateDirective::Else, _)) => {
1223 if !chain_active {
1225 chain_active = true;
1226 chain_taken = false;
1227 }
1228 }
1229 _ => {
1230 chain_active = false;
1232 chain_taken = false;
1233 }
1234 }
1235
1236 match directive {
1237 Some((TemplateDirective::If(condition), val)) => {
1238 let cond_result =
1239 engine.evaluate_compile_time(&condition).map_err(|e| {
1240 TemplateError::new(
1241 format!(
1242 "error evaluating if condition '{}': {}",
1243 condition, e.message
1244 ),
1245 TemplateErrorKind::ExpressionError,
1246 )
1247 .to_parse_error()
1248 })?;
1249
1250 if cond_result.is_truthy() {
1251 let expanded = self.expand_directive_body(val, engine)?;
1252 result.extend(expanded);
1253 chain_taken = true;
1254 }
1255 }
1256 Some((TemplateDirective::ElseIf(condition), val)) => {
1257 if !chain_taken {
1258 let cond_result =
1259 engine.evaluate_compile_time(&condition).map_err(|e| {
1260 TemplateError::new(
1261 format!(
1262 "error evaluating elseif condition '{}': {}",
1263 condition, e.message
1264 ),
1265 TemplateErrorKind::ExpressionError,
1266 )
1267 .to_parse_error()
1268 })?;
1269
1270 if cond_result.is_truthy() {
1271 let expanded = self.expand_directive_body(val, engine)?;
1272 result.extend(expanded);
1273 chain_taken = true;
1274 }
1275 }
1276 }
1277 Some((TemplateDirective::Else, val)) => {
1278 if !chain_taken {
1279 let expanded = self.expand_directive_body(val, engine)?;
1280 result.extend(expanded);
1281 chain_taken = true;
1282 }
1283 }
1284 Some((TemplateDirective::Each(var_name, collection_expr), val)) => {
1285 let collection = engine
1286 .evaluate_compile_time(&collection_expr)
1287 .map_err(|e| {
1288 TemplateError::new(
1289 format!(
1290 "error evaluating each collection '{}': {}",
1291 collection_expr, e.message
1292 ),
1293 TemplateErrorKind::ExpressionError,
1294 )
1295 .to_parse_error()
1296 })?;
1297
1298 let items = self.value_to_iterable(&collection)?;
1299
1300 for (iter_key, iter_value) in &items {
1301 let iter_engine = self.build_iteration_engine(
1302 engine, &var_name, iter_key, iter_value,
1303 );
1304 let expanded = self.expand_directive_body(val, &iter_engine)?;
1305 result.extend(expanded);
1306 }
1307 }
1308 None => {
1309 let processed = self.process_template_expressions(item, engine)?;
1311 result.push(processed);
1312 }
1313 }
1314 }
1315 Ok(serde_yaml::Value::Sequence(result))
1316 }
1317 serde_yaml::Value::Mapping(map) => {
1318 let mut result = serde_yaml::Mapping::new();
1319 for (key, val) in map {
1320 if let Some(key_str) = key.as_str() {
1322 if let Some(directive) = Self::parse_directive(key_str) {
1323 match directive {
1325 TemplateDirective::If(condition) => {
1326 let cond_result =
1327 engine.evaluate_compile_time(&condition).map_err(|e| {
1328 TemplateError::new(
1329 format!(
1330 "error evaluating if condition '{}': {}",
1331 condition, e.message
1332 ),
1333 TemplateErrorKind::ExpressionError,
1334 )
1335 .to_parse_error()
1336 })?;
1337
1338 if cond_result.is_truthy() {
1339 if let Some(inner_map) = val.as_mapping() {
1341 for (ik, iv) in inner_map {
1342 let processed =
1343 self.process_template_expressions(iv, engine)?;
1344 result.insert(ik.clone(), processed);
1345 }
1346 }
1347 }
1348 continue;
1349 }
1350 TemplateDirective::ElseIf(_) | TemplateDirective::Else => {
1351 continue;
1354 }
1355 TemplateDirective::Each(var_name, collection_expr) => {
1356 let collection = engine
1357 .evaluate_compile_time(&collection_expr)
1358 .map_err(|e| {
1359 TemplateError::new(
1360 format!(
1361 "error evaluating each collection '{}': {}",
1362 collection_expr, e.message
1363 ),
1364 TemplateErrorKind::ExpressionError,
1365 )
1366 .to_parse_error()
1367 })?;
1368
1369 if let Some(inner_map) = val.as_mapping() {
1370 let items = self.value_to_iterable(&collection)?;
1371 for (iter_key, iter_value) in &items {
1372 let iter_engine = self.build_iteration_engine(
1373 engine, &var_name, iter_key, iter_value,
1374 );
1375 for (ik, iv) in inner_map {
1376 let resolved_key =
1377 self.substitute_yaml_value(ik, &iter_engine)?;
1378 let resolved_val = self
1379 .process_template_expressions(
1380 iv,
1381 &iter_engine,
1382 )?;
1383 result.insert(resolved_key, resolved_val);
1384 }
1385 }
1386 }
1387 continue;
1388 }
1389 }
1390 }
1391 }
1392
1393 let processed = self.process_template_expressions(val, engine)?;
1395 result.insert(key.clone(), processed);
1396 }
1397 Ok(serde_yaml::Value::Mapping(result))
1398 }
1399 serde_yaml::Value::String(s) => {
1400 let substituted = self.substitute_compile_time(s, engine)?;
1402 Ok(serde_yaml::Value::String(substituted))
1403 }
1404 other => Ok(other.clone()),
1406 }
1407 }
1408
1409 fn extract_directive<'a>(
1412 &self,
1413 item: &'a serde_yaml::Value,
1414 ) -> Option<(TemplateDirective, &'a serde_yaml::Value)> {
1415 let map = item.as_mapping()?;
1416 if map.len() != 1 {
1417 return None;
1418 }
1419 let (key, val) = map.iter().next()?;
1420 let key_str = key.as_str()?;
1421 let directive = Self::parse_directive(key_str)?;
1422 Some((directive, val))
1423 }
1424
1425 fn expand_directive_body(
1427 &self,
1428 value: &serde_yaml::Value,
1429 engine: &ExpressionEngine,
1430 ) -> ParseResult<Vec<serde_yaml::Value>> {
1431 match value {
1432 serde_yaml::Value::Sequence(_) => {
1433 let processed = self.process_template_expressions(value, engine)?;
1436 if let serde_yaml::Value::Sequence(items) = processed {
1437 Ok(items)
1438 } else {
1439 Ok(vec![processed])
1440 }
1441 }
1442 serde_yaml::Value::Mapping(_) => {
1444 let processed = self.process_template_expressions(value, engine)?;
1445 Ok(vec![processed])
1446 }
1447 serde_yaml::Value::String(s) => {
1450 let substituted = self.substitute_compile_time(s, engine)?;
1451 Ok(vec![serde_yaml::Value::String(substituted)])
1452 }
1453 other => Ok(vec![other.clone()]),
1454 }
1455 }
1456
1457 fn parse_directive(key: &str) -> Option<TemplateDirective> {
1461 let trimmed = key.trim();
1462
1463 if !trimmed.starts_with("${{") || !trimmed.ends_with("}}") {
1465 return None;
1466 }
1467
1468 let inner = trimmed[3..trimmed.len() - 2].trim();
1470
1471 if let Some(rest) = inner.strip_prefix("if ") {
1472 let condition = rest.trim().to_string();
1473 Some(TemplateDirective::If(condition))
1474 } else if let Some(rest) = inner.strip_prefix("elseif ") {
1475 let condition = rest.trim().to_string();
1476 Some(TemplateDirective::ElseIf(condition))
1477 } else if let Some(rest) = inner.strip_prefix("else if ") {
1478 let condition = rest.trim().to_string();
1479 Some(TemplateDirective::ElseIf(condition))
1480 } else if inner == "else" {
1481 Some(TemplateDirective::Else)
1482 } else if let Some(rest) = inner.strip_prefix("each ") {
1483 let rest = rest.trim();
1485 if let Some(in_pos) = rest.find(" in ") {
1486 let var_name = rest[..in_pos].trim().to_string();
1487 let collection_expr = rest[in_pos + 4..].trim().to_string();
1488 if !var_name.is_empty() && !collection_expr.is_empty() {
1489 Some(TemplateDirective::Each(var_name, collection_expr))
1490 } else {
1491 None
1492 }
1493 } else {
1494 None
1495 }
1496 } else {
1497 None
1498 }
1499 }
1500
1501 fn value_to_iterable(&self, value: &Value) -> ParseResult<Vec<(Value, Value)>> {
1505 match value {
1506 Value::Array(arr) => Ok(arr
1507 .iter()
1508 .enumerate()
1509 .map(|(i, v)| (Value::Number(i as f64), v.clone()))
1510 .collect()),
1511 Value::Object(map) => Ok(map
1512 .iter()
1513 .map(|(k, v)| (Value::String(k.clone()), v.clone()))
1514 .collect()),
1515 other => Err(TemplateError::new(
1516 format!(
1517 "each directive requires an array or object, got: {}",
1518 other.as_string()
1519 ),
1520 TemplateErrorKind::ExpressionError,
1521 )
1522 .to_parse_error()),
1523 }
1524 }
1525
1526 fn build_iteration_engine(
1529 &self,
1530 parent_engine: &ExpressionEngine,
1531 var_name: &str,
1532 _iter_key: &Value,
1533 iter_value: &Value,
1534 ) -> ExpressionEngine {
1535 let mut ctx = parent_engine.context().clone();
1536 ctx.parameters
1537 .insert(var_name.to_string(), iter_value.clone());
1538 ExpressionEngine::new(ctx)
1539 }
1540
1541 fn substitute_yaml_value(
1543 &self,
1544 value: &serde_yaml::Value,
1545 engine: &ExpressionEngine,
1546 ) -> ParseResult<serde_yaml::Value> {
1547 match value {
1548 serde_yaml::Value::String(s) => {
1549 let substituted = self.substitute_compile_time(s, engine)?;
1550 Ok(serde_yaml::Value::String(substituted))
1551 }
1552 other => Ok(other.clone()),
1553 }
1554 }
1555
1556 fn resolve_template_path(&self, template_ref: &str) -> ParseResult<PathBuf> {
1562 if let Some((repo_name, template_path)) = template_ref.split_once('@') {
1564 if let Some(repo_path) = self.resource_repos.get(repo_name) {
1567 let full_path = repo_path.join(template_path);
1568 if full_path.exists() {
1569 return Ok(full_path);
1570 }
1571 return Err(TemplateError::new(
1572 format!(
1573 "template '{}' not found in repository '{}' (looked in {})",
1574 template_path,
1575 repo_name,
1576 full_path.display()
1577 ),
1578 TemplateErrorKind::NotFound,
1579 )
1580 .with_path(template_ref)
1581 .to_parse_error());
1582 }
1583 if let Some(repo_path) = self.resource_repos.get(template_path) {
1585 let full_path = repo_path.join(repo_name);
1586 if full_path.exists() {
1587 return Ok(full_path);
1588 }
1589 }
1590 }
1591
1592 let full_path = self.repo_root.join(template_ref);
1594 if full_path.exists() {
1595 return Ok(full_path);
1596 }
1597
1598 Err(TemplateError::new(
1599 format!(
1600 "template '{}' not found (looked in {})",
1601 template_ref,
1602 full_path.display()
1603 ),
1604 TemplateErrorKind::NotFound,
1605 )
1606 .with_path(template_ref)
1607 .to_parse_error())
1608 }
1609
1610 fn push_template(&mut self, canonical_path: &str) -> ParseResult<()> {
1616 if self.include_stack.len() >= MAX_TEMPLATE_DEPTH {
1618 return Err(TemplateError::new(
1619 format!(
1620 "maximum template inclusion depth ({}) exceeded. Include stack:\n {}",
1621 MAX_TEMPLATE_DEPTH,
1622 self.include_stack.join("\n -> ")
1623 ),
1624 TemplateErrorKind::MaxDepthExceeded,
1625 )
1626 .to_parse_error());
1627 }
1628
1629 if self.include_stack.contains(&canonical_path.to_string()) {
1631 let mut cycle = self.include_stack.clone();
1632 cycle.push(canonical_path.to_string());
1633 return Err(TemplateError::new(
1634 format!(
1635 "circular template reference detected:\n {}",
1636 cycle.join("\n -> ")
1637 ),
1638 TemplateErrorKind::CircularReference,
1639 )
1640 .to_parse_error());
1641 }
1642
1643 self.include_stack.push(canonical_path.to_string());
1644 Ok(())
1645 }
1646
1647 fn pop_template(&mut self) {
1649 self.include_stack.pop();
1650 }
1651
1652 fn canonical_path(&self, path: &Path) -> String {
1654 path.canonicalize()
1655 .unwrap_or_else(|_| path.to_path_buf())
1656 .to_string_lossy()
1657 .to_string()
1658 }
1659}
1660
1661pub fn yaml_to_value(yaml: &serde_yaml::Value) -> Value {
1667 match yaml {
1668 serde_yaml::Value::Null => Value::Null,
1669 serde_yaml::Value::Bool(b) => Value::Bool(*b),
1670 serde_yaml::Value::Number(n) => {
1671 Value::Number(n.as_f64().unwrap_or(n.as_i64().unwrap_or(0) as f64))
1672 }
1673 serde_yaml::Value::String(s) => Value::String(s.clone()),
1674 serde_yaml::Value::Sequence(seq) => Value::Array(seq.iter().map(yaml_to_value).collect()),
1675 serde_yaml::Value::Mapping(map) => Value::Object(
1676 map.iter()
1677 .filter_map(|(k, v)| k.as_str().map(|key| (key.to_string(), yaml_to_value(v))))
1678 .collect(),
1679 ),
1680 serde_yaml::Value::Tagged(_) => Value::Null,
1681 }
1682}
1683
1684pub fn value_to_yaml(value: &Value) -> serde_yaml::Value {
1686 match value {
1687 Value::Null => serde_yaml::Value::Null,
1688 Value::Bool(b) => serde_yaml::Value::Bool(*b),
1689 Value::Number(n) => {
1690 if n.fract() == 0.0 {
1691 serde_yaml::Value::Number(serde_yaml::Number::from(*n as i64))
1692 } else {
1693 serde_yaml::Value::Number(serde_yaml::Number::from(*n))
1694 }
1695 }
1696 Value::String(s) => serde_yaml::Value::String(s.clone()),
1697 Value::Array(arr) => serde_yaml::Value::Sequence(arr.iter().map(value_to_yaml).collect()),
1698 Value::Object(map) => {
1699 let mut mapping = serde_yaml::Mapping::new();
1700 for (k, v) in map {
1701 mapping.insert(serde_yaml::Value::String(k.clone()), value_to_yaml(v));
1702 }
1703 serde_yaml::Value::Mapping(mapping)
1704 }
1705 }
1706}
1707
1708fn content_type_name(content: &TemplateContent) -> &'static str {
1710 match content {
1711 TemplateContent::Steps(_) => "steps",
1712 TemplateContent::Jobs(_) => "jobs",
1713 TemplateContent::Stages(_) => "stages",
1714 TemplateContent::Variables(_) => "variables",
1715 TemplateContent::Pipeline(_) => "pipeline",
1716 }
1717}
1718
1719#[cfg(test)]
1724mod tests {
1725 use super::*;
1726 use std::io::Write;
1727 use tempfile::TempDir;
1728
1729 fn setup_templates(files: &[(&str, &str)]) -> TempDir {
1731 let dir = TempDir::new().unwrap();
1732 for (name, content) in files {
1733 let path = dir.path().join(name);
1734 if let Some(parent) = path.parent() {
1735 fs::create_dir_all(parent).unwrap();
1736 }
1737 let mut file = fs::File::create(&path).unwrap();
1738 file.write_all(content.as_bytes()).unwrap();
1739 }
1740 dir
1741 }
1742
1743 #[test]
1744 fn test_resolve_step_template() {
1745 let dir = setup_templates(&[(
1746 "steps/build.yml",
1747 r#"
1748parameters:
1749 - name: buildConfig
1750 type: string
1751 default: Debug
1752
1753steps:
1754 - script: echo Building ${{ parameters.buildConfig }}
1755 displayName: Build ${{ parameters.buildConfig }}
1756"#,
1757 )]);
1758
1759 let mut engine = TemplateEngine::new(dir.path().to_path_buf());
1760
1761 let pipeline = Pipeline {
1762 steps: vec![Step {
1763 name: None,
1764 display_name: None,
1765 condition: None,
1766 continue_on_error: BoolOrExpression::default(),
1767 enabled: true,
1768 timeout_in_minutes: None,
1769 retry_count_on_task_failure: None,
1770 env: HashMap::new(),
1771 action: StepAction::Template(TemplateStep {
1772 template: "steps/build.yml".to_string(),
1773 parameters: {
1774 let mut params = HashMap::new();
1775 params.insert(
1776 "buildConfig".to_string(),
1777 serde_yaml::Value::String("Release".to_string()),
1778 );
1779 params
1780 },
1781 }),
1782 }],
1783 ..Default::default()
1784 };
1785
1786 let resolved = engine.resolve_pipeline(pipeline).unwrap();
1787 assert_eq!(resolved.steps.len(), 1);
1788 if let StepAction::Script(script) = &resolved.steps[0].action {
1789 assert_eq!(script.script, "echo Building Release");
1790 } else {
1791 panic!("expected script step");
1792 }
1793 assert_eq!(
1794 resolved.steps[0].display_name.as_deref(),
1795 Some("Build Release")
1796 );
1797 }
1798
1799 #[test]
1800 fn test_resolve_step_template_default_params() {
1801 let dir = setup_templates(&[(
1802 "steps/build.yml",
1803 r#"
1804parameters:
1805 - name: buildConfig
1806 type: string
1807 default: Debug
1808
1809steps:
1810 - script: echo Building ${{ parameters.buildConfig }}
1811"#,
1812 )]);
1813
1814 let mut engine = TemplateEngine::new(dir.path().to_path_buf());
1815
1816 let pipeline = Pipeline {
1817 steps: vec![Step {
1818 name: None,
1819 display_name: None,
1820 condition: None,
1821 continue_on_error: BoolOrExpression::default(),
1822 enabled: true,
1823 timeout_in_minutes: None,
1824 retry_count_on_task_failure: None,
1825 env: HashMap::new(),
1826 action: StepAction::Template(TemplateStep {
1827 template: "steps/build.yml".to_string(),
1828 parameters: HashMap::new(), }),
1830 }],
1831 ..Default::default()
1832 };
1833
1834 let resolved = engine.resolve_pipeline(pipeline).unwrap();
1835 assert_eq!(resolved.steps.len(), 1);
1836 if let StepAction::Script(script) = &resolved.steps[0].action {
1837 assert_eq!(script.script, "echo Building Debug");
1838 } else {
1839 panic!("expected script step");
1840 }
1841 }
1842
1843 #[test]
1844 fn test_resolve_job_template() {
1845 let dir = setup_templates(&[(
1846 "jobs/build.yml",
1847 r#"
1848parameters:
1849 - name: vmImage
1850 type: string
1851 default: ubuntu-latest
1852
1853jobs:
1854 - job: Build
1855 pool:
1856 vmImage: ${{ parameters.vmImage }}
1857 steps:
1858 - script: cargo build
1859"#,
1860 )]);
1861
1862 let mut engine = TemplateEngine::new(dir.path().to_path_buf());
1863
1864 let pipeline = Pipeline {
1865 jobs: vec![Job {
1866 template: Some("jobs/build.yml".to_string()),
1867 parameters: {
1868 let mut params = HashMap::new();
1869 params.insert(
1870 "vmImage".to_string(),
1871 serde_yaml::Value::String("windows-latest".to_string()),
1872 );
1873 params
1874 },
1875 ..Default::default()
1876 }],
1877 ..Default::default()
1878 };
1879
1880 let resolved = engine.resolve_pipeline(pipeline).unwrap();
1881 assert_eq!(resolved.jobs.len(), 1);
1882 assert_eq!(resolved.jobs[0].job, Some("Build".to_string()));
1883 }
1884
1885 #[test]
1886 fn test_resolve_stage_template() {
1887 let dir = setup_templates(&[(
1888 "stages/deploy.yml",
1889 r#"
1890parameters:
1891 - name: environment
1892 type: string
1893
1894stages:
1895 - stage: Deploy
1896 displayName: Deploy to ${{ parameters.environment }}
1897 jobs:
1898 - job: DeployJob
1899 steps:
1900 - script: echo Deploying to ${{ parameters.environment }}
1901"#,
1902 )]);
1903
1904 let mut engine = TemplateEngine::new(dir.path().to_path_buf());
1905
1906 let pipeline = Pipeline {
1907 stages: vec![Stage {
1908 stage: Some("placeholder".to_string()),
1909 template: Some("stages/deploy.yml".to_string()),
1910 parameters: {
1911 let mut params = HashMap::new();
1912 params.insert(
1913 "environment".to_string(),
1914 serde_yaml::Value::String("production".to_string()),
1915 );
1916 params
1917 },
1918 ..Default::default()
1919 }],
1920 ..Default::default()
1921 };
1922
1923 let resolved = engine.resolve_pipeline(pipeline).unwrap();
1924 assert_eq!(resolved.stages.len(), 1);
1925 assert_eq!(
1926 resolved.stages[0].display_name.as_deref(),
1927 Some("Deploy to production")
1928 );
1929 }
1930
1931 #[test]
1932 fn test_resolve_variable_template() {
1933 let dir = setup_templates(&[(
1934 "variables/common.yml",
1935 r#"
1936variables:
1937 - name: buildConfig
1938 value: Release
1939 - name: testFramework
1940 value: NUnit
1941"#,
1942 )]);
1943
1944 let mut engine = TemplateEngine::new(dir.path().to_path_buf());
1945
1946 let pipeline = Pipeline {
1947 variables: vec![
1948 Variable::Template {
1949 template: "variables/common.yml".to_string(),
1950 parameters: HashMap::new(),
1951 },
1952 Variable::KeyValue {
1953 name: "extraVar".to_string(),
1954 value: "extraValue".to_string(),
1955 readonly: false,
1956 },
1957 ],
1958 steps: vec![Step {
1959 name: None,
1960 display_name: None,
1961 condition: None,
1962 continue_on_error: BoolOrExpression::default(),
1963 enabled: true,
1964 timeout_in_minutes: None,
1965 retry_count_on_task_failure: None,
1966 env: HashMap::new(),
1967 action: StepAction::Script(ScriptStep {
1968 script: "echo hello".to_string(),
1969 working_directory: None,
1970 fail_on_stderr: false,
1971 }),
1972 }],
1973 ..Default::default()
1974 };
1975
1976 let resolved = engine.resolve_pipeline(pipeline).unwrap();
1977 assert_eq!(resolved.variables.len(), 3);
1978 }
1979
1980 #[test]
1981 fn test_resolve_extends_template() {
1982 let dir = setup_templates(&[(
1983 "base-pipeline.yml",
1984 r#"
1985parameters:
1986 - name: buildConfig
1987 type: string
1988 default: Debug
1989
1990stages:
1991 - stage: Build
1992 jobs:
1993 - job: BuildJob
1994 steps:
1995 - script: echo Building ${{ parameters.buildConfig }}
1996"#,
1997 )]);
1998
1999 let mut engine = TemplateEngine::new(dir.path().to_path_buf());
2000
2001 let pipeline = Pipeline {
2002 extends: Some(Extends {
2003 template: "base-pipeline.yml".to_string(),
2004 parameters: {
2005 let mut params = HashMap::new();
2006 params.insert(
2007 "buildConfig".to_string(),
2008 serde_yaml::Value::String("Release".to_string()),
2009 );
2010 params
2011 },
2012 }),
2013 ..Default::default()
2014 };
2015
2016 let resolved = engine.resolve_pipeline(pipeline).unwrap();
2017 assert_eq!(resolved.stages.len(), 1);
2018 assert_eq!(resolved.stages[0].stage, Some("Build".to_string()));
2019 let build_step = &resolved.stages[0].jobs[0].steps[0];
2020 if let StepAction::Script(script) = &build_step.action {
2021 assert_eq!(script.script, "echo Building Release");
2022 } else {
2023 panic!("expected script step");
2024 }
2025 }
2026
2027 #[test]
2028 fn test_circular_reference_detection() {
2029 let dir = setup_templates(&[
2030 (
2031 "a.yml",
2032 r#"
2033steps:
2034 - template: b.yml
2035"#,
2036 ),
2037 (
2038 "b.yml",
2039 r#"
2040steps:
2041 - template: a.yml
2042"#,
2043 ),
2044 ]);
2045
2046 let mut engine = TemplateEngine::new(dir.path().to_path_buf());
2047
2048 let pipeline = Pipeline {
2049 steps: vec![Step {
2050 name: None,
2051 display_name: None,
2052 condition: None,
2053 continue_on_error: BoolOrExpression::default(),
2054 enabled: true,
2055 timeout_in_minutes: None,
2056 retry_count_on_task_failure: None,
2057 env: HashMap::new(),
2058 action: StepAction::Template(TemplateStep {
2059 template: "a.yml".to_string(),
2060 parameters: HashMap::new(),
2061 }),
2062 }],
2063 ..Default::default()
2064 };
2065
2066 let result = engine.resolve_pipeline(pipeline);
2067 assert!(result.is_err());
2068 let err = result.unwrap_err();
2069 assert!(
2070 err.message.contains("circular") || err.kind == ParseErrorKind::TemplateError,
2071 "Expected circular reference error, got: {}",
2072 err.message
2073 );
2074 }
2075
2076 #[test]
2077 fn test_missing_required_parameter() {
2078 let dir = setup_templates(&[(
2079 "steps/build.yml",
2080 r#"
2081parameters:
2082 - name: buildConfig
2083 type: string
2084
2085steps:
2086 - script: echo ${{ parameters.buildConfig }}
2087"#,
2088 )]);
2089
2090 let mut engine = TemplateEngine::new(dir.path().to_path_buf());
2091
2092 let pipeline = Pipeline {
2093 steps: vec![Step {
2094 name: None,
2095 display_name: None,
2096 condition: None,
2097 continue_on_error: BoolOrExpression::default(),
2098 enabled: true,
2099 timeout_in_minutes: None,
2100 retry_count_on_task_failure: None,
2101 env: HashMap::new(),
2102 action: StepAction::Template(TemplateStep {
2103 template: "steps/build.yml".to_string(),
2104 parameters: HashMap::new(), }),
2106 }],
2107 ..Default::default()
2108 };
2109
2110 let result = engine.resolve_pipeline(pipeline);
2111 assert!(result.is_err());
2112 let err = result.unwrap_err();
2113 assert!(
2114 err.message.contains("required parameter"),
2115 "Expected missing parameter error, got: {}",
2116 err.message
2117 );
2118 }
2119
2120 #[test]
2121 fn test_template_not_found() {
2122 let dir = TempDir::new().unwrap();
2123 let mut engine = TemplateEngine::new(dir.path().to_path_buf());
2124
2125 let pipeline = Pipeline {
2126 steps: vec![Step {
2127 name: None,
2128 display_name: None,
2129 condition: None,
2130 continue_on_error: BoolOrExpression::default(),
2131 enabled: true,
2132 timeout_in_minutes: None,
2133 retry_count_on_task_failure: None,
2134 env: HashMap::new(),
2135 action: StepAction::Template(TemplateStep {
2136 template: "nonexistent.yml".to_string(),
2137 parameters: HashMap::new(),
2138 }),
2139 }],
2140 ..Default::default()
2141 };
2142
2143 let result = engine.resolve_pipeline(pipeline);
2144 assert!(result.is_err());
2145 assert!(result.unwrap_err().message.contains("not found"));
2146 }
2147
2148 #[test]
2149 fn test_nested_templates() {
2150 let dir = setup_templates(&[
2151 (
2152 "steps/inner.yml",
2153 r#"
2154parameters:
2155 - name: msg
2156 type: string
2157
2158steps:
2159 - script: echo ${{ parameters.msg }}
2160"#,
2161 ),
2162 (
2163 "steps/outer.yml",
2164 r#"
2165parameters:
2166 - name: prefix
2167 type: string
2168
2169steps:
2170 - script: echo Starting ${{ parameters.prefix }}
2171 - template: steps/inner.yml
2172 parameters:
2173 msg: ${{ parameters.prefix }} inner
2174 - script: echo Done ${{ parameters.prefix }}
2175"#,
2176 ),
2177 ]);
2178
2179 let mut engine = TemplateEngine::new(dir.path().to_path_buf());
2180
2181 let pipeline = Pipeline {
2182 steps: vec![Step {
2183 name: None,
2184 display_name: None,
2185 condition: None,
2186 continue_on_error: BoolOrExpression::default(),
2187 enabled: true,
2188 timeout_in_minutes: None,
2189 retry_count_on_task_failure: None,
2190 env: HashMap::new(),
2191 action: StepAction::Template(TemplateStep {
2192 template: "steps/outer.yml".to_string(),
2193 parameters: {
2194 let mut params = HashMap::new();
2195 params.insert(
2196 "prefix".to_string(),
2197 serde_yaml::Value::String("Build".to_string()),
2198 );
2199 params
2200 },
2201 }),
2202 }],
2203 ..Default::default()
2204 };
2205
2206 let resolved = engine.resolve_pipeline(pipeline).unwrap();
2207 assert_eq!(resolved.steps.len(), 3);
2208
2209 if let StepAction::Script(script) = &resolved.steps[0].action {
2210 assert_eq!(script.script, "echo Starting Build");
2211 } else {
2212 panic!("expected script step at index 0");
2213 }
2214
2215 if let StepAction::Script(script) = &resolved.steps[1].action {
2216 assert_eq!(script.script, "echo Build inner");
2217 } else {
2218 panic!("expected script step at index 1");
2219 }
2220
2221 if let StepAction::Script(script) = &resolved.steps[2].action {
2222 assert_eq!(script.script, "echo Done Build");
2223 } else {
2224 panic!("expected script step at index 2");
2225 }
2226 }
2227
2228 #[test]
2229 fn test_macro_variables_preserved() {
2230 let dir = setup_templates(&[(
2231 "steps/build.yml",
2232 r#"
2233parameters:
2234 - name: config
2235 type: string
2236
2237steps:
2238 - script: echo Building $(Build.SourceBranch) with ${{ parameters.config }}
2239"#,
2240 )]);
2241
2242 let mut engine = TemplateEngine::new(dir.path().to_path_buf());
2243
2244 let pipeline = Pipeline {
2245 steps: vec![Step {
2246 name: None,
2247 display_name: None,
2248 condition: None,
2249 continue_on_error: BoolOrExpression::default(),
2250 enabled: true,
2251 timeout_in_minutes: None,
2252 retry_count_on_task_failure: None,
2253 env: HashMap::new(),
2254 action: StepAction::Template(TemplateStep {
2255 template: "steps/build.yml".to_string(),
2256 parameters: {
2257 let mut params = HashMap::new();
2258 params.insert(
2259 "config".to_string(),
2260 serde_yaml::Value::String("Release".to_string()),
2261 );
2262 params
2263 },
2264 }),
2265 }],
2266 ..Default::default()
2267 };
2268
2269 let resolved = engine.resolve_pipeline(pipeline).unwrap();
2270 if let StepAction::Script(script) = &resolved.steps[0].action {
2271 assert_eq!(
2273 script.script,
2274 "echo Building $(Build.SourceBranch) with Release"
2275 );
2276 } else {
2277 panic!("expected script step");
2278 }
2279 }
2280
2281 #[test]
2282 fn test_yaml_to_value_conversion() {
2283 assert_eq!(yaml_to_value(&serde_yaml::Value::Null), Value::Null);
2284 assert_eq!(
2285 yaml_to_value(&serde_yaml::Value::Bool(true)),
2286 Value::Bool(true)
2287 );
2288 assert_eq!(
2289 yaml_to_value(&serde_yaml::Value::String("hello".to_string())),
2290 Value::String("hello".to_string())
2291 );
2292
2293 let seq = serde_yaml::Value::Sequence(vec![
2294 serde_yaml::Value::String("a".to_string()),
2295 serde_yaml::Value::String("b".to_string()),
2296 ]);
2297 if let Value::Array(arr) = yaml_to_value(&seq) {
2298 assert_eq!(arr.len(), 2);
2299 } else {
2300 panic!("expected array");
2301 }
2302 }
2303
2304 #[test]
2305 fn test_value_to_yaml_conversion() {
2306 let val = Value::String("hello".to_string());
2307 let yaml = value_to_yaml(&val);
2308 assert_eq!(yaml, serde_yaml::Value::String("hello".to_string()));
2309
2310 let val = Value::Bool(true);
2311 let yaml = value_to_yaml(&val);
2312 assert_eq!(yaml, serde_yaml::Value::Bool(true));
2313
2314 let val = Value::Array(vec![Value::String("a".to_string())]);
2315 let yaml = value_to_yaml(&val);
2316 assert!(yaml.is_sequence());
2317 }
2318
2319 #[test]
2320 fn test_simple_key_value_parameters() {
2321 let dir = setup_templates(&[(
2322 "steps/build.yml",
2323 r#"
2324parameters:
2325 buildConfig: Debug
2326 platform: x64
2327
2328steps:
2329 - script: echo ${{ parameters.buildConfig }} ${{ parameters.platform }}
2330"#,
2331 )]);
2332
2333 let mut engine = TemplateEngine::new(dir.path().to_path_buf());
2334
2335 let pipeline = Pipeline {
2336 steps: vec![Step {
2337 name: None,
2338 display_name: None,
2339 condition: None,
2340 continue_on_error: BoolOrExpression::default(),
2341 enabled: true,
2342 timeout_in_minutes: None,
2343 retry_count_on_task_failure: None,
2344 env: HashMap::new(),
2345 action: StepAction::Template(TemplateStep {
2346 template: "steps/build.yml".to_string(),
2347 parameters: HashMap::new(), }),
2349 }],
2350 ..Default::default()
2351 };
2352
2353 let resolved = engine.resolve_pipeline(pipeline).unwrap();
2354 if let StepAction::Script(script) = &resolved.steps[0].action {
2355 assert_eq!(script.script, "echo Debug x64");
2356 } else {
2357 panic!("expected script step");
2358 }
2359 }
2360
2361 #[test]
2362 fn test_multiple_step_templates() {
2363 let dir = setup_templates(&[
2364 (
2365 "steps/build.yml",
2366 r#"
2367steps:
2368 - script: cargo build
2369 displayName: Build
2370"#,
2371 ),
2372 (
2373 "steps/test.yml",
2374 r#"
2375steps:
2376 - script: cargo test
2377 displayName: Test
2378"#,
2379 ),
2380 ]);
2381
2382 let mut engine = TemplateEngine::new(dir.path().to_path_buf());
2383
2384 let pipeline = Pipeline {
2385 steps: vec![
2386 Step {
2387 name: None,
2388 display_name: None,
2389 condition: None,
2390 continue_on_error: BoolOrExpression::default(),
2391 enabled: true,
2392 timeout_in_minutes: None,
2393 retry_count_on_task_failure: None,
2394 env: HashMap::new(),
2395 action: StepAction::Template(TemplateStep {
2396 template: "steps/build.yml".to_string(),
2397 parameters: HashMap::new(),
2398 }),
2399 },
2400 Step {
2401 name: None,
2402 display_name: None,
2403 condition: None,
2404 continue_on_error: BoolOrExpression::default(),
2405 enabled: true,
2406 timeout_in_minutes: None,
2407 retry_count_on_task_failure: None,
2408 env: HashMap::new(),
2409 action: StepAction::Template(TemplateStep {
2410 template: "steps/test.yml".to_string(),
2411 parameters: HashMap::new(),
2412 }),
2413 },
2414 ],
2415 ..Default::default()
2416 };
2417
2418 let resolved = engine.resolve_pipeline(pipeline).unwrap();
2419 assert_eq!(resolved.steps.len(), 2);
2420 assert_eq!(resolved.steps[0].display_name.as_deref(), Some("Build"));
2421 assert_eq!(resolved.steps[1].display_name.as_deref(), Some("Test"));
2422 }
2423
2424 #[test]
2425 fn test_extends_with_child_overrides() {
2426 let dir = setup_templates(&[(
2427 "base.yml",
2428 r#"
2429variables:
2430 - name: baseVar
2431 value: baseValue
2432 - name: sharedVar
2433 value: fromBase
2434
2435stages:
2436 - stage: Build
2437 jobs:
2438 - job: BuildJob
2439 steps:
2440 - script: echo base build
2441"#,
2442 )]);
2443
2444 let mut engine = TemplateEngine::new(dir.path().to_path_buf());
2445
2446 let pipeline = Pipeline {
2447 extends: Some(Extends {
2448 template: "base.yml".to_string(),
2449 parameters: HashMap::new(),
2450 }),
2451 variables: vec![Variable::KeyValue {
2452 name: "sharedVar".to_string(),
2453 value: "fromChild".to_string(),
2454 readonly: false,
2455 }],
2456 ..Default::default()
2457 };
2458
2459 let resolved = engine.resolve_pipeline(pipeline).unwrap();
2460
2461 let shared = resolved.variables.iter().find(|v| {
2463 if let Variable::KeyValue { name, .. } = v {
2464 name == "sharedVar"
2465 } else {
2466 false
2467 }
2468 });
2469 assert!(shared.is_some());
2470 if let Some(Variable::KeyValue { value, .. }) = shared {
2471 assert_eq!(value, "fromChild");
2472 }
2473 }
2474
2475 #[test]
2480 fn test_if_directive_true_condition() {
2481 let dir = setup_templates(&[(
2482 "steps/conditional.yml",
2483 r#"
2484parameters:
2485 - name: runTests
2486 type: boolean
2487 default: true
2488
2489steps:
2490 - script: echo always runs
2491 - ${{ if eq(parameters.runTests, true) }}:
2492 - script: cargo test
2493 displayName: Run Tests
2494"#,
2495 )]);
2496
2497 let mut engine = TemplateEngine::new(dir.path().to_path_buf());
2498
2499 let pipeline = Pipeline {
2500 steps: vec![Step {
2501 name: None,
2502 display_name: None,
2503 condition: None,
2504 continue_on_error: BoolOrExpression::default(),
2505 enabled: true,
2506 timeout_in_minutes: None,
2507 retry_count_on_task_failure: None,
2508 env: HashMap::new(),
2509 action: StepAction::Template(TemplateStep {
2510 template: "steps/conditional.yml".to_string(),
2511 parameters: {
2512 let mut params = HashMap::new();
2513 params.insert("runTests".to_string(), serde_yaml::Value::Bool(true));
2514 params
2515 },
2516 }),
2517 }],
2518 ..Default::default()
2519 };
2520
2521 let resolved = engine.resolve_pipeline(pipeline).unwrap();
2522 assert_eq!(resolved.steps.len(), 2);
2523 if let StepAction::Script(script) = &resolved.steps[0].action {
2524 assert_eq!(script.script, "echo always runs");
2525 }
2526 if let StepAction::Script(script) = &resolved.steps[1].action {
2527 assert_eq!(script.script, "cargo test");
2528 }
2529 assert_eq!(resolved.steps[1].display_name.as_deref(), Some("Run Tests"));
2530 }
2531
2532 #[test]
2533 fn test_if_directive_false_condition() {
2534 let dir = setup_templates(&[(
2535 "steps/conditional.yml",
2536 r#"
2537parameters:
2538 - name: runTests
2539 type: boolean
2540 default: true
2541
2542steps:
2543 - script: echo always runs
2544 - ${{ if eq(parameters.runTests, true) }}:
2545 - script: cargo test
2546 displayName: Run Tests
2547"#,
2548 )]);
2549
2550 let mut engine = TemplateEngine::new(dir.path().to_path_buf());
2551
2552 let pipeline = Pipeline {
2553 steps: vec![Step {
2554 name: None,
2555 display_name: None,
2556 condition: None,
2557 continue_on_error: BoolOrExpression::default(),
2558 enabled: true,
2559 timeout_in_minutes: None,
2560 retry_count_on_task_failure: None,
2561 env: HashMap::new(),
2562 action: StepAction::Template(TemplateStep {
2563 template: "steps/conditional.yml".to_string(),
2564 parameters: {
2565 let mut params = HashMap::new();
2566 params.insert("runTests".to_string(), serde_yaml::Value::Bool(false));
2567 params
2568 },
2569 }),
2570 }],
2571 ..Default::default()
2572 };
2573
2574 let resolved = engine.resolve_pipeline(pipeline).unwrap();
2575 assert_eq!(resolved.steps.len(), 1);
2577 if let StepAction::Script(script) = &resolved.steps[0].action {
2578 assert_eq!(script.script, "echo always runs");
2579 }
2580 }
2581
2582 #[test]
2583 fn test_if_directive_with_string_comparison() {
2584 let dir = setup_templates(&[(
2585 "steps/env-steps.yml",
2586 r#"
2587parameters:
2588 - name: environment
2589 type: string
2590
2591steps:
2592 - script: echo deploying
2593 - ${{ if eq(parameters.environment, 'production') }}:
2594 - script: echo production safety checks
2595 - ${{ if ne(parameters.environment, 'production') }}:
2596 - script: echo skipping safety checks
2597"#,
2598 )]);
2599
2600 let mut engine = TemplateEngine::new(dir.path().to_path_buf());
2601
2602 let pipeline = Pipeline {
2604 steps: vec![Step {
2605 name: None,
2606 display_name: None,
2607 condition: None,
2608 continue_on_error: BoolOrExpression::default(),
2609 enabled: true,
2610 timeout_in_minutes: None,
2611 retry_count_on_task_failure: None,
2612 env: HashMap::new(),
2613 action: StepAction::Template(TemplateStep {
2614 template: "steps/env-steps.yml".to_string(),
2615 parameters: {
2616 let mut params = HashMap::new();
2617 params.insert(
2618 "environment".to_string(),
2619 serde_yaml::Value::String("production".to_string()),
2620 );
2621 params
2622 },
2623 }),
2624 }],
2625 ..Default::default()
2626 };
2627
2628 let resolved = engine.resolve_pipeline(pipeline).unwrap();
2629 assert_eq!(resolved.steps.len(), 2);
2630 if let StepAction::Script(script) = &resolved.steps[1].action {
2631 assert_eq!(script.script, "echo production safety checks");
2632 }
2633 }
2634
2635 #[test]
2636 fn test_if_directive_multiple_items() {
2637 let dir = setup_templates(&[(
2638 "steps/multi.yml",
2639 r#"
2640parameters:
2641 - name: includeExtra
2642 type: boolean
2643 default: true
2644
2645steps:
2646 - script: echo first
2647 - ${{ if eq(parameters.includeExtra, true) }}:
2648 - script: echo extra step 1
2649 - script: echo extra step 2
2650 - script: echo extra step 3
2651 - script: echo last
2652"#,
2653 )]);
2654
2655 let mut engine = TemplateEngine::new(dir.path().to_path_buf());
2656
2657 let pipeline = Pipeline {
2658 steps: vec![Step {
2659 name: None,
2660 display_name: None,
2661 condition: None,
2662 continue_on_error: BoolOrExpression::default(),
2663 enabled: true,
2664 timeout_in_minutes: None,
2665 retry_count_on_task_failure: None,
2666 env: HashMap::new(),
2667 action: StepAction::Template(TemplateStep {
2668 template: "steps/multi.yml".to_string(),
2669 parameters: {
2670 let mut params = HashMap::new();
2671 params.insert("includeExtra".to_string(), serde_yaml::Value::Bool(true));
2672 params
2673 },
2674 }),
2675 }],
2676 ..Default::default()
2677 };
2678
2679 let resolved = engine.resolve_pipeline(pipeline).unwrap();
2680 assert_eq!(resolved.steps.len(), 5);
2682 if let StepAction::Script(script) = &resolved.steps[0].action {
2683 assert_eq!(script.script, "echo first");
2684 }
2685 if let StepAction::Script(script) = &resolved.steps[1].action {
2686 assert_eq!(script.script, "echo extra step 1");
2687 }
2688 if let StepAction::Script(script) = &resolved.steps[4].action {
2689 assert_eq!(script.script, "echo last");
2690 }
2691 }
2692
2693 #[test]
2698 fn test_each_directive_array() {
2699 let dir = setup_templates(&[(
2700 "steps/deploy.yml",
2701 r#"
2702parameters:
2703 - name: environments
2704 type: object
2705
2706steps:
2707 - ${{ each env in parameters.environments }}:
2708 - script: echo deploying to ${{ env }}
2709"#,
2710 )]);
2711
2712 let mut engine = TemplateEngine::new(dir.path().to_path_buf());
2713
2714 let pipeline = Pipeline {
2715 steps: vec![Step {
2716 name: None,
2717 display_name: None,
2718 condition: None,
2719 continue_on_error: BoolOrExpression::default(),
2720 enabled: true,
2721 timeout_in_minutes: None,
2722 retry_count_on_task_failure: None,
2723 env: HashMap::new(),
2724 action: StepAction::Template(TemplateStep {
2725 template: "steps/deploy.yml".to_string(),
2726 parameters: {
2727 let mut params = HashMap::new();
2728 params.insert(
2729 "environments".to_string(),
2730 serde_yaml::Value::Sequence(vec![
2731 serde_yaml::Value::String("dev".to_string()),
2732 serde_yaml::Value::String("staging".to_string()),
2733 serde_yaml::Value::String("production".to_string()),
2734 ]),
2735 );
2736 params
2737 },
2738 }),
2739 }],
2740 ..Default::default()
2741 };
2742
2743 let resolved = engine.resolve_pipeline(pipeline).unwrap();
2744 assert_eq!(resolved.steps.len(), 3);
2745 if let StepAction::Script(script) = &resolved.steps[0].action {
2746 assert_eq!(script.script, "echo deploying to dev");
2747 }
2748 if let StepAction::Script(script) = &resolved.steps[1].action {
2749 assert_eq!(script.script, "echo deploying to staging");
2750 }
2751 if let StepAction::Script(script) = &resolved.steps[2].action {
2752 assert_eq!(script.script, "echo deploying to production");
2753 }
2754 }
2755
2756 #[test]
2757 fn test_each_directive_with_multiple_steps_per_iteration() {
2758 let dir = setup_templates(&[(
2759 "steps/multi-deploy.yml",
2760 r#"
2761parameters:
2762 - name: environments
2763 type: object
2764
2765steps:
2766 - ${{ each env in parameters.environments }}:
2767 - script: echo starting deploy to ${{ env }}
2768 - script: echo finished deploy to ${{ env }}
2769"#,
2770 )]);
2771
2772 let mut engine = TemplateEngine::new(dir.path().to_path_buf());
2773
2774 let pipeline = Pipeline {
2775 steps: vec![Step {
2776 name: None,
2777 display_name: None,
2778 condition: None,
2779 continue_on_error: BoolOrExpression::default(),
2780 enabled: true,
2781 timeout_in_minutes: None,
2782 retry_count_on_task_failure: None,
2783 env: HashMap::new(),
2784 action: StepAction::Template(TemplateStep {
2785 template: "steps/multi-deploy.yml".to_string(),
2786 parameters: {
2787 let mut params = HashMap::new();
2788 params.insert(
2789 "environments".to_string(),
2790 serde_yaml::Value::Sequence(vec![
2791 serde_yaml::Value::String("dev".to_string()),
2792 serde_yaml::Value::String("prod".to_string()),
2793 ]),
2794 );
2795 params
2796 },
2797 }),
2798 }],
2799 ..Default::default()
2800 };
2801
2802 let resolved = engine.resolve_pipeline(pipeline).unwrap();
2803 assert_eq!(resolved.steps.len(), 4);
2805 if let StepAction::Script(script) = &resolved.steps[0].action {
2806 assert_eq!(script.script, "echo starting deploy to dev");
2807 }
2808 if let StepAction::Script(script) = &resolved.steps[1].action {
2809 assert_eq!(script.script, "echo finished deploy to dev");
2810 }
2811 if let StepAction::Script(script) = &resolved.steps[2].action {
2812 assert_eq!(script.script, "echo starting deploy to prod");
2813 }
2814 if let StepAction::Script(script) = &resolved.steps[3].action {
2815 assert_eq!(script.script, "echo finished deploy to prod");
2816 }
2817 }
2818
2819 #[test]
2820 fn test_each_directive_empty_array() {
2821 let dir = setup_templates(&[(
2822 "steps/deploy.yml",
2823 r#"
2824parameters:
2825 - name: environments
2826 type: object
2827
2828steps:
2829 - script: echo before
2830 - ${{ each env in parameters.environments }}:
2831 - script: echo deploying to ${{ env }}
2832 - script: echo after
2833"#,
2834 )]);
2835
2836 let mut engine = TemplateEngine::new(dir.path().to_path_buf());
2837
2838 let pipeline = Pipeline {
2839 steps: vec![Step {
2840 name: None,
2841 display_name: None,
2842 condition: None,
2843 continue_on_error: BoolOrExpression::default(),
2844 enabled: true,
2845 timeout_in_minutes: None,
2846 retry_count_on_task_failure: None,
2847 env: HashMap::new(),
2848 action: StepAction::Template(TemplateStep {
2849 template: "steps/deploy.yml".to_string(),
2850 parameters: {
2851 let mut params = HashMap::new();
2852 params.insert(
2853 "environments".to_string(),
2854 serde_yaml::Value::Sequence(vec![]),
2855 );
2856 params
2857 },
2858 }),
2859 }],
2860 ..Default::default()
2861 };
2862
2863 let resolved = engine.resolve_pipeline(pipeline).unwrap();
2864 assert_eq!(resolved.steps.len(), 2);
2866 if let StepAction::Script(script) = &resolved.steps[0].action {
2867 assert_eq!(script.script, "echo before");
2868 }
2869 if let StepAction::Script(script) = &resolved.steps[1].action {
2870 assert_eq!(script.script, "echo after");
2871 }
2872 }
2873
2874 #[test]
2875 fn test_if_and_each_combined() {
2876 let dir = setup_templates(&[(
2877 "steps/combined.yml",
2878 r#"
2879parameters:
2880 - name: runDeploy
2881 type: boolean
2882 - name: environments
2883 type: object
2884
2885steps:
2886 - script: echo building
2887 - ${{ if eq(parameters.runDeploy, true) }}:
2888 - ${{ each env in parameters.environments }}:
2889 - script: echo deploying to ${{ env }}
2890"#,
2891 )]);
2892
2893 let mut engine = TemplateEngine::new(dir.path().to_path_buf());
2894
2895 let pipeline = Pipeline {
2897 steps: vec![Step {
2898 name: None,
2899 display_name: None,
2900 condition: None,
2901 continue_on_error: BoolOrExpression::default(),
2902 enabled: true,
2903 timeout_in_minutes: None,
2904 retry_count_on_task_failure: None,
2905 env: HashMap::new(),
2906 action: StepAction::Template(TemplateStep {
2907 template: "steps/combined.yml".to_string(),
2908 parameters: {
2909 let mut params = HashMap::new();
2910 params.insert("runDeploy".to_string(), serde_yaml::Value::Bool(true));
2911 params.insert(
2912 "environments".to_string(),
2913 serde_yaml::Value::Sequence(vec![
2914 serde_yaml::Value::String("dev".to_string()),
2915 serde_yaml::Value::String("prod".to_string()),
2916 ]),
2917 );
2918 params
2919 },
2920 }),
2921 }],
2922 ..Default::default()
2923 };
2924
2925 let resolved = engine.resolve_pipeline(pipeline).unwrap();
2926 assert_eq!(resolved.steps.len(), 3);
2928 if let StepAction::Script(script) = &resolved.steps[1].action {
2929 assert_eq!(script.script, "echo deploying to dev");
2930 }
2931 if let StepAction::Script(script) = &resolved.steps[2].action {
2932 assert_eq!(script.script, "echo deploying to prod");
2933 }
2934 }
2935
2936 #[test]
2937 fn test_if_and_each_combined_false() {
2938 let dir = setup_templates(&[(
2939 "steps/combined.yml",
2940 r#"
2941parameters:
2942 - name: runDeploy
2943 type: boolean
2944 - name: environments
2945 type: object
2946
2947steps:
2948 - script: echo building
2949 - ${{ if eq(parameters.runDeploy, true) }}:
2950 - ${{ each env in parameters.environments }}:
2951 - script: echo deploying to ${{ env }}
2952"#,
2953 )]);
2954
2955 let mut engine = TemplateEngine::new(dir.path().to_path_buf());
2956
2957 let pipeline = Pipeline {
2959 steps: vec![Step {
2960 name: None,
2961 display_name: None,
2962 condition: None,
2963 continue_on_error: BoolOrExpression::default(),
2964 enabled: true,
2965 timeout_in_minutes: None,
2966 retry_count_on_task_failure: None,
2967 env: HashMap::new(),
2968 action: StepAction::Template(TemplateStep {
2969 template: "steps/combined.yml".to_string(),
2970 parameters: {
2971 let mut params = HashMap::new();
2972 params.insert("runDeploy".to_string(), serde_yaml::Value::Bool(false));
2973 params.insert(
2974 "environments".to_string(),
2975 serde_yaml::Value::Sequence(vec![
2976 serde_yaml::Value::String("dev".to_string()),
2977 serde_yaml::Value::String("prod".to_string()),
2978 ]),
2979 );
2980 params
2981 },
2982 }),
2983 }],
2984 ..Default::default()
2985 };
2986
2987 let resolved = engine.resolve_pipeline(pipeline).unwrap();
2988 assert_eq!(resolved.steps.len(), 1);
2990 if let StepAction::Script(script) = &resolved.steps[0].action {
2991 assert_eq!(script.script, "echo building");
2992 }
2993 }
2994
2995 #[test]
3000 fn test_parse_directive_if() {
3001 let result = TemplateEngine::parse_directive("${{ if eq(parameters.x, true) }}");
3002 assert!(result.is_some());
3003 if let Some(TemplateDirective::If(condition)) = result {
3004 assert_eq!(condition, "eq(parameters.x, true)");
3005 } else {
3006 panic!("expected If directive");
3007 }
3008 }
3009
3010 #[test]
3011 fn test_parse_directive_elseif() {
3012 let result = TemplateEngine::parse_directive("${{ elseif eq(parameters.x, 'y') }}");
3013 assert!(result.is_some());
3014 if let Some(TemplateDirective::ElseIf(condition)) = result {
3015 assert_eq!(condition, "eq(parameters.x, 'y')");
3016 } else {
3017 panic!("expected ElseIf directive");
3018 }
3019 }
3020
3021 #[test]
3022 fn test_parse_directive_else_if() {
3023 let result = TemplateEngine::parse_directive("${{ else if ne(parameters.a, 'b') }}");
3024 assert!(result.is_some());
3025 if let Some(TemplateDirective::ElseIf(condition)) = result {
3026 assert_eq!(condition, "ne(parameters.a, 'b')");
3027 } else {
3028 panic!("expected ElseIf directive");
3029 }
3030 }
3031
3032 #[test]
3033 fn test_parse_directive_else() {
3034 let result = TemplateEngine::parse_directive("${{ else }}");
3035 assert!(result.is_some());
3036 assert!(matches!(result, Some(TemplateDirective::Else)));
3037 }
3038
3039 #[test]
3040 fn test_parse_directive_each() {
3041 let result = TemplateEngine::parse_directive("${{ each env in parameters.environments }}");
3042 assert!(result.is_some());
3043 if let Some(TemplateDirective::Each(var, collection)) = result {
3044 assert_eq!(var, "env");
3045 assert_eq!(collection, "parameters.environments");
3046 } else {
3047 panic!("expected Each directive");
3048 }
3049 }
3050
3051 #[test]
3052 fn test_parse_directive_not_a_directive() {
3053 assert!(TemplateEngine::parse_directive("regular string").is_none());
3054 assert!(TemplateEngine::parse_directive("${{ parameters.x }}").is_none());
3055 assert!(TemplateEngine::parse_directive("${{ }}").is_none());
3056 }
3057
3058 #[test]
3059 fn test_each_directive_in_jobs() {
3060 let dir = setup_templates(&[(
3061 "jobs/deploy.yml",
3062 r#"
3063parameters:
3064 - name: environments
3065 type: object
3066
3067jobs:
3068 - ${{ each env in parameters.environments }}:
3069 - job: Deploy_${{ env }}
3070 displayName: Deploy to ${{ env }}
3071 steps:
3072 - script: echo deploying to ${{ env }}
3073"#,
3074 )]);
3075
3076 let mut engine = TemplateEngine::new(dir.path().to_path_buf());
3077
3078 let pipeline = Pipeline {
3079 jobs: vec![Job {
3080 template: Some("jobs/deploy.yml".to_string()),
3081 parameters: {
3082 let mut params = HashMap::new();
3083 params.insert(
3084 "environments".to_string(),
3085 serde_yaml::Value::Sequence(vec![
3086 serde_yaml::Value::String("dev".to_string()),
3087 serde_yaml::Value::String("staging".to_string()),
3088 ]),
3089 );
3090 params
3091 },
3092 ..Default::default()
3093 }],
3094 ..Default::default()
3095 };
3096
3097 let resolved = engine.resolve_pipeline(pipeline).unwrap();
3098 assert_eq!(resolved.jobs.len(), 2);
3099 assert_eq!(resolved.jobs[0].job, Some("Deploy_dev".to_string()));
3100 assert_eq!(
3101 resolved.jobs[0].display_name.as_deref(),
3102 Some("Deploy to dev")
3103 );
3104 assert_eq!(resolved.jobs[1].job, Some("Deploy_staging".to_string()));
3105 assert_eq!(
3106 resolved.jobs[1].display_name.as_deref(),
3107 Some("Deploy to staging")
3108 );
3109 }
3110
3111 #[test]
3112 fn test_if_elseif_else_chain_first_branch() {
3113 let dir = setup_templates(&[(
3115 "steps/deploy.yml",
3116 r#"
3117parameters:
3118 - name: environment
3119 type: string
3120
3121steps:
3122 - ${{ if eq(parameters.environment, 'production') }}:
3123 - script: echo deploying to production
3124 - ${{ elseif eq(parameters.environment, 'staging') }}:
3125 - script: echo deploying to staging
3126 - ${{ else }}:
3127 - script: echo deploying to dev
3128"#,
3129 )]);
3130
3131 let mut engine = TemplateEngine::new(dir.path().to_path_buf());
3132
3133 let pipeline = Pipeline {
3134 steps: vec![Step {
3135 name: None,
3136 display_name: None,
3137 condition: None,
3138 continue_on_error: BoolOrExpression::default(),
3139 enabled: true,
3140 timeout_in_minutes: None,
3141 retry_count_on_task_failure: None,
3142 env: HashMap::new(),
3143 action: StepAction::Template(TemplateStep {
3144 template: "steps/deploy.yml".to_string(),
3145 parameters: {
3146 let mut params = HashMap::new();
3147 params.insert(
3148 "environment".to_string(),
3149 serde_yaml::Value::String("production".to_string()),
3150 );
3151 params
3152 },
3153 }),
3154 }],
3155 ..Default::default()
3156 };
3157
3158 let resolved = engine.resolve_pipeline(pipeline).unwrap();
3159 assert_eq!(resolved.steps.len(), 1);
3160 if let StepAction::Script(script) = &resolved.steps[0].action {
3161 assert_eq!(script.script, "echo deploying to production");
3162 } else {
3163 panic!("Expected script step");
3164 }
3165 }
3166
3167 #[test]
3168 fn test_if_elseif_else_chain_second_branch() {
3169 let dir = setup_templates(&[(
3171 "steps/deploy.yml",
3172 r#"
3173parameters:
3174 - name: environment
3175 type: string
3176
3177steps:
3178 - ${{ if eq(parameters.environment, 'production') }}:
3179 - script: echo deploying to production
3180 - ${{ elseif eq(parameters.environment, 'staging') }}:
3181 - script: echo deploying to staging
3182 - ${{ else }}:
3183 - script: echo deploying to dev
3184"#,
3185 )]);
3186
3187 let mut engine = TemplateEngine::new(dir.path().to_path_buf());
3188
3189 let pipeline = Pipeline {
3190 steps: vec![Step {
3191 name: None,
3192 display_name: None,
3193 condition: None,
3194 continue_on_error: BoolOrExpression::default(),
3195 enabled: true,
3196 timeout_in_minutes: None,
3197 retry_count_on_task_failure: None,
3198 env: HashMap::new(),
3199 action: StepAction::Template(TemplateStep {
3200 template: "steps/deploy.yml".to_string(),
3201 parameters: {
3202 let mut params = HashMap::new();
3203 params.insert(
3204 "environment".to_string(),
3205 serde_yaml::Value::String("staging".to_string()),
3206 );
3207 params
3208 },
3209 }),
3210 }],
3211 ..Default::default()
3212 };
3213
3214 let resolved = engine.resolve_pipeline(pipeline).unwrap();
3215 assert_eq!(resolved.steps.len(), 1);
3216 if let StepAction::Script(script) = &resolved.steps[0].action {
3217 assert_eq!(script.script, "echo deploying to staging");
3218 } else {
3219 panic!("Expected script step");
3220 }
3221 }
3222
3223 #[test]
3224 fn test_if_elseif_else_chain_else_branch() {
3225 let dir = setup_templates(&[(
3227 "steps/deploy.yml",
3228 r#"
3229parameters:
3230 - name: environment
3231 type: string
3232
3233steps:
3234 - ${{ if eq(parameters.environment, 'production') }}:
3235 - script: echo deploying to production
3236 - ${{ elseif eq(parameters.environment, 'staging') }}:
3237 - script: echo deploying to staging
3238 - ${{ else }}:
3239 - script: echo deploying to dev
3240"#,
3241 )]);
3242
3243 let mut engine = TemplateEngine::new(dir.path().to_path_buf());
3244
3245 let pipeline = Pipeline {
3246 steps: vec![Step {
3247 name: None,
3248 display_name: None,
3249 condition: None,
3250 continue_on_error: BoolOrExpression::default(),
3251 enabled: true,
3252 timeout_in_minutes: None,
3253 retry_count_on_task_failure: None,
3254 env: HashMap::new(),
3255 action: StepAction::Template(TemplateStep {
3256 template: "steps/deploy.yml".to_string(),
3257 parameters: {
3258 let mut params = HashMap::new();
3259 params.insert(
3260 "environment".to_string(),
3261 serde_yaml::Value::String("development".to_string()),
3262 );
3263 params
3264 },
3265 }),
3266 }],
3267 ..Default::default()
3268 };
3269
3270 let resolved = engine.resolve_pipeline(pipeline).unwrap();
3271 assert_eq!(resolved.steps.len(), 1);
3272 if let StepAction::Script(script) = &resolved.steps[0].action {
3273 assert_eq!(script.script, "echo deploying to dev");
3274 } else {
3275 panic!("Expected script step");
3276 }
3277 }
3278
3279 #[test]
3280 fn test_if_elseif_chain_multiple_elseif() {
3281 let dir = setup_templates(&[(
3283 "steps/config.yml",
3284 r#"
3285parameters:
3286 - name: os
3287 type: string
3288
3289steps:
3290 - ${{ if eq(parameters.os, 'linux') }}:
3291 - script: echo linux setup
3292 - ${{ elseif eq(parameters.os, 'macos') }}:
3293 - script: echo macos setup
3294 - ${{ elseif eq(parameters.os, 'windows') }}:
3295 - script: echo windows setup
3296 - ${{ else }}:
3297 - script: echo unknown os
3298"#,
3299 )]);
3300
3301 let mut engine = TemplateEngine::new(dir.path().to_path_buf());
3302
3303 let pipeline = Pipeline {
3305 steps: vec![Step {
3306 name: None,
3307 display_name: None,
3308 condition: None,
3309 continue_on_error: BoolOrExpression::default(),
3310 enabled: true,
3311 timeout_in_minutes: None,
3312 retry_count_on_task_failure: None,
3313 env: HashMap::new(),
3314 action: StepAction::Template(TemplateStep {
3315 template: "steps/config.yml".to_string(),
3316 parameters: {
3317 let mut params = HashMap::new();
3318 params.insert(
3319 "os".to_string(),
3320 serde_yaml::Value::String("windows".to_string()),
3321 );
3322 params
3323 },
3324 }),
3325 }],
3326 ..Default::default()
3327 };
3328
3329 let resolved = engine.resolve_pipeline(pipeline).unwrap();
3330 assert_eq!(resolved.steps.len(), 1);
3331 if let StepAction::Script(script) = &resolved.steps[0].action {
3332 assert_eq!(script.script, "echo windows setup");
3333 } else {
3334 panic!("Expected script step");
3335 }
3336 }
3337
3338 #[test]
3339 fn test_if_chain_non_directive_breaks_chain() {
3340 let dir = setup_templates(&[(
3343 "steps/broken.yml",
3344 r#"
3345parameters:
3346 - name: flag
3347 type: boolean
3348
3349steps:
3350 - ${{ if eq(parameters.flag, true) }}:
3351 - script: echo flag is true
3352 - script: echo always runs
3353 - ${{ else }}:
3354 - script: echo flag is false
3355"#,
3356 )]);
3357
3358 let mut engine = TemplateEngine::new(dir.path().to_path_buf());
3359
3360 let pipeline = Pipeline {
3361 steps: vec![Step {
3362 name: None,
3363 display_name: None,
3364 condition: None,
3365 continue_on_error: BoolOrExpression::default(),
3366 enabled: true,
3367 timeout_in_minutes: None,
3368 retry_count_on_task_failure: None,
3369 env: HashMap::new(),
3370 action: StepAction::Template(TemplateStep {
3371 template: "steps/broken.yml".to_string(),
3372 parameters: {
3373 let mut params = HashMap::new();
3374 params.insert("flag".to_string(), serde_yaml::Value::Bool(true));
3375 params
3376 },
3377 }),
3378 }],
3379 ..Default::default()
3380 };
3381
3382 let resolved = engine.resolve_pipeline(pipeline).unwrap();
3383 assert_eq!(resolved.steps.len(), 3);
3385 if let StepAction::Script(script) = &resolved.steps[0].action {
3386 assert_eq!(script.script, "echo flag is true");
3387 } else {
3388 panic!("Expected script step");
3389 }
3390 if let StepAction::Script(script) = &resolved.steps[1].action {
3391 assert_eq!(script.script, "echo always runs");
3392 } else {
3393 panic!("Expected script step");
3394 }
3395 if let StepAction::Script(script) = &resolved.steps[2].action {
3396 assert_eq!(script.script, "echo flag is false");
3397 } else {
3398 panic!("Expected script step");
3399 }
3400 }
3401}