Skip to main content

dataflow_rs/base/
issue.rs

1//! The issue vocabulary: authored paths, issue codes, severities and issues.
2
3use std::fmt;
4use std::str::FromStr;
5
6/// One segment of an [`AuthoredPath`].
7#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
8#[non_exhaustive]
9pub enum PathSeg {
10    /// An object key.
11    Key(Box<str>),
12    /// An array index.
13    Index(usize),
14}
15
16impl From<&str> for PathSeg {
17    fn from(key: &str) -> Self {
18        Self::Key(key.into())
19    }
20}
21
22impl From<String> for PathSeg {
23    fn from(key: String) -> Self {
24        Self::Key(key.into())
25    }
26}
27
28impl From<usize> for PathSeg {
29    fn from(index: usize) -> Self {
30        Self::Index(index)
31    }
32}
33
34/// A path in the authored JSON: keys and indices, displayed as v3 spelled them,
35/// `tasks[1].tasks[0].id`, `function.input.mappings[3].logic`. The root displays
36/// as the empty string.
37///
38/// A key that could not otherwise be read back, one that is empty or holds a
39/// `.`, `[`, `]` or `"`, displays as a JSON string in brackets:
40/// `function.input.fields["a.b"]`, `[""]`. [`FromStr`] reads every displayed
41/// form back, and a path serializes as it displays.
42#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
43pub struct AuthoredPath(Vec<PathSeg>);
44
45impl AuthoredPath {
46    /// The empty path, the document itself.
47    pub fn root() -> Self {
48        Self::default()
49    }
50
51    /// The segments, outermost first.
52    pub fn segs(&self) -> &[PathSeg] {
53        &self.0
54    }
55
56    /// Is this the root?
57    pub fn is_root(&self) -> bool {
58        self.0.is_empty()
59    }
60
61    /// Appends a segment.
62    pub fn push(&mut self, seg: impl Into<PathSeg>) {
63        self.0.push(seg.into());
64    }
65
66    /// Removes and returns the last segment.
67    pub fn pop(&mut self) -> Option<PathSeg> {
68        self.0.pop()
69    }
70
71    /// A copy with one more segment.
72    pub fn join(&self, seg: impl Into<PathSeg>) -> Self {
73        let mut path = self.clone();
74        path.push(seg);
75        path
76    }
77
78    /// `self` followed by `rest`.
79    pub fn extend(&self, rest: &Self) -> Self {
80        let mut path = self.clone();
81        path.0.extend(rest.0.iter().cloned());
82        path
83    }
84}
85
86/// Whether `key` displays quoted: bare, it would read back as something else.
87fn needs_quotes(key: &str) -> bool {
88    key.is_empty() || key.contains(['.', '[', ']', '"'])
89}
90
91impl fmt::Display for AuthoredPath {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        for (i, seg) in self.0.iter().enumerate() {
94            match seg {
95                PathSeg::Key(key) if needs_quotes(key) => {
96                    let quoted = serde_json::to_string(&**key).map_err(|_| fmt::Error)?;
97                    write!(f, "[{quoted}]")?;
98                }
99                PathSeg::Key(key) if i == 0 => f.write_str(key)?,
100                PathSeg::Key(key) => write!(f, ".{key}")?,
101                PathSeg::Index(index) => write!(f, "[{index}]")?,
102            }
103        }
104        Ok(())
105    }
106}
107
108/// A string that is not an [`AuthoredPath`].
109#[derive(Clone, Debug, PartialEq, Eq)]
110pub struct ParsePathError(String);
111
112impl fmt::Display for ParsePathError {
113    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114        write!(f, "not an authored path: {:?}", self.0)
115    }
116}
117
118impl std::error::Error for ParsePathError {}
119
120impl FromStr for AuthoredPath {
121    type Err = ParsePathError;
122
123    /// The inverse of `Display`: a bare key holds no `.`, `[`, `]` or `"`, and
124    /// a bracketed one is a JSON string.
125    fn from_str(text: &str) -> Result<Self, Self::Err> {
126        let bad = || ParsePathError(text.to_string());
127        let mut path = Self::root();
128        let mut rest = text;
129        let mut first = true;
130        while !rest.is_empty() {
131            if let Some(after) = rest.strip_prefix("[\"") {
132                // The string runs to the first `"` no backslash escapes.
133                let mut escaped = false;
134                let end = after
135                    .char_indices()
136                    .find(|&(_, c)| {
137                        let close = c == '"' && !escaped;
138                        escaped = c == '\\' && !escaped;
139                        close
140                    })
141                    .map(|(at, _)| at)
142                    .ok_or_else(bad)?;
143                // `after` starts past `["`: the closing quote is `rest[end + 2]`.
144                let literal = &rest[1..end + 3];
145                let key: String = serde_json::from_str(literal).map_err(|_| bad())?;
146                path.push(key);
147                rest = after[end + 1..].strip_prefix(']').ok_or_else(bad)?;
148            } else if let Some(after) = rest.strip_prefix('[') {
149                let end = after.find(']').ok_or_else(bad)?;
150                let index = after[..end].parse::<usize>().map_err(|_| bad())?;
151                path.push(index);
152                rest = &after[end + 1..];
153            } else {
154                let after = if first {
155                    rest
156                } else {
157                    rest.strip_prefix('.').ok_or_else(bad)?
158                };
159                let end = after.find(['.', '[']).unwrap_or(after.len());
160                let key = &after[..end];
161                if needs_quotes(key) {
162                    return Err(bad());
163                }
164                path.push(key);
165                rest = &after[end..];
166            }
167            first = false;
168        }
169        Ok(path)
170    }
171}
172
173impl serde::Serialize for AuthoredPath {
174    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
175        serializer.collect_str(self)
176    }
177}
178
179/// The `IssueCode` table: each row a variant, its documentation, its stable
180/// string and its default severity. A row without a severity does not
181/// compile, so a new code is always classified deliberately, never by a
182/// wildcard (a new advisory must not arrive as a silent rejection in a
183/// host's screen). One row is the whole cost of a code.
184macro_rules! issue_codes {
185    ($($(#[$doc:meta])* $code:ident = $text:literal, $severity:ident;)*) => {
186        /// What an issue is about: v3's codes, then v4's.
187        ///
188        /// `#[non_exhaustive]`: use [`Self::as_str`] to serialize.
189        #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
190        #[non_exhaustive]
191        pub enum IssueCode {
192            $($(#[$doc])* $code,)*
193        }
194
195        impl IssueCode {
196            /// Every code, in the table's order: v3's, then v4's.
197            pub const ALL: &'static [Self] = &[$(Self::$code),*];
198
199            /// The stable string form, SCREAMING_SNAKE, v3's spellings
200            /// unchanged.
201            pub fn as_str(self) -> &'static str {
202                match self {
203                    $(Self::$code => $text,)*
204                }
205            }
206
207            /// The default severity of an issue carrying this code. An
208            /// issue may carry a different one ([`Issue::with_severity`]).
209            pub fn severity(self) -> Severity {
210                match self {
211                    $(Self::$code => Severity::$severity,)*
212                }
213            }
214        }
215    };
216}
217
218issue_codes! {
219    /// `id` is missing or empty.
220    EmptyWorkflowId = "EMPTY_WORKFLOW_ID", Rejected;
221    /// `tasks` is missing, not an array, or empty.
222    NoTasks = "NO_TASKS", Rejected;
223    /// A step carries no `id`.
224    MissingStepId = "MISSING_STEP_ID", Rejected;
225    /// Two steps share an id. Groups share the task id namespace.
226    DuplicateStepId = "DUPLICATE_STEP_ID", Rejected;
227    /// A group's `tasks` is not a non-empty array.
228    EmptyGroup = "EMPTY_GROUP", Rejected;
229    /// A group is nested at or beyond
230    /// `MAX_GROUP_DEPTH`.
231    GroupTooDeep = "GROUP_TOO_DEEP", Rejected;
232    /// A task carries no `function`.
233    MissingFunction = "MISSING_FUNCTION", Rejected;
234    /// `function` is not an object, or its `name` is missing or empty.
235    InvalidFunctionName = "INVALID_FUNCTION_NAME", Rejected;
236    /// `terminal` is present but not a boolean.
237    InvalidTerminal = "INVALID_TERMINAL", Rejected;
238    /// A `validation` task whose failure cannot stop anything: it is not
239    /// `terminal`, its `on_rejection` is `continue`, and every task after it in
240    /// the workflow is unguarded. A failing rule returns `400`, a rejection,
241    /// so the pipeline proceeds as if it had passed.
242    ///
243    /// [`Severity::Advisory`] — reported by the compat reader in
244    /// [`def::parse`](crate::def::parse) and never a reason to
245    /// refuse. Validating purely to record errors is a
246    /// legitimate shape; this only says the assertion is not acting as a gate.
247    UnguardedValidation = "UNGUARDED_VALIDATION", Advisory;
248    /// `halt_on` is present and is not one of the accepted strings, or is
249    /// present on a *group* — a group has no outcome of its own, so the
250    /// executor could not honour it.
251    InvalidHaltOn = "INVALID_HALT_ON", Rejected;
252    /// A task group carries `continue_on_error`, which the engine drops. Error
253    /// handling is per task and per workflow; a group only gates a span.
254    ///
255    /// [`Severity::Advisory`] — reported by the compat reader in
256    /// [`def::parse`](crate::def::parse) and never a reason to
257    /// refuse. Unlike [`Self::InvalidHaltOn`], which
258    /// the parser refuses outright, this key is real at the other two levels
259    /// and may already sit on a host's group nodes; refusing it would abort
260    /// every workflow in the build.
261    GroupContinueOnError = "GROUP_CONTINUE_ON_ERROR", Advisory;
262    /// A `map` mapping breaks a rule between its keys: it has neither `logic`
263    /// nor `"unset": true`, or both; it carries `on_null` or `mode` without
264    /// `logic`; or it would remove or append to a context root (`data`,
265    /// `metadata`, `temp_data`) by a literal path. Reported at the offending key, e.g.
266    /// `tasks[0].function.input.mappings[2].unset`.
267    InvalidMapping = "INVALID_MAPPING", Rejected;
268    /// A `map` mapping whose `logic` always evaluates to `null` — `"logic":
269    /// null`, or anything that folds to it — under the default
270    /// `on_null: "skip"`. A null result is skipped, so the mapping can never
271    /// write anything; it usually stands where the author meant to clear the
272    /// path, which is `"unset": true`.
273    ///
274    /// [`Severity::Advisory`] — reported by the compat reader in
275    /// [`def::parse`](crate::def::parse) and never a reason to
276    /// refuse. The mapping is a no-op, not an error,
277    /// and refusing it would fail every build that already carries one.
278    NullMapping = "NULL_MAPPING", Advisory;
279    /// `loop.increment` is below 1 — the counter would never reach `max`.
280    LoopIncrementTooSmall = "LOOP_INCREMENT_TOO_SMALL", Rejected;
281    /// `loop.max` is not greater than `loop.init` — no sweep could ever run.
282    LoopBoundEmpty = "LOOP_BOUND_EMPTY", Rejected;
283    /// `loop.counter` is not a non-empty dotted path.
284    LoopCounterInvalid = "LOOP_COUNTER_INVALID", Rejected;
285    /// `loop.as` or `loop.scratch` is not a non-empty dotted path.
286    LoopSlotInvalid = "LOOP_SLOT_INVALID", Rejected;
287    /// `loop.as` names a slot for the current element, but there is no
288    /// `loop.over` to take elements from.
289    LoopItemWithoutOver = "LOOP_ITEM_WITHOUT_OVER", Rejected;
290    /// Two of `loop.counter`, `loop.as` and `loop.scratch` name the same
291    /// `temp_data` path, or one lies inside the other — a per-iteration reset
292    /// of one would wipe the other. Reported at the later of the two fields.
293    LoopSlotCollision = "LOOP_SLOT_COLLISION", Rejected;
294    /// `loop.over` can never yield an array: it is a scalar literal (a string,
295    /// number, boolean or `null`), or `loop.init` is negative, which no array
296    /// index can be. Reported at `loop.over` or `loop.init` respectively.
297    LoopOverInvalid = "LOOP_OVER_INVALID", Rejected;
298    /// A task's `for_each` breaks one of its rules — `over` a scalar literal,
299    /// `as` not a slot path, `max_concurrency` of `0`, `collect` without
300    /// `into` or the reverse, a result path outside the context, a `collect`
301    /// or `into` overlapping a binding — or sits on a built-in function, or on
302    /// a task group. Reported at the offending key, e.g.
303    /// `tasks[0].for_each.max_concurrency`, or at `…for_each` itself.
304    InvalidForEach = "INVALID_FOR_EACH", Rejected;
305    /// No handler will dispatch this function name, and it is not a built-in.
306    /// Usually a typo or a handler the host forgot to register.
307    UnknownFunction = "UNKNOWN_FUNCTION", Rejected;
308    /// A custom task's `input` does not deserialize into its handler's declared
309    /// `Input` type.
310    InputParse = "INPUT_PARSE", Rejected;
311    /// A `Template` field of a custom task's input does not compile.
312    TemplateCompile = "TEMPLATE_COMPILE", Rejected;
313    /// An expression reads `{"secret": "name"}` and no secret of that name is
314    /// in the `ExprEngine`'s store (`ExprEngineBuilder::secrets`). Only
315    /// literal names are checked; a dynamic name fails at evaluation instead.
316    UnknownSecret = "UNKNOWN_SECRET", Rejected;
317    /// An expression whose result the engine writes to the message or emits to
318    /// a log reads a secret — a `map` mapping, or a `log` message or field. The
319    /// store exists so a value is never recorded; an expression that would
320    /// record it is refused outright, derived or not. Compute derived values in
321    /// a custom handler.
322    SecretInMessageWrite = "SECRET_IN_MESSAGE_WRITE", Rejected;
323    /// Two keys in one template object collapse to the same name once the
324    /// template-key escape is stripped — `{"$a": 1, "a": 2}` emits `a` twice.
325    /// The context is a `Vec` of pairs, so both survive: a later read sees only
326    /// the first while serialization emits both. Always a bug, so
327    /// `build` refuses it.
328    DuplicateTemplateKey = "DUPLICATE_TEMPLATE_KEY", Rejected;
329    /// A template key carries the escape prefix, so it is emitted with one
330    /// prefix stripped: `$type` emits `type`. [`Severity::Advisory`] — reported
331    /// by `Compiler::check` and never a reason to
332    /// refuse.
333    ///
334    /// Exists for migration. The escape strips uniformly from every template
335    /// key, so a workflow written before 3.9 that emits genuinely `$`-prefixed
336    /// keys — MongoDB's `$set`/`$oid`, JSON Schema's `$schema`/`$ref` — changes
337    /// what it produces, silently. This lists every one so the audit is
338    /// mechanical rather than archaeological.
339    EscapedTemplateKey = "ESCAPED_TEMPLATE_KEY", Advisory;
340    /// A key holds a value of the wrong type, or a required key is missing.
341    /// Reported at the key's path, with a message naming what the key
342    /// takes.
343    ParseFailed = "PARSE_FAILED", Rejected;
344    /// A v3 policy key (`continue_on_error`, `halt_on`) beside a v4 one
345    /// (`on_failure`, `on_rejection`) on one node: the reader cannot tell
346    /// which the author meant, so it refuses the v3 key. Write only the v4
347    /// keys.
348    MixedSpelling = "MIXED_SPELLING", Rejected;
349    /// A key the reader or the walk keeps in a node's `ext` under a name
350    /// `ext` already holds: an authored `ext` entry, or an earlier key kept
351    /// under the same name. Rename one of them.
352    ExtCollision = "EXT_COLLISION", Rejected;
353    /// An unknown key within edit distance 1 (keys of up to five characters) or 2 (longer) of a known key at its level: almost certainly a misspelling, so the setting it was meant to carry is silently lost.
354    ///
355    /// Default severity [`Severity::Defect`].
356    LikelyTypo = "LIKELY_TYPO", Defect;
357    /// An unknown key that resembles no known key at its level. It moves into `ext` under the compat reader and is refused without it.
358    ///
359    /// Default severity [`Severity::Advisory`].
360    UnknownKey = "UNKNOWN_KEY", Advisory;
361    /// The compat reader rewrote a v3 spelling to its v4 counterpart.
362    ///
363    /// Default severity [`Severity::Advisory`].
364    V3Spelling = "V3_SPELLING", Advisory;
365    /// A literal `null` `condition`. It is kept as the literal expression
366    /// `null`, which is falsy, so the step never runs; omit the key for no
367    /// condition, or write the expression that was meant.
368    ///
369    /// Default severity [`Severity::Advisory`].
370    NullCondition = "NULL_CONDITION", Advisory;
371    /// A literal `var` rooted at `payload`. The evaluation context is `{data, metadata, temp_data}`, so it resolves to nothing.
372    ///
373    /// Default severity [`Severity::Defect`].
374    PayloadVar = "PAYLOAD_VAR", Defect;
375    /// A single-key object inside an interpolated document whose key is a live operator name. A document is never evaluated, so it stays data.
376    ///
377    /// Default severity [`Severity::Advisory`].
378    OperatorInDocument = "OPERATOR_IN_DOCUMENT", Advisory;
379    /// A `{"secret": ...}` node inside an interpolated document. A document is never evaluated, so the secret would never be substituted.
380    ///
381    /// Default severity [`Severity::Rejected`].
382    SecretInDocument = "SECRET_IN_DOCUMENT", Rejected;
383    /// A literal context path that is not rooted at `data`, `metadata` or `temp_data`, or a document hole whose path is not a literal.
384    ///
385    /// Default severity [`Severity::Rejected`].
386    InvalidPath = "INVALID_PATH", Rejected;
387    /// A `Reference` field whose value is a literal string the host's
388    /// `ResourceOracle` does not resolve, such as a connector no deployment
389    /// declares, or one of the wrong type; the message leads with the
390    /// oracle's reason when it gives one. A computed or absent value is not
391    /// checked.
392    ///
393    /// Default severity [`Severity::Defect`]: the workflow loads, then fails
394    /// every call.
395    UnresolvedReference = "UNRESOLVED_REFERENCE", Defect;
396    /// A workflow's `rollout` range can never match: its `bucket_end` is
397    /// below `bucket_start`, or above 100. Such a range serves no traffic,
398    /// so the workflow silently never runs.
399    ///
400    /// Default severity [`Severity::Defect`]: the definition loads, then the
401    /// workflow is never selected.
402    InvalidRollout = "INVALID_ROLLOUT", Defect;
403}
404
405impl fmt::Display for IssueCode {
406    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
407        f.write_str(self.as_str())
408    }
409}
410
411/// Whether any of `issues` is Rejected: a definition or a config with one
412/// does not build.
413pub(crate) fn any_rejected(issues: &[Issue]) -> bool {
414    issues.iter().any(|i| i.severity == Severity::Rejected)
415}
416
417/// A string that is not an [`IssueCode`].
418#[derive(Clone, Debug, PartialEq, Eq)]
419pub struct ParseIssueCodeError(String);
420
421impl fmt::Display for ParseIssueCodeError {
422    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
423        write!(f, "unknown issue code: {:?}", self.0)
424    }
425}
426
427impl std::error::Error for ParseIssueCodeError {}
428
429impl FromStr for IssueCode {
430    type Err = ParseIssueCodeError;
431
432    /// The inverse of [`IssueCode::as_str`], for reading recordings.
433    fn from_str(text: &str) -> Result<Self, Self::Err> {
434        Self::ALL
435            .iter()
436            .copied()
437            .find(|code| code.as_str() == text)
438            .ok_or_else(|| ParseIssueCodeError(text.to_string()))
439    }
440}
441
442/// When an issue bites.
443#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
444#[non_exhaustive]
445pub enum Severity {
446    /// The definition never runs.
447    Rejected,
448    /// It loads and then fails or misbehaves on messages.
449    Defect,
450    /// It loads and runs; the shape deserves an author's attention.
451    Advisory,
452}
453
454impl Severity {
455    const ALL: [Self; 3] = [Self::Rejected, Self::Defect, Self::Advisory];
456
457    /// The stable string form.
458    pub fn as_str(self) -> &'static str {
459        match self {
460            Self::Rejected => "REJECTED",
461            Self::Defect => "DEFECT",
462            Self::Advisory => "ADVISORY",
463        }
464    }
465}
466
467impl fmt::Display for Severity {
468    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
469        f.write_str(self.as_str())
470    }
471}
472
473/// What kind of step an issue sits in.
474#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
475#[non_exhaustive]
476pub enum StepKind {
477    /// A task.
478    Task,
479    /// A task group.
480    Group,
481    /// Any step under `loop.setup`.
482    Setup,
483}
484
485impl StepKind {
486    const ALL: [Self; 3] = [Self::Task, Self::Group, Self::Setup];
487
488    /// The stable string form: `task`, `group` or `setup`.
489    pub fn as_str(self) -> &'static str {
490        match self {
491            Self::Task => "task",
492            Self::Group => "group",
493            Self::Setup => "setup",
494        }
495    }
496}
497
498/// Reads a value from the string it serializes as.
499fn from_text<'de, D, T, E>(
500    deserializer: D,
501    read: impl FnOnce(&str) -> Result<T, E>,
502) -> Result<T, D::Error>
503where
504    D: serde::Deserializer<'de>,
505    E: fmt::Display,
506{
507    let text = <String as serde::Deserialize>::deserialize(deserializer)?;
508    read(&text).map_err(serde::de::Error::custom)
509}
510
511/// The variant of `all` whose string is `text`.
512fn named<T: Copy>(
513    all: &[T],
514    as_str: fn(T) -> &'static str,
515    what: &str,
516    text: &str,
517) -> Result<T, String> {
518    all.iter()
519        .copied()
520        .find(|v| as_str(*v) == text)
521        .ok_or_else(|| format!("unknown {what}: {text:?}"))
522}
523
524impl<'de> serde::Deserialize<'de> for AuthoredPath {
525    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
526        from_text(deserializer, str::parse)
527    }
528}
529
530impl serde::Serialize for IssueCode {
531    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
532        serializer.serialize_str(self.as_str())
533    }
534}
535
536impl<'de> serde::Deserialize<'de> for IssueCode {
537    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
538        from_text(deserializer, str::parse)
539    }
540}
541
542impl serde::Serialize for Severity {
543    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
544        serializer.serialize_str(self.as_str())
545    }
546}
547
548impl<'de> serde::Deserialize<'de> for Severity {
549    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
550        from_text(deserializer, |text| {
551            named(&Self::ALL, Self::as_str, "severity", text)
552        })
553    }
554}
555
556impl serde::Serialize for StepKind {
557    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
558        serializer.serialize_str(self.as_str())
559    }
560}
561
562impl<'de> serde::Deserialize<'de> for StepKind {
563    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
564        from_text(deserializer, |text| {
565            named(&Self::ALL, Self::as_str, "step kind", text)
566        })
567    }
568}
569
570/// One finding about an authored definition.
571///
572/// It serializes as an object of its seven fields, under their own names: the
573/// code and severity as their strings, the path as it displays, the step kind
574/// as `task`, `group` or `setup`, and an absent part as `null`.
575#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
576#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
577#[non_exhaustive]
578pub struct Issue {
579    /// What it is about.
580    pub code: IssueCode,
581    /// When it bites: its code's own severity, unless the host overrode it.
582    pub severity: Severity,
583    /// Where in the authored JSON.
584    pub path: AuthoredPath,
585    /// The kind of step it sits in, if any.
586    pub step_kind: Option<StepKind>,
587    /// The id of the step it sits in, if known.
588    pub step_id: Option<String>,
589    /// The config field (the first key under `function.input`), if inside one.
590    pub field: Option<String>,
591    /// A human-readable explanation.
592    pub message: String,
593}
594
595impl Issue {
596    /// An issue at its code's default severity.
597    pub fn new(code: IssueCode, path: AuthoredPath, message: impl Into<String>) -> Self {
598        Self {
599            code,
600            severity: code.severity(),
601            path,
602            step_kind: None,
603            step_id: None,
604            field: None,
605            message: message.into(),
606        }
607    }
608
609    /// Overrides the severity.
610    #[must_use]
611    pub fn with_severity(mut self, severity: Severity) -> Self {
612        self.severity = severity;
613        self
614    }
615
616    /// Names the step the issue sits in.
617    #[must_use]
618    pub fn with_step(mut self, kind: StepKind, id: Option<&str>) -> Self {
619        self.step_kind = Some(kind);
620        self.step_id = id.map(str::to_string);
621        self
622    }
623
624    /// Names the config field the issue sits in.
625    #[must_use]
626    pub fn with_field(mut self, field: impl Into<String>) -> Self {
627        self.field = Some(field.into());
628        self
629    }
630}
631
632#[cfg(feature = "ts")]
633crate::base::ts::ts_decl!(
634    IssueCode,
635    "IssueCode",
636    [" What an issue is about: its SCREAMING_SNAKE code, such as `UNKNOWN_FUNCTION`."],
637    |_cfg| crate::base::ts::literals(IssueCode::ALL.iter().map(|c| c.as_str()))
638);
639
640#[cfg(feature = "ts")]
641crate::base::ts::ts_decl!(Severity, "Severity", [" When an issue bites."], |_cfg| {
642    crate::base::ts::literals(Severity::ALL.map(Severity::as_str))
643});
644
645#[cfg(feature = "ts")]
646crate::base::ts::ts_decl!(
647    StepKind,
648    "StepKind",
649    [" What kind of step an issue sits in."],
650    |_cfg| crate::base::ts::literals(StepKind::ALL.map(StepKind::as_str))
651);
652
653#[cfg(feature = "ts")]
654crate::base::ts::ts_decl!(
655    AuthoredPath,
656    "AuthoredPath",
657    [
658        " A path in the authored JSON, as v3 spelled it: `tasks[1].tasks[0].id`.",
659        " The document itself is the empty string.",
660    ],
661    |_cfg| "string".to_owned()
662);
663
664impl fmt::Display for Issue {
665    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
666        if self.path.is_root() {
667            write!(f, "{} [{}]", self.message, self.code)
668        } else {
669            write!(f, "{}: {} [{}]", self.path, self.message, self.code)
670        }
671    }
672}
673
674#[cfg(test)]
675mod tests {
676    use super::*;
677    use serde_json::{Value, json};
678    use std::collections::HashSet;
679
680    #[test]
681    fn every_code_round_trips_through_its_string() {
682        for &code in IssueCode::ALL {
683            assert_eq!(code.as_str().parse::<IssueCode>(), Ok(code), "{code:?}");
684        }
685        assert!("NOT_A_CODE".parse::<IssueCode>().is_err());
686    }
687
688    #[test]
689    fn code_strings_are_distinct_and_screaming_snake() {
690        let mut seen = HashSet::new();
691        for &code in IssueCode::ALL {
692            assert!(seen.insert(code.as_str()), "duplicate string for {code:?}");
693            assert_eq!(code.to_string(), code.as_str());
694            assert!(
695                code.as_str()
696                    .chars()
697                    .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_'),
698                "{code:?} is not SCREAMING_SNAKE"
699            );
700        }
701    }
702
703    #[test]
704    fn the_advisory_and_defect_sets_are_exactly_these() {
705        let by = |want: Severity| -> HashSet<&'static str> {
706            IssueCode::ALL
707                .iter()
708                .filter(|c| c.severity() == want)
709                .map(|c| c.as_str())
710                .collect()
711        };
712        assert_eq!(
713            by(Severity::Advisory),
714            HashSet::from([
715                "UNGUARDED_VALIDATION",
716                "GROUP_CONTINUE_ON_ERROR",
717                "ESCAPED_TEMPLATE_KEY",
718                "NULL_MAPPING",
719                "UNKNOWN_KEY",
720                "V3_SPELLING",
721                "OPERATOR_IN_DOCUMENT",
722                "NULL_CONDITION",
723            ])
724        );
725        assert_eq!(
726            by(Severity::Defect),
727            HashSet::from([
728                "LIKELY_TYPO",
729                "PAYLOAD_VAR",
730                "UNRESOLVED_REFERENCE",
731                "INVALID_ROLLOUT",
732            ])
733        );
734        assert_eq!(by(Severity::Rejected).len(), IssueCode::ALL.len() - 12);
735        assert!(by(Severity::Rejected).contains("SECRET_IN_DOCUMENT"));
736        assert!(by(Severity::Rejected).contains("INVALID_PATH"));
737    }
738
739    #[test]
740    fn severities_have_distinct_strings() {
741        let all = [Severity::Rejected, Severity::Defect, Severity::Advisory];
742        let strings: HashSet<_> = all.iter().map(|s| s.as_str()).collect();
743        assert_eq!(strings.len(), 3);
744        assert_eq!(Severity::Defect.to_string(), "DEFECT");
745    }
746
747    #[test]
748    fn an_authored_path_round_trips() {
749        for text in [
750            "tasks[1].tasks[0].id",
751            "loop.setup[0].id",
752            "function.input.fields.a",
753            "[2]",
754            "",
755            "a[0][1].b",
756        ] {
757            let path: AuthoredPath = text.parse().unwrap();
758            assert_eq!(path.to_string(), text);
759            assert_eq!(path.is_root(), text.is_empty());
760        }
761        assert!("a..b".parse::<AuthoredPath>().is_err());
762        assert!("a[x]".parse::<AuthoredPath>().is_err());
763        assert!("a[1".parse::<AuthoredPath>().is_err());
764    }
765
766    #[test]
767    fn path_building_and_serialization() {
768        let mut p = AuthoredPath::root().join("tasks").join(1usize);
769        assert_eq!(p.to_string(), "tasks[1]");
770        let q = p.extend(&"function.name".parse().unwrap());
771        assert_eq!(q.to_string(), "tasks[1].function.name");
772        assert_eq!(p.pop(), Some(PathSeg::Index(1)));
773        assert_eq!(
774            serde_json::to_string(&q).unwrap(),
775            "\"tasks[1].function.name\""
776        );
777    }
778
779    #[test]
780    fn an_issue_takes_its_codes_severity_and_displays() {
781        let issue = Issue::new(IssueCode::UnknownKey, "tasks[0].x".parse().unwrap(), "m");
782        assert_eq!(issue.severity, Severity::Advisory);
783        assert_eq!(issue.to_string(), "tasks[0].x: m [UNKNOWN_KEY]");
784        let root = Issue::new(IssueCode::NoTasks, AuthoredPath::root(), "m")
785            .with_severity(Severity::Defect)
786            .with_step(StepKind::Setup, Some("a"))
787            .with_field("f");
788        assert_eq!(root.severity, Severity::Defect);
789        assert_eq!(root.step_kind, Some(StepKind::Setup));
790        assert_eq!(root.step_id.as_deref(), Some("a"));
791        assert_eq!(root.field.as_deref(), Some("f"));
792        assert_eq!(root.to_string(), "m [NO_TASKS]");
793    }
794
795    #[test]
796    fn a_key_it_could_not_otherwise_print_is_quoted() {
797        for (key, shown) in [
798            ("a.b", r#"["a.b"]"#),
799            ("", r#"[""]"#),
800            ("x[0]", r#"["x[0]"]"#),
801            ("q\"t", r#"["q\"t"]"#),
802        ] {
803            let path = AuthoredPath::root().join(key);
804            assert_eq!(path.to_string(), shown, "{key:?}");
805            assert_eq!(shown.parse::<AuthoredPath>(), Ok(path), "{shown}");
806            let deeper = AuthoredPath::root().join("log").join(key).join(0usize);
807            let shown = format!("log{shown}[0]");
808            assert_eq!(deeper.to_string(), shown, "{key:?}");
809            assert_eq!(shown.parse::<AuthoredPath>(), Ok(deeper), "{shown}");
810        }
811    }
812
813    #[test]
814    fn a_quoted_key_round_trips_anywhere_in_a_path() {
815        let text = r#"tasks[0].function.input.fields["a.b"]"#;
816        let path: AuthoredPath = text.parse().unwrap();
817        assert_eq!(path.segs().len(), 6);
818        assert_eq!(path.segs()[5], PathSeg::Key("a.b".into()));
819        assert_eq!(path.to_string(), text);
820
821        let text = r#"["a.b"].c["]"]["\"\\"][3]"#;
822        let path: AuthoredPath = text.parse().unwrap();
823        assert_eq!(
824            path.segs(),
825            [
826                PathSeg::Key("a.b".into()),
827                PathSeg::Key("c".into()),
828                PathSeg::Key("]".into()),
829                PathSeg::Key("\"\\".into()),
830                PathSeg::Index(3),
831            ]
832        );
833        assert_eq!(path.to_string(), text);
834
835        // A key quoted when it need not be reads back, and displays bare: a
836        // backslash alone reads back unquoted.
837        let path: AuthoredPath = r#"a["b"]["\\"]"#.parse().unwrap();
838        assert_eq!(
839            path.segs(),
840            [
841                PathSeg::Key("a".into()),
842                PathSeg::Key("b".into()),
843                PathSeg::Key("\\".into()),
844            ]
845        );
846        assert_eq!(path.to_string(), r"a.b.\");
847        assert_eq!(path.to_string().parse::<AuthoredPath>(), Ok(path));
848
849        // An ordinary path displays unchanged.
850        let ordinary = AuthoredPath::root()
851            .join("tasks")
852            .join(1usize)
853            .join("function")
854            .join("input");
855        assert_eq!(ordinary.to_string(), "tasks[1].function.input");
856
857        for bad in [
858            r#"a.["b"]"#,
859            r#"a["b"#,
860            r#"a["b""#,
861            r#"a["b"x]"#,
862            r#"a[b"]"#,
863            r#"a"b"#,
864            r#"["\q"]"#,
865        ] {
866            assert!(bad.parse::<AuthoredPath>().is_err(), "{bad}");
867        }
868    }
869
870    #[test]
871    fn an_issue_serializes_with_its_parts_as_strings() {
872        let issue = Issue::new(
873            IssueCode::UnknownFunction,
874            r#"tasks[0].function.input["a.b"]"#.parse().unwrap(),
875            "no function named `x`",
876        )
877        .with_step(StepKind::Task, Some("t"))
878        .with_field("a.b");
879        let value = serde_json::to_value(&issue).unwrap();
880        let mut keys: Vec<&str> = value
881            .as_object()
882            .unwrap()
883            .keys()
884            .map(String::as_str)
885            .collect();
886        keys.sort_unstable();
887        let mut want = [
888            "code",
889            "severity",
890            "path",
891            "step_kind",
892            "step_id",
893            "field",
894            "message",
895        ];
896        want.sort_unstable();
897        assert_eq!(keys, want);
898        assert_eq!(value["code"], "UNKNOWN_FUNCTION");
899        assert_eq!(value["severity"], "REJECTED");
900        assert_eq!(value["path"], r#"tasks[0].function.input["a.b"]"#);
901        assert_eq!(value["step_kind"], "task");
902        assert_eq!(value["step_id"], "t");
903        assert_eq!(value["field"], "a.b");
904        assert_eq!(value["message"], "no function named `x`");
905        assert_eq!(serde_json::from_value::<Issue>(value).unwrap(), issue);
906
907        // The parts that are absent are `null`, and read back absent.
908        let bare = Issue::new(IssueCode::NoTasks, AuthoredPath::root(), "m");
909        let value = serde_json::to_value(&bare).unwrap();
910        assert_eq!(value["step_kind"], Value::Null);
911        assert_eq!(value["path"], "");
912        assert_eq!(serde_json::from_value::<Issue>(value).unwrap(), bare);
913    }
914
915    #[test]
916    fn the_parts_of_an_issue_read_back_from_their_strings() {
917        for kind in [StepKind::Task, StepKind::Group, StepKind::Setup] {
918            let value = serde_json::to_value(kind).unwrap();
919            assert_eq!(value, kind.as_str());
920            assert_eq!(serde_json::from_value::<StepKind>(value).unwrap(), kind);
921        }
922        for severity in [Severity::Rejected, Severity::Defect, Severity::Advisory] {
923            let value = serde_json::to_value(severity).unwrap();
924            assert_eq!(serde_json::from_value::<Severity>(value).unwrap(), severity);
925        }
926        for &code in IssueCode::ALL {
927            let value = serde_json::to_value(code).unwrap();
928            assert_eq!(value, code.as_str());
929            assert_eq!(serde_json::from_value::<IssueCode>(value).unwrap(), code);
930        }
931        assert!(serde_json::from_value::<StepKind>(json!("Task")).is_err());
932        assert!(serde_json::from_value::<Severity>(json!("rejected")).is_err());
933        assert!(serde_json::from_value::<IssueCode>(json!("NOT_A_CODE")).is_err());
934        assert!(serde_json::from_value::<AuthoredPath>(json!("a..b")).is_err());
935    }
936}