use super::workflow_actions::WorkflowPath;
use serde::de::{Deserializer, MapAccess, Visitor};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fmt;
#[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, PartialEq, Eq)]
pub enum Trigger {
PullRequest,
PullRequestTarget,
Push,
Schedule,
WorkflowDispatch,
WorkflowCall,
WorkflowRun,
Release,
Tags,
Other(String),
}
impl Trigger {
fn from_name(name: &str) -> Self {
match name {
"pull_request" => Self::PullRequest,
"pull_request_target" => Self::PullRequestTarget,
"push" => Self::Push,
"schedule" => Self::Schedule,
"workflow_dispatch" => Self::WorkflowDispatch,
"workflow_call" => Self::WorkflowCall,
"workflow_run" => Self::WorkflowRun,
"release" => Self::Release,
"tags" => Self::Tags,
other => Self::Other(other.to_owned()),
}
}
#[must_use]
pub fn as_str(&self) -> &str {
match self {
Self::PullRequest => "pull_request",
Self::PullRequestTarget => "pull_request_target",
Self::Push => "push",
Self::Schedule => "schedule",
Self::WorkflowDispatch => "workflow_dispatch",
Self::WorkflowCall => "workflow_call",
Self::WorkflowRun => "workflow_run",
Self::Release => "release",
Self::Tags => "tags",
Self::Other(s) => s.as_str(),
}
}
}
impl fmt::Display for Trigger {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
fn parse_triggers<'de, D: Deserializer<'de>>(de: D) -> Result<Vec<Trigger>, D::Error> {
struct V;
impl<'de> Visitor<'de> for V {
type Value = Vec<Trigger>;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a YAML string, list, or map describing workflow triggers")
}
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Vec<Trigger>, E> {
Ok(vec![Trigger::from_name(v)])
}
fn visit_string<E: serde::de::Error>(self, v: String) -> Result<Vec<Trigger>, E> {
Ok(vec![Trigger::from_name(&v)])
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(
self,
mut seq: A,
) -> Result<Vec<Trigger>, A::Error> {
let mut out = Vec::new();
while let Some(name) = seq.next_element::<String>()? {
out.push(Trigger::from_name(&name));
}
Ok(out)
}
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Vec<Trigger>, A::Error> {
let mut out = Vec::new();
while let Some(name) = map.next_key::<String>()? {
let _ = map.next_value::<serde::de::IgnoredAny>()?;
out.push(Trigger::from_name(&name));
}
Ok(out)
}
}
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 => true,
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, Deserialize)]
pub struct Step {
#[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>,
}
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)]
pub steps: Vec<Step>,
#[serde(default)]
pub secrets: Option<JobSecrets>,
}
#[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 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)]
jobs: BTreeMap<String, Job>,
}
fn parse_triggers_opt<'de, D: Deserializer<'de>>(de: D) -> Result<Option<Vec<Trigger>>, D::Error> {
parse_triggers(de).map(Some)
}
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,
jobs,
})
}
#[must_use]
pub fn has_trigger(&self, t: &Trigger) -> bool {
self.on.iter().any(|x| x == t)
}
}
#[cfg(test)]
mod tests;