Skip to main content

pipeline_service/expression/
evaluator.rs

1// Expression Engine Evaluator
2// Evaluates AST expressions with context (variables, parameters, etc.)
3
4use crate::expression::functions::BuiltinFunctions;
5use crate::expression::parser::{BinaryOp, Expr, Reference, ReferencePart, UnaryOp};
6use crate::parser::models::Value;
7
8use std::collections::HashMap;
9use std::fmt;
10
11/// Evaluation error
12#[derive(Debug, Clone)]
13pub struct EvalError {
14    pub message: String,
15}
16
17impl fmt::Display for EvalError {
18    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
19        write!(f, "evaluation error: {}", self.message)
20    }
21}
22
23impl std::error::Error for EvalError {}
24
25impl EvalError {
26    pub fn new(message: impl Into<String>) -> Self {
27        Self {
28            message: message.into(),
29        }
30    }
31}
32
33/// Context for expression evaluation
34#[derive(Debug, Clone, Default)]
35pub struct ExpressionContext {
36    /// Pipeline variables
37    pub variables: HashMap<String, Value>,
38
39    /// Pipeline parameters
40    pub parameters: HashMap<String, Value>,
41
42    /// Pipeline context (pipeline.*, Build.*, etc.)
43    pub pipeline: PipelineContext,
44
45    /// Current stage context
46    pub stage: Option<StageContext>,
47
48    /// Current job context
49    pub job: Option<JobContext>,
50
51    /// Step outputs by step name
52    pub steps: HashMap<String, StepContext>,
53
54    /// Stage/job dependencies output
55    pub dependencies: DependenciesContext,
56
57    /// Environment variables
58    pub env: HashMap<String, Value>,
59
60    /// Resources context
61    pub resources: ResourcesContext,
62}
63
64#[derive(Debug, Clone, Default)]
65pub struct PipelineContext {
66    /// Pipeline name
67    pub name: Option<String>,
68    /// Pipeline workspace
69    pub workspace: Option<String>,
70}
71
72#[derive(Debug, Clone, Default)]
73pub struct StageContext {
74    /// Stage name
75    pub name: String,
76    /// Stage display name
77    pub display_name: Option<String>,
78}
79
80#[derive(Debug, Clone, Default)]
81pub struct JobContext {
82    /// Job name
83    pub name: String,
84    /// Job display name
85    pub display_name: Option<String>,
86    /// Agent context
87    pub agent: AgentContext,
88    /// Job status
89    pub status: JobStatusContext,
90}
91
92#[derive(Debug, Clone, Default)]
93pub struct AgentContext {
94    pub name: Option<String>,
95    pub os: Option<String>,
96    pub os_architecture: Option<String>,
97    pub temp_directory: Option<String>,
98    pub tools_directory: Option<String>,
99    pub work_folder: Option<String>,
100    pub build_directory: Option<String>,
101}
102
103#[derive(Debug, Clone, Default)]
104pub struct JobStatusContext {
105    pub succeeded: bool,
106    pub failed: bool,
107    pub canceled: bool,
108}
109
110#[derive(Debug, Clone, Default)]
111pub struct StepContext {
112    /// Step outputs
113    pub outputs: HashMap<String, Value>,
114    /// Step status
115    pub status: StepStatusContext,
116}
117
118#[derive(Debug, Clone, Default)]
119pub struct StepStatusContext {
120    pub succeeded: bool,
121    pub failed: bool,
122    pub skipped: bool,
123}
124
125#[derive(Debug, Clone, Default)]
126pub struct DependenciesContext {
127    /// Stage dependencies: dependencies.stageName.outputs.jobName.varName
128    pub stages: HashMap<String, StageDependency>,
129    /// Job dependencies: dependencies.jobName.outputs.varName
130    pub jobs: HashMap<String, JobDependency>,
131}
132
133#[derive(Debug, Clone, Default)]
134pub struct StageDependency {
135    pub outputs: HashMap<String, HashMap<String, Value>>,
136    pub result: String,
137}
138
139#[derive(Debug, Clone, Default)]
140pub struct JobDependency {
141    pub outputs: HashMap<String, Value>,
142    pub result: String,
143}
144
145#[derive(Debug, Clone, Default)]
146pub struct ResourcesContext {
147    pub pipelines: HashMap<String, PipelineResourceContext>,
148    pub repositories: HashMap<String, RepositoryResourceContext>,
149}
150
151#[derive(Debug, Clone, Default)]
152pub struct PipelineResourceContext {
153    pub pipeline_id: Option<String>,
154    pub run_name: Option<String>,
155    pub run_id: Option<String>,
156    pub run_uri: Option<String>,
157    pub source_branch: Option<String>,
158    pub source_commit: Option<String>,
159    pub source_provider: Option<String>,
160    pub requested_for: Option<String>,
161    pub requested_for_id: Option<String>,
162}
163
164#[derive(Debug, Clone, Default)]
165pub struct RepositoryResourceContext {
166    pub name: Option<String>,
167    pub repo_type: Option<String>,
168    pub ref_name: Option<String>,
169    pub version: Option<String>,
170}
171
172/// Expression evaluator
173pub struct Evaluator<'a> {
174    context: &'a ExpressionContext,
175    functions: BuiltinFunctions,
176}
177
178impl<'a> Evaluator<'a> {
179    pub fn new(context: &'a ExpressionContext) -> Self {
180        Self {
181            context,
182            functions: BuiltinFunctions::new(),
183        }
184    }
185
186    /// Evaluate an expression
187    pub fn eval(&self, expr: &Expr) -> Result<Value, EvalError> {
188        match expr {
189            Expr::Null => Ok(Value::Null),
190            Expr::Bool(b) => Ok(Value::Bool(*b)),
191            Expr::Number(n) => Ok(Value::Number(*n)),
192            Expr::String(s) => Ok(Value::String(s.clone())),
193
194            Expr::Reference(reference) => self.eval_reference(reference),
195
196            Expr::FunctionCall { name, args } => self.eval_function(name, args),
197
198            Expr::Index { object, index } => {
199                let obj = self.eval(object)?;
200                let idx = self.eval(index)?;
201                self.eval_index(&obj, &idx)
202            }
203
204            Expr::Member { object, property } => {
205                let obj = self.eval(object)?;
206                self.eval_member(&obj, property)
207            }
208
209            Expr::Unary { op, expr } => {
210                let val = self.eval(expr)?;
211                self.eval_unary(*op, &val)
212            }
213
214            Expr::Binary { op, left, right } => {
215                // Short-circuit evaluation for && and ||
216                match op {
217                    BinaryOp::And => {
218                        let left_val = self.eval(left)?;
219                        if !left_val.is_truthy() {
220                            return Ok(Value::Bool(false));
221                        }
222                        let right_val = self.eval(right)?;
223                        Ok(Value::Bool(right_val.is_truthy()))
224                    }
225                    BinaryOp::Or => {
226                        let left_val = self.eval(left)?;
227                        if left_val.is_truthy() {
228                            return Ok(Value::Bool(true));
229                        }
230                        let right_val = self.eval(right)?;
231                        Ok(Value::Bool(right_val.is_truthy()))
232                    }
233                    _ => {
234                        let left_val = self.eval(left)?;
235                        let right_val = self.eval(right)?;
236                        self.eval_binary(*op, &left_val, &right_val)
237                    }
238                }
239            }
240
241            Expr::Ternary {
242                condition,
243                then_expr,
244                else_expr,
245            } => {
246                let cond = self.eval(condition)?;
247                if cond.is_truthy() {
248                    self.eval(then_expr)
249                } else {
250                    self.eval(else_expr)
251                }
252            }
253
254            Expr::Array(items) => {
255                let values: Result<Vec<Value>, EvalError> =
256                    items.iter().map(|e| self.eval(e)).collect();
257                Ok(Value::Array(values?))
258            }
259
260            Expr::Object(pairs) => {
261                let mut map = HashMap::new();
262                for (key, value_expr) in pairs {
263                    map.insert(key.clone(), self.eval(value_expr)?);
264                }
265                Ok(Value::Object(map))
266            }
267        }
268    }
269
270    fn eval_reference(&self, reference: &Reference) -> Result<Value, EvalError> {
271        let mut current: Option<Value> = None;
272
273        for (i, part) in reference.parts.iter().enumerate() {
274            match part {
275                ReferencePart::Property(name) => {
276                    if i == 0 {
277                        // Top-level context lookup
278                        current = Some(self.lookup_context(name)?);
279                    } else {
280                        let obj = current.ok_or_else(|| EvalError::new("invalid reference"))?;
281                        current = Some(self.eval_member(&obj, name)?);
282                    }
283                }
284                ReferencePart::Index(index_expr) => {
285                    let obj = current.ok_or_else(|| EvalError::new("invalid index access"))?;
286                    let index = self.eval(index_expr)?;
287                    current = Some(self.eval_index(&obj, &index)?);
288                }
289            }
290        }
291
292        current.ok_or_else(|| EvalError::new("empty reference"))
293    }
294
295    fn lookup_context(&self, name: &str) -> Result<Value, EvalError> {
296        // Check for direct parameter match first (iteration variables from ${{ each }}
297        // shadow built-in context names like 'env')
298        if let Some(value) = self.context.parameters.get(name) {
299            // Only shadow built-in contexts for non-context names, OR when the
300            // parameter name matches a built-in context name (iteration variable).
301            // The full context objects "variables" and "parameters" should still
302            // be accessible via their full paths, so only shadow them if the
303            // parameter name is NOT one of the primary context prefixes that
304            // users access with dot-notation (variables.x, parameters.x).
305            let is_primary_context =
306                matches!(name.to_lowercase().as_str(), "variables" | "parameters");
307            if !is_primary_context {
308                return Ok(value.clone());
309            }
310        }
311
312        match name.to_lowercase().as_str() {
313            "variables" => Ok(Value::Object(
314                self.context
315                    .variables
316                    .iter()
317                    .map(|(k, v)| (k.clone(), v.clone()))
318                    .collect(),
319            )),
320            "parameters" => Ok(Value::Object(
321                self.context
322                    .parameters
323                    .iter()
324                    .map(|(k, v)| (k.clone(), v.clone()))
325                    .collect(),
326            )),
327            "pipeline" => self.pipeline_to_value(),
328            "stage" => self.stage_to_value(),
329            "job" => self.job_to_value(),
330            "steps" => Ok(Value::Object(
331                self.context
332                    .steps
333                    .iter()
334                    .map(|(k, v)| (k.clone(), self.step_context_to_value(v)))
335                    .collect(),
336            )),
337            "dependencies" => self.dependencies_to_value(),
338            "stagedependencies" => self.stage_dependencies_to_value(),
339            "env" => Ok(Value::Object(
340                self.context
341                    .env
342                    .iter()
343                    .map(|(k, v)| (k.clone(), v.clone()))
344                    .collect(),
345            )),
346            "resources" => self.resources_to_value(),
347
348            // Direct variable lookup (for $(varName) compatibility)
349            _ => {
350                // First try variables
351                if let Some(value) = self.context.variables.get(name) {
352                    return Ok(value.clone());
353                }
354                // Then try parameters
355                if let Some(value) = self.context.parameters.get(name) {
356                    return Ok(value.clone());
357                }
358                // Return empty string for undefined (Azure DevOps behavior)
359                Ok(Value::String(String::new()))
360            }
361        }
362    }
363
364    fn pipeline_to_value(&self) -> Result<Value, EvalError> {
365        let mut map = HashMap::new();
366        if let Some(name) = &self.context.pipeline.name {
367            map.insert("name".to_string(), Value::String(name.clone()));
368        }
369        if let Some(workspace) = &self.context.pipeline.workspace {
370            map.insert("workspace".to_string(), Value::String(workspace.clone()));
371        }
372        Ok(Value::Object(map))
373    }
374
375    fn stage_to_value(&self) -> Result<Value, EvalError> {
376        let Some(stage) = &self.context.stage else {
377            return Ok(Value::Null);
378        };
379
380        let mut map = HashMap::new();
381        map.insert("name".to_string(), Value::String(stage.name.clone()));
382        if let Some(display_name) = &stage.display_name {
383            map.insert(
384                "displayName".to_string(),
385                Value::String(display_name.clone()),
386            );
387        }
388        Ok(Value::Object(map))
389    }
390
391    fn job_to_value(&self) -> Result<Value, EvalError> {
392        let Some(job) = &self.context.job else {
393            return Ok(Value::Null);
394        };
395
396        let mut map = HashMap::new();
397        map.insert("name".to_string(), Value::String(job.name.clone()));
398        if let Some(display_name) = &job.display_name {
399            map.insert(
400                "displayName".to_string(),
401                Value::String(display_name.clone()),
402            );
403        }
404
405        // Agent sub-object
406        let mut agent = HashMap::new();
407        if let Some(name) = &job.agent.name {
408            agent.insert("name".to_string(), Value::String(name.clone()));
409        }
410        if let Some(os) = &job.agent.os {
411            agent.insert("os".to_string(), Value::String(os.clone()));
412        }
413        map.insert("agent".to_string(), Value::Object(agent));
414
415        Ok(Value::Object(map))
416    }
417
418    fn step_context_to_value(&self, step: &StepContext) -> Value {
419        let mut map = HashMap::new();
420
421        // Outputs
422        let outputs: HashMap<String, Value> = step
423            .outputs
424            .iter()
425            .map(|(k, v)| (k.clone(), v.clone()))
426            .collect();
427        map.insert("outputs".to_string(), Value::Object(outputs));
428
429        Value::Object(map)
430    }
431
432    fn dependencies_to_value(&self) -> Result<Value, EvalError> {
433        let mut map = HashMap::new();
434
435        // Stage dependencies
436        for (name, dep) in &self.context.dependencies.stages {
437            let mut stage_map = HashMap::new();
438            stage_map.insert("result".to_string(), Value::String(dep.result.clone()));
439
440            let mut outputs = HashMap::new();
441            for (job_name, job_outputs) in &dep.outputs {
442                outputs.insert(
443                    job_name.clone(),
444                    Value::Object(
445                        job_outputs
446                            .iter()
447                            .map(|(k, v)| (k.clone(), v.clone()))
448                            .collect(),
449                    ),
450                );
451            }
452            stage_map.insert("outputs".to_string(), Value::Object(outputs));
453
454            map.insert(name.clone(), Value::Object(stage_map));
455        }
456
457        // Job dependencies
458        for (name, dep) in &self.context.dependencies.jobs {
459            let mut job_map = HashMap::new();
460            job_map.insert("result".to_string(), Value::String(dep.result.clone()));
461            job_map.insert(
462                "outputs".to_string(),
463                Value::Object(
464                    dep.outputs
465                        .iter()
466                        .map(|(k, v)| (k.clone(), v.clone()))
467                        .collect(),
468                ),
469            );
470            map.insert(name.clone(), Value::Object(job_map));
471        }
472
473        Ok(Value::Object(map))
474    }
475
476    /// Build stageDependencies value with Azure DevOps nesting:
477    /// stageDependencies.StageName.JobName.outputs['stepName.varName']
478    fn stage_dependencies_to_value(&self) -> Result<Value, EvalError> {
479        let mut map = HashMap::new();
480
481        for (stage_name, dep) in &self.context.dependencies.stages {
482            let mut stage_map = HashMap::new();
483            stage_map.insert("result".to_string(), Value::String(dep.result.clone()));
484
485            // In stageDependencies, jobs are direct children of the stage
486            for (job_name, job_outputs) in &dep.outputs {
487                let mut job_map = HashMap::new();
488                job_map.insert(
489                    "outputs".to_string(),
490                    Value::Object(
491                        job_outputs
492                            .iter()
493                            .map(|(k, v)| (k.clone(), v.clone()))
494                            .collect(),
495                    ),
496                );
497                stage_map.insert(job_name.clone(), Value::Object(job_map));
498            }
499
500            map.insert(stage_name.clone(), Value::Object(stage_map));
501        }
502
503        Ok(Value::Object(map))
504    }
505
506    fn resources_to_value(&self) -> Result<Value, EvalError> {
507        let mut map = HashMap::new();
508
509        // Pipelines
510        let mut pipelines = HashMap::new();
511        for (name, resource) in &self.context.resources.pipelines {
512            let mut resource_map = HashMap::new();
513            if let Some(id) = &resource.pipeline_id {
514                resource_map.insert("pipelineID".to_string(), Value::String(id.clone()));
515            }
516            if let Some(name) = &resource.run_name {
517                resource_map.insert("runName".to_string(), Value::String(name.clone()));
518            }
519            pipelines.insert(name.clone(), Value::Object(resource_map));
520        }
521        map.insert("pipelines".to_string(), Value::Object(pipelines));
522
523        // Repositories
524        let mut repos = HashMap::new();
525        for (name, resource) in &self.context.resources.repositories {
526            let mut resource_map = HashMap::new();
527            if let Some(n) = &resource.name {
528                resource_map.insert("name".to_string(), Value::String(n.clone()));
529            }
530            if let Some(t) = &resource.repo_type {
531                resource_map.insert("type".to_string(), Value::String(t.clone()));
532            }
533            repos.insert(name.clone(), Value::Object(resource_map));
534        }
535        map.insert("repositories".to_string(), Value::Object(repos));
536
537        Ok(Value::Object(map))
538    }
539
540    fn eval_function(&self, name: &str, args: &[Expr]) -> Result<Value, EvalError> {
541        let evaluated_args: Result<Vec<Value>, EvalError> =
542            args.iter().map(|a| self.eval(a)).collect();
543        self.functions.call(name, evaluated_args?, self.context)
544    }
545
546    fn eval_index(&self, object: &Value, index: &Value) -> Result<Value, EvalError> {
547        match (object, index) {
548            (Value::Array(arr), Value::Number(n)) => {
549                let i = *n as usize;
550                arr.get(i)
551                    .cloned()
552                    .ok_or_else(|| EvalError::new(format!("array index {} out of bounds", i)))
553            }
554            (Value::Object(map), Value::String(key)) => {
555                Ok(map.get(key).cloned().unwrap_or(Value::Null))
556            }
557            (Value::Object(map), Value::Number(n)) => {
558                let key = n.to_string();
559                Ok(map.get(&key).cloned().unwrap_or(Value::Null))
560            }
561            (Value::String(s), Value::Number(n)) => {
562                let i = *n as usize;
563                s.chars()
564                    .nth(i)
565                    .map(|c| Value::String(c.to_string()))
566                    .ok_or_else(|| EvalError::new(format!("string index {} out of bounds", i)))
567            }
568            _ => Err(EvalError::new(format!(
569                "cannot index {:?} with {:?}",
570                object, index
571            ))),
572        }
573    }
574
575    fn eval_member(&self, object: &Value, property: &str) -> Result<Value, EvalError> {
576        match object {
577            Value::Object(map) => Ok(map.get(property).cloned().unwrap_or(Value::Null)),
578            Value::Array(arr) if property == "length" => Ok(Value::Number(arr.len() as f64)),
579            Value::String(s) if property == "length" => Ok(Value::Number(s.len() as f64)),
580            _ => Err(EvalError::new(format!(
581                "cannot access property '{}' on {:?}",
582                property, object
583            ))),
584        }
585    }
586
587    fn eval_unary(&self, op: UnaryOp, value: &Value) -> Result<Value, EvalError> {
588        match op {
589            UnaryOp::Not => Ok(Value::Bool(!value.is_truthy())),
590            UnaryOp::Neg => match value {
591                Value::Number(n) => Ok(Value::Number(-n)),
592                _ => Err(EvalError::new("cannot negate non-number")),
593            },
594        }
595    }
596
597    fn eval_binary(&self, op: BinaryOp, left: &Value, right: &Value) -> Result<Value, EvalError> {
598        match op {
599            // Arithmetic
600            BinaryOp::Add => self.eval_add(left, right),
601            BinaryOp::Sub => self.eval_numeric_op(left, right, |a, b| a - b),
602            BinaryOp::Mul => self.eval_numeric_op(left, right, |a, b| a * b),
603            BinaryOp::Div => self.eval_numeric_op(left, right, |a, b| a / b),
604            BinaryOp::Mod => self.eval_numeric_op(left, right, |a, b| a % b),
605
606            // Comparison
607            BinaryOp::Eq => Ok(Value::Bool(self.values_equal(left, right))),
608            BinaryOp::Ne => Ok(Value::Bool(!self.values_equal(left, right))),
609            BinaryOp::Lt => self.eval_comparison(left, right, |a, b| a < b),
610            BinaryOp::Le => self.eval_comparison(left, right, |a, b| a <= b),
611            BinaryOp::Gt => self.eval_comparison(left, right, |a, b| a > b),
612            BinaryOp::Ge => self.eval_comparison(left, right, |a, b| a >= b),
613
614            // Logical (handled in eval() for short-circuit)
615            BinaryOp::And | BinaryOp::Or => unreachable!("handled in eval()"),
616        }
617    }
618
619    fn eval_add(&self, left: &Value, right: &Value) -> Result<Value, EvalError> {
620        match (left, right) {
621            (Value::Number(a), Value::Number(b)) => Ok(Value::Number(a + b)),
622            (Value::String(a), Value::String(b)) => Ok(Value::String(format!("{}{}", a, b))),
623            (Value::String(a), b) => Ok(Value::String(format!("{}{}", a, b.as_string()))),
624            (a, Value::String(b)) => Ok(Value::String(format!("{}{}", a.as_string(), b))),
625            _ => Err(EvalError::new("cannot add these types")),
626        }
627    }
628
629    fn eval_numeric_op<F>(&self, left: &Value, right: &Value, op: F) -> Result<Value, EvalError>
630    where
631        F: FnOnce(f64, f64) -> f64,
632    {
633        let a = left
634            .as_number()
635            .ok_or_else(|| EvalError::new("left operand is not a number"))?;
636        let b = right
637            .as_number()
638            .ok_or_else(|| EvalError::new("right operand is not a number"))?;
639        Ok(Value::Number(op(a, b)))
640    }
641
642    fn eval_comparison<F>(&self, left: &Value, right: &Value, op: F) -> Result<Value, EvalError>
643    where
644        F: FnOnce(f64, f64) -> bool,
645    {
646        let a = left
647            .as_number()
648            .ok_or_else(|| EvalError::new("left operand is not comparable"))?;
649        let b = right
650            .as_number()
651            .ok_or_else(|| EvalError::new("right operand is not comparable"))?;
652        Ok(Value::Bool(op(a, b)))
653    }
654
655    fn values_equal(&self, left: &Value, right: &Value) -> bool {
656        match (left, right) {
657            (Value::Null, Value::Null) => true,
658            (Value::Bool(a), Value::Bool(b)) => a == b,
659            (Value::Number(a), Value::Number(b)) => (a - b).abs() < f64::EPSILON,
660            (Value::String(a), Value::String(b)) => a.to_lowercase() == b.to_lowercase(),
661            // Coerce for comparison
662            (Value::Number(a), Value::String(b)) | (Value::String(b), Value::Number(a)) => b
663                .parse::<f64>()
664                .map(|n| (a - n).abs() < f64::EPSILON)
665                .unwrap_or(false),
666            (Value::Bool(a), Value::String(b)) | (Value::String(b), Value::Bool(a)) => {
667                let b_lower = b.to_lowercase();
668                (*a && b_lower == "true") || (!*a && b_lower == "false")
669            }
670            _ => false,
671        }
672    }
673}
674
675/// High-level expression engine
676pub struct ExpressionEngine {
677    context: ExpressionContext,
678}
679
680impl ExpressionEngine {
681    pub fn new(context: ExpressionContext) -> Self {
682        Self { context }
683    }
684
685    /// Evaluate a compile-time expression: ${{ expression }}
686    pub fn evaluate_compile_time(&self, expr: &str) -> Result<Value, EvalError> {
687        use crate::expression::parser::ExprParser;
688
689        let ast = ExprParser::parse_str(expr)
690            .map_err(|e| EvalError::new(format!("parse error: {}", e)))?;
691
692        let evaluator = Evaluator::new(&self.context);
693        evaluator.eval(&ast)
694    }
695
696    /// Evaluate a runtime expression: $[ expression ]
697    pub fn evaluate_runtime(&self, expr: &str) -> Result<Value, EvalError> {
698        // Runtime expressions have the same syntax as compile-time
699        self.evaluate_compile_time(expr)
700    }
701
702    /// Substitute macro variables: $(variableName)
703    pub fn substitute_macros(&self, text: &str) -> Result<String, EvalError> {
704        use crate::expression::lexer::{extract_expressions, ExpressionType};
705
706        let expressions = extract_expressions(text);
707        let mut result = String::new();
708
709        for expr in expressions {
710            match expr {
711                ExpressionType::Text(s) => result.push_str(&s),
712                ExpressionType::Macro(var_path) => {
713                    let value = self.resolve_variable_path(&var_path)?;
714                    result.push_str(&value.as_string());
715                }
716                ExpressionType::CompileTime(expr) => {
717                    let value = self.evaluate_compile_time(&expr)?;
718                    result.push_str(&value.as_string());
719                }
720                ExpressionType::Runtime(expr) => {
721                    let value = self.evaluate_runtime(&expr)?;
722                    result.push_str(&value.as_string());
723                }
724            }
725        }
726
727        Ok(result)
728    }
729
730    fn resolve_variable_path(&self, path: &str) -> Result<Value, EvalError> {
731        // Handle dotted paths like Build.SourceBranch or simple names like foo
732        let parts: Vec<&str> = path.split('.').collect();
733
734        if parts.len() == 1 {
735            // Simple variable lookup
736            if let Some(value) = self.context.variables.get(parts[0]) {
737                return Ok(value.clone());
738            }
739            if let Some(value) = self.context.parameters.get(parts[0]) {
740                return Ok(value.clone());
741            }
742            // Return empty string for undefined variables
743            return Ok(Value::String(String::new()));
744        }
745
746        // Handle prefixed lookups like variables.foo
747        let prefix = parts[0].to_lowercase();
748        let rest = &parts[1..];
749
750        match prefix.as_str() {
751            "variables" => {
752                let var_name = rest.join(".");
753                Ok(self
754                    .context
755                    .variables
756                    .get(&var_name)
757                    .cloned()
758                    .unwrap_or(Value::String(String::new())))
759            }
760            "parameters" => {
761                let param_name = rest.join(".");
762                Ok(self
763                    .context
764                    .parameters
765                    .get(&param_name)
766                    .cloned()
767                    .unwrap_or(Value::Null))
768            }
769            "env" => {
770                let env_name = rest.join(".");
771                Ok(self
772                    .context
773                    .env
774                    .get(&env_name)
775                    .cloned()
776                    .unwrap_or(Value::String(String::new())))
777            }
778            _ => {
779                // Try as a full dotted variable name (e.g., Build.SourceBranch)
780                if let Some(value) = self.context.variables.get(path) {
781                    return Ok(value.clone());
782                }
783                Ok(Value::String(String::new()))
784            }
785        }
786    }
787
788    /// Get the context for modification
789    pub fn context_mut(&mut self) -> &mut ExpressionContext {
790        &mut self.context
791    }
792
793    /// Get the context
794    pub fn context(&self) -> &ExpressionContext {
795        &self.context
796    }
797}
798
799#[cfg(test)]
800mod tests {
801    use super::*;
802
803    fn make_context() -> ExpressionContext {
804        let mut ctx = ExpressionContext::default();
805        ctx.variables
806            .insert("foo".to_string(), Value::String("bar".to_string()));
807        ctx.variables.insert("num".to_string(), Value::Number(42.0));
808        ctx.variables.insert(
809            "Build.SourceBranch".to_string(),
810            Value::String("refs/heads/main".to_string()),
811        );
812        ctx.parameters
813            .insert("config".to_string(), Value::String("Release".to_string()));
814        ctx
815    }
816
817    #[test]
818    fn test_eval_literals() {
819        let engine = ExpressionEngine::new(ExpressionContext::default());
820
821        assert_eq!(engine.evaluate_compile_time("null").unwrap(), Value::Null);
822        assert_eq!(
823            engine.evaluate_compile_time("true").unwrap(),
824            Value::Bool(true)
825        );
826        assert_eq!(
827            engine.evaluate_compile_time("42").unwrap(),
828            Value::Number(42.0)
829        );
830        assert_eq!(
831            engine.evaluate_compile_time("'hello'").unwrap(),
832            Value::String("hello".to_string())
833        );
834    }
835
836    #[test]
837    fn test_eval_variable_reference() {
838        let engine = ExpressionEngine::new(make_context());
839
840        assert_eq!(
841            engine.evaluate_compile_time("variables.foo").unwrap(),
842            Value::String("bar".to_string())
843        );
844        assert_eq!(
845            engine.evaluate_compile_time("variables['foo']").unwrap(),
846            Value::String("bar".to_string())
847        );
848    }
849
850    #[test]
851    fn test_eval_parameter_reference() {
852        let engine = ExpressionEngine::new(make_context());
853
854        assert_eq!(
855            engine.evaluate_compile_time("parameters.config").unwrap(),
856            Value::String("Release".to_string())
857        );
858    }
859
860    #[test]
861    fn test_eval_comparison() {
862        let engine = ExpressionEngine::new(make_context());
863
864        assert_eq!(
865            engine
866                .evaluate_compile_time("variables.foo == 'bar'")
867                .unwrap(),
868            Value::Bool(true)
869        );
870        assert_eq!(
871            engine.evaluate_compile_time("variables.num > 40").unwrap(),
872            Value::Bool(true)
873        );
874    }
875
876    #[test]
877    fn test_eval_logical() {
878        let engine = ExpressionEngine::new(make_context());
879
880        assert_eq!(
881            engine.evaluate_compile_time("true && true").unwrap(),
882            Value::Bool(true)
883        );
884        assert_eq!(
885            engine.evaluate_compile_time("true && false").unwrap(),
886            Value::Bool(false)
887        );
888        assert_eq!(
889            engine.evaluate_compile_time("false || true").unwrap(),
890            Value::Bool(true)
891        );
892        assert_eq!(
893            engine.evaluate_compile_time("!false").unwrap(),
894            Value::Bool(true)
895        );
896    }
897
898    #[test]
899    fn test_eval_ternary() {
900        let engine = ExpressionEngine::new(make_context());
901
902        assert_eq!(
903            engine.evaluate_compile_time("true ? 'yes' : 'no'").unwrap(),
904            Value::String("yes".to_string())
905        );
906        assert_eq!(
907            engine
908                .evaluate_compile_time("false ? 'yes' : 'no'")
909                .unwrap(),
910            Value::String("no".to_string())
911        );
912    }
913
914    #[test]
915    fn test_substitute_macros() {
916        let engine = ExpressionEngine::new(make_context());
917
918        assert_eq!(
919            engine.substitute_macros("Value: $(foo)").unwrap(),
920            "Value: bar"
921        );
922        assert_eq!(
923            engine
924                .substitute_macros("Branch: $(Build.SourceBranch)")
925                .unwrap(),
926            "Branch: refs/heads/main"
927        );
928    }
929
930    #[test]
931    fn test_substitute_mixed() {
932        let engine = ExpressionEngine::new(make_context());
933
934        assert_eq!(
935            engine
936                .substitute_macros("Config: ${{ parameters.config }} on $(Build.SourceBranch)")
937                .unwrap(),
938            "Config: Release on refs/heads/main"
939        );
940    }
941
942    #[test]
943    fn test_undefined_variable() {
944        let engine = ExpressionEngine::new(make_context());
945
946        // Undefined variables return empty string
947        assert_eq!(engine.substitute_macros("$(undefined)").unwrap(), "");
948    }
949}