Skip to main content

headless_lms_mcp/
diff.rs

1//! Compares two states of course content: blocks matched by id, and modules, chapters and pages
2//! by the fields models compare.
3
4use std::collections::{HashMap, HashSet};
5
6use headless_lms_models::library::{
7    agent_changesets::{StagedEntity, course_state::CourseState},
8    entity_snapshots::{ContentEntityType, EntityKey},
9};
10use headless_lms_utils::document_schema_processor::GutenbergBlock;
11use serde::Serialize;
12use uuid::Uuid;
13
14use crate::engine::PageRef;
15use crate::snapshots;
16
17/// How a block or an entity differs between two states.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
19#[serde(rename_all = "snake_case")]
20pub enum ChangeKind {
21    Added,
22    Removed,
23    Changed,
24    /// Only its place differs.
25    Moved,
26}
27
28/// One block that differs between two states of the same pages.
29#[derive(Debug, Clone, PartialEq, Serialize)]
30pub struct BlockChange {
31    pub block_id: Uuid,
32    pub name: String,
33    pub kind: ChangeKind,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub page_before: Option<Uuid>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub page_after: Option<Uuid>,
38    /// Set for exercise blocks.
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub exercise_id: Option<Uuid>,
41    /// `name` and the attribute keys whose values differ, for changed blocks.
42    #[serde(skip_serializing_if = "Vec::is_empty")]
43    pub changed_fields: Vec<String>,
44    /// Also set on a changed block that moved.
45    pub is_moved: bool,
46}
47
48struct Placed<'a> {
49    page: Uuid,
50    parent: Option<Uuid>,
51    previous_sibling: Option<Uuid>,
52    block: &'a GutenbergBlock,
53}
54
55/// Where every block of the pages is, and the block ids in page order.
56fn place_blocks<'a>(pages: &[PageRef<'a>]) -> (HashMap<Uuid, Placed<'a>>, Vec<Uuid>) {
57    fn place<'a>(
58        page: Uuid,
59        parent: Option<Uuid>,
60        blocks: &'a [GutenbergBlock],
61        placed: &mut HashMap<Uuid, Placed<'a>>,
62        order: &mut Vec<Uuid>,
63    ) {
64        let mut previous_sibling = None;
65        for block in blocks {
66            order.push(block.client_id);
67            placed.insert(
68                block.client_id,
69                Placed {
70                    page,
71                    parent,
72                    previous_sibling,
73                    block,
74                },
75            );
76            place(
77                page,
78                Some(block.client_id),
79                &block.inner_blocks,
80                placed,
81                order,
82            );
83            previous_sibling = Some(block.client_id);
84        }
85    }
86    let mut placed = HashMap::new();
87    let mut order = Vec::new();
88    for page in pages {
89        place(page.page_id, None, page.blocks, &mut placed, &mut order);
90    }
91    (placed, order)
92}
93
94/// The blocks that differ between the pages of `before` and `after`. A page must be on both sides
95/// when it exists on both.
96pub fn diff_blocks(before: &[PageRef], after: &[PageRef]) -> Vec<BlockChange> {
97    let (old, old_order) = place_blocks(before);
98    let (new, new_order) = place_blocks(after);
99    let mut changes = Vec::new();
100
101    let mut seen: HashSet<Uuid> = HashSet::new();
102    for id in old_order.into_iter().chain(new_order) {
103        if !seen.insert(id) {
104            continue;
105        }
106        match (old.get(&id), new.get(&id)) {
107            (Some(o), None) => changes.push(BlockChange {
108                block_id: id,
109                name: o.block.name.clone(),
110                kind: ChangeKind::Removed,
111                page_before: Some(o.page),
112                page_after: None,
113                exercise_id: o.block.exercise_id(),
114                changed_fields: Vec::new(),
115                is_moved: false,
116            }),
117            (None, Some(n)) => changes.push(BlockChange {
118                block_id: id,
119                name: n.block.name.clone(),
120                kind: ChangeKind::Added,
121                page_before: None,
122                page_after: Some(n.page),
123                exercise_id: n.block.exercise_id(),
124                changed_fields: Vec::new(),
125                is_moved: false,
126            }),
127            (Some(o), Some(n)) => {
128                let mut changed_fields = Vec::new();
129                if o.block.name != n.block.name {
130                    changed_fields.push("name".to_string());
131                }
132                let mut keys: Vec<&String> = o
133                    .block
134                    .attributes
135                    .keys()
136                    .chain(n.block.attributes.keys())
137                    .collect();
138                keys.sort();
139                keys.dedup();
140                for key in keys {
141                    if o.block.attributes.get(key) != n.block.attributes.get(key) {
142                        changed_fields.push(format!("attributes/{key}"));
143                    }
144                }
145                // A block next to an added or removed one hasn't moved just because of that.
146                let is_moved_among_siblings = o.previous_sibling != n.previous_sibling
147                    && n.previous_sibling.is_none_or(|p| old.contains_key(&p))
148                    && o.previous_sibling.is_none_or(|p| new.contains_key(&p));
149                let is_moved = o.page != n.page || o.parent != n.parent || is_moved_among_siblings;
150                if changed_fields.is_empty() && !is_moved {
151                    continue;
152                }
153                changes.push(BlockChange {
154                    block_id: id,
155                    name: n.block.name.clone(),
156                    kind: if changed_fields.is_empty() {
157                        ChangeKind::Moved
158                    } else {
159                        ChangeKind::Changed
160                    },
161                    page_before: Some(o.page),
162                    page_after: Some(n.page),
163                    exercise_id: n.block.exercise_id(),
164                    changed_fields,
165                    is_moved,
166                });
167            }
168            (None, None) => {}
169        }
170    }
171    changes
172}
173
174/// The blocks that differ between two states of the pages. A page a state doesn't have has no
175/// blocks there.
176pub fn diff_pages(
177    before: &CourseState,
178    after: &CourseState,
179    page_ids: &[Uuid],
180) -> Vec<BlockChange> {
181    fn blocks<'a>(state: &'a CourseState, page_ids: &[Uuid]) -> Vec<PageRef<'a>> {
182        page_ids
183            .iter()
184            .filter_map(|id| Some(snapshots::page_ref(state.page(*id)?)))
185            .collect()
186    }
187    diff_blocks(&blocks(before, page_ids), &blocks(after, page_ids))
188}
189
190/// The modules, chapters and pages that `after` adds or changes of `before`, by id.
191pub fn structure_changes(before: &CourseState, after: &CourseState) -> HashMap<Uuid, ChangeKind> {
192    let changes = after.changes_since(before);
193    let written = changes
194        .entities
195        .iter()
196        .filter(|entity| entity.after().is_some())
197        .map(StagedEntity::key)
198        .filter(|key| {
199            matches!(
200                key.entity_type,
201                ContentEntityType::Module | ContentEntityType::Chapter
202            )
203        })
204        .chain(
205            changes
206                .pages
207                .iter()
208                .filter(|page_id| after.page(**page_id).is_some())
209                .map(|page_id| EntityKey::new(ContentEntityType::Page, *page_id)),
210        );
211    written
212        .map(|key| {
213            let kind = if !before.has_entity(key) {
214                ChangeKind::Added
215            } else {
216                let is_content_changed = key.entity_type == ContentEntityType::Page
217                    && before.page(key.entity_id).map(|page| &page.content)
218                        != after.page(key.entity_id).map(|page| &page.content);
219                let fields = after.field_changes_since(before, key);
220                let is_placed_only = !is_content_changed
221                    && !fields.is_empty()
222                    && fields.iter().all(|change| change.field.is_placement());
223                if is_placed_only {
224                    ChangeKind::Moved
225                } else {
226                    ChangeKind::Changed
227                }
228            };
229            (key.entity_id, kind)
230        })
231        .collect()
232}
233
234/// The top-level trees of `page` that hold a block added, changed or moved there since `before`,
235/// or that lost a child: a container's template can require the child. `before` is the page as it
236/// was, if it existed.
237pub fn changed_roots(before: Option<PageRef>, page: PageRef) -> Vec<GutenbergBlock> {
238    let mut parent_before: HashMap<Uuid, Uuid> = HashMap::new();
239    if let Some(before) = before {
240        crate::engine::blocks::walk(before.blocks, &mut |block, _| {
241            for child in &block.inner_blocks {
242                parent_before.insert(child.client_id, block.client_id);
243            }
244        });
245    }
246    let mut touched: HashSet<Uuid> = HashSet::new();
247    let before_pages: Vec<PageRef> = before.into_iter().collect();
248    for change in diff_blocks(&before_pages, &[page]) {
249        if change.kind != ChangeKind::Removed {
250            touched.insert(change.block_id);
251        }
252        let left_its_parent = change.kind == ChangeKind::Removed || change.is_moved;
253        if left_its_parent && let Some(parent) = parent_before.get(&change.block_id) {
254            touched.insert(*parent);
255        }
256    }
257    fn holds_touched(block: &GutenbergBlock, touched: &HashSet<Uuid>) -> bool {
258        touched.contains(&block.client_id)
259            || block
260                .inner_blocks
261                .iter()
262                .any(|child| holds_touched(child, touched))
263    }
264    page.blocks
265        .iter()
266        .filter(|root| holds_touched(root, &touched))
267        .cloned()
268        .collect()
269}
270
271#[cfg(test)]
272mod tests {
273    use pretty_assertions::assert_eq;
274
275    use serde_json::json;
276
277    use super::*;
278    use crate::engine::{PageDocument, PageType};
279    use crate::test_fixtures::{exercise, id, infobox, page, para};
280
281    fn blocks(documents: &[PageDocument]) -> Vec<PageRef<'_>> {
282        documents.iter().map(PageDocument::as_page_ref).collect()
283    }
284
285    fn diff(before: &[PageDocument], after: &[PageDocument]) -> Vec<BlockChange> {
286        diff_blocks(&blocks(before), &blocks(after))
287    }
288
289    fn before() -> Vec<PageDocument> {
290        vec![
291            page(
292                "pg1",
293                PageType::CoursePage,
294                vec![
295                    para("p1", "a"),
296                    para("p2", "b"),
297                    infobox("box", vec![para("c1", "c")]),
298                    exercise("ex1", "e1"),
299                ],
300            ),
301            page("pg2", PageType::CoursePage, vec![para("q1", "x")]),
302        ]
303    }
304
305    #[test]
306    fn block_changes_by_id() {
307        let mut after = before();
308        after[0].blocks.remove(0);
309        after[0].blocks[0]
310            .attributes
311            .insert("content".into(), json!("B"));
312        let moved = after[0].blocks.remove(2);
313        after[1].blocks.push(moved);
314        after[0].blocks[1].inner_blocks.push(para("new", "n"));
315        let result = diff(&before(), &after);
316        let kinds: Vec<(Uuid, ChangeKind)> = result.iter().map(|b| (b.block_id, b.kind)).collect();
317        assert_eq!(
318            kinds,
319            vec![
320                (id("p1"), ChangeKind::Removed),
321                (id("p2"), ChangeKind::Changed),
322                (id("ex1"), ChangeKind::Moved),
323                (id("new"), ChangeKind::Added)
324            ]
325        );
326        let exercise_move = &result[2];
327        assert_eq!(
328            (exercise_move.page_before, exercise_move.page_after),
329            (Some(id("pg1")), Some(id("pg2")))
330        );
331        assert_eq!(exercise_move.exercise_id, Some(id("e1")));
332        assert_eq!(
333            result[1].changed_fields,
334            vec!["attributes/content".to_string()]
335        );
336    }
337
338    #[test]
339    fn reordering_siblings_is_a_move() {
340        let mut after = before();
341        after[0].blocks.swap(0, 1);
342        let result = diff(&before(), &after);
343        assert!(result.iter().all(|b| b.kind == ChangeKind::Moved));
344        assert!(!result.is_empty());
345    }
346
347    #[test]
348    fn unchanged_is_empty() {
349        let result = diff(&before(), &before());
350        assert!(result.is_empty());
351    }
352
353    #[test]
354    fn changed_roots_are_top_level_ancestors() {
355        let before = page(
356            "pg1",
357            PageType::CoursePage,
358            vec![para("p1", "a"), infobox("box", vec![para("c1", "b")])],
359        );
360        let mut after = before.clone();
361        after.blocks[1].inner_blocks[0]
362            .attributes
363            .insert("content".into(), json!("B"));
364        let roots = changed_roots(Some(before.as_page_ref()), after.as_page_ref());
365        assert_eq!(roots.len(), 1);
366        assert_eq!(roots[0].client_id, id("box"));
367    }
368
369    #[test]
370    fn a_container_that_lost_a_child_is_a_changed_root() {
371        let before = page(
372            "pg1",
373            PageType::CoursePage,
374            vec![
375                para("p1", "a"),
376                infobox("box", vec![para("c1", "b"), para("c2", "c")]),
377            ],
378        );
379        let mut after = before.clone();
380        after.blocks[1].inner_blocks.remove(0);
381        let roots = changed_roots(Some(before.as_page_ref()), after.as_page_ref());
382        assert_eq!(roots.len(), 1);
383        assert_eq!(roots[0].client_id, id("box"));
384
385        let mut moved_out = before.clone();
386        let child = moved_out.blocks[1].inner_blocks.remove(1);
387        moved_out.blocks.insert(0, child);
388        let ids: Vec<Uuid> = changed_roots(Some(before.as_page_ref()), moved_out.as_page_ref())
389            .iter()
390            .map(|root| root.client_id)
391            .collect();
392        assert!(ids.contains(&id("box")), "{ids:?}");
393    }
394}