1use serde::Serialize;
7use std::collections::HashMap;
8
9#[derive(Debug, Clone, Default, Serialize)]
11pub struct Pipeline {
12 pub steps: Vec<Step>,
14
15 #[serde(skip_serializing_if = "HashMap::is_empty")]
17 pub env: HashMap<String, String>,
18}
19
20#[derive(Debug, Clone, Serialize)]
22#[serde(untagged)]
23pub enum Step {
24 Command(Box<CommandStep>),
26 Block(BlockStep),
28 Wait(WaitStep),
30 Group(GroupStep),
32}
33
34#[derive(Debug, Clone, Default, Serialize)]
36pub struct CommandStep {
37 #[serde(skip_serializing_if = "Option::is_none")]
39 pub label: Option<String>,
40
41 #[serde(skip_serializing_if = "Option::is_none")]
43 pub key: Option<String>,
44
45 #[serde(skip_serializing_if = "Option::is_none")]
47 pub command: Option<CommandValue>,
48
49 #[serde(skip_serializing_if = "HashMap::is_empty")]
51 pub env: HashMap<String, String>,
52
53 #[serde(skip_serializing_if = "Option::is_none")]
55 pub agents: Option<AgentRules>,
56
57 #[serde(skip_serializing_if = "Vec::is_empty")]
59 pub artifact_paths: Vec<String>,
60
61 #[serde(skip_serializing_if = "Vec::is_empty")]
63 pub depends_on: Vec<DependsOn>,
64
65 #[serde(skip_serializing_if = "Option::is_none")]
67 pub concurrency_group: Option<String>,
68
69 #[serde(skip_serializing_if = "Option::is_none")]
71 pub concurrency: Option<u32>,
72
73 #[serde(skip_serializing_if = "Option::is_none")]
75 pub retry: Option<RetryConfig>,
76
77 #[serde(skip_serializing_if = "Option::is_none")]
79 pub timeout_in_minutes: Option<u32>,
80
81 #[serde(skip_serializing_if = "Option::is_none")]
83 pub soft_fail: Option<bool>,
84}
85
86#[derive(Debug, Clone, Serialize)]
88#[serde(untagged)]
89pub enum CommandValue {
90 Single(String),
92 Array(Vec<String>),
94}
95
96#[derive(Debug, Clone, Serialize)]
98pub struct BlockStep {
99 pub block: String,
101
102 #[serde(skip_serializing_if = "Option::is_none")]
104 pub key: Option<String>,
105
106 #[serde(skip_serializing_if = "Vec::is_empty")]
108 pub depends_on: Vec<DependsOn>,
109
110 #[serde(skip_serializing_if = "Option::is_none")]
112 pub prompt: Option<String>,
113
114 #[serde(skip_serializing_if = "Vec::is_empty")]
116 pub fields: Vec<BlockField>,
117}
118
119impl BlockStep {
120 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#[derive(Debug, Clone, Serialize)]
134pub struct BlockField {
135 #[serde(rename = "type")]
137 pub field_type: String,
138
139 pub key: String,
141
142 #[serde(skip_serializing_if = "Option::is_none")]
144 pub text: Option<String>,
145
146 #[serde(skip_serializing_if = "Option::is_none")]
148 pub required: Option<bool>,
149}
150
151#[derive(Debug, Clone, Serialize)]
153pub struct WaitStep {
154 pub wait: Option<String>,
156
157 #[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#[derive(Debug, Clone, Serialize)]
173pub struct GroupStep {
174 pub group: String,
176
177 #[serde(skip_serializing_if = "Option::is_none")]
179 pub key: Option<String>,
180
181 pub steps: Vec<Step>,
183
184 #[serde(skip_serializing_if = "Vec::is_empty")]
186 pub depends_on: Vec<DependsOn>,
187}
188
189#[derive(Debug, Clone, Serialize)]
191pub struct AgentRules {
192 #[serde(skip_serializing_if = "Option::is_none")]
194 pub queue: Option<String>,
195
196 #[serde(flatten)]
198 pub tags: HashMap<String, String>,
199}
200
201impl AgentRules {
202 pub fn with_queue(queue: impl Into<String>) -> Self {
204 Self {
205 queue: Some(queue.into()),
206 tags: HashMap::new(),
207 }
208 }
209
210 #[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 rules.queue = Some(tag);
232 }
233 }
234
235 Some(rules)
236 }
237}
238
239#[derive(Debug, Clone, Serialize)]
241#[serde(untagged)]
242pub enum DependsOn {
243 Key(String),
245 Detailed(DetailedDependency),
247}
248
249#[derive(Debug, Clone, Serialize)]
251pub struct DetailedDependency {
252 pub step: String,
254
255 #[serde(skip_serializing_if = "Option::is_none")]
257 pub allow_failure: Option<bool>,
258}
259
260#[derive(Debug, Clone, Serialize)]
262pub struct RetryConfig {
263 #[serde(skip_serializing_if = "Option::is_none")]
265 pub automatic: Option<AutomaticRetry>,
266
267 #[serde(skip_serializing_if = "Option::is_none")]
269 pub manual: Option<ManualRetry>,
270}
271
272#[derive(Debug, Clone, Serialize)]
274#[serde(untagged)]
275pub enum AutomaticRetry {
276 Enabled(bool),
278 Config(Vec<AutomaticRetryRule>),
280}
281
282#[derive(Debug, Clone, Serialize)]
284pub struct AutomaticRetryRule {
285 #[serde(skip_serializing_if = "Option::is_none")]
287 pub exit_status: Option<String>,
288
289 #[serde(skip_serializing_if = "Option::is_none")]
291 pub limit: Option<u32>,
292}
293
294#[derive(Debug, Clone, Serialize)]
296pub struct ManualRetry {
297 #[serde(skip_serializing_if = "Option::is_none")]
299 pub allowed: Option<bool>,
300
301 #[serde(skip_serializing_if = "Option::is_none")]
303 pub permit_on_passed: Option<bool>,
304
305 #[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 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}