1use std::ops::Deref;
19
20use serde::Serialize;
21
22use crate::atlassian::adf::{AdfDocument, AdfNode};
23use crate::atlassian::adf_schema::{validate_document, AdfSchemaViolation};
24use crate::atlassian::convert::markdown_to_adf;
25
26#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct SourceLocation {
32 pub line: usize,
34 pub column: usize,
36}
37
38#[derive(Debug, Clone, PartialEq, Default)]
46struct ViolationContext {
47 node_type: Option<String>,
49 excerpt: Option<String>,
51 location: Option<SourceLocation>,
54}
55
56const EXCERPT_MAX_CHARS: usize = 60;
60
61fn node_at_path<'a>(doc: &'a AdfDocument, path: &[usize]) -> Option<&'a AdfNode> {
64 let mut children = doc.content.as_slice();
65 let mut found: Option<&AdfNode> = None;
66 for &idx in path {
67 let node = children.get(idx)?;
68 found = Some(node);
69 children = node.content.as_deref().unwrap_or(&[]);
70 }
71 found
72}
73
74fn collect_text(node: &AdfNode) -> String {
79 let mut out = String::new();
80 gather_text(node, &mut out);
81 out
82}
83
84fn gather_text(node: &AdfNode, out: &mut String) {
85 if out.chars().count() >= EXCERPT_MAX_CHARS {
86 return;
87 }
88 if let Some(text) = &node.text {
89 out.push_str(text);
90 }
91 if let Some(children) = &node.content {
92 for child in children {
93 gather_text(child, out);
94 if out.chars().count() >= EXCERPT_MAX_CHARS {
95 return;
96 }
97 }
98 }
99}
100
101fn truncate_excerpt(s: &str) -> String {
104 if s.chars().count() <= EXCERPT_MAX_CHARS {
105 return s.to_string();
106 }
107 let mut out: String = s.chars().take(EXCERPT_MAX_CHARS).collect();
108 out.push('…');
109 out
110}
111
112fn offset_to_line_col(source: &str, byte_offset: usize) -> SourceLocation {
114 let mut line = 1;
115 let mut column = 1;
116 for (idx, ch) in source.char_indices() {
117 if idx >= byte_offset {
118 break;
119 }
120 if ch == '\n' {
121 line += 1;
122 column = 1;
123 } else {
124 column += 1;
125 }
126 }
127 SourceLocation { line, column }
128}
129
130fn locate_in_source(source: &str, needle: &str) -> Option<SourceLocation> {
138 let needle = needle.trim();
139 if needle.is_empty() {
140 return None;
141 }
142 let byte_offset = source.find(needle)?;
143 Some(offset_to_line_col(source, byte_offset))
144}
145
146fn resolve_context(
149 violation: &AdfSchemaViolation,
150 doc: &AdfDocument,
151 source: Option<&str>,
152) -> ViolationContext {
153 let Some(node) = node_at_path(doc, violation.path()) else {
154 return ViolationContext::default();
155 };
156 let node_type = Some(node.node_type.clone());
157
158 let full_text = match &node.text {
159 Some(text) => text.clone(),
160 None => collect_text(node),
161 };
162 let full_text = full_text.trim();
163 if full_text.is_empty() {
164 return ViolationContext {
165 node_type,
166 ..ViolationContext::default()
167 };
168 }
169
170 let location = source.and_then(|src| locate_in_source(src, full_text));
171 ViolationContext {
172 node_type,
173 excerpt: Some(truncate_excerpt(full_text)),
174 location,
175 }
176}
177
178#[derive(Debug, Clone, PartialEq)]
185pub struct AdfValidationError {
186 pub violations: Vec<AdfSchemaViolation>,
188 contexts: Vec<ViolationContext>,
194}
195
196impl AdfValidationError {
197 #[must_use]
201 pub fn new(violations: Vec<AdfSchemaViolation>) -> Self {
202 Self {
203 violations,
204 contexts: Vec::new(),
205 }
206 }
207
208 #[must_use]
216 fn enriched(mut self, doc: &AdfDocument, source: Option<&str>) -> Self {
217 self.contexts = self
218 .violations
219 .iter()
220 .map(|v| resolve_context(v, doc, source))
221 .collect();
222 self
223 }
224}
225
226impl std::fmt::Display for AdfValidationError {
227 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
228 let mut out = String::new();
233 for (i, v) in self.violations.iter().enumerate() {
234 if i > 0 {
235 out.push_str("\n\n");
236 }
237 let path = v
238 .path()
239 .iter()
240 .map(usize::to_string)
241 .collect::<Vec<_>>()
242 .join("/");
243 match v {
244 AdfSchemaViolation::DisallowedChild {
245 child_type,
246 parent_type,
247 ..
248 } => {
249 out.push_str(&format!(
250 "invalid ADF nesting — `{child_type}` cannot be a child of `{parent_type}` at /{path}.\n",
251 ));
252 let hint = hint_for(parent_type, child_type).map_or_else(
253 || {
254 format!(
255 "hint: restructure the document so `{child_type}` is not a direct child of `{parent_type}`.",
256 )
257 },
258 |h| format!("hint: {h}"),
259 );
260 out.push_str(&hint);
261 }
262 AdfSchemaViolation::Arity { .. } => {
263 out.push_str(&format!("invalid ADF nesting — {v}.\n"));
264 out.push_str(
265 "hint: adjust the number of children to match the schema's quantifier.",
266 );
267 }
268 AdfSchemaViolation::MissingAttr { .. } | AdfSchemaViolation::InvalidAttr { .. } => {
269 out.push_str(&format!("invalid ADF attribute — {v}.\n"));
270 out.push_str("hint: fix the offending attribute on the node before retrying.");
271 }
272 AdfSchemaViolation::DisallowedMark { .. }
273 | AdfSchemaViolation::InvalidMarkAttr { .. } => {
274 out.push_str(&format!("invalid ADF mark — {v}.\n"));
275 out.push_str("hint: remove or correct the offending mark before retrying.");
276 }
277 AdfSchemaViolation::ForbiddenMarkCombination { .. } => {
278 out.push_str(&format!("invalid ADF mark combination — {v}.\n"));
279 out.push_str(
280 "hint: ADF does not allow these marks on the same text — the `code` (monospace) mark only combines with a link; split the run so each piece carries a single style (e.g. drop the backticks or the surrounding bold/italic).",
281 );
282 }
283 }
284 if let Some(ctx) = self.contexts.get(i) {
285 append_context(&mut out, ctx);
286 }
287 }
288 f.write_str(&out)
289 }
290}
291
292fn append_context(out: &mut String, ctx: &ViolationContext) {
295 if let Some(loc) = &ctx.location {
296 out.push_str(&format!("\n at line {}, column {}", loc.line, loc.column));
297 }
298 if let Some(excerpt) = &ctx.excerpt {
299 out.push_str(&format!("\n in text: {excerpt:?}"));
300 }
301}
302
303impl std::error::Error for AdfValidationError {}
304
305fn hint_for(parent: &str, child: &str) -> Option<&'static str> {
312 HINTS
313 .iter()
314 .find(|(p, c, _)| *p == parent && *c == child)
315 .map(|(_, _, h)| *h)
316}
317
318const HINTS: &[(&str, &str, &str)] = &[
319 (
320 "panel",
321 "expand",
322 "invert the nesting (put the panel inside the expand) or use siblings.",
323 ),
324 (
325 "panel",
326 "nestedExpand",
327 "invert the nesting (put the panel inside the expand) or use siblings.",
328 ),
329 (
330 "panel",
331 "panel",
332 "panels cannot nest; use siblings or convert one to a blockquote.",
333 ),
334 (
335 "expand",
336 "expand",
337 "expands cannot nest directly; consider a single expand with sectioned headings.",
338 ),
339 (
340 "expand",
341 "nestedExpand",
342 "use a plain `expand` at the inner level only inside table cells or layout columns.",
343 ),
344 (
345 "nestedExpand",
346 "expand",
347 "nestedExpand cannot contain another expand; flatten the structure.",
348 ),
349 (
350 "nestedExpand",
351 "nestedExpand",
352 "nestedExpand cannot nest; use siblings.",
353 ),
354 (
355 "nestedExpand",
356 "panel",
357 "move the panel outside the nestedExpand or replace it with a blockquote.",
358 ),
359 (
360 "tableCell",
361 "expand",
362 "use a `nestedExpand` inside table cells; `expand` is only valid at the top level or inside layout columns.",
363 ),
364 (
365 "tableHeader",
366 "expand",
367 "use a `nestedExpand` inside table headers; `expand` is only valid at the top level or inside layout columns.",
368 ),
369 (
370 "tableCell",
371 "panel",
372 "panels are not allowed inside table cells; move the panel outside the table.",
373 ),
374 (
375 "tableHeader",
376 "panel",
377 "panels are not allowed inside table headers; move the panel outside the table.",
378 ),
379 (
380 "layoutSection",
381 "layoutSection",
382 "layout sections cannot nest; use sibling sections.",
383 ),
384 (
385 "layoutColumn",
386 "layoutSection",
387 "a layout column cannot contain another layout section; flatten the structure.",
388 ),
389 (
390 "blockquote",
391 "blockquote",
392 "blockquotes cannot nest; use a single blockquote with paragraph siblings.",
393 ),
394 (
395 "blockquote",
396 "panel",
397 "move the panel outside the blockquote.",
398 ),
399 (
400 "blockquote",
401 "expand",
402 "move the expand outside the blockquote.",
403 ),
404 (
405 "listItem",
406 "panel",
407 "panels cannot appear inside list items; place the panel outside the list.",
408 ),
409 (
410 "listItem",
411 "expand",
412 "expands cannot appear inside list items; place the expand outside the list.",
413 ),
414];
415
416pub fn validate(doc: &AdfDocument) -> Result<(), AdfValidationError> {
427 validate_with_source(doc, None)
428}
429
430pub fn validate_with_source(
442 doc: &AdfDocument,
443 source: Option<&str>,
444) -> Result<(), AdfValidationError> {
445 let violations = validate_document(doc);
446 if violations.is_empty() {
447 Ok(())
448 } else {
449 Err(AdfValidationError::new(violations).enriched(doc, source))
450 }
451}
452
453pub fn markdown_to_validated_adf(source: &str) -> anyhow::Result<ValidatedAdfDocument> {
468 let doc = markdown_to_adf(source)?;
469 Ok(ValidatedAdfDocument::try_new_with_source(
470 doc,
471 Some(source),
472 )?)
473}
474
475#[derive(Debug, Clone, PartialEq)]
487pub struct ValidatedAdfDocument(AdfDocument);
488
489impl ValidatedAdfDocument {
490 pub fn try_new(doc: AdfDocument) -> Result<Self, AdfValidationError> {
498 Self::try_new_with_source(doc, None)
499 }
500
501 pub fn try_new_with_source(
511 doc: AdfDocument,
512 source: Option<&str>,
513 ) -> Result<Self, AdfValidationError> {
514 let violations = validate_document(&doc);
515 if violations.is_empty() {
516 Ok(Self(doc))
517 } else {
518 Err(AdfValidationError::new(violations).enriched(&doc, source))
519 }
520 }
521
522 #[must_use]
526 pub fn empty() -> Self {
527 Self(AdfDocument::new())
528 }
529
530 #[cfg(test)]
542 #[must_use]
543 pub fn trust(doc: AdfDocument) -> Self {
544 Self(doc)
545 }
546}
547
548impl Deref for ValidatedAdfDocument {
549 type Target = AdfDocument;
550
551 fn deref(&self) -> &Self::Target {
552 &self.0
553 }
554}
555
556impl Serialize for ValidatedAdfDocument {
557 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
558 self.0.serialize(serializer)
559 }
560}
561
562#[cfg(test)]
563#[allow(clippy::unwrap_used, clippy::expect_used)]
564mod tests {
565 use super::*;
566 use crate::atlassian::adf::AdfNode;
567
568 fn doc(nodes: Vec<AdfNode>) -> AdfDocument {
569 AdfDocument {
570 version: 1,
571 doc_type: "doc".to_string(),
572 content: nodes,
573 }
574 }
575
576 #[test]
577 fn try_new_accepts_clean_document() {
578 let d = doc(vec![AdfNode::paragraph(vec![AdfNode::text("ok")])]);
579 let v = ValidatedAdfDocument::try_new(d).unwrap();
580 assert_eq!(v.content.len(), 1);
581 }
582
583 #[test]
584 fn try_new_rejects_panel_with_expand() {
585 let d = doc(vec![AdfNode::panel(
590 "info",
591 vec![AdfNode::expand(None, vec![])],
592 )]);
593 let err = ValidatedAdfDocument::try_new(d).unwrap_err();
594 assert!(err.violations.iter().any(|v| matches!(
595 v,
596 AdfSchemaViolation::DisallowedChild { child_type, parent_type, .. }
597 if child_type == "expand" && parent_type == "panel"
598 )));
599 }
600
601 #[test]
602 fn try_new_rejects_table_cell_with_expand() {
603 let d = doc(vec![AdfNode::table(vec![AdfNode::table_row(vec![
604 AdfNode::table_cell(vec![AdfNode::expand(None, vec![])]),
605 ])])]);
606 let err = ValidatedAdfDocument::try_new(d).unwrap_err();
607 assert!(err.violations.iter().any(|v| matches!(
608 v,
609 AdfSchemaViolation::DisallowedChild { child_type, parent_type, .. }
610 if child_type == "expand" && parent_type == "tableCell"
611 )));
612 }
613
614 #[test]
615 fn try_new_allows_expand_inside_layout_column() {
616 let inner = || AdfNode::paragraph(vec![AdfNode::text("x")]);
619 let column = || AdfNode::layout_column(50, vec![AdfNode::expand(None, vec![inner()])]);
620 let d = doc(vec![AdfNode::layout_section(vec![column(), column()])]);
621 assert!(ValidatedAdfDocument::try_new(d).is_ok());
622 }
623
624 #[test]
625 fn empty_is_trivially_valid() {
626 let v = ValidatedAdfDocument::empty();
627 assert!(v.content.is_empty());
628 }
629
630 #[test]
631 fn serializes_as_inner_adf() {
632 let d = doc(vec![AdfNode::paragraph(vec![AdfNode::text("hello")])]);
633 let v = ValidatedAdfDocument::try_new(d.clone()).unwrap();
634 let v_json = serde_json::to_string(&v).unwrap();
635 let d_json = serde_json::to_string(&d).unwrap();
636 assert_eq!(v_json, d_json);
637 }
638
639 #[test]
640 fn deref_exposes_inner_fields() {
641 let d = doc(vec![AdfNode::paragraph(vec![])]);
642 let v = ValidatedAdfDocument::try_new(d).unwrap();
643 assert_eq!(v.version, 1);
644 assert_eq!(v.doc_type, "doc");
645 }
646
647 #[test]
648 fn error_display_includes_path_and_hint_for_known_pair() {
649 let d = doc(vec![AdfNode::panel(
650 "info",
651 vec![AdfNode::expand(None, vec![])],
652 )]);
653 let err = ValidatedAdfDocument::try_new(d).unwrap_err();
654 let msg = err.to_string();
655 assert!(msg.contains("invalid ADF nesting"));
656 assert!(msg.contains("`expand` cannot be a child of `panel`"));
657 assert!(msg.contains("at /0/0"));
660 assert!(msg.contains("hint: invert the nesting"));
661 }
662
663 #[test]
664 fn error_display_falls_back_to_generic_hint_for_unknown_pair() {
665 let d = doc(vec![AdfNode::paragraph(vec![AdfNode::table(vec![])])]);
669 let err = ValidatedAdfDocument::try_new(d).unwrap_err();
670 let msg = err.to_string();
671 assert!(msg.contains("invalid ADF nesting"));
672 assert!(msg.contains("`table` cannot be a child of `paragraph`"));
673 assert!(msg.contains("hint: restructure the document"));
674 }
675
676 #[test]
677 fn error_display_separates_multiple_violations() {
678 let d = doc(vec![
679 AdfNode::panel("info", vec![AdfNode::expand(None, vec![])]),
680 AdfNode::blockquote(vec![AdfNode::panel("note", vec![])]),
681 ]);
682 let err = ValidatedAdfDocument::try_new(d).unwrap_err();
683 assert!(err.violations.len() >= 2);
684 let msg = err.to_string();
685 assert!(msg.contains("\n\n"));
688 }
689
690 #[test]
698 fn error_display_for_missing_attr_violation() {
699 let err = AdfValidationError::new(vec![AdfSchemaViolation::MissingAttr {
700 node_type: "panel".to_string(),
701 attr_name: "panelType".to_string(),
702 path: vec![0],
703 }]);
704 let msg = err.to_string();
705 assert!(msg.contains("invalid ADF attribute"), "got: {msg}");
706 assert!(msg.contains("'panelType'"), "got: {msg}");
707 assert!(msg.contains("hint:"), "got: {msg}");
708 }
709
710 #[test]
711 fn error_display_for_invalid_attr_violation() {
712 use crate::atlassian::adf_attr_schema::AttrProblem;
713 let err = AdfValidationError::new(vec![AdfSchemaViolation::InvalidAttr {
714 node_type: "heading".to_string(),
715 attr_name: "level".to_string(),
716 problem: AttrProblem::OutOfRange {
717 lo: 1,
718 hi: 6,
719 actual: 7,
720 },
721 path: vec![0],
722 }]);
723 let msg = err.to_string();
724 assert!(msg.contains("invalid ADF attribute"), "got: {msg}");
725 assert!(msg.contains("'heading.level'"), "got: {msg}");
726 }
727
728 #[test]
729 fn error_display_for_disallowed_mark_violation() {
730 let err = AdfValidationError::new(vec![AdfSchemaViolation::DisallowedMark {
731 mark_type: "code".to_string(),
732 parent_type: "heading".to_string(),
733 inline_index: Some(0),
734 path: vec![0],
735 }]);
736 let msg = err.to_string();
737 assert!(msg.contains("invalid ADF mark"), "got: {msg}");
738 assert!(msg.contains("'code' mark"), "got: {msg}");
739 assert!(msg.contains("hint: remove or correct"), "got: {msg}");
740 }
741
742 #[test]
743 fn error_display_for_forbidden_mark_combination() {
744 let err = AdfValidationError::new(vec![AdfSchemaViolation::ForbiddenMarkCombination {
745 mark_type: "strong".to_string(),
746 conflicts_with: "code".to_string(),
747 parent_type: "paragraph".to_string(),
748 inline_index: Some(0),
749 path: vec![0, 0],
750 }]);
751 let msg = err.to_string();
752 assert!(msg.contains("invalid ADF mark combination"), "got: {msg}");
753 assert!(
754 msg.contains("'strong' mark cannot be combined with 'code' mark"),
755 "got: {msg}"
756 );
757 assert!(msg.contains("hint:"), "got: {msg}");
758 }
759
760 #[test]
761 fn try_new_rejects_strong_plus_code_text() {
762 let text = AdfNode {
765 node_type: "text".to_string(),
766 attrs: None,
767 content: None,
768 text: Some("x".to_string()),
769 marks: Some(vec![
770 crate::atlassian::adf::AdfMark::strong(),
771 crate::atlassian::adf::AdfMark::code(),
772 ]),
773 local_id: None,
774 parameters: None,
775 };
776 let d = doc(vec![AdfNode::paragraph(vec![text])]);
777 let err = ValidatedAdfDocument::try_new(d).unwrap_err();
778 assert!(err.violations.iter().any(|v| matches!(
779 v,
780 AdfSchemaViolation::ForbiddenMarkCombination { mark_type, conflicts_with, .. }
781 if mark_type == "strong" && conflicts_with == "code"
782 )));
783 }
784
785 #[test]
786 fn error_display_for_invalid_mark_attr_violation() {
787 use crate::atlassian::adf_attr_schema::AttrProblem;
788 let err = AdfValidationError::new(vec![AdfSchemaViolation::InvalidMarkAttr {
789 mark_type: "link".to_string(),
790 attr_name: "href".to_string(),
791 problem: AttrProblem::BadFormat {
792 reason: "not a valid URL",
793 },
794 inline_index: Some(0),
795 path: vec![0],
796 }]);
797 let msg = err.to_string();
798 assert!(msg.contains("invalid ADF mark"), "got: {msg}");
799 assert!(msg.contains("'link' mark"), "got: {msg}");
800 }
801
802 #[test]
805 fn offset_to_line_col_counts_chars_not_bytes() {
806 let src = "ab\ncd\néx";
809 assert_eq!(
810 offset_to_line_col(src, 0),
811 SourceLocation { line: 1, column: 1 }
812 );
813 assert_eq!(
814 offset_to_line_col(src, 6),
815 SourceLocation { line: 3, column: 1 } );
817 assert_eq!(
818 offset_to_line_col(src, 8),
819 SourceLocation { line: 3, column: 2 } );
821 }
822
823 #[test]
824 fn locate_in_source_reports_first_occurrence() {
825 let loc = locate_in_source("hello\nworld foo", "foo").unwrap();
826 assert_eq!(loc, SourceLocation { line: 2, column: 7 });
827 assert!(locate_in_source("no match here", "absent").is_none());
828 assert!(locate_in_source("some text", " ").is_none());
830 }
831
832 #[test]
833 fn collect_text_stops_at_the_excerpt_budget() {
834 let long = "a".repeat(EXCERPT_MAX_CHARS + 5);
839 let mut node = AdfNode::paragraph(vec![AdfNode::text("SHOULD_NOT_APPEAR")]);
840 node.text = Some(long);
841 let gathered = collect_text(&node);
842 assert!(gathered.chars().count() >= EXCERPT_MAX_CHARS);
843 assert!(!gathered.contains("SHOULD_NOT_APPEAR"), "got: {gathered}");
844 }
845
846 #[test]
847 fn resolve_context_returns_empty_for_off_tree_path() {
848 let d = doc(vec![AdfNode::paragraph(vec![AdfNode::text("hi")])]);
851 let violation = AdfSchemaViolation::DisallowedChild {
852 child_type: "x".to_string(),
853 parent_type: "y".to_string(),
854 path: vec![9, 9],
855 };
856 assert_eq!(
857 resolve_context(&violation, &d, Some("hi")),
858 ViolationContext::default()
859 );
860 }
861
862 #[test]
863 fn node_at_path_resolves_nested_and_rejects_off_tree() {
864 let d = doc(vec![AdfNode::paragraph(vec![AdfNode::text("hi")])]);
865 assert_eq!(node_at_path(&d, &[0]).unwrap().node_type, "paragraph");
866 assert_eq!(
867 node_at_path(&d, &[0, 0]).unwrap().text.as_deref(),
868 Some("hi")
869 );
870 assert!(node_at_path(&d, &[5]).is_none());
871 assert!(node_at_path(&d, &[0, 9]).is_none());
872 }
873
874 #[test]
875 fn truncate_excerpt_shortens_long_runs() {
876 assert_eq!(truncate_excerpt("abc"), "abc");
877 let long = "x".repeat(EXCERPT_MAX_CHARS + 5);
878 let t = truncate_excerpt(&long);
879 assert!(t.ends_with('…'), "got: {t}");
880 assert_eq!(t.chars().count(), EXCERPT_MAX_CHARS + 1);
881 }
882
883 #[test]
884 fn try_new_error_names_offending_text_without_source() {
885 let text = AdfNode {
888 node_type: "text".to_string(),
889 attrs: None,
890 content: None,
891 text: Some("/api/v1/example".to_string()),
892 marks: Some(vec![
893 crate::atlassian::adf::AdfMark::strong(),
894 crate::atlassian::adf::AdfMark::code(),
895 ]),
896 local_id: None,
897 parameters: None,
898 };
899 let d = doc(vec![AdfNode::paragraph(vec![text])]);
900 let err = ValidatedAdfDocument::try_new(d).unwrap_err();
901 let msg = err.to_string();
902 assert!(msg.contains("in text: \"/api/v1/example\""), "got: {msg}");
903 assert!(
904 !msg.contains("at line"),
905 "no source ⇒ no line:col, got: {msg}"
906 );
907 }
908
909 #[test]
910 fn markdown_to_validated_adf_reports_line_column_and_excerpt() {
911 let src = "# Heading\n\nIntro paragraph.\n\nHere is **`/api/v1/example`** in a sentence.\n";
915 let err = markdown_to_validated_adf(src).unwrap_err();
916 let msg = err.to_string();
917 assert!(msg.contains("/api/v1/example"), "excerpt, got: {msg}");
918 assert!(msg.contains("at line 5"), "line, got: {msg}");
919 assert!(msg.contains("column"), "column, got: {msg}");
920 }
921
922 #[test]
923 fn markdown_to_validated_adf_accepts_clean_document() {
924 let v = markdown_to_validated_adf("# Title\n\nA clean paragraph.\n").unwrap();
925 assert!(!v.content.is_empty());
926 }
927}