Skip to main content

pipeline_service/parser/
template.rs

1// Template Resolution Engine for Azure DevOps Pipelines
2// Resolves template references, expands parameters, handles extends,
3// and supports ${{ each }} and ${{ if }} template expressions.
4
5use 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
14/// Maximum template inclusion depth to prevent infinite recursion
15const MAX_TEMPLATE_DEPTH: usize = 50;
16
17/// Error specific to template resolution
18#[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    /// Template file not found
28    NotFound,
29    /// Circular template inclusion
30    CircularReference,
31    /// Maximum depth exceeded
32    MaxDepthExceeded,
33    /// Invalid parameter
34    InvalidParameter,
35    /// Parameter type mismatch
36    TypeMismatch,
37    /// Required parameter missing
38    MissingParameter,
39    /// Parse error in template file
40    ParseError,
41    /// Expression evaluation error in template
42    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/// Content types that a template file can contain
77#[derive(Debug, Clone)]
78pub enum TemplateContent {
79    /// Template containing steps
80    Steps(Vec<Step>),
81    /// Template containing jobs
82    Jobs(Vec<Job>),
83    /// Template containing stages
84    Stages(Vec<Stage>),
85    /// Template containing variables
86    Variables(Vec<Variable>),
87    /// Full pipeline template (for extends)
88    Pipeline(Box<Pipeline>),
89}
90
91/// Raw template content before deserialization (preserves ${{ if }}, ${{ each }} directives)
92#[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/// A parsed template file with its declared parameters
102#[derive(Debug, Clone)]
103pub struct TemplateFile {
104    /// Template parameters declaration
105    pub parameters: Vec<Parameter>,
106    /// The template content
107    pub content: TemplateContent,
108}
109
110/// A raw template file with its declared parameters (pre-expression processing)
111#[derive(Debug, Clone)]
112struct RawTemplateFile {
113    /// Template parameters declaration
114    parameters: Vec<Parameter>,
115    /// The raw template content (before ${{ if }}/${{ each }} processing)
116    content: RawTemplateContent,
117}
118
119/// Parsed template directive from a YAML key
120#[derive(Debug, Clone)]
121enum TemplateDirective {
122    /// `${{ if <condition> }}`
123    If(String),
124    /// `${{ elseif <condition> }}` or `${{ else if <condition> }}`
125    ElseIf(String),
126    /// `${{ else }}`
127    Else,
128    /// `${{ each <var> in <collection> }}`
129    Each(String, String),
130}
131
132/// Template resolution engine
133///
134/// Resolves template references in Azure DevOps pipelines by:
135/// 1. Loading template files relative to the repository root
136/// 2. Validating and substituting parameters
137/// 3. Expanding template expressions (${{ each }}, ${{ if }})
138/// 4. Handling extends (pipeline inheritance)
139/// 5. Detecting circular references
140pub struct TemplateEngine {
141    /// Repository root directory for resolving template paths
142    repo_root: PathBuf,
143    /// Resource repository paths for cross-repo template references
144    resource_repos: HashMap<String, PathBuf>,
145    /// Track included templates for cycle detection
146    include_stack: Vec<String>,
147}
148
149impl TemplateEngine {
150    /// Create a new template engine
151    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    /// Add a resource repository path for cross-repo template references
160    pub fn with_resource_repo(mut self, name: String, path: PathBuf) -> Self {
161        self.resource_repos.insert(name, path);
162        self
163    }
164
165    /// Resolve all templates in a pipeline, returning a fully expanded pipeline
166    /// with no template references remaining.
167    pub fn resolve_pipeline(&mut self, pipeline: Pipeline) -> ParseResult<Pipeline> {
168        let mut resolved = pipeline;
169
170        // 1. Handle extends template (pipeline inheritance)
171        if let Some(extends) = resolved.extends.take() {
172            resolved = self.resolve_extends(&extends, resolved)?;
173        }
174
175        // 2. Resolve variable templates
176        resolved.variables = self.resolve_variable_templates(&resolved.variables)?;
177
178        // 3. Resolve stage templates
179        resolved.stages = self.resolve_stage_templates(&resolved.stages)?;
180
181        // 4. Resolve job templates (for pipeline-level jobs)
182        resolved.jobs = self.resolve_job_templates(&resolved.jobs)?;
183
184        // 5. Resolve step templates (for pipeline-level steps)
185        resolved.steps = self.resolve_step_templates(&resolved.steps)?;
186
187        Ok(resolved)
188    }
189
190    // =========================================================================
191    // Extends Resolution
192    // =========================================================================
193
194    /// Resolve an extends template (pipeline inheritance)
195    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        // Validate parameters
223        let params =
224            self.resolve_parameters(&parent.parameters, &extends.parameters, &extends.template)?;
225
226        // Substitute parameters in parent template
227        parent = self.substitute_template_parameters(parent, &params)?;
228
229        // Merge child into parent (child values override parent where applicable)
230        let merged = self.merge_extends(parent, child);
231
232        self.pop_template();
233
234        // Recursively resolve templates in the merged pipeline
235        self.resolve_pipeline(merged)
236    }
237
238    /// Merge a child pipeline into a parent (extends) pipeline
239    fn merge_extends(&self, mut parent: Pipeline, child: Pipeline) -> Pipeline {
240        // Child's trigger overrides parent's
241        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        // Merge resources
252        if child.resources.is_some() {
253            parent.resources = child.resources;
254        }
255
256        // Child variables are added (overriding parent on conflict)
257        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        // Child pool overrides
273        if child.pool.is_some() {
274            parent.pool = child.pool;
275        }
276
277        // Child name overrides
278        if child.name.is_some() {
279            parent.name = child.name;
280        }
281
282        parent
283    }
284
285    // =========================================================================
286    // Variable Template Resolution
287    // =========================================================================
288
289    /// Resolve variable template references
290    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    /// Expand a single variable template reference
310    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        // Validate and resolve parameters
318        let params =
319            self.resolve_parameters(&raw_template_file.parameters, call_params, template_ref)?;
320
321        // Build engine and process ${{ if }}, ${{ each }}, and parameter substitution
322        let engine = self.build_parameter_engine(&params);
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                // Substitute remaining parameters in variable values
328                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    // =========================================================================
351    // Stage Template Resolution
352    // =========================================================================
353
354    /// Resolve stage template references
355    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                // Resolve variable templates within the stage
365                stage.variables = self.resolve_variable_templates(&stage.variables)?;
366                // Resolve job templates within the stage
367                stage.jobs = self.resolve_job_templates(&stage.jobs)?;
368                resolved.push(stage);
369            }
370        }
371
372        Ok(resolved)
373    }
374
375    /// Expand a single stage template reference
376    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        // Build engine and process ${{ if }}, ${{ each }}, and parameter substitution
387        let engine = self.build_parameter_engine(&params);
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                    // Recursively resolve templates within the expanded stage
396                    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    // =========================================================================
417    // Job Template Resolution
418    // =========================================================================
419
420    /// Resolve job template references
421    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                // Resolve variable templates within the job
431                job.variables = self.resolve_variable_templates(&job.variables)?;
432                // Resolve step templates within the job
433                job.steps = self.resolve_step_templates(&job.steps)?;
434                resolved.push(job);
435            }
436        }
437
438        Ok(resolved)
439    }
440
441    /// Expand a single job template reference
442    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        // Build engine and process ${{ if }}, ${{ each }}, and parameter substitution
453        let engine = self.build_parameter_engine(&params);
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                    // Recursively resolve templates within the expanded job
462                    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    // =========================================================================
483    // Step Template Resolution
484    // =========================================================================
485
486    /// Resolve step template references
487    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    /// Expand a single step template reference
504    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        // Build engine and process ${{ if }}, ${{ each }}, and parameter substitution
515        let engine = self.build_parameter_engine(&params);
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                    // Recursively resolve any nested template steps
524                    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    // =========================================================================
547    // Template File Loading
548    // =========================================================================
549
550    /// Load and parse a template file.
551    /// NOTE: This pushes the template onto the include stack for cycle detection.
552    /// Callers must call `pop_template()` after they are done expanding the template
553    /// (including any recursive resolution of nested templates).
554    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        // On success, caller is responsible for calling pop_template()
576
577        result
578    }
579
580    /// Parse template file content into raw form (before expression processing)
581    fn parse_raw_template_content(
582        &self,
583        template_ref: &str,
584        content: &str,
585    ) -> ParseResult<RawTemplateFile> {
586        // Parse as generic YAML first
587        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        // Extract parameters
600        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        // Determine content type based on which key is present, but keep raw YAML
607        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            // Try to parse as a full pipeline (for extends) - store raw content string
629            Ok(RawTemplateFile {
630                parameters,
631                content: RawTemplateContent::Pipeline(content.to_string()),
632            })
633        }
634    }
635
636    /// Process template expressions (${{ if }}, ${{ each }}) in raw YAML content
637    /// and deserialize to a TemplateFile.
638    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    /// Parse template parameter declarations
719    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                // Simple key-value parameter format (name: default_value)
737                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    // =========================================================================
759    // Parameter Resolution
760    // =========================================================================
761
762    /// Validate and resolve parameters passed to a template
763    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(&param.name) {
773                // Validate type if possible
774                self.validate_parameter_type(
775                    &param.name,
776                    provided_val,
777                    &param.param_type,
778                    template_ref,
779                )?;
780
781                // Validate allowed values
782                if let Some(allowed) = &param.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) = &param.default {
795                // Use default value
796                resolved.insert(param.name.clone(), yaml_to_value(default));
797            } else {
798                // Required parameter missing
799                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        // Also pass through any extra parameters not declared (Azure DevOps allows this)
812        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    /// Validate that a parameter value matches the declared type
822    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    // =========================================================================
870    // Parameter Substitution
871    // =========================================================================
872
873    /// Build an ExpressionEngine with parameters set as context
874    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    /// Substitute ${{ }} compile-time expressions in a string,
883    /// preserving $(macro) and $[ runtime ] expressions for later evaluation.
884    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                    // Preserve macros - they are runtime, not template-time
912                    result.push_str(&format!("$({})", var_name));
913                }
914                ExpressionType::Runtime(expr_str) => {
915                    // Preserve runtime expressions - they are evaluated at runtime
916                    result.push_str(&format!("$[ {} ]", expr_str));
917                }
918            }
919        }
920
921        Ok(result)
922    }
923
924    /// Substitute parameters in a pipeline (for extends)
925    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        // Substitute in variables
933        pipeline.variables = pipeline
934            .variables
935            .iter()
936            .map(|v| self.substitute_variable_params(v, &engine))
937            .collect::<ParseResult<Vec<_>>>()?;
938
939        // Substitute in stages
940        pipeline.stages = pipeline
941            .stages
942            .iter()
943            .map(|s| self.substitute_stage_params(s, &engine))
944            .collect::<ParseResult<Vec<_>>>()?;
945
946        // Substitute in jobs
947        pipeline.jobs = pipeline
948            .jobs
949            .iter()
950            .map(|j| self.substitute_job_params(j, &engine))
951            .collect::<ParseResult<Vec<_>>>()?;
952
953        // Substitute in steps
954        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    /// Substitute parameters in a variable definition
964    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    /// Substitute parameters in a stage
988    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        // Substitute in variables
1008        new_stage.variables = stage
1009            .variables
1010            .iter()
1011            .map(|v| self.substitute_variable_params(v, engine))
1012            .collect::<ParseResult<Vec<_>>>()?;
1013
1014        // Substitute in jobs
1015        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    /// Substitute parameters in a job
1025    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        // Substitute in variables
1041        new_job.variables = job
1042            .variables
1043            .iter()
1044            .map(|v| self.substitute_variable_params(v, engine))
1045            .collect::<ParseResult<Vec<_>>>()?;
1046
1047        // Substitute in steps
1048        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    /// Substitute parameters in a step
1058    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        // Substitute in the action
1070        new_step.action = self.substitute_step_action_params(&step.action, engine)?;
1071
1072        // Substitute in env
1073        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    /// Substitute parameters in a step action
1085    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            // Template steps: substitute ${{ }} expressions in parameter values
1159            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                    // Substitute in string parameter values
1164                    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 actions pass through unchanged
1177            other => Ok(other.clone()),
1178        }
1179    }
1180
1181    // =========================================================================
1182    // Template Expression Processing (${{ if }} and ${{ each }})
1183    // =========================================================================
1184
1185    /// Process `${{ if }}` and `${{ each }}` template expressions in a serde_yaml::Value tree.
1186    /// These are compile-time structural directives that conditionally include or
1187    /// repeat YAML nodes before deserialization to typed structs.
1188    ///
1189    /// In Azure DevOps YAML, these appear as mapping entries within sequences:
1190    /// ```yaml
1191    /// steps:
1192    ///   - ${{ if eq(parameters.runTests, true) }}:
1193    ///     - script: cargo test
1194    ///   - ${{ each env in parameters.environments }}:
1195    ///     - script: echo deploying to ${{ env }}
1196    /// ```
1197    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                // Track if/elseif/else chaining: when we encounter an ${{ if }},
1206                // we record whether any branch in the chain was taken. Subsequent
1207                // ${{ elseif }} and ${{ else }} directives check this state.
1208                let mut chain_active = false; // Are we inside an if/elseif/else chain?
1209                let mut chain_taken = false; // Was any branch in the current chain already taken?
1210
1211                for item in seq {
1212                    // Determine if this item is a directive
1213                    let directive = self.extract_directive(item);
1214
1215                    match &directive {
1216                        Some((TemplateDirective::If(_), _)) => {
1217                            // Start a new if-chain
1218                            chain_active = true;
1219                            chain_taken = false;
1220                        }
1221                        Some((TemplateDirective::ElseIf(_), _))
1222                        | Some((TemplateDirective::Else, _)) => {
1223                            // Continue existing chain - if no chain is active, treat as standalone
1224                            if !chain_active {
1225                                chain_active = true;
1226                                chain_taken = false;
1227                            }
1228                        }
1229                        _ => {
1230                            // Non-directive item breaks the chain
1231                            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                            // Not a directive - process recursively and include
1310                            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                    // Check if the key itself is a template expression
1321                    if let Some(key_str) = key.as_str() {
1322                        if let Some(directive) = Self::parse_directive(key_str) {
1323                            // Process the directive at the mapping level
1324                            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                                        // Include the value's entries into this mapping
1340                                        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                                    // elseif/else at mapping level - skip for now
1352                                    // (handled in sequence context with preceding if)
1353                                    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                    // Regular key-value pair: recurse into value
1394                    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                // Substitute compile-time expressions in strings
1401                let substituted = self.substitute_compile_time(s, engine)?;
1402                Ok(serde_yaml::Value::String(substituted))
1403            }
1404            // Scalars pass through unchanged
1405            other => Ok(other.clone()),
1406        }
1407    }
1408
1409    /// Extract a template directive and its value from a YAML sequence item.
1410    /// Returns `None` if the item is not a directive.
1411    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    /// Expand the body of a directive (the value portion), which should be a sequence.
1426    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                // Delegate to process_template_expressions which handles
1434                // if/elseif/else chaining within sequences
1435                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            // If the body is a single mapping, wrap it in a vec
1443            serde_yaml::Value::Mapping(_) => {
1444                let processed = self.process_template_expressions(value, engine)?;
1445                Ok(vec![processed])
1446            }
1447            // If the body is a scalar value (e.g., ${{ item }} in an each),
1448            // process it as an expression
1449            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    /// Parse a YAML key string to determine if it's a template directive.
1458    /// Handles: `${{ if condition }}`, `${{ elseif condition }}`, `${{ else }}`,
1459    /// and `${{ each var in collection }}`.
1460    fn parse_directive(key: &str) -> Option<TemplateDirective> {
1461        let trimmed = key.trim();
1462
1463        // Check for ${{ ... }} pattern
1464        if !trimmed.starts_with("${{") || !trimmed.ends_with("}}") {
1465            return None;
1466        }
1467
1468        // Extract the inner content
1469        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            // Parse: each <var> in <collection>
1484            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    /// Convert a Value into an iterable list of (key, value) pairs.
1502    /// Arrays yield (index_as_string, item) pairs.
1503    /// Objects yield (key, value) pairs.
1504    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    /// Build an ExpressionEngine for a single iteration of `${{ each }}`.
1527    /// Adds the iteration variable to the parameters context.
1528    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    /// Substitute compile-time expressions within a serde_yaml::Value key
1542    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    // =========================================================================
1557    // Path Resolution
1558    // =========================================================================
1559
1560    /// Resolve a template reference to an absolute file path
1561    fn resolve_template_path(&self, template_ref: &str) -> ParseResult<PathBuf> {
1562        // Check for cross-repository template reference: repo@template
1563        if let Some((repo_name, template_path)) = template_ref.split_once('@') {
1564            // Format: template@repo_name  (Azure DevOps uses template path first)
1565            // Actually Azure DevOps uses: template: steps/build.yml@templates_repo
1566            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            // Also try: file_path@repo_name (the path part is before @)
1584            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        // Local template reference (relative to repo root)
1593        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    // =========================================================================
1611    // Cycle Detection
1612    // =========================================================================
1613
1614    /// Push a template onto the include stack, checking for cycles
1615    fn push_template(&mut self, canonical_path: &str) -> ParseResult<()> {
1616        // Check depth limit
1617        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        // Check for cycles
1630        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    /// Pop the current template from the include stack
1648    fn pop_template(&mut self) {
1649        self.include_stack.pop();
1650    }
1651
1652    /// Get a canonical path string for comparison
1653    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
1661// =============================================================================
1662// Helper Functions
1663// =============================================================================
1664
1665/// Convert serde_yaml::Value to our Value type
1666pub 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
1684/// Convert our Value type back to serde_yaml::Value
1685pub 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
1708/// Get a human-readable name for template content type
1709fn 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// =============================================================================
1720// Tests
1721// =============================================================================
1722
1723#[cfg(test)]
1724mod tests {
1725    use super::*;
1726    use std::io::Write;
1727    use tempfile::TempDir;
1728
1729    /// Helper to create a temp directory with template files
1730    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(), // No params - use defaults
1829                }),
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(), // Missing required param
2105                }),
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            // ${{ parameters.config }} should be resolved, $(Build.SourceBranch) should be preserved
2272            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(), // Use defaults
2348                }),
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        // Should have both baseVar and the overridden sharedVar
2462        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    // =========================================================================
2476    // ${{ if }} directive tests
2477    // =========================================================================
2478
2479    #[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        // Only the unconditional step should be present
2576        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        // Test with production
2603        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        // first + 3 extra + last = 5 steps
2681        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    // =========================================================================
2694    // ${{ each }} directive tests
2695    // =========================================================================
2696
2697    #[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        // 2 environments * 2 steps each = 4 steps
2804        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        // Only before + after, no items from each
2865        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        // Test with deploy enabled
2896        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        // build + 2 deploy steps = 3
2927        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        // Test with deploy disabled
2958        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        // Only the build step
2989        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    // =========================================================================
2996    // parse_directive unit tests
2997    // =========================================================================
2998
2999    #[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        // When the if condition is true, elseif and else should be skipped
3114        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        // When if is false but elseif is true, only elseif body should be included
3170        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        // When both if and elseif are false, else body should be included
3226        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        // Multiple elseif branches: only the first matching one should be taken
3282        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        // Test that 'windows' matches third branch only
3304        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        // A non-directive item between if and else should break the chain,
3341        // so the else is treated as a standalone (always included)
3342        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        // if(true) + always runs + else (treated as standalone since chain was broken)
3384        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}