Skip to main content

pipeline_service/parser/
models.rs

1// Azure DevOps Pipeline Data Models
2// Comprehensive types representing the full Azure DevOps YAML schema
3
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::time::Duration;
7
8/// A value that can be either a boolean literal or a runtime expression string.
9/// Azure DevOps allows fields like `continueOnError` to use runtime expressions
10/// such as `$[eq(variables.rustToolchain, 'nightly')]`.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12#[serde(untagged)]
13pub enum BoolOrExpression {
14    Bool(bool),
15    Expression(String),
16}
17
18impl Default for BoolOrExpression {
19    fn default() -> Self {
20        BoolOrExpression::Bool(false)
21    }
22}
23
24impl BoolOrExpression {
25    /// Returns the boolean value if this is a literal bool, or false for expressions
26    /// (expressions must be evaluated at runtime).
27    pub fn as_bool(&self) -> bool {
28        match self {
29            BoolOrExpression::Bool(b) => *b,
30            BoolOrExpression::Expression(_) => false,
31        }
32    }
33}
34
35/// Root pipeline structure supporting all Azure DevOps formats
36#[derive(Debug, Clone, Serialize, Deserialize, Default)]
37#[serde(rename_all = "camelCase")]
38pub struct Pipeline {
39    /// Pipeline name
40    pub name: Option<String>,
41
42    /// CI trigger configuration
43    pub trigger: Option<Trigger>,
44
45    /// PR trigger configuration
46    pub pr: Option<PrTrigger>,
47
48    /// Scheduled triggers
49    pub schedules: Option<Vec<Schedule>>,
50
51    /// Resource definitions (repositories, containers, pipelines, packages)
52    pub resources: Option<Resources>,
53
54    /// Pipeline-level variables
55    #[serde(default, deserialize_with = "deserialize_variables")]
56    pub variables: Vec<Variable>,
57
58    /// Pipeline parameters (template inputs)
59    #[serde(default)]
60    pub parameters: Vec<Parameter>,
61
62    /// Full pipeline structure with stages
63    #[serde(default, deserialize_with = "deserialize_tolerant_vec")]
64    pub stages: Vec<Stage>,
65
66    /// Shorthand: jobs without stages
67    #[serde(default, deserialize_with = "deserialize_tolerant_vec")]
68    pub jobs: Vec<Job>,
69
70    /// Shorthand: steps without stages/jobs
71    #[serde(default, deserialize_with = "deserialize_tolerant_vec")]
72    pub steps: Vec<Step>,
73
74    /// Default pool for all jobs
75    pub pool: Option<Pool>,
76
77    /// Template extension
78    pub extends: Option<Extends>,
79
80    /// Lock behavior for resources
81    #[serde(rename = "lockBehavior")]
82    pub lock_behavior: Option<LockBehavior>,
83
84    /// Whether stages/jobs/steps lists contained compile-time template directives
85    /// (${{ if }}, ${{ each }}) that were dropped during deserialization.
86    #[serde(skip)]
87    pub has_template_directives: bool,
88}
89
90// =============================================================================
91// Triggers
92// =============================================================================
93
94/// CI trigger configuration
95#[derive(Debug, Clone, Serialize, Deserialize)]
96#[serde(untagged)]
97pub enum Trigger {
98    /// Simple: trigger: none
99    None,
100    /// Branches list
101    Branches(Vec<String>),
102    /// Full configuration
103    Full(TriggerConfig),
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize, Default)]
107pub struct TriggerConfig {
108    pub batch: Option<bool>,
109    pub branches: Option<BranchFilter>,
110    pub paths: Option<PathFilter>,
111    pub tags: Option<TagFilter>,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize, Default)]
115pub struct BranchFilter {
116    #[serde(default)]
117    pub include: Vec<String>,
118    #[serde(default)]
119    pub exclude: Vec<String>,
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize, Default)]
123pub struct PathFilter {
124    #[serde(default)]
125    pub include: Vec<String>,
126    #[serde(default)]
127    pub exclude: Vec<String>,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize, Default)]
131pub struct TagFilter {
132    #[serde(default)]
133    pub include: Vec<String>,
134    #[serde(default)]
135    pub exclude: Vec<String>,
136}
137
138/// PR trigger configuration
139#[derive(Debug, Clone, Serialize, Deserialize)]
140#[serde(untagged)]
141pub enum PrTrigger {
142    /// Simple: pr: none
143    None,
144    /// Branches list
145    Branches(Vec<String>),
146    /// Full configuration
147    Full(PrTriggerConfig),
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize, Default)]
151#[serde(rename_all = "camelCase")]
152pub struct PrTriggerConfig {
153    pub auto_cancel: Option<bool>,
154    pub branches: Option<BranchFilter>,
155    pub paths: Option<PathFilter>,
156    pub drafts: Option<bool>,
157}
158
159/// Scheduled trigger
160#[derive(Debug, Clone, Serialize, Deserialize)]
161#[serde(rename_all = "camelCase")]
162pub struct Schedule {
163    pub cron: String,
164    pub display_name: Option<String>,
165    pub branches: Option<BranchFilter>,
166    #[serde(default = "default_true")]
167    pub always: bool,
168    #[serde(default)]
169    pub batch: bool,
170}
171
172fn default_true() -> bool {
173    true
174}
175
176// =============================================================================
177// Resources
178// =============================================================================
179
180#[derive(Debug, Clone, Serialize, Deserialize, Default)]
181pub struct Resources {
182    #[serde(default)]
183    pub repositories: Vec<RepositoryResource>,
184    #[serde(default)]
185    pub containers: Vec<ContainerResource>,
186    #[serde(default)]
187    pub pipelines: Vec<PipelineResource>,
188    #[serde(default)]
189    pub packages: Vec<PackageResource>,
190    #[serde(default)]
191    pub webhooks: Vec<WebhookResource>,
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct RepositoryResource {
196    pub repository: String,
197    #[serde(rename = "type")]
198    pub repo_type: Option<String>,
199    pub name: Option<String>,
200    #[serde(rename = "ref")]
201    pub git_ref: Option<String>,
202    pub endpoint: Option<String>,
203    pub trigger: Option<Trigger>,
204}
205
206#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct ContainerResource {
208    pub container: String,
209    pub image: String,
210    pub endpoint: Option<String>,
211    #[serde(default)]
212    pub env: HashMap<String, String>,
213    #[serde(default)]
214    pub ports: Vec<String>,
215    #[serde(default)]
216    pub volumes: Vec<String>,
217    pub options: Option<String>,
218    #[serde(rename = "mapDockerSocket")]
219    pub map_docker_socket: Option<bool>,
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize)]
223#[serde(rename_all = "camelCase")]
224pub struct PipelineResource {
225    pub pipeline: String,
226    pub source: String,
227    pub project: Option<String>,
228    pub trigger: Option<PipelineResourceTrigger>,
229    pub version: Option<String>,
230    pub branch: Option<String>,
231    pub tags: Option<Vec<String>>,
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct PipelineResourceTrigger {
236    pub enabled: Option<bool>,
237    pub branches: Option<BranchFilter>,
238    pub stages: Option<Vec<String>>,
239    pub tags: Option<Vec<String>>,
240}
241
242#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct PackageResource {
244    pub package: String,
245    #[serde(rename = "type")]
246    pub package_type: String,
247    pub connection: String,
248    pub name: String,
249    pub version: Option<String>,
250    pub tag: Option<String>,
251    pub trigger: Option<bool>,
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize)]
255pub struct WebhookResource {
256    pub webhook: String,
257    pub connection: String,
258    #[serde(rename = "type")]
259    pub webhook_type: Option<String>,
260    pub filters: Option<Vec<WebhookFilter>>,
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct WebhookFilter {
265    pub path: String,
266    pub value: String,
267}
268
269// =============================================================================
270// Variables
271// =============================================================================
272
273/// Variable can be a simple key-value, a group reference, or a template
274#[derive(Debug, Clone, Serialize, Deserialize)]
275#[serde(untagged)]
276pub enum Variable {
277    /// Simple variable: { name: foo, value: bar }
278    KeyValue {
279        name: String,
280        value: String,
281        #[serde(default)]
282        readonly: bool,
283    },
284    /// Variable group reference: { group: my-group }
285    Group { group: String },
286    /// Template reference: { template: vars.yml }
287    Template {
288        template: String,
289        #[serde(default)]
290        parameters: HashMap<String, serde_yaml::Value>,
291    },
292}
293
294/// Custom deserializer for variables supporting both map and list formats
295fn deserialize_variables<'de, D>(deserializer: D) -> Result<Vec<Variable>, D::Error>
296where
297    D: serde::Deserializer<'de>,
298{
299    use serde::de::{MapAccess, SeqAccess, Visitor};
300
301    struct VariablesVisitor;
302
303    impl<'de> Visitor<'de> for VariablesVisitor {
304        type Value = Vec<Variable>;
305
306        fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
307            formatter.write_str("a map of variables or a list of variable definitions")
308        }
309
310        fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
311        where
312            A: SeqAccess<'de>,
313        {
314            let mut vars = Vec::new();
315            while let Some(var) = seq.next_element::<Variable>()? {
316                vars.push(var);
317            }
318            Ok(vars)
319        }
320
321        fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
322        where
323            M: MapAccess<'de>,
324        {
325            let mut vars = Vec::new();
326            while let Some((key, value)) = map.next_entry::<String, String>()? {
327                vars.push(Variable::KeyValue {
328                    name: key,
329                    value,
330                    readonly: false,
331                });
332            }
333            Ok(vars)
334        }
335    }
336
337    deserializer.deserialize_any(VariablesVisitor)
338}
339
340/// Tolerant deserializer for Vec<T> that skips items which fail to deserialize.
341/// This handles Azure DevOps template expressions like `${{ if ... }}:` and
342/// `${{ each ... }}:` that appear as list items but cannot be deserialized into
343/// typed structs. These are compile-time template directives that should be
344/// preprocessed but may appear in raw YAML discovery.
345fn deserialize_tolerant_vec<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
346where
347    D: serde::Deserializer<'de>,
348    T: serde::de::DeserializeOwned,
349{
350    use serde::de::{SeqAccess, Visitor};
351
352    struct TolerantVecVisitor<T>(std::marker::PhantomData<T>);
353
354    impl<'de, T: serde::de::DeserializeOwned> Visitor<'de> for TolerantVecVisitor<T> {
355        type Value = Vec<T>;
356
357        fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
358            formatter.write_str("a sequence")
359        }
360
361        fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
362        where
363            A: SeqAccess<'de>,
364        {
365            let mut items = Vec::new();
366            // Try to deserialize each element; use serde_yaml::Value as a fallback
367            // to consume items that fail typed deserialization (e.g., template directives)
368            while let Some(value) = seq.next_element::<serde_yaml::Value>()? {
369                // Skip compile-time template directives (${{ if }}, ${{ each }}, etc.)
370                // These appear as mappings with a ${{ ... }} key and must not be
371                // deserialized into typed structs (which would create phantom entries
372                // with empty/default fields).
373                if is_template_directive(&value) {
374                    continue;
375                }
376                if let Ok(item) = serde_yaml::from_value::<T>(value) {
377                    items.push(item);
378                }
379                // Silently skip items that fail to deserialize
380            }
381            Ok(items)
382        }
383    }
384
385    deserializer.deserialize_seq(TolerantVecVisitor::<T>(std::marker::PhantomData))
386}
387
388/// Check whether a YAML value is a compile-time template directive.
389/// Template directives appear as mappings with a key that starts with `${{`.
390pub fn is_template_directive(value: &serde_yaml::Value) -> bool {
391    if let Some(mapping) = value.as_mapping() {
392        mapping.keys().any(|key| {
393            key.as_str()
394                .is_some_and(|s| s.trim_start().starts_with("${{"))
395        })
396    } else {
397        false
398    }
399}
400
401// =============================================================================
402// Parameters
403// =============================================================================
404
405#[derive(Debug, Clone, Serialize, Deserialize)]
406#[serde(rename_all = "camelCase")]
407pub struct Parameter {
408    pub name: String,
409    pub display_name: Option<String>,
410    #[serde(rename = "type", default)]
411    pub param_type: ParameterType,
412    pub default: Option<serde_yaml::Value>,
413    pub values: Option<Vec<serde_yaml::Value>>,
414}
415
416#[derive(Debug, Clone, Serialize, Deserialize, Default)]
417#[serde(rename_all = "lowercase")]
418pub enum ParameterType {
419    #[default]
420    String,
421    Number,
422    Boolean,
423    Object,
424    Step,
425    StepList,
426    Job,
427    JobList,
428    Stage,
429    StageList,
430}
431
432// =============================================================================
433// Pool
434// =============================================================================
435
436#[derive(Debug, Clone, Serialize, Deserialize)]
437#[serde(untagged)]
438pub enum Pool {
439    /// Named pool: pool: my-pool
440    Name(String),
441    /// Full pool spec
442    Full(PoolSpec),
443}
444
445#[derive(Debug, Clone, Serialize, Deserialize)]
446#[serde(rename_all = "camelCase")]
447pub struct PoolSpec {
448    pub name: Option<String>,
449    pub vm_image: Option<String>,
450    pub demands: Option<PoolDemands>,
451}
452
453#[derive(Debug, Clone, Serialize, Deserialize)]
454#[serde(untagged)]
455pub enum PoolDemands {
456    List(Vec<String>),
457    Map(HashMap<String, String>),
458}
459
460// =============================================================================
461// Stage
462// =============================================================================
463
464#[derive(Debug, Clone, Serialize, Deserialize, Default)]
465#[serde(rename_all = "camelCase")]
466pub struct Stage {
467    /// Stage identifier
468    pub stage: Option<String>,
469
470    /// Display name in UI
471    pub display_name: Option<String>,
472
473    /// Dependency on other stages
474    #[serde(default)]
475    pub depends_on: DependsOn,
476
477    /// Condition for running this stage
478    pub condition: Option<String>,
479
480    /// Stage-level variables
481    #[serde(default, deserialize_with = "deserialize_variables")]
482    pub variables: Vec<Variable>,
483
484    /// Jobs in this stage
485    #[serde(default, deserialize_with = "deserialize_tolerant_vec")]
486    pub jobs: Vec<Job>,
487
488    /// Lock behavior
489    pub lock_behavior: Option<LockBehavior>,
490
491    /// Template reference
492    pub template: Option<String>,
493
494    /// Template parameters
495    #[serde(default)]
496    pub parameters: HashMap<String, serde_yaml::Value>,
497
498    /// Pool override for all jobs
499    pub pool: Option<Pool>,
500
501    /// Whether the jobs list contained compile-time template directives
502    /// (${{ if }}, ${{ each }}) that were dropped during deserialization.
503    /// When true, the validator should not require jobs to be non-empty.
504    #[serde(skip)]
505    pub has_template_directives: bool,
506}
507
508// =============================================================================
509// Job
510// =============================================================================
511
512#[derive(Debug, Clone, Serialize, Deserialize, Default)]
513#[serde(rename_all = "camelCase")]
514pub struct Job {
515    /// Job identifier (mutually exclusive with deployment)
516    pub job: Option<String>,
517
518    /// Deployment job identifier
519    pub deployment: Option<String>,
520
521    /// Display name in UI
522    pub display_name: Option<String>,
523
524    /// Dependency on other jobs
525    #[serde(default)]
526    pub depends_on: DependsOn,
527
528    /// Condition for running this job
529    pub condition: Option<String>,
530
531    /// Execution strategy (matrix, parallel)
532    pub strategy: Option<Strategy>,
533
534    /// Agent pool for this job
535    pub pool: Option<Pool>,
536
537    /// Container to run the job in
538    pub container: Option<ContainerRef>,
539
540    /// Service containers
541    #[serde(default)]
542    pub services: HashMap<String, ContainerRef>,
543
544    /// Job-level variables
545    #[serde(default, deserialize_with = "deserialize_variables")]
546    pub variables: Vec<Variable>,
547
548    /// Steps to execute
549    #[serde(default, deserialize_with = "deserialize_tolerant_vec")]
550    pub steps: Vec<Step>,
551
552    /// Job timeout
553    pub timeout_in_minutes: Option<u32>,
554
555    /// Cancel timeout
556    pub cancel_timeout_in_minutes: Option<u32>,
557
558    /// Continue pipeline on error
559    #[serde(default)]
560    pub continue_on_error: BoolOrExpression,
561
562    /// Workspace settings
563    pub workspace: Option<Workspace>,
564
565    /// Uses statement (template reference)
566    pub uses: Option<UsesSpec>,
567
568    /// Template reference
569    pub template: Option<String>,
570
571    /// Template parameters
572    #[serde(default)]
573    pub parameters: HashMap<String, serde_yaml::Value>,
574
575    /// Deployment environment (for deployment jobs)
576    pub environment: Option<Environment>,
577
578    /// Whether the steps list contained compile-time template directives
579    /// (${{ if }}, ${{ each }}) that were dropped during deserialization.
580    /// When true, the validator should not require steps to be non-empty.
581    #[serde(skip)]
582    pub has_template_directives: bool,
583}
584
585impl Job {
586    /// Returns the job identifier (either job or deployment name)
587    pub fn identifier(&self) -> Option<&str> {
588        self.job.as_deref().or(self.deployment.as_deref())
589    }
590}
591
592#[derive(Debug, Clone, Serialize, Deserialize)]
593#[serde(untagged)]
594pub enum ContainerRef {
595    /// Simple image reference
596    Image(String),
597    /// Full container spec
598    Spec(ContainerSpec),
599}
600
601#[derive(Debug, Clone, Serialize, Deserialize)]
602#[serde(rename_all = "camelCase")]
603pub struct ContainerSpec {
604    pub image: String,
605    pub endpoint: Option<String>,
606    #[serde(default)]
607    pub env: HashMap<String, String>,
608    #[serde(default)]
609    pub ports: Vec<String>,
610    #[serde(default)]
611    pub volumes: Vec<String>,
612    pub options: Option<String>,
613    pub map_docker_socket: Option<bool>,
614    #[serde(rename = "mountReadOnly")]
615    pub mount_read_only: Option<MountReadOnly>,
616}
617
618#[derive(Debug, Clone, Serialize, Deserialize, Default)]
619pub struct MountReadOnly {
620    pub work: Option<bool>,
621    pub externals: Option<bool>,
622    pub tools: Option<bool>,
623    pub tasks: Option<bool>,
624}
625
626#[derive(Debug, Clone, Serialize, Deserialize)]
627pub struct Workspace {
628    pub clean: Option<WorkspaceClean>,
629}
630
631#[derive(Debug, Clone, Serialize, Deserialize)]
632#[serde(rename_all = "lowercase")]
633pub enum WorkspaceClean {
634    Outputs,
635    Resources,
636    All,
637}
638
639#[derive(Debug, Clone, Serialize, Deserialize)]
640pub struct UsesSpec {
641    pub repositories: Option<Vec<String>>,
642    pub pools: Option<Vec<String>>,
643}
644
645#[derive(Debug, Clone, Serialize, Deserialize)]
646#[serde(untagged)]
647pub enum Environment {
648    Name(String),
649    Full(EnvironmentSpec),
650}
651
652#[derive(Debug, Clone, Serialize, Deserialize)]
653#[serde(rename_all = "camelCase")]
654pub struct EnvironmentSpec {
655    pub name: String,
656    pub resource_name: Option<String>,
657    pub resource_id: Option<u64>,
658    pub resource_type: Option<String>,
659    pub tags: Option<Vec<String>>,
660}
661
662// =============================================================================
663// Strategy
664// =============================================================================
665
666#[derive(Debug, Clone, Serialize, Deserialize)]
667#[serde(rename_all = "camelCase")]
668pub struct Strategy {
669    /// Matrix strategy
670    pub matrix: Option<MatrixStrategy>,
671
672    /// Parallel jobs count
673    pub parallel: Option<u32>,
674
675    /// Maximum parallel jobs
676    pub max_parallel: Option<u32>,
677
678    /// Deployment strategy (runOnce, rolling, canary)
679    pub run_once: Option<DeploymentHooks>,
680    pub rolling: Option<RollingStrategy>,
681    pub canary: Option<CanaryStrategy>,
682}
683
684#[derive(Debug, Clone, Serialize, Deserialize)]
685#[serde(untagged)]
686pub enum MatrixStrategy {
687    /// Inline matrix definition
688    Inline(HashMap<String, HashMap<String, serde_yaml::Value>>),
689    /// Expression reference
690    Expression(String),
691}
692
693#[derive(Debug, Clone, Serialize, Deserialize)]
694#[serde(rename_all = "camelCase")]
695pub struct DeploymentHooks {
696    pub pre_deploy: Option<HookSteps>,
697    pub deploy: Option<HookSteps>,
698    pub route_traffic: Option<HookSteps>,
699    pub post_route_traffic: Option<HookSteps>,
700    pub on_failure: Option<HookSteps>,
701    pub on_success: Option<HookSteps>,
702}
703
704#[derive(Debug, Clone, Serialize, Deserialize)]
705pub struct HookSteps {
706    pub pool: Option<Pool>,
707    #[serde(default, deserialize_with = "deserialize_tolerant_vec")]
708    pub steps: Vec<Step>,
709}
710
711#[derive(Debug, Clone, Serialize, Deserialize)]
712#[serde(rename_all = "camelCase")]
713pub struct RollingStrategy {
714    pub max_parallel: Option<u32>,
715    #[serde(flatten)]
716    pub hooks: DeploymentHooks,
717}
718
719#[derive(Debug, Clone, Serialize, Deserialize)]
720#[serde(rename_all = "camelCase")]
721pub struct CanaryStrategy {
722    pub increments: Vec<u32>,
723    #[serde(flatten)]
724    pub hooks: DeploymentHooks,
725}
726
727// =============================================================================
728// DependsOn
729// =============================================================================
730
731#[derive(Debug, Clone, Serialize, Deserialize, Default)]
732#[serde(untagged)]
733pub enum DependsOn {
734    /// No dependencies (default: depends on previous)
735    #[default]
736    Default,
737    /// Explicitly no dependencies
738    None,
739    /// Single dependency
740    Single(String),
741    /// Multiple dependencies
742    Multiple(Vec<String>),
743}
744
745impl DependsOn {
746    pub fn as_vec(&self) -> Vec<String> {
747        match self {
748            DependsOn::Default => vec![],
749            DependsOn::None => vec![],
750            DependsOn::Single(s) => vec![s.clone()],
751            DependsOn::Multiple(v) => v.clone(),
752        }
753    }
754
755    pub fn is_explicit_none(&self) -> bool {
756        matches!(self, DependsOn::None)
757    }
758}
759
760// =============================================================================
761// Step
762// =============================================================================
763
764#[derive(Debug, Clone, Serialize, Deserialize)]
765#[serde(rename_all = "camelCase")]
766pub struct Step {
767    /// Step name for output references
768    pub name: Option<String>,
769
770    /// Display name in UI
771    pub display_name: Option<String>,
772
773    /// Condition for running this step
774    pub condition: Option<String>,
775
776    /// Continue job on step failure
777    #[serde(default)]
778    pub continue_on_error: BoolOrExpression,
779
780    /// Enable/disable step
781    #[serde(default = "default_true")]
782    pub enabled: bool,
783
784    /// Step timeout
785    pub timeout_in_minutes: Option<u32>,
786
787    /// Retry count on failure
788    pub retry_count_on_task_failure: Option<u32>,
789
790    /// Step-level environment variables
791    #[serde(default)]
792    pub env: HashMap<String, String>,
793
794    /// The action to perform (flattened from different step types)
795    #[serde(flatten)]
796    pub action: StepAction,
797}
798
799/// The specific action a step performs
800#[derive(Debug, Clone, Serialize, Deserialize)]
801#[serde(untagged)]
802pub enum StepAction {
803    /// Script step: - script: echo hello
804    Script(ScriptStep),
805    /// Bash step: - bash: echo hello
806    Bash(BashStep),
807    /// PowerShell Core step: - pwsh: Write-Host hello
808    Pwsh(PwshStep),
809    /// Windows PowerShell step: - powershell: Write-Host hello
810    PowerShell(PowerShellStep),
811    /// Checkout step: - checkout: self
812    Checkout(CheckoutStep),
813    /// Task step: - task: Bash@3
814    Task(TaskStep),
815    /// Template step: - template: steps.yml
816    Template(TemplateStep),
817    /// Download step: - download: current
818    Download(DownloadStep),
819    /// Publish step: - publish: $(Build.ArtifactStagingDirectory)
820    Publish(PublishStep),
821    /// Get package step
822    GetPackage(GetPackageStep),
823    /// Review app step (deployment)
824    ReviewApp(ReviewAppStep),
825}
826
827#[derive(Debug, Clone, Serialize, Deserialize)]
828#[serde(rename_all = "camelCase")]
829pub struct ScriptStep {
830    pub script: String,
831    pub working_directory: Option<String>,
832    #[serde(default)]
833    pub fail_on_stderr: bool,
834}
835
836#[derive(Debug, Clone, Serialize, Deserialize)]
837#[serde(rename_all = "camelCase")]
838pub struct BashStep {
839    pub bash: String,
840    pub working_directory: Option<String>,
841    #[serde(default)]
842    pub fail_on_stderr: bool,
843}
844
845#[derive(Debug, Clone, Serialize, Deserialize)]
846#[serde(rename_all = "camelCase")]
847pub struct PwshStep {
848    pub pwsh: String,
849    pub working_directory: Option<String>,
850    #[serde(default)]
851    pub fail_on_stderr: bool,
852    pub error_action_preference: Option<String>,
853}
854
855#[derive(Debug, Clone, Serialize, Deserialize)]
856#[serde(rename_all = "camelCase")]
857pub struct PowerShellStep {
858    pub powershell: String,
859    pub working_directory: Option<String>,
860    #[serde(default)]
861    pub fail_on_stderr: bool,
862    pub error_action_preference: Option<String>,
863}
864
865#[derive(Debug, Clone, Serialize, Deserialize)]
866#[serde(rename_all = "camelCase")]
867pub struct CheckoutStep {
868    pub checkout: CheckoutSource,
869    #[serde(default)]
870    pub clean: bool,
871    pub fetch_depth: Option<u32>,
872    pub fetch_tags: Option<bool>,
873    #[serde(default)]
874    pub lfs: bool,
875    #[serde(default)]
876    pub submodules: SubmoduleOption,
877    pub path: Option<String>,
878    pub persistent_credentials: Option<bool>,
879}
880
881#[derive(Debug, Clone, Serialize, Deserialize)]
882#[serde(untagged)]
883pub enum CheckoutSource {
884    /// checkout: self
885    SelfRepo(CheckoutSelf),
886    /// checkout: none
887    None(CheckoutNone),
888    /// checkout: repository-name
889    Repository(String),
890}
891
892#[derive(Debug, Clone, Serialize, Deserialize)]
893#[serde(rename_all = "lowercase")]
894pub enum CheckoutSelf {
895    #[serde(rename = "self")]
896    SelfRepo,
897}
898
899#[derive(Debug, Clone, Serialize, Deserialize)]
900#[serde(rename_all = "lowercase")]
901pub enum CheckoutNone {
902    None,
903}
904
905#[derive(Debug, Clone, Serialize, Deserialize, Default)]
906#[serde(untagged)]
907pub enum SubmoduleOption {
908    #[default]
909    False,
910    True,
911    Recursive,
912}
913
914#[derive(Debug, Clone, Serialize, Deserialize)]
915pub struct TaskStep {
916    pub task: String,
917    #[serde(default)]
918    pub inputs: HashMap<String, String>,
919}
920
921#[derive(Debug, Clone, Serialize, Deserialize)]
922pub struct TemplateStep {
923    pub template: String,
924    #[serde(default)]
925    pub parameters: HashMap<String, serde_yaml::Value>,
926}
927
928#[derive(Debug, Clone, Serialize, Deserialize)]
929#[serde(rename_all = "camelCase")]
930pub struct DownloadStep {
931    pub download: DownloadSource,
932    pub artifact: Option<String>,
933    pub patterns: Option<String>,
934    pub path: Option<String>,
935}
936
937#[derive(Debug, Clone, Serialize, Deserialize)]
938#[serde(untagged)]
939pub enum DownloadSource {
940    Current(DownloadCurrent),
941    None(DownloadNone),
942    Pipeline(String),
943}
944
945#[derive(Debug, Clone, Serialize, Deserialize)]
946#[serde(rename_all = "lowercase")]
947pub enum DownloadCurrent {
948    Current,
949}
950
951#[derive(Debug, Clone, Serialize, Deserialize)]
952#[serde(rename_all = "lowercase")]
953pub enum DownloadNone {
954    None,
955}
956
957#[derive(Debug, Clone, Serialize, Deserialize)]
958#[serde(rename_all = "camelCase")]
959pub struct PublishStep {
960    pub publish: String,
961    pub artifact: Option<String>,
962}
963
964#[derive(Debug, Clone, Serialize, Deserialize)]
965#[serde(rename_all = "camelCase")]
966pub struct GetPackageStep {
967    pub get_package: String,
968    pub path: Option<String>,
969}
970
971#[derive(Debug, Clone, Serialize, Deserialize)]
972#[serde(rename_all = "camelCase")]
973pub struct ReviewAppStep {
974    pub review_app: String,
975}
976
977// =============================================================================
978// Extends
979// =============================================================================
980
981#[derive(Debug, Clone, Serialize, Deserialize)]
982pub struct Extends {
983    pub template: String,
984    #[serde(default)]
985    pub parameters: HashMap<String, serde_yaml::Value>,
986}
987
988// =============================================================================
989// Lock Behavior
990// =============================================================================
991
992#[derive(Debug, Clone, Serialize, Deserialize)]
993#[serde(rename_all = "lowercase")]
994pub enum LockBehavior {
995    RunLatest,
996    Sequential,
997}
998
999// =============================================================================
1000// Execution Results (for runtime)
1001// =============================================================================
1002
1003#[derive(Debug, Clone)]
1004pub struct StepResult {
1005    pub step_name: Option<String>,
1006    pub display_name: Option<String>,
1007    pub status: StepStatus,
1008    pub output: String,
1009    pub error: Option<String>,
1010    pub duration: Duration,
1011    pub exit_code: Option<i32>,
1012    pub outputs: HashMap<String, String>,
1013}
1014
1015#[derive(Debug, Clone, PartialEq, Eq)]
1016pub enum StepStatus {
1017    Pending,
1018    Running,
1019    Succeeded,
1020    SucceededWithIssues,
1021    Failed,
1022    Canceled,
1023    Skipped,
1024}
1025
1026#[derive(Debug, Clone)]
1027pub struct JobResult {
1028    pub job_name: String,
1029    pub display_name: Option<String>,
1030    pub status: JobStatus,
1031    pub steps: Vec<StepResult>,
1032    pub duration: Duration,
1033    pub outputs: HashMap<String, String>,
1034}
1035
1036#[derive(Debug, Clone, PartialEq, Eq)]
1037pub enum JobStatus {
1038    Pending,
1039    Running,
1040    Succeeded,
1041    SucceededWithIssues,
1042    Failed,
1043    Canceled,
1044    Skipped,
1045}
1046
1047#[derive(Debug, Clone)]
1048pub struct StageResult {
1049    pub stage_name: String,
1050    pub display_name: Option<String>,
1051    pub status: StageStatus,
1052    pub jobs: Vec<JobResult>,
1053    pub duration: Duration,
1054}
1055
1056#[derive(Debug, Clone, PartialEq, Eq)]
1057pub enum StageStatus {
1058    Pending,
1059    Running,
1060    Succeeded,
1061    SucceededWithIssues,
1062    Failed,
1063    Canceled,
1064    Skipped,
1065}
1066
1067#[derive(Debug, Clone)]
1068pub struct ExecutionContext {
1069    pub pipeline_name: String,
1070    pub env: HashMap<String, String>,
1071    pub working_dir: String,
1072    pub variables: HashMap<String, String>,
1073    pub parameters: HashMap<String, serde_yaml::Value>,
1074}
1075
1076impl ExecutionContext {
1077    pub fn new(pipeline_name: String, working_dir: String) -> Self {
1078        Self {
1079            pipeline_name,
1080            env: HashMap::new(),
1081            working_dir,
1082            variables: HashMap::new(),
1083            parameters: HashMap::new(),
1084        }
1085    }
1086
1087    pub fn with_env(mut self, env: HashMap<String, String>) -> Self {
1088        self.env = env;
1089        self
1090    }
1091
1092    pub fn with_variables(mut self, variables: HashMap<String, String>) -> Self {
1093        self.variables = variables;
1094        self
1095    }
1096
1097    pub fn with_parameters(mut self, parameters: HashMap<String, serde_yaml::Value>) -> Self {
1098        self.parameters = parameters;
1099        self
1100    }
1101}
1102
1103// =============================================================================
1104// Value type for expressions
1105// =============================================================================
1106
1107/// Runtime value type used in expression evaluation
1108#[derive(Debug, Clone, PartialEq, Default)]
1109pub enum Value {
1110    #[default]
1111    Null,
1112    Bool(bool),
1113    Number(f64),
1114    String(String),
1115    Array(Vec<Value>),
1116    Object(HashMap<String, Value>),
1117}
1118
1119impl Value {
1120    pub fn is_truthy(&self) -> bool {
1121        match self {
1122            Value::Null => false,
1123            Value::Bool(b) => *b,
1124            Value::Number(n) => *n != 0.0,
1125            Value::String(s) => !s.is_empty(),
1126            Value::Array(a) => !a.is_empty(),
1127            Value::Object(o) => !o.is_empty(),
1128        }
1129    }
1130
1131    pub fn as_bool(&self) -> Option<bool> {
1132        match self {
1133            Value::Bool(b) => Some(*b),
1134            _ => None,
1135        }
1136    }
1137
1138    pub fn as_number(&self) -> Option<f64> {
1139        match self {
1140            Value::Number(n) => Some(*n),
1141            Value::String(s) => s.parse().ok(),
1142            _ => None,
1143        }
1144    }
1145
1146    pub fn as_string(&self) -> String {
1147        match self {
1148            Value::Null => "".to_string(),
1149            Value::Bool(b) => b.to_string(),
1150            Value::Number(n) => {
1151                if n.fract() == 0.0 {
1152                    (*n as i64).to_string()
1153                } else {
1154                    n.to_string()
1155                }
1156            }
1157            Value::String(s) => s.clone(),
1158            Value::Array(_) | Value::Object(_) => self.to_json(),
1159        }
1160    }
1161
1162    pub fn to_json(&self) -> String {
1163        match self {
1164            Value::Null => "null".to_string(),
1165            Value::Bool(b) => b.to_string(),
1166            Value::Number(n) => n.to_string(),
1167            Value::String(s) => format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")),
1168            Value::Array(arr) => {
1169                let items: Vec<String> = arr.iter().map(|v| v.to_json()).collect();
1170                format!("[{}]", items.join(","))
1171            }
1172            Value::Object(obj) => {
1173                let items: Vec<String> = obj
1174                    .iter()
1175                    .map(|(k, v)| format!("\"{}\":{}", k, v.to_json()))
1176                    .collect();
1177                format!("{{{}}}", items.join(","))
1178            }
1179        }
1180    }
1181}
1182
1183impl From<bool> for Value {
1184    fn from(b: bool) -> Self {
1185        Value::Bool(b)
1186    }
1187}
1188
1189impl From<i64> for Value {
1190    fn from(n: i64) -> Self {
1191        Value::Number(n as f64)
1192    }
1193}
1194
1195impl From<f64> for Value {
1196    fn from(n: f64) -> Self {
1197        Value::Number(n)
1198    }
1199}
1200
1201impl From<String> for Value {
1202    fn from(s: String) -> Self {
1203        Value::String(s)
1204    }
1205}
1206
1207impl From<&str> for Value {
1208    fn from(s: &str) -> Self {
1209        Value::String(s.to_string())
1210    }
1211}
1212
1213impl<T: Into<Value>> From<Vec<T>> for Value {
1214    fn from(v: Vec<T>) -> Self {
1215        Value::Array(v.into_iter().map(Into::into).collect())
1216    }
1217}
1218
1219#[cfg(test)]
1220mod tests {
1221    use super::*;
1222
1223    #[test]
1224    fn test_value_is_truthy() {
1225        assert!(!Value::Null.is_truthy());
1226        assert!(!Value::Bool(false).is_truthy());
1227        assert!(Value::Bool(true).is_truthy());
1228        assert!(!Value::Number(0.0).is_truthy());
1229        assert!(Value::Number(1.0).is_truthy());
1230        assert!(!Value::String("".to_string()).is_truthy());
1231        assert!(Value::String("hello".to_string()).is_truthy());
1232    }
1233
1234    #[test]
1235    fn test_value_as_string() {
1236        assert_eq!(Value::Null.as_string(), "");
1237        assert_eq!(Value::Bool(true).as_string(), "true");
1238        assert_eq!(Value::Number(42.0).as_string(), "42");
1239        assert_eq!(Value::Number(3.14).as_string(), "3.14");
1240        assert_eq!(Value::String("hello".to_string()).as_string(), "hello");
1241    }
1242
1243    #[test]
1244    fn test_depends_on_as_vec() {
1245        assert_eq!(DependsOn::Default.as_vec(), Vec::<String>::new());
1246        assert_eq!(DependsOn::None.as_vec(), Vec::<String>::new());
1247        assert_eq!(
1248            DependsOn::Single("build".to_string()).as_vec(),
1249            vec!["build".to_string()]
1250        );
1251        assert_eq!(
1252            DependsOn::Multiple(vec!["a".to_string(), "b".to_string()]).as_vec(),
1253            vec!["a".to_string(), "b".to_string()]
1254        );
1255    }
1256}