#![expect(clippy::pub_use, reason = "reexport Trigger from extracted submodule")]
use super::workflow_actions::WorkflowPath;
use serde::de::{Deserializer, MapAccess, Visitor};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fmt;
mod de;
mod trigger;
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, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Access {
Read,
Write,
None,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Permissions {
ReadAll,
WriteAll,
Empty,
Specific(BTreeMap<String, Access>),
}
impl Permissions {
#[must_use]
pub fn is_excessive(&self) -> bool {
match self {
Self::WriteAll | Self::ReadAll => true,
Self::Empty => false,
Self::Specific(map) => map.iter().any(|(scope, access)| {
!(scope == "contents" && matches!(access, Access::Read | Access::None))
}),
}
}
#[must_use]
pub fn has_write(&self) -> bool {
match self {
Self::WriteAll => true,
Self::ReadAll | Self::Empty => false,
Self::Specific(map) => map.values().any(|a| matches!(a, Access::Write)),
}
}
}
impl<'de> Deserialize<'de> for Permissions {
fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
struct V;
impl<'de> Visitor<'de> for V {
type Value = Permissions;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("\"read-all\", \"write-all\", or a per-scope map")
}
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Permissions, E> {
match v {
"read-all" => Ok(Permissions::ReadAll),
"write-all" => Ok(Permissions::WriteAll),
other => Err(E::custom(format!("unknown permissions shorthand: {other}"))),
}
}
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Permissions, A::Error> {
let mut out = BTreeMap::new();
while let Some((k, v)) = map.next_entry::<String, Access>()? {
out.insert(k, v);
}
if out.is_empty() {
Ok(Permissions::Empty)
} else {
Ok(Permissions::Specific(out))
}
}
}
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<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 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, Clone)]
pub struct Parsed {
pub path: WorkflowPath,
pub on: Vec<Trigger>,
pub permissions: Option<Permissions>,
pub concurrency: Option<Concurrency>,
pub defaults: Option<Defaults>,
pub jobs: Vec<Job>,
}
#[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>,
}
impl Parsed {
pub fn from_yaml(path: WorkflowPath, content: &str) -> Result<Self, Box<serde_saphyr::Error>> {
let wire: WireWorkflow = serde_saphyr::from_str(content).map_err(Box::new)?;
let jobs = wire
.jobs
.into_iter()
.map(|(id, mut job)| {
job.id = id;
job
})
.collect();
Ok(Self {
path,
on: wire.on.unwrap_or_default(),
permissions: wire.permissions,
concurrency: wire.concurrency,
defaults: wire.defaults,
jobs,
})
}
#[must_use]
pub fn has_trigger(&self, t: &Trigger) -> bool {
self.on.iter().any(|x| x == t)
}
}
#[cfg(test)]
mod tests;