1#![allow(clippy::too_many_lines)]
7
8use crate::schema::{AgentRules, BlockStep, CommandStep, CommandValue, DependsOn, Pipeline, Step};
9use cuenv_ci::emitter::{Emitter, EmitterError, EmitterResult};
10use cuenv_ci::ir::{BuildStage, IntermediateRepresentation, OutputType, Task};
11use std::collections::HashMap;
12
13#[derive(Debug, Clone, Default)]
32pub struct BuildkiteEmitter {
33 pub use_emojis: bool,
35 pub default_queue: Option<String>,
37}
38
39impl BuildkiteEmitter {
40 #[must_use]
42 pub fn new() -> Self {
43 Self::default()
44 }
45
46 #[must_use]
48 pub const fn with_emojis(mut self) -> Self {
49 self.use_emojis = true;
50 self
51 }
52
53 #[must_use]
55 pub fn with_default_queue(mut self, queue: impl Into<String>) -> Self {
56 self.default_queue = Some(queue.into());
57 self
58 }
59
60 fn build_pipeline(&self, ir: &IntermediateRepresentation) -> Pipeline {
62 let mut steps: Vec<Step> = Vec::new();
63
64 for task in ir.sorted_phase_tasks(BuildStage::Bootstrap) {
66 steps.push(Step::Command(Box::new(self.phase_task_to_step(task))));
67 }
68
69 for task in ir.sorted_phase_tasks(BuildStage::Setup) {
71 steps.push(Step::Command(Box::new(self.phase_task_to_step(task))));
72 }
73
74 let setup_keys: Vec<String> = ir
76 .sorted_phase_tasks(BuildStage::Bootstrap)
77 .iter()
78 .chain(ir.sorted_phase_tasks(BuildStage::Setup).iter())
79 .map(|t| t.id.clone())
80 .collect();
81
82 let approval_keys: HashMap<String, String> = ir
84 .regular_tasks()
85 .filter(|task| task.manual_approval)
86 .map(|task| (task.id.clone(), format!("{}-approval", task.id)))
87 .collect();
88
89 for task in ir.regular_tasks() {
91 if let Some(approval_key) = approval_keys.get(&task.id) {
93 steps.push(Step::Block(self.build_block_step(task, approval_key)));
94 }
95
96 steps.push(Step::Command(Box::new(self.build_command_step(
98 task,
99 ir,
100 &approval_keys,
101 &setup_keys,
102 ))));
103 }
104
105 Pipeline {
106 steps,
107 env: HashMap::new(),
108 }
109 }
110
111 fn phase_task_to_step(&self, task: &Task) -> CommandStep {
113 let label = task.label.as_ref().map(|l| self.format_label(l, false));
114
115 let command = if task.shell {
117 Some(CommandValue::Single(task.command.join(" ")))
118 } else {
119 Some(CommandValue::Array(task.command.clone()))
120 };
121
122 let depends_on: Vec<DependsOn> = task
124 .depends_on
125 .iter()
126 .map(|dep| DependsOn::Key(dep.clone()))
127 .collect();
128
129 CommandStep {
130 label,
131 key: Some(task.id.clone()),
132 command,
133 env: task
134 .env
135 .iter()
136 .map(|(k, v)| (k.clone(), v.clone()))
137 .collect(),
138 agents: None,
139 artifact_paths: vec![],
140 depends_on,
141 concurrency_group: None,
142 concurrency: None,
143 retry: None,
144 timeout_in_minutes: None,
145 soft_fail: None,
146 }
147 }
148
149 fn build_command_step(
151 &self,
152 task: &Task,
153 ir: &IntermediateRepresentation,
154 approval_keys: &HashMap<String, String>,
155 setup_keys: &[String],
156 ) -> CommandStep {
157 let label = self.format_label(&task.id, task.deployment);
158
159 let base_command = ir.pipeline.environment.as_ref().map_or_else(
161 || format!("cuenv task {}", task.id),
162 |env| format!("cuenv task {} -e {}", task.id, env),
163 );
164
165 let command = if let Some(runtime_id) = &task.runtime {
167 if let Some(runtime) = ir.runtimes.iter().find(|r| r.id == *runtime_id) {
168 Some(CommandValue::Single(format!(
170 ". /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh && nix develop {}#{} --command {}",
171 runtime.flake, runtime.output, base_command
172 )))
173 } else {
174 Some(CommandValue::Single(base_command))
175 }
176 } else {
177 Some(CommandValue::Single(base_command))
178 };
179
180 let env: HashMap<String, String> = task
183 .env
184 .iter()
185 .map(|(k, v)| (k.clone(), v.clone()))
186 .collect();
187
188 let agents = task
190 .resources
191 .as_ref()
192 .and_then(|r| AgentRules::from_tags(r.tags.clone()))
193 .or_else(|| self.default_queue.as_ref().map(AgentRules::with_queue));
194
195 let artifact_paths: Vec<String> = task
197 .outputs
198 .iter()
199 .filter(|o| o.output_type == OutputType::Orchestrator)
200 .map(|o| o.path.clone())
201 .collect();
202
203 let mut depends_on: Vec<DependsOn> = Vec::new();
205
206 if task.runtime.is_some() || ir.pipeline.requires_onepassword {
208 for setup_key in setup_keys {
209 depends_on.push(DependsOn::Key(setup_key.clone()));
210 }
211 }
212
213 for dep in &task.depends_on {
215 if let Some(approval_key) = approval_keys.get(dep) {
216 depends_on.push(DependsOn::Key(approval_key.clone()));
217 } else {
218 depends_on.push(DependsOn::Key(dep.clone()));
219 }
220 }
221
222 if let Some(approval_key) = approval_keys.get(&task.id) {
224 depends_on.push(DependsOn::Key(approval_key.clone()));
225 }
226
227 let (concurrency_group, concurrency) = task
229 .concurrency_group
230 .as_ref()
231 .map_or((None, None), |group| (Some(group.clone()), Some(1)));
232
233 CommandStep {
234 label: Some(label),
235 key: Some(task.id.clone()),
236 command,
237 env,
238 agents,
239 artifact_paths,
240 depends_on,
241 concurrency_group,
242 concurrency,
243 retry: None,
244 timeout_in_minutes: None,
245 soft_fail: None,
246 }
247 }
248
249 fn build_block_step(&self, task: &Task, approval_key: &str) -> BlockStep {
251 let label = if self.use_emojis {
252 format!(":hand: Approve {}", task.id)
253 } else {
254 format!("Approve {}", task.id)
255 };
256
257 let mut depends_on: Vec<DependsOn> = task
258 .depends_on
259 .iter()
260 .map(|dep| DependsOn::Key(dep.clone()))
261 .collect();
262
263 if depends_on.is_empty() {
265 depends_on = Vec::new();
266 }
267
268 BlockStep {
269 block: label,
270 key: Some(approval_key.to_string()),
271 depends_on,
272 prompt: Some(format!("Approve execution of {}", task.id)),
273 fields: Vec::new(),
274 }
275 }
276
277 fn format_label(&self, task_id: &str, is_deployment: bool) -> String {
279 if self.use_emojis {
280 let emoji = if is_deployment { ":rocket:" } else { ":gear:" };
281 format!("{emoji} {task_id}")
282 } else {
283 task_id.to_string()
284 }
285 }
286}
287
288impl Emitter for BuildkiteEmitter {
289 fn emit_thin(&self, ir: &IntermediateRepresentation) -> EmitterResult<String> {
296 let pipeline_name = &ir.pipeline.name;
297
298 let mut steps = Vec::new();
300
301 for task in ir.sorted_phase_tasks(BuildStage::Bootstrap) {
303 steps.push(Step::Command(Box::new(self.phase_task_to_step(task))));
304 }
305
306 for task in ir.sorted_phase_tasks(BuildStage::Setup) {
308 steps.push(Step::Command(Box::new(self.phase_task_to_step(task))));
309 }
310
311 let cuenv_command = format!("cuenv ci --pipeline {pipeline_name}");
313 let main_step = CommandStep {
314 label: Some(self.format_label(&format!("Run pipeline: {pipeline_name}"), false)),
315 key: Some("cuenv-ci".to_string()),
316 command: Some(CommandValue::Single(cuenv_command)),
317 env: HashMap::new(),
318 agents: self.default_queue.as_ref().map(AgentRules::with_queue),
319 artifact_paths: vec![],
320 depends_on: ir
321 .sorted_phase_tasks(BuildStage::Setup)
322 .last()
323 .map(|t| vec![DependsOn::Key(t.id.clone())])
324 .unwrap_or_default(),
325 concurrency_group: None,
326 concurrency: None,
327 retry: None,
328 timeout_in_minutes: None,
329 soft_fail: None,
330 };
331 steps.push(Step::Command(Box::new(main_step)));
332
333 let pipeline = Pipeline {
334 steps,
335 env: HashMap::new(),
336 };
337
338 serde_yaml::to_string(&pipeline).map_err(|e| EmitterError::Serialization(e.to_string()))
339 }
340
341 fn emit_expanded(&self, ir: &IntermediateRepresentation) -> EmitterResult<String> {
346 let pipeline = self.build_pipeline(ir);
347 serde_yaml::to_string(&pipeline).map_err(|e| EmitterError::Serialization(e.to_string()))
348 }
349
350 fn format_name(&self) -> &'static str {
351 "buildkite"
352 }
353
354 fn file_extension(&self) -> &'static str {
355 "yml"
356 }
357
358 fn description(&self) -> &'static str {
359 "Buildkite pipeline YAML emitter"
360 }
361
362 fn validate(&self, ir: &IntermediateRepresentation) -> EmitterResult<()> {
363 for task in &ir.tasks {
365 if task.id.contains(' ') {
366 return Err(EmitterError::InvalidIR(format!(
367 "Task ID '{}' contains spaces, which are not allowed in Buildkite keys",
368 task.id
369 )));
370 }
371 }
372
373 let task_ids: std::collections::HashSet<_> = ir.tasks.iter().map(|t| &t.id).collect();
375 for task in &ir.tasks {
376 for dep in &task.depends_on {
377 if !task_ids.contains(dep) {
378 return Err(EmitterError::InvalidIR(format!(
379 "Task '{}' depends on non-existent task '{}'",
380 task.id, dep
381 )));
382 }
383 }
384 }
385
386 Ok(())
387 }
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393 use cuenv_ci::ir::{
394 CachePolicy, OutputDeclaration, PipelineMetadata, PurityMode, ResourceRequirements,
395 Runtime, SecretConfig,
396 };
397 use cuenv_core::ci::PipelineMode;
398 use std::collections::BTreeMap;
399
400 fn make_ir(tasks: Vec<Task>) -> IntermediateRepresentation {
403 IntermediateRepresentation {
404 version: "1.4".to_string(),
405 pipeline: PipelineMetadata {
406 name: "test-pipeline".to_string(),
407 mode: PipelineMode::Expanded,
408 environment: None,
409 requires_onepassword: false,
410 project_name: None,
411 trigger: None,
412 pipeline_tasks: vec![],
413 pipeline_task_defs: vec![],
414 },
415 runtimes: vec![],
416 tasks,
417 }
418 }
419
420 fn make_task(id: &str, command: &[&str]) -> Task {
421 Task {
422 id: id.to_string(),
423 runtime: None,
424 command: command.iter().map(|s| (*s).to_string()).collect(),
425 shell: false,
426 env: BTreeMap::new(),
427 secrets: BTreeMap::new(),
428 resources: None,
429 concurrency_group: None,
430 inputs: vec![],
431 outputs: vec![],
432 depends_on: vec![],
433 cache_policy: CachePolicy::Normal,
434 deployment: false,
435 manual_approval: false,
436 matrix: None,
437 artifact_downloads: vec![],
438 params: BTreeMap::new(),
439 phase: None,
440 label: None,
441 priority: None,
442 contributor: None,
443 condition: None,
444 provider_hints: None,
445 }
446 }
447
448 #[test]
449 fn test_simple_pipeline() {
450 let emitter = BuildkiteEmitter::new();
451 let ir = make_ir(vec![make_task("build", &["cargo", "build"])]);
452
453 let yaml = emitter.emit(&ir).unwrap();
454
455 assert!(yaml.contains("steps:"));
456 assert!(yaml.contains("key: build"));
457 assert!(yaml.contains("cuenv task build"));
459 }
460
461 #[test]
462 fn test_with_dependencies() {
463 let emitter = BuildkiteEmitter::new();
464 let mut test_task = make_task("test", &["cargo", "test"]);
465 test_task.depends_on = vec!["build".to_string()];
466
467 let ir = make_ir(vec![make_task("build", &["cargo", "build"]), test_task]);
468
469 let yaml = emitter.emit(&ir).unwrap();
470
471 assert!(yaml.contains("depends_on:"));
472 assert!(yaml.contains("- build"));
473 }
474
475 #[test]
476 fn test_with_manual_approval() {
477 let emitter = BuildkiteEmitter::new().with_emojis();
478 let mut deploy_task = make_task("deploy", &["./deploy.sh"]);
479 deploy_task.manual_approval = true;
480 deploy_task.deployment = true;
481
482 let ir = make_ir(vec![deploy_task]);
483
484 let yaml = emitter.emit(&ir).unwrap();
485
486 assert!(yaml.contains("block:"));
487 assert!(yaml.contains("Approve deploy"));
488 assert!(yaml.contains("deploy-approval"));
489 }
490
491 #[test]
492 fn test_with_concurrency_group() {
493 let emitter = BuildkiteEmitter::new();
494 let mut deploy_task = make_task("deploy", &["./deploy.sh"]);
495 deploy_task.concurrency_group = Some("production".to_string());
496
497 let ir = make_ir(vec![deploy_task]);
498
499 let yaml = emitter.emit(&ir).unwrap();
500
501 assert!(yaml.contains("concurrency_group: production"));
502 assert!(yaml.contains("concurrency: 1"));
503 }
504
505 #[test]
506 fn test_with_agent_queue() {
507 let emitter = BuildkiteEmitter::new();
508 let mut task = make_task("build", &["cargo", "build"]);
509 task.resources = Some(ResourceRequirements {
510 cpu: None,
511 memory: None,
512 tags: vec!["linux-x86".to_string()],
513 });
514
515 let ir = make_ir(vec![task]);
516
517 let yaml = emitter.emit(&ir).unwrap();
518
519 assert!(yaml.contains("agents:"));
520 assert!(yaml.contains("queue: linux-x86"));
521 }
522
523 #[test]
524 fn test_with_secrets() {
525 let emitter = BuildkiteEmitter::new();
526 let mut task = make_task("deploy", &["./deploy.sh"]);
527 task.secrets.insert(
528 "API_KEY".to_string(),
529 SecretConfig {
530 source: "BUILDKITE_SECRET_API_KEY".to_string(),
531 cache_key: false,
532 },
533 );
534
535 let ir = make_ir(vec![task]);
536
537 let yaml = emitter.emit(&ir).unwrap();
538
539 assert!(yaml.contains("cuenv task deploy"));
541 }
542
543 #[test]
544 fn test_with_artifacts() {
545 let emitter = BuildkiteEmitter::new();
546 let mut task = make_task("build", &["cargo", "build"]);
547 task.outputs = vec![OutputDeclaration {
548 path: "target/release/binary".to_string(),
549 output_type: OutputType::Orchestrator,
550 }];
551
552 let ir = make_ir(vec![task]);
553
554 let yaml = emitter.emit(&ir).unwrap();
555
556 assert!(yaml.contains("artifact_paths:"));
557 assert!(yaml.contains("target/release/binary"));
558 }
559
560 #[test]
561 fn test_default_queue() {
562 let emitter = BuildkiteEmitter::new().with_default_queue("default");
563 let ir = make_ir(vec![make_task("build", &["cargo", "build"])]);
564
565 let yaml = emitter.emit(&ir).unwrap();
566
567 assert!(yaml.contains("agents:"));
568 assert!(yaml.contains("queue: default"));
569 }
570
571 #[test]
572 fn test_emojis() {
573 let emitter = BuildkiteEmitter::new().with_emojis();
574 let ir = make_ir(vec![make_task("build", &["cargo", "build"])]);
575
576 let yaml = emitter.emit(&ir).unwrap();
577
578 assert!(yaml.contains(":gear:"));
579 }
580
581 #[test]
582 fn test_validation_invalid_id() {
583 let emitter = BuildkiteEmitter::new();
584 let ir = make_ir(vec![make_task("invalid task", &["echo"])]);
585
586 let result = emitter.validate(&ir);
587 assert!(result.is_err());
588 }
589
590 #[test]
591 fn test_validation_missing_dependency() {
592 let emitter = BuildkiteEmitter::new();
593 let mut task = make_task("test", &["cargo", "test"]);
594 task.depends_on = vec!["nonexistent".to_string()];
595
596 let ir = make_ir(vec![task]);
597
598 let result = emitter.validate(&ir);
599 assert!(result.is_err());
600 }
601
602 #[test]
603 fn test_format_name() {
604 let emitter = BuildkiteEmitter::new();
605 assert_eq!(emitter.format_name(), "buildkite");
606 assert_eq!(emitter.file_extension(), "yml");
607 }
608
609 fn make_phase_task(id: &str, command: &[&str], phase: BuildStage, priority: i32) -> Task {
611 Task {
612 id: id.to_string(),
613 runtime: None,
614 command: command.iter().map(|s| (*s).to_string()).collect(),
615 shell: command.len() == 1, env: BTreeMap::new(),
617 secrets: BTreeMap::new(),
618 resources: None,
619 concurrency_group: None,
620 inputs: vec![],
621 outputs: vec![],
622 depends_on: vec![],
623 cache_policy: CachePolicy::Disabled,
624 deployment: false,
625 manual_approval: false,
626 matrix: None,
627 artifact_downloads: vec![],
628 params: BTreeMap::new(),
629 phase: Some(phase),
630 label: None,
631 priority: Some(priority),
632 contributor: None,
633 condition: None,
634 provider_hints: None,
635 }
636 }
637
638 #[test]
639 fn test_with_nix_runtime() {
640 let emitter = BuildkiteEmitter::new();
641
642 let mut task = make_task("build", &["cargo", "build"]);
644 task.runtime = Some("nix-rust".to_string());
645
646 let mut bootstrap_task = make_phase_task(
648 "install-nix",
649 &[
650 "curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install linux --no-confirm --init none",
651 ],
652 BuildStage::Bootstrap,
653 0,
654 );
655 bootstrap_task.label = Some("Install Nix".to_string());
656 bootstrap_task.contributor = Some("nix".to_string());
657
658 let mut setup_task = make_phase_task(
659 "setup-cuenv",
660 &[
661 ". /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh && nix build .#cuenv --accept-flake-config",
662 ],
663 BuildStage::Setup,
664 10,
665 );
666 setup_task.label = Some("Setup cuenv".to_string());
667 setup_task.contributor = Some("cuenv".to_string());
668 setup_task.depends_on = vec!["install-nix".to_string()];
669
670 let mut ir = make_ir(vec![bootstrap_task, setup_task, task]);
672 ir.runtimes.push(Runtime {
673 id: "nix-rust".to_string(),
674 flake: "github:NixOS/nixpkgs/nixos-unstable".to_string(),
675 output: "devShells.x86_64-linux.default".to_string(),
676 system: "x86_64-linux".to_string(),
677 digest: "sha256:abc123".to_string(),
678 purity: PurityMode::Strict,
679 });
680
681 let yaml = emitter.emit(&ir).unwrap();
682
683 assert!(yaml.contains("install.determinate.systems/nix"));
685 assert!(yaml.contains("nix-daemon.sh"));
686
687 assert!(yaml.contains("nix develop"));
689 assert!(yaml.contains("github:NixOS/nixpkgs/nixos-unstable"));
690 assert!(yaml.contains("devShells.x86_64-linux.default"));
691
692 assert!(yaml.contains("cuenv task build"));
694 }
695
696 #[test]
697 fn test_without_runtime_no_nix_setup() {
698 let emitter = BuildkiteEmitter::new();
699
700 let task = make_task("build", &["cargo", "build"]);
702 let ir = make_ir(vec![task]);
703
704 let yaml = emitter.emit(&ir).unwrap();
705
706 assert!(!yaml.contains("install.determinate.systems/nix"));
708 assert!(!yaml.contains("nix develop"));
709
710 assert!(yaml.contains("cuenv task build"));
712 }
713}