Skip to main content

headless_lms_mcp/engine/
mod.rs

1//! Pure edit engines. They take documents and ops as plain data, never touch the database or the
2//! network, and either return the whole new state or reject the whole call.
3
4pub mod blocks;
5pub mod course_settings;
6pub mod course_structure;
7pub mod error;
8pub mod find_replace;
9pub mod glossary_references;
10pub(crate) mod labels;
11pub mod op;
12pub mod page_content;
13pub(crate) mod path;
14pub mod read;
15pub mod structural_replace;
16
17use headless_lms_utils::document_schema_processor::GutenbergBlock;
18use serde::{Deserialize, Serialize};
19use serde_json::{Map, Value};
20use uuid::Uuid;
21
22use crate::ids::IdGenerator;
23use crate::instruction_lists::{InstructionsList, InstructionsLocation};
24
25pub use error::{EditError, EditErrorCode, Warning, WarningCode};
26pub use op::{PatchTarget, parse_ops};
27
28/// The editor (and validator) context a page's blocks are checked against. A chapter's front
29/// page takes the same blocks as its other pages.
30#[derive(
31    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, utoipa::ToSchema,
32)]
33#[serde(rename_all = "kebab-case")]
34pub enum PageType {
35    /// A page inside a chapter.
36    CoursePage,
37    /// A page outside chapters other than the course front page.
38    TopLevelPage,
39    /// The course's front page, `/`.
40    CourseFrontPage,
41}
42
43impl PageType {
44    /// The type of a page in a chapter or, outside chapters, at `url_path`.
45    pub fn of(in_chapter: bool, url_path: &str) -> Self {
46        match (in_chapter, url_path) {
47            (true, _) => PageType::CoursePage,
48            (false, "/") => PageType::CourseFrontPage,
49            (false, _) => PageType::TopLevelPage,
50        }
51    }
52}
53
54/// One page's block tree with what the engines need to know about the page.
55#[derive(Debug, Clone, PartialEq)]
56pub struct PageDocument {
57    pub page_id: Uuid,
58    pub page_type: PageType,
59    pub blocks: Vec<GutenbergBlock>,
60}
61
62impl PageDocument {
63    /// The page borrowed, as the course-wide engines take it.
64    pub fn as_page_ref(&self) -> PageRef<'_> {
65        PageRef {
66            page_id: self.page_id,
67            page_type: self.page_type,
68            blocks: &self.blocks,
69        }
70    }
71}
72
73/// A page borrowed from a course state. Engines that scan many pages take these and copy only
74/// the pages they change.
75#[derive(Debug, Clone, Copy)]
76pub struct PageRef<'a> {
77    pub page_id: Uuid,
78    pub page_type: PageType,
79    pub blocks: &'a [GutenbergBlock],
80}
81
82impl PageRef<'_> {
83    /// An owned copy of the page, to change.
84    pub fn to_document(self) -> PageDocument {
85        PageDocument {
86            page_id: self.page_id,
87            page_type: self.page_type,
88            blocks: self.blocks.to_vec(),
89        }
90    }
91}
92
93impl<'a> From<PageRef<'a>> for DocumentRef<'a> {
94    fn from(page: PageRef<'a>) -> Self {
95        DocumentRef {
96            key: DocumentKey::Page(page.page_id),
97            page_type: page.page_type,
98            blocks: page.blocks,
99        }
100    }
101}
102
103/// Which block document a course-wide engine read: a page or exercise text. Serialized, it is the
104/// location reported in results.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum DocumentKey {
107    Page(Uuid),
108    /// `page_id` is the page holding the exercise; `None` for the course default's.
109    Instructions {
110        list: InstructionsList,
111        page_id: Option<Uuid>,
112    },
113    /// The name, as one paragraph of rich text.
114    ExerciseName {
115        exercise_id: Uuid,
116        page_id: Uuid,
117    },
118}
119
120impl DocumentKey {
121    /// The page the document is or is on; `None` for the course default review instructions.
122    pub fn page_id(self) -> Option<Uuid> {
123        match self {
124            DocumentKey::Page(page_id) | DocumentKey::ExerciseName { page_id, .. } => Some(page_id),
125            DocumentKey::Instructions { page_id, .. } => page_id,
126        }
127    }
128
129    /// A match of `block` at `path` within the document, addressed as the tool that edits the
130    /// document takes it.
131    pub(crate) fn block_match(
132        self,
133        block: &GutenbergBlock,
134        path: String,
135        preview: String,
136    ) -> BlockMatch {
137        let (block_id, path, block_name) = match self {
138            DocumentKey::Page(_) => (Some(block.client_id), path, block.name.clone()),
139            DocumentKey::Instructions { list, .. } => (
140                Some(block.client_id),
141                format!("{}{path}", list.path_prefix()),
142                block.name.clone(),
143            ),
144            DocumentKey::ExerciseName { .. } => {
145                (None, "/name".to_string(), "exercise name".to_string())
146            }
147        };
148        BlockMatch {
149            document: self,
150            block_id,
151            path,
152            block_name,
153            preview,
154        }
155    }
156}
157
158impl std::fmt::Display for DocumentKey {
159    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160        match self {
161            DocumentKey::Page(page_id) => write!(f, "page {page_id}"),
162            DocumentKey::Instructions { list, .. } => match list {
163                InstructionsList::Task {
164                    exercise_id,
165                    task_id,
166                } => write!(f, "exercise {exercise_id} task {task_id} instructions"),
167                InstructionsList::Review { exercise_id } => {
168                    write!(f, "exercise {exercise_id} review instructions")
169                }
170                InstructionsList::CourseDefaultReview { .. } => {
171                    write!(f, "the course default review instructions")
172                }
173            },
174            DocumentKey::ExerciseName { exercise_id, .. } => {
175                write!(f, "exercise {exercise_id} name")
176            }
177        }
178    }
179}
180
181impl Serialize for DocumentKey {
182    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
183        #[derive(Serialize)]
184        struct Location {
185            #[serde(skip_serializing_if = "Option::is_none")]
186            page_id: Option<Uuid>,
187            #[serde(flatten)]
188            instructions: Option<InstructionsLocation>,
189            #[serde(skip_serializing_if = "Option::is_none")]
190            exercise: Option<Uuid>,
191        }
192        let (instructions, exercise) = match self {
193            DocumentKey::Page(_) => (None, None),
194            DocumentKey::Instructions { list, .. } => (Some(list.location()), None),
195            DocumentKey::ExerciseName { exercise_id, .. } => (None, Some(*exercise_id)),
196        };
197        Location {
198            page_id: self.page_id(),
199            instructions,
200            exercise,
201        }
202        .serialize(serializer)
203    }
204}
205
206/// A block document the course-wide engines read, borrowed.
207#[derive(Debug, Clone, Copy)]
208pub struct DocumentRef<'a> {
209    pub key: DocumentKey,
210    pub page_type: PageType,
211    pub blocks: &'a [GutenbergBlock],
212}
213
214impl DocumentRef<'_> {
215    /// An owned copy of the document, to change.
216    pub fn to_document(self) -> Document {
217        Document {
218            key: self.key,
219            page_type: self.page_type,
220            blocks: self.blocks.to_vec(),
221        }
222    }
223}
224
225/// A block document the course-wide engines read or changed.
226#[derive(Debug, Clone, PartialEq)]
227pub struct Document {
228    pub key: DocumentKey,
229    pub page_type: PageType,
230    pub blocks: Vec<GutenbergBlock>,
231}
232
233impl Document {
234    /// The document as a borrowed [`DocumentRef`].
235    pub fn as_document_ref(&self) -> DocumentRef<'_> {
236        DocumentRef {
237            key: self.key,
238            page_type: self.page_type,
239            blocks: &self.blocks,
240        }
241    }
242}
243
244/// A block a bulk edit matched.
245#[derive(Debug, Clone, PartialEq, Serialize)]
246pub struct BlockMatch {
247    #[serde(flatten)]
248    pub document: DocumentKey,
249    /// `None` for an exercise name.
250    #[serde(skip_serializing_if = "Option::is_none")]
251    pub block_id: Option<Uuid>,
252    /// Ready to read or patch: the block's path, or `/<block id>/attributes/<attribute>`, under
253    /// the tool that edits the document.
254    pub path: String,
255    pub block_name: String,
256    pub preview: String,
257}
258
259impl BlockMatch {
260    /// Where the match is, as an error lists it, with its number of matches when counted.
261    pub(crate) fn location(&self, count: Option<usize>) -> Value {
262        #[derive(Serialize)]
263        struct Location<'a> {
264            #[serde(flatten)]
265            document: DocumentKey,
266            path: &'a str,
267            #[serde(skip_serializing_if = "Option::is_none")]
268            count: Option<usize>,
269        }
270        serde_json::to_value(Location {
271            document: self.document,
272            path: &self.path,
273            count,
274        })
275        .unwrap_or_default()
276    }
277}
278
279/// Where an op puts items: next to a sibling, or at the start or end of a list `L`.
280#[derive(Debug, Clone, Copy)]
281pub(crate) enum Anchor<L> {
282    Before(Uuid),
283    After(Uuid),
284    Start(L),
285    End(L),
286}
287
288impl<L: Copy> Anchor<L> {
289    /// The sibling, or the list's owner as `owner` gives it, which a move mustn't take along.
290    pub(crate) fn target_id(self, owner: impl FnOnce(L) -> Option<Uuid>) -> Option<Uuid> {
291        match self {
292            Anchor::Before(id) | Anchor::After(id) => Some(id),
293            Anchor::Start(list) | Anchor::End(list) => owner(list),
294        }
295    }
296
297    /// The list `P` to insert into and the index there. `sibling` gives a sibling's list and
298    /// index, `list_len` a list and its length.
299    pub(crate) fn insertion_point<P>(
300        self,
301        sibling: impl FnOnce(Uuid) -> Result<(P, usize), EditError>,
302        list_len: impl FnOnce(L) -> Result<(P, usize), EditError>,
303    ) -> Result<(P, usize), EditError> {
304        match self {
305            Anchor::Before(id) => sibling(id),
306            Anchor::After(id) => sibling(id).map(|(list, index)| (list, index + 1)),
307            Anchor::Start(list) => list_len(list).map(|(list, _)| (list, 0)),
308            Anchor::End(list) => list_len(list),
309        }
310    }
311}
312
313/// Ids one op created, in payload order (pre-order for nested items).
314#[derive(Debug, Clone, PartialEq, Serialize)]
315pub struct CreatedIds {
316    pub op: usize,
317    pub ids: Vec<Uuid>,
318}
319
320/// Mints the ids a call's ops create and lists each under the op that created it.
321pub(crate) struct CreatedIdLog<'a> {
322    generator: &'a dyn IdGenerator,
323    op: usize,
324    created: Vec<CreatedIds>,
325}
326
327impl<'a> CreatedIdLog<'a> {
328    /// A log that mints ids with `generator`.
329    pub(crate) fn new(generator: &'a dyn IdGenerator) -> Self {
330        Self {
331            generator,
332            op: 0,
333            created: Vec::new(),
334        }
335    }
336
337    /// Lists the ids minted or recorded from now on under op `index`.
338    pub(crate) fn set_op(&mut self, index: usize) {
339        self.op = index;
340    }
341
342    /// The op being applied.
343    pub(crate) fn op(&self) -> usize {
344        self.op
345    }
346
347    /// A new id, listed as created by the current op.
348    pub(crate) fn new_id(&mut self) -> Uuid {
349        let id = self.generator.new_id();
350        self.record(id);
351        id
352    }
353
354    /// Lists `id`, minted with [`Self::generator`], as created by the current op.
355    pub(crate) fn record(&mut self, id: Uuid) {
356        match self.created.last_mut() {
357            Some(last) if last.op == self.op => last.ids.push(id),
358            _ => self.created.push(CreatedIds {
359                op: self.op,
360                ids: vec![id],
361            }),
362        }
363    }
364
365    /// The generator, for ids that aren't listed (exercise ids) or are listed afterwards.
366    pub(crate) fn generator(&self) -> &'a dyn IdGenerator {
367        self.generator
368    }
369
370    /// The ids the call created, listed by op.
371    pub(crate) fn into_created(self) -> Vec<CreatedIds> {
372        self.created
373    }
374}
375
376/// The value as a string with more than whitespace in it. Errors with `InvalidValue` saying that
377/// `what` is one.
378pub(crate) fn non_empty_string(value: Option<&Value>, what: &str) -> Result<String, EditError> {
379    match value {
380        Some(Value::String(s)) if !s.trim().is_empty() => Ok(s.clone()),
381        _ => Err(EditError::new(
382            EditErrorCode::InvalidValue,
383            format!("{what} is a non-empty string"),
384        )),
385    }
386}
387
388/// The items an `add` value creates: each item of a non-empty list, or the one object. `noun`
389/// names an item in the error, e.g. "a block".
390pub(crate) fn value_items<'v>(value: &'v Value, noun: &str) -> Result<Vec<&'v Value>, EditError> {
391    match value {
392        Value::Array(items) if !items.is_empty() => Ok(items.iter().collect()),
393        Value::Object(_) => Ok(vec![value]),
394        Value::Array(_) => Err(EditError::new(
395            EditErrorCode::InvalidValue,
396            "add got an empty list",
397        )),
398        _ => Err(EditError::new(
399            EditErrorCode::InvalidValue,
400            format!("add takes {noun} object or a list of them"),
401        )),
402    }
403}
404
405/// The objects an `add` value creates, each checked to have only `fields`. `noun` names an item
406/// in errors, e.g. "an audience", and `shape` is the error for an item that isn't an object.
407pub(crate) fn new_item_objects<'v>(
408    value: &'v Value,
409    noun: &str,
410    shape: &str,
411    fields: &[&str],
412) -> Result<Vec<&'v Map<String, Value>>, EditError> {
413    value_items(value, noun)?
414        .into_iter()
415        .map(|item| {
416            let object = item
417                .as_object()
418                .ok_or_else(|| EditError::new(EditErrorCode::InvalidValue, shape))?;
419            reject_unknown_keys(object, fields, noun)?;
420            Ok(object)
421        })
422        .collect()
423}
424
425/// Fails with `UnknownField` naming every key of `object` outside `allowed`. `noun` names the
426/// object in the message, e.g. "a block".
427pub(crate) fn reject_unknown_keys(
428    object: &Map<String, Value>,
429    allowed: &[&str],
430    noun: &str,
431) -> Result<(), EditError> {
432    let unknown: Vec<&String> = object
433        .keys()
434        .filter(|key| !allowed.contains(&key.as_str()))
435        .collect();
436    if unknown.is_empty() {
437        return Ok(());
438    }
439    Err(EditError::new(
440        EditErrorCode::UnknownField,
441        format!("{noun} has no field {}", op::quote_list(&unknown)),
442    )
443    .with_fix(format!("fields: {}", allowed.join(", "))))
444}