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