Skip to main content

headless_lms_mcp/engine/
labels.rs

1//! `$name` labels: call-local names for items the call creates.
2
3use std::collections::{BTreeMap, HashMap};
4
5use serde_json::{Map, Value};
6use uuid::Uuid;
7
8use super::error::{EditError, EditErrorCode};
9use super::op::Op;
10use super::path::{ItemRef, parse_item_ref, parse_uuid};
11
12/// The `$label`s of one call and the ids they name.
13#[derive(Debug, Default)]
14pub(crate) struct Labels {
15    assigned: BTreeMap<String, Uuid>,
16    /// Labels some later op defines, for "used before defined" errors.
17    defined_by_op: HashMap<String, usize>,
18}
19
20impl Labels {
21    /// Scans every op for the labels it defines so out-of-order use gets a precise error.
22    pub(crate) fn prescan(ops: &[Op]) -> Self {
23        let mut defined_by_op = HashMap::new();
24        for (index, op) in ops.iter().enumerate() {
25            if let Some(label) = op.label() {
26                defined_by_op.entry(label.to_string()).or_insert(index);
27            }
28            if let Some(value) = op.value() {
29                collect_value_labels(value, index, &mut defined_by_op);
30            }
31        }
32        Self {
33            assigned: BTreeMap::new(),
34            defined_by_op,
35        }
36    }
37
38    /// Binds `label` to the new item `id`. Errors if the label is malformed or already bound.
39    pub(crate) fn define(&mut self, label: &str, id: Uuid) -> Result<(), EditError> {
40        check_label_syntax(label)?;
41        if self.assigned.contains_key(label) {
42            return Err(EditError::new(
43                EditErrorCode::LabelReused,
44                format!("label {label} is already used in this call; each label names one item"),
45            )
46            .with_fix(format!("rename one of them, e.g. {label}2")));
47        }
48        self.assigned.insert(label.to_string(), id);
49        Ok(())
50    }
51
52    fn resolve(&self, label: &str, current_op: usize) -> Result<Uuid, EditError> {
53        if let Some(id) = self.assigned.get(label) {
54            return Ok(*id);
55        }
56        match self.defined_by_op.get(label) {
57            Some(defining) if *defining >= current_op => Err(EditError::new(
58                EditErrorCode::LabelUndefined,
59                format!("label {label} is created by op {defining}, which comes later in ops"),
60            )
61            .with_fix("move the op that creates the label before the ops that use it")),
62            _ => Err(EditError::new(
63                EditErrorCode::LabelUndefined,
64                format!("label {label} isn't defined in this call"),
65            )
66            .with_fix(
67                "labels last for one call only; use the id returned by the call that created the item, or re-read",
68            )),
69        }
70    }
71
72    /// The id a path segment names: a `$label` or a UUID. `whole` is the path, for errors.
73    pub(crate) fn resolve_segment(
74        &self,
75        segment: &str,
76        whole: &str,
77        current_op: usize,
78    ) -> Result<Uuid, EditError> {
79        if segment.starts_with('$') {
80            self.resolve(segment, current_op)
81        } else {
82            parse_uuid(segment, whole)
83        }
84    }
85
86    /// The id an item reference such as `through` names: a `$label` or an id path.
87    pub(crate) fn resolve_item_ref(&self, raw: &str, current_op: usize) -> Result<Uuid, EditError> {
88        match parse_item_ref(raw)? {
89            ItemRef::Label(label) => self.resolve(label, current_op),
90            ItemRef::Id(id) => Ok(id),
91        }
92    }
93
94    /// [`Self::resolve_item_ref`] for an optional field.
95    pub(crate) fn resolve_optional_item_ref(
96        &self,
97        raw: Option<&str>,
98        current_op: usize,
99    ) -> Result<Option<Uuid>, EditError> {
100        raw.map(|raw| self.resolve_item_ref(raw, current_op))
101            .transpose()
102    }
103
104    /// The labels bound by the call.
105    pub(crate) fn into_map(self) -> BTreeMap<String, Uuid> {
106        self.assigned
107    }
108}
109
110fn check_label_syntax(label: &str) -> Result<(), EditError> {
111    let valid = label.len() > 1
112        && label.starts_with('$')
113        && label[1..]
114            .chars()
115            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
116    if valid {
117        Ok(())
118    } else {
119        Err(EditError::new(
120            EditErrorCode::LabelInvalid,
121            format!("label \"{label}\" is invalid; labels start with $ followed by letters, digits, _ or -"),
122        )
123        .with_fix(format!("${}", label.trim_start_matches('$').replace(|c: char| !c.is_ascii_alphanumeric(), "_"))))
124    }
125}
126
127fn collect_value_labels(value: &Value, op: usize, out: &mut HashMap<String, usize>) {
128    match value {
129        Value::Object(object) => {
130            if let Some(Value::String(label)) = object.get("label") {
131                out.entry(label.clone()).or_insert(op);
132            }
133            for key in ["innerBlocks", "pages", "blocks"] {
134                if let Some(child) = object.get(key) {
135                    collect_value_labels(child, op, out);
136                }
137            }
138        }
139        Value::Array(items) => {
140            for item in items {
141                collect_value_labels(item, op, out);
142            }
143        }
144        _ => {}
145    }
146}
147
148/// The `label` field of a new item, if it has one. `example` shows a label in the error.
149pub(crate) fn label_field(
150    object: &Map<String, Value>,
151    example: &str,
152) -> Result<Option<String>, EditError> {
153    match object.get("label") {
154        None | Some(Value::Null) => Ok(None),
155        Some(Value::String(label)) => Ok(Some(label.clone())),
156        Some(_) => Err(EditError::new(
157            EditErrorCode::LabelInvalid,
158            format!("label is a string like \"{example}\""),
159        )),
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use crate::engine::op::{PatchTarget, parse_ops};
167    use serde_json::json;
168
169    #[test]
170    fn label_used_before_definition() {
171        let ops = parse_ops(
172            &[
173                json!({"op":"remove","path":"/$box"}),
174                json!({"op":"add","path":"","position":"end","value":{"label":"$box","name":"moocfi/infobox"}}),
175            ],
176            PatchTarget::PageContent,
177        )
178        .unwrap();
179        let labels = Labels::prescan(&ops);
180        let error = labels.resolve("$box", 0).unwrap_err();
181        assert_eq!(error.code, EditErrorCode::LabelUndefined);
182        assert!(error.message.contains("op 1"));
183    }
184
185    #[test]
186    fn label_reuse_and_syntax() {
187        let mut labels = Labels::default();
188        labels.define("$a", Uuid::nil()).unwrap();
189        assert_eq!(
190            labels.define("$a", Uuid::nil()).unwrap_err().code,
191            EditErrorCode::LabelReused
192        );
193        assert_eq!(
194            labels.define("box", Uuid::nil()).unwrap_err().code,
195            EditErrorCode::LabelInvalid
196        );
197    }
198}