Skip to main content

pointlock_ir/
run_path.rs

1//! `failedStepPath`: the structured RunPath representation (spine §9) and
2//! its canonical human-readable string form (07 §2.1).
3//!
4//! The frame array is authoritative — RunLog, `CheckpointView`,
5//! `StepRecord.runPath` and `pointlock locate` all use the JSON
6//! `PathFrame[]` form. The canonical string (e.g.
7//! `checkout@a1f3c9d2/purchase/call→login@9c2e77b0/enterPassword#2!tokenVisible`)
8//! is its deterministic rendering, implemented here by [`render_run_path`]
9//! and parsed back by [`parse_run_path`]. Macro expansions never appear in
10//! a path — the sourceMap translates back to YAML lines and macro call
11//! chains. Not part of the FlowIR wire schema; consumed by
12//! `pointlock-store` / `pointlock locate`.
13//!
14//! ## Rendering rules (07 §2.1, verbatim)
15//!
16//! | frame | rendering | notes |
17//! |---|---|---|
18//! | `flow` | `<flowId>@<hash8>` | leading segment; flows always carry the first 8 hex of their irHash |
19//! | `step` | `/<stepId>` | |
20//! | `call` | `/<stepId>/call→<calleeFlowId>@<hash8>` | one frame, two visual segments |
21//! | `iteration` | `[<index>]` or `[<index>:<key>]` | no `/`; appends to the foreach step segment |
22//! | `hook` | `/hook:<hookName>:<trigger>` | handler audit frame (spine R10) |
23//! | `attempt` | `#<n>` | appends to the step segment, n ≥ 1 |
24//! | `phase` | `:<preflight\|act\|observe\|assert>` | appends after the attempt |
25//! | `assertion` | `!<assertId>` | path tail |
26//!
27//! An attempt (and a phase) following a `call` frame attaches to the call
28//! step's own segment, before the arrow segment — 07's example
29//! `purchase#2/call→login@9c2e77b0` (caller retried the callee: call step
30//! attempt #2). A `call→` segment directly after a `hook` segment (07's
31//! `pay/hook:onFail:1/call→repairCart@55d0ab12`) is a handler-launched
32//! subflow and carries no call step id; [`ParsedPathFrame::Call::step_id`]
33//! is `None` there.
34//!
35//! ## Grammar caveats (documented)
36//!
37//! - Rendering truncates hashes to their first 8 hex chars, so parsing
38//!   yields [`ParsedPathFrame`] (hash *prefixes*), not [`PathFrame`].
39//!   Round-tripping is exact in both directions modulo that truncation:
40//!   `render(frames) == render_parsed(parse(render(frames)))`.
41//! - A phase suffix is only recognized after an attempt (`#n:phase`, per
42//!   07 §2.1 "appends after the attempt"); a bare `:` inside a segment
43//!   parses as part of a compiler-synthesized step id (which legally
44//!   contains `:`). A synthesized id whose last segment spells a phase
45//!   keyword is therefore renderable but not distinguishable — the
46//!   structured array remains authoritative for such paths.
47//! - Segments starting with `hook:` always parse as hook frames (a real
48//!   hook trigger count is numeric, which no step-id segment can be).
49//! - Iteration keys must not contain `/`, `]` or be empty; v0.1 locates by
50//!   index and reserves `key` for keyed foreach.
51
52use 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
60/// A structured run path: an ordered stack of frames from the run's root
61/// flow down to the addressed site.
62pub type RunPath = Vec<PathFrame>;
63
64/// One frame of a [`RunPath`] (8 kinds, closed; spine §9), internally tagged
65/// with `kind`.
66#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
67#[serde(tag = "kind", rename_all = "camelCase")]
68#[schemars(deny_unknown_fields)]
69pub enum PathFrame {
70    /// A flow root. Paths always carry the flow's `irHash` (hard rule 1).
71    #[serde(rename_all = "camelCase")]
72    Flow {
73        /// The flow's id.
74        flow_id: FlowId,
75        /// The flow's content hash.
76        ir_hash: Hash,
77    },
78    /// A step within the current flow body.
79    #[serde(rename_all = "camelCase")]
80    Step {
81        /// The step's id.
82        step_id: StepId,
83    },
84    /// A subflow call frame.
85    #[serde(rename_all = "camelCase")]
86    Call {
87        /// The call step's id. Absent exactly when this call frame is a
88        /// handler-repair launched flow (directly under a `hook` frame,
89        /// no host call step — spine §9).
90        #[serde(skip_serializing_if = "Option::is_none")]
91        step_id: Option<StepId>,
92        /// The callee's flow id.
93        callee_flow_id: FlowId,
94        /// The callee's content hash.
95        callee_ir_hash: Hash,
96    },
97    /// A foreach iteration.
98    Iteration {
99        /// Zero-based iteration index.
100        index: u64,
101        /// Optional stable item key.
102        #[serde(skip_serializing_if = "Option::is_none")]
103        key: Option<String>,
104    },
105    /// A handler execution (audit trace of spine R10).
106    Hook {
107        /// The hook that fired.
108        hook: HandlerHook,
109        /// One-based trigger count for this hook.
110        trigger: u64,
111    },
112    /// An act-chain attempt.
113    Attempt {
114        /// One-based attempt number.
115        n: u64,
116    },
117    /// A step pipeline phase.
118    Phase {
119        /// The phase.
120        phase: Phase,
121    },
122    /// An assertion within the assert phase.
123    #[serde(rename_all = "camelCase")]
124    Assertion {
125        /// The assertion's id.
126        assert_id: AssertId,
127    },
128}
129
130/// A [`PathFrame`] as recoverable from the canonical string: identical in
131/// shape except that flow hashes appear as their rendered 8-hex prefixes
132/// (the full digest is not present in the string), and a call frame under a
133/// hook segment carries no step id (handler-launched subflow, 07 §2.1).
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub enum ParsedPathFrame {
136    /// A flow root.
137    Flow {
138        /// The flow's id.
139        flow_id: FlowId,
140        /// The first 8 hex chars of the flow's irHash.
141        ir_hash_prefix: String,
142    },
143    /// A step within the current flow body.
144    Step {
145        /// The step's id.
146        step_id: StepId,
147    },
148    /// A subflow call frame.
149    Call {
150        /// The call step's id; `None` for a handler-launched subflow
151        /// (a `call→` segment directly under a `hook:` segment).
152        step_id: Option<StepId>,
153        /// The callee's flow id.
154        callee_flow_id: FlowId,
155        /// The first 8 hex chars of the callee's irHash.
156        callee_ir_hash_prefix: String,
157    },
158    /// A foreach iteration.
159    Iteration {
160        /// Zero-based iteration index.
161        index: u64,
162        /// Optional stable item key.
163        key: Option<String>,
164    },
165    /// A handler execution.
166    Hook {
167        /// The hook that fired.
168        hook: HandlerHook,
169        /// One-based trigger count for this hook.
170        trigger: u64,
171    },
172    /// An act-chain attempt.
173    Attempt {
174        /// One-based attempt number.
175        n: u64,
176    },
177    /// A step pipeline phase.
178    Phase {
179        /// The phase.
180        phase: Phase,
181    },
182    /// An assertion within the assert phase.
183    Assertion {
184        /// The assertion's id.
185        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
225/// Renders a structured [`RunPath`] to its canonical human-readable string
226/// (07 §2.1; rules in the module docs). Deterministic; hashes appear as
227/// their first 8 hex chars.
228pub 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
233/// Renders a parsed run path back to the canonical string. For any `s`
234/// accepted by [`parse_run_path`], `render_parsed_run_path(&parse_run_path(s)?) == s`.
235pub fn render_parsed_run_path(path: &[ParsedPathFrame]) -> String {
236    let mut out = String::new();
237    // A call frame renders as two visual segments; the arrow segment is
238    // held back so that a following attempt/phase can attach to the call
239    // step's own segment (07: `purchase#2/call→login@9c2e77b0`).
240    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/// Error produced by [`parse_run_path`].
307#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
308#[error("invalid RunPath string at byte {offset}: {message}")]
309pub struct RunPathParseError {
310    /// Byte offset (of the offending segment's start) in the input.
311    pub offset: usize,
312    /// Human-readable reason.
313    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
323/// Parses a canonical RunPath string back to its frames. Inverse of
324/// [`render_run_path`] up to hash truncation (see [`ParsedPathFrame`] and
325/// the grammar caveats in the module docs).
326pub 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
353/// `<name>@<8 hex>` — the flow-root and call-arrow segment shape.
354fn 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
378/// `call→<calleeFlowId>@<hash8>`: converts the preceding step segment into
379/// a call frame (walking back over attached attempt/phase frames), or —
380/// directly after a hook segment — pushes a step-id-less call frame
381/// (handler-launched subflow).
382fn 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
430/// `hook:<hookName>:<trigger>`.
431fn 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
453/// A step segment: `<stepId>` followed by attached suffixes
454/// (`[i]`/`[i:key]`, `#n`, `:phase` after an attempt, `!assertId`).
455fn 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                // Phases attach after attempts (07 §2.1); a ':' anywhere
497                // else belongs to a synthesized step id and was consumed by
498                // the id token above.
499                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    /// render → parse → render must reproduce the string, and the parsed
650    /// frames must equal the truncated originals.
651    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        // 07 §2.1 example 1.
663        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        // 07 §2.1 example 2.
677        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        // 07 §2.1 example 3 (assertId adapted to the AssertId grammar,
696        // which admits no '.').
697        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        // 07 §2.1 example 4: caller re-invoked the callee (call step
712        // attempt #2); the attempt renders before the arrow segment.
713        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        // A call frame always renders both visual segments.
729        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        // 07 §2.1 example 5: handler repair subflow — the call arrow
773        // follows the hook segment directly, with no call step id.
774        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            "",                                  // empty
809            "checkout",                          // flow root without @hash8
810            "checkout@a1f3",                     // hash prefix too short
811            "checkout@A1F3C9D2",                 // hash prefix not lowercase hex
812            "checkout@a1f3c9d2/x#",              // '#' without digits
813            "checkout@a1f3c9d2/x#1:sleep",       // unknown phase
814            "checkout@a1f3c9d2/eachItem[2]:act", // phase not after an attempt
815            "checkout@a1f3c9d2/eachItem[a]",     // non-numeric iteration index
816            "checkout@a1f3c9d2/eachItem[2",      // unterminated iteration
817            "checkout@a1f3c9d2/hook:onFoo:1",    // unknown hook name
818            "checkout@a1f3c9d2/hook:onFail",     // hook without trigger
819            "checkout@a1f3c9d2/call→x@11223344", // call arrow directly after flow root
820            "checkout@a1f3c9d2/9bad",            // invalid step id
821            "checkout@a1f3c9d2//x",              // empty segment
822        ] {
823            assert!(parse_run_path(bad).is_err(), "expected reject: {bad:?}");
824        }
825    }
826}