Skip to main content

cuenv_buildkite/
schema.rs

1//! Buildkite Pipeline Schema Types
2//!
3//! Defines the data structures for Buildkite pipeline YAML generation.
4//! See: <https://buildkite.com/docs/pipelines/configure/defining-steps>
5
6use serde::Serialize;
7use std::collections::HashMap;
8
9/// A Buildkite pipeline definition
10#[derive(Debug, Clone, Default, Serialize)]
11pub struct Pipeline {
12    /// Pipeline steps
13    pub steps: Vec<Step>,
14
15    /// Pipeline-level environment variables
16    #[serde(skip_serializing_if = "HashMap::is_empty")]
17    pub env: HashMap<String, String>,
18}
19
20/// A step in a Buildkite pipeline
21#[derive(Debug, Clone, Serialize)]
22#[serde(untagged)]
23pub enum Step {
24    /// A command step that runs commands
25    Command(Box<CommandStep>),
26    /// A block step for manual approval
27    Block(BlockStep),
28    /// A wait step to synchronize parallel steps
29    Wait(WaitStep),
30    /// A group of steps
31    Group(GroupStep),
32}
33
34/// A command step that executes commands
35#[derive(Debug, Clone, Default, Serialize)]
36pub struct CommandStep {
37    /// Display label for the step
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub label: Option<String>,
40
41    /// Unique key for the step (used for `depends_on`)
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub key: Option<String>,
44
45    /// Commands to execute (can be single string or array)
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub command: Option<CommandValue>,
48
49    /// Environment variables for this step
50    #[serde(skip_serializing_if = "HashMap::is_empty")]
51    pub env: HashMap<String, String>,
52
53    /// Agent targeting rules
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub agents: Option<AgentRules>,
56
57    /// Artifact paths to upload
58    #[serde(skip_serializing_if = "Vec::is_empty")]
59    pub artifact_paths: Vec<String>,
60
61    /// Step dependencies
62    #[serde(skip_serializing_if = "Vec::is_empty")]
63    pub depends_on: Vec<DependsOn>,
64
65    /// Concurrency group name
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub concurrency_group: Option<String>,
68
69    /// Maximum concurrent jobs in the group
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub concurrency: Option<u32>,
72
73    /// Retry configuration
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub retry: Option<RetryConfig>,
76
77    /// Timeout in minutes
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub timeout_in_minutes: Option<u32>,
80
81    /// Soft fail configuration
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub soft_fail: Option<bool>,
84}
85
86/// Command value can be a single string or an array
87#[derive(Debug, Clone, Serialize)]
88#[serde(untagged)]
89pub enum CommandValue {
90    /// Single command string
91    Single(String),
92    /// Array of commands
93    Array(Vec<String>),
94}
95
96/// A block step for manual approval
97#[derive(Debug, Clone, Serialize)]
98pub struct BlockStep {
99    /// Block step marker
100    pub block: String,
101
102    /// Unique key for the step
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub key: Option<String>,
105
106    /// Step dependencies
107    #[serde(skip_serializing_if = "Vec::is_empty")]
108    pub depends_on: Vec<DependsOn>,
109
110    /// Prompt message
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub prompt: Option<String>,
113
114    /// Fields for the block form
115    #[serde(skip_serializing_if = "Vec::is_empty")]
116    pub fields: Vec<BlockField>,
117}
118
119impl BlockStep {
120    /// Create a new block step with the given label
121    pub fn new(label: impl Into<String>) -> Self {
122        Self {
123            block: label.into(),
124            key: None,
125            depends_on: Vec::new(),
126            prompt: None,
127            fields: Vec::new(),
128        }
129    }
130}
131
132/// A field in a block step form
133#[derive(Debug, Clone, Serialize)]
134pub struct BlockField {
135    /// Field type (text, select)
136    #[serde(rename = "type")]
137    pub field_type: String,
138
139    /// Field key
140    pub key: String,
141
142    /// Field label
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub text: Option<String>,
145
146    /// Whether the field is required
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub required: Option<bool>,
149}
150
151/// A wait step to synchronize parallel steps
152#[derive(Debug, Clone, Serialize)]
153pub struct WaitStep {
154    /// Wait step marker
155    pub wait: Option<String>,
156
157    /// Continue on failure
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub continue_on_failure: Option<bool>,
160}
161
162impl Default for WaitStep {
163    fn default() -> Self {
164        Self {
165            wait: Some("~".to_string()),
166            continue_on_failure: None,
167        }
168    }
169}
170
171/// A group of steps
172#[derive(Debug, Clone, Serialize)]
173pub struct GroupStep {
174    /// Group label
175    pub group: String,
176
177    /// Unique key for the group
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub key: Option<String>,
180
181    /// Steps within the group
182    pub steps: Vec<Step>,
183
184    /// Group dependencies
185    #[serde(skip_serializing_if = "Vec::is_empty")]
186    pub depends_on: Vec<DependsOn>,
187}
188
189/// Agent targeting rules
190#[derive(Debug, Clone, Serialize)]
191pub struct AgentRules {
192    /// Queue name
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub queue: Option<String>,
195
196    /// Additional agent tags
197    #[serde(flatten)]
198    pub tags: HashMap<String, String>,
199}
200
201impl AgentRules {
202    /// Create agent rules with a queue
203    pub fn with_queue(queue: impl Into<String>) -> Self {
204        Self {
205            queue: Some(queue.into()),
206            tags: HashMap::new(),
207        }
208    }
209
210    /// Create agent rules from tags
211    #[must_use]
212    pub fn from_tags(tags: Vec<String>) -> Option<Self> {
213        if tags.is_empty() {
214            return None;
215        }
216
217        let mut rules = Self {
218            queue: None,
219            tags: HashMap::new(),
220        };
221
222        for tag in tags {
223            if let Some((key, value)) = tag.split_once('=') {
224                if key == "queue" {
225                    rules.queue = Some(value.to_string());
226                } else {
227                    rules.tags.insert(key.to_string(), value.to_string());
228                }
229            } else {
230                // Treat as queue if no key=value format
231                rules.queue = Some(tag);
232            }
233        }
234
235        Some(rules)
236    }
237}
238
239/// Step dependency specification
240#[derive(Debug, Clone, Serialize)]
241#[serde(untagged)]
242pub enum DependsOn {
243    /// Simple key reference
244    Key(String),
245    /// Detailed dependency with options
246    Detailed(DetailedDependency),
247}
248
249/// Detailed dependency specification
250#[derive(Debug, Clone, Serialize)]
251pub struct DetailedDependency {
252    /// Step key to depend on
253    pub step: String,
254
255    /// Allow dependency failure
256    #[serde(skip_serializing_if = "Option::is_none")]
257    pub allow_failure: Option<bool>,
258}
259
260/// Retry configuration
261#[derive(Debug, Clone, Serialize)]
262pub struct RetryConfig {
263    /// Automatic retry settings
264    #[serde(skip_serializing_if = "Option::is_none")]
265    pub automatic: Option<AutomaticRetry>,
266
267    /// Manual retry allowed
268    #[serde(skip_serializing_if = "Option::is_none")]
269    pub manual: Option<ManualRetry>,
270}
271
272/// Automatic retry configuration
273#[derive(Debug, Clone, Serialize)]
274#[serde(untagged)]
275pub enum AutomaticRetry {
276    /// Simple boolean
277    Enabled(bool),
278    /// Detailed configuration
279    Config(Vec<AutomaticRetryRule>),
280}
281
282/// Automatic retry rule
283#[derive(Debug, Clone, Serialize)]
284pub struct AutomaticRetryRule {
285    /// Exit status to retry on
286    #[serde(skip_serializing_if = "Option::is_none")]
287    pub exit_status: Option<String>,
288
289    /// Number of retries
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub limit: Option<u32>,
292}
293
294/// Manual retry configuration
295#[derive(Debug, Clone, Serialize)]
296pub struct ManualRetry {
297    /// Allow manual retry
298    #[serde(skip_serializing_if = "Option::is_none")]
299    pub allowed: Option<bool>,
300
301    /// Permit retry on passed
302    #[serde(skip_serializing_if = "Option::is_none")]
303    pub permit_on_passed: Option<bool>,
304
305    /// Reason required for retry
306    #[serde(skip_serializing_if = "Option::is_none")]
307    pub reason: Option<String>,
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    #[test]
315    fn test_pipeline_default() {
316        let pipeline = Pipeline::default();
317        assert!(pipeline.steps.is_empty());
318        assert!(pipeline.env.is_empty());
319    }
320
321    #[test]
322    fn test_command_step_default() {
323        let step = CommandStep::default();
324        assert!(step.label.is_none());
325        assert!(step.key.is_none());
326        assert!(step.command.is_none());
327        assert!(step.env.is_empty());
328        assert!(step.agents.is_none());
329        assert!(step.artifact_paths.is_empty());
330        assert!(step.depends_on.is_empty());
331        assert!(step.concurrency_group.is_none());
332        assert!(step.concurrency.is_none());
333        assert!(step.retry.is_none());
334        assert!(step.timeout_in_minutes.is_none());
335        assert!(step.soft_fail.is_none());
336    }
337
338    #[test]
339    fn test_command_step_serialization() {
340        let step = CommandStep {
341            label: Some(":rust: Build".to_string()),
342            key: Some("build".to_string()),
343            command: Some(CommandValue::Array(vec![
344                "cargo".to_string(),
345                "build".to_string(),
346            ])),
347            env: HashMap::from([("RUST_BACKTRACE".to_string(), "1".to_string())]),
348            ..Default::default()
349        };
350
351        let yaml = serde_yaml::to_string(&step).unwrap();
352        assert!(yaml.contains("label:"));
353        assert!(yaml.contains("key: build"));
354        assert!(yaml.contains("RUST_BACKTRACE"));
355    }
356
357    #[test]
358    fn test_command_value_single() {
359        let cmd = CommandValue::Single("echo hello".to_string());
360        let yaml = serde_yaml::to_string(&cmd).unwrap();
361        assert!(yaml.contains("echo hello"));
362    }
363
364    #[test]
365    fn test_command_value_array() {
366        let cmd = CommandValue::Array(vec!["echo".to_string(), "hello".to_string()]);
367        let yaml = serde_yaml::to_string(&cmd).unwrap();
368        assert!(yaml.contains("echo"));
369        assert!(yaml.contains("hello"));
370    }
371
372    #[test]
373    fn test_block_step_new() {
374        let step = BlockStep::new("Approve Deploy");
375        assert_eq!(step.block, "Approve Deploy");
376        assert!(step.key.is_none());
377        assert!(step.depends_on.is_empty());
378        assert!(step.prompt.is_none());
379        assert!(step.fields.is_empty());
380    }
381
382    #[test]
383    fn test_block_step_serialization() {
384        let step = BlockStep::new(":hand: Approve Deploy");
385
386        let yaml = serde_yaml::to_string(&step).unwrap();
387        assert!(yaml.contains("block:"));
388        assert!(yaml.contains("Approve Deploy"));
389    }
390
391    #[test]
392    fn test_block_field_serialization() {
393        let field = BlockField {
394            field_type: "text".to_string(),
395            key: "reason".to_string(),
396            text: Some("Reason for deployment".to_string()),
397            required: Some(true),
398        };
399        let yaml = serde_yaml::to_string(&field).unwrap();
400        assert!(yaml.contains("type: text"));
401        assert!(yaml.contains("key: reason"));
402        assert!(yaml.contains("text:"));
403        assert!(yaml.contains("required: true"));
404    }
405
406    #[test]
407    fn test_wait_step_default() {
408        let step = WaitStep::default();
409        assert_eq!(step.wait, Some("~".to_string()));
410        assert!(step.continue_on_failure.is_none());
411    }
412
413    #[test]
414    fn test_wait_step_serialization() {
415        let step = WaitStep {
416            wait: Some("~".to_string()),
417            continue_on_failure: Some(true),
418        };
419        let yaml = serde_yaml::to_string(&step).unwrap();
420        assert!(yaml.contains("wait:"));
421        assert!(yaml.contains("continue_on_failure: true"));
422    }
423
424    #[test]
425    fn test_group_step_serialization() {
426        let group = GroupStep {
427            group: "Build and Test".to_string(),
428            key: Some("build-group".to_string()),
429            steps: vec![Step::Command(Box::new(CommandStep {
430                label: Some("Build".to_string()),
431                ..Default::default()
432            }))],
433            depends_on: vec![],
434        };
435        let yaml = serde_yaml::to_string(&group).unwrap();
436        assert!(yaml.contains("group:"));
437        assert!(yaml.contains("Build and Test"));
438        assert!(yaml.contains("key: build-group"));
439    }
440
441    #[test]
442    fn test_agent_rules_with_queue() {
443        let rules = AgentRules::with_queue("production");
444        assert_eq!(rules.queue, Some("production".to_string()));
445        assert!(rules.tags.is_empty());
446    }
447
448    #[test]
449    fn test_agent_rules_from_tags_empty() {
450        let rules = AgentRules::from_tags(vec![]);
451        assert!(rules.is_none());
452    }
453
454    #[test]
455    fn test_agent_rules_from_tags() {
456        let rules = AgentRules::from_tags(vec!["linux-x86".to_string()]);
457        assert!(rules.is_some());
458        assert_eq!(rules.unwrap().queue, Some("linux-x86".to_string()));
459
460        let rules = AgentRules::from_tags(vec!["queue=deploy".to_string(), "os=linux".to_string()]);
461        let rules = rules.unwrap();
462        assert_eq!(rules.queue, Some("deploy".to_string()));
463        assert_eq!(rules.tags.get("os"), Some(&"linux".to_string()));
464    }
465
466    #[test]
467    fn test_agent_rules_serialization() {
468        let mut rules = AgentRules::with_queue("linux");
469        rules.tags.insert("arch".to_string(), "x86_64".to_string());
470        let yaml = serde_yaml::to_string(&rules).unwrap();
471        assert!(yaml.contains("queue: linux"));
472        assert!(yaml.contains("arch: x86_64"));
473    }
474
475    #[test]
476    fn test_depends_on_key() {
477        let dep = DependsOn::Key("build".to_string());
478        let yaml = serde_yaml::to_string(&dep).unwrap();
479        assert!(yaml.contains("build"));
480    }
481
482    #[test]
483    fn test_depends_on_detailed() {
484        let dep = DependsOn::Detailed(DetailedDependency {
485            step: "build".to_string(),
486            allow_failure: Some(true),
487        });
488        let yaml = serde_yaml::to_string(&dep).unwrap();
489        assert!(yaml.contains("step: build"));
490        assert!(yaml.contains("allow_failure: true"));
491    }
492
493    #[test]
494    fn test_retry_config_serialization() {
495        let config = RetryConfig {
496            automatic: Some(AutomaticRetry::Enabled(true)),
497            manual: Some(ManualRetry {
498                allowed: Some(true),
499                permit_on_passed: Some(false),
500                reason: Some("Flaky test".to_string()),
501            }),
502        };
503        let yaml = serde_yaml::to_string(&config).unwrap();
504        assert!(yaml.contains("automatic: true"));
505        assert!(yaml.contains("allowed: true"));
506        assert!(yaml.contains("Flaky test"));
507    }
508
509    #[test]
510    fn test_automatic_retry_config() {
511        let config = AutomaticRetry::Config(vec![AutomaticRetryRule {
512            exit_status: Some("*".to_string()),
513            limit: Some(2),
514        }]);
515        let yaml = serde_yaml::to_string(&config).unwrap();
516        assert!(yaml.contains("exit_status:"));
517        assert!(yaml.contains("limit: 2"));
518    }
519
520    #[test]
521    fn test_pipeline_serialization() {
522        let pipeline = Pipeline {
523            steps: vec![Step::Command(Box::new(CommandStep {
524                label: Some("Test".to_string()),
525                key: Some("test".to_string()),
526                command: Some(CommandValue::Single("echo hello".to_string())),
527                ..Default::default()
528            }))],
529            env: HashMap::new(),
530        };
531
532        let yaml = serde_yaml::to_string(&pipeline).unwrap();
533        assert!(yaml.contains("steps:"));
534        assert!(yaml.contains("label: Test"));
535    }
536
537    #[test]
538    fn test_pipeline_with_env() {
539        let mut env = HashMap::new();
540        env.insert("CI".to_string(), "true".to_string());
541        let pipeline = Pipeline { steps: vec![], env };
542        let yaml = serde_yaml::to_string(&pipeline).unwrap();
543        assert!(yaml.contains("env:"));
544        assert!(yaml.contains("CI: 'true'"));
545    }
546
547    #[test]
548    fn test_step_variants() {
549        let command = Step::Command(Box::default());
550        let block = Step::Block(BlockStep::new("Approve"));
551        let wait = Step::Wait(WaitStep::default());
552        let group = Step::Group(GroupStep {
553            group: "Test".to_string(),
554            key: None,
555            steps: vec![],
556            depends_on: vec![],
557        });
558
559        // All should serialize without panic
560        let _ = serde_yaml::to_string(&command).unwrap();
561        let _ = serde_yaml::to_string(&block).unwrap();
562        let _ = serde_yaml::to_string(&wait).unwrap();
563        let _ = serde_yaml::to_string(&group).unwrap();
564    }
565}