use super::keys::{
BuiltinInput, Ext, TASK_POLICIES, TASK_V3_KEYS, WORKFLOW_POLICIES, builtin_input,
};
use super::parse::{descends, is_group, step_kind};
use super::{V3_VALIDATE, VALIDATION};
use crate::base::typo::unknown_key;
use crate::base::{AuthoredPath, Issue, IssueCode, StepKind};
use serde_json::{Map, Value};
use std::collections::HashMap;
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct Rewrite {
pub path: AuthoredPath,
pub kind: RewriteKind,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum RewriteKind {
Policy {
v3: String,
v4: Vec<(String, String)>,
},
Function {
v3: String,
v4: String,
},
}
impl std::fmt::Display for RewriteKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Policy { v3, v4 } => {
let v4 = v4
.iter()
.map(|(key, value)| format!("{key}: {value}"))
.collect::<Vec<_>>()
.join(", ");
write!(f, "`{v3}` is a v3 spelling, read as `{v4}`")
}
Self::Function { v3, v4 } => {
write!(f, "`{v3}` is a v3 spelling, read as `{v4}`")
}
}
}
}
impl std::fmt::Display for Rewrite {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.kind.fmt(f)
}
}
pub(super) struct Read {
pub(super) issues: Vec<Issue>,
pub(super) rewrites: Vec<Rewrite>,
pub(super) exts: HashMap<AuthoredPath, Ext>,
}
pub(super) fn read(workflow: &mut Map<String, Value>) -> Read {
let mut reader = Read::new();
reader.workflow(workflow);
reader
}
pub(super) fn read_steps(
items: &mut [Value],
at: &AuthoredPath,
setup: bool,
depth: usize,
) -> Read {
let mut reader = Read::new();
reader.steps(items, at, setup, depth);
reader
}
struct Step {
kind: StepKind,
id: Option<String>,
}
enum Flag {
Absent,
Valid {
text: String,
set: bool,
},
Refused {
code: IssueCode,
message: String,
},
}
impl Flag {
fn is_set(&self) -> bool {
matches!(self, Self::Valid { set: true, .. })
}
}
fn take(obj: &mut Map<String, Value>, key: &str) -> Option<Value> {
obj.shift_remove(key)
}
fn keys_among(obj: &Map<String, Value>, wanted: &[&'static str]) -> Vec<&'static str> {
obj.keys()
.filter_map(|key| wanted.iter().copied().find(|w| *w == key))
.collect()
}
fn policies_in(obj: &Map<String, Value>, policies: &[&'static str]) -> Vec<&'static str> {
policies
.iter()
.copied()
.filter(|key| obj.contains_key(*key))
.collect()
}
fn mixed(v3: &str, v4: &[&str], node: &str, policies: &str) -> String {
let v4: Vec<String> = v4.iter().map(|key| format!("`{key}`")).collect();
format!(
"v3's `{v3}` and v4's {} on one {node}; write only the v4 {policies}",
v4.join(" and ")
)
}
impl Read {
fn new() -> Self {
Self {
issues: Vec::new(),
rewrites: Vec::new(),
exts: HashMap::new(),
}
}
fn report(
&mut self,
code: IssueCode,
path: AuthoredPath,
message: impl Into<String>,
step: Option<&Step>,
field: Option<&str>,
) {
let mut issue = Issue::new(code, path, message);
if let Some(step) = step {
issue = issue.with_step(step.kind, step.id.as_deref());
}
if let Some(field) = field {
issue = issue.with_field(field);
}
self.issues.push(issue);
}
fn rewrite(
&mut self,
path: AuthoredPath,
kind: RewriteKind,
step: Option<&Step>,
field: Option<&str>,
) {
let message = kind.to_string();
self.report(IssueCode::V3Spelling, path.clone(), message, step, field);
self.rewrites.push(Rewrite { path, kind });
}
fn workflow(&mut self, obj: &mut Map<String, Value>) {
const WANTED: &[&str] = &["continue_on_error", "loop", "tasks"];
let root = AuthoredPath::root();
for key in keys_among(obj, WANTED) {
match key {
"continue_on_error" => self.workflow_flag(obj, &root),
"loop" => {
if let Some(Value::Object(config)) = obj.get_mut("loop")
&& let Some(Value::Array(setup)) = config.get_mut("setup")
{
self.steps(setup, &root.join("loop").join("setup"), true, 0);
}
}
"tasks" => {
if let Some(Value::Array(tasks)) = obj.get_mut("tasks") {
self.steps(tasks, &root.join("tasks"), false, 0);
}
}
_ => {}
}
}
}
fn workflow_flag(&mut self, obj: &mut Map<String, Value>, at: &AuthoredPath) {
let Some(raw) = take(obj, "continue_on_error") else {
return;
};
let path = at.join("continue_on_error");
let Value::Bool(flag) = raw else {
self.report(
IssueCode::ParseFailed,
path,
"expected a boolean",
None,
None,
);
return;
};
let v4 = policies_in(obj, WORKFLOW_POLICIES);
if !v4.is_empty() {
let message = mixed("continue_on_error", &v4, "workflow", "policy");
self.report(IssueCode::MixedSpelling, path, message, None, None);
return;
}
let policy = if flag { "continue" } else { "fail" };
obj.insert("on_failure".into(), Value::String(policy.into()));
let (v3, v4) = (
format!("continue_on_error: {flag}"),
vec![("on_failure".to_string(), policy.to_string())],
);
self.rewrite(path, RewriteKind::Policy { v3, v4 }, None, None);
}
fn steps(&mut self, items: &mut [Value], at: &AuthoredPath, setup: bool, depth: usize) {
for (i, node) in items.iter_mut().enumerate() {
if let Value::Object(obj) = node {
self.step(obj, at.join(i), setup, depth);
}
}
}
fn step(&mut self, obj: &mut Map<String, Value>, at: AuthoredPath, setup: bool, depth: usize) {
let group = is_group(obj);
let step = Step {
kind: step_kind(obj, setup),
id: obj
.get("id")
.and_then(Value::as_str)
.filter(|id| !id.is_empty())
.map(str::to_string),
};
if group {
self.group(obj, &at, &step, setup, depth);
} else {
self.task(obj, &at, &step);
}
}
fn group(
&mut self,
obj: &mut Map<String, Value>,
at: &AuthoredPath,
step: &Step,
setup: bool,
depth: usize,
) {
const WANTED: &[&str] = &["continue_on_error", "tasks"];
for key in keys_among(obj, WANTED) {
if key == "continue_on_error" {
self.group_flag(obj, at, step);
} else if descends(depth)
&& let Some(Value::Array(members)) = obj.get_mut("tasks")
{
self.steps(members, &at.join("tasks"), setup, depth + 1);
}
}
}
fn group_flag(&mut self, obj: &mut Map<String, Value>, at: &AuthoredPath, step: &Step) {
const KEY: &str = "continue_on_error";
let Some(value) = take(obj, KEY) else {
return;
};
let (code, message) = if value == Value::Bool(true) {
let message = format!(
"group '{}' carries continue_on_error, which the engine does not honour — error \
handling is per task and per workflow, and a group only gates a span. Put it \
on the tasks inside the group, or on the workflow",
step.id.as_deref().unwrap_or_default()
);
(IssueCode::GroupContinueOnError, message)
} else {
let message = format!(
"unknown key `{KEY}`: a group has no failure policy (kept in `ext` as `{KEY}`)"
);
(IssueCode::UnknownKey, message)
};
let moved = Moved {
key: KEY,
name: KEY.to_string(),
value,
path: at.join(KEY),
field: None,
};
self.keep_in_ext(obj, at, moved, code, message, step);
}
fn task(&mut self, obj: &mut Map<String, Value>, at: &AuthoredPath, step: &Step) {
if !TASK_V3_KEYS.iter().any(|key| obj.contains_key(*key)) {
self.function(obj, at, step);
return;
}
const WANTED: &[&str] = &["continue_on_error", "halt_on", "function"];
let order = keys_among(obj, WANTED);
let v4_policies = policies_in(obj, TASK_POLICIES);
let refuse_mixed = |v3: &str| Flag::Refused {
code: IssueCode::MixedSpelling,
message: mixed(v3, &v4_policies, "task", "policies"),
};
let continue_on_error = match take(obj, "continue_on_error") {
None => Flag::Absent,
Some(Value::Bool(_)) if !v4_policies.is_empty() => refuse_mixed("continue_on_error"),
Some(Value::Bool(set)) => Flag::Valid {
text: format!("continue_on_error: {set}"),
set,
},
Some(_) => Flag::Refused {
code: IssueCode::ParseFailed,
message: "expected a boolean".into(),
},
};
let halt_on = match take(obj, "halt_on") {
None => Flag::Absent,
Some(raw) => match raw.as_str() {
Some("never" | "failure") if !v4_policies.is_empty() => refuse_mixed("halt_on"),
Some(spelling @ ("never" | "failure")) => Flag::Valid {
text: format!("halt_on: {spelling}"),
set: spelling == "failure",
},
_ => Flag::Refused {
code: IssueCode::InvalidHaltOn,
message: "halt_on must be one of \"never\", \"failure\"".into(),
},
},
};
let v4: Vec<(String, String)> = if v4_policies.is_empty() {
let (on_failure, on_rejection) = match (continue_on_error.is_set(), halt_on.is_set()) {
(false, false) => ("fail", "continue"),
(true, false) => ("continue", "continue"),
(false, true) => ("fail", "halt"),
(true, true) => ("halt", "halt"),
};
obj.insert("on_failure".into(), Value::String(on_failure.into()));
obj.insert("on_rejection".into(), Value::String(on_rejection.into()));
vec![
("on_failure".to_string(), on_failure.to_string()),
("on_rejection".to_string(), on_rejection.to_string()),
]
} else {
Vec::new()
};
for key in order {
match key {
"function" => self.function(obj, at, step),
_ => {
let flag = if key == "halt_on" {
&halt_on
} else {
&continue_on_error
};
let path = at.join(key);
match flag {
Flag::Absent => {}
Flag::Valid { text, .. } => {
self.rewrite(
path,
RewriteKind::Policy {
v3: text.clone(),
v4: v4.clone(),
},
Some(step),
None,
);
}
Flag::Refused { code, message } => {
self.report(*code, path, message.clone(), Some(step), None);
}
}
}
}
}
}
fn function(&mut self, obj: &mut Map<String, Value>, at: &AuthoredPath, step: &Step) {
let Some(Value::Object(slot)) = obj.get_mut("function") else {
return;
};
let mut fields = std::mem::take(slot);
let task = at;
let at = at.join("function");
let (is_validate, builtin) = match fields.get("name").and_then(Value::as_str) {
Some(V3_VALIDATE) => (true, builtin_input(VALIDATION)),
Some(name) => (false, builtin_input(name)),
None => (false, None),
};
for key in keys_among(&fields, &["input", "name"]) {
if key == "name" {
if is_validate {
fields.insert("name".into(), Value::String(VALIDATION.into()));
self.rewrite(
at.join("name"),
RewriteKind::Function {
v3: V3_VALIDATE.into(),
v4: VALIDATION.into(),
},
Some(step),
None,
);
}
} else if let Some(Value::Object(input)) = fields.get_mut("input")
&& let Some(known) = builtin
{
self.builtin_input(obj, task, input, known, step);
}
}
if let Some(Value::Object(slot)) = obj.get_mut("function") {
*slot = fields;
}
}
fn builtin_input(
&mut self,
obj: &Map<String, Value>,
task: &AuthoredPath,
input: &mut Map<String, Value>,
known: BuiltinInput,
step: &Step,
) {
input.retain(|key, value| {
if !known.keys.contains(&key.as_str()) {
let at = StrayKey {
key,
field: key,
below: AuthoredPath::root().join(key.as_str()),
};
self.stray(obj, task, at, known.keys, std::mem::take(value), step);
return false;
}
if let Some((array, element_keys)) = known.elements
&& key == array
&& let Value::Array(items) = value
{
for (i, item) in items.iter_mut().enumerate() {
let Value::Object(element) = item else {
continue;
};
element.retain(|stray, val| {
if element_keys.contains(&stray.as_str()) {
return true;
}
let at = StrayKey {
key: stray,
field: array,
below: AuthoredPath::root()
.join(array)
.join(i)
.join(stray.as_str()),
};
self.stray(obj, task, at, element_keys, std::mem::take(val), step);
false
});
}
}
true
});
}
fn stray(
&mut self,
obj: &Map<String, Value>,
task: &AuthoredPath,
at: StrayKey<'_>,
known: &[&str],
value: Value,
step: &Step,
) {
let StrayKey { key, field, below } = at;
let rel = AuthoredPath::root()
.join("function")
.join("input")
.extend(&below);
let name = rel.to_string();
let (code, message) = unknown_key(key, known);
let message = format!("{message} (kept in `ext` as `{name}`)");
let moved = Moved {
key,
path: task.extend(&rel),
field: Some(field),
name,
value,
};
self.keep_in_ext(obj, task, moved, code, message, step);
}
fn keep_in_ext(
&mut self,
obj: &Map<String, Value>,
node: &AuthoredPath,
moved: Moved<'_>,
code: IssueCode,
message: String,
step: &Step,
) {
let Moved {
key,
name,
value,
path,
field,
} = moved;
let ext = self
.exts
.entry(node.clone())
.or_insert_with(|| Ext::authored(obj.get("ext")));
match ext.keep(key, name, value, path.clone()) {
Ok(()) => self.report(code, path, message, Some(step), field),
Err(collision) => {
self.report(IssueCode::ExtCollision, path, collision, Some(step), field);
}
}
}
}
struct StrayKey<'k> {
key: &'k str,
field: &'k str,
below: AuthoredPath,
}
struct Moved<'k> {
key: &'k str,
name: String,
value: Value,
path: AuthoredPath,
field: Option<&'k str>,
}