1use std::fmt::Write as _;
53
54use schemars::JsonSchema;
55use serde::{Deserialize, Serialize};
56
57use crate::primitives::{AssertId, FlowId, Hash, StepId};
58use crate::vocab::{HandlerHook, Phase};
59
60pub type RunPath = Vec<PathFrame>;
63
64#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
67#[serde(tag = "kind", rename_all = "camelCase")]
68#[schemars(deny_unknown_fields)]
69pub enum PathFrame {
70 #[serde(rename_all = "camelCase")]
72 Flow {
73 flow_id: FlowId,
75 ir_hash: Hash,
77 },
78 #[serde(rename_all = "camelCase")]
80 Step {
81 step_id: StepId,
83 },
84 #[serde(rename_all = "camelCase")]
86 Call {
87 #[serde(skip_serializing_if = "Option::is_none")]
91 step_id: Option<StepId>,
92 callee_flow_id: FlowId,
94 callee_ir_hash: Hash,
96 },
97 Iteration {
99 index: u64,
101 #[serde(skip_serializing_if = "Option::is_none")]
103 key: Option<String>,
104 },
105 Hook {
107 hook: HandlerHook,
109 trigger: u64,
111 },
112 Attempt {
114 n: u64,
116 },
117 Phase {
119 phase: Phase,
121 },
122 #[serde(rename_all = "camelCase")]
124 Assertion {
125 assert_id: AssertId,
127 },
128}
129
130#[derive(Debug, Clone, PartialEq, Eq)]
135pub enum ParsedPathFrame {
136 Flow {
138 flow_id: FlowId,
140 ir_hash_prefix: String,
142 },
143 Step {
145 step_id: StepId,
147 },
148 Call {
150 step_id: Option<StepId>,
153 callee_flow_id: FlowId,
155 callee_ir_hash_prefix: String,
157 },
158 Iteration {
160 index: u64,
162 key: Option<String>,
164 },
165 Hook {
167 hook: HandlerHook,
169 trigger: u64,
171 },
172 Attempt {
174 n: u64,
176 },
177 Phase {
179 phase: Phase,
181 },
182 Assertion {
184 assert_id: AssertId,
186 },
187}
188
189impl From<&PathFrame> for ParsedPathFrame {
190 fn from(frame: &PathFrame) -> Self {
191 match frame {
192 PathFrame::Flow { flow_id, ir_hash } => ParsedPathFrame::Flow {
193 flow_id: flow_id.clone(),
194 ir_hash_prefix: ir_hash.hex_prefix8().to_owned(),
195 },
196 PathFrame::Step { step_id } => ParsedPathFrame::Step {
197 step_id: step_id.clone(),
198 },
199 PathFrame::Call {
200 step_id,
201 callee_flow_id,
202 callee_ir_hash,
203 } => ParsedPathFrame::Call {
204 step_id: step_id.clone(),
205 callee_flow_id: callee_flow_id.clone(),
206 callee_ir_hash_prefix: callee_ir_hash.hex_prefix8().to_owned(),
207 },
208 PathFrame::Iteration { index, key } => ParsedPathFrame::Iteration {
209 index: *index,
210 key: key.clone(),
211 },
212 PathFrame::Hook { hook, trigger } => ParsedPathFrame::Hook {
213 hook: *hook,
214 trigger: *trigger,
215 },
216 PathFrame::Attempt { n } => ParsedPathFrame::Attempt { n: *n },
217 PathFrame::Phase { phase } => ParsedPathFrame::Phase { phase: *phase },
218 PathFrame::Assertion { assert_id } => ParsedPathFrame::Assertion {
219 assert_id: assert_id.clone(),
220 },
221 }
222 }
223}
224
225pub fn render_run_path(path: &[PathFrame]) -> String {
229 let parsed: Vec<ParsedPathFrame> = path.iter().map(ParsedPathFrame::from).collect();
230 render_parsed_run_path(&parsed)
231}
232
233pub fn render_parsed_run_path(path: &[ParsedPathFrame]) -> String {
236 let mut out = String::new();
237 let mut pending_call: Option<String> = None;
241 for frame in path {
242 if !matches!(
243 frame,
244 ParsedPathFrame::Attempt { .. } | ParsedPathFrame::Phase { .. }
245 ) {
246 flush_pending_call(&mut out, &mut pending_call);
247 }
248 match frame {
249 ParsedPathFrame::Flow {
250 flow_id,
251 ir_hash_prefix,
252 } => {
253 if !out.is_empty() {
254 out.push('/');
255 }
256 let _ = write!(out, "{flow_id}@{ir_hash_prefix}");
257 }
258 ParsedPathFrame::Step { step_id } => {
259 let _ = write!(out, "/{step_id}");
260 }
261 ParsedPathFrame::Call {
262 step_id,
263 callee_flow_id,
264 callee_ir_hash_prefix,
265 } => {
266 if let Some(id) = step_id {
267 let _ = write!(out, "/{id}");
268 }
269 pending_call = Some(format!("call→{callee_flow_id}@{callee_ir_hash_prefix}"));
270 }
271 ParsedPathFrame::Iteration { index, key } => {
272 match key {
273 Some(key) => {
274 let _ = write!(out, "[{index}:{key}]");
275 }
276 None => {
277 let _ = write!(out, "[{index}]");
278 }
279 };
280 }
281 ParsedPathFrame::Hook { hook, trigger } => {
282 let _ = write!(out, "/hook:{}:{trigger}", hook_wire_name(*hook));
283 }
284 ParsedPathFrame::Attempt { n } => {
285 let _ = write!(out, "#{n}");
286 }
287 ParsedPathFrame::Phase { phase } => {
288 let _ = write!(out, ":{}", phase_wire_name(*phase));
289 }
290 ParsedPathFrame::Assertion { assert_id } => {
291 let _ = write!(out, "!{assert_id}");
292 }
293 }
294 }
295 flush_pending_call(&mut out, &mut pending_call);
296 out
297}
298
299fn flush_pending_call(out: &mut String, pending_call: &mut Option<String>) {
300 if let Some(segment) = pending_call.take() {
301 out.push('/');
302 out.push_str(&segment);
303 }
304}
305
306#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
308#[error("invalid RunPath string at byte {offset}: {message}")]
309pub struct RunPathParseError {
310 pub offset: usize,
312 pub message: String,
314}
315
316fn parse_error(offset: usize, message: impl Into<String>) -> RunPathParseError {
317 RunPathParseError {
318 offset,
319 message: message.into(),
320 }
321}
322
323pub fn parse_run_path(input: &str) -> Result<Vec<ParsedPathFrame>, RunPathParseError> {
327 if input.is_empty() {
328 return Err(parse_error(0, "empty RunPath string"));
329 }
330 let mut frames: Vec<ParsedPathFrame> = Vec::new();
331 let mut offset = 0usize;
332 for (position, segment) in input.split('/').enumerate() {
333 if position == 0 {
334 let (name, prefix) = parse_name_at_hash(segment, offset, "flow root")?;
335 let flow_id = FlowId::new(name)
336 .map_err(|e| parse_error(offset, format!("invalid flow id: {e}")))?;
337 frames.push(ParsedPathFrame::Flow {
338 flow_id,
339 ir_hash_prefix: prefix,
340 });
341 } else if let Some(rest) = segment.strip_prefix("call→") {
342 parse_call_segment(rest, offset, &mut frames)?;
343 } else if segment.starts_with("hook:") {
344 frames.push(parse_hook_segment(segment, offset)?);
345 } else {
346 parse_step_segment(segment, offset, &mut frames)?;
347 }
348 offset += segment.len() + 1;
349 }
350 Ok(frames)
351}
352
353fn parse_name_at_hash<'a>(
355 segment: &'a str,
356 offset: usize,
357 what: &str,
358) -> Result<(&'a str, String), RunPathParseError> {
359 let Some((name, prefix)) = segment.split_once('@') else {
360 return Err(parse_error(
361 offset,
362 format!("a {what} segment must be '<flowId>@<8-hex irHash prefix>', got {segment:?}"),
363 ));
364 };
365 if prefix.len() != 8
366 || !prefix
367 .chars()
368 .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
369 {
370 return Err(parse_error(
371 offset,
372 format!("hash prefix must be exactly 8 lowercase hex chars, got {prefix:?}"),
373 ));
374 }
375 Ok((name, prefix.to_owned()))
376}
377
378fn parse_call_segment(
383 rest: &str,
384 offset: usize,
385 frames: &mut Vec<ParsedPathFrame>,
386) -> Result<(), RunPathParseError> {
387 let (name, prefix) = parse_name_at_hash(rest, offset, "call target")?;
388 let callee_flow_id = FlowId::new(name)
389 .map_err(|e| parse_error(offset, format!("invalid callee flow id: {e}")))?;
390
391 let mut anchor = frames.len();
392 while anchor > 0
393 && matches!(
394 frames[anchor - 1],
395 ParsedPathFrame::Attempt { .. } | ParsedPathFrame::Phase { .. }
396 )
397 {
398 anchor -= 1;
399 }
400 if anchor == 0 {
401 return Err(parse_error(offset, "a call→ segment cannot open a path"));
402 }
403 match frames[anchor - 1].clone() {
404 ParsedPathFrame::Step { step_id } => {
405 frames[anchor - 1] = ParsedPathFrame::Call {
406 step_id: Some(step_id),
407 callee_flow_id,
408 callee_ir_hash_prefix: prefix,
409 };
410 Ok(())
411 }
412 ParsedPathFrame::Hook { .. } => {
413 frames.insert(
414 anchor,
415 ParsedPathFrame::Call {
416 step_id: None,
417 callee_flow_id,
418 callee_ir_hash_prefix: prefix,
419 },
420 );
421 Ok(())
422 }
423 _ => Err(parse_error(
424 offset,
425 "a call→ segment must follow a step or hook segment",
426 )),
427 }
428}
429
430fn parse_hook_segment(segment: &str, offset: usize) -> Result<ParsedPathFrame, RunPathParseError> {
432 let rest = segment
433 .strip_prefix("hook:")
434 .expect("caller checked the prefix");
435 let Some((name, trigger)) = rest.split_once(':') else {
436 return Err(parse_error(
437 offset,
438 format!("a hook segment must be 'hook:<hookName>:<trigger>', got {segment:?}"),
439 ));
440 };
441 let Some(hook) = parse_hook_name(name) else {
442 return Err(parse_error(offset, format!("unknown hook name {name:?}")));
443 };
444 let trigger: u64 = trigger.parse().map_err(|_| {
445 parse_error(
446 offset,
447 format!("hook trigger must be a number, got {trigger:?}"),
448 )
449 })?;
450 Ok(ParsedPathFrame::Hook { hook, trigger })
451}
452
453fn parse_step_segment(
456 segment: &str,
457 offset: usize,
458 frames: &mut Vec<ParsedPathFrame>,
459) -> Result<(), RunPathParseError> {
460 let is_id_char = |c: char| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == ':';
461 let id_end = segment
462 .char_indices()
463 .find(|(_, c)| !is_id_char(*c))
464 .map_or(segment.len(), |(i, _)| i);
465 let id = &segment[..id_end];
466 if id.is_empty() {
467 return Err(parse_error(
468 offset,
469 format!("expected a step id, got {segment:?}"),
470 ));
471 }
472 let step_id =
473 StepId::new(id).map_err(|e| parse_error(offset, format!("invalid step id: {e}")))?;
474 frames.push(ParsedPathFrame::Step { step_id });
475
476 let mut rest = &segment[id_end..];
477 let mut after_attempt = false;
478 while let Some(next) = rest.chars().next() {
479 match next {
480 '#' => {
481 let (digits, tail) = take_ascii_digits(&rest[1..]);
482 if digits.is_empty() {
483 return Err(parse_error(
484 offset,
485 "'#' must be followed by an attempt number",
486 ));
487 }
488 let n: u64 = digits.parse().map_err(|_| {
489 parse_error(offset, format!("attempt number out of range: {digits:?}"))
490 })?;
491 frames.push(ParsedPathFrame::Attempt { n });
492 rest = tail;
493 after_attempt = true;
494 }
495 ':' => {
496 if !after_attempt {
500 return Err(parse_error(
501 offset,
502 "a ':<phase>' suffix is only valid directly after an attempt '#n'",
503 ));
504 }
505 let keyword_end = rest[1..]
506 .char_indices()
507 .find(|(_, c)| !c.is_ascii_lowercase())
508 .map_or(rest.len(), |(i, _)| i + 1);
509 let keyword = &rest[1..keyword_end];
510 let Some(phase) = parse_phase_name(keyword) else {
511 return Err(parse_error(offset, format!("unknown phase {keyword:?}")));
512 };
513 frames.push(ParsedPathFrame::Phase { phase });
514 rest = &rest[keyword_end..];
515 after_attempt = false;
516 }
517 '[' => {
518 let Some(close) = rest.find(']') else {
519 return Err(parse_error(offset, "unterminated '[' iteration suffix"));
520 };
521 let body = &rest[1..close];
522 let (index_str, key) = match body.split_once(':') {
523 Some((index, key)) => (index, Some(key)),
524 None => (body, None),
525 };
526 let index: u64 = index_str.parse().map_err(|_| {
527 parse_error(
528 offset,
529 format!("iteration index must be a number, got {index_str:?}"),
530 )
531 })?;
532 if key == Some("") {
533 return Err(parse_error(offset, "iteration key must not be empty"));
534 }
535 frames.push(ParsedPathFrame::Iteration {
536 index,
537 key: key.map(str::to_owned),
538 });
539 rest = &rest[close + 1..];
540 after_attempt = false;
541 }
542 '!' => {
543 let assert_id = AssertId::new(&rest[1..])
544 .map_err(|e| parse_error(offset, format!("invalid assertion id: {e}")))?;
545 frames.push(ParsedPathFrame::Assertion { assert_id });
546 rest = "";
547 }
548 other => {
549 return Err(parse_error(
550 offset,
551 format!("unexpected character {other:?} in step segment {segment:?}"),
552 ));
553 }
554 }
555 }
556 Ok(())
557}
558
559fn take_ascii_digits(s: &str) -> (&str, &str) {
560 let end = s
561 .char_indices()
562 .find(|(_, c)| !c.is_ascii_digit())
563 .map_or(s.len(), |(i, _)| i);
564 s.split_at(end)
565}
566
567fn hook_wire_name(hook: HandlerHook) -> &'static str {
568 match hook {
569 HandlerHook::OnFail => "onFail",
570 HandlerHook::OnUnknown => "onUnknown",
571 HandlerHook::OnError => "onError",
572 HandlerHook::OnResumeDrift => "onResumeDrift",
573 }
574}
575
576fn parse_hook_name(name: &str) -> Option<HandlerHook> {
577 match name {
578 "onFail" => Some(HandlerHook::OnFail),
579 "onUnknown" => Some(HandlerHook::OnUnknown),
580 "onError" => Some(HandlerHook::OnError),
581 "onResumeDrift" => Some(HandlerHook::OnResumeDrift),
582 _ => None,
583 }
584}
585
586fn phase_wire_name(phase: Phase) -> &'static str {
587 match phase {
588 Phase::Preflight => "preflight",
589 Phase::Act => "act",
590 Phase::Observe => "observe",
591 Phase::Assert => "assert",
592 }
593}
594
595fn parse_phase_name(name: &str) -> Option<Phase> {
596 match name {
597 "preflight" => Some(Phase::Preflight),
598 "act" => Some(Phase::Act),
599 "observe" => Some(Phase::Observe),
600 "assert" => Some(Phase::Assert),
601 _ => None,
602 }
603}
604
605#[cfg(test)]
606mod tests {
607 use super::*;
608
609 fn hash_with_prefix(prefix: &str) -> Hash {
610 Hash::new(format!("sha256:{prefix}{}", "0".repeat(64 - prefix.len())))
611 .expect("valid hash literal")
612 }
613
614 fn flow(id: &str, prefix: &str) -> PathFrame {
615 PathFrame::Flow {
616 flow_id: FlowId::new(id).expect("valid flow id"),
617 ir_hash: hash_with_prefix(prefix),
618 }
619 }
620
621 fn step(id: &str) -> PathFrame {
622 PathFrame::Step {
623 step_id: StepId::new(id).expect("valid step id"),
624 }
625 }
626
627 fn call(id: &str, callee: &str, prefix: &str) -> PathFrame {
628 PathFrame::Call {
629 step_id: Some(StepId::new(id).expect("valid step id")),
630 callee_flow_id: FlowId::new(callee).expect("valid flow id"),
631 callee_ir_hash: hash_with_prefix(prefix),
632 }
633 }
634
635 fn attempt(n: u64) -> PathFrame {
636 PathFrame::Attempt { n }
637 }
638
639 fn phase(phase: Phase) -> PathFrame {
640 PathFrame::Phase { phase }
641 }
642
643 fn assertion(id: &str) -> PathFrame {
644 PathFrame::Assertion {
645 assert_id: AssertId::new(id).expect("valid assert id"),
646 }
647 }
648
649 fn assert_round_trip(frames: &[PathFrame], expected: &str) {
652 let rendered = render_run_path(frames);
653 assert_eq!(rendered, expected);
654 let parsed = parse_run_path(&rendered).expect("canonical rendering must parse");
655 let truncated: Vec<ParsedPathFrame> = frames.iter().map(ParsedPathFrame::from).collect();
656 assert_eq!(parsed, truncated);
657 assert_eq!(render_parsed_run_path(&parsed), rendered);
658 }
659
660 #[test]
661 fn renders_step_attempt_phase() {
662 assert_round_trip(
664 &[
665 flow("checkout", "a1f3c9d2"),
666 step("loadCart"),
667 attempt(1),
668 phase(Phase::Act),
669 ],
670 "checkout@a1f3c9d2/loadCart#1:act",
671 );
672 }
673
674 #[test]
675 fn renders_iteration_and_assertion() {
676 assert_round_trip(
678 &[
679 flow("checkout", "a1f3c9d2"),
680 step("eachItem"),
681 PathFrame::Iteration {
682 index: 2,
683 key: None,
684 },
685 step("addToCart"),
686 attempt(3),
687 assertion("itemInCart"),
688 ],
689 "checkout@a1f3c9d2/eachItem[2]/addToCart#3!itemInCart",
690 );
691 }
692
693 #[test]
694 fn renders_call_crossing_into_callee() {
695 assert_round_trip(
698 &[
699 flow("checkout", "a1f3c9d2"),
700 call("purchase", "login", "9c2e77b0"),
701 step("enterPassword"),
702 attempt(2),
703 assertion("tokenVisible"),
704 ],
705 "checkout@a1f3c9d2/purchase/call→login@9c2e77b0/enterPassword#2!tokenVisible",
706 );
707 }
708
709 #[test]
710 fn call_attempt_attaches_to_the_call_step_segment() {
711 assert_round_trip(
714 &[
715 flow("checkout", "a1f3c9d2"),
716 call("purchase", "login", "9c2e77b0"),
717 attempt(2),
718 step("focusAccount"),
719 attempt(1),
720 phase(Phase::Preflight),
721 ],
722 "checkout@a1f3c9d2/purchase#2/call→login@9c2e77b0/focusAccount#1:preflight",
723 );
724 }
725
726 #[test]
727 fn path_may_end_at_the_call_frame() {
728 assert_round_trip(
730 &[
731 flow("checkout", "a1f3c9d2"),
732 call("purchase", "login", "9c2e77b0"),
733 ],
734 "checkout@a1f3c9d2/purchase/call→login@9c2e77b0",
735 );
736 }
737
738 #[test]
739 fn keyed_iteration_round_trips() {
740 assert_round_trip(
741 &[
742 flow("checkout", "a1f3c9d2"),
743 step("eachItem"),
744 PathFrame::Iteration {
745 index: 3,
746 key: Some("sku-42".to_owned()),
747 },
748 step("addToCart"),
749 ],
750 "checkout@a1f3c9d2/eachItem[3:sku-42]/addToCart",
751 );
752 }
753
754 #[test]
755 fn synthesized_step_ids_keep_their_colons() {
756 assert_round_trip(
757 &[
758 flow("checkout", "a1f3c9d2"),
759 step("pay"),
760 PathFrame::Hook {
761 hook: HandlerHook::OnUnknown,
762 trigger: 1,
763 },
764 step("pay:onUnknown:escalate"),
765 ],
766 "checkout@a1f3c9d2/pay/hook:onUnknown:1/pay:onUnknown:escalate",
767 );
768 }
769
770 #[test]
771 fn hook_launched_subflow_parses_with_step_less_call_frame() {
772 let input = "checkout@a1f3c9d2/pay/hook:onFail:1/call→repairCart@55d0ab12/clearStale#1:act";
775 let parsed = parse_run_path(input).expect("doc example must parse");
776 assert_eq!(
777 parsed,
778 vec![
779 ParsedPathFrame::Flow {
780 flow_id: FlowId::new("checkout").unwrap(),
781 ir_hash_prefix: "a1f3c9d2".to_owned(),
782 },
783 ParsedPathFrame::Step {
784 step_id: StepId::new("pay").unwrap()
785 },
786 ParsedPathFrame::Hook {
787 hook: HandlerHook::OnFail,
788 trigger: 1
789 },
790 ParsedPathFrame::Call {
791 step_id: None,
792 callee_flow_id: FlowId::new("repairCart").unwrap(),
793 callee_ir_hash_prefix: "55d0ab12".to_owned(),
794 },
795 ParsedPathFrame::Step {
796 step_id: StepId::new("clearStale").unwrap()
797 },
798 ParsedPathFrame::Attempt { n: 1 },
799 ParsedPathFrame::Phase { phase: Phase::Act },
800 ],
801 );
802 assert_eq!(render_parsed_run_path(&parsed), input);
803 }
804
805 #[test]
806 fn rejects_malformed_paths() {
807 for bad in [
808 "", "checkout", "checkout@a1f3", "checkout@A1F3C9D2", "checkout@a1f3c9d2/x#", "checkout@a1f3c9d2/x#1:sleep", "checkout@a1f3c9d2/eachItem[2]:act", "checkout@a1f3c9d2/eachItem[a]", "checkout@a1f3c9d2/eachItem[2", "checkout@a1f3c9d2/hook:onFoo:1", "checkout@a1f3c9d2/hook:onFail", "checkout@a1f3c9d2/call→x@11223344", "checkout@a1f3c9d2/9bad", "checkout@a1f3c9d2//x", ] {
823 assert!(parse_run_path(bad).is_err(), "expected reject: {bad:?}");
824 }
825 }
826}