Skip to main content

headless_lms_mcp/engine/read/
structure.rs

1//! Read selectors over the course structure.
2
3use serde_json::{Map, Value, json};
4use uuid::Uuid;
5
6use super::window::{HasId, list_read, reject_window, sibling_window, single_unit, window};
7use super::{ReadMode, ReadOptions, ReadResult, Selector, check_selector, field_result};
8use crate::engine::course_structure::{
9    CourseStructure, EntityRef, ListRef, Loc, RENDERED_KEYS, parse_structure_path,
10};
11use crate::engine::error::{EditError, EditErrorCode};
12use crate::engine::path::parse_uuid;
13
14impl HasId for EntityRef<'_> {
15    fn id(&self) -> Uuid {
16        EntityRef::id(*self)
17    }
18}
19
20struct Renderer<'a> {
21    doc: &'a CourseStructure,
22    mode: ReadMode,
23    options: &'a ReadOptions<'a>,
24}
25
26impl Renderer<'_> {
27    fn entity(&self, entity: EntityRef) -> Value {
28        let mut value = serde_json::to_value(entity).unwrap_or_else(|error| {
29            tracing::error!(%error, "a course structure entity didn't serialize");
30            Value::Null
31        });
32        if let Value::Object(object) = &mut value {
33            self.add_read_keys(object, entity);
34        }
35        value
36    }
37
38    /// Adds [`RENDERED_KEYS`] to the serialized `entity` and its children; outlines leave out
39    /// chapter colours.
40    fn add_read_keys(&self, object: &mut Map<String, Value>, entity: EntityRef) {
41        let id = entity.id();
42        object.insert("path".into(), json!(format!("/{id}")));
43        object.insert("kind".into(), json!(entity.kind().noun()));
44        if matches!(entity, EntityRef::Page(_)) && self.doc.is_chapter_front_page(id) {
45            object.insert("is_chapter_front_page".into(), json!(true));
46        }
47        if let Some(pending) = self.options.pending.and_then(|p| p.get(&id)) {
48            object.insert("pending".into(), json!(pending));
49        }
50        let (key, children): (&str, Vec<EntityRef>) = match entity {
51            EntityRef::Module(module) => (
52                "chapters",
53                module.chapters.iter().map(EntityRef::Chapter).collect(),
54            ),
55            EntityRef::Chapter(chapter) => {
56                if self.mode == ReadMode::Outline {
57                    object.remove("color");
58                }
59                ("pages", chapter.pages.iter().map(EntityRef::Page).collect())
60            }
61            EntityRef::Page(_) => return,
62        };
63        if let Some(Value::Array(rendered)) = object.get_mut(key) {
64            for (child, child_entity) in rendered.iter_mut().zip(children) {
65                if let Value::Object(child) = child {
66                    self.add_read_keys(child, child_entity);
67                }
68            }
69        }
70    }
71
72    fn course(&self, doc: &CourseStructure) -> Value {
73        json!({
74            "id": doc.id,
75            "path": "/course",
76            "kind": "course",
77            "pages": doc.pages.iter().map(|p| self.entity(EntityRef::Page(p))).collect::<Vec<_>>(),
78            "modules": doc.modules.iter().map(|m| self.entity(EntityRef::Module(m))).collect::<Vec<_>>(),
79        })
80    }
81}
82
83/// Runs one selector against the course structure.
84pub fn read_structure(
85    doc: &CourseStructure,
86    selector: &Selector,
87    options: &ReadOptions,
88) -> Result<ReadResult, EditError> {
89    check_selector(selector)?;
90    let mode = selector.mode.unwrap_or_default();
91    let renderer = Renderer { doc, mode, options };
92    let render = |entity: &EntityRef| renderer.entity(*entity);
93    let whole_course = || -> Result<ReadResult, EditError> {
94        reject_window(selector, "the whole course")?;
95        Ok(single_unit(selector, renderer.course(doc), options))
96    };
97    let path = selector.path.as_deref().unwrap_or("");
98    if path.is_empty() || path == "/" {
99        return whole_course();
100    }
101    let parsed = parse_structure_path(path).ok_or_else(|| {
102        EditError::new(
103            EditErrorCode::InvalidPath,
104            format!("path \"{path}\" must start with /"),
105        )
106    })?;
107    if parsed.is_course(doc.id) {
108        let Some(child) = parsed.child.as_deref() else {
109            return whole_course();
110        };
111        let list = ListRef::of_course(child)
112            .filter(|_| !parsed.is_deeper)
113            .ok_or_else(|| {
114                EditError::new(
115                    EditErrorCode::InvalidPath,
116                    "the course's lists are /course/pages and /course/modules",
117                )
118            })?;
119        return read_list(doc, selector, list, options, &render);
120    }
121    let id = parse_uuid(&parsed.root, path)?;
122    let Loc { list, index } = doc.locate(id).ok_or_else(|| {
123        EditError::new(
124            EditErrorCode::NotFound,
125            format!("no module, chapter or page {id}"),
126        )
127    })?;
128    let siblings = doc.list(list);
129    match (parsed.child.as_deref(), parsed.is_deeper) {
130        (None, _) => {
131            if selector.offset.is_some() {
132                return Err(EditError::new(
133                    EditErrorCode::InvalidValue,
134                    "offset works on a list path such as /<chapter id>/pages",
135                ));
136            }
137            let sibling_ids: Vec<Uuid> = siblings.iter().map(|e| e.id()).collect();
138            let Some((start, end)) = sibling_window(selector, index, &sibling_ids)? else {
139                return Ok(single_unit(
140                    selector,
141                    renderer.entity(siblings[index]),
142                    options,
143                ));
144            };
145            Ok(window(
146                selector,
147                &siblings,
148                start,
149                end,
150                Some(list.path()),
151                options,
152                &render,
153            ))
154        }
155        (Some(key @ ("pages" | "modules" | "chapters")), false) => {
156            let child_list = ListRef::of_entity(list.kind(), id, key).ok_or_else(|| {
157                EditError::new(
158                    EditErrorCode::InvalidPath,
159                    format!(
160                        "/{id}/{key} isn't a list; lists are /<module id>/chapters and /<chapter id>/pages"
161                    ),
162                )
163            })?;
164            read_list(doc, selector, child_list, options, &render)
165        }
166        (Some(field), false) => {
167            reject_window(selector, "a field")?;
168            let full = Renderer {
169                doc,
170                mode: ReadMode::Raw,
171                options,
172            }
173            .entity(siblings[index]);
174            let value = full
175                .get(field)
176                .filter(|_| field != "id" && !RENDERED_KEYS.contains(&field))
177                .cloned()
178                .ok_or_else(|| {
179                    EditError::new(EditErrorCode::NotFound, format!("{path} isn't a field"))
180                })?;
181            Ok(field_result(selector, value))
182        }
183        (Some(_), true) => Err(EditError::new(
184            EditErrorCode::InvalidPath,
185            format!("\"{path}\" goes deeper than an entity field"),
186        )),
187    }
188}
189
190/// A window over one of the structure's lists.
191fn read_list(
192    doc: &CourseStructure,
193    selector: &Selector,
194    list: ListRef,
195    options: &ReadOptions,
196    render: &dyn Fn(&EntityRef) -> Value,
197) -> Result<ReadResult, EditError> {
198    list_read(
199        selector,
200        &doc.list(list),
201        Some(list.path()),
202        options,
203        render,
204        "an entity",
205    )
206}
207
208#[cfg(test)]
209mod tests {
210    use pretty_assertions::assert_eq;
211
212    use super::*;
213    use crate::engine::course_structure::StructureModule;
214    use crate::engine::read::StopReason;
215    use crate::test_fixtures::{id, p, read_options, structure_chapter, structure_page};
216
217    fn sample() -> CourseStructure {
218        let page = |name: &str, path: &str| structure_page(name, &name.to_uppercase(), path);
219        let chapters = (1..=15)
220            .map(|n| {
221                structure_chapter(
222                    &format!("ch{n}"),
223                    n,
224                    &format!("Chapter {n}"),
225                    (0..10)
226                        .map(|k| page(&format!("ch{n}pg{k}"), &format!("/chapter-{n}/p{k}")))
227                        .collect(),
228                )
229            })
230            .collect();
231        CourseStructure {
232            id: id("c1"),
233            pages: vec![page("pg0", "/")],
234            modules: vec![StructureModule {
235                id: id("m1"),
236                default: true,
237                name: None,
238                chapters,
239            }],
240        }
241    }
242
243    #[test]
244    fn whole_course_collapses_when_over_budget() {
245        let options = read_options(2_000);
246        let result = read_structure(&sample(), &Selector::default(), &options).unwrap();
247        assert_eq!(result.items[0]["modules"][0]["collapsed"], json!(1));
248        assert_eq!(
249            result.items[0]["modules"][0]["path"],
250            json!("/course/modules")
251        );
252    }
253
254    #[test]
255    fn chapter_list_window_and_continuation() {
256        let selector = Selector {
257            path: Some(format!("{}/chapters", p("m1"))),
258            limit: Some(4),
259            ..Default::default()
260        };
261        let result = read_structure(&sample(), &selector, &read_options(24_000)).unwrap();
262        assert_eq!(result.items.len(), 4);
263        assert_eq!(result.total, Some(15));
264        assert_eq!(result.stopped_by, Some(StopReason::Limit));
265        assert_eq!(result.next.unwrap().path, Some(p("ch5")));
266    }
267
268    #[test]
269    fn entity_range_and_field() {
270        let range = Selector {
271            path: Some(p("ch2pg3")),
272            through: Some(p("ch2pg5")),
273            ..Default::default()
274        };
275        let result = read_structure(&sample(), &range, &read_options(24_000)).unwrap();
276        assert_eq!(result.items.len(), 3);
277        assert_eq!(result.items[0]["kind"], json!("page"));
278        let field = Selector {
279            path: Some(format!("{}/deadline", p("ch3"))),
280            ..Default::default()
281        };
282        assert_eq!(
283            read_structure(&sample(), &field, &read_options(24_000))
284                .unwrap()
285                .value,
286            Some(Value::Null)
287        );
288        let chapter = Selector {
289            path: Some(p("ch3")),
290            ..Default::default()
291        };
292        let result = read_structure(&sample(), &chapter, &read_options(24_000)).unwrap();
293        assert_eq!(result.items[0]["pages"].as_array().unwrap().len(), 10);
294        for rendered_only in RENDERED_KEYS {
295            let selector = Selector {
296                path: Some(format!("{}/{rendered_only}", p("ch3"))),
297                ..Default::default()
298            };
299            assert!(read_structure(&sample(), &selector, &read_options(24_000)).is_err());
300        }
301    }
302
303    #[test]
304    fn a_large_only_module_collapses_when_its_list_is_read() {
305        let options = read_options(2_000);
306        let selector = Selector {
307            path: Some("/course/modules".into()),
308            ..Default::default()
309        };
310        let result = read_structure(&sample(), &selector, &options).unwrap();
311        assert_eq!(result.items.len(), 1);
312        assert_eq!(result.items[0]["chapters"][0]["collapsed"], json!(15));
313    }
314}