Skip to main content

headless_lms_mcp/engine/course_structure/
model.rs

1//! The course tree the structure tools read and edit, and the paths that address it.
2
3use chrono::{DateTime, Utc};
4use serde::Serialize;
5use uuid::Uuid;
6
7use crate::engine::path::{is_course_root, split_path};
8
9/// Keys a read adds to an entity besides its fields. Writes ignore them, so an entity read back
10/// can be written again.
11pub(crate) const RENDERED_KEYS: [&str; 4] = ["path", "kind", "pending", "is_chapter_front_page"];
12
13/// The course tree as agents read and edit it. Children arrays are in display order.
14#[derive(Debug, Clone, PartialEq, Serialize)]
15pub struct CourseStructure {
16    pub id: Uuid,
17    /// Pages outside chapters; the course front page (`/`) is always first.
18    pub pages: Vec<StructurePage>,
19    /// The default module is always first.
20    pub modules: Vec<StructureModule>,
21}
22
23/// A module and its chapters in order.
24#[derive(Debug, Clone, PartialEq, Serialize)]
25pub struct StructureModule {
26    pub id: Uuid,
27    pub default: bool,
28    /// Null exactly for the default module.
29    pub name: Option<String>,
30    pub chapters: Vec<StructureChapter>,
31}
32
33/// A chapter and its pages in order.
34#[derive(Debug, Clone, PartialEq, Serialize)]
35pub struct StructureChapter {
36    pub id: Uuid,
37    /// Derived from order; course-wide, starting at 1.
38    pub chapter_number: i32,
39    pub name: String,
40    pub color: Option<String>,
41    pub opens_at: Option<DateTime<Utc>>,
42    pub deadline: Option<DateTime<Utc>>,
43    /// The first page is the chapter's front page.
44    pub pages: Vec<StructurePage>,
45}
46
47/// A page's own fields with counts of its content; the blocks are the page tool's.
48#[derive(Debug, Clone, PartialEq, Serialize)]
49pub struct StructurePage {
50    pub id: Uuid,
51    pub title: String,
52    pub url_path: String,
53    pub hidden: bool,
54    pub block_count: u32,
55    pub exercise_count: u32,
56    #[serde(skip_serializing_if = "std::ops::Not::not")]
57    pub has_submissions: bool,
58    /// Shared by the page's versions in the course's other languages; `None` until the page is
59    /// published, unless it was added with `pair_with`.
60    pub page_language_group_id: Option<Uuid>,
61}
62
63impl CourseStructure {
64    /// The modules in order.
65    pub fn modules(&self) -> impl Iterator<Item = &StructureModule> {
66        self.modules.iter()
67    }
68
69    /// Every chapter, in course order.
70    pub fn chapters(&self) -> impl Iterator<Item = &StructureChapter> {
71        self.modules.iter().flat_map(|m| m.chapters.iter())
72    }
73
74    /// [`Self::chapters`] for editing.
75    pub(crate) fn chapters_mut(&mut self) -> impl Iterator<Item = &mut StructureChapter> {
76        self.modules.iter_mut().flat_map(|m| m.chapters.iter_mut())
77    }
78
79    /// Every page with the chapter it belongs to.
80    pub fn pages(&self) -> impl Iterator<Item = (Option<&StructureChapter>, &StructurePage)> {
81        self.pages.iter().map(|p| (None, p)).chain(
82            self.chapters()
83                .flat_map(|c| c.pages.iter().map(move |p| (Some(c), p))),
84        )
85    }
86
87    /// Every page, top-level ones first.
88    pub(crate) fn pages_mut(&mut self) -> impl Iterator<Item = &mut StructurePage> {
89        self.pages.iter_mut().chain(
90            self.modules
91                .iter_mut()
92                .flat_map(|m| m.chapters.iter_mut())
93                .flat_map(|c| c.pages.iter_mut()),
94        )
95    }
96
97    /// The page `page_id`, wherever it is.
98    pub fn page(&self, page_id: Uuid) -> Option<&StructurePage> {
99        self.pages().map(|(_, p)| p).find(|p| p.id == page_id)
100    }
101
102    /// [`Self::page`] for editing.
103    pub(crate) fn page_mut(&mut self, page_id: Uuid) -> Option<&mut StructurePage> {
104        self.pages_mut().find(|p| p.id == page_id)
105    }
106
107    /// The chapter holding the page; `None` for a page outside chapters.
108    pub fn chapter_of_page(&self, page_id: Uuid) -> Option<&StructureChapter> {
109        self.pages()
110            .find(|(_, p)| p.id == page_id)
111            .and_then(|(c, _)| c)
112    }
113
114    /// Whether the page is the first of its chapter.
115    pub fn is_chapter_front_page(&self, page_id: Uuid) -> bool {
116        self.chapters()
117            .any(|c| c.pages.first().is_some_and(|p| p.id == page_id))
118    }
119
120    /// The module `id`.
121    pub fn module(&self, id: Uuid) -> Option<&StructureModule> {
122        self.modules.iter().find(|m| m.id == id)
123    }
124
125    /// [`Self::module`] for editing.
126    pub(crate) fn module_mut(&mut self, id: Uuid) -> Option<&mut StructureModule> {
127        self.modules.iter_mut().find(|m| m.id == id)
128    }
129
130    /// The chapter `id`.
131    pub fn chapter(&self, id: Uuid) -> Option<&StructureChapter> {
132        self.chapters().find(|c| c.id == id)
133    }
134
135    /// [`Self::chapter`] for editing.
136    pub(crate) fn chapter_mut(&mut self, id: Uuid) -> Option<&mut StructureChapter> {
137        self.chapters_mut().find(|c| c.id == id)
138    }
139
140    /// Where the module, chapter or page `id` is.
141    pub(crate) fn locate(&self, id: Uuid) -> Option<Loc> {
142        let loc = |list, index| Some(Loc { list, index });
143        if let Some(index) = self.pages.iter().position(|p| p.id == id) {
144            return loc(ListRef::CoursePages, index);
145        }
146        for (module_index, module) in self.modules.iter().enumerate() {
147            if module.id == id {
148                return loc(ListRef::CourseModules, module_index);
149            }
150            for (chapter_index, chapter) in module.chapters.iter().enumerate() {
151                if chapter.id == id {
152                    return loc(ListRef::ModuleChapters(module.id), chapter_index);
153                }
154                if let Some(index) = chapter.pages.iter().position(|p| p.id == id) {
155                    return loc(ListRef::ChapterPages(chapter.id), index);
156                }
157            }
158        }
159        None
160    }
161
162    /// The entities of `list` in order; none when its owner doesn't exist.
163    pub(crate) fn list(&self, list: ListRef) -> Vec<EntityRef<'_>> {
164        match list {
165            ListRef::CoursePages => self.pages.iter().map(EntityRef::Page).collect(),
166            ListRef::CourseModules => self.modules.iter().map(EntityRef::Module).collect(),
167            ListRef::ModuleChapters(id) => self
168                .module(id)
169                .map(|m| m.chapters.iter().map(EntityRef::Chapter).collect())
170                .unwrap_or_default(),
171            ListRef::ChapterPages(id) => self
172                .chapter(id)
173                .map(|c| c.pages.iter().map(EntityRef::Page).collect())
174                .unwrap_or_default(),
175        }
176    }
177}
178
179/// Where an entity is: its list and its index there.
180#[derive(Debug, Clone, Copy)]
181pub(crate) struct Loc {
182    pub(crate) list: ListRef,
183    pub(crate) index: usize,
184}
185
186impl Loc {
187    pub(crate) fn kind(&self) -> Kind {
188        self.list.kind()
189    }
190}
191
192/// What a course tree entity is.
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194pub(crate) enum Kind {
195    Module,
196    Chapter,
197    Page,
198}
199
200impl Kind {
201    /// The singular name of the kind, as used in error messages.
202    pub(crate) fn noun(self) -> &'static str {
203        match self {
204            Kind::Module => "module",
205            Kind::Chapter => "chapter",
206            Kind::Page => "page",
207        }
208    }
209}
210
211/// One of the course tree's ordered lists.
212#[derive(Debug, Clone, Copy, PartialEq, Eq)]
213pub(crate) enum ListRef {
214    CoursePages,
215    CourseModules,
216    ModuleChapters(Uuid),
217    ChapterPages(Uuid),
218}
219
220impl ListRef {
221    /// The list `key` names under the course: `pages` or `modules`.
222    pub(crate) fn of_course(key: &str) -> Option<Self> {
223        match key {
224            "pages" => Some(ListRef::CoursePages),
225            "modules" => Some(ListRef::CourseModules),
226            _ => None,
227        }
228    }
229
230    /// The list `key` names under the entity `id` of `kind`: a module's `chapters` or a
231    /// chapter's `pages`.
232    pub(crate) fn of_entity(kind: Kind, id: Uuid, key: &str) -> Option<Self> {
233        match (kind, key) {
234            (Kind::Module, "chapters") => Some(ListRef::ModuleChapters(id)),
235            (Kind::Chapter, "pages") => Some(ListRef::ChapterPages(id)),
236            _ => None,
237        }
238    }
239
240    /// The module or chapter the list belongs to; `None` for the course's lists.
241    pub(crate) fn owner(self) -> Option<Uuid> {
242        match self {
243            ListRef::ModuleChapters(id) | ListRef::ChapterPages(id) => Some(id),
244            ListRef::CoursePages | ListRef::CourseModules => None,
245        }
246    }
247
248    pub(crate) fn kind(self) -> Kind {
249        match self {
250            ListRef::CoursePages | ListRef::ChapterPages(_) => Kind::Page,
251            ListRef::CourseModules => Kind::Module,
252            ListRef::ModuleChapters(_) => Kind::Chapter,
253        }
254    }
255
256    /// The JSON-pointer path of the list, as ops address it.
257    pub(crate) fn path(self) -> String {
258        match self {
259            ListRef::CoursePages => "/course/pages".to_string(),
260            ListRef::CourseModules => "/course/modules".to_string(),
261            ListRef::ModuleChapters(id) => format!("/{id}/chapters"),
262            ListRef::ChapterPages(id) => format!("/{id}/pages"),
263        }
264    }
265}
266
267/// A module, chapter or page of a [`CourseStructure`]. Serializes as the entity itself.
268#[derive(Debug, Clone, Copy, Serialize)]
269#[serde(untagged)]
270pub(crate) enum EntityRef<'a> {
271    Module(&'a StructureModule),
272    Chapter(&'a StructureChapter),
273    Page(&'a StructurePage),
274}
275
276impl EntityRef<'_> {
277    pub(crate) fn id(self) -> Uuid {
278        match self {
279            EntityRef::Module(m) => m.id,
280            EntityRef::Chapter(c) => c.id,
281            EntityRef::Page(p) => p.id,
282        }
283    }
284
285    pub(crate) fn kind(self) -> Kind {
286        match self {
287            EntityRef::Module(_) => Kind::Module,
288            EntityRef::Chapter(_) => Kind::Chapter,
289            EntityRef::Page(_) => Kind::Page,
290        }
291    }
292}
293
294/// A structure path split into its parts, before its ids and labels are resolved:
295/// `/<root>/<child>/…`.
296pub(crate) struct StructurePath {
297    /// `course`, an id or a `$label`.
298    pub(crate) root: String,
299    /// A list (`pages`, `modules`, `chapters`) or a field after the root.
300    pub(crate) child: Option<String>,
301    /// Whether segments follow the child.
302    pub(crate) is_deeper: bool,
303}
304
305impl StructurePath {
306    /// Whether the root names the course: `course` or the course's id.
307    pub(crate) fn is_course(&self, course_id: Uuid) -> bool {
308        is_course_root(&self.root, course_id)
309    }
310}
311
312/// Splits a structure path into its parts; `None` when it doesn't start with `/`.
313pub(crate) fn parse_structure_path(path: &str) -> Option<StructurePath> {
314    let mut segments = split_path(path)?.into_iter();
315    let root = segments.next().unwrap_or_default();
316    let child = segments.next();
317    Some(StructurePath {
318        root,
319        child,
320        is_deeper: segments.next().is_some(),
321    })
322}
323
324pub(super) enum Entity {
325    Module(StructureModule),
326    Chapter(StructureChapter),
327    Page(StructurePage),
328}
329
330impl Entity {
331    pub(super) fn id(&self) -> Uuid {
332        match self {
333            Entity::Module(m) => m.id,
334            Entity::Chapter(c) => c.id,
335            Entity::Page(p) => p.id,
336        }
337    }
338
339    pub(super) fn kind(&self) -> Kind {
340        match self {
341            Entity::Module(_) => Kind::Module,
342            Entity::Chapter(_) => Kind::Chapter,
343            Entity::Page(_) => Kind::Page,
344        }
345    }
346}
347
348pub(super) fn entity_ids(entity: &Entity) -> Vec<Uuid> {
349    match entity {
350        Entity::Module(m) => std::iter::once(m.id)
351            .chain(
352                m.chapters
353                    .iter()
354                    .flat_map(|c| std::iter::once(c.id).chain(c.pages.iter().map(|p| p.id))),
355            )
356            .collect(),
357        Entity::Chapter(c) => std::iter::once(c.id)
358            .chain(c.pages.iter().map(|p| p.id))
359            .collect(),
360        Entity::Page(p) => vec![p.id],
361    }
362}