1use 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#[derive(Debug, Default)]
14pub(crate) struct Labels {
15 assigned: BTreeMap<String, Uuid>,
16 defined_by_op: HashMap<String, usize>,
18}
19
20impl Labels {
21 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 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 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 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 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 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
148pub(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}