1use serde::{Deserialize, Serialize};
2use std::collections::{BTreeMap, HashMap};
3
4#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
6#[serde(rename_all = "camelCase")]
7pub struct WorkflowDispatchInput {
8 pub description: String,
10 pub required: Option<bool>,
12 pub default: Option<String>,
14 #[serde(rename = "type")]
16 pub input_type: Option<String>,
17 pub options: Option<Vec<String>>,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
23#[serde(untagged)]
24pub enum ManualTrigger {
25 Enabled(bool),
27 WithInputs(HashMap<String, WorkflowDispatchInput>),
29}
30
31impl ManualTrigger {
32 pub fn is_enabled(&self) -> bool {
34 match self {
35 ManualTrigger::Enabled(enabled) => *enabled,
36 ManualTrigger::WithInputs(inputs) => !inputs.is_empty(),
37 }
38 }
39
40 pub fn inputs(&self) -> Option<&HashMap<String, WorkflowDispatchInput>> {
42 match self {
43 ManualTrigger::Enabled(_) => None,
44 ManualTrigger::WithInputs(inputs) => Some(inputs),
45 }
46 }
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
50#[serde(rename_all = "camelCase")]
51pub struct PipelineCondition {
52 pub pull_request: Option<bool>,
53 #[serde(default)]
54 pub branch: Option<StringOrVec>,
55 #[serde(default)]
56 pub tag: Option<StringOrVec>,
57 pub default_branch: Option<bool>,
58 #[serde(default)]
60 pub scheduled: Option<StringOrVec>,
61 pub manual: Option<ManualTrigger>,
63 pub release: Option<Vec<String>>,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
69pub struct RunnerMapping {
70 pub arch: Option<HashMap<String, String>>,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
76#[serde(rename_all = "camelCase")]
77pub struct ArtifactDownload {
78 pub from: String,
80 pub to: String,
82 #[serde(default)]
84 pub filter: String,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
89#[serde(rename_all = "camelCase")]
90pub struct MatrixTask {
91 pub task: String,
93 pub matrix: BTreeMap<String, Vec<String>>,
95 #[serde(default)]
97 pub artifacts: Option<Vec<ArtifactDownload>>,
98 #[serde(default)]
100 pub params: Option<BTreeMap<String, String>>,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
105#[serde(untagged)]
106pub enum PipelineTask {
107 Simple(String),
109 Matrix(MatrixTask),
111}
112
113impl PipelineTask {
114 pub fn task_name(&self) -> &str {
116 match self {
117 PipelineTask::Simple(name) => name,
118 PipelineTask::Matrix(matrix) => &matrix.task,
119 }
120 }
121
122 pub fn is_matrix(&self) -> bool {
124 matches!(self, PipelineTask::Matrix(_))
125 }
126
127 pub fn has_matrix_dimensions(&self) -> bool {
132 match self {
133 PipelineTask::Simple(_) => false,
134 PipelineTask::Matrix(m) => !m.matrix.is_empty(),
135 }
136 }
137
138 pub fn matrix(&self) -> Option<&BTreeMap<String, Vec<String>>> {
140 match self {
141 PipelineTask::Simple(_) => None,
142 PipelineTask::Matrix(m) => Some(&m.matrix),
143 }
144 }
145}
146
147pub type ProviderConfig = HashMap<String, serde_json::Value>;
163
164#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
166#[serde(rename_all = "camelCase")]
167pub struct GitHubActionConfig {
168 pub uses: String,
170
171 #[serde(default, skip_serializing_if = "HashMap::is_empty", rename = "with")]
173 pub inputs: HashMap<String, serde_json::Value>,
174}
175
176#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
182#[serde(rename_all = "lowercase")]
183pub enum PipelineMode {
184 #[default]
187 Thin,
188 Expanded,
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
194#[serde(rename_all = "camelCase")]
195pub struct Pipeline {
196 #[serde(default)]
198 pub mode: PipelineMode,
199 #[serde(default, skip_serializing_if = "Vec::is_empty")]
202 pub providers: Vec<String>,
203 pub environment: Option<String>,
205 pub when: Option<PipelineCondition>,
206 #[serde(default)]
208 pub tasks: Vec<PipelineTask>,
209 pub derive_paths: Option<bool>,
212 pub provider: Option<ProviderConfig>,
214}
215
216#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
225#[serde(rename_all = "snake_case")]
226pub enum TaskCondition {
227 OnSuccess,
229
230 OnFailure,
232
233 Always,
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
240#[serde(rename_all = "camelCase")]
241pub struct ActivationCondition {
242 #[serde(skip_serializing_if = "Option::is_none")]
244 pub always: Option<bool>,
245
246 #[serde(default, skip_serializing_if = "Vec::is_empty")]
249 pub workspace_member: Vec<String>,
250
251 #[serde(default, skip_serializing_if = "Vec::is_empty")]
254 pub runtime_type: Vec<String>,
255
256 #[serde(default, skip_serializing_if = "Vec::is_empty")]
259 pub cuenv_source: Vec<String>,
260
261 #[serde(default, skip_serializing_if = "Vec::is_empty")]
264 pub secrets_provider: Vec<String>,
265
266 #[serde(default, skip_serializing_if = "Vec::is_empty")]
269 pub provider_config: Vec<String>,
270
271 #[serde(default, skip_serializing_if = "Vec::is_empty")]
273 pub task_command: Vec<String>,
274
275 #[serde(default, skip_serializing_if = "Vec::is_empty")]
277 pub task_labels: Vec<String>,
278
279 #[serde(default, skip_serializing_if = "Vec::is_empty")]
281 pub environment: Vec<String>,
282}
283
284#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
286#[serde(untagged)]
287pub enum SecretRef {
288 Simple(String),
290 Detailed(SecretRefConfig),
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
296#[serde(rename_all = "camelCase")]
297pub struct SecretRefConfig {
298 pub source: String,
300 #[serde(default)]
302 pub cache_key: bool,
303}
304
305#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
307#[serde(rename_all = "camelCase")]
308pub struct TaskProviderConfig {
309 #[serde(skip_serializing_if = "Option::is_none")]
311 pub github: Option<GitHubActionConfig>,
312}
313
314#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
317#[serde(rename_all = "camelCase")]
318pub struct AutoAssociate {
319 #[serde(default, skip_serializing_if = "Vec::is_empty")]
321 pub command: Vec<String>,
322
323 #[serde(skip_serializing_if = "Option::is_none")]
325 pub inject_dependency: Option<String>,
326}
327
328#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
330#[serde(rename_all = "camelCase")]
331pub struct ContributorTask {
332 pub id: String,
335
336 #[serde(skip_serializing_if = "Option::is_none")]
338 pub label: Option<String>,
339
340 #[serde(skip_serializing_if = "Option::is_none")]
342 pub description: Option<String>,
343
344 #[serde(skip_serializing_if = "Option::is_none")]
346 pub command: Option<String>,
347
348 #[serde(default, skip_serializing_if = "Vec::is_empty")]
350 pub args: Vec<String>,
351
352 #[serde(skip_serializing_if = "Option::is_none")]
354 pub script: Option<String>,
355
356 #[serde(default)]
358 pub shell: bool,
359
360 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
362 pub env: HashMap<String, String>,
363
364 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
366 pub secrets: HashMap<String, SecretRef>,
367
368 #[serde(default, skip_serializing_if = "Vec::is_empty")]
370 pub inputs: Vec<String>,
371
372 #[serde(default, skip_serializing_if = "Vec::is_empty")]
374 pub outputs: Vec<String>,
375
376 #[serde(default)]
378 pub hermetic: bool,
379
380 #[serde(default, skip_serializing_if = "Vec::is_empty")]
382 pub depends_on: Vec<String>,
383
384 #[serde(default = "default_priority")]
386 pub priority: i32,
387
388 #[serde(skip_serializing_if = "Option::is_none")]
390 pub condition: Option<TaskCondition>,
391
392 #[serde(skip_serializing_if = "Option::is_none")]
394 pub provider: Option<TaskProviderConfig>,
395}
396
397const fn default_priority() -> i32 {
398 10
399}
400
401#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
404#[serde(rename_all = "camelCase")]
405pub struct Contributor {
406 pub id: String,
408
409 #[serde(skip_serializing_if = "Option::is_none")]
411 pub when: Option<ActivationCondition>,
412
413 pub tasks: Vec<ContributorTask>,
415
416 #[serde(skip_serializing_if = "Option::is_none")]
418 pub auto_associate: Option<AutoAssociate>,
419}
420
421#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
422pub struct CI {
423 #[serde(default, skip_serializing_if = "Vec::is_empty")]
427 pub providers: Vec<String>,
428 #[serde(default)]
429 pub pipelines: BTreeMap<String, Pipeline>,
430 pub provider: Option<ProviderConfig>,
432 #[serde(default, skip_serializing_if = "Vec::is_empty")]
434 pub contributors: Vec<Contributor>,
435}
436
437impl CI {
438 #[must_use]
443 pub fn providers_for_pipeline(&self, pipeline_name: &str) -> &[String] {
444 self.pipelines
445 .get(pipeline_name)
446 .filter(|p| !p.providers.is_empty())
447 .map(|p| p.providers.as_slice())
448 .unwrap_or(&self.providers)
449 }
450}
451
452#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
453#[serde(untagged)]
454pub enum StringOrVec {
455 String(String),
456 Vec(Vec<String>),
457}
458
459impl StringOrVec {
460 pub fn to_vec(&self) -> Vec<String> {
462 match self {
463 StringOrVec::String(s) => vec![s.clone()],
464 StringOrVec::Vec(v) => v.clone(),
465 }
466 }
467
468 pub fn as_single(&self) -> Option<&str> {
470 match self {
471 StringOrVec::String(s) => Some(s),
472 StringOrVec::Vec(v) => v.first().map(|s| s.as_str()),
473 }
474 }
475}
476
477#[cfg(test)]
478mod tests {
479 use super::*;
480
481 #[test]
482 fn test_string_or_vec() {
483 let single = StringOrVec::String("value".to_string());
484 assert_eq!(single.to_vec(), vec!["value"]);
485 assert_eq!(single.as_single(), Some("value"));
486
487 let multi = StringOrVec::Vec(vec!["a".to_string(), "b".to_string()]);
488 assert_eq!(multi.to_vec(), vec!["a", "b"]);
489 assert_eq!(multi.as_single(), Some("a"));
490 }
491
492 #[test]
493 fn test_manual_trigger_bool() {
494 let json = r#"{"manual": true}"#;
495 let cond: PipelineCondition = serde_json::from_str(json).unwrap();
496 assert!(matches!(cond.manual, Some(ManualTrigger::Enabled(true))));
497
498 let json = r#"{"manual": false}"#;
499 let cond: PipelineCondition = serde_json::from_str(json).unwrap();
500 assert!(matches!(cond.manual, Some(ManualTrigger::Enabled(false))));
501 }
502
503 #[test]
504 fn test_manual_trigger_with_inputs() {
505 let json =
506 r#"{"manual": {"tag_name": {"description": "Tag to release", "required": true}}}"#;
507 let cond: PipelineCondition = serde_json::from_str(json).unwrap();
508
509 match &cond.manual {
510 Some(ManualTrigger::WithInputs(inputs)) => {
511 assert!(inputs.contains_key("tag_name"));
512 let input = inputs.get("tag_name").unwrap();
513 assert_eq!(input.description, "Tag to release");
514 assert_eq!(input.required, Some(true));
515 }
516 _ => panic!("Expected WithInputs variant"),
517 }
518 }
519
520 #[test]
521 fn test_manual_trigger_helpers() {
522 let enabled = ManualTrigger::Enabled(true);
523 assert!(enabled.is_enabled());
524 assert!(enabled.inputs().is_none());
525
526 let disabled = ManualTrigger::Enabled(false);
527 assert!(!disabled.is_enabled());
528
529 let mut inputs = HashMap::new();
530 inputs.insert(
531 "tag".to_string(),
532 WorkflowDispatchInput {
533 description: "Tag name".to_string(),
534 required: Some(true),
535 default: None,
536 input_type: None,
537 options: None,
538 },
539 );
540 let with_inputs = ManualTrigger::WithInputs(inputs);
541 assert!(with_inputs.is_enabled());
542 assert!(with_inputs.inputs().is_some());
543 }
544
545 #[test]
546 fn test_scheduled_cron_expressions() {
547 let json = r#"{"scheduled": "0 0 * * 0"}"#;
549 let cond: PipelineCondition = serde_json::from_str(json).unwrap();
550 match &cond.scheduled {
551 Some(StringOrVec::String(s)) => assert_eq!(s, "0 0 * * 0"),
552 _ => panic!("Expected single string"),
553 }
554
555 let json = r#"{"scheduled": ["0 0 * * 0", "0 12 * * *"]}"#;
557 let cond: PipelineCondition = serde_json::from_str(json).unwrap();
558 match &cond.scheduled {
559 Some(StringOrVec::Vec(v)) => {
560 assert_eq!(v.len(), 2);
561 assert_eq!(v[0], "0 0 * * 0");
562 assert_eq!(v[1], "0 12 * * *");
563 }
564 _ => panic!("Expected vec"),
565 }
566 }
567
568 #[test]
569 fn test_release_trigger() {
570 let json = r#"{"release": ["published", "created"]}"#;
571 let cond: PipelineCondition = serde_json::from_str(json).unwrap();
572 assert_eq!(
573 cond.release,
574 Some(vec!["published".to_string(), "created".to_string()])
575 );
576 }
577
578 #[test]
579 fn test_pipeline_derive_paths() {
580 let json = r#"{"tasks": ["test"], "derivePaths": true}"#;
581 let pipeline: Pipeline = serde_json::from_str(json).unwrap();
582 assert_eq!(pipeline.derive_paths, Some(true));
583
584 let json = r#"{"tasks": ["sync"], "derivePaths": false}"#;
585 let pipeline: Pipeline = serde_json::from_str(json).unwrap();
586 assert_eq!(pipeline.derive_paths, Some(false));
587
588 let json = r#"{"tasks": ["build"]}"#;
589 let pipeline: Pipeline = serde_json::from_str(json).unwrap();
590 assert_eq!(pipeline.derive_paths, None);
591 }
592
593 #[test]
594 fn test_pipeline_task_simple() {
595 let json = r#""build""#;
596 let task: PipelineTask = serde_json::from_str(json).unwrap();
597 assert!(matches!(task, PipelineTask::Simple(ref s) if s == "build"));
598 assert_eq!(task.task_name(), "build");
599 assert!(!task.is_matrix());
600 assert!(task.matrix().is_none());
601 }
602
603 #[test]
604 fn test_pipeline_task_matrix() {
605 let json =
606 r#"{"task": "release.build", "matrix": {"arch": ["linux-x64", "darwin-arm64"]}}"#;
607 let task: PipelineTask = serde_json::from_str(json).unwrap();
608 assert!(task.is_matrix());
609 assert_eq!(task.task_name(), "release.build");
610
611 let matrix = task.matrix().unwrap();
612 assert!(matrix.contains_key("arch"));
613 assert_eq!(matrix["arch"], vec!["linux-x64", "darwin-arm64"]);
614 }
615
616 #[test]
617 fn test_pipeline_task_matrix_with_artifacts() {
618 let json = r#"{
619 "task": "release.publish",
620 "matrix": {},
621 "artifacts": [{"from": "release.build", "to": "dist", "filter": "*stable"}],
622 "params": {"tag": "v1.0.0"}
623 }"#;
624 let task: PipelineTask = serde_json::from_str(json).unwrap();
625
626 if let PipelineTask::Matrix(m) = task {
627 assert_eq!(m.task, "release.publish");
628 let artifacts = m.artifacts.unwrap();
629 assert_eq!(artifacts.len(), 1);
630 assert_eq!(artifacts[0].from, "release.build");
631 assert_eq!(artifacts[0].to, "dist");
632 assert_eq!(artifacts[0].filter, "*stable");
633
634 let params = m.params.unwrap();
635 assert_eq!(params.get("tag"), Some(&"v1.0.0".to_string()));
636 } else {
637 panic!("Expected Matrix variant");
638 }
639 }
640
641 #[test]
642 fn test_pipeline_mixed_tasks() {
643 let json = r#"{
644 "tasks": [
645 {"task": "release.build", "matrix": {"arch": ["linux-x64", "darwin-arm64"]}},
646 "release.publish:github",
647 "docs.deploy"
648 ]
649 }"#;
650 let pipeline: Pipeline = serde_json::from_str(json).unwrap();
651 assert_eq!(pipeline.tasks.len(), 3);
652 assert!(pipeline.tasks[0].is_matrix());
653 assert!(!pipeline.tasks[1].is_matrix());
654 assert!(!pipeline.tasks[2].is_matrix());
655 }
656
657 #[test]
658 fn test_runner_mapping() {
659 let json = r#"{"arch": {"linux-x64": "ubuntu-latest", "darwin-arm64": "macos-14"}}"#;
660 let mapping: RunnerMapping = serde_json::from_str(json).unwrap();
661 let arch = mapping.arch.unwrap();
662 assert_eq!(arch.get("linux-x64"), Some(&"ubuntu-latest".to_string()));
663 assert_eq!(arch.get("darwin-arm64"), Some(&"macos-14".to_string()));
664 }
665
666 #[test]
667 fn test_contributor_task_with_command_and_args() {
668 let json = r#"{
669 "id": "bun.workspace.install",
670 "command": "bun",
671 "args": ["install", "--frozen-lockfile"],
672 "inputs": ["package.json", "bun.lock"],
673 "outputs": ["node_modules"]
674 }"#;
675 let task: ContributorTask = serde_json::from_str(json).unwrap();
676 assert_eq!(task.id, "bun.workspace.install");
677 assert_eq!(task.command, Some("bun".to_string()));
678 assert_eq!(task.args, vec!["install", "--frozen-lockfile"]);
679 assert_eq!(task.inputs, vec!["package.json", "bun.lock"]);
680 assert_eq!(task.outputs, vec!["node_modules"]);
681 }
682
683 #[test]
684 fn test_contributor_task_with_script() {
685 let json = r#"{
686 "id": "nix.install",
687 "command": "sh",
688 "args": ["-c", "curl -sSL https://install.determinate.systems/nix | sh"]
689 }"#;
690 let task: ContributorTask = serde_json::from_str(json).unwrap();
691 assert_eq!(task.id, "nix.install");
692 assert_eq!(task.command, Some("sh".to_string()));
693 assert_eq!(
694 task.args,
695 vec![
696 "-c",
697 "curl -sSL https://install.determinate.systems/nix | sh"
698 ]
699 );
700 }
701
702 #[test]
703 fn test_contributor_with_auto_associate() {
704 let json = r#"{
705 "id": "bun.workspace",
706 "when": {"workspaceMember": ["bun"]},
707 "tasks": [{
708 "id": "bun.workspace.install",
709 "command": "bun",
710 "args": ["install"]
711 }],
712 "autoAssociate": {
713 "command": ["bun", "bunx"],
714 "injectDependency": "cuenv:contributor:bun.workspace.setup"
715 }
716 }"#;
717 let contributor: Contributor = serde_json::from_str(json).unwrap();
718 assert_eq!(contributor.id, "bun.workspace");
719
720 let when = contributor.when.unwrap();
721 assert_eq!(when.workspace_member, vec!["bun"]);
722
723 let auto = contributor.auto_associate.unwrap();
724 assert_eq!(auto.command, vec!["bun", "bunx"]);
725 assert_eq!(
726 auto.inject_dependency,
727 Some("cuenv:contributor:bun.workspace.setup".to_string())
728 );
729 }
730
731 #[test]
732 fn test_activation_condition_workspace_member() {
733 let json = r#"{"workspaceMember": ["npm", "bun"]}"#;
734 let cond: ActivationCondition = serde_json::from_str(json).unwrap();
735 assert_eq!(cond.workspace_member, vec!["npm", "bun"]);
736 }
737
738 #[test]
739 fn test_providers_for_pipeline_global() {
740 let ci = CI {
741 providers: vec!["github".to_string()],
742 pipelines: BTreeMap::from([(
743 "ci".to_string(),
744 Pipeline {
745 providers: vec![],
746 mode: PipelineMode::default(),
747 environment: None,
748 when: None,
749 tasks: vec![],
750 derive_paths: None,
751 provider: None,
752 },
753 )]),
754 ..Default::default()
755 };
756 assert_eq!(ci.providers_for_pipeline("ci"), &["github"]);
757 }
758
759 #[test]
760 fn test_providers_for_pipeline_override() {
761 let ci = CI {
762 providers: vec!["github".to_string()],
763 pipelines: BTreeMap::from([(
764 "release".to_string(),
765 Pipeline {
766 providers: vec!["buildkite".to_string()],
767 mode: PipelineMode::default(),
768 environment: None,
769 when: None,
770 tasks: vec![],
771 derive_paths: None,
772 provider: None,
773 },
774 )]),
775 ..Default::default()
776 };
777 assert_eq!(ci.providers_for_pipeline("release"), &["buildkite"]);
778 }
779
780 #[test]
781 fn test_providers_for_pipeline_empty() {
782 let ci = CI::default();
783 assert!(ci.providers_for_pipeline("any").is_empty());
784 }
785
786 #[test]
787 fn test_providers_for_pipeline_nonexistent() {
788 let ci = CI {
789 providers: vec!["github".to_string()],
790 ..Default::default()
791 };
792 assert_eq!(ci.providers_for_pipeline("nonexistent"), &["github"]);
794 }
795}