Skip to main content

headless_lms_mcp/engine/course_structure/
outline.rs

1//! A compact account of what a structure call changed, for dry runs.
2
3use std::collections::{HashMap, HashSet};
4
5use serde::Serialize;
6use uuid::Uuid;
7
8use super::model::{CourseStructure, StructureChapter, StructurePage};
9
10/// Lines of an outline in brief form; the counts still cover everything.
11const BRIEF_MAX_LINES: usize = 60;
12
13/// What a call changed of chapters and pages: counts, and one line per changed entity.
14#[derive(Debug, Clone, PartialEq, Serialize)]
15pub struct StructureOutline {
16    pub chapters: OutlineCounts,
17    pub pages: OutlineCounts,
18    /// `+` created, `-` removed, `~` changed; chapters first.
19    pub lines: Vec<String>,
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub lines_omitted: Option<usize>,
22}
23
24/// How many entities of a kind a call created, removed or changed.
25#[derive(Debug, Clone, Default, PartialEq, Serialize)]
26pub struct OutlineCounts {
27    pub created: usize,
28    pub removed: usize,
29    pub changed: usize,
30}
31
32struct PageSite<'a> {
33    page: &'a StructurePage,
34    chapter: Option<&'a StructureChapter>,
35    index: usize,
36}
37
38fn page_sites(structure: &CourseStructure) -> HashMap<Uuid, PageSite<'_>> {
39    let top_level = structure.pages.iter().enumerate().map(|(index, page)| {
40        (
41            page.id,
42            PageSite {
43                page,
44                chapter: None,
45                index,
46            },
47        )
48    });
49    let in_chapters = structure.chapters().flat_map(|chapter| {
50        chapter.pages.iter().enumerate().map(move |(index, page)| {
51            (
52                page.id,
53                PageSite {
54                    page,
55                    chapter: Some(chapter),
56                    index,
57                },
58            )
59        })
60    });
61    top_level.chain(in_chapters).collect()
62}
63
64fn place(chapter: Option<&StructureChapter>) -> String {
65    match chapter {
66        Some(chapter) => format!("chapter {}", chapter.chapter_number),
67        None => "top level".to_string(),
68    }
69}
70
71/// The order of the pages of `chapter` that exist in both `before` and `after`.
72fn surviving_order(chapter: &[StructurePage], survivors: &HashSet<Uuid>) -> Vec<Uuid> {
73    chapter
74        .iter()
75        .map(|page| page.id)
76        .filter(|id| survivors.contains(id))
77        .collect()
78}
79
80/// The chapters and pages `after` has, lacks or changes compared with `before`. Positions are
81/// 1-based and reported only for pages that moved relative to the pages that stayed.
82pub fn outline(
83    before: &CourseStructure,
84    after: &CourseStructure,
85    is_brief: bool,
86) -> StructureOutline {
87    let mut lines = Vec::new();
88    let mut chapter_counts = OutlineCounts::default();
89    let mut page_counts = OutlineCounts::default();
90
91    let modules_before: HashMap<Uuid, Uuid> = before
92        .modules()
93        .flat_map(|module| module.chapters.iter().map(move |c| (c.id, module.id)))
94        .collect();
95    let chapters_before: HashMap<Uuid, &StructureChapter> = before
96        .chapters()
97        .map(|chapter| (chapter.id, chapter))
98        .collect();
99    let chapters_after: HashMap<Uuid, &StructureChapter> = after
100        .chapters()
101        .map(|chapter| (chapter.id, chapter))
102        .collect();
103
104    for module in after.modules() {
105        for chapter in &module.chapters {
106            match chapters_before.get(&chapter.id) {
107                None => {
108                    chapter_counts.created += 1;
109                    lines.push(format!(
110                        "+ chapter {} \"{}\" as chapter {}",
111                        chapter.id, chapter.name, chapter.chapter_number
112                    ));
113                }
114                Some(old) => {
115                    let mut parts = Vec::new();
116                    if old.name != chapter.name {
117                        parts.push(format!("name \"{}\" -> \"{}\"", old.name, chapter.name));
118                    }
119                    if old.chapter_number != chapter.chapter_number {
120                        parts.push(format!(
121                            "number {} -> {}",
122                            old.chapter_number, chapter.chapter_number
123                        ));
124                    }
125                    if modules_before.get(&chapter.id) != Some(&module.id) {
126                        parts.push(format!(
127                            "moved to module {}",
128                            module.name.as_deref().unwrap_or("(default)")
129                        ));
130                    }
131                    for (field, is_changed) in [
132                        ("color", old.color != chapter.color),
133                        ("opens_at", old.opens_at != chapter.opens_at),
134                        ("deadline", old.deadline != chapter.deadline),
135                    ] {
136                        if is_changed {
137                            parts.push(format!("{field} changed"));
138                        }
139                    }
140                    if !parts.is_empty() {
141                        chapter_counts.changed += 1;
142                        lines.push(format!(
143                            "~ chapter {} \"{}\": {}",
144                            chapter.id,
145                            chapter.name,
146                            parts.join("; ")
147                        ));
148                    }
149                }
150            }
151        }
152    }
153    for chapter in before.chapters() {
154        if !chapters_after.contains_key(&chapter.id) {
155            chapter_counts.removed += 1;
156            lines.push(format!(
157                "- chapter {} \"{}\" (was chapter {})",
158                chapter.id, chapter.name, chapter.chapter_number
159            ));
160        }
161    }
162
163    let sites_before = page_sites(before);
164    let sites_after = page_sites(after);
165    let survivors: HashSet<Uuid> = sites_after
166        .keys()
167        .filter(|id| sites_before.contains_key(*id))
168        .copied()
169        .collect();
170    let order_before: HashMap<Option<Uuid>, Vec<Uuid>> =
171        std::iter::once((None, surviving_order(&before.pages, &survivors)))
172            .chain(
173                before
174                    .chapters()
175                    .map(|c| (Some(c.id), surviving_order(&c.pages, &survivors))),
176            )
177            .collect();
178    let order_after: HashMap<Option<Uuid>, Vec<Uuid>> =
179        std::iter::once((None, surviving_order(&after.pages, &survivors)))
180            .chain(
181                after
182                    .chapters()
183                    .map(|c| (Some(c.id), surviving_order(&c.pages, &survivors))),
184            )
185            .collect();
186    let rank = |orders: &HashMap<Option<Uuid>, Vec<Uuid>>, site: &PageSite<'_>| {
187        orders
188            .get(&site.chapter.map(|c| c.id))
189            .and_then(|order| order.iter().position(|id| *id == site.page.id))
190    };
191
192    let after_in_order = after.pages().map(|(_, page)| page.id);
193    for id in after_in_order {
194        let new = &sites_after[&id];
195        let Some(old) = sites_before.get(&id) else {
196            page_counts.created += 1;
197            lines.push(format!(
198                "+ page {id} \"{}\" {} ({}, position {})",
199                new.page.title,
200                new.page.url_path,
201                place(new.chapter),
202                new.index + 1
203            ));
204            continue;
205        };
206        let mut parts = Vec::new();
207        if old.page.title != new.page.title {
208            parts.push(format!(
209                "title \"{}\" -> \"{}\"",
210                old.page.title, new.page.title
211            ));
212        }
213        if old.page.url_path != new.page.url_path {
214            parts.push(format!("{} -> {}", old.page.url_path, new.page.url_path));
215        }
216        if old.page.hidden != new.page.hidden {
217            parts.push(format!("hidden {} -> {}", old.page.hidden, new.page.hidden));
218        }
219        let old_chapter = old.chapter.map(|c| c.id);
220        let new_chapter = new.chapter.map(|c| c.id);
221        if old_chapter != new_chapter {
222            parts.push(format!(
223                "moved from {} to {}, position {}",
224                place(old.chapter),
225                place(new.chapter),
226                new.index + 1
227            ));
228        } else if rank(&order_before, old) != rank(&order_after, new) {
229            parts.push(format!("position {} -> {}", old.index + 1, new.index + 1));
230        }
231        if !parts.is_empty() {
232            page_counts.changed += 1;
233            lines.push(format!(
234                "~ page {id} \"{}\": {}",
235                new.page.title,
236                parts.join("; ")
237            ));
238        }
239    }
240    for (_, page) in before.pages() {
241        if !sites_after.contains_key(&page.id) {
242            page_counts.removed += 1;
243            let site = &sites_before[&page.id];
244            lines.push(format!(
245                "- page {} \"{}\" {} ({})",
246                page.id,
247                page.title,
248                page.url_path,
249                place(site.chapter)
250            ));
251        }
252    }
253
254    let lines_omitted = (is_brief && lines.len() > BRIEF_MAX_LINES).then(|| {
255        let omitted = lines.len() - BRIEF_MAX_LINES;
256        lines.truncate(BRIEF_MAX_LINES);
257        omitted
258    });
259    StructureOutline {
260        chapters: chapter_counts,
261        pages: page_counts,
262        lines,
263        lines_omitted,
264    }
265}