diff --git a/Cargo.toml b/Cargo.toml index 7eb9732..fe8c1f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = ["crates/*"] # Core dependencies async-trait = "0.1.89" derive_more = { version = "2.0.1", features = ["from", "deref", "deref_mut"] } -derive_setters = "0.1.8" +derive_setters = "0.1.9" heck = "0.5.0" indexmap = { version = "2.12.0", features = ["serde"] } merge = "0.2.0" diff --git a/crates/gh-workflow/src/job.rs b/crates/gh-workflow/src/job.rs index a905787..0b22393 100644 --- a/crates/gh-workflow/src/job.rs +++ b/crates/gh-workflow/src/job.rs @@ -10,8 +10,8 @@ use serde_json::Value; use crate::concurrency::Concurrency; use crate::step::{Step, StepType, StepValue}; use crate::{ - Artifacts, Container, Defaults, Env, Expression, Input, Permissions, RetryStrategy, Secrets, - Strategy, + private, Artifacts, Container, Defaults, Env, Expression, Input, Permissions, RetryStrategy, + Secrets, Strategy, }; /// Represents the environment in which a job runs. @@ -29,12 +29,148 @@ where } } +/// A trait implemented by the different kinds of jobs (`RunJob` and +/// `UsesJob`) to convert them into a serializable `JobValue`. +pub trait JobType: Default + private::Sealed { + /// Converts a job to its value representation. + fn to_value(j: Job) -> JobValue; +} + +/// Configuration specific to jobs that run steps. +#[derive(Debug, Clone, Setters, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +#[setters( + strip_option, + into, + generate_delegates(ty = "Job", field = "config") +)] +pub struct RunJob { + #[serde(skip_serializing_if = "Option::is_none")] + pub runs_on: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub environment: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub outputs: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub env: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub defaults: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub timeout_minutes: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub continue_on_error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub container: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub services: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub steps: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub retry: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub artifacts: Option, +} + +impl Default for RunJob { + /// Creates a default `RunJob` with `runs_on` set to "ubuntu-latest". + fn default() -> Self { + Self { + runs_on: Some(RunsOn(Value::from("ubuntu-latest"))), + environment: None, + outputs: None, + env: None, + defaults: None, + timeout_minutes: None, + continue_on_error: None, + container: None, + services: None, + steps: None, + retry: None, + artifacts: None, + } + } +} + +/// Configuration specific to jobs that call a reusable workflow. +#[derive(Debug, Clone, Default, Setters, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +#[setters( + strip_option, + into, + generate_delegates(ty = "Job", field = "config") +)] +pub struct UsesJob { + #[setters(skip)] + pub uses: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub with: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub secrets: Option, +} + +impl private::Sealed for RunJob {} +impl private::Sealed for UsesJob {} + +impl JobType for RunJob { + fn to_value(j: Job) -> JobValue { + let Self { + runs_on, + environment, + outputs, + env, + defaults, + timeout_minutes, + continue_on_error, + container, + services, + steps, + retry, + artifacts, + } = j.config; + JobValue { + runs_on, + environment, + outputs, + env, + defaults, + timeout_minutes, + continue_on_error, + container, + services, + steps, + retry, + artifacts, + ..j.value + } + } +} + +impl JobType for UsesJob { + fn to_value(j: Job) -> JobValue { + let Self { uses, with, secrets } = j.config; + JobValue { uses: Some(uses), with, secrets, ..j.value } + } +} + /// Represents a job in the workflow. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Job { + #[serde(flatten)] + config: J, + #[serde(flatten)] + value: JobValue, +} + +/// The serializable value of a job in the workflow. /// Field order matches GitHub Actions YAML structure for better readability. -#[derive(Debug, Setters, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[derive(Debug, Setters, Serialize, Deserialize, Clone, Default, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] -#[setters(strip_option, into)] -pub struct Job { +#[setters( + strip_option, + into, + generate_delegates(ty = "Job", generics = "", field = "value") +)] +pub struct JobValue { #[serde(skip_serializing_if = "Option::is_none")] pub needs: Option>, #[serde(skip_serializing_if = "Option::is_none", rename = "if")] @@ -42,134 +178,148 @@ pub struct Job { #[serde(skip_serializing_if = "Option::is_none")] pub name: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[setters(skip)] pub runs_on: Option, #[serde(skip_serializing_if = "Option::is_none")] pub permissions: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[setters(skip)] pub environment: Option, #[serde(skip_serializing_if = "Option::is_none")] pub concurrency: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[setters(skip)] pub outputs: Option>, #[serde(skip_serializing_if = "Option::is_none")] + #[setters(skip)] pub env: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[setters(skip)] pub defaults: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[setters(skip)] pub timeout_minutes: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[setters(skip)] pub continue_on_error: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[setters(skip)] pub container: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[setters(skip)] pub services: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub strategy: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[setters(skip)] pub steps: Option>, #[serde(skip_serializing_if = "Option::is_none")] + #[setters(skip)] pub uses: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[setters(skip)] pub secrets: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[setters(skip)] pub retry: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[setters(skip)] pub artifacts: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[setters(skip)] pub with: Option, } -impl Default for Job { - /// Creates a default `Job` with `runs_on` set to "ubuntu-latest". - fn default() -> Self { - Self { - needs: None, - cond: None, - name: None, - runs_on: Some(RunsOn(Value::from("ubuntu-latest"))), - permissions: None, - environment: None, - concurrency: None, - outputs: None, - env: None, - defaults: None, - timeout_minutes: None, - continue_on_error: None, - container: None, - services: None, - strategy: None, - steps: None, - uses: None, - secrets: None, - retry: None, - artifacts: None, - with: None, - } - } -} - impl Job { /// Creates a new `Job` with the specified name and default settings. pub fn new(name: T) -> Self { Self { - name: Some(name.to_string()), - runs_on: Some(RunsOn(Value::from("ubuntu-latest"))), + value: JobValue { name: Some(name.to_string()), ..Default::default() }, ..Default::default() } } - /// Adds a step to the job. - pub fn add_step>, T: StepType>(mut self, step: S) -> Self { - let mut steps = self.steps.take().unwrap_or_default(); - let step: Step = step.into(); - let step: StepValue = T::to_value(step); - steps.push(step); - self.steps = Some(steps); - self - } - - /// Adds an environment variable to the job. - pub fn add_env>(mut self, new_env: T) -> Self { - let mut env = self.env.take().unwrap_or_default(); - - env.0.extend(new_env.into().0); - self.env = Some(env); - self + /// Creates a new `Job` that calls a reusable workflow. + pub fn uses( + self, + owner: Owner, + repo: Repo, + path: Path, + version: Version, + ) -> Job { + Job { + config: UsesJob { + uses: format!( + "{}/{}/{}@{}", + owner.to_string(), + repo.to_string(), + path.to_string(), + version.to_string() + ), + ..Default::default() + }, + value: self.value, + } } +} - pub fn add_needs(mut self, job_id: J) -> Self { - if let Some(needs) = self.needs.as_mut() { +impl Job { + pub fn add_needs(mut self, job_id: T) -> Self { + if let Some(needs) = self.value.needs.as_mut() { needs.push(job_id.to_string()); } else { - self.needs = Some(vec![job_id.to_string()]); + self.value.needs = Some(vec![job_id.to_string()]); } self } +} +impl Job { /// Adds an output to the job. pub fn add_output(mut self, key: K, value: V) -> Self { - let mut outputs = self.outputs.take().unwrap_or_default(); + let mut outputs = self.config.outputs.take().unwrap_or_default(); outputs.insert(key.to_string(), value.to_string()); - self.outputs = Some(outputs); + self.config.outputs = Some(outputs); self } /// Adds a service to the job. pub fn add_service>(mut self, key: K, service: V) -> Self { - let mut services = self.services.take().unwrap_or_default(); + let mut services = self.config.services.take().unwrap_or_default(); services.insert(key.to_string(), service.into()); - self.services = Some(services); + self.config.services = Some(services); self } + /// Adds a step to the job. + pub fn add_step>, T: StepType>(mut self, step: S) -> Self { + let mut steps = self.config.steps.take().unwrap_or_default(); + let step: Step = step.into(); + let step: StepValue = T::to_value(step); + steps.push(step); + self.config.steps = Some(steps); + self + } + + /// Adds an environment variable to the job. + pub fn add_env>(mut self, new_env: E) -> Self { + let mut env = self.config.env.take().unwrap_or_default(); + + env.0.extend(new_env.into().0); + self.config.env = Some(env); + self + } +} + +impl Job { /// Adds a new input to the job. pub fn add_with>(mut self, new_with: I) -> Self { - let mut with = self.with.take().unwrap_or_default(); + let mut with = self.config.with.take().unwrap_or_default(); with.merge(new_with.into()); if with.0.is_empty() { - self.with = None; + self.config.with = None; } else { - self.with = Some(with); + self.config.with = Some(with); } self @@ -179,7 +329,7 @@ impl Job { /// Will silently drop any secrets added. /// Mutually exclusive with `add_secret` pub fn inherit_secrets(mut self) -> Self { - self.secrets = Some(Secrets::Inherit); + self.config.secrets = Some(Secrets::Inherit); self } @@ -188,6 +338,7 @@ impl Job { /// Mutually exclusive with `inherit_secrets` pub fn add_secret>(mut self, key: K, secret: V) -> Self { let mut secrets = match self + .config .secrets .take() .unwrap_or(Secrets::Values(IndexMap::default())) @@ -196,7 +347,7 @@ impl Job { Secrets::Values(values) => values, }; secrets.insert(key.to_string(), secret.into()); - self.secrets = Some(Secrets::Values(secrets)); + self.config.secrets = Some(Secrets::Values(secrets)); self } } @@ -207,15 +358,27 @@ mod tests { #[test] fn test_job_default_sets_runs_on() { - let job = Job::default(); - assert!(job.runs_on.is_some()); + let value = RunJob::to_value(Job::default()); + assert!(value.runs_on.is_some()); // Verify it's set to "ubuntu-latest" - if let Some(runs_on) = job.runs_on { + if let Some(runs_on) = value.runs_on { assert_eq!( runs_on.0, serde_json::Value::String("ubuntu-latest".to_string()) ); } } + + #[test] + fn test_uses_job_has_no_runs_on() { + let job = Job::new("Reusable Job").uses("owner", "repo", ".github/workflows/ci.yml", "v1"); + let value = UsesJob::to_value(job); + + assert_eq!( + value.uses.as_deref(), + Some("owner/repo/.github/workflows/ci.yml@v1") + ); + assert!(value.runs_on.is_none()); + } } diff --git a/crates/gh-workflow/src/workflow.rs b/crates/gh-workflow/src/workflow.rs index d8b9338..489fd1d 100644 --- a/crates/gh-workflow/src/workflow.rs +++ b/crates/gh-workflow/src/workflow.rs @@ -14,14 +14,16 @@ use crate::error::Result; use crate::generate::Generate; use crate::job::Job; use crate::permissions::Permissions; -use crate::Event; +use crate::{Event, JobType, JobValue}; #[derive(Debug, Default, Serialize, Deserialize, Clone)] #[serde(transparent)] -pub struct Jobs(pub(crate) IndexMap); +pub struct Jobs(pub(crate) IndexMap); impl Jobs { - pub fn add(mut self, key: String, value: Job) -> Self { - self.0.insert(key, value); + pub fn add>, T: JobType>(mut self, key: String, value: J) -> Self { + let job: Job = value.into(); + let job: JobValue = T::to_value(job); + self.0.insert(key, job); self } @@ -33,8 +35,8 @@ impl Jobs { /// /// # Returns /// - /// Returns `Some(&Job)` if the job exists, `None` otherwise. - pub fn get(&self, key: &str) -> Option<&Job> { + /// Returns `Some(&JobValue)` if the job exists, `None` otherwise. + pub fn get(&self, key: &str) -> Option<&JobValue> { self.0.get(key) } } @@ -115,7 +117,7 @@ impl Workflow { } /// Adds a job to the workflow with the specified ID and job configuration. - pub fn add_job>(mut self, id: T, job: J) -> Self { + pub fn add_job>, T: JobType>(mut self, id: I, job: J) -> Self { let key = id.to_string(); let jobs = self.jobs.take().unwrap_or_default().add(key, job.into());