Skip to main content

headless_lms_mcp/engine/
path.rs

1//! The path grammar the engines share: segments with JSON Pointer escapes, ids and `$labels`,
2//! and block paths.
3
4use uuid::Uuid;
5
6use super::error::{EditError, EditErrorCode};
7
8/// A path segment with its JSON Pointer escapes (`~1` for `/`, `~0` for `~`) undone.
9pub(crate) fn unescape_segment(segment: &str) -> String {
10    segment.replace("~1", "/").replace("~0", "~")
11}
12
13/// `segment` JSON Pointer escaped, to put in a path; the inverse of [`unescape_segment`].
14pub(crate) fn escape_segment(segment: &str) -> String {
15    segment.replace('~', "~0").replace('/', "~1")
16}
17
18/// The unescaped segments of a path; `None` when it doesn't start with `/`.
19pub(crate) fn split_path(path: &str) -> Option<Vec<String>> {
20    Some(
21        path.strip_prefix('/')?
22            .split('/')
23            .map(unescape_segment)
24            .collect(),
25    )
26}
27
28/// Whether a path's first segment names the course: `course` or the course's id.
29pub(crate) fn is_course_root(segment: &str, course_id: Uuid) -> bool {
30    segment == "course" || Uuid::parse_str(segment).is_ok_and(|id| id == course_id)
31}
32
33/// How an id-or-label reference outside a path (`from`, `to_page`, reorder values) was written.
34pub(crate) enum ItemRef<'a> {
35    Label(&'a str),
36    Id(Uuid),
37}
38
39/// Parses `pg7`, `/pg7`, `$x` or `/$x`.
40pub(crate) fn parse_item_ref(raw: &str) -> Result<ItemRef<'_>, EditError> {
41    let trimmed = raw.strip_prefix('/').unwrap_or(raw);
42    if trimmed.contains('/') {
43        return Err(EditError::new(
44            EditErrorCode::InvalidPath,
45            format!("\"{raw}\" should name one item, like \"/<id>\""),
46        ));
47    }
48    if trimmed.starts_with('$') {
49        return Ok(ItemRef::Label(trimmed));
50    }
51    parse_uuid(trimmed, raw).map(ItemRef::Id)
52}
53
54/// Parses a path segment as an id. `whole` is the full path, quoted in the error.
55pub(crate) fn parse_uuid(segment: &str, whole: &str) -> Result<Uuid, EditError> {
56    if segment.parse::<u64>().is_ok() {
57        return Err(EditError::numeric_index(whole, "items")
58            .with_fix("read the outline to get ids, then use /<id>"));
59    }
60    if segment == "-" {
61        return Err(EditError::new(
62            EditErrorCode::NumericIndex,
63            format!("\"{whole}\" uses \"-\"; to append, use position \"end\" on the children list"),
64        )
65        .with_fix("{\"path\":\"/box1/innerBlocks\",\"position\":\"end\"} or {\"path\":\"\",\"position\":\"end\"}"));
66    }
67    Uuid::parse_str(segment).map_err(|_| {
68        EditError::new(
69            EditErrorCode::InvalidPath,
70            format!("\"{segment}\" in \"{whole}\" isn't an id or a $label"),
71        )
72        .with_fix("paths start with the full id of a block or entity: /<uuid>, /<uuid>/innerBlocks, /<uuid>/attributes/content")
73    })
74}
75
76/// A block path split into its parts, before its id or label is resolved.
77pub(crate) enum BlockPath<'p> {
78    /// `""`, `/` or `/blocks`: the page's top level.
79    TopLevel,
80    /// `/<root>…`, where `root` is an id or a `$label`.
81    Block { root: String, part: BlockPart<'p> },
82}
83
84/// What follows the root of a [`BlockPath`].
85pub(crate) enum BlockPart<'p> {
86    /// Nothing: the block itself.
87    Whole,
88    /// `/innerBlocks`.
89    Children,
90    /// `/innerBlocks/<segment>…`, which names a child by position.
91    ChildIndex(String),
92    /// `/name`.
93    Name,
94    /// `/attributes` and the JSON Pointer after it, `""` for all of them.
95    Attributes(&'p str),
96    /// Any other field, or `name` with segments after it.
97    Other { field: String, is_deeper: bool },
98}
99
100/// Splits a block path into its parts; `None` when it doesn't start with `/`.
101pub(crate) fn parse_block_path(path: &str) -> Option<BlockPath<'_>> {
102    if matches!(path, "" | "/" | "/blocks") {
103        return Some(BlockPath::TopLevel);
104    }
105    let rest = path.strip_prefix('/')?;
106    let (root, after_root) = split_segment(rest);
107    let part = match after_root.strip_prefix('/').map(split_segment) {
108        None => BlockPart::Whole,
109        Some(("attributes", pointer)) => BlockPart::Attributes(pointer),
110        Some(("innerBlocks", "")) => BlockPart::Children,
111        Some(("innerBlocks", index)) => {
112            BlockPart::ChildIndex(unescape_segment(split_segment(&index[1..]).0))
113        }
114        Some(("name", "")) => BlockPart::Name,
115        Some((field, after)) => BlockPart::Other {
116            field: unescape_segment(field),
117            is_deeper: !after.is_empty(),
118        },
119    };
120    Some(BlockPath::Block {
121        root: unescape_segment(root),
122        part,
123    })
124}
125
126/// The first segment of `path` (no leading `/`) and the rest, which starts with `/` or is empty.
127pub(crate) fn split_segment(path: &str) -> (&str, &str) {
128    match path.find('/') {
129        Some(slash) => path.split_at(slash),
130        None => (path, ""),
131    }
132}