use std::fmt;
use std::str::FromStr;
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[non_exhaustive]
pub enum PathSeg {
Key(Box<str>),
Index(usize),
}
impl From<&str> for PathSeg {
fn from(key: &str) -> Self {
Self::Key(key.into())
}
}
impl From<String> for PathSeg {
fn from(key: String) -> Self {
Self::Key(key.into())
}
}
impl From<usize> for PathSeg {
fn from(index: usize) -> Self {
Self::Index(index)
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct AuthoredPath(Vec<PathSeg>);
impl AuthoredPath {
pub fn root() -> Self {
Self::default()
}
pub fn segs(&self) -> &[PathSeg] {
&self.0
}
pub fn is_root(&self) -> bool {
self.0.is_empty()
}
pub fn push(&mut self, seg: impl Into<PathSeg>) {
self.0.push(seg.into());
}
pub fn pop(&mut self) -> Option<PathSeg> {
self.0.pop()
}
pub fn join(&self, seg: impl Into<PathSeg>) -> Self {
let mut path = self.clone();
path.push(seg);
path
}
pub fn extend(&self, rest: &Self) -> Self {
let mut path = self.clone();
path.0.extend(rest.0.iter().cloned());
path
}
}
fn needs_quotes(key: &str) -> bool {
key.is_empty() || key.contains(['.', '[', ']', '"'])
}
impl fmt::Display for AuthoredPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for (i, seg) in self.0.iter().enumerate() {
match seg {
PathSeg::Key(key) if needs_quotes(key) => {
let quoted = serde_json::to_string(&**key).map_err(|_| fmt::Error)?;
write!(f, "[{quoted}]")?;
}
PathSeg::Key(key) if i == 0 => f.write_str(key)?,
PathSeg::Key(key) => write!(f, ".{key}")?,
PathSeg::Index(index) => write!(f, "[{index}]")?,
}
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ParsePathError(String);
impl fmt::Display for ParsePathError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "not an authored path: {:?}", self.0)
}
}
impl std::error::Error for ParsePathError {}
impl FromStr for AuthoredPath {
type Err = ParsePathError;
fn from_str(text: &str) -> Result<Self, Self::Err> {
let bad = || ParsePathError(text.to_string());
let mut path = Self::root();
let mut rest = text;
let mut first = true;
while !rest.is_empty() {
if let Some(after) = rest.strip_prefix("[\"") {
let mut escaped = false;
let end = after
.char_indices()
.find(|&(_, c)| {
let close = c == '"' && !escaped;
escaped = c == '\\' && !escaped;
close
})
.map(|(at, _)| at)
.ok_or_else(bad)?;
let literal = &rest[1..end + 3];
let key: String = serde_json::from_str(literal).map_err(|_| bad())?;
path.push(key);
rest = after[end + 1..].strip_prefix(']').ok_or_else(bad)?;
} else if let Some(after) = rest.strip_prefix('[') {
let end = after.find(']').ok_or_else(bad)?;
let index = after[..end].parse::<usize>().map_err(|_| bad())?;
path.push(index);
rest = &after[end + 1..];
} else {
let after = if first {
rest
} else {
rest.strip_prefix('.').ok_or_else(bad)?
};
let end = after.find(['.', '[']).unwrap_or(after.len());
let key = &after[..end];
if needs_quotes(key) {
return Err(bad());
}
path.push(key);
rest = &after[end..];
}
first = false;
}
Ok(path)
}
}
impl serde::Serialize for AuthoredPath {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(self)
}
}
macro_rules! issue_codes {
($($(#[$doc:meta])* $code:ident = $text:literal, $severity:ident;)*) => {
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum IssueCode {
$($(#[$doc])* $code,)*
}
impl IssueCode {
pub const ALL: &'static [Self] = &[$(Self::$code),*];
pub fn as_str(self) -> &'static str {
match self {
$(Self::$code => $text,)*
}
}
pub fn severity(self) -> Severity {
match self {
$(Self::$code => Severity::$severity,)*
}
}
}
};
}
issue_codes! {
EmptyWorkflowId = "EMPTY_WORKFLOW_ID", Rejected;
NoTasks = "NO_TASKS", Rejected;
MissingStepId = "MISSING_STEP_ID", Rejected;
DuplicateStepId = "DUPLICATE_STEP_ID", Rejected;
EmptyGroup = "EMPTY_GROUP", Rejected;
GroupTooDeep = "GROUP_TOO_DEEP", Rejected;
MissingFunction = "MISSING_FUNCTION", Rejected;
InvalidFunctionName = "INVALID_FUNCTION_NAME", Rejected;
InvalidTerminal = "INVALID_TERMINAL", Rejected;
UnguardedValidation = "UNGUARDED_VALIDATION", Advisory;
InvalidHaltOn = "INVALID_HALT_ON", Rejected;
GroupContinueOnError = "GROUP_CONTINUE_ON_ERROR", Advisory;
InvalidMapping = "INVALID_MAPPING", Rejected;
NullMapping = "NULL_MAPPING", Advisory;
LoopIncrementTooSmall = "LOOP_INCREMENT_TOO_SMALL", Rejected;
LoopBoundEmpty = "LOOP_BOUND_EMPTY", Rejected;
LoopCounterInvalid = "LOOP_COUNTER_INVALID", Rejected;
LoopSlotInvalid = "LOOP_SLOT_INVALID", Rejected;
LoopItemWithoutOver = "LOOP_ITEM_WITHOUT_OVER", Rejected;
LoopSlotCollision = "LOOP_SLOT_COLLISION", Rejected;
LoopOverInvalid = "LOOP_OVER_INVALID", Rejected;
InvalidForEach = "INVALID_FOR_EACH", Rejected;
UnknownFunction = "UNKNOWN_FUNCTION", Rejected;
InputParse = "INPUT_PARSE", Rejected;
TemplateCompile = "TEMPLATE_COMPILE", Rejected;
UnknownSecret = "UNKNOWN_SECRET", Rejected;
SecretInMessageWrite = "SECRET_IN_MESSAGE_WRITE", Rejected;
DuplicateTemplateKey = "DUPLICATE_TEMPLATE_KEY", Rejected;
EscapedTemplateKey = "ESCAPED_TEMPLATE_KEY", Advisory;
ParseFailed = "PARSE_FAILED", Rejected;
MixedSpelling = "MIXED_SPELLING", Rejected;
ExtCollision = "EXT_COLLISION", Rejected;
LikelyTypo = "LIKELY_TYPO", Defect;
UnknownKey = "UNKNOWN_KEY", Advisory;
V3Spelling = "V3_SPELLING", Advisory;
NullCondition = "NULL_CONDITION", Advisory;
PayloadVar = "PAYLOAD_VAR", Defect;
OperatorInDocument = "OPERATOR_IN_DOCUMENT", Advisory;
SecretInDocument = "SECRET_IN_DOCUMENT", Rejected;
InvalidPath = "INVALID_PATH", Rejected;
UnresolvedReference = "UNRESOLVED_REFERENCE", Defect;
InvalidRollout = "INVALID_ROLLOUT", Defect;
}
impl fmt::Display for IssueCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
pub(crate) fn any_rejected(issues: &[Issue]) -> bool {
issues.iter().any(|i| i.severity == Severity::Rejected)
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ParseIssueCodeError(String);
impl fmt::Display for ParseIssueCodeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "unknown issue code: {:?}", self.0)
}
}
impl std::error::Error for ParseIssueCodeError {}
impl FromStr for IssueCode {
type Err = ParseIssueCodeError;
fn from_str(text: &str) -> Result<Self, Self::Err> {
Self::ALL
.iter()
.copied()
.find(|code| code.as_str() == text)
.ok_or_else(|| ParseIssueCodeError(text.to_string()))
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Severity {
Rejected,
Defect,
Advisory,
}
impl Severity {
const ALL: [Self; 3] = [Self::Rejected, Self::Defect, Self::Advisory];
pub fn as_str(self) -> &'static str {
match self {
Self::Rejected => "REJECTED",
Self::Defect => "DEFECT",
Self::Advisory => "ADVISORY",
}
}
}
impl fmt::Display for Severity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum StepKind {
Task,
Group,
Setup,
}
impl StepKind {
const ALL: [Self; 3] = [Self::Task, Self::Group, Self::Setup];
pub fn as_str(self) -> &'static str {
match self {
Self::Task => "task",
Self::Group => "group",
Self::Setup => "setup",
}
}
}
fn from_text<'de, D, T, E>(
deserializer: D,
read: impl FnOnce(&str) -> Result<T, E>,
) -> Result<T, D::Error>
where
D: serde::Deserializer<'de>,
E: fmt::Display,
{
let text = <String as serde::Deserialize>::deserialize(deserializer)?;
read(&text).map_err(serde::de::Error::custom)
}
fn named<T: Copy>(
all: &[T],
as_str: fn(T) -> &'static str,
what: &str,
text: &str,
) -> Result<T, String> {
all.iter()
.copied()
.find(|v| as_str(*v) == text)
.ok_or_else(|| format!("unknown {what}: {text:?}"))
}
impl<'de> serde::Deserialize<'de> for AuthoredPath {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
from_text(deserializer, str::parse)
}
}
impl serde::Serialize for IssueCode {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_str())
}
}
impl<'de> serde::Deserialize<'de> for IssueCode {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
from_text(deserializer, str::parse)
}
}
impl serde::Serialize for Severity {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_str())
}
}
impl<'de> serde::Deserialize<'de> for Severity {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
from_text(deserializer, |text| {
named(&Self::ALL, Self::as_str, "severity", text)
})
}
}
impl serde::Serialize for StepKind {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_str())
}
}
impl<'de> serde::Deserialize<'de> for StepKind {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
from_text(deserializer, |text| {
named(&Self::ALL, Self::as_str, "step kind", text)
})
}
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[non_exhaustive]
pub struct Issue {
pub code: IssueCode,
pub severity: Severity,
pub path: AuthoredPath,
pub step_kind: Option<StepKind>,
pub step_id: Option<String>,
pub field: Option<String>,
pub message: String,
}
impl Issue {
pub fn new(code: IssueCode, path: AuthoredPath, message: impl Into<String>) -> Self {
Self {
code,
severity: code.severity(),
path,
step_kind: None,
step_id: None,
field: None,
message: message.into(),
}
}
#[must_use]
pub fn with_severity(mut self, severity: Severity) -> Self {
self.severity = severity;
self
}
#[must_use]
pub fn with_step(mut self, kind: StepKind, id: Option<&str>) -> Self {
self.step_kind = Some(kind);
self.step_id = id.map(str::to_string);
self
}
#[must_use]
pub fn with_field(mut self, field: impl Into<String>) -> Self {
self.field = Some(field.into());
self
}
}
#[cfg(feature = "ts")]
crate::base::ts::ts_decl!(
IssueCode,
"IssueCode",
[" What an issue is about: its SCREAMING_SNAKE code, such as `UNKNOWN_FUNCTION`."],
|_cfg| crate::base::ts::literals(IssueCode::ALL.iter().map(|c| c.as_str()))
);
#[cfg(feature = "ts")]
crate::base::ts::ts_decl!(Severity, "Severity", [" When an issue bites."], |_cfg| {
crate::base::ts::literals(Severity::ALL.map(Severity::as_str))
});
#[cfg(feature = "ts")]
crate::base::ts::ts_decl!(
StepKind,
"StepKind",
[" What kind of step an issue sits in."],
|_cfg| crate::base::ts::literals(StepKind::ALL.map(StepKind::as_str))
);
#[cfg(feature = "ts")]
crate::base::ts::ts_decl!(
AuthoredPath,
"AuthoredPath",
[
" A path in the authored JSON, as v3 spelled it: `tasks[1].tasks[0].id`.",
" The document itself is the empty string.",
],
|_cfg| "string".to_owned()
);
impl fmt::Display for Issue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.path.is_root() {
write!(f, "{} [{}]", self.message, self.code)
} else {
write!(f, "{}: {} [{}]", self.path, self.message, self.code)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::{Value, json};
use std::collections::HashSet;
#[test]
fn every_code_round_trips_through_its_string() {
for &code in IssueCode::ALL {
assert_eq!(code.as_str().parse::<IssueCode>(), Ok(code), "{code:?}");
}
assert!("NOT_A_CODE".parse::<IssueCode>().is_err());
}
#[test]
fn code_strings_are_distinct_and_screaming_snake() {
let mut seen = HashSet::new();
for &code in IssueCode::ALL {
assert!(seen.insert(code.as_str()), "duplicate string for {code:?}");
assert_eq!(code.to_string(), code.as_str());
assert!(
code.as_str()
.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_'),
"{code:?} is not SCREAMING_SNAKE"
);
}
}
#[test]
fn the_advisory_and_defect_sets_are_exactly_these() {
let by = |want: Severity| -> HashSet<&'static str> {
IssueCode::ALL
.iter()
.filter(|c| c.severity() == want)
.map(|c| c.as_str())
.collect()
};
assert_eq!(
by(Severity::Advisory),
HashSet::from([
"UNGUARDED_VALIDATION",
"GROUP_CONTINUE_ON_ERROR",
"ESCAPED_TEMPLATE_KEY",
"NULL_MAPPING",
"UNKNOWN_KEY",
"V3_SPELLING",
"OPERATOR_IN_DOCUMENT",
"NULL_CONDITION",
])
);
assert_eq!(
by(Severity::Defect),
HashSet::from([
"LIKELY_TYPO",
"PAYLOAD_VAR",
"UNRESOLVED_REFERENCE",
"INVALID_ROLLOUT",
])
);
assert_eq!(by(Severity::Rejected).len(), IssueCode::ALL.len() - 12);
assert!(by(Severity::Rejected).contains("SECRET_IN_DOCUMENT"));
assert!(by(Severity::Rejected).contains("INVALID_PATH"));
}
#[test]
fn severities_have_distinct_strings() {
let all = [Severity::Rejected, Severity::Defect, Severity::Advisory];
let strings: HashSet<_> = all.iter().map(|s| s.as_str()).collect();
assert_eq!(strings.len(), 3);
assert_eq!(Severity::Defect.to_string(), "DEFECT");
}
#[test]
fn an_authored_path_round_trips() {
for text in [
"tasks[1].tasks[0].id",
"loop.setup[0].id",
"function.input.fields.a",
"[2]",
"",
"a[0][1].b",
] {
let path: AuthoredPath = text.parse().unwrap();
assert_eq!(path.to_string(), text);
assert_eq!(path.is_root(), text.is_empty());
}
assert!("a..b".parse::<AuthoredPath>().is_err());
assert!("a[x]".parse::<AuthoredPath>().is_err());
assert!("a[1".parse::<AuthoredPath>().is_err());
}
#[test]
fn path_building_and_serialization() {
let mut p = AuthoredPath::root().join("tasks").join(1usize);
assert_eq!(p.to_string(), "tasks[1]");
let q = p.extend(&"function.name".parse().unwrap());
assert_eq!(q.to_string(), "tasks[1].function.name");
assert_eq!(p.pop(), Some(PathSeg::Index(1)));
assert_eq!(
serde_json::to_string(&q).unwrap(),
"\"tasks[1].function.name\""
);
}
#[test]
fn an_issue_takes_its_codes_severity_and_displays() {
let issue = Issue::new(IssueCode::UnknownKey, "tasks[0].x".parse().unwrap(), "m");
assert_eq!(issue.severity, Severity::Advisory);
assert_eq!(issue.to_string(), "tasks[0].x: m [UNKNOWN_KEY]");
let root = Issue::new(IssueCode::NoTasks, AuthoredPath::root(), "m")
.with_severity(Severity::Defect)
.with_step(StepKind::Setup, Some("a"))
.with_field("f");
assert_eq!(root.severity, Severity::Defect);
assert_eq!(root.step_kind, Some(StepKind::Setup));
assert_eq!(root.step_id.as_deref(), Some("a"));
assert_eq!(root.field.as_deref(), Some("f"));
assert_eq!(root.to_string(), "m [NO_TASKS]");
}
#[test]
fn a_key_it_could_not_otherwise_print_is_quoted() {
for (key, shown) in [
("a.b", r#"["a.b"]"#),
("", r#"[""]"#),
("x[0]", r#"["x[0]"]"#),
("q\"t", r#"["q\"t"]"#),
] {
let path = AuthoredPath::root().join(key);
assert_eq!(path.to_string(), shown, "{key:?}");
assert_eq!(shown.parse::<AuthoredPath>(), Ok(path), "{shown}");
let deeper = AuthoredPath::root().join("log").join(key).join(0usize);
let shown = format!("log{shown}[0]");
assert_eq!(deeper.to_string(), shown, "{key:?}");
assert_eq!(shown.parse::<AuthoredPath>(), Ok(deeper), "{shown}");
}
}
#[test]
fn a_quoted_key_round_trips_anywhere_in_a_path() {
let text = r#"tasks[0].function.input.fields["a.b"]"#;
let path: AuthoredPath = text.parse().unwrap();
assert_eq!(path.segs().len(), 6);
assert_eq!(path.segs()[5], PathSeg::Key("a.b".into()));
assert_eq!(path.to_string(), text);
let text = r#"["a.b"].c["]"]["\"\\"][3]"#;
let path: AuthoredPath = text.parse().unwrap();
assert_eq!(
path.segs(),
[
PathSeg::Key("a.b".into()),
PathSeg::Key("c".into()),
PathSeg::Key("]".into()),
PathSeg::Key("\"\\".into()),
PathSeg::Index(3),
]
);
assert_eq!(path.to_string(), text);
let path: AuthoredPath = r#"a["b"]["\\"]"#.parse().unwrap();
assert_eq!(
path.segs(),
[
PathSeg::Key("a".into()),
PathSeg::Key("b".into()),
PathSeg::Key("\\".into()),
]
);
assert_eq!(path.to_string(), r"a.b.\");
assert_eq!(path.to_string().parse::<AuthoredPath>(), Ok(path));
let ordinary = AuthoredPath::root()
.join("tasks")
.join(1usize)
.join("function")
.join("input");
assert_eq!(ordinary.to_string(), "tasks[1].function.input");
for bad in [
r#"a.["b"]"#,
r#"a["b"#,
r#"a["b""#,
r#"a["b"x]"#,
r#"a[b"]"#,
r#"a"b"#,
r#"["\q"]"#,
] {
assert!(bad.parse::<AuthoredPath>().is_err(), "{bad}");
}
}
#[test]
fn an_issue_serializes_with_its_parts_as_strings() {
let issue = Issue::new(
IssueCode::UnknownFunction,
r#"tasks[0].function.input["a.b"]"#.parse().unwrap(),
"no function named `x`",
)
.with_step(StepKind::Task, Some("t"))
.with_field("a.b");
let value = serde_json::to_value(&issue).unwrap();
let mut keys: Vec<&str> = value
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
keys.sort_unstable();
let mut want = [
"code",
"severity",
"path",
"step_kind",
"step_id",
"field",
"message",
];
want.sort_unstable();
assert_eq!(keys, want);
assert_eq!(value["code"], "UNKNOWN_FUNCTION");
assert_eq!(value["severity"], "REJECTED");
assert_eq!(value["path"], r#"tasks[0].function.input["a.b"]"#);
assert_eq!(value["step_kind"], "task");
assert_eq!(value["step_id"], "t");
assert_eq!(value["field"], "a.b");
assert_eq!(value["message"], "no function named `x`");
assert_eq!(serde_json::from_value::<Issue>(value).unwrap(), issue);
let bare = Issue::new(IssueCode::NoTasks, AuthoredPath::root(), "m");
let value = serde_json::to_value(&bare).unwrap();
assert_eq!(value["step_kind"], Value::Null);
assert_eq!(value["path"], "");
assert_eq!(serde_json::from_value::<Issue>(value).unwrap(), bare);
}
#[test]
fn the_parts_of_an_issue_read_back_from_their_strings() {
for kind in [StepKind::Task, StepKind::Group, StepKind::Setup] {
let value = serde_json::to_value(kind).unwrap();
assert_eq!(value, kind.as_str());
assert_eq!(serde_json::from_value::<StepKind>(value).unwrap(), kind);
}
for severity in [Severity::Rejected, Severity::Defect, Severity::Advisory] {
let value = serde_json::to_value(severity).unwrap();
assert_eq!(serde_json::from_value::<Severity>(value).unwrap(), severity);
}
for &code in IssueCode::ALL {
let value = serde_json::to_value(code).unwrap();
assert_eq!(value, code.as_str());
assert_eq!(serde_json::from_value::<IssueCode>(value).unwrap(), code);
}
assert!(serde_json::from_value::<StepKind>(json!("Task")).is_err());
assert!(serde_json::from_value::<Severity>(json!("rejected")).is_err());
assert!(serde_json::from_value::<IssueCode>(json!("NOT_A_CODE")).is_err());
assert!(serde_json::from_value::<AuthoredPath>(json!("a..b")).is_err());
}
}