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_command_step_serialization() {
316        let step = CommandStep {
317            label: Some(":rust: Build".to_string()),
318            key: Some("build".to_string()),
319            command: Some(CommandValue::Array(vec![
320                "cargo".to_string(),
321                "build".to_string(),
322            ])),
323            env: HashMap::from([("RUST_BACKTRACE".to_string(), "1".to_string())]),
324            ..Default::default()
325        };
326
327        let yaml = serde_yaml::to_string(&step).unwrap();
328        assert!(yaml.contains("label:"));
329        assert!(yaml.contains("key: build"));
330        assert!(yaml.contains("RUST_BACKTRACE"));
331    }
332
333    #[test]
334    fn test_block_step_serialization() {
335        let step = BlockStep::new(":hand: Approve Deploy");
336
337        let yaml = serde_yaml::to_string(&step).unwrap();
338        assert!(yaml.contains("block:"));
339        assert!(yaml.contains("Approve Deploy"));
340    }
341
342    #[test]
343    fn test_agent_rules_from_tags() {
344        let rules = AgentRules::from_tags(vec!["linux-x86".to_string()]);
345        assert!(rules.is_some());
346        assert_eq!(rules.unwrap().queue, Some("linux-x86".to_string()));
347
348        let rules = AgentRules::from_tags(vec!["queue=deploy".to_string(), "os=linux".to_string()]);
349        let rules = rules.unwrap();
350        assert_eq!(rules.queue, Some("deploy".to_string()));
351        assert_eq!(rules.tags.get("os"), Some(&"linux".to_string()));
352    }
353
354    #[test]
355    fn test_pipeline_serialization() {
356        let pipeline = Pipeline {
357            steps: vec![Step::Command(Box::new(CommandStep {
358                label: Some("Test".to_string()),
359                key: Some("test".to_string()),
360                command: Some(CommandValue::Single("echo hello".to_string())),
361                ..Default::default()
362            }))],
363            env: HashMap::new(),
364        };
365
366        let yaml = serde_yaml::to_string(&pipeline).unwrap();
367        assert!(yaml.contains("steps:"));
368        assert!(yaml.contains("label: Test"));
369    }
370}