1use memstead_schema::content_expr::ObservedBlock;
22use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Parser, Tag, TagEnd};
23
24#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct ReducedBlock {
28 pub observed: ObservedBlock,
29 pub line: usize,
31 pub detail: BlockDetail,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum BlockDetail {
37 None,
38 List {
43 items: Vec<(usize, String)>,
44 },
45 Paragraph {
47 lines: Vec<(usize, String)>,
48 },
49 Table {
55 header: Vec<String>,
56 rows: Vec<TableRow>,
57 },
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct TableRow {
63 pub line: usize,
64 pub cells: Vec<String>,
67 pub raw_cell_count: usize,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct SetextReservedHeading {
77 pub line: usize,
78 pub depth: u8,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct ReducedSection {
84 pub blocks: Vec<ReducedBlock>,
85 pub setext_reserved: Vec<SetextReservedHeading>,
88}
89
90impl ReducedSection {
91 pub fn observed(&self) -> Vec<ObservedBlock> {
93 self.blocks.iter().map(|b| b.observed.clone()).collect()
94 }
95}
96
97fn line_of(source: &str, offset: usize) -> usize {
99 source.as_bytes()[..offset.min(source.len())]
100 .iter()
101 .filter(|&&b| b == b'\n')
102 .count()
103 + 1
104}
105
106fn heading_depth(level: HeadingLevel) -> u8 {
107 match level {
108 HeadingLevel::H1 => 1,
109 HeadingLevel::H2 => 2,
110 HeadingLevel::H3 => 3,
111 HeadingLevel::H4 => 4,
112 HeadingLevel::H5 => 5,
113 HeadingLevel::H6 => 6,
114 }
115}
116
117pub fn reduce_section(source: &str) -> ReducedSection {
119 let options = crate::markdown::parser_options();
123
124 let mut blocks: Vec<ReducedBlock> = Vec::new();
125 let mut setext_reserved: Vec<SetextReservedHeading> = Vec::new();
126 let mut depth: usize = 0;
129 let mut current: Option<ReducedBlock> = None;
131 struct ItemState {
137 line: usize,
138 span: Option<(usize, usize)>,
139 nested: usize,
140 own_paragraph_seen: bool,
141 }
142 let mut item: Option<ItemState> = None;
143 let mut in_table_head = false;
145 let mut current_cell: Option<String> = None;
146 let mut current_row: Option<(usize, Vec<String>)> = None;
147 let mut para_range_start: usize = 0;
149
150 fn is_block_tag(tag: &Tag) -> bool {
153 matches!(
154 tag,
155 Tag::Paragraph
156 | Tag::List(_)
157 | Tag::Item
158 | Tag::Table(_)
159 | Tag::CodeBlock(_)
160 | Tag::BlockQuote(_)
161 | Tag::Heading { .. }
162 | Tag::HtmlBlock
163 | Tag::FootnoteDefinition(_)
164 )
165 }
166
167 for (event, range) in Parser::new_ext(source, options).into_offset_iter() {
168 match event {
169 Event::Start(tag) => {
170 let line = line_of(source, range.start);
171 if depth == 0 {
172 let observed = match &tag {
173 Tag::Paragraph => {
174 para_range_start = range.start;
175 Some(ObservedBlock::Paragraph)
176 }
177 Tag::List(ordering) => Some(ObservedBlock::List {
178 ordered: ordering.is_some(),
179 }),
180 Tag::Table(_) => Some(ObservedBlock::Table),
181 Tag::CodeBlock(kind) => Some(ObservedBlock::Code {
182 lang: match kind {
183 CodeBlockKind::Fenced(info) => {
184 info.split_whitespace().next().unwrap_or("").to_string()
185 }
186 CodeBlockKind::Indented => String::new(),
187 },
188 }),
189 Tag::BlockQuote(_) => Some(ObservedBlock::Blockquote),
190 Tag::Heading { level, .. } => Some(ObservedBlock::Heading {
191 depth: heading_depth(*level),
192 }),
193 Tag::HtmlBlock => Some(ObservedBlock::Html),
194 _ => None,
195 };
196 if let Some(observed) = observed {
197 let detail = match &observed {
198 ObservedBlock::List { .. } => BlockDetail::List { items: Vec::new() },
199 ObservedBlock::Paragraph => {
200 BlockDetail::Paragraph { lines: Vec::new() }
201 }
202 ObservedBlock::Table => BlockDetail::Table {
203 header: Vec::new(),
204 rows: Vec::new(),
205 },
206 _ => BlockDetail::None,
207 };
208 current = Some(ReducedBlock {
209 observed,
210 line,
211 detail,
212 });
213 }
214 }
215 if let Tag::Heading { level, .. } = &tag {
219 let d = heading_depth(*level);
220 if d <= 2 {
221 let slice = &source[range.start..range.end.min(source.len())];
222 if !slice.trim_start().starts_with('#') {
223 setext_reserved.push(SetextReservedHeading { line, depth: d });
224 }
225 }
226 }
227 match &tag {
228 Tag::Item if depth == 1 => {
229 item = Some(ItemState {
230 line,
231 span: None,
232 nested: 0,
233 own_paragraph_seen: false,
234 });
235 }
236 Tag::TableHead if depth == 1 => in_table_head = true,
237 Tag::TableRow if depth == 1 => {
238 current_row = Some((line, Vec::new()));
239 }
240 Tag::TableCell => current_cell = Some(String::new()),
241 _ => {
242 if let Some(st) = item.as_mut()
243 && !is_block_tag(&tag)
244 && st.nested == 0
245 && current_cell.is_none()
246 {
247 merge_span(&mut st.span, range.start, range.end);
252 }
253 if let Some(st) = item.as_mut()
254 && is_block_tag(&tag)
255 {
256 if matches!(tag, Tag::Paragraph)
260 && st.nested == 0
261 && !st.own_paragraph_seen
262 {
263 st.own_paragraph_seen = true;
264 } else {
265 st.nested += 1;
266 }
267 }
268 }
269 }
270 depth += 1;
271 }
272 Event::End(tag_end) => {
273 depth -= 1;
274 match tag_end {
275 TagEnd::Item if depth == 1 => {
276 if let (Some(st), Some(block)) = (item.take(), current.as_mut())
277 && let BlockDetail::List { items } = &mut block.detail
278 {
279 let text = st
280 .span
281 .map(|(a, b)| {
282 source[a..b.min(source.len())]
283 .lines()
284 .map(str::trim)
285 .filter(|l| !l.is_empty())
286 .collect::<Vec<_>>()
287 .join(" ")
288 })
289 .unwrap_or_default();
290 items.push((st.line, text));
291 }
292 }
293 TagEnd::TableHead if depth == 1 => in_table_head = false,
294 TagEnd::TableRow if depth == 1 => {
295 if let (Some((line, cells)), Some(block)) =
296 (current_row.take(), current.as_mut())
297 && let BlockDetail::Table { rows, .. } = &mut block.detail
298 {
299 let raw = raw_cell_count(source, line);
300 rows.push(TableRow {
301 line,
302 cells,
303 raw_cell_count: raw,
304 });
305 }
306 }
307 TagEnd::TableCell => {
308 if let Some(cell) = current_cell.take() {
309 let cell = cell.trim().to_string();
310 if in_table_head {
311 if let Some(block) = current.as_mut()
312 && let BlockDetail::Table { header, .. } = &mut block.detail
313 {
314 header.push(cell);
315 }
316 } else if let Some((_, cells)) = current_row.as_mut() {
317 cells.push(cell);
318 }
319 }
320 }
321 TagEnd::Paragraph => {
322 if depth == 0
323 && let Some(block) = current.as_mut()
324 && let BlockDetail::Paragraph { lines } = &mut block.detail
325 {
326 let end = range.end.min(source.len());
327 let slice = &source[para_range_start..end];
328 let first_line = line_of(source, para_range_start);
329 for (line_no, l) in (first_line..).zip(slice.lines()) {
330 let t = l.trim();
331 if !t.is_empty() {
332 lines.push((line_no, t.to_string()));
333 }
334 }
335 }
336 if let Some(st) = item.as_mut()
337 && st.nested > 0
338 {
339 st.nested -= 1;
340 }
341 }
342 TagEnd::List(_)
343 | TagEnd::Item
344 | TagEnd::Table
345 | TagEnd::CodeBlock
346 | TagEnd::BlockQuote(_)
347 | TagEnd::Heading(_)
348 | TagEnd::HtmlBlock
349 | TagEnd::FootnoteDefinition => {
350 if let Some(st) = item.as_mut()
351 && st.nested > 0
352 {
353 st.nested -= 1;
354 }
355 }
356 _ => {}
357 }
358 if depth == 0
359 && let Some(block) = current.take()
360 {
361 blocks.push(block);
362 }
363 }
364 Event::Rule if depth == 0 => {
365 blocks.push(ReducedBlock {
366 observed: ObservedBlock::ThematicBreak,
367 line: line_of(source, range.start),
368 detail: BlockDetail::None,
369 });
370 }
371 Event::Text(t) | Event::Code(t) | Event::InlineHtml(t) => {
372 if let Some(cell) = current_cell.as_mut() {
373 cell.push_str(&t);
374 } else if let Some(st) = item.as_mut()
375 && st.nested == 0
376 {
377 merge_span(&mut st.span, range.start, range.end);
378 }
379 }
380 Event::SoftBreak | Event::HardBreak => {
381 if let Some(cell) = current_cell.as_mut() {
382 cell.push(' ');
383 } else if let Some(st) = item.as_mut()
384 && st.nested == 0
385 {
386 merge_span(&mut st.span, range.start, range.end);
387 }
388 }
389 _ => {}
390 }
391 }
392
393 ReducedSection {
394 blocks,
395 setext_reserved,
396 }
397}
398
399fn merge_span(span: &mut Option<(usize, usize)>, start: usize, end: usize) {
405 *span = Some(match span {
406 None => (start, end),
407 Some((a, b)) => ((*a).min(start), (*b).max(end)),
408 });
409}
410
411fn raw_cell_count(source: &str, line: usize) -> usize {
416 let Some(row) = source.lines().nth(line.saturating_sub(1)) else {
417 return 0;
418 };
419 let trimmed = row.trim();
420 let inner = trimmed
421 .strip_prefix('|')
422 .unwrap_or(trimmed)
423 .strip_suffix('|')
424 .unwrap_or(trimmed);
425 let mut count = 1;
426 let mut escaped = false;
427 for c in inner.chars() {
428 match c {
429 '\\' if !escaped => escaped = true,
430 '|' if !escaped => {
431 count += 1;
432 escaped = false;
433 }
434 _ => escaped = false,
435 }
436 }
437 count
438}
439
440#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
450#[serde(tag = "kind", rename_all = "snake_case")]
451pub enum SectionFormatViolation {
452 ContentMismatch {
453 section: String,
454 expected: String,
456 found: Vec<String>,
458 failed_at: usize,
461 expected_next: Vec<String>,
463 #[serde(skip_serializing_if = "Option::is_none")]
464 example: Option<String>,
465 },
466 ItemPatternMismatch {
467 section: String,
468 item_index: usize,
471 line: usize,
473 text: String,
476 pattern: String,
478 groups: Vec<String>,
481 #[serde(skip_serializing_if = "Option::is_none")]
482 example: Option<String>,
483 },
484 TableColumns {
485 section: String,
486 reason: String,
490 expected_columns: Vec<String>,
491 #[serde(skip_serializing_if = "Vec::is_empty")]
492 found_columns: Vec<String>,
493 #[serde(skip_serializing_if = "Option::is_none")]
494 row_line: Option<usize>,
495 #[serde(skip_serializing_if = "Option::is_none")]
496 expected_cells: Option<usize>,
497 #[serde(skip_serializing_if = "Option::is_none")]
498 found_cells: Option<usize>,
499 #[serde(skip_serializing_if = "Option::is_none")]
500 column: Option<String>,
501 #[serde(skip_serializing_if = "Option::is_none")]
502 pattern: Option<String>,
503 #[serde(skip_serializing_if = "Option::is_none")]
504 cell: Option<String>,
505 #[serde(skip_serializing_if = "Option::is_none")]
506 example: Option<String>,
507 },
508 SetextReserved {
511 section: String,
512 line: usize,
513 depth: u8,
514 },
515}
516
517impl SectionFormatViolation {
518 pub fn code(&self) -> &'static str {
520 match self {
521 Self::ContentMismatch { .. } => "SECTION_CONTENT_MISMATCH",
522 Self::ItemPatternMismatch { .. } => "SECTION_ITEM_PATTERN_MISMATCH",
523 Self::TableColumns { .. } => "INVALID_TABLE_COLUMNS",
524 Self::SetextReserved { .. } => "SECTION_CONTENT_INVALID",
525 }
526 }
527
528 pub fn example(&self) -> Option<&str> {
530 match self {
531 Self::ContentMismatch { example, .. }
532 | Self::ItemPatternMismatch { example, .. }
533 | Self::TableColumns { example, .. } => example.as_deref(),
534 Self::SetextReserved { .. } => None,
535 }
536 }
537
538 pub fn describe(&self) -> String {
540 match self {
541 Self::ContentMismatch {
542 section,
543 expected,
544 found,
545 failed_at,
546 expected_next,
547 ..
548 } => format!(
549 "section '{section}' does not match its declared shape `{expected}` — found [{}], expected {} at line {failed_at}",
550 found.join(", "),
551 if expected_next.is_empty() {
552 "end of section".to_string()
553 } else {
554 expected_next.join(" | ")
555 },
556 ),
557 Self::ItemPatternMismatch {
558 section,
559 line,
560 text,
561 pattern,
562 ..
563 } => format!(
564 "section '{section}' line {line} does not match the declared item pattern `{pattern}`: {text}"
565 ),
566 Self::TableColumns {
567 section, reason, ..
568 } => format!("section '{section}' violates its table contract ({reason})"),
569 Self::SetextReserved {
570 section,
571 line,
572 depth,
573 } => format!(
574 "section '{section}' line {line} is a setext h{depth} heading — h1/h2 are the entity's own levels"
575 ),
576 }
577 }
578}
579
580pub fn check_section_format(
586 def: &memstead_schema::SectionDef,
587 body: &str,
588) -> Vec<SectionFormatViolation> {
589 let Some(expr) = def.compiled_content.as_ref() else {
590 return Vec::new();
591 };
592 let section = def.key.as_str();
593 let reduced = reduce_section(body);
594 let mut out: Vec<SectionFormatViolation> = Vec::new();
595
596 for setext in &reduced.setext_reserved {
597 out.push(SectionFormatViolation::SetextReserved {
598 section: section.to_string(),
599 line: setext.line,
600 depth: setext.depth,
601 });
602 }
603
604 let observed = reduced.observed();
605 if let Err(failure) = expr.match_blocks(&observed) {
606 let failed_at_line = reduced
607 .blocks
608 .get(failure.failed_at)
609 .map(|b| b.line)
610 .unwrap_or_else(|| reduced.blocks.last().map(|b| b.line + 1).unwrap_or(1));
611 out.push(SectionFormatViolation::ContentMismatch {
612 section: section.to_string(),
613 expected: expr.source().to_string(),
614 found: observed.iter().map(|b| b.display()).collect(),
615 failed_at: failed_at_line,
616 expected_next: failure.expected_next,
617 example: def.example.clone(),
618 });
619 }
620
621 if let Some(pattern_src) = &def.item_pattern
622 && let Ok(pattern) = regex::Regex::new(&format!("^(?:{pattern_src})$"))
625 {
626 let groups: Vec<String> = pattern
627 .capture_names()
628 .flatten()
629 .map(str::to_string)
630 .collect();
631 let targets_lists = expr.mentioned_names().contains(&"list");
632 let mut unit_index = 0usize;
633 for block in &reduced.blocks {
634 match &block.detail {
635 BlockDetail::List { items } if targets_lists => {
636 for (line, text) in items {
637 if !pattern.is_match(text) {
638 out.push(SectionFormatViolation::ItemPatternMismatch {
639 section: section.to_string(),
640 item_index: unit_index,
641 line: *line,
642 text: text.clone(),
643 pattern: pattern_src.clone(),
644 groups: groups.clone(),
645 example: def.example.clone(),
646 });
647 }
648 unit_index += 1;
649 }
650 }
651 BlockDetail::Paragraph { lines } if !targets_lists => {
652 for (line, text) in lines {
653 if !pattern.is_match(text) {
654 out.push(SectionFormatViolation::ItemPatternMismatch {
655 section: section.to_string(),
656 item_index: unit_index,
657 line: *line,
658 text: text.clone(),
659 pattern: pattern_src.clone(),
660 groups: groups.clone(),
661 example: def.example.clone(),
662 });
663 }
664 unit_index += 1;
665 }
666 }
667 _ => {}
668 }
669 }
670 }
671
672 if let Some(table_format) = &def.table {
673 for block in &reduced.blocks {
674 let BlockDetail::Table { header, rows } = &block.detail else {
675 continue;
676 };
677 if header != &table_format.columns {
678 out.push(SectionFormatViolation::TableColumns {
679 section: section.to_string(),
680 reason: "header".to_string(),
681 expected_columns: table_format.columns.clone(),
682 found_columns: header.clone(),
683 row_line: None,
684 expected_cells: None,
685 found_cells: None,
686 column: None,
687 pattern: None,
688 cell: None,
689 example: def.example.clone(),
690 });
691 continue;
693 }
694 for row in rows {
695 if row.raw_cell_count != table_format.columns.len() {
696 out.push(SectionFormatViolation::TableColumns {
697 section: section.to_string(),
698 reason: "cell_count".to_string(),
699 expected_columns: table_format.columns.clone(),
700 found_columns: Vec::new(),
701 row_line: Some(row.line),
702 expected_cells: Some(table_format.columns.len()),
703 found_cells: Some(row.raw_cell_count),
704 column: None,
705 pattern: None,
706 cell: None,
707 example: def.example.clone(),
708 });
709 continue;
710 }
711 for (column, pattern_src) in &table_format.column_patterns {
712 let Some(col_idx) = table_format.columns.iter().position(|c| c == column)
713 else {
714 continue;
715 };
716 let Some(cell) = row.cells.get(col_idx) else {
717 continue;
718 };
719 let Ok(pattern) = regex::Regex::new(&format!("^(?:{pattern_src})$")) else {
720 continue;
721 };
722 if !pattern.is_match(cell) {
723 out.push(SectionFormatViolation::TableColumns {
724 section: section.to_string(),
725 reason: "cell_pattern".to_string(),
726 expected_columns: table_format.columns.clone(),
727 found_columns: Vec::new(),
728 row_line: Some(row.line),
729 expected_cells: None,
730 found_cells: None,
731 column: Some(column.clone()),
732 pattern: Some(pattern_src.clone()),
733 cell: Some(cell.clone()),
734 example: def.example.clone(),
735 });
736 }
737 }
738 }
739 }
740 }
741
742 out.sort_by_key(|v| match v {
743 SectionFormatViolation::ContentMismatch { failed_at, .. } => *failed_at,
744 SectionFormatViolation::ItemPatternMismatch { line, .. } => *line,
745 SectionFormatViolation::TableColumns { row_line, .. } => row_line.unwrap_or(0),
746 SectionFormatViolation::SetextReserved { line, .. } => *line,
747 });
748 out
749}
750
751#[cfg(test)]
752mod tests {
753 use super::*;
754
755 fn observed(source: &str) -> Vec<ObservedBlock> {
756 reduce_section(source).observed()
757 }
758
759 #[test]
760 fn simple_bullet_list_is_one_block() {
761 assert_eq!(
762 observed("- one\n- two\n"),
763 vec![ObservedBlock::List { ordered: false }]
764 );
765 assert_eq!(
766 observed("1. one\n2. two\n"),
767 vec![ObservedBlock::List { ordered: true }]
768 );
769 }
770
771 #[test]
775 fn lazy_continuation_stays_one_list_and_joins_item_text() {
776 let src = "- **Kickoff** — Projektstart\n mit allen Beteiligten — 2026-09-01\n- **Zwei** — kurz — 2026-09-02\n";
777 let reduced = reduce_section(src);
778 assert_eq!(
779 reduced.observed(),
780 vec![ObservedBlock::List { ordered: false }]
781 );
782 let BlockDetail::List { items } = &reduced.blocks[0].detail else {
783 panic!("list detail");
784 };
785 assert_eq!(items.len(), 2);
786 assert_eq!(
787 items[0].1,
788 "**Kickoff** — Projektstart mit allen Beteiligten — 2026-09-01",
789 );
790 assert_eq!(items[0].0, 1, "item line");
791 assert_eq!(items[1].0, 3);
792
793 let lazy = "- alpha\nbeta\n";
795 let reduced = reduce_section(lazy);
796 let BlockDetail::List { items } = &reduced.blocks[0].detail else {
797 panic!("list detail");
798 };
799 assert_eq!(items[0].1, "alpha beta");
800 }
801
802 #[test]
805 fn mixed_markers_are_two_lists() {
806 assert_eq!(
807 observed("- one\n* two\n"),
808 vec![
809 ObservedBlock::List { ordered: false },
810 ObservedBlock::List { ordered: false },
811 ]
812 );
813 }
814
815 #[test]
818 fn code_blocks_containing_dashes_are_not_lists() {
819 assert_eq!(
820 observed("```\n- not a list\n```\n"),
821 vec![ObservedBlock::Code {
822 lang: String::new()
823 }]
824 );
825 assert_eq!(
826 observed(" - not a list\n"),
827 vec![ObservedBlock::Code {
828 lang: String::new()
829 }]
830 );
831 assert_eq!(
832 observed("```rust\nfn x() {}\n```\n"),
833 vec![ObservedBlock::Code {
834 lang: "rust".to_string()
835 }]
836 );
837 }
838
839 #[test]
842 fn malformed_delimiter_row_is_not_a_table() {
843 let good = "| Name | Datum |\n| --- | --- |\n| a | b |\n";
844 assert_eq!(observed(good), vec![ObservedBlock::Table]);
845
846 let bad = "| Name | Datum |\n| -x- | --- |\n| a | b |\n";
847 assert!(
848 !observed(bad).contains(&ObservedBlock::Table),
849 "malformed delimiter row must not parse as a table: {:?}",
850 observed(bad)
851 );
852 }
853
854 #[test]
855 fn table_reduction_carries_header_and_row_cells() {
856 let src = "| Name | Beschreibung | Datum |\n| --- | --- | --- |\n| Kickoff | Start | 2026-09-01 |\n| Zwei | Kurz | 2026-09-02 |\n";
857 let reduced = reduce_section(src);
858 let BlockDetail::Table { header, rows } = &reduced.blocks[0].detail else {
859 panic!("table detail");
860 };
861 assert_eq!(header, &["Name", "Beschreibung", "Datum"]);
862 assert_eq!(rows.len(), 2);
863 assert_eq!(rows[0].cells, vec!["Kickoff", "Start", "2026-09-01"]);
864 assert_eq!(rows[0].raw_cell_count, 3);
865 assert_eq!(rows[1].line, 4, "row line number");
866 }
867
868 #[test]
872 fn table_rows_keep_their_real_cell_count() {
873 let src = "| A | B |\n| --- | --- |\n| only |\n| x | y | z |\n";
874 let reduced = reduce_section(src);
875 let BlockDetail::Table { rows, .. } = &reduced.blocks[0].detail else {
876 panic!("table detail");
877 };
878 assert_eq!(rows[0].raw_cell_count, 1, "short row really has 1 cell");
881 assert_eq!(rows[1].raw_cell_count, 3, "long row really has 3 cells");
882 }
883
884 #[test]
885 fn paragraph_lines_carry_source_lines() {
886 let src = "erste zeile\nzweite zeile\n\nnächster absatz\n";
887 let reduced = reduce_section(src);
888 assert_eq!(
889 reduced.observed(),
890 vec![ObservedBlock::Paragraph, ObservedBlock::Paragraph]
891 );
892 let BlockDetail::Paragraph { lines } = &reduced.blocks[0].detail else {
893 panic!("paragraph detail");
894 };
895 assert_eq!(
896 lines,
897 &[
898 (1, "erste zeile".to_string()),
899 (2, "zweite zeile".to_string())
900 ]
901 );
902 let BlockDetail::Paragraph { lines } = &reduced.blocks[1].detail else {
903 panic!("paragraph detail");
904 };
905 assert_eq!(lines, &[(4, "nächster absatz".to_string())]);
906 }
907
908 #[test]
909 fn setext_headings_are_reported() {
910 let src = "Titel\n=====\n\ntext\n\nUnter\n-----\n";
911 let reduced = reduce_section(src);
912 assert_eq!(
913 reduced.setext_reserved,
914 vec![
915 SetextReservedHeading { line: 1, depth: 1 },
916 SetextReservedHeading { line: 6, depth: 2 },
917 ]
918 );
919 let reduced = reduce_section("### Phase 1\n- x\n");
921 assert!(reduced.setext_reserved.is_empty());
922 assert_eq!(
923 reduced.observed(),
924 vec![
925 ObservedBlock::Heading { depth: 3 },
926 ObservedBlock::List { ordered: false },
927 ]
928 );
929 }
930
931 #[test]
932 fn nested_list_text_stays_out_of_parent_item() {
933 let src = "- parent\n - child\n- second\n";
934 let reduced = reduce_section(src);
935 assert_eq!(
936 reduced.observed(),
937 vec![ObservedBlock::List { ordered: false }]
938 );
939 let BlockDetail::List { items } = &reduced.blocks[0].detail else {
940 panic!("list detail");
941 };
942 assert_eq!(
943 items.iter().map(|(_, t)| t.as_str()).collect::<Vec<_>>(),
944 vec!["parent", "second"]
945 );
946 }
947
948 #[test]
949 fn blockquote_html_and_rule_reduce() {
950 assert_eq!(observed("> quoted\n"), vec![ObservedBlock::Blockquote]);
951 assert_eq!(observed("---\n"), vec![ObservedBlock::ThematicBreak]);
952 assert_eq!(observed("<div>\nx\n</div>\n"), vec![ObservedBlock::Html]);
953 }
954
955 #[test]
958 fn plan_example_roundtrip() {
959 use memstead_schema::content_expr::ContentExpr;
960 let expr = ContentExpr::parse("(heading(3) list(bullet))+").unwrap();
961 let body = "### Phase 1\n- **Kickoff** — Projektstart mit allen Beteiligten — 2026-09-01\n";
962 assert!(expr.match_blocks(&reduce_section(body).observed()).is_ok());
963
964 let wrong = "### Phase 1\n\nkein listenpunkt\n";
965 let err = expr
966 .match_blocks(&reduce_section(wrong).observed())
967 .unwrap_err();
968 assert_eq!(err.failed_at, 1);
969 assert_eq!(err.expected_next, vec!["list(bullet)".to_string()]);
970 }
971}
972
973#[cfg(test)]
974mod check_tests {
975 use super::*;
976 use memstead_schema::{ConstraintSeverity, SectionDef, TableFormat};
977
978 fn def(
979 content: &str,
980 item_pattern: Option<&str>,
981 table: Option<TableFormat>,
982 example: Option<&str>,
983 ) -> SectionDef {
984 SectionDef {
985 key: "body".to_string(),
986 heading: "Body".to_string(),
987 required: true,
988 load_bearing: None,
989 search_weight: 1.0,
990 catch_all: true,
991 write_rules: vec![],
992 description: None,
993 content: Some(content.to_string()),
994 item_pattern: item_pattern.map(str::to_string),
995 table,
996 example: example.map(str::to_string),
997 format_severity: ConstraintSeverity::Block,
998 compiled_content: Some(
999 memstead_schema::content_expr::ContentExpr::parse(content).unwrap(),
1000 ),
1001 format_problems: Vec::new(),
1002 }
1003 }
1004
1005 #[test]
1006 fn content_mismatch_carries_position_expectation_and_example() {
1007 let d = def(
1008 "(heading(3) list(bullet))+",
1009 None,
1010 None,
1011 Some("### Phase 1\n- **Kickoff** — 2026-09-01\n"),
1012 );
1013 let violations = check_section_format(&d, "### Phase 1\n\nprose statt liste\n");
1014 assert_eq!(violations.len(), 1);
1015 let SectionFormatViolation::ContentMismatch {
1016 failed_at,
1017 expected_next,
1018 found,
1019 example,
1020 ..
1021 } = &violations[0]
1022 else {
1023 panic!("expected content mismatch: {violations:?}");
1024 };
1025 assert_eq!(*failed_at, 3, "line of the offending paragraph");
1026 assert_eq!(expected_next, &vec!["list(bullet)".to_string()]);
1027 assert_eq!(
1028 found,
1029 &vec!["heading(3)".to_string(), "paragraph".to_string()]
1030 );
1031 assert!(example.as_deref().unwrap().contains("Kickoff"));
1032 assert_eq!(violations[0].code(), "SECTION_CONTENT_MISMATCH");
1033
1034 assert!(check_section_format(&d, "### Phase 1\n- **Kickoff** — 2026-09-01\n").is_empty());
1036 }
1037
1038 #[test]
1039 fn item_pattern_flags_each_nonconforming_item_with_groups() {
1040 let d = def(
1041 "list(bullet)",
1042 Some(r"\*\*(?<name>[^*]+)\*\* — (?<datum>\d{4}-\d{2}-\d{2})"),
1043 None,
1044 None,
1045 );
1046 let ok = "- **Kickoff** — 2026-09-01\n- **Zwei** — 2026-09-02\n";
1047 assert!(check_section_format(&d, ok).is_empty());
1048
1049 let lazy = "- **Kickoff** —\n 2026-09-01\n";
1051 assert!(
1052 check_section_format(&d, lazy).is_empty(),
1053 "continuation never changes the match: {:?}",
1054 check_section_format(&d, lazy)
1055 );
1056
1057 let bad = "- **Kickoff** — 2026-09-01\n- kein format\n";
1058 let violations = check_section_format(&d, bad);
1059 assert_eq!(violations.len(), 1);
1060 let SectionFormatViolation::ItemPatternMismatch {
1061 item_index,
1062 line,
1063 text,
1064 groups,
1065 ..
1066 } = &violations[0]
1067 else {
1068 panic!("expected item mismatch: {violations:?}");
1069 };
1070 assert_eq!(*item_index, 1);
1071 assert_eq!(*line, 2);
1072 assert_eq!(text, "kein format");
1073 assert_eq!(groups, &vec!["name".to_string(), "datum".to_string()]);
1074 assert_eq!(violations[0].code(), "SECTION_ITEM_PATTERN_MISMATCH");
1075 }
1076
1077 #[test]
1078 fn paragraph_pattern_checks_each_source_line() {
1079 let d = def(
1082 "paragraph+",
1083 Some(r"(?<quelle>\S[^|]*?) \| (?<aussage>.+)"),
1084 None,
1085 None,
1086 );
1087 let ok = "bverfg:1 BvR 2649/21 Rn. 183 | Der Staat schuldet Schutz.\ngg:art-20a | Schutzauftrag.\n";
1088 assert!(
1089 check_section_format(&d, ok).is_empty(),
1090 "{:?}",
1091 check_section_format(&d, ok)
1092 );
1093 let bad = "bverfg:1 BvR 2649/21 Rn. 183 — Der Staat schuldet Schutz.\n";
1095 let violations = check_section_format(&d, bad);
1096 assert_eq!(violations.len(), 1);
1097 assert!(matches!(
1098 &violations[0],
1099 SectionFormatViolation::ItemPatternMismatch { line: 1, .. }
1100 ));
1101 }
1102
1103 #[test]
1104 fn table_contract_enforces_columns_counts_and_cell_patterns() {
1105 let table = TableFormat {
1106 columns: vec!["Name".into(), "Datum".into()],
1107 column_patterns: [("Datum".to_string(), r"\d{4}-\d{2}-\d{2}".to_string())]
1108 .into_iter()
1109 .collect(),
1110 };
1111 let d = def("table", None, Some(table), None);
1112
1113 let ok = "| Name | Datum |\n| --- | --- |\n| Kickoff | 2026-09-01 |\n";
1114 assert!(check_section_format(&d, ok).is_empty());
1115
1116 let wrong_header = "| Datum | Name |\n| --- | --- |\n| 2026-09-01 | Kickoff |\n";
1118 let violations = check_section_format(&d, wrong_header);
1119 assert!(matches!(
1120 &violations[0],
1121 SectionFormatViolation::TableColumns { reason, .. } if reason == "header"
1122 ));
1123
1124 let short = "| Name | Datum |\n| --- | --- |\n| nur-eine |\n";
1126 let violations = check_section_format(&d, short);
1127 let SectionFormatViolation::TableColumns {
1128 reason,
1129 expected_cells,
1130 found_cells,
1131 row_line,
1132 ..
1133 } = &violations[0]
1134 else {
1135 panic!("expected table violation: {violations:?}");
1136 };
1137 assert_eq!(reason, "cell_count");
1138 assert_eq!(*expected_cells, Some(2));
1139 assert_eq!(*found_cells, Some(1));
1140 assert_eq!(*row_line, Some(3));
1141 assert_eq!(violations[0].code(), "INVALID_TABLE_COLUMNS");
1142
1143 let bad_cell = "| Name | Datum |\n| --- | --- |\n| Kickoff | morgen |\n";
1145 let violations = check_section_format(&d, bad_cell);
1146 let SectionFormatViolation::TableColumns {
1147 reason,
1148 column,
1149 pattern,
1150 cell,
1151 row_line,
1152 ..
1153 } = &violations[0]
1154 else {
1155 panic!("expected cell violation: {violations:?}");
1156 };
1157 assert_eq!(reason, "cell_pattern");
1158 assert_eq!(column.as_deref(), Some("Datum"));
1159 assert!(pattern.as_deref().unwrap().contains("d{4}"));
1160 assert_eq!(cell.as_deref(), Some("morgen"));
1161 assert_eq!(*row_line, Some(3));
1162 }
1163
1164 #[test]
1170 fn plenum_coordinate_grammar_is_declarable() {
1171 let d = def(
1172 "paragraph+",
1173 Some(
1174 r"(?<quelle>[a-z]+):(?<dokument>[^:|]+):(?<von>\d+)-(?<bis>\d+):(?<dokument_hash>[0-9a-f]{12}):(?<span_hash>[0-9a-f]{12}) \| (?<fundstelle>.+)",
1175 ),
1176 None,
1177 None,
1178 );
1179 let ok = "btp:20/13/073:4559-4985:09b80726ef42:0a582b1c5530 | 2022-01-26 · Tino Chrupalla · https://dserver.bundestag.de/btp/20/20013.pdf
1180";
1181 assert!(
1182 check_section_format(&d, ok).is_empty(),
1183 "{:?}",
1184 check_section_format(&d, ok)
1185 );
1186 let bad =
1188 "btp:20/13/073:4559-4985:09b80726ef42 | 2022-01-26 · Chrupalla · https://example.org
1189";
1190 assert_eq!(check_section_format(&d, bad).len(), 1);
1191 let bad = "btp:20/13/073:4559-4985:09b80726ef42:0a582b1c5530 2022-01-26
1193";
1194 assert_eq!(check_section_format(&d, bad).len(), 1);
1195 }
1196
1197 #[test]
1198 fn setext_reserved_headings_refuse_in_checked_sections() {
1199 let d = def("paragraph+", None, None, None);
1200 let violations = check_section_format(&d, "Titel\n=====\n\ntext\n");
1201 assert!(
1202 violations
1203 .iter()
1204 .any(|v| matches!(v, SectionFormatViolation::SetextReserved { depth: 1, .. })),
1205 "{violations:?}"
1206 );
1207 assert_eq!(
1208 violations
1209 .iter()
1210 .find(|v| matches!(v, SectionFormatViolation::SetextReserved { .. }))
1211 .unwrap()
1212 .code(),
1213 "SECTION_CONTENT_INVALID"
1214 );
1215 }
1216
1217 #[test]
1218 fn free_form_section_produces_no_violations() {
1219 let d = SectionDef {
1220 key: "body".to_string(),
1221 heading: "Body".to_string(),
1222 required: true,
1223 load_bearing: None,
1224 search_weight: 1.0,
1225 catch_all: true,
1226 write_rules: vec![],
1227 description: None,
1228 content: None,
1229 item_pattern: None,
1230 table: None,
1231 example: None,
1232 format_severity: ConstraintSeverity::Block,
1233 compiled_content: None,
1234 format_problems: Vec::new(),
1235 };
1236 assert!(check_section_format(&d, "anything\n=====\n\n- mixed\n* markers\n").is_empty());
1237 }
1238}