headless_lms_mcp/engine/course_structure/
outline.rs1use std::collections::{HashMap, HashSet};
4
5use serde::Serialize;
6use uuid::Uuid;
7
8use super::model::{CourseStructure, StructureChapter, StructurePage};
9
10const BRIEF_MAX_LINES: usize = 60;
12
13#[derive(Debug, Clone, PartialEq, Serialize)]
15pub struct StructureOutline {
16 pub chapters: OutlineCounts,
17 pub pages: OutlineCounts,
18 pub lines: Vec<String>,
20 #[serde(skip_serializing_if = "Option::is_none")]
21 pub lines_omitted: Option<usize>,
22}
23
24#[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
71fn 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
80pub 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}