1use std::borrow::Cow;
10use std::fmt;
11
12use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
13use serde::{Deserialize, Serialize};
14
15#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
19#[error("invalid {kind}: {reason} (value: {value:?})")]
20pub struct ValidationError {
21 pub kind: &'static str,
23 pub reason: &'static str,
25 pub value: String,
27}
28
29fn 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
45fn 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
51fn 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
57fn is_dotted_identifiers(s: &str) -> bool {
59 !s.is_empty() && s.split('.').all(is_identifier)
60}
61
62macro_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 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 pub fn as_str(&self) -> &str {
91 &self.0
92 }
93
94 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
156string_newtype! {
159 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 pub fn hex(&self) -> &str {
186 &self.0["sha256:".len()..]
187 }
188
189 pub fn hex_prefix8(&self) -> &str {
192 &self.hex()[..8]
193 }
194}
195
196string_newtype! {
197 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 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 pub fn is_synthesized(&self) -> bool {
259 self.0.contains(':')
260 }
261}
262
263string_newtype! {
264 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 FeatureId {
290 kind: "FeatureId",
291 validate: |s: &str| -> Result<(), &'static str> {
292 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 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 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 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 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 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 JsonPointer {
477 kind: "JsonPointer",
478 validate: |s: &str| -> Result<(), &'static str> {
479 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
502macro_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 #[default]
523 #[serde(rename = $lit)]
524 Value,
525 }
526 };
527}
528pub(crate) use literal_marker;
529
530literal_marker! {
531 OnMissingInput => "unknown"
534}
535
536literal_marker! {
537 OnTimeout => "unknown"
540}
541
542literal_marker! {
543 Protection => "standard"
547}
548
549literal_marker! {
550 ProviderName => "devicerail"
552}
553
554#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
560pub struct IrVersion;
561
562impl IrVersion {
563 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#[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 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 pub fn as_value(&self) -> &serde_json::Value {
629 &self.0
630 }
631
632 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}