Skip to main content

pipeline_service/execution/
context.rs

1// Runtime Execution Context
2// Manages execution state and context for expression evaluation
3
4use crate::expression::{
5    DependenciesContext, ExpressionContext, ExpressionEngine, JobContext, JobDependency,
6    JobStatusContext, PipelineContext, StageContext, StageDependency, StepContext,
7    StepStatusContext,
8};
9use crate::parser::models::{
10    ExecutionContext, Job, JobResult, JobStatus, Pipeline, Stage, StageResult, StageStatus,
11    StepResult, StepStatus, Value, Variable,
12};
13
14use std::collections::HashMap;
15
16/// Runtime context during pipeline execution
17#[derive(Debug, Clone)]
18pub struct RuntimeContext {
19    /// Base execution context (pipeline name, working dir, etc.)
20    pub base: ExecutionContext,
21
22    /// Current stage being executed
23    pub current_stage: Option<String>,
24
25    /// Current job being executed
26    pub current_job: Option<String>,
27
28    /// Completed stage results
29    pub stage_results: HashMap<String, StageResult>,
30
31    /// Completed job results (indexed by "stage.job" or just "job" for implicit stage)
32    pub job_results: HashMap<String, JobResult>,
33
34    /// Current job's step results
35    pub step_results: Vec<StepResult>,
36
37    /// All variables (merged from pipeline, stage, job levels)
38    pub variables: HashMap<String, Value>,
39
40    /// All parameters
41    pub parameters: HashMap<String, Value>,
42
43    /// Environment variables
44    pub env: HashMap<String, Value>,
45
46    /// Output variables from steps (step_name -> output_name -> value)
47    pub step_outputs: HashMap<String, HashMap<String, Value>>,
48}
49
50impl RuntimeContext {
51    /// Create a new runtime context from base execution context
52    pub fn new(base: ExecutionContext) -> Self {
53        let variables: HashMap<String, Value> = base
54            .variables
55            .iter()
56            .map(|(k, v)| (k.clone(), Value::String(v.clone())))
57            .collect();
58
59        let parameters: HashMap<String, Value> = base
60            .parameters
61            .iter()
62            .map(|(k, v)| (k.clone(), yaml_to_value(v)))
63            .collect();
64
65        Self {
66            base,
67            current_stage: None,
68            current_job: None,
69            stage_results: HashMap::new(),
70            job_results: HashMap::new(),
71            step_results: Vec::new(),
72            variables,
73            parameters,
74            env: HashMap::new(),
75            step_outputs: HashMap::new(),
76        }
77    }
78
79    /// Create a new runtime context from a pipeline
80    pub fn from_pipeline(pipeline: &Pipeline, working_dir: String) -> Self {
81        let base = ExecutionContext::new(
82            pipeline
83                .name
84                .clone()
85                .unwrap_or_else(|| "unnamed".to_string()),
86            working_dir,
87        );
88
89        let mut ctx = Self::new(base);
90
91        // Merge pipeline-level variables
92        ctx.merge_variables(&pipeline.variables);
93
94        // Merge pipeline-level parameters
95        for param in &pipeline.parameters {
96            if let Some(default) = &param.default {
97                ctx.parameters
98                    .entry(param.name.clone())
99                    .or_insert_with(|| yaml_to_value(default));
100            }
101        }
102
103        ctx
104    }
105
106    /// Enter a stage (set current stage and merge variables)
107    pub fn enter_stage(&mut self, stage: &Stage) {
108        self.current_stage = stage.stage.clone();
109        self.current_job = None;
110        self.step_results.clear();
111        self.step_outputs.clear();
112
113        // Merge stage-level variables
114        self.merge_variables(&stage.variables);
115    }
116
117    /// Exit current stage with result
118    pub fn exit_stage(&mut self, result: StageResult) {
119        if let Some(stage_name) = self.current_stage.take() {
120            self.stage_results.insert(stage_name, result);
121        }
122    }
123
124    /// Enter a job (set current job and merge variables)
125    pub fn enter_job(&mut self, job: &Job) {
126        self.current_job = job.identifier().map(|s| s.to_string());
127        self.step_results.clear();
128        self.step_outputs.clear();
129
130        // Merge job-level variables
131        self.merge_variables(&job.variables);
132    }
133
134    /// Exit current job with result
135    pub fn exit_job(&mut self, result: JobResult) {
136        let key = match (&self.current_stage, &self.current_job) {
137            (Some(stage), Some(job)) => format!("{}.{}", stage, job),
138            (None, Some(job)) => job.clone(),
139            _ => return,
140        };
141        self.job_results.insert(key, result);
142        self.current_job = None;
143    }
144
145    /// Record a step result
146    pub fn record_step_result(&mut self, result: StepResult) {
147        // Store step outputs
148        if let Some(step_name) = &result.step_name {
149            if !result.outputs.is_empty() {
150                self.step_outputs.insert(
151                    step_name.clone(),
152                    result
153                        .outputs
154                        .iter()
155                        .map(|(k, v)| (k.clone(), Value::String(v.clone())))
156                        .collect(),
157                );
158            }
159        }
160
161        self.step_results.push(result);
162    }
163
164    /// Set a variable during execution
165    pub fn set_variable(&mut self, name: String, value: Value) {
166        self.variables.insert(name, value);
167    }
168
169    /// Set an output variable for the current step
170    pub fn set_step_output(&mut self, step_name: String, output_name: String, value: Value) {
171        self.step_outputs
172            .entry(step_name)
173            .or_default()
174            .insert(output_name, value);
175    }
176
177    /// Set an environment variable
178    pub fn set_env(&mut self, name: String, value: Value) {
179        self.env.insert(name, value);
180    }
181
182    /// Merge variables from a variable list
183    fn merge_variables(&mut self, variables: &[Variable]) {
184        for var in variables {
185            match var {
186                Variable::KeyValue { name, value, .. } => {
187                    let trimmed = value.trim();
188                    if trimmed.starts_with("$[") && trimmed.ends_with(']') {
189                        // Runtime expression ($[...]): evaluate the inner expression
190                        let inner = &trimmed[2..trimmed.len() - 1];
191                        let engine = self.expression_engine();
192                        match engine.evaluate_runtime(inner) {
193                            Ok(result) => {
194                                self.variables.insert(name.clone(), result);
195                            }
196                            Err(_) => {
197                                // If evaluation fails, store the raw string
198                                self.variables
199                                    .insert(name.clone(), Value::String(value.clone()));
200                            }
201                        }
202                    } else if trimmed.starts_with("${{") && trimmed.ends_with("}}") {
203                        // Compile-time expression (${{ expr }}): evaluate it now since
204                        // template resolution may not have processed pipeline-level variables.
205                        let inner = &trimmed[3..trimmed.len() - 2].trim();
206                        let engine = self.expression_engine();
207                        match engine.evaluate_compile_time(inner) {
208                            Ok(result) => {
209                                self.variables.insert(name.clone(), result);
210                            }
211                            Err(_) => {
212                                self.variables
213                                    .insert(name.clone(), Value::String(value.clone()));
214                            }
215                        }
216                    } else if trimmed.contains("${{") {
217                        // Value contains inline compile-time expressions; use substitute_macros
218                        // which handles ${{ }}, $[ ], and $() patterns within a string.
219                        let engine = self.expression_engine();
220                        match engine.substitute_macros(trimmed) {
221                            Ok(result) => {
222                                self.variables.insert(name.clone(), Value::String(result));
223                            }
224                            Err(_) => {
225                                self.variables
226                                    .insert(name.clone(), Value::String(value.clone()));
227                            }
228                        }
229                    } else {
230                        self.variables
231                            .insert(name.clone(), Value::String(value.clone()));
232                    }
233                }
234                Variable::Group { .. } => {
235                    // Variable groups would need to be resolved from external source
236                    // For now, skip them
237                }
238                Variable::Template { .. } => {
239                    // Template variables would be expanded earlier
240                    // For now, skip them
241                }
242            }
243        }
244    }
245
246    /// Merge pipeline-level variables (public entry point for the executor)
247    pub fn merge_pipeline_variables(&mut self, variables: &[Variable]) {
248        self.merge_variables(variables);
249    }
250
251    /// Build an ExpressionContext for evaluating conditions
252    pub fn to_expression_context(&self) -> ExpressionContext {
253        let mut ctx = ExpressionContext {
254            variables: self.variables.clone(),
255            parameters: self.parameters.clone(),
256            pipeline: PipelineContext {
257                name: Some(self.base.pipeline_name.clone()),
258                workspace: Some(self.base.working_dir.clone()),
259            },
260            ..Default::default()
261        };
262
263        // Stage context
264        if let Some(stage_name) = &self.current_stage {
265            ctx.stage = Some(StageContext {
266                name: stage_name.clone(),
267                display_name: None, // Could be enhanced
268            });
269        }
270
271        // Job context
272        if let Some(job_name) = &self.current_job {
273            ctx.job = Some(JobContext {
274                name: job_name.clone(),
275                display_name: None,
276                agent: Default::default(),
277                status: self.current_job_status(),
278            });
279        }
280
281        // Step outputs
282        for (step_name, outputs) in &self.step_outputs {
283            let step_status = self
284                .step_results
285                .iter()
286                .find(|r| r.step_name.as_deref() == Some(step_name))
287                .map(|r| StepStatusContext {
288                    succeeded: r.status == StepStatus::Succeeded
289                        || r.status == StepStatus::SucceededWithIssues,
290                    failed: r.status == StepStatus::Failed,
291                    skipped: r.status == StepStatus::Skipped,
292                })
293                .unwrap_or_default();
294
295            ctx.steps.insert(
296                step_name.clone(),
297                StepContext {
298                    outputs: outputs.clone(),
299                    status: step_status,
300                },
301            );
302        }
303
304        // Dependencies
305        ctx.dependencies = self.build_dependencies_context();
306
307        // Environment
308        ctx.env = self.env.clone();
309
310        ctx
311    }
312
313    /// Create an expression engine with current context
314    pub fn expression_engine(&self) -> ExpressionEngine {
315        ExpressionEngine::new(self.to_expression_context())
316    }
317
318    /// Evaluate a condition expression
319    pub fn evaluate_condition(&self, condition: &str) -> Result<bool, String> {
320        let engine = self.expression_engine();
321        engine
322            .evaluate_runtime(condition)
323            .map(|v| v.is_truthy())
324            .map_err(|e| e.message)
325    }
326
327    /// Substitute variables in a string ($(var) syntax)
328    pub fn substitute_variables(&self, text: &str) -> Result<String, String> {
329        let engine = self.expression_engine();
330        engine.substitute_macros(text).map_err(|e| e.message)
331    }
332
333    /// Get current job status context
334    fn current_job_status(&self) -> JobStatusContext {
335        // Determine job status based on step results
336        let has_failed = self
337            .step_results
338            .iter()
339            .any(|r| r.status == StepStatus::Failed);
340
341        JobStatusContext {
342            succeeded: !has_failed && !self.step_results.is_empty(),
343            failed: has_failed,
344            canceled: false, // Would need cancellation tracking
345        }
346    }
347
348    /// Build dependencies context from completed stages/jobs
349    fn build_dependencies_context(&self) -> DependenciesContext {
350        let mut ctx = DependenciesContext::default();
351
352        // Stage dependencies
353        for (stage_name, result) in &self.stage_results {
354            let mut outputs = HashMap::new();
355
356            // Collect job outputs within this stage
357            for (job_key, job_result) in &self.job_results {
358                if job_key.starts_with(&format!("{}.", stage_name)) {
359                    let job_name = job_key.strip_prefix(&format!("{}.", stage_name)).unwrap();
360                    outputs.insert(
361                        job_name.to_string(),
362                        job_result
363                            .outputs
364                            .iter()
365                            .map(|(k, v)| (k.clone(), Value::String(v.clone())))
366                            .collect(),
367                    );
368                }
369            }
370
371            ctx.stages.insert(
372                stage_name.clone(),
373                StageDependency {
374                    outputs,
375                    result: status_to_string(&result.status),
376                },
377            );
378        }
379
380        // Job dependencies (within current stage)
381        if let Some(current_stage) = &self.current_stage {
382            for (job_key, job_result) in &self.job_results {
383                // Only include jobs from current stage for job-level dependencies
384                if let Some(job_name) = job_key.strip_prefix(&format!("{}.", current_stage)) {
385                    ctx.jobs.insert(
386                        job_name.to_string(),
387                        JobDependency {
388                            outputs: job_result
389                                .outputs
390                                .iter()
391                                .map(|(k, v)| (k.clone(), Value::String(v.clone())))
392                                .collect(),
393                            result: job_status_to_string(&job_result.status),
394                        },
395                    );
396                }
397            }
398        }
399
400        ctx
401    }
402
403    /// Check if all dependencies succeeded
404    pub fn dependencies_succeeded(&self, deps: &[String], is_stage: bool) -> bool {
405        if is_stage {
406            deps.iter().all(|dep| {
407                self.stage_results
408                    .get(dep)
409                    .map(|r| {
410                        r.status == StageStatus::Succeeded
411                            || r.status == StageStatus::SucceededWithIssues
412                    })
413                    .unwrap_or(false)
414            })
415        } else {
416            // Job dependencies (within current stage)
417            let stage_prefix = self
418                .current_stage
419                .as_ref()
420                .map(|s| format!("{}.", s))
421                .unwrap_or_default();
422
423            deps.iter().all(|dep| {
424                let key = format!("{}{}", stage_prefix, dep);
425                self.job_results
426                    .get(&key)
427                    .map(|r| {
428                        r.status == JobStatus::Succeeded
429                            || r.status == JobStatus::SucceededWithIssues
430                    })
431                    .unwrap_or(false)
432            })
433        }
434    }
435
436    /// Get environment variables as string map for process execution
437    pub fn env_as_strings(&self) -> HashMap<String, String> {
438        let mut env = HashMap::new();
439
440        // Add base environment
441        for (k, v) in &self.base.env {
442            env.insert(k.clone(), v.clone());
443        }
444
445        // Add context environment
446        for (k, v) in &self.env {
447            env.insert(k.clone(), v.as_string());
448        }
449
450        // Add common Azure DevOps variables
451        env.insert(
452            "BUILD_SOURCESDIRECTORY".to_string(),
453            self.base.working_dir.clone(),
454        );
455        env.insert(
456            "SYSTEM_DEFAULTWORKINGDIRECTORY".to_string(),
457            self.base.working_dir.clone(),
458        );
459        env.insert(
460            "PIPELINE_WORKSPACE".to_string(),
461            self.base.working_dir.clone(),
462        );
463
464        if let Some(stage) = &self.current_stage {
465            env.insert("SYSTEM_STAGENAME".to_string(), stage.clone());
466            env.insert("SYSTEM_STAGEDISPLAYNAME".to_string(), stage.clone());
467        }
468
469        if let Some(job) = &self.current_job {
470            env.insert("SYSTEM_JOBNAME".to_string(), job.clone());
471            env.insert("SYSTEM_JOBDISPLAYNAME".to_string(), job.clone());
472        }
473
474        env
475    }
476}
477
478/// Convert serde_yaml::Value to our Value type
479fn yaml_to_value(yaml: &serde_yaml::Value) -> Value {
480    match yaml {
481        serde_yaml::Value::Null => Value::Null,
482        serde_yaml::Value::Bool(b) => Value::Bool(*b),
483        serde_yaml::Value::Number(n) => {
484            Value::Number(n.as_f64().unwrap_or(n.as_i64().unwrap_or(0) as f64))
485        }
486        serde_yaml::Value::String(s) => Value::String(s.clone()),
487        serde_yaml::Value::Sequence(seq) => Value::Array(seq.iter().map(yaml_to_value).collect()),
488        serde_yaml::Value::Mapping(map) => Value::Object(
489            map.iter()
490                .filter_map(|(k, v)| k.as_str().map(|key| (key.to_string(), yaml_to_value(v))))
491                .collect(),
492        ),
493        serde_yaml::Value::Tagged(_) => Value::Null,
494    }
495}
496
497fn status_to_string(status: &StageStatus) -> String {
498    match status {
499        StageStatus::Succeeded => "Succeeded".to_string(),
500        StageStatus::SucceededWithIssues => "SucceededWithIssues".to_string(),
501        StageStatus::Failed => "Failed".to_string(),
502        StageStatus::Canceled => "Canceled".to_string(),
503        StageStatus::Skipped => "Skipped".to_string(),
504        StageStatus::Pending | StageStatus::Running => "InProgress".to_string(),
505    }
506}
507
508fn job_status_to_string(status: &JobStatus) -> String {
509    match status {
510        JobStatus::Succeeded => "Succeeded".to_string(),
511        JobStatus::SucceededWithIssues => "SucceededWithIssues".to_string(),
512        JobStatus::Failed => "Failed".to_string(),
513        JobStatus::Canceled => "Canceled".to_string(),
514        JobStatus::Skipped => "Skipped".to_string(),
515        JobStatus::Pending | JobStatus::Running => "InProgress".to_string(),
516    }
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522    use std::time::Duration;
523
524    #[test]
525    fn test_runtime_context_creation() {
526        let base = ExecutionContext::new("test-pipeline".to_string(), "/work".to_string());
527        let ctx = RuntimeContext::new(base);
528
529        assert_eq!(ctx.base.pipeline_name, "test-pipeline");
530        assert_eq!(ctx.base.working_dir, "/work");
531        assert!(ctx.current_stage.is_none());
532        assert!(ctx.current_job.is_none());
533    }
534
535    #[test]
536    fn test_enter_exit_stage() {
537        let base = ExecutionContext::new("test".to_string(), "/work".to_string());
538        let mut ctx = RuntimeContext::new(base);
539
540        let stage = Stage {
541            stage: Some("Build".to_string()),
542            display_name: None,
543            depends_on: Default::default(),
544            condition: None,
545            variables: vec![Variable::KeyValue {
546                name: "stage_var".to_string(),
547                value: "stage_value".to_string(),
548                readonly: false,
549            }],
550            jobs: Vec::new(),
551            lock_behavior: None,
552            template: None,
553            parameters: HashMap::new(),
554            pool: None,
555            has_template_directives: false,
556        };
557
558        ctx.enter_stage(&stage);
559        assert_eq!(ctx.current_stage, Some("Build".to_string()));
560        assert_eq!(
561            ctx.variables.get("stage_var"),
562            Some(&Value::String("stage_value".to_string()))
563        );
564
565        let result = StageResult {
566            stage_name: "Build".to_string(),
567            display_name: None,
568            status: StageStatus::Succeeded,
569            jobs: Vec::new(),
570            duration: Duration::from_secs(10),
571        };
572
573        ctx.exit_stage(result);
574        assert!(ctx.current_stage.is_none());
575        assert!(ctx.stage_results.contains_key("Build"));
576    }
577
578    #[test]
579    fn test_evaluate_condition() {
580        let mut base = ExecutionContext::new("test".to_string(), "/work".to_string());
581        base.variables
582            .insert("isRelease".to_string(), "true".to_string());
583
584        let ctx = RuntimeContext::new(base);
585
586        assert!(ctx
587            .evaluate_condition("eq(variables.isRelease, 'true')")
588            .unwrap());
589        assert!(!ctx
590            .evaluate_condition("eq(variables.isRelease, 'false')")
591            .unwrap());
592    }
593
594    #[test]
595    fn test_substitute_variables() {
596        let mut base = ExecutionContext::new("test".to_string(), "/work".to_string());
597        base.variables
598            .insert("version".to_string(), "1.0.0".to_string());
599
600        let ctx = RuntimeContext::new(base);
601
602        let result = ctx.substitute_variables("Version: $(version)").unwrap();
603        assert_eq!(result, "Version: 1.0.0");
604    }
605
606    #[test]
607    fn test_step_outputs() {
608        let base = ExecutionContext::new("test".to_string(), "/work".to_string());
609        let mut ctx = RuntimeContext::new(base);
610
611        ctx.set_step_output(
612            "GetVersion".to_string(),
613            "version".to_string(),
614            Value::String("2.0.0".to_string()),
615        );
616
617        let expr_ctx = ctx.to_expression_context();
618        let step_ctx = expr_ctx.steps.get("GetVersion").unwrap();
619        assert_eq!(
620            step_ctx.outputs.get("version"),
621            Some(&Value::String("2.0.0".to_string()))
622        );
623    }
624
625    #[test]
626    fn test_dependencies_succeeded() {
627        let base = ExecutionContext::new("test".to_string(), "/work".to_string());
628        let mut ctx = RuntimeContext::new(base);
629
630        // Add a completed stage
631        ctx.stage_results.insert(
632            "Build".to_string(),
633            StageResult {
634                stage_name: "Build".to_string(),
635                display_name: None,
636                status: StageStatus::Succeeded,
637                jobs: Vec::new(),
638                duration: Duration::from_secs(10),
639            },
640        );
641
642        assert!(ctx.dependencies_succeeded(&["Build".to_string()], true));
643        assert!(!ctx.dependencies_succeeded(&["Test".to_string()], true));
644    }
645}