Skip to main content

headless_lms_mcp/engine/
structural_replace.rs

1//! `structural_replace`: match blocks by type and text, then wrap, retype, unwrap or remove each.
2
3use std::borrow::Cow;
4
5use headless_lms_utils::document_schema_processor::GutenbergBlock;
6use regex::Regex;
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9use serde_json::{Value, json};
10use uuid::Uuid;
11
12use super::blocks;
13use super::error::{EditError, EditErrorCode};
14use super::op::Op;
15use super::page_content;
16use super::read::block_preview;
17use super::{BlockMatch, Document, DocumentRef, PageDocument};
18use crate::ids::IdGenerator;
19use crate::text::plain_text;
20
21/// Which blocks to act on. Every given condition must hold; text conditions look at the visible
22/// text of the block's `content`, without markup.
23#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
24#[serde(deny_unknown_fields)]
25pub struct StructuralMatch {
26    /// Block type, e.g. `core/paragraph`.
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub name: Option<String>,
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub text_starts_with: Option<String>,
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub text_contains: Option<String>,
33    /// Regex on the visible text. Rust regex syntax: no backreferences or lookaround; (?i) for
34    /// case-insensitive.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub text_regex: Option<String>,
37}
38
39/// What to do with each match. Exactly one field.
40#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
41#[serde(deny_unknown_fields)]
42pub struct StructuralAction {
43    /// Wrap each matched block in a new block of this type.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub wrap_in: Option<String>,
46    /// Change each matched block's type, keeping attributes and children.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub retype_to: Option<String>,
49    /// Replace each matched container with its children.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub unwrap: Option<bool>,
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub remove: Option<bool>,
54}
55
56/// The blocks that matched and the pages the action changed.
57#[derive(Debug, Clone, PartialEq)]
58pub struct StructuralReplaceResult {
59    pub matches: Vec<BlockMatch>,
60    pub changed_pages: Vec<Document>,
61}
62
63enum Action<'a> {
64    Wrap(&'a str),
65    Retype(&'a str),
66    Unwrap,
67    Remove,
68}
69
70/// Applies `action` to each block of `pages` that `pattern` matches, by the page tool's rules.
71/// Fails without changing anything when `expected_count` differs from the matches.
72pub fn structural_replace(
73    pages: &[DocumentRef],
74    pattern: &StructuralMatch,
75    action: &StructuralAction,
76    expected_count: Option<usize>,
77    ids: &dyn IdGenerator,
78) -> Result<StructuralReplaceResult, EditError> {
79    let action = parse_action(action)?;
80    if pattern == &StructuralMatch::default() {
81        return Err(EditError::new(
82            EditErrorCode::MissingField,
83            "match needs at least one condition: name, text_starts_with, text_contains or text_regex",
84        ));
85    }
86    let text_regex = pattern
87        .text_regex
88        .as_deref()
89        .map(Regex::new)
90        .transpose()
91        .map_err(|e| {
92            EditError::new(
93                EditErrorCode::InvalidRegex,
94                format!("text_regex isn't a valid regex: {e}"),
95            )
96        })?;
97
98    let mut matches = Vec::new();
99    let mut changed_pages = Vec::new();
100    for page in pages {
101        let mut page_matches: Vec<(Uuid, Vec<usize>)> = Vec::new();
102        blocks::walk_outside_exercises(page.blocks, &mut |block, path| {
103            if is_match(block, pattern, text_regex.as_ref()) {
104                page_matches.push((block.client_id, path.to_vec()));
105            }
106        });
107        match action {
108            Action::Remove => {
109                // A removed ancestor already removes its matched descendants.
110                let ancestors: Vec<Vec<usize>> =
111                    page_matches.iter().map(|(_, p)| p.clone()).collect();
112                page_matches.retain(|(_, path)| {
113                    !ancestors
114                        .iter()
115                        .any(|a| a.len() < path.len() && path.starts_with(a))
116                });
117            }
118            Action::Unwrap => page_matches.retain(|(_, path)| {
119                blocks::get(page.blocks, path).is_ok_and(|block| !block.inner_blocks.is_empty())
120            }),
121            _ => {}
122        }
123        for (block_id, path) in &page_matches {
124            let block = blocks::get(page.blocks, path)?;
125            matches.push(page.key.block_match(
126                block,
127                format!("/{block_id}"),
128                block_preview(block).unwrap_or_default(),
129            ));
130        }
131        if page_matches.is_empty() {
132            continue;
133        }
134        let ops: Vec<Op> = page_matches
135            .iter()
136            .map(|(id, _)| op_for(&action, *id))
137            .collect();
138        // Only ops that reach other pages use the id; these don't.
139        let document = PageDocument {
140            page_id: page.key.page_id().unwrap_or_default(),
141            page_type: page.page_type,
142            blocks: page.blocks.to_vec(),
143        };
144        let result = page_content::apply(document, &|_| None, &ops, ids).map_err(|mut e| {
145            e.message = format!("in {}: {}", page.key, e.message);
146            e
147        })?;
148        changed_pages.push(Document {
149            key: page.key,
150            page_type: page.page_type,
151            blocks: result.page.blocks,
152        });
153    }
154    if let Some(expected) = expected_count
155        && expected != matches.len()
156    {
157        return Err(EditError::expected_count_mismatch(
158            expected,
159            matches.len(),
160            matches.iter().map(|m| m.location(None)).collect(),
161        ));
162    }
163    Ok(StructuralReplaceResult {
164        matches,
165        changed_pages,
166    })
167}
168
169fn parse_action(action: &StructuralAction) -> Result<Action<'_>, EditError> {
170    let mut chosen = Vec::new();
171    if let Some(name) = &action.wrap_in {
172        chosen.push(Action::Wrap(name));
173    }
174    if let Some(name) = &action.retype_to {
175        chosen.push(Action::Retype(name));
176    }
177    if action.unwrap == Some(true) {
178        chosen.push(Action::Unwrap);
179    }
180    if action.remove == Some(true) {
181        chosen.push(Action::Remove);
182    }
183    if chosen.len() != 1 {
184        return Err(EditError::new(
185            EditErrorCode::InvalidValue,
186            "action takes exactly one of wrap_in, retype_to, unwrap: true or remove: true",
187        ));
188    }
189    Ok(chosen.remove(0))
190}
191
192fn op_for(action: &Action, id: Uuid) -> Op {
193    let path = format!("/{id}");
194    match action {
195        Action::Wrap(name) => Op::Wrap {
196            path,
197            through: None,
198            value: json!({ "name": name }),
199            label: None,
200        },
201        Action::Retype(name) => Op::Replace {
202            path: format!("{path}/name"),
203            value: Value::String(name.to_string()),
204        },
205        Action::Unwrap => Op::Unwrap { path },
206        Action::Remove => Op::Remove {
207            path,
208            through: None,
209        },
210    }
211}
212
213/// The visible text of the block's `content`, without markup.
214fn block_text(block: &GutenbergBlock) -> Option<Cow<'_, str>> {
215    block
216        .attributes
217        .get("content")
218        .and_then(Value::as_str)
219        .map(plain_text)
220}
221
222fn is_match(block: &GutenbergBlock, pattern: &StructuralMatch, text_regex: Option<&Regex>) -> bool {
223    if pattern
224        .name
225        .as_ref()
226        .is_some_and(|name| *name != block.name)
227    {
228        return false;
229    }
230    let needs_text = pattern.text_starts_with.is_some()
231        || pattern.text_contains.is_some()
232        || text_regex.is_some();
233    if !needs_text {
234        return true;
235    }
236    let Some(text) = block_text(block) else {
237        return false;
238    };
239    pattern
240        .text_starts_with
241        .as_ref()
242        .is_none_or(|p| text.starts_with(p.as_str()))
243        && pattern
244            .text_contains
245            .as_ref()
246            .is_none_or(|p| text.contains(p.as_str()))
247        && text_regex.is_none_or(|re| re.is_match(&text))
248}
249
250#[cfg(test)]
251mod tests {
252    use pretty_assertions::assert_eq;
253
254    use super::*;
255    use crate::engine::PageType;
256    use crate::ids::SequentialIds;
257    use crate::test_fixtures::{document_refs, id, infobox, page, para, top_level};
258
259    fn pages() -> Vec<PageDocument> {
260        vec![page(
261            "pg1",
262            PageType::CoursePage,
263            vec![
264                para("p1", "<strong>Note:</strong> remember this."),
265                para("p2", "Regular text."),
266                infobox("box1", vec![para("p3", "Note: nested.")]),
267            ],
268        )]
269    }
270
271    fn note_match() -> StructuralMatch {
272        StructuralMatch {
273            name: Some("core/paragraph".into()),
274            text_starts_with: Some("Note:".into()),
275            ..Default::default()
276        }
277    }
278
279    #[test]
280    fn wraps_each_match_ignoring_markup() {
281        let ids = SequentialIds::default();
282        let action = StructuralAction {
283            wrap_in: Some("moocfi/infobox".into()),
284            ..Default::default()
285        };
286        let result = structural_replace(
287            &document_refs(&pages()),
288            &note_match(),
289            &action,
290            Some(2),
291            &ids,
292        )
293        .unwrap();
294        assert_eq!(result.matches.len(), 2);
295        let blocks = &result.changed_pages[0].blocks;
296        assert_eq!(blocks[0].name, "moocfi/infobox");
297        assert_eq!(top_level(&blocks[0].inner_blocks), vec![id("p1")]);
298        assert_eq!(blocks[2].inner_blocks[0].name, "moocfi/infobox");
299    }
300
301    #[test]
302    fn retype_and_remove() {
303        let ids = SequentialIds::default();
304        let retype = StructuralAction {
305            retype_to: Some("core/heading".into()),
306            ..Default::default()
307        };
308        let result =
309            structural_replace(&document_refs(&pages()), &note_match(), &retype, None, &ids)
310                .unwrap();
311        assert_eq!(result.changed_pages[0].blocks[0].name, "core/heading");
312        let remove = StructuralAction {
313            remove: Some(true),
314            ..Default::default()
315        };
316        let all = StructuralMatch {
317            text_contains: Some("e".into()),
318            ..Default::default()
319        };
320        let result =
321            structural_replace(&document_refs(&pages()), &all, &remove, None, &ids).unwrap();
322        assert_eq!(top_level(&result.changed_pages[0].blocks), vec![id("box1")]);
323    }
324
325    #[test]
326    fn unwrap_reaches_nested_matches_and_skips_empty_ones() {
327        let ids = SequentialIds::default();
328        let doc = vec![page(
329            "pg1",
330            PageType::CoursePage,
331            vec![
332                infobox("box1", vec![infobox("box2", vec![para("p1", "Inside.")])]),
333                infobox("empty", vec![]),
334            ],
335        )];
336        let boxes = StructuralMatch {
337            name: Some("moocfi/infobox".into()),
338            ..Default::default()
339        };
340        let unwrap = StructuralAction {
341            unwrap: Some(true),
342            ..Default::default()
343        };
344        let result =
345            structural_replace(&document_refs(&doc), &boxes, &unwrap, Some(2), &ids).unwrap();
346        assert_eq!(result.matches.len(), 2);
347        assert_eq!(
348            top_level(&result.changed_pages[0].blocks),
349            vec![id("p1"), id("empty")]
350        );
351    }
352
353    #[test]
354    fn action_and_count_checks() {
355        let ids = SequentialIds::default();
356        let none = StructuralAction::default();
357        assert_eq!(
358            structural_replace(&document_refs(&pages()), &note_match(), &none, None, &ids)
359                .unwrap_err()
360                .code,
361            EditErrorCode::InvalidValue
362        );
363        let wrap = StructuralAction {
364            wrap_in: Some("moocfi/infobox".into()),
365            ..Default::default()
366        };
367        let error = structural_replace(
368            &document_refs(&pages()),
369            &note_match(),
370            &wrap,
371            Some(5),
372            &ids,
373        )
374        .unwrap_err();
375        assert_eq!(error.code, EditErrorCode::ExpectedCountMismatch);
376    }
377}