1use serde::{Deserialize, Serialize};
2use std::collections::{BTreeMap, HashMap};
3
4use crate::tasks::TaskNode;
5
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
12#[serde(untagged)]
13pub enum AnnotationValue {
14 CaptureRef {
16 #[serde(rename = "cuenvCaptureRef")]
17 cuenv_capture_ref: bool,
18 #[serde(rename = "cuenvTask")]
19 cuenv_task: String,
20 #[serde(rename = "cuenvCapture")]
21 cuenv_capture: String,
22 },
23 Literal(String),
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
29#[serde(rename_all = "camelCase")]
30pub struct WorkflowDispatchInput {
31 pub description: String,
33 pub required: Option<bool>,
35 pub default: Option<String>,
37 #[serde(rename = "type")]
39 pub input_type: Option<String>,
40 pub options: Option<Vec<String>>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
46#[serde(untagged)]
47pub enum ManualTrigger {
48 Enabled(bool),
50 WithInputs(HashMap<String, WorkflowDispatchInput>),
52}
53
54impl ManualTrigger {
55 pub fn is_enabled(&self) -> bool {
57 match self {
58 ManualTrigger::Enabled(enabled) => *enabled,
59 ManualTrigger::WithInputs(inputs) => !inputs.is_empty(),
60 }
61 }
62
63 pub fn inputs(&self) -> Option<&HashMap<String, WorkflowDispatchInput>> {
65 match self {
66 ManualTrigger::Enabled(_) => None,
67 ManualTrigger::WithInputs(inputs) => Some(inputs),
68 }
69 }
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
73#[serde(rename_all = "camelCase")]
74pub struct PipelineCondition {
75 pub pull_request: Option<bool>,
76 #[serde(default)]
77 pub branch: Option<StringOrVec>,
78 #[serde(default)]
79 pub tag: Option<StringOrVec>,
80 pub default_branch: Option<bool>,
81 #[serde(default)]
83 pub scheduled: Option<StringOrVec>,
84 pub manual: Option<ManualTrigger>,
86 pub release: Option<Vec<String>>,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
92pub struct RunnerMapping {
93 pub arch: Option<HashMap<String, String>>,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
99#[serde(rename_all = "camelCase")]
100pub struct ArtifactDownload {
101 pub from: String,
103 pub to: String,
105 #[serde(default)]
107 pub filter: String,
108}
109
110#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
117pub struct TaskRef {
118 #[serde(rename = "_name")]
120 pub name: String,
121
122 #[serde(flatten)]
124 _rest: serde_json::Value,
125}
126
127impl<'de> serde::Deserialize<'de> for TaskRef {
128 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
129 where
130 D: serde::Deserializer<'de>,
131 {
132 use serde::de::{self, Visitor};
133
134 struct TaskRefVisitor;
135
136 impl<'de> Visitor<'de> for TaskRefVisitor {
137 type Value = TaskRef;
138
139 fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
140 formatter.write_str("an object with _name field (task reference)")
141 }
142
143 fn visit_map<M>(self, map: M) -> Result<Self::Value, M::Error>
144 where
145 M: de::MapAccess<'de>,
146 {
147 let value: serde_json::Value =
149 serde::Deserialize::deserialize(de::value::MapAccessDeserializer::new(map))?;
150
151 let name = value
152 .get("_name")
153 .and_then(|v| v.as_str())
154 .ok_or_else(|| de::Error::missing_field("_name"))?
155 .to_string();
156
157 Ok(TaskRef { name, _rest: value })
158 }
159 }
160
161 deserializer.deserialize_map(TaskRefVisitor)
162 }
163}
164
165impl TaskRef {
166 #[must_use]
168 pub fn from_name(name: impl Into<String>) -> Self {
169 Self {
170 name: name.into(),
171 _rest: serde_json::Value::Null,
172 }
173 }
174
175 #[must_use]
177 pub fn task_name(&self) -> &str {
178 &self.name
179 }
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
184#[serde(rename_all = "camelCase")]
185pub struct MatrixTask {
186 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
189 pub task_type: Option<String>,
190 pub task: TaskRef,
192 pub matrix: BTreeMap<String, Vec<String>>,
194 #[serde(default)]
196 pub artifacts: Option<Vec<ArtifactDownload>>,
197 #[serde(default)]
199 pub params: Option<BTreeMap<String, String>>,
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
204#[serde(untagged)]
205pub enum PipelineTask {
206 Matrix(MatrixTask),
209 Simple(TaskRef),
212 Node(TaskNode),
214}
215
216impl PipelineTask {
217 pub fn task_name(&self) -> &str {
224 match self {
225 PipelineTask::Matrix(matrix) => matrix.task.task_name(),
226 PipelineTask::Simple(task_ref) => task_ref.task_name(),
227 PipelineTask::Node(node) => Self::extract_task_name_from_node(node),
228 }
229 }
230
231 fn extract_task_name_from_node(node: &TaskNode) -> &str {
233 match node {
234 TaskNode::Task(task) => {
235 task.description.as_deref().unwrap_or("unnamed-task")
237 }
238 TaskNode::Group(group) => {
239 group
241 .children
242 .keys()
243 .next()
244 .map(String::as_str)
245 .unwrap_or("unnamed-group")
246 }
247 TaskNode::Sequence(sequence) => {
248 sequence
250 .first()
251 .map(Self::extract_task_name_from_node)
252 .unwrap_or("unnamed-sequence")
253 }
254 }
255 }
256
257 pub fn child_task_names(&self) -> Vec<&str> {
259 match self {
260 PipelineTask::Matrix(_) | PipelineTask::Simple(_) => vec![],
261 PipelineTask::Node(node) => Self::extract_child_names_from_node(node),
262 }
263 }
264
265 fn extract_child_names_from_node(node: &TaskNode) -> Vec<&str> {
267 match node {
268 TaskNode::Task(_) => vec![],
269 TaskNode::Group(group) => group.children.keys().map(String::as_str).collect(),
270 TaskNode::Sequence(sequence) => sequence
271 .iter()
272 .flat_map(Self::extract_child_names_from_node)
273 .collect(),
274 }
275 }
276
277 pub fn is_matrix(&self) -> bool {
279 matches!(self, PipelineTask::Matrix(_))
280 }
281
282 pub fn is_node(&self) -> bool {
284 matches!(self, PipelineTask::Node(_))
285 }
286
287 pub fn has_matrix_dimensions(&self) -> bool {
292 match self {
293 PipelineTask::Simple(_) | PipelineTask::Node(_) => false,
294 PipelineTask::Matrix(m) => !m.matrix.is_empty(),
295 }
296 }
297
298 pub fn matrix(&self) -> Option<&BTreeMap<String, Vec<String>>> {
300 match self {
301 PipelineTask::Simple(_) | PipelineTask::Node(_) => None,
302 PipelineTask::Matrix(m) => Some(&m.matrix),
303 }
304 }
305
306 pub fn as_node(&self) -> Option<&TaskNode> {
308 match self {
309 PipelineTask::Node(node) => Some(node),
310 PipelineTask::Matrix(_) | PipelineTask::Simple(_) => None,
311 }
312 }
313
314 pub fn is_simple(&self) -> bool {
316 matches!(self, PipelineTask::Simple(_))
317 }
318}
319
320pub type ProviderConfig = HashMap<String, serde_json::Value>;
336
337#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
339#[serde(rename_all = "camelCase")]
340pub struct GitHubActionConfig {
341 pub uses: String,
343
344 #[serde(default, skip_serializing_if = "BTreeMap::is_empty", rename = "with")]
346 pub inputs: BTreeMap<String, serde_json::Value>,
347}
348
349#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
355#[serde(rename_all = "lowercase")]
356pub enum PipelineMode {
357 #[default]
360 Thin,
361 Expanded,
364}
365
366#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
367#[serde(rename_all = "camelCase")]
368pub struct Pipeline {
369 #[serde(default)]
371 pub mode: PipelineMode,
372 #[serde(default, skip_serializing_if = "Vec::is_empty")]
375 pub providers: Vec<String>,
376 pub environment: Option<String>,
378 pub when: Option<PipelineCondition>,
379 #[serde(default)]
381 pub tasks: Vec<PipelineTask>,
382 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
385 pub annotations: HashMap<String, AnnotationValue>,
386 pub derive_paths: Option<bool>,
389 pub provider: Option<ProviderConfig>,
391}
392
393#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
402#[serde(rename_all = "snake_case")]
403pub enum TaskCondition {
404 OnSuccess,
406
407 OnFailure,
409
410 Always,
412}
413
414#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
417#[serde(rename_all = "camelCase")]
418pub struct ActivationCondition {
419 #[serde(skip_serializing_if = "Option::is_none")]
421 pub always: Option<bool>,
422
423 #[serde(default, skip_serializing_if = "Vec::is_empty")]
426 pub workspace_member: Vec<String>,
427
428 #[serde(default, skip_serializing_if = "Vec::is_empty")]
431 pub runtime_type: Vec<String>,
432
433 #[serde(default, skip_serializing_if = "Vec::is_empty")]
436 pub cuenv_source: Vec<String>,
437
438 #[serde(default, skip_serializing_if = "Vec::is_empty")]
441 pub secrets_provider: Vec<String>,
442
443 #[serde(default, skip_serializing_if = "Vec::is_empty")]
446 pub provider_config: Vec<String>,
447
448 #[serde(default, skip_serializing_if = "Vec::is_empty")]
450 pub task_command: Vec<String>,
451
452 #[serde(default, skip_serializing_if = "Vec::is_empty")]
454 pub task_labels: Vec<String>,
455
456 #[serde(default, skip_serializing_if = "Vec::is_empty")]
458 pub environment: Vec<String>,
459
460 #[serde(default, skip_serializing_if = "Vec::is_empty")]
462 pub service_command: Vec<String>,
463
464 #[serde(default, skip_serializing_if = "Option::is_none")]
466 pub has_service: Option<bool>,
467}
468
469#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
471#[serde(untagged)]
472pub enum SecretRef {
473 Simple(String),
475 Detailed(SecretRefConfig),
477}
478
479#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
481#[serde(rename_all = "camelCase")]
482pub struct SecretRefConfig {
483 pub source: String,
485 #[serde(default)]
487 pub cache_key: bool,
488}
489
490#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
492#[serde(rename_all = "camelCase")]
493pub struct TaskProviderConfig {
494 #[serde(skip_serializing_if = "Option::is_none")]
496 pub github: Option<GitHubActionConfig>,
497}
498
499#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
502#[serde(rename_all = "camelCase")]
503pub struct AutoAssociate {
504 #[serde(default, skip_serializing_if = "Vec::is_empty")]
506 pub command: Vec<String>,
507
508 #[serde(skip_serializing_if = "Option::is_none")]
510 pub inject_dependency: Option<String>,
511}
512
513#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
515#[serde(rename_all = "camelCase")]
516pub struct ContributorTask {
517 pub id: String,
520
521 #[serde(skip_serializing_if = "Option::is_none")]
523 pub label: Option<String>,
524
525 #[serde(skip_serializing_if = "Option::is_none")]
527 pub description: Option<String>,
528
529 #[serde(skip_serializing_if = "Option::is_none")]
531 pub command: Option<String>,
532
533 #[serde(default, skip_serializing_if = "Vec::is_empty")]
535 pub args: Vec<String>,
536
537 #[serde(skip_serializing_if = "Option::is_none")]
539 pub script: Option<String>,
540
541 #[serde(default)]
543 pub shell: bool,
544
545 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
547 pub env: HashMap<String, String>,
548
549 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
551 pub secrets: HashMap<String, SecretRef>,
552
553 #[serde(default, skip_serializing_if = "Vec::is_empty")]
555 pub inputs: Vec<String>,
556
557 #[serde(default, skip_serializing_if = "Vec::is_empty")]
559 pub outputs: Vec<String>,
560
561 #[serde(default)]
563 pub hermetic: bool,
564
565 #[serde(default, skip_serializing_if = "Vec::is_empty")]
567 pub depends_on: Vec<String>,
568
569 #[serde(default = "default_priority")]
571 pub priority: i32,
572
573 #[serde(skip_serializing_if = "Option::is_none")]
575 pub condition: Option<TaskCondition>,
576
577 #[serde(skip_serializing_if = "Option::is_none")]
579 pub provider: Option<TaskProviderConfig>,
580}
581
582const fn default_priority() -> i32 {
583 10
584}
585
586#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
589#[serde(rename_all = "camelCase")]
590pub struct Contributor {
591 pub id: String,
593
594 #[serde(skip_serializing_if = "Option::is_none")]
596 pub when: Option<ActivationCondition>,
597
598 pub tasks: Vec<ContributorTask>,
600
601 #[serde(skip_serializing_if = "Option::is_none")]
603 pub auto_associate: Option<AutoAssociate>,
604}
605
606#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
607pub struct CI {
608 #[serde(default, skip_serializing_if = "Vec::is_empty")]
612 pub providers: Vec<String>,
613 #[serde(default)]
614 pub pipelines: BTreeMap<String, Pipeline>,
615 pub provider: Option<ProviderConfig>,
617 #[serde(default, skip_serializing_if = "Vec::is_empty")]
619 pub contributors: Vec<Contributor>,
620}
621
622impl CI {
623 #[must_use]
628 pub fn providers_for_pipeline(&self, pipeline_name: &str) -> &[String] {
629 self.pipelines
630 .get(pipeline_name)
631 .filter(|p| !p.providers.is_empty())
632 .map(|p| p.providers.as_slice())
633 .unwrap_or(&self.providers)
634 }
635}
636
637#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
638#[serde(untagged)]
639pub enum StringOrVec {
640 String(String),
641 Vec(Vec<String>),
642}
643
644impl StringOrVec {
645 pub fn to_vec(&self) -> Vec<String> {
647 match self {
648 StringOrVec::String(s) => vec![s.clone()],
649 StringOrVec::Vec(v) => v.clone(),
650 }
651 }
652
653 pub fn as_single(&self) -> Option<&str> {
655 match self {
656 StringOrVec::String(s) => Some(s),
657 StringOrVec::Vec(v) => v.first().map(|s| s.as_str()),
658 }
659 }
660}
661
662#[cfg(test)]
663mod tests {
664 use super::*;
665
666 #[test]
667 fn test_string_or_vec() {
668 let single = StringOrVec::String("value".to_string());
669 assert_eq!(single.to_vec(), vec!["value"]);
670 assert_eq!(single.as_single(), Some("value"));
671
672 let multi = StringOrVec::Vec(vec!["a".to_string(), "b".to_string()]);
673 assert_eq!(multi.to_vec(), vec!["a", "b"]);
674 assert_eq!(multi.as_single(), Some("a"));
675 }
676
677 #[test]
678 fn test_manual_trigger_bool() {
679 let json = r#"{"manual": true}"#;
680 let cond: PipelineCondition = serde_json::from_str(json).unwrap();
681 assert!(matches!(cond.manual, Some(ManualTrigger::Enabled(true))));
682
683 let json = r#"{"manual": false}"#;
684 let cond: PipelineCondition = serde_json::from_str(json).unwrap();
685 assert!(matches!(cond.manual, Some(ManualTrigger::Enabled(false))));
686 }
687
688 #[test]
689 fn test_manual_trigger_with_inputs() {
690 let json =
691 r#"{"manual": {"tag_name": {"description": "Tag to release", "required": true}}}"#;
692 let cond: PipelineCondition = serde_json::from_str(json).unwrap();
693
694 match &cond.manual {
695 Some(ManualTrigger::WithInputs(inputs)) => {
696 assert!(inputs.contains_key("tag_name"));
697 let input = inputs.get("tag_name").unwrap();
698 assert_eq!(input.description, "Tag to release");
699 assert_eq!(input.required, Some(true));
700 }
701 _ => panic!("Expected WithInputs variant"),
702 }
703 }
704
705 #[test]
706 fn test_manual_trigger_helpers() {
707 let enabled = ManualTrigger::Enabled(true);
708 assert!(enabled.is_enabled());
709 assert!(enabled.inputs().is_none());
710
711 let disabled = ManualTrigger::Enabled(false);
712 assert!(!disabled.is_enabled());
713
714 let mut inputs = HashMap::new();
715 inputs.insert(
716 "tag".to_string(),
717 WorkflowDispatchInput {
718 description: "Tag name".to_string(),
719 required: Some(true),
720 default: None,
721 input_type: None,
722 options: None,
723 },
724 );
725 let with_inputs = ManualTrigger::WithInputs(inputs);
726 assert!(with_inputs.is_enabled());
727 assert!(with_inputs.inputs().is_some());
728 }
729
730 #[test]
731 fn test_scheduled_cron_expressions() {
732 let json = r#"{"scheduled": "0 0 * * 0"}"#;
734 let cond: PipelineCondition = serde_json::from_str(json).unwrap();
735 match &cond.scheduled {
736 Some(StringOrVec::String(s)) => assert_eq!(s, "0 0 * * 0"),
737 _ => panic!("Expected single string"),
738 }
739
740 let json = r#"{"scheduled": ["0 0 * * 0", "0 12 * * *"]}"#;
742 let cond: PipelineCondition = serde_json::from_str(json).unwrap();
743 match &cond.scheduled {
744 Some(StringOrVec::Vec(v)) => {
745 assert_eq!(v.len(), 2);
746 assert_eq!(v[0], "0 0 * * 0");
747 assert_eq!(v[1], "0 12 * * *");
748 }
749 _ => panic!("Expected vec"),
750 }
751 }
752
753 #[test]
754 fn test_release_trigger() {
755 let json = r#"{"release": ["published", "created"]}"#;
756 let cond: PipelineCondition = serde_json::from_str(json).unwrap();
757 assert_eq!(
758 cond.release,
759 Some(vec!["published".to_string(), "created".to_string()])
760 );
761 }
762
763 #[test]
764 fn test_pipeline_derive_paths() {
765 let json = r#"{"tasks": [{"_name": "test"}], "derivePaths": true}"#;
767 let pipeline: Pipeline = serde_json::from_str(json).unwrap();
768 assert_eq!(pipeline.derive_paths, Some(true));
769
770 let json = r#"{"tasks": [{"_name": "sync"}], "derivePaths": false}"#;
771 let pipeline: Pipeline = serde_json::from_str(json).unwrap();
772 assert_eq!(pipeline.derive_paths, Some(false));
773
774 let json = r#"{"tasks": [{"_name": "build"}]}"#;
775 let pipeline: Pipeline = serde_json::from_str(json).unwrap();
776 assert_eq!(pipeline.derive_paths, None);
777 }
778
779 #[test]
780 fn test_pipeline_task_simple() {
781 let json = r#"{"_name": "build", "command": "cargo build"}"#;
783 let task: PipelineTask = serde_json::from_str(json).unwrap();
784 assert!(matches!(task, PipelineTask::Simple(_)));
785 assert_eq!(task.task_name(), "build");
786 assert!(!task.is_matrix());
787 assert!(task.matrix().is_none());
788 }
789
790 #[test]
791 fn test_pipeline_task_matrix() {
792 let json = r#"{"type": "matrix", "task": {"_name": "release.build"}, "matrix": {"arch": ["linux-x64", "darwin-arm64"]}}"#;
794 let task: PipelineTask = serde_json::from_str(json).unwrap();
795 assert!(task.is_matrix());
796 assert_eq!(task.task_name(), "release.build");
797
798 let matrix = task.matrix().unwrap();
799 assert!(matrix.contains_key("arch"));
800 assert_eq!(matrix["arch"], vec!["linux-x64", "darwin-arm64"]);
801 }
802
803 #[test]
804 fn test_pipeline_task_matrix_with_artifacts() {
805 let json = r#"{
806 "type": "matrix",
807 "task": {"_name": "release.publish"},
808 "matrix": {},
809 "artifacts": [{"from": "release.build", "to": "dist", "filter": "*stable"}],
810 "params": {"tag": "v1.0.0"}
811 }"#;
812 let task: PipelineTask = serde_json::from_str(json).unwrap();
813
814 if let PipelineTask::Matrix(m) = task {
815 assert_eq!(m.task.task_name(), "release.publish");
816 let artifacts = m.artifacts.unwrap();
817 assert_eq!(artifacts.len(), 1);
818 assert_eq!(artifacts[0].from, "release.build");
819 assert_eq!(artifacts[0].to, "dist");
820 assert_eq!(artifacts[0].filter, "*stable");
821
822 let params = m.params.unwrap();
823 assert_eq!(params.get("tag"), Some(&"v1.0.0".to_string()));
824 } else {
825 panic!("Expected Matrix variant");
826 }
827 }
828
829 #[test]
830 fn test_pipeline_mixed_tasks() {
831 let json = r#"{
833 "tasks": [
834 {"type": "matrix", "task": {"_name": "release.build"}, "matrix": {"arch": ["linux-x64", "darwin-arm64"]}},
835 {"_name": "release.publish:github"},
836 {"_name": "docs.deploy"}
837 ]
838 }"#;
839 let pipeline: Pipeline = serde_json::from_str(json).unwrap();
840 assert_eq!(pipeline.tasks.len(), 3);
841 assert!(pipeline.tasks[0].is_matrix());
842 assert!(!pipeline.tasks[1].is_matrix());
843 assert!(!pipeline.tasks[2].is_matrix());
844 }
845
846 #[test]
847 fn test_runner_mapping() {
848 let json = r#"{"arch": {"linux-x64": "ubuntu-latest", "darwin-arm64": "macos-14"}}"#;
849 let mapping: RunnerMapping = serde_json::from_str(json).unwrap();
850 let arch = mapping.arch.unwrap();
851 assert_eq!(arch.get("linux-x64"), Some(&"ubuntu-latest".to_string()));
852 assert_eq!(arch.get("darwin-arm64"), Some(&"macos-14".to_string()));
853 }
854
855 #[test]
856 fn test_contributor_task_with_command_and_args() {
857 let json = r#"{
858 "id": "bun.workspace.install",
859 "command": "bun",
860 "args": ["install", "--frozen-lockfile"],
861 "inputs": ["package.json", "bun.lock"],
862 "outputs": ["node_modules"]
863 }"#;
864 let task: ContributorTask = serde_json::from_str(json).unwrap();
865 assert_eq!(task.id, "bun.workspace.install");
866 assert_eq!(task.command, Some("bun".to_string()));
867 assert_eq!(task.args, vec!["install", "--frozen-lockfile"]);
868 assert_eq!(task.inputs, vec!["package.json", "bun.lock"]);
869 assert_eq!(task.outputs, vec!["node_modules"]);
870 }
871
872 #[test]
873 fn test_contributor_task_with_script() {
874 let json = r#"{
875 "id": "nix.install",
876 "command": "sh",
877 "args": ["-c", "curl -sSL https://install.determinate.systems/nix | sh"]
878 }"#;
879 let task: ContributorTask = serde_json::from_str(json).unwrap();
880 assert_eq!(task.id, "nix.install");
881 assert_eq!(task.command, Some("sh".to_string()));
882 assert_eq!(
883 task.args,
884 vec![
885 "-c",
886 "curl -sSL https://install.determinate.systems/nix | sh"
887 ]
888 );
889 }
890
891 #[test]
892 fn test_contributor_with_auto_associate() {
893 let json = r#"{
894 "id": "bun.workspace",
895 "when": {"workspaceMember": ["bun"]},
896 "tasks": [{
897 "id": "bun.workspace.install",
898 "command": "bun",
899 "args": ["install"]
900 }],
901 "autoAssociate": {
902 "command": ["bun", "bunx"],
903 "injectDependency": "cuenv:contributor:bun.workspace.setup"
904 }
905 }"#;
906 let contributor: Contributor = serde_json::from_str(json).unwrap();
907 assert_eq!(contributor.id, "bun.workspace");
908
909 let when = contributor.when.unwrap();
910 assert_eq!(when.workspace_member, vec!["bun"]);
911
912 let auto = contributor.auto_associate.unwrap();
913 assert_eq!(auto.command, vec!["bun", "bunx"]);
914 assert_eq!(
915 auto.inject_dependency,
916 Some("cuenv:contributor:bun.workspace.setup".to_string())
917 );
918 }
919
920 #[test]
921 fn test_activation_condition_workspace_member() {
922 let json = r#"{"workspaceMember": ["npm", "bun"]}"#;
923 let cond: ActivationCondition = serde_json::from_str(json).unwrap();
924 assert_eq!(cond.workspace_member, vec!["npm", "bun"]);
925 }
926
927 #[test]
928 fn test_providers_for_pipeline_global() {
929 let ci = CI {
930 providers: vec!["github".to_string()],
931 pipelines: BTreeMap::from([(
932 "ci".to_string(),
933 Pipeline {
934 providers: vec![],
935 mode: PipelineMode::default(),
936 environment: None,
937 when: None,
938 tasks: vec![],
939 annotations: HashMap::new(),
940 derive_paths: None,
941 provider: None,
942 },
943 )]),
944 ..Default::default()
945 };
946 assert_eq!(ci.providers_for_pipeline("ci"), &["github"]);
947 }
948
949 #[test]
950 fn test_providers_for_pipeline_override() {
951 let ci = CI {
952 providers: vec!["github".to_string()],
953 pipelines: BTreeMap::from([(
954 "release".to_string(),
955 Pipeline {
956 providers: vec!["buildkite".to_string()],
957 mode: PipelineMode::default(),
958 environment: None,
959 when: None,
960 tasks: vec![],
961 annotations: HashMap::new(),
962 derive_paths: None,
963 provider: None,
964 },
965 )]),
966 ..Default::default()
967 };
968 assert_eq!(ci.providers_for_pipeline("release"), &["buildkite"]);
969 }
970
971 #[test]
972 fn test_providers_for_pipeline_empty() {
973 let ci = CI::default();
974 assert!(ci.providers_for_pipeline("any").is_empty());
975 }
976
977 #[test]
978 fn test_providers_for_pipeline_nonexistent() {
979 let ci = CI {
980 providers: vec!["github".to_string()],
981 ..Default::default()
982 };
983 assert_eq!(ci.providers_for_pipeline("nonexistent"), &["github"]);
985 }
986
987 #[test]
988 fn test_pipeline_task_node_task_group() {
989 let json = r#"{
991 "type": "group",
992 "http": {
993 "command": "bun",
994 "args": ["x", "wrangler", "deploy"]
995 }
996 }"#;
997 let task: PipelineTask = serde_json::from_str(json).unwrap();
998 assert!(task.is_node());
999 assert!(!task.is_matrix());
1000 assert!(!task.is_simple());
1001 assert_eq!(task.task_name(), "http");
1003 let children = task.child_task_names();
1005 assert!(children.contains(&"http"));
1006 }
1007
1008 #[test]
1009 fn test_pipeline_task_node_inline_task() {
1010 let json = r#"{
1012 "command": "echo",
1013 "args": ["hello"],
1014 "description": "Say hello"
1015 }"#;
1016 let task: PipelineTask = serde_json::from_str(json).unwrap();
1017 assert!(task.is_node());
1018 assert_eq!(task.task_name(), "Say hello");
1020 }
1021
1022 #[test]
1023 fn test_pipeline_mixed_with_node() {
1024 let json = r#"{
1026 "tasks": [
1027 {"_name": "build"},
1028 {"type": "matrix", "task": {"_name": "release"}, "matrix": {}},
1029 {"type": "group", "deploy": {"command": "deploy"}}
1030 ]
1031 }"#;
1032 let pipeline: Pipeline = serde_json::from_str(json).unwrap();
1033 assert_eq!(pipeline.tasks.len(), 3);
1034 assert!(pipeline.tasks[0].is_simple());
1035 assert!(pipeline.tasks[1].is_matrix());
1036 assert!(pipeline.tasks[2].is_node());
1037 }
1038
1039 #[test]
1040 fn test_annotation_value_serde_roundtrip() {
1041 let literal = AnnotationValue::Literal("hello".to_string());
1043 let json = serde_json::to_string(&literal).unwrap();
1044 let deserialized: AnnotationValue = serde_json::from_str(&json).unwrap();
1045 assert_eq!(literal, deserialized);
1046
1047 let capture_ref = AnnotationValue::CaptureRef {
1049 cuenv_capture_ref: true,
1050 cuenv_task: "deploy.preview".to_string(),
1051 cuenv_capture: "previewUrl".to_string(),
1052 };
1053 let json = serde_json::to_string(&capture_ref).unwrap();
1054 assert!(json.contains("cuenvCaptureRef"));
1055 assert!(json.contains("cuenvTask"));
1056 assert!(json.contains("cuenvCapture"));
1057 let deserialized: AnnotationValue = serde_json::from_str(&json).unwrap();
1058 assert_eq!(capture_ref, deserialized);
1059 }
1060
1061 #[test]
1062 fn test_pipeline_with_annotations() {
1063 let json = r#"{
1064 "tasks": [{"_name": "deploy"}],
1065 "annotations": {
1066 "Preview URL": {"cuenvCaptureRef": true, "cuenvTask": "deploy.preview", "cuenvCapture": "previewUrl"},
1067 "Version": "1.0.0"
1068 }
1069 }"#;
1070 let pipeline: Pipeline = serde_json::from_str(json).unwrap();
1071 assert_eq!(pipeline.annotations.len(), 2);
1072 assert!(matches!(
1073 pipeline.annotations.get("Version"),
1074 Some(AnnotationValue::Literal(s)) if s == "1.0.0"
1075 ));
1076 assert!(matches!(
1077 pipeline.annotations.get("Preview URL"),
1078 Some(AnnotationValue::CaptureRef { cuenv_task, cuenv_capture, .. })
1079 if cuenv_task == "deploy.preview" && cuenv_capture == "previewUrl"
1080 ));
1081 }
1082}