headless_lms_mcp/engine/
path.rs1use uuid::Uuid;
5
6use super::error::{EditError, EditErrorCode};
7
8pub(crate) fn unescape_segment(segment: &str) -> String {
10 segment.replace("~1", "/").replace("~0", "~")
11}
12
13pub(crate) fn escape_segment(segment: &str) -> String {
15 segment.replace('~', "~0").replace('/', "~1")
16}
17
18pub(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
28pub(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
33pub(crate) enum ItemRef<'a> {
35 Label(&'a str),
36 Id(Uuid),
37}
38
39pub(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
54pub(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
76pub(crate) enum BlockPath<'p> {
78 TopLevel,
80 Block { root: String, part: BlockPart<'p> },
82}
83
84pub(crate) enum BlockPart<'p> {
86 Whole,
88 Children,
90 ChildIndex(String),
92 Name,
94 Attributes(&'p str),
96 Other { field: String, is_deeper: bool },
98}
99
100pub(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
126pub(crate) fn split_segment(path: &str) -> (&str, &str) {
128 match path.find('/') {
129 Some(slash) => path.split_at(slash),
130 None => (path, ""),
131 }
132}