Skip to main content

gh_workflow/
job.rs

1//!
2//! Job-related structures and implementations for GitHub workflow jobs.
3
4use derive_setters::Setters;
5use indexmap::IndexMap;
6use merge::Merge;
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9
10use crate::concurrency::Concurrency;
11use crate::step::{Step, StepType, StepValue};
12use crate::{
13    Artifacts, Container, Defaults, Env, Expression, Input, Permissions, RetryStrategy, Secrets,
14    Strategy,
15};
16
17/// Represents the environment in which a job runs.
18#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
19#[serde(transparent)]
20pub struct RunsOn(Value);
21
22impl<T> From<T> for RunsOn
23where
24    T: Into<Value>,
25{
26    /// Converts a value into a `RunsOn` instance.
27    fn from(value: T) -> Self {
28        Self(value.into())
29    }
30}
31
32/// Represents a job in the workflow.
33/// Field order matches GitHub Actions YAML structure for better readability.
34#[derive(Debug, Setters, Serialize, Deserialize, Clone, PartialEq, Eq)]
35#[serde(rename_all = "kebab-case")]
36#[setters(strip_option, into)]
37pub struct Job {
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub needs: Option<Vec<String>>,
40    #[serde(skip_serializing_if = "Option::is_none", rename = "if")]
41    pub cond: Option<Expression>,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub name: Option<String>,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub runs_on: Option<RunsOn>,
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub permissions: Option<Permissions>,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub environment: Option<crate::Environment>,
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub concurrency: Option<Concurrency>,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub outputs: Option<IndexMap<String, String>>,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub env: Option<Env>,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub defaults: Option<Defaults>,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub timeout_minutes: Option<u32>,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub continue_on_error: Option<bool>,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub container: Option<Container>,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub services: Option<IndexMap<String, Container>>,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub strategy: Option<Strategy>,
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub steps: Option<Vec<StepValue>>,
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub uses: Option<String>,
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub secrets: Option<Secrets>,
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub retry: Option<RetryStrategy>,
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub artifacts: Option<Artifacts>,
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub with: Option<Input>,
80}
81
82impl Default for Job {
83    /// Creates a default `Job` with `runs_on` set to "ubuntu-latest".
84    fn default() -> Self {
85        Self {
86            needs: None,
87            cond: None,
88            name: None,
89            runs_on: Some(RunsOn(Value::from("ubuntu-latest"))),
90            permissions: None,
91            environment: None,
92            concurrency: None,
93            outputs: None,
94            env: None,
95            defaults: None,
96            timeout_minutes: None,
97            continue_on_error: None,
98            container: None,
99            services: None,
100            strategy: None,
101            steps: None,
102            uses: None,
103            secrets: None,
104            retry: None,
105            artifacts: None,
106            with: None,
107        }
108    }
109}
110
111impl Job {
112    /// Creates a new `Job` with the specified name and default settings.
113    pub fn new<T: ToString>(name: T) -> Self {
114        Self {
115            name: Some(name.to_string()),
116            runs_on: Some(RunsOn(Value::from("ubuntu-latest"))),
117            ..Default::default()
118        }
119    }
120
121    /// Adds a step to the job.
122    pub fn add_step<S: Into<Step<T>>, T: StepType>(mut self, step: S) -> Self {
123        let mut steps = self.steps.take().unwrap_or_default();
124        let step: Step<T> = step.into();
125        let step: StepValue = T::to_value(step);
126        steps.push(step);
127        self.steps = Some(steps);
128        self
129    }
130
131    /// Adds an environment variable to the job.
132    pub fn add_env<T: Into<Env>>(mut self, new_env: T) -> Self {
133        let mut env = self.env.take().unwrap_or_default();
134
135        env.0.extend(new_env.into().0);
136        self.env = Some(env);
137        self
138    }
139
140    pub fn add_needs<J: ToString>(mut self, job_id: J) -> Self {
141        if let Some(needs) = self.needs.as_mut() {
142            needs.push(job_id.to_string());
143        } else {
144            self.needs = Some(vec![job_id.to_string()]);
145        }
146        self
147    }
148
149    /// Adds an output to the job.
150    pub fn add_output<K: ToString, V: ToString>(mut self, key: K, value: V) -> Self {
151        let mut outputs = self.outputs.take().unwrap_or_default();
152        outputs.insert(key.to_string(), value.to_string());
153        self.outputs = Some(outputs);
154        self
155    }
156
157    /// Adds a service to the job.
158    pub fn add_service<K: ToString, V: Into<Container>>(mut self, key: K, service: V) -> Self {
159        let mut services = self.services.take().unwrap_or_default();
160        services.insert(key.to_string(), service.into());
161        self.services = Some(services);
162        self
163    }
164
165    /// Adds a new input to the job.
166    pub fn add_with<I: Into<Input>>(mut self, new_with: I) -> Self {
167        let mut with = self.with.take().unwrap_or_default();
168        with.merge(new_with.into());
169        if with.0.is_empty() {
170            self.with = None;
171        } else {
172            self.with = Some(with);
173        }
174
175        self
176    }
177
178    /// Changes job to inherit secrets.
179    /// Will silently drop any secrets added.
180    /// Mutually exclusive with `add_secret`
181    pub fn inherit_secrets(mut self) -> Self {
182        self.secrets = Some(Secrets::Inherit);
183        self
184    }
185
186    /// Adds a secret to the job.
187    /// Will silently drop/override 'inherit_secrets' if previously called.
188    /// Mutually exclusive with `inherit_secrets`
189    pub fn add_secret<K: ToString, V: Into<String>>(mut self, key: K, secret: V) -> Self {
190        let mut secrets = match self
191            .secrets
192            .take()
193            .unwrap_or(Secrets::Values(IndexMap::default()))
194        {
195            Secrets::Inherit => IndexMap::default(),
196            Secrets::Values(values) => values,
197        };
198        secrets.insert(key.to_string(), secret.into());
199        self.secrets = Some(Secrets::Values(secrets));
200        self
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn test_job_default_sets_runs_on() {
210        let job = Job::default();
211        assert!(job.runs_on.is_some());
212
213        // Verify it's set to "ubuntu-latest"
214        if let Some(runs_on) = job.runs_on {
215            assert_eq!(
216                runs_on.0,
217                serde_json::Value::String("ubuntu-latest".to_string())
218            );
219        }
220    }
221}