#![expect(
clippy::pub_use,
reason = "reexport Trigger and Permissions from extracted submodules"
)]
#![expect(
clippy::module_name_repetitions,
reason = "ParsedWorkflow/ParsedAction name the variants of Parsed; the shared prefix is \
the point, and callers import them from this module's public surface"
)]
#![expect(
clippy::multiple_inherent_impl,
reason = "parsing constructors need WireWorkflow, which lives here; the accessors live \
beside the type in file.rs"
)]
use super::site::WorkflowPath;
use serde::de::{Deserializer, MapAccess, Visitor};
use serde::{Deserialize, Serialize};
use serde_saphyr::{Commented, Spanned};
use std::collections::BTreeMap;
use std::fmt;
mod de;
mod file;
mod kind;
mod permissions;
mod trigger;
pub use file::{Parsed, ParsedAction, ParsedWorkflow};
pub use kind::FileKind;
pub use permissions::{Access, Permissions};
pub use trigger::Trigger;
use de::deserialize_needs;
use trigger::parse_triggers_opt;
#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
pub struct AnyScalar(pub String);
impl AnyScalar {
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl<'de> Deserialize<'de> for AnyScalar {
fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
struct V;
impl Visitor<'_> for V {
type Value = AnyScalar;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a YAML scalar")
}
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<AnyScalar, E> {
Ok(AnyScalar(v.to_owned()))
}
fn visit_string<E: serde::de::Error>(self, v: String) -> Result<AnyScalar, E> {
Ok(AnyScalar(v))
}
fn visit_bool<E: serde::de::Error>(self, v: bool) -> Result<AnyScalar, E> {
Ok(AnyScalar(v.to_string()))
}
fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<AnyScalar, E> {
Ok(AnyScalar(v.to_string()))
}
fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<AnyScalar, E> {
Ok(AnyScalar(v.to_string()))
}
fn visit_f64<E: serde::de::Error>(self, v: f64) -> Result<AnyScalar, E> {
Ok(AnyScalar(v.to_string()))
}
fn visit_unit<E: serde::de::Error>(self) -> Result<AnyScalar, E> {
Ok(AnyScalar(String::new()))
}
fn visit_none<E: serde::de::Error>(self) -> Result<AnyScalar, E> {
Ok(AnyScalar(String::new()))
}
}
de.deserialize_any(V)
}
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct Concurrency {
#[serde(default)]
pub group: Option<String>,
#[serde(default, rename = "cancel-in-progress")]
pub cancel_in_progress: Option<bool>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct Defaults {
#[serde(default)]
pub run: Option<RunDefaults>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct RunDefaults {
#[serde(default)]
pub shell: Option<String>,
}
impl Defaults {
fn run_shell(&self) -> Option<&str> {
self.run.as_ref().and_then(|r| r.shell.as_deref())
}
}
#[must_use]
pub fn effective_shell(
step_shell: Option<&str>,
job_defaults: Option<&Defaults>,
workflow_defaults: Option<&Defaults>,
) -> String {
let raw = step_shell
.or_else(|| job_defaults.and_then(Defaults::run_shell))
.or_else(|| workflow_defaults.and_then(Defaults::run_shell))
.unwrap_or("bash");
normalize_shell(raw)
}
fn normalize_shell(raw: &str) -> String {
raw.split_whitespace().next().unwrap_or("bash").to_owned()
}
#[derive(Debug, Clone, Deserialize)]
pub struct Step {
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub uses: Option<Spanned<Commented<String>>>,
#[serde(default, rename = "if")]
pub if_cond: Option<String>,
#[serde(default)]
pub with: BTreeMap<String, AnyScalar>,
#[serde(default)]
pub env: BTreeMap<String, AnyScalar>,
#[serde(default)]
pub run: Option<String>,
#[serde(default)]
pub shell: Option<String>,
}
impl Step {
#[must_use]
pub fn uses_ref(&self) -> Option<&str> {
self.uses.as_ref().map(|s| s.value.0.as_str())
}
#[must_use]
pub fn uses_comment(&self) -> Option<&str> {
self.uses
.as_ref()
.map(|s| s.value.1.as_str())
.filter(|c| !c.is_empty())
}
#[must_use]
pub fn uses_line(&self) -> Option<u32> {
self.uses
.as_ref()
.map(|s| s.referenced.line())
.filter(|&line| line != 0)
.and_then(|line| u32::try_from(line).ok())
}
#[must_use]
pub fn scalar_text(&self) -> String {
let mut out = String::new();
for v in self.with.values() {
out.push_str(v.as_str());
out.push('\n');
}
for v in self.env.values() {
out.push_str(v.as_str());
out.push('\n');
}
if let Some(run) = &self.run {
out.push_str(run);
}
out
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct Job {
#[serde(skip)]
pub id: String,
#[serde(default)]
pub permissions: Option<Permissions>,
#[serde(default, rename = "if")]
pub if_cond: Option<String>,
#[serde(default, deserialize_with = "deserialize_needs")]
pub needs: Vec<String>,
#[serde(default)]
pub outputs: BTreeMap<String, String>,
#[serde(default)]
pub steps: Vec<Step>,
#[serde(default)]
pub secrets: Option<JobSecrets>,
#[serde(default)]
pub defaults: Option<Defaults>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum JobSecrets {
Inherit,
Explicit,
}
impl<'de> Deserialize<'de> for JobSecrets {
fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
struct V;
impl<'de> Visitor<'de> for V {
type Value = JobSecrets;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("\"inherit\" or a secrets map")
}
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<JobSecrets, E> {
Ok(if v == "inherit" {
JobSecrets::Inherit
} else {
JobSecrets::Explicit
})
}
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<JobSecrets, A::Error> {
while map
.next_entry::<serde::de::IgnoredAny, serde::de::IgnoredAny>()?
.is_some()
{}
Ok(JobSecrets::Explicit)
}
}
de.deserialize_any(V)
}
}
#[derive(Debug, Deserialize)]
struct WireWorkflow {
#[serde(default, deserialize_with = "parse_triggers_opt")]
on: Option<Vec<Trigger>>,
#[serde(default)]
permissions: Option<Permissions>,
#[serde(default)]
concurrency: Option<Concurrency>,
#[serde(default)]
defaults: Option<Defaults>,
#[serde(default)]
jobs: BTreeMap<String, Job>,
#[serde(default)]
runs: Option<Runs>,
}
#[derive(Debug, Deserialize)]
struct Runs {
#[serde(default)]
using: Option<String>,
#[serde(default)]
steps: Vec<Step>,
}
impl Parsed {
#[cfg(test)]
pub fn from_yaml(
path: WorkflowPath,
content: &str,
) -> Result<ParsedWorkflow, Box<serde_saphyr::Error>> {
let wire = Self::deserialize(content)?;
Ok(Self::into_workflow(path, wire))
}
pub fn parse(
path: WorkflowPath,
kind: FileKind,
content: &str,
) -> Result<Self, Box<serde_saphyr::Error>> {
let wire = Self::deserialize(content)?;
Ok(match kind {
FileKind::Workflow => Self::Workflow(Self::into_workflow(path, wire)),
FileKind::ActionDefinition => Self::Action(ParsedAction {
path,
steps: wire
.runs
.filter(|runs| runs.using.as_deref() == Some("composite"))
.map(|runs| runs.steps)
.unwrap_or_default(),
}),
})
}
fn deserialize(content: &str) -> Result<WireWorkflow, Box<serde_saphyr::Error>> {
serde_saphyr::from_str(content).map_err(Box::new)
}
fn into_workflow(path: WorkflowPath, wire: WireWorkflow) -> ParsedWorkflow {
ParsedWorkflow {
path,
on: wire.on.unwrap_or_default(),
permissions: wire.permissions,
concurrency: wire.concurrency,
defaults: wire.defaults,
jobs: wire
.jobs
.into_iter()
.map(|(id, mut job)| {
job.id = id;
job
})
.collect(),
}
}
}
#[cfg(test)]
mod tests;