Skip to main content

pointlock_ir/
primitives.rs

1//! Validated string primitives of the IR.
2//!
3//! Every grammar-bearing string in the baseline schema
4//! (`schema/flow-ir.v0.1.schema.json`) is a newtype here whose constructor
5//! enforces exactly the baseline pattern, so an in-memory value can never
6//! hold a string the wire schema would reject. Deserialization goes through
7//! the same constructor (`#[serde(try_from = "String")]`).
8
9use std::borrow::Cow;
10use std::fmt;
11
12use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
13use serde::{Deserialize, Serialize};
14
15/// Error returned when a validated primitive is constructed from an
16/// out-of-grammar string (or, for [`JsonSchemaDocument`], a non-object /
17/// non-boolean JSON value).
18#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
19#[error("invalid {kind}: {reason} (value: {value:?})")]
20pub struct ValidationError {
21    /// The primitive type that rejected the value (e.g. `"StepId"`).
22    pub kind: &'static str,
23    /// Human-readable reason for the rejection.
24    pub reason: &'static str,
25    /// The offending input, verbatim.
26    pub value: String,
27}
28
29// ─── Character-class helpers (hand-rolled; grammar identical to the baseline
30//     schema regexes, kept regex-crate-free to preserve the "no runtime deps
31//     beyond serde/schemars" stance of this crate) ──────────────────────────
32
33fn is_ident_start(c: char) -> bool {
34    c.is_ascii_alphabetic() || c == '_'
35}
36
37fn is_ident_continue(c: char) -> bool {
38    c.is_ascii_alphanumeric() || c == '_'
39}
40
41fn is_step_segment_continue(c: char) -> bool {
42    is_ident_continue(c) || c == '-'
43}
44
45/// `[A-Za-z_][A-Za-z0-9_]*`
46fn is_identifier(s: &str) -> bool {
47    let mut chars = s.chars();
48    matches!(chars.next(), Some(c) if is_ident_start(c)) && chars.all(is_ident_continue)
49}
50
51/// `[A-Za-z_][A-Za-z0-9_-]*` — one author-level step-id segment.
52fn is_step_segment(s: &str) -> bool {
53    let mut chars = s.chars();
54    matches!(chars.next(), Some(c) if is_ident_start(c)) && chars.all(is_step_segment_continue)
55}
56
57/// `Ident('.'Ident)*` — dotted identifier path with at least one segment.
58fn is_dotted_identifiers(s: &str) -> bool {
59    !s.is_empty() && s.split('.').all(is_identifier)
60}
61
62// ─── Newtype factory ────────────────────────────────────────────────────────
63
64macro_rules! string_newtype {
65    (
66        $(#[$meta:meta])*
67        $name:ident {
68            kind: $kind:literal,
69            validate: $validate:expr,
70            inline: $inline:expr,
71            schema: $schema:tt $(,)?
72        }
73    ) => {
74        $(#[$meta])*
75        #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
76        #[serde(try_from = "String", into = "String")]
77        pub struct $name(String);
78
79        impl $name {
80            /// Validates `value` against the grammar and constructs the newtype.
81            pub fn new(value: impl Into<String>) -> Result<Self, ValidationError> {
82                let value = value.into();
83                match ($validate)(value.as_str()) {
84                    Ok(()) => Ok(Self(value)),
85                    Err(reason) => Err(ValidationError { kind: $kind, reason, value }),
86                }
87            }
88
89            /// Borrows the underlying string.
90            pub fn as_str(&self) -> &str {
91                &self.0
92            }
93
94            /// Consumes the newtype, returning the underlying string.
95            pub fn into_string(self) -> String {
96                self.0
97            }
98        }
99
100        impl TryFrom<String> for $name {
101            type Error = ValidationError;
102            fn try_from(value: String) -> Result<Self, Self::Error> {
103                Self::new(value)
104            }
105        }
106
107        impl TryFrom<&str> for $name {
108            type Error = ValidationError;
109            fn try_from(value: &str) -> Result<Self, Self::Error> {
110                Self::new(value)
111            }
112        }
113
114        impl std::str::FromStr for $name {
115            type Err = ValidationError;
116            fn from_str(value: &str) -> Result<Self, Self::Err> {
117                Self::new(value)
118            }
119        }
120
121        impl From<$name> for String {
122            fn from(value: $name) -> String {
123                value.0
124            }
125        }
126
127        impl AsRef<str> for $name {
128            fn as_ref(&self) -> &str {
129                &self.0
130            }
131        }
132
133        impl fmt::Display for $name {
134            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135                f.write_str(&self.0)
136            }
137        }
138
139        impl JsonSchema for $name {
140            fn inline_schema() -> bool {
141                $inline
142            }
143            fn schema_name() -> Cow<'static, str> {
144                Cow::Borrowed(stringify!($name))
145            }
146            fn schema_id() -> Cow<'static, str> {
147                Cow::Borrowed(concat!("pointlock_ir::", stringify!($name)))
148            }
149            fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
150                json_schema!($schema)
151            }
152        }
153    };
154}
155
156// ─── The validated primitives ───────────────────────────────────────────────
157
158string_newtype! {
159    /// Content hash in canonical form `sha256:<64 lowercase hex>` (spine §3).
160    Hash {
161        kind: "Hash",
162        validate: |s: &str| -> Result<(), &'static str> {
163            let Some(hex) = s.strip_prefix("sha256:") else {
164                return Err("must start with 'sha256:'");
165            };
166            if hex.len() != 64 {
167                return Err("hex digest must be exactly 64 characters");
168            }
169            if !hex.chars().all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)) {
170                return Err("hex digest must be lowercase [0-9a-f]");
171            }
172            Ok(())
173        },
174        inline: false,
175        schema: {
176            "type": "string",
177            "pattern": "^sha256:[0-9a-f]{64}$",
178            "description": "Content hash, canonical form 'sha256:<64 lowercase hex>'."
179        },
180    }
181}
182
183impl Hash {
184    /// The 64-char lowercase hex digest, without the `sha256:` prefix.
185    pub fn hex(&self) -> &str {
186        &self.0["sha256:".len()..]
187    }
188
189    /// The first 8 hex chars of the digest — the abbreviation used by the
190    /// canonical RunPath rendering (07 §2.1: `<flowId>@<hash8>`).
191    pub fn hex_prefix8(&self) -> &str {
192        &self.hex()[..8]
193    }
194}
195
196string_newtype! {
197    /// Flow identifier: `^[A-Za-z_][A-Za-z0-9_.-]*$`, max 256 chars.
198    FlowId {
199        kind: "FlowId",
200        validate: |s: &str| -> Result<(), &'static str> {
201            if s.len() > 256 {
202                return Err("exceeds 256 characters");
203            }
204            let mut chars = s.chars();
205            match chars.next() {
206                Some(c) if is_ident_start(c) => {}
207                _ => return Err("must start with [A-Za-z_]"),
208            }
209            if !chars.all(|c| is_ident_continue(c) || c == '.' || c == '-') {
210                return Err("allowed characters after the first are [A-Za-z0-9_.-]");
211            }
212            Ok(())
213        },
214        inline: false,
215        schema: {
216            "type": "string",
217            "pattern": "^[A-Za-z_][A-Za-z0-9_.-]*$",
218            "maxLength": 256
219        },
220    }
221}
222
223string_newtype! {
224    /// Step identifier (spine §3, 02 §3).
225    ///
226    /// Author-written ids are a single segment `[A-Za-z_][A-Za-z0-9_-]*`.
227    /// `:`-separated additional segments are reserved for compiler-synthesized
228    /// ids (handler-embedded human steps such as `flow:onUnknown:escalate`,
229    /// macro hygiene prefixes such as `set_ssid:field`). Synthesized ids never
230    /// appear in ref paths ([`RefPath`] has no `:` in its grammar).
231    StepId {
232        kind: "StepId",
233        validate: |s: &str| -> Result<(), &'static str> {
234            if s.len() > 256 {
235                return Err("exceeds 256 characters");
236            }
237            if s.is_empty() {
238                return Err("must not be empty");
239            }
240            if !s.split(':').all(is_step_segment) {
241                return Err("each ':'-separated segment must match [A-Za-z_][A-Za-z0-9_-]*");
242            }
243            Ok(())
244        },
245        inline: false,
246        schema: {
247            "type": "string",
248            "pattern": "^[A-Za-z_][A-Za-z0-9_-]*(:[A-Za-z_][A-Za-z0-9_-]*)*$",
249            "maxLength": 256,
250            "description": "Author-written ids use the first segment only ([A-Za-z_][A-Za-z0-9_-]*). ':'-separated segments are reserved for compiler-synthesized ids (e.g. handler-embedded human steps 'host:onFail:escalate'); synthesized ids never appear in ref paths."
251        },
252    }
253}
254
255impl StepId {
256    /// `true` when this id carries a compiler-synthesized `:` segment
257    /// (macro hygiene prefix or handler-embedded step id).
258    pub fn is_synthesized(&self) -> bool {
259        self.0.contains(':')
260    }
261}
262
263string_newtype! {
264    /// Plain identifier: `^[A-Za-z_][A-Za-z0-9_]*$`, max 128 chars.
265    /// Used for param/output names, `ExprMap` keys, `foreach.as`, etc.
266    Identifier {
267        kind: "Identifier",
268        validate: |s: &str| -> Result<(), &'static str> {
269            if s.len() > 128 {
270                return Err("exceeds 128 characters");
271            }
272            if !is_identifier(s) {
273                return Err("must match [A-Za-z_][A-Za-z0-9_]*");
274            }
275            Ok(())
276        },
277        inline: false,
278        schema: {
279            "type": "string",
280            "pattern": "^[A-Za-z_][A-Za-z0-9_]*$",
281            "maxLength": 128
282        },
283    }
284}
285
286string_newtype! {
287    /// DeviceRail feature id passthrough (e.g. `device.semanticActions.v1`);
288    /// future Pointlock-owned features use the `pointlock.` prefix.
289    FeatureId {
290        kind: "FeatureId",
291        validate: |s: &str| -> Result<(), &'static str> {
292            // ^[a-z][a-zA-Z0-9]*(\.[a-zA-Z0-9]+)*\.v[0-9]+$
293            let segments: Vec<&str> = s.split('.').collect();
294            if segments.len() < 2 {
295                return Err("must have at least two '.'-separated segments ending in vN");
296            }
297            let first = segments[0];
298            let mut chars = first.chars();
299            match chars.next() {
300                Some(c) if c.is_ascii_lowercase() => {}
301                _ => return Err("first segment must start with a lowercase letter"),
302            }
303            if !chars.all(|c| c.is_ascii_alphanumeric()) {
304                return Err("first segment must be alphanumeric");
305            }
306            let last = segments[segments.len() - 1];
307            let Some(version) = last.strip_prefix('v') else {
308                return Err("last segment must be 'v' followed by digits");
309            };
310            if version.is_empty() || !version.chars().all(|c| c.is_ascii_digit()) {
311                return Err("last segment must be 'v' followed by digits");
312            }
313            for middle in &segments[1..segments.len() - 1] {
314                if middle.is_empty() || !middle.chars().all(|c| c.is_ascii_alphanumeric()) {
315                    return Err("middle segments must be non-empty alphanumeric");
316                }
317            }
318            Ok(())
319        },
320        inline: false,
321        schema: {
322            "type": "string",
323            "pattern": "^[a-z][a-zA-Z0-9]*(\\.[a-zA-Z0-9]+)*\\.v[0-9]+$",
324            "description": "DeviceRail feature id passthrough (e.g. 'device.semanticActions.v1'); future Pointlock-owned features use the 'pointlock.' prefix."
325        },
326    }
327}
328
329string_newtype! {
330    /// Provider-native action name (DeviceRail camelCase, e.g. `tapElement`).
331    ///
332    /// Canonical verbs never appear here; provider-qualified names
333    /// (`<provider>:<action>`) are reserved for multi-provider futures and
334    /// rejected in v0.1 (spine R7 / A.6).
335    ActionName {
336        kind: "ActionName",
337        validate: |s: &str| -> Result<(), &'static str> {
338            if s.len() > 256 {
339                return Err("exceeds 256 characters");
340            }
341            let mut chars = s.chars();
342            match chars.next() {
343                Some(c) if c.is_ascii_alphabetic() => {}
344                _ => return Err("must start with [A-Za-z]"),
345            }
346            if !chars.all(is_ident_continue) {
347                return Err("allowed characters after the first are [A-Za-z0-9_]");
348            }
349            Ok(())
350        },
351        inline: false,
352        schema: {
353            "type": "string",
354            "pattern": "^[A-Za-z][A-Za-z0-9_]*$",
355            "maxLength": 256,
356            "description": "Provider-native action name (DeviceRail camelCase, e.g. 'tapElement'). Canonical verbs never appear here; provider-qualified names ('<provider>:<action>') are reserved for multi-provider futures and rejected in v0.1."
357        },
358    }
359}
360
361string_newtype! {
362    /// Assertion identifier: `^[A-Za-z_][A-Za-z0-9_-]*$`, max 128 chars.
363    ///
364    /// Inlined in the schema (the baseline declares this grammar inline on
365    /// `AssertionIR.assertId` rather than as a named `$def`).
366    AssertId {
367        kind: "AssertId",
368        validate: |s: &str| -> Result<(), &'static str> {
369            if s.len() > 128 {
370                return Err("exceeds 128 characters");
371            }
372            if !is_step_segment(s) {
373                return Err("must match [A-Za-z_][A-Za-z0-9_-]*");
374            }
375            Ok(())
376        },
377        inline: true,
378        schema: {
379            "type": "string",
380            "pattern": "^[A-Za-z_][A-Za-z0-9_-]*$",
381            "maxLength": 128
382        },
383    }
384}
385
386string_newtype! {
387    /// Closed-scope reference path of the expression grammar (spine §7, 02 §8.1):
388    ///
389    /// ```ebnf
390    /// RefPath ::= 'params.' Ident ('.' Ident)*
391    ///           | 'env.'    Ident
392    ///           | 'vars.'   Ident
393    ///           | 'iter.'   Ident
394    ///           | 'steps.'  StepIdRef '.' 'output' ('.' Ident)*
395    ///           | 'steps.'  StepIdRef '.' 'verdict'
396    /// ```
397    ///
398    /// Dotted identifier segments only; array/deep access goes through the
399    /// `jsonPath` pure function. `secrets.*` is reserved for v0.2 and rejected
400    /// today. `StepIdRef` deliberately has no `:`: compiler-synthesized step
401    /// ids can never be referenced.
402    RefPath {
403        kind: "RefPath",
404        validate: |s: &str| -> Result<(), &'static str> {
405            if s.len() > 1024 {
406                return Err("exceeds 1024 characters");
407            }
408            if let Some(rest) = s.strip_prefix("params.") {
409                return if is_dotted_identifiers(rest) {
410                    Ok(())
411                } else {
412                    Err("params.* takes a dotted identifier path")
413                };
414            }
415            for prefix in ["env.", "vars.", "iter."] {
416                if let Some(rest) = s.strip_prefix(prefix) {
417                    return if is_identifier(rest) {
418                        Ok(())
419                    } else {
420                        Err("env./vars./iter. take exactly one identifier")
421                    };
422                }
423            }
424            if let Some(rest) = s.strip_prefix("steps.") {
425                let Some((step_id, tail)) = rest.split_once('.') else {
426                    return Err("steps.<id> must be followed by .output or .verdict");
427                };
428                if !is_step_segment(step_id) {
429                    return Err("step id segment must match [A-Za-z_][A-Za-z0-9_-]* (synthesized ':' ids are not referenceable)");
430                }
431                if tail == "verdict" || tail == "output" {
432                    return Ok(());
433                }
434                if let Some(fields) = tail.strip_prefix("output.") {
435                    return if is_dotted_identifiers(fields) {
436                        Ok(())
437                    } else {
438                        Err("output field access takes a dotted identifier path")
439                    };
440                }
441                return Err("steps.<id> supports only .verdict and .output(.field)*");
442            }
443            Err("scope root must be one of params/env/vars/iter/steps ('secrets' is reserved for v0.2)")
444        },
445        inline: false,
446        schema: {
447            "type": "string",
448            "pattern": "^(params\\.[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)*|env\\.[A-Za-z_][A-Za-z0-9_]*|vars\\.[A-Za-z_][A-Za-z0-9_]*|iter\\.[A-Za-z_][A-Za-z0-9_]*|steps\\.[A-Za-z_][A-Za-z0-9_-]*\\.(verdict|output(\\.[A-Za-z_][A-Za-z0-9_]*)*))$",
449            "maxLength": 1024,
450            "description": "Closed scope grammar: params.* | env.* | vars.* | iter.* | steps.<id>.output(.field)* | steps.<id>.verdict. Dotted identifier segments only; array/deep access goes through the jsonPath pure function."
451        },
452    }
453}
454
455impl RefPath {
456    /// Returns the referenced upstream step id when this path is rooted at
457    /// `steps.` (the syntactic data-dependency answer of 02 §8.1), or `None`
458    /// for the other scope roots.
459    pub fn referenced_step(&self) -> Option<&str> {
460        let rest = self.0.strip_prefix("steps.")?;
461        rest.split_once('.').map(|(step_id, _)| step_id)
462    }
463
464    /// Returns the scope root of the path (`params`, `env`, `vars`, `iter`
465    /// or `steps`).
466    pub fn scope_root(&self) -> &str {
467        self.0
468            .split_once('.')
469            .map(|(root, _)| root)
470            .unwrap_or(&self.0)
471    }
472}
473
474string_newtype! {
475    /// RFC 6901 JSON Pointer into a FlowIR document (`SourceMapEntry.irPath`).
476    JsonPointer {
477        kind: "JsonPointer",
478        validate: |s: &str| -> Result<(), &'static str> {
479            // ^(/([^~/]|~0|~1)*)*$  — empty is legal (whole document);
480            // otherwise each token starts with '/', and '~' must be followed
481            // by '0' or '1'.
482            if !s.is_empty() && !s.starts_with('/') {
483                return Err("non-empty pointer must start with '/'");
484            }
485            let mut chars = s.chars();
486            while let Some(c) = chars.next() {
487                if c == '~' && !matches!(chars.next(), Some('0') | Some('1')) {
488                    return Err("'~' must be followed by '0' or '1'");
489                }
490            }
491            Ok(())
492        },
493        inline: true,
494        schema: {
495            "type": "string",
496            "pattern": "^(/([^~/]|~0|~1)*)*$",
497            "description": "RFC 6901 JSON Pointer into this FlowIR document."
498        },
499    }
500}
501
502// ─── Literal markers (const wire values) ────────────────────────────────────
503
504/// Declares a single-variant enum that serializes to exactly one string
505/// literal — the Rust encoding of a schema `const` field. Deserialization of
506/// any other value fails, which is how `onMissingInput: "unknown"`,
507/// `protection: "standard"`, step `kind` tags, etc. are enforced at the type
508/// level.
509macro_rules! literal_marker {
510    (
511        $(#[$meta:meta])*
512        $name:ident => $lit:literal
513    ) => {
514        $(#[$meta])*
515        #[derive(
516            Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash,
517            Serialize, Deserialize, JsonSchema,
518        )]
519        #[schemars(inline)]
520        pub enum $name {
521            /// The only admissible wire value.
522            #[default]
523            #[serde(rename = $lit)]
524            Value,
525        }
526    };
527}
528pub(crate) use literal_marker;
529
530literal_marker! {
531    /// `onMissingInput: "unknown"` — principle 4 typed: an assertion whose
532    /// input is missing yields `unknown`, never a guess.
533    OnMissingInput => "unknown"
534}
535
536literal_marker! {
537    /// `onTimeout: "unknown"` — a human step that times out never defaults
538    /// to pass or fail (principles 4/8).
539    OnTimeout => "unknown"
540}
541
542literal_marker! {
543    /// `protection: "standard"` — v0.1 casts spine R6 into the type system:
544    /// protected actions are rejected at bind. Widening this to an enum in
545    /// v0.2 (`secrets.*` / `protected: true`) is a breaking change (02 §11).
546    Protection => "standard"
547}
548
549literal_marker! {
550    /// `provider.name: "devicerail"` — the only provider of v0.1.
551    ProviderName => "devicerail"
552}
553
554// ─── IrVersion (const 1) ────────────────────────────────────────────────────
555
556/// The IR semantic-generation number, pinned to the integer `1` in v0.1
557/// (02 §11). Serializes as the JSON number `1`; any other value fails
558/// deserialization (runners match `irVersion` exactly, fail-closed).
559#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
560pub struct IrVersion;
561
562impl IrVersion {
563    /// The numeric value of this version marker.
564    pub const VALUE: u64 = 1;
565}
566
567impl Serialize for IrVersion {
568    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
569        serializer.serialize_u64(Self::VALUE)
570    }
571}
572
573impl<'de> Deserialize<'de> for IrVersion {
574    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
575        let value = u64::deserialize(deserializer)?;
576        if value == Self::VALUE {
577            Ok(IrVersion)
578        } else {
579            Err(serde::de::Error::custom(format!(
580                "unsupported irVersion {value}: this crate implements irVersion {}",
581                Self::VALUE
582            )))
583        }
584    }
585}
586
587impl JsonSchema for IrVersion {
588    fn inline_schema() -> bool {
589        true
590    }
591    fn schema_name() -> Cow<'static, str> {
592        Cow::Borrowed("IrVersion")
593    }
594    fn schema_id() -> Cow<'static, str> {
595        Cow::Borrowed("pointlock_ir::IrVersion")
596    }
597    fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
598        json_schema!({ "const": 1 })
599    }
600}
601
602// ─── JsonSchemaDocument ─────────────────────────────────────────────────────
603
604/// An embedded JSON Schema (Draft 2020-12) document.
605///
606/// Deliberately open (baseline exemption class 1): its internal grammar is
607/// governed by the JSON Schema meta-schema, not by the IR schema. The only
608/// shape constraint enforced here is the JSON Schema data model itself: a
609/// schema document is an object or a boolean.
610#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
611#[serde(try_from = "serde_json::Value", into = "serde_json::Value")]
612pub struct JsonSchemaDocument(serde_json::Value);
613
614impl JsonSchemaDocument {
615    /// Validates that `value` is an object or boolean and wraps it.
616    pub fn new(value: serde_json::Value) -> Result<Self, ValidationError> {
617        match &value {
618            serde_json::Value::Object(_) | serde_json::Value::Bool(_) => Ok(Self(value)),
619            other => Err(ValidationError {
620                kind: "JsonSchemaDocument",
621                reason: "a JSON Schema document must be an object or a boolean",
622                value: other.to_string(),
623            }),
624        }
625    }
626
627    /// Borrows the underlying JSON value.
628    pub fn as_value(&self) -> &serde_json::Value {
629        &self.0
630    }
631
632    /// Consumes the wrapper, returning the underlying JSON value.
633    pub fn into_value(self) -> serde_json::Value {
634        self.0
635    }
636}
637
638impl TryFrom<serde_json::Value> for JsonSchemaDocument {
639    type Error = ValidationError;
640    fn try_from(value: serde_json::Value) -> Result<Self, Self::Error> {
641        Self::new(value)
642    }
643}
644
645impl From<JsonSchemaDocument> for serde_json::Value {
646    fn from(doc: JsonSchemaDocument) -> serde_json::Value {
647        doc.0
648    }
649}
650
651impl JsonSchema for JsonSchemaDocument {
652    fn schema_name() -> Cow<'static, str> {
653        Cow::Borrowed("JsonSchemaDocument")
654    }
655    fn schema_id() -> Cow<'static, str> {
656        Cow::Borrowed("pointlock_ir::JsonSchemaDocument")
657    }
658    fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
659        json_schema!({
660            "anyOf": [{ "type": "object" }, { "type": "boolean" }],
661            "description": "An embedded JSON Schema (Draft 2020-12) document. Deliberately open (exemption class 1): its internal grammar is governed by the JSON Schema meta-schema, not by this schema."
662        })
663    }
664}
665
666#[cfg(test)]
667mod tests {
668    use super::*;
669
670    #[test]
671    fn hash_grammar() {
672        assert!(Hash::new(format!("sha256:{}", "a".repeat(64))).is_ok());
673        assert!(Hash::new(format!("sha256:{}", "A".repeat(64))).is_err());
674        assert!(Hash::new("sha256:abcd").is_err());
675        assert!(Hash::new(format!("sha1:{}", "a".repeat(64))).is_err());
676    }
677
678    #[test]
679    fn step_id_grammar() {
680        assert!(StepId::new("open_wifi_settings").is_ok());
681        assert!(StepId::new("set_ssid:field").is_ok());
682        assert!(StepId::new("flow:onUnknown:escalate").is_ok());
683        assert!(StepId::new("9bad").is_err());
684        assert!(StepId::new("a:").is_err());
685        assert!(StepId::new(":a").is_err());
686        assert!(StepId::new("a..b").is_err());
687        assert!(StepId::new("").is_err());
688        assert!(!StepId::new("plain").unwrap().is_synthesized());
689        assert!(StepId::new("m:x").unwrap().is_synthesized());
690    }
691
692    #[test]
693    fn ref_path_grammar() {
694        for ok in [
695            "params.ssid",
696            "params.a.b.c",
697            "env.deviceId",
698            "vars.report_label",
699            "iter.item",
700            "steps.wifi_on_visible.verdict",
701            "steps.wait_connected.output",
702            "steps.wait_connected.output.matched",
703            "steps.x.output.a.b",
704        ] {
705            assert!(RefPath::new(ok).is_ok(), "expected accept: {ok}");
706        }
707        for bad in [
708            "secrets.token",
709            "params.",
710            "env.a.b",
711            "steps.x",
712            "steps.x.outputs",
713            "steps.x.verdict.status",
714            "steps.set_ssid:field.output",
715            "steps..output",
716            "output.x",
717            "",
718        ] {
719            assert!(RefPath::new(bad).is_err(), "expected reject: {bad}");
720        }
721        assert_eq!(
722            RefPath::new("steps.wait_connected.output.matched")
723                .unwrap()
724                .referenced_step(),
725            Some("wait_connected")
726        );
727        assert_eq!(RefPath::new("params.ssid").unwrap().referenced_step(), None);
728    }
729
730    #[test]
731    fn feature_id_grammar() {
732        assert!(FeatureId::new("device.semanticActions.v1").is_ok());
733        assert!(FeatureId::new("verdict.record.v1").is_ok());
734        assert!(FeatureId::new("session.export.page.v1").is_ok());
735        assert!(FeatureId::new("Device.foo.v1").is_err());
736        assert!(FeatureId::new("device.foo").is_err());
737        assert!(FeatureId::new("device..v1").is_err());
738        assert!(FeatureId::new("v1").is_err());
739    }
740
741    #[test]
742    fn json_pointer_grammar() {
743        assert!(JsonPointer::new("").is_ok());
744        assert!(JsonPointer::new("/body/0").is_ok());
745        assert!(JsonPointer::new("/a~0b/~1c").is_ok());
746        assert!(JsonPointer::new("body/0").is_err());
747        assert!(JsonPointer::new("/a~2b").is_err());
748        assert!(JsonPointer::new("/a~").is_err());
749    }
750
751    #[test]
752    fn ir_version_round_trip() {
753        assert_eq!(
754            serde_json::to_value(IrVersion).unwrap(),
755            serde_json::json!(1)
756        );
757        assert!(serde_json::from_value::<IrVersion>(serde_json::json!(1)).is_ok());
758        assert!(serde_json::from_value::<IrVersion>(serde_json::json!(2)).is_err());
759    }
760}