1use serde::{Deserialize, Serialize};
2use std::collections::{BTreeMap, HashMap};
3
4use crate::tasks::TaskNode;
5
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
8#[serde(rename_all = "camelCase")]
9pub struct WorkflowDispatchInput {
10 pub description: String,
12 pub required: Option<bool>,
14 pub default: Option<String>,
16 #[serde(rename = "type")]
18 pub input_type: Option<String>,
19 pub options: Option<Vec<String>>,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
25#[serde(untagged)]
26pub enum ManualTrigger {
27 Enabled(bool),
29 WithInputs(HashMap<String, WorkflowDispatchInput>),
31}
32
33impl ManualTrigger {
34 pub fn is_enabled(&self) -> bool {
36 match self {
37 ManualTrigger::Enabled(enabled) => *enabled,
38 ManualTrigger::WithInputs(inputs) => !inputs.is_empty(),
39 }
40 }
41
42 pub fn inputs(&self) -> Option<&HashMap<String, WorkflowDispatchInput>> {
44 match self {
45 ManualTrigger::Enabled(_) => None,
46 ManualTrigger::WithInputs(inputs) => Some(inputs),
47 }
48 }
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
52#[serde(rename_all = "camelCase")]
53pub struct PipelineCondition {
54 pub pull_request: Option<bool>,
55 #[serde(default)]
56 pub branch: Option<StringOrVec>,
57 #[serde(default)]
58 pub tag: Option<StringOrVec>,
59 pub default_branch: Option<bool>,
60 #[serde(default)]
62 pub scheduled: Option<StringOrVec>,
63 pub manual: Option<ManualTrigger>,
65 pub release: Option<Vec<String>>,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
71pub struct RunnerMapping {
72 pub arch: Option<HashMap<String, String>>,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
78#[serde(rename_all = "camelCase")]
79pub struct ArtifactDownload {
80 pub from: String,
82 pub to: String,
84 #[serde(default)]
86 pub filter: String,
87}
88
89#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
96pub struct TaskRef {
97 #[serde(rename = "_name")]
99 pub name: String,
100
101 #[serde(flatten)]
103 _rest: serde_json::Value,
104}
105
106impl<'de> serde::Deserialize<'de> for TaskRef {
107 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
108 where
109 D: serde::Deserializer<'de>,
110 {
111 use serde::de::{self, Visitor};
112
113 struct TaskRefVisitor;
114
115 impl<'de> Visitor<'de> for TaskRefVisitor {
116 type Value = TaskRef;
117
118 fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
119 formatter.write_str("an object with _name field (task reference)")
120 }
121
122 fn visit_map<M>(self, map: M) -> Result<Self::Value, M::Error>
123 where
124 M: de::MapAccess<'de>,
125 {
126 let value: serde_json::Value =
128 serde::Deserialize::deserialize(de::value::MapAccessDeserializer::new(map))?;
129
130 let name = value
131 .get("_name")
132 .and_then(|v| v.as_str())
133 .ok_or_else(|| de::Error::missing_field("_name"))?
134 .to_string();
135
136 Ok(TaskRef { name, _rest: value })
137 }
138 }
139
140 deserializer.deserialize_map(TaskRefVisitor)
141 }
142}
143
144impl TaskRef {
145 #[must_use]
147 pub fn from_name(name: impl Into<String>) -> Self {
148 Self {
149 name: name.into(),
150 _rest: serde_json::Value::Null,
151 }
152 }
153
154 #[must_use]
156 pub fn task_name(&self) -> &str {
157 &self.name
158 }
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
163#[serde(rename_all = "camelCase")]
164pub struct MatrixTask {
165 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
168 pub task_type: Option<String>,
169 pub task: TaskRef,
171 pub matrix: BTreeMap<String, Vec<String>>,
173 #[serde(default)]
175 pub artifacts: Option<Vec<ArtifactDownload>>,
176 #[serde(default)]
178 pub params: Option<BTreeMap<String, String>>,
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
183#[serde(untagged)]
184pub enum PipelineTask {
185 Matrix(MatrixTask),
188 Simple(TaskRef),
191 Node(TaskNode),
193}
194
195impl PipelineTask {
196 pub fn task_name(&self) -> &str {
203 match self {
204 PipelineTask::Matrix(matrix) => matrix.task.task_name(),
205 PipelineTask::Simple(task_ref) => task_ref.task_name(),
206 PipelineTask::Node(node) => Self::extract_task_name_from_node(node),
207 }
208 }
209
210 fn extract_task_name_from_node(node: &TaskNode) -> &str {
212 match node {
213 TaskNode::Task(task) => {
214 task.description.as_deref().unwrap_or("unnamed-task")
216 }
217 TaskNode::Group(group) => {
218 group
220 .children
221 .keys()
222 .next()
223 .map(String::as_str)
224 .unwrap_or("unnamed-group")
225 }
226 TaskNode::Sequence(sequence) => {
227 sequence
229 .first()
230 .map(Self::extract_task_name_from_node)
231 .unwrap_or("unnamed-sequence")
232 }
233 }
234 }
235
236 pub fn child_task_names(&self) -> Vec<&str> {
238 match self {
239 PipelineTask::Matrix(_) | PipelineTask::Simple(_) => vec![],
240 PipelineTask::Node(node) => Self::extract_child_names_from_node(node),
241 }
242 }
243
244 fn extract_child_names_from_node(node: &TaskNode) -> Vec<&str> {
246 match node {
247 TaskNode::Task(_) => vec![],
248 TaskNode::Group(group) => group.children.keys().map(String::as_str).collect(),
249 TaskNode::Sequence(sequence) => sequence
250 .iter()
251 .flat_map(Self::extract_child_names_from_node)
252 .collect(),
253 }
254 }
255
256 pub fn is_matrix(&self) -> bool {
258 matches!(self, PipelineTask::Matrix(_))
259 }
260
261 pub fn is_node(&self) -> bool {
263 matches!(self, PipelineTask::Node(_))
264 }
265
266 pub fn has_matrix_dimensions(&self) -> bool {
271 match self {
272 PipelineTask::Simple(_) | PipelineTask::Node(_) => false,
273 PipelineTask::Matrix(m) => !m.matrix.is_empty(),
274 }
275 }
276
277 pub fn matrix(&self) -> Option<&BTreeMap<String, Vec<String>>> {
279 match self {
280 PipelineTask::Simple(_) | PipelineTask::Node(_) => None,
281 PipelineTask::Matrix(m) => Some(&m.matrix),
282 }
283 }
284
285 pub fn as_node(&self) -> Option<&TaskNode> {
287 match self {
288 PipelineTask::Node(node) => Some(node),
289 PipelineTask::Matrix(_) | PipelineTask::Simple(_) => None,
290 }
291 }
292
293 pub fn is_simple(&self) -> bool {
295 matches!(self, PipelineTask::Simple(_))
296 }
297}
298
299pub type ProviderConfig = HashMap<String, serde_json::Value>;
315
316#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
318#[serde(rename_all = "camelCase")]
319pub struct GitHubActionConfig {
320 pub uses: String,
322
323 #[serde(default, skip_serializing_if = "BTreeMap::is_empty", rename = "with")]
325 pub inputs: BTreeMap<String, serde_json::Value>,
326}
327
328#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
334#[serde(rename_all = "lowercase")]
335pub enum PipelineMode {
336 #[default]
339 Thin,
340 Expanded,
343}
344
345#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
346#[serde(rename_all = "camelCase")]
347pub struct Pipeline {
348 #[serde(default)]
350 pub mode: PipelineMode,
351 #[serde(default, skip_serializing_if = "Vec::is_empty")]
354 pub providers: Vec<String>,
355 pub environment: Option<String>,
357 pub when: Option<PipelineCondition>,
358 #[serde(default)]
360 pub tasks: Vec<PipelineTask>,
361 pub derive_paths: Option<bool>,
364 pub provider: Option<ProviderConfig>,
366}
367
368#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
377#[serde(rename_all = "snake_case")]
378pub enum TaskCondition {
379 OnSuccess,
381
382 OnFailure,
384
385 Always,
387}
388
389#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
392#[serde(rename_all = "camelCase")]
393pub struct ActivationCondition {
394 #[serde(skip_serializing_if = "Option::is_none")]
396 pub always: Option<bool>,
397
398 #[serde(default, skip_serializing_if = "Vec::is_empty")]
401 pub workspace_member: Vec<String>,
402
403 #[serde(default, skip_serializing_if = "Vec::is_empty")]
406 pub runtime_type: Vec<String>,
407
408 #[serde(default, skip_serializing_if = "Vec::is_empty")]
411 pub cuenv_source: Vec<String>,
412
413 #[serde(default, skip_serializing_if = "Vec::is_empty")]
416 pub secrets_provider: Vec<String>,
417
418 #[serde(default, skip_serializing_if = "Vec::is_empty")]
421 pub provider_config: Vec<String>,
422
423 #[serde(default, skip_serializing_if = "Vec::is_empty")]
425 pub task_command: Vec<String>,
426
427 #[serde(default, skip_serializing_if = "Vec::is_empty")]
429 pub task_labels: Vec<String>,
430
431 #[serde(default, skip_serializing_if = "Vec::is_empty")]
433 pub environment: Vec<String>,
434}
435
436#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
438#[serde(untagged)]
439pub enum SecretRef {
440 Simple(String),
442 Detailed(SecretRefConfig),
444}
445
446#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
448#[serde(rename_all = "camelCase")]
449pub struct SecretRefConfig {
450 pub source: String,
452 #[serde(default)]
454 pub cache_key: bool,
455}
456
457#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
459#[serde(rename_all = "camelCase")]
460pub struct TaskProviderConfig {
461 #[serde(skip_serializing_if = "Option::is_none")]
463 pub github: Option<GitHubActionConfig>,
464}
465
466#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
469#[serde(rename_all = "camelCase")]
470pub struct AutoAssociate {
471 #[serde(default, skip_serializing_if = "Vec::is_empty")]
473 pub command: Vec<String>,
474
475 #[serde(skip_serializing_if = "Option::is_none")]
477 pub inject_dependency: Option<String>,
478}
479
480#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
482#[serde(rename_all = "camelCase")]
483pub struct ContributorTask {
484 pub id: String,
487
488 #[serde(skip_serializing_if = "Option::is_none")]
490 pub label: Option<String>,
491
492 #[serde(skip_serializing_if = "Option::is_none")]
494 pub description: Option<String>,
495
496 #[serde(skip_serializing_if = "Option::is_none")]
498 pub command: Option<String>,
499
500 #[serde(default, skip_serializing_if = "Vec::is_empty")]
502 pub args: Vec<String>,
503
504 #[serde(skip_serializing_if = "Option::is_none")]
506 pub script: Option<String>,
507
508 #[serde(default)]
510 pub shell: bool,
511
512 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
514 pub env: HashMap<String, String>,
515
516 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
518 pub secrets: HashMap<String, SecretRef>,
519
520 #[serde(default, skip_serializing_if = "Vec::is_empty")]
522 pub inputs: Vec<String>,
523
524 #[serde(default, skip_serializing_if = "Vec::is_empty")]
526 pub outputs: Vec<String>,
527
528 #[serde(default)]
530 pub hermetic: bool,
531
532 #[serde(default, skip_serializing_if = "Vec::is_empty")]
534 pub depends_on: Vec<String>,
535
536 #[serde(default = "default_priority")]
538 pub priority: i32,
539
540 #[serde(skip_serializing_if = "Option::is_none")]
542 pub condition: Option<TaskCondition>,
543
544 #[serde(skip_serializing_if = "Option::is_none")]
546 pub provider: Option<TaskProviderConfig>,
547}
548
549const fn default_priority() -> i32 {
550 10
551}
552
553#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
556#[serde(rename_all = "camelCase")]
557pub struct Contributor {
558 pub id: String,
560
561 #[serde(skip_serializing_if = "Option::is_none")]
563 pub when: Option<ActivationCondition>,
564
565 pub tasks: Vec<ContributorTask>,
567
568 #[serde(skip_serializing_if = "Option::is_none")]
570 pub auto_associate: Option<AutoAssociate>,
571}
572
573#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
574pub struct CI {
575 #[serde(default, skip_serializing_if = "Vec::is_empty")]
579 pub providers: Vec<String>,
580 #[serde(default)]
581 pub pipelines: BTreeMap<String, Pipeline>,
582 pub provider: Option<ProviderConfig>,
584 #[serde(default, skip_serializing_if = "Vec::is_empty")]
586 pub contributors: Vec<Contributor>,
587}
588
589impl CI {
590 #[must_use]
595 pub fn providers_for_pipeline(&self, pipeline_name: &str) -> &[String] {
596 self.pipelines
597 .get(pipeline_name)
598 .filter(|p| !p.providers.is_empty())
599 .map(|p| p.providers.as_slice())
600 .unwrap_or(&self.providers)
601 }
602}
603
604#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
605#[serde(untagged)]
606pub enum StringOrVec {
607 String(String),
608 Vec(Vec<String>),
609}
610
611impl StringOrVec {
612 pub fn to_vec(&self) -> Vec<String> {
614 match self {
615 StringOrVec::String(s) => vec![s.clone()],
616 StringOrVec::Vec(v) => v.clone(),
617 }
618 }
619
620 pub fn as_single(&self) -> Option<&str> {
622 match self {
623 StringOrVec::String(s) => Some(s),
624 StringOrVec::Vec(v) => v.first().map(|s| s.as_str()),
625 }
626 }
627}
628
629#[cfg(test)]
630mod tests {
631 use super::*;
632
633 #[test]
634 fn test_string_or_vec() {
635 let single = StringOrVec::String("value".to_string());
636 assert_eq!(single.to_vec(), vec!["value"]);
637 assert_eq!(single.as_single(), Some("value"));
638
639 let multi = StringOrVec::Vec(vec!["a".to_string(), "b".to_string()]);
640 assert_eq!(multi.to_vec(), vec!["a", "b"]);
641 assert_eq!(multi.as_single(), Some("a"));
642 }
643
644 #[test]
645 fn test_manual_trigger_bool() {
646 let json = r#"{"manual": true}"#;
647 let cond: PipelineCondition = serde_json::from_str(json).unwrap();
648 assert!(matches!(cond.manual, Some(ManualTrigger::Enabled(true))));
649
650 let json = r#"{"manual": false}"#;
651 let cond: PipelineCondition = serde_json::from_str(json).unwrap();
652 assert!(matches!(cond.manual, Some(ManualTrigger::Enabled(false))));
653 }
654
655 #[test]
656 fn test_manual_trigger_with_inputs() {
657 let json =
658 r#"{"manual": {"tag_name": {"description": "Tag to release", "required": true}}}"#;
659 let cond: PipelineCondition = serde_json::from_str(json).unwrap();
660
661 match &cond.manual {
662 Some(ManualTrigger::WithInputs(inputs)) => {
663 assert!(inputs.contains_key("tag_name"));
664 let input = inputs.get("tag_name").unwrap();
665 assert_eq!(input.description, "Tag to release");
666 assert_eq!(input.required, Some(true));
667 }
668 _ => panic!("Expected WithInputs variant"),
669 }
670 }
671
672 #[test]
673 fn test_manual_trigger_helpers() {
674 let enabled = ManualTrigger::Enabled(true);
675 assert!(enabled.is_enabled());
676 assert!(enabled.inputs().is_none());
677
678 let disabled = ManualTrigger::Enabled(false);
679 assert!(!disabled.is_enabled());
680
681 let mut inputs = HashMap::new();
682 inputs.insert(
683 "tag".to_string(),
684 WorkflowDispatchInput {
685 description: "Tag name".to_string(),
686 required: Some(true),
687 default: None,
688 input_type: None,
689 options: None,
690 },
691 );
692 let with_inputs = ManualTrigger::WithInputs(inputs);
693 assert!(with_inputs.is_enabled());
694 assert!(with_inputs.inputs().is_some());
695 }
696
697 #[test]
698 fn test_scheduled_cron_expressions() {
699 let json = r#"{"scheduled": "0 0 * * 0"}"#;
701 let cond: PipelineCondition = serde_json::from_str(json).unwrap();
702 match &cond.scheduled {
703 Some(StringOrVec::String(s)) => assert_eq!(s, "0 0 * * 0"),
704 _ => panic!("Expected single string"),
705 }
706
707 let json = r#"{"scheduled": ["0 0 * * 0", "0 12 * * *"]}"#;
709 let cond: PipelineCondition = serde_json::from_str(json).unwrap();
710 match &cond.scheduled {
711 Some(StringOrVec::Vec(v)) => {
712 assert_eq!(v.len(), 2);
713 assert_eq!(v[0], "0 0 * * 0");
714 assert_eq!(v[1], "0 12 * * *");
715 }
716 _ => panic!("Expected vec"),
717 }
718 }
719
720 #[test]
721 fn test_release_trigger() {
722 let json = r#"{"release": ["published", "created"]}"#;
723 let cond: PipelineCondition = serde_json::from_str(json).unwrap();
724 assert_eq!(
725 cond.release,
726 Some(vec!["published".to_string(), "created".to_string()])
727 );
728 }
729
730 #[test]
731 fn test_pipeline_derive_paths() {
732 let json = r#"{"tasks": [{"_name": "test"}], "derivePaths": true}"#;
734 let pipeline: Pipeline = serde_json::from_str(json).unwrap();
735 assert_eq!(pipeline.derive_paths, Some(true));
736
737 let json = r#"{"tasks": [{"_name": "sync"}], "derivePaths": false}"#;
738 let pipeline: Pipeline = serde_json::from_str(json).unwrap();
739 assert_eq!(pipeline.derive_paths, Some(false));
740
741 let json = r#"{"tasks": [{"_name": "build"}]}"#;
742 let pipeline: Pipeline = serde_json::from_str(json).unwrap();
743 assert_eq!(pipeline.derive_paths, None);
744 }
745
746 #[test]
747 fn test_pipeline_task_simple() {
748 let json = r#"{"_name": "build", "command": "cargo build"}"#;
750 let task: PipelineTask = serde_json::from_str(json).unwrap();
751 assert!(matches!(task, PipelineTask::Simple(_)));
752 assert_eq!(task.task_name(), "build");
753 assert!(!task.is_matrix());
754 assert!(task.matrix().is_none());
755 }
756
757 #[test]
758 fn test_pipeline_task_matrix() {
759 let json = r#"{"type": "matrix", "task": {"_name": "release.build"}, "matrix": {"arch": ["linux-x64", "darwin-arm64"]}}"#;
761 let task: PipelineTask = serde_json::from_str(json).unwrap();
762 assert!(task.is_matrix());
763 assert_eq!(task.task_name(), "release.build");
764
765 let matrix = task.matrix().unwrap();
766 assert!(matrix.contains_key("arch"));
767 assert_eq!(matrix["arch"], vec!["linux-x64", "darwin-arm64"]);
768 }
769
770 #[test]
771 fn test_pipeline_task_matrix_with_artifacts() {
772 let json = r#"{
773 "type": "matrix",
774 "task": {"_name": "release.publish"},
775 "matrix": {},
776 "artifacts": [{"from": "release.build", "to": "dist", "filter": "*stable"}],
777 "params": {"tag": "v1.0.0"}
778 }"#;
779 let task: PipelineTask = serde_json::from_str(json).unwrap();
780
781 if let PipelineTask::Matrix(m) = task {
782 assert_eq!(m.task.task_name(), "release.publish");
783 let artifacts = m.artifacts.unwrap();
784 assert_eq!(artifacts.len(), 1);
785 assert_eq!(artifacts[0].from, "release.build");
786 assert_eq!(artifacts[0].to, "dist");
787 assert_eq!(artifacts[0].filter, "*stable");
788
789 let params = m.params.unwrap();
790 assert_eq!(params.get("tag"), Some(&"v1.0.0".to_string()));
791 } else {
792 panic!("Expected Matrix variant");
793 }
794 }
795
796 #[test]
797 fn test_pipeline_mixed_tasks() {
798 let json = r#"{
800 "tasks": [
801 {"type": "matrix", "task": {"_name": "release.build"}, "matrix": {"arch": ["linux-x64", "darwin-arm64"]}},
802 {"_name": "release.publish:github"},
803 {"_name": "docs.deploy"}
804 ]
805 }"#;
806 let pipeline: Pipeline = serde_json::from_str(json).unwrap();
807 assert_eq!(pipeline.tasks.len(), 3);
808 assert!(pipeline.tasks[0].is_matrix());
809 assert!(!pipeline.tasks[1].is_matrix());
810 assert!(!pipeline.tasks[2].is_matrix());
811 }
812
813 #[test]
814 fn test_runner_mapping() {
815 let json = r#"{"arch": {"linux-x64": "ubuntu-latest", "darwin-arm64": "macos-14"}}"#;
816 let mapping: RunnerMapping = serde_json::from_str(json).unwrap();
817 let arch = mapping.arch.unwrap();
818 assert_eq!(arch.get("linux-x64"), Some(&"ubuntu-latest".to_string()));
819 assert_eq!(arch.get("darwin-arm64"), Some(&"macos-14".to_string()));
820 }
821
822 #[test]
823 fn test_contributor_task_with_command_and_args() {
824 let json = r#"{
825 "id": "bun.workspace.install",
826 "command": "bun",
827 "args": ["install", "--frozen-lockfile"],
828 "inputs": ["package.json", "bun.lock"],
829 "outputs": ["node_modules"]
830 }"#;
831 let task: ContributorTask = serde_json::from_str(json).unwrap();
832 assert_eq!(task.id, "bun.workspace.install");
833 assert_eq!(task.command, Some("bun".to_string()));
834 assert_eq!(task.args, vec!["install", "--frozen-lockfile"]);
835 assert_eq!(task.inputs, vec!["package.json", "bun.lock"]);
836 assert_eq!(task.outputs, vec!["node_modules"]);
837 }
838
839 #[test]
840 fn test_contributor_task_with_script() {
841 let json = r#"{
842 "id": "nix.install",
843 "command": "sh",
844 "args": ["-c", "curl -sSL https://install.determinate.systems/nix | sh"]
845 }"#;
846 let task: ContributorTask = serde_json::from_str(json).unwrap();
847 assert_eq!(task.id, "nix.install");
848 assert_eq!(task.command, Some("sh".to_string()));
849 assert_eq!(
850 task.args,
851 vec![
852 "-c",
853 "curl -sSL https://install.determinate.systems/nix | sh"
854 ]
855 );
856 }
857
858 #[test]
859 fn test_contributor_with_auto_associate() {
860 let json = r#"{
861 "id": "bun.workspace",
862 "when": {"workspaceMember": ["bun"]},
863 "tasks": [{
864 "id": "bun.workspace.install",
865 "command": "bun",
866 "args": ["install"]
867 }],
868 "autoAssociate": {
869 "command": ["bun", "bunx"],
870 "injectDependency": "cuenv:contributor:bun.workspace.setup"
871 }
872 }"#;
873 let contributor: Contributor = serde_json::from_str(json).unwrap();
874 assert_eq!(contributor.id, "bun.workspace");
875
876 let when = contributor.when.unwrap();
877 assert_eq!(when.workspace_member, vec!["bun"]);
878
879 let auto = contributor.auto_associate.unwrap();
880 assert_eq!(auto.command, vec!["bun", "bunx"]);
881 assert_eq!(
882 auto.inject_dependency,
883 Some("cuenv:contributor:bun.workspace.setup".to_string())
884 );
885 }
886
887 #[test]
888 fn test_activation_condition_workspace_member() {
889 let json = r#"{"workspaceMember": ["npm", "bun"]}"#;
890 let cond: ActivationCondition = serde_json::from_str(json).unwrap();
891 assert_eq!(cond.workspace_member, vec!["npm", "bun"]);
892 }
893
894 #[test]
895 fn test_providers_for_pipeline_global() {
896 let ci = CI {
897 providers: vec!["github".to_string()],
898 pipelines: BTreeMap::from([(
899 "ci".to_string(),
900 Pipeline {
901 providers: vec![],
902 mode: PipelineMode::default(),
903 environment: None,
904 when: None,
905 tasks: vec![],
906 derive_paths: None,
907 provider: None,
908 },
909 )]),
910 ..Default::default()
911 };
912 assert_eq!(ci.providers_for_pipeline("ci"), &["github"]);
913 }
914
915 #[test]
916 fn test_providers_for_pipeline_override() {
917 let ci = CI {
918 providers: vec!["github".to_string()],
919 pipelines: BTreeMap::from([(
920 "release".to_string(),
921 Pipeline {
922 providers: vec!["buildkite".to_string()],
923 mode: PipelineMode::default(),
924 environment: None,
925 when: None,
926 tasks: vec![],
927 derive_paths: None,
928 provider: None,
929 },
930 )]),
931 ..Default::default()
932 };
933 assert_eq!(ci.providers_for_pipeline("release"), &["buildkite"]);
934 }
935
936 #[test]
937 fn test_providers_for_pipeline_empty() {
938 let ci = CI::default();
939 assert!(ci.providers_for_pipeline("any").is_empty());
940 }
941
942 #[test]
943 fn test_providers_for_pipeline_nonexistent() {
944 let ci = CI {
945 providers: vec!["github".to_string()],
946 ..Default::default()
947 };
948 assert_eq!(ci.providers_for_pipeline("nonexistent"), &["github"]);
950 }
951
952 #[test]
953 fn test_pipeline_task_node_task_group() {
954 let json = r#"{
956 "type": "group",
957 "http": {
958 "command": "bun",
959 "args": ["x", "wrangler", "deploy"]
960 }
961 }"#;
962 let task: PipelineTask = serde_json::from_str(json).unwrap();
963 assert!(task.is_node());
964 assert!(!task.is_matrix());
965 assert!(!task.is_simple());
966 assert_eq!(task.task_name(), "http");
968 let children = task.child_task_names();
970 assert!(children.contains(&"http"));
971 }
972
973 #[test]
974 fn test_pipeline_task_node_inline_task() {
975 let json = r#"{
977 "command": "echo",
978 "args": ["hello"],
979 "description": "Say hello"
980 }"#;
981 let task: PipelineTask = serde_json::from_str(json).unwrap();
982 assert!(task.is_node());
983 assert_eq!(task.task_name(), "Say hello");
985 }
986
987 #[test]
988 fn test_pipeline_mixed_with_node() {
989 let json = r#"{
991 "tasks": [
992 {"_name": "build"},
993 {"type": "matrix", "task": {"_name": "release"}, "matrix": {}},
994 {"type": "group", "deploy": {"command": "deploy"}}
995 ]
996 }"#;
997 let pipeline: Pipeline = serde_json::from_str(json).unwrap();
998 assert_eq!(pipeline.tasks.len(), 3);
999 assert!(pipeline.tasks[0].is_simple());
1000 assert!(pipeline.tasks[1].is_matrix());
1001 assert!(pipeline.tasks[2].is_node());
1002 }
1003}