headless_lms_mcp/staging/
validation.rs1use std::collections::{BTreeSet, HashMap, HashSet};
4use std::sync::Arc;
5
6use headless_lms_models::library::agent_changesets::course_state::CourseState;
7use headless_lms_utils::document_schema_processor::{
8 GutenbergBlock, blocks_not_allowed_in_top_level_pages,
9};
10use serde::Serialize;
11use serde_json::Value;
12use uuid::Uuid;
13
14use crate::diff::changed_roots;
15use crate::engine::{EditError, EditErrorCode};
16use crate::engine::{PageDocument, PageType};
17use crate::instruction_lists::{self, InstructionsList, InstructionsLocation};
18use crate::page_readiness::pages_with_content_changes;
19use crate::protocol::progress::ProgressReporter;
20use crate::snapshots;
21use crate::tools::ToolError;
22use crate::validator::{
23 BlockValidationError, BlockValidator, CourseBlockFeatures, PageValidationRequest,
24 ValidationRequest, ValidationTarget,
25};
26
27#[derive(Debug, Clone, PartialEq, Serialize)]
30pub struct BlockFinding {
31 #[serde(skip_serializing_if = "Option::is_none")]
33 pub page: Option<Uuid>,
34 #[serde(flatten)]
35 pub instructions: Option<InstructionsLocation>,
36 #[serde(flatten)]
37 pub finding: BlockValidationError,
38}
39
40#[derive(Debug, Default)]
42pub struct ValidatedBlocks {
43 pub block_names: Vec<String>,
45 pub unchanged_block_warnings: Vec<BlockFinding>,
47}
48
49#[derive(Clone, Copy)]
51enum ValidatedList {
52 Page { page_id: Uuid, page_type: PageType },
53 Instructions(InstructionsList),
54}
55
56impl ValidatedList {
57 fn findings(
59 self,
60 state: &CourseState,
61 findings: Vec<BlockValidationError>,
62 ) -> impl Iterator<Item = BlockFinding> {
63 let (page, instructions) = match self {
64 ValidatedList::Page { page_id, .. } => (Some(page_id), None),
65 ValidatedList::Instructions(list) => (list.page(state), Some(list.location())),
66 };
67 findings.into_iter().map(move |finding| BlockFinding {
68 page,
69 instructions: instructions.clone(),
70 finding,
71 })
72 }
73}
74
75struct PageToValidate {
77 list: ValidatedList,
78 roots: Vec<GutenbergBlock>,
79 unchanged_client_ids: Vec<Uuid>,
81}
82
83fn blocks_by_id(blocks: &[GutenbergBlock]) -> HashMap<Uuid, &GutenbergBlock> {
85 let mut by_id = HashMap::new();
86 crate::engine::blocks::walk(blocks, &mut |block, _| {
87 by_id.insert(block.client_id, block);
88 });
89 by_id
90}
91
92fn unchanged_blocks(
95 roots: &[GutenbergBlock],
96 live_blocks: &HashMap<Uuid, &GutenbergBlock>,
97 is_unchanged: impl Fn(&GutenbergBlock, bool) -> bool,
98) -> Vec<Uuid> {
99 let mut unchanged_client_ids = Vec::new();
100 crate::engine::blocks::walk(roots, &mut |block, _| {
101 let is_as_published = live_blocks
102 .get(&block.client_id)
103 .is_some_and(|live| live.name == block.name && live.attributes == block.attributes);
104 if is_unchanged(block, is_as_published) {
105 unchanged_client_ids.push(block.client_id);
106 }
107 });
108 unchanged_client_ids
109}
110
111pub async fn validate_changed_blocks(
116 validator: &dyn BlockValidator,
117 live: &CourseState,
118 before: &CourseState,
119 after: &mut CourseState,
120 generated_blocks: &HashSet<Uuid>,
121 progress: &ProgressReporter,
122) -> Result<ValidatedBlocks, ToolError> {
123 let mut validated = ValidatedBlocks::default();
124 let pages = collect_validation_targets(live, before, after, generated_blocks)?;
125 if pages.is_empty() {
126 return Ok(validated);
127 }
128 let earlier_block_ids: HashSet<Uuid> = after
130 .pages_changed_since(before)
131 .filter_map(|page| before.page(page.id))
132 .flat_map(|page| blocks_by_id(&page.content).into_keys())
133 .collect();
134 let mut block_names: BTreeSet<&str> = BTreeSet::new();
135 for page in &pages {
136 crate::engine::blocks::walk(&page.roots, &mut |block, _| {
137 if !earlier_block_ids.contains(&block.client_id) {
138 block_names.insert(&block.name);
139 }
140 });
141 }
142 validated.block_names = block_names.into_iter().map(str::to_owned).collect();
143 if pages.len() > 1 {
144 progress.report(0.0, Some(pages.len() as f64), Some("validating pages"));
145 }
146 let outcomes = validator
147 .validate_blocks(&ValidationRequest {
148 pages: pages
149 .iter()
150 .map(|page| PageValidationRequest {
151 target: match page.list {
152 ValidatedList::Page { page_type, .. } => {
153 ValidationTarget::Page { page_type }
154 }
155 ValidatedList::Instructions(list) => ValidationTarget::Exercise {
156 exercise_context: list.validation_context(),
157 },
158 },
159 blocks: &page.roots,
160 unchanged_client_ids: &page.unchanged_client_ids,
161 })
162 .collect(),
163 course_features: CourseBlockFeatures {
164 chapter_locking_enabled: after.settings.chapter_locking_enabled,
165 can_add_chatbot: after.settings.can_add_chatbot,
166 },
167 })
168 .await?;
169 if outcomes.len() != pages.len() {
170 return Err(ToolError::Internal(format!(
171 "the validator answered for {} pages of {}",
172 outcomes.len(),
173 pages.len()
174 )));
175 }
176 let mut errors = Vec::new();
177 let mut normalised: Vec<(ValidatedList, Vec<GutenbergBlock>)> = Vec::new();
178 for (page, outcome) in pages.iter().zip(outcomes) {
179 validated
180 .unchanged_block_warnings
181 .extend(page.list.findings(after, outcome.warnings));
182 if outcome.errors.is_empty() {
183 normalised.push((page.list, outcome.blocks));
184 } else {
185 errors.extend(page.list.findings(after, outcome.errors));
186 }
187 }
188 if let Some(error) = block_error(&errors) {
189 return Err(error);
190 }
191 write_back_normalised(after, normalised);
192 Ok(validated)
193}
194
195fn collect_validation_targets(
197 live: &CourseState,
198 before: &CourseState,
199 after: &CourseState,
200 generated_blocks: &HashSet<Uuid>,
201) -> Result<Vec<PageToValidate>, ToolError> {
202 let changed_page_ids: Vec<(Uuid, bool)> = after
203 .pages_changed_since(before)
204 .filter_map(|page| {
205 let is_type_changed = before
206 .page(page.id)
207 .is_some_and(|before| snapshots::page_type(before) != snapshots::page_type(page));
208 let is_content_changed =
209 before.page(page.id).map(|p| &p.content) != Some(&page.content);
210 (is_type_changed || is_content_changed).then_some((page.id, is_type_changed))
211 })
212 .collect();
213 top_level_pages_hold_no_chapter_blocks(after, changed_page_ids.iter().map(|(id, _)| *id))?;
214 let mut live_blocks: HashMap<Uuid, &GutenbergBlock> = HashMap::new();
215 if !changed_page_ids.is_empty() {
216 let mut source_pages: HashSet<Uuid> = live
218 .pages_changed_since(before)
219 .map(|page| page.id)
220 .collect();
221 source_pages.extend(changed_page_ids.iter().map(|(id, _)| *id));
222 for page in live.pages.iter().filter(|p| source_pages.contains(&p.id)) {
223 live_blocks.extend(blocks_by_id(&page.content));
224 }
225 }
226 let mut pages: Vec<PageToValidate> = Vec::new();
227 for (page_id, is_type_changed) in &changed_page_ids {
228 let Some(after_page) = after.page(*page_id).map(snapshots::page_ref) else {
229 continue;
230 };
231 let roots = if *is_type_changed {
232 after_page.blocks.to_vec()
233 } else {
234 changed_roots(before.page(*page_id).map(snapshots::page_ref), after_page)
235 };
236 if roots.is_empty() {
237 continue;
238 }
239 let unchanged_client_ids = unchanged_blocks(
240 &roots,
241 &live_blocks,
242 |block, is_as_published| {
244 (is_as_published && !is_type_changed) || generated_blocks.contains(&block.client_id)
245 },
246 );
247 pages.push(PageToValidate {
248 list: ValidatedList::Page {
249 page_id: *page_id,
250 page_type: after_page.page_type,
251 },
252 roots,
253 unchanged_client_ids,
254 });
255 }
256 for list in instruction_lists::changed_lists(before, after) {
257 let Some(after_document) = list.document(after).transpose()? else {
258 continue;
259 };
260 let before_document = list.document(before).and_then(Result::ok);
261 let roots = changed_roots(
262 before_document.as_ref().map(PageDocument::as_page_ref),
263 after_document.as_page_ref(),
264 );
265 if roots.is_empty() {
266 continue;
267 }
268 let live_document = list.document(live).or_else(|| {
270 let config = live.course_default_review_config.as_ref()?;
271 matches!(list, InstructionsList::Review { .. }).then_some(())?;
272 InstructionsList::CourseDefaultReview {
273 config_id: config.id,
274 }
275 .document(live)
276 });
277 let live_blocks = live_document
278 .and_then(Result::ok)
279 .map(|document| document.blocks)
280 .unwrap_or_default();
281 let unchanged_client_ids =
282 unchanged_blocks(&roots, &blocks_by_id(&live_blocks), |_, is_as_published| {
283 is_as_published
284 });
285 pages.push(PageToValidate {
286 list: ValidatedList::Instructions(list),
287 roots,
288 unchanged_client_ids,
289 });
290 }
291 Ok(pages)
292}
293
294pub fn changeset_block_names(live: &CourseState, staged: &CourseState) -> HashSet<String> {
297 let mut roots = Vec::new();
298 for page in pages_with_content_changes(staged, live) {
299 roots.extend(changed_roots(
300 live.page(page.id).map(snapshots::page_ref),
301 snapshots::page_ref(page),
302 ));
303 }
304 for list in instruction_lists::changed_lists(live, staged) {
305 let Some(Ok(staged_document)) = list.document(staged) else {
306 continue;
307 };
308 let live_document = list.document(live).and_then(Result::ok);
309 roots.extend(changed_roots(
310 live_document.as_ref().map(PageDocument::as_page_ref),
311 staged_document.as_page_ref(),
312 ));
313 }
314 let mut names = HashSet::new();
315 crate::engine::blocks::walk(&roots, &mut |block, _| {
316 names.insert(block.name.clone());
317 });
318 names
319}
320
321fn block_error(errors: &[BlockFinding]) -> Option<ToolError> {
323 let first = &errors.first()?.finding;
324 let message = format!(
325 "{} block error(s), nothing was staged; first: {}{}",
326 errors.len(),
327 first.message,
328 first
329 .client_id
330 .map(|id| format!(" (block {id})"))
331 .unwrap_or_default()
332 );
333 let details: Vec<Value> = errors
334 .iter()
335 .map(|error| serde_json::to_value(error).unwrap_or_default())
336 .collect();
337 let mut error =
338 EditError::new(EditErrorCode::InvalidBlocks, message).with_details(Value::Array(details));
339 if !first.fix.is_empty() {
340 error = error.with_fix(first.fix.clone());
341 }
342 Some(ToolError::Edit(error))
343}
344
345fn write_back_normalised(
347 after: &mut CourseState,
348 normalised: Vec<(ValidatedList, Vec<GutenbergBlock>)>,
349) {
350 for (list, blocks) in normalised {
351 match list {
352 ValidatedList::Page { page_id, .. } => {
353 if let Some(page) = after.page_mut(page_id) {
354 replace_roots(Arc::make_mut(&mut page.content).as_mut_slice(), blocks);
355 }
356 }
357 ValidatedList::Instructions(list) => {
358 if let Some(Ok(mut document)) = list.document(after) {
359 replace_roots(&mut document.blocks, blocks);
360 list.set_blocks(after, &document.blocks);
361 }
362 }
363 }
364 }
365}
366
367fn replace_roots(content: &mut [GutenbergBlock], roots: Vec<GutenbergBlock>) {
369 let root_index: HashMap<Uuid, usize> = content
370 .iter()
371 .enumerate()
372 .map(|(index, block)| (block.client_id, index))
373 .collect();
374 for block in roots {
375 if let Some(index) = root_index.get(&block.client_id) {
376 content[*index] = block;
377 }
378 }
379}
380
381fn top_level_pages_hold_no_chapter_blocks(
383 after: &CourseState,
384 page_ids: impl Iterator<Item = Uuid>,
385) -> Result<(), ToolError> {
386 for page_id in page_ids {
387 let Some(page) = after.page(page_id) else {
388 continue;
389 };
390 if page.chapter_id.is_some() {
391 continue;
392 }
393 let disallowed: Vec<String> = blocks_not_allowed_in_top_level_pages(&page.content)
394 .iter()
395 .map(|block| format!("/{} ({})", block.client_id, block.name))
396 .collect();
397 if !disallowed.is_empty() {
398 return Err(ToolError::Edit(
399 EditError::new(
400 EditErrorCode::InvalidBlocks,
401 format!(
402 "page {page_id} is outside chapters, where exercises and chapter listings can't be: {}",
403 disallowed.join(", ")
404 ),
405 )
406 .with_fix("keep the page in a chapter, or remove those blocks from it first"),
407 ));
408 }
409 }
410 Ok(())
411}