1use serde::de::{self, MapAccess, Visitor};
4use serde::ser::SerializeMap;
5use serde::{Deserialize, Serialize};
6
7use crate::content::block::MathFormat;
8
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct Text {
30 pub value: String,
32
33 #[serde(default, skip_serializing_if = "Vec::is_empty")]
35 pub marks: Vec<Mark>,
36}
37
38impl Text {
39 #[must_use]
41 pub fn plain(value: impl Into<String>) -> Self {
42 Self {
43 value: value.into(),
44 marks: Vec::new(),
45 }
46 }
47
48 #[must_use]
50 pub fn with_marks(value: impl Into<String>, marks: Vec<Mark>) -> Self {
51 Self {
52 value: value.into(),
53 marks,
54 }
55 }
56
57 #[must_use]
59 pub fn bold(value: impl Into<String>) -> Self {
60 Self::with_marks(value, vec![Mark::Bold])
61 }
62
63 #[must_use]
65 pub fn italic(value: impl Into<String>) -> Self {
66 Self::with_marks(value, vec![Mark::Italic])
67 }
68
69 #[must_use]
71 pub fn code(value: impl Into<String>) -> Self {
72 Self::with_marks(value, vec![Mark::Code])
73 }
74
75 #[must_use]
77 pub fn link(value: impl Into<String>, href: impl Into<String>) -> Self {
78 Self::with_marks(
79 value,
80 vec![Mark::Link {
81 href: href.into(),
82 title: None,
83 }],
84 )
85 }
86
87 #[must_use]
89 pub fn footnote(value: impl Into<String>, number: u32) -> Self {
90 Self::with_marks(value, vec![Mark::Footnote { number, id: None }])
91 }
92
93 #[must_use]
95 pub fn has_marks(&self) -> bool {
96 !self.marks.is_empty()
97 }
98
99 #[must_use]
101 pub fn has_mark(&self, mark_type: MarkType) -> bool {
102 self.marks.iter().any(|m| m.mark_type() == mark_type)
103 }
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum Mark {
118 Bold,
120
121 Italic,
123
124 Underline,
126
127 Strikethrough,
129
130 Code,
132
133 Superscript,
135
136 Subscript,
138
139 Link {
141 href: String,
143
144 title: Option<String>,
146 },
147
148 Anchor {
150 id: String,
152 },
153
154 Footnote {
158 number: u32,
160
161 id: Option<String>,
163 },
164
165 Math {
167 format: MathFormat,
169
170 source: String,
172 },
173
174 Extension(ExtensionMark),
180}
181
182#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
189#[serde(rename_all = "camelCase")]
190pub struct ExtensionMark {
191 pub namespace: String,
193
194 pub mark_type: String,
196
197 #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
199 pub attributes: serde_json::Value,
200}
201
202impl ExtensionMark {
203 #[must_use]
205 pub fn new(namespace: impl Into<String>, mark_type: impl Into<String>) -> Self {
206 Self {
207 namespace: namespace.into(),
208 mark_type: mark_type.into(),
209 attributes: serde_json::Value::Null,
210 }
211 }
212
213 #[must_use]
217 pub fn parse_type(type_str: &str) -> Option<(&str, &str)> {
218 type_str.split_once(':')
219 }
220
221 #[must_use]
223 pub fn full_type(&self) -> String {
224 format!("{}:{}", self.namespace, self.mark_type)
225 }
226
227 #[must_use]
229 pub fn is_namespace(&self, namespace: &str) -> bool {
230 self.namespace == namespace
231 }
232
233 #[must_use]
235 pub fn is_type(&self, namespace: &str, mark_type: &str) -> bool {
236 self.namespace == namespace && self.mark_type == mark_type
237 }
238
239 #[must_use]
241 pub fn with_attributes(mut self, attributes: serde_json::Value) -> Self {
242 self.attributes = attributes;
243 self
244 }
245
246 #[must_use]
248 pub fn get_attribute(&self, key: &str) -> Option<&serde_json::Value> {
249 self.attributes.get(key)
250 }
251
252 #[must_use]
254 pub fn get_string_attribute(&self, key: &str) -> Option<&str> {
255 self.attributes.get(key).and_then(serde_json::Value::as_str)
256 }
257
258 #[must_use]
262 pub fn citation(reference: impl Into<String>) -> Self {
263 Self::new("semantic", "citation").with_attributes(serde_json::json!({
264 "ref": reference.into()
265 }))
266 }
267
268 #[must_use]
270 pub fn citation_with_page(reference: impl Into<String>, page: impl Into<String>) -> Self {
271 Self::new("semantic", "citation").with_attributes(serde_json::json!({
272 "ref": reference.into(),
273 "locator": page.into(),
274 "locatorType": "page"
275 }))
276 }
277
278 #[must_use]
280 pub fn entity(uri: impl Into<String>, entity_type: impl Into<String>) -> Self {
281 Self::new("semantic", "entity").with_attributes(serde_json::json!({
282 "uri": uri.into(),
283 "entityType": entity_type.into()
284 }))
285 }
286
287 #[must_use]
289 pub fn glossary(term_id: impl Into<String>) -> Self {
290 Self::new("semantic", "glossary").with_attributes(serde_json::json!({
291 "termId": term_id.into()
292 }))
293 }
294
295 #[must_use]
297 pub fn index(term: impl Into<String>) -> Self {
298 Self::new("presentation", "index").with_attributes(serde_json::json!({
299 "term": term.into()
300 }))
301 }
302
303 #[must_use]
305 pub fn index_with_subterm(term: impl Into<String>, subterm: impl Into<String>) -> Self {
306 Self::new("presentation", "index").with_attributes(serde_json::json!({
307 "term": term.into(),
308 "subterm": subterm.into()
309 }))
310 }
311
312 #[must_use]
318 pub fn equation_ref(target: impl Into<String>) -> Self {
319 Self::new("academic", "equation-ref").with_attributes(serde_json::json!({
320 "target": target.into()
321 }))
322 }
323
324 #[must_use]
328 pub fn equation_ref_formatted(target: impl Into<String>, format: impl Into<String>) -> Self {
329 Self::new("academic", "equation-ref").with_attributes(serde_json::json!({
330 "target": target.into(),
331 "format": format.into()
332 }))
333 }
334
335 #[must_use]
339 pub fn algorithm_ref(target: impl Into<String>) -> Self {
340 Self::new("academic", "algorithm-ref").with_attributes(serde_json::json!({
341 "target": target.into()
342 }))
343 }
344
345 #[must_use]
349 pub fn algorithm_ref_line(target: impl Into<String>, line: impl Into<String>) -> Self {
350 Self::new("academic", "algorithm-ref").with_attributes(serde_json::json!({
351 "target": target.into(),
352 "line": line.into()
353 }))
354 }
355
356 #[must_use]
360 pub fn algorithm_ref_formatted(target: impl Into<String>, format: impl Into<String>) -> Self {
361 Self::new("academic", "algorithm-ref").with_attributes(serde_json::json!({
362 "target": target.into(),
363 "format": format.into()
364 }))
365 }
366
367 #[must_use]
369 pub fn algorithm_ref_line_formatted(
370 target: impl Into<String>,
371 line: impl Into<String>,
372 format: impl Into<String>,
373 ) -> Self {
374 Self::new("academic", "algorithm-ref").with_attributes(serde_json::json!({
375 "target": target.into(),
376 "line": line.into(),
377 "format": format.into()
378 }))
379 }
380
381 #[must_use]
385 pub fn theorem_ref(target: impl Into<String>) -> Self {
386 Self::new("academic", "theorem-ref").with_attributes(serde_json::json!({
387 "target": target.into()
388 }))
389 }
390
391 #[must_use]
395 pub fn theorem_ref_formatted(target: impl Into<String>, format: impl Into<String>) -> Self {
396 Self::new("academic", "theorem-ref").with_attributes(serde_json::json!({
397 "target": target.into(),
398 "format": format.into()
399 }))
400 }
401
402 #[must_use]
409 pub fn highlight(color: impl Into<String>) -> Self {
410 Self::new("collaboration", "highlight").with_attributes(serde_json::json!({
411 "color": color.into()
412 }))
413 }
414
415 #[must_use]
417 pub fn highlight_yellow() -> Self {
418 Self::highlight("yellow")
419 }
420
421 #[must_use]
425 pub fn highlight_colored(color: impl std::fmt::Display) -> Self {
426 Self::highlight(color.to_string())
427 }
428}
429
430fn infer_mark_namespace(mark_type: &str) -> &'static str {
435 match mark_type {
436 "citation" | "entity" | "glossary" => "semantic",
437 "theorem-ref" | "equation-ref" | "algorithm-ref" => "academic",
438 "cite" => "legal",
439 "highlight" => "collaboration",
440 "index" => "presentation",
441 _ => "",
442 }
443}
444
445impl Serialize for Mark {
446 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
447 match self {
448 Self::Bold => serializer.serialize_str("bold"),
450 Self::Italic => serializer.serialize_str("italic"),
451 Self::Underline => serializer.serialize_str("underline"),
452 Self::Strikethrough => serializer.serialize_str("strikethrough"),
453 Self::Code => serializer.serialize_str("code"),
454 Self::Superscript => serializer.serialize_str("superscript"),
455 Self::Subscript => serializer.serialize_str("subscript"),
456
457 Self::Link { href, title } => {
459 let len = 2 + usize::from(title.is_some());
460 let mut map = serializer.serialize_map(Some(len))?;
461 map.serialize_entry("type", "link")?;
462 map.serialize_entry("href", href)?;
463 if let Some(t) = title {
464 map.serialize_entry("title", t)?;
465 }
466 map.end()
467 }
468 Self::Anchor { id } => {
469 let mut map = serializer.serialize_map(Some(2))?;
470 map.serialize_entry("type", "anchor")?;
471 map.serialize_entry("id", id)?;
472 map.end()
473 }
474 Self::Footnote { number, id } => {
475 let len = 2 + usize::from(id.is_some());
476 let mut map = serializer.serialize_map(Some(len))?;
477 map.serialize_entry("type", "footnote")?;
478 map.serialize_entry("number", number)?;
479 if let Some(i) = id {
480 map.serialize_entry("id", i)?;
481 }
482 map.end()
483 }
484 Self::Math { format, source } => {
485 let mut map = serializer.serialize_map(Some(3))?;
486 map.serialize_entry("type", "math")?;
487 map.serialize_entry("format", format)?;
488 map.serialize_entry("source", source)?;
489 map.end()
490 }
491
492 Self::Extension(ext) => {
494 let type_str = ext.full_type();
495 let attr_count = ext.attributes.as_object().map_or(0, serde_json::Map::len);
496 let mut map = serializer.serialize_map(Some(1 + attr_count))?;
497 map.serialize_entry("type", &type_str)?;
498 if let Some(obj) = ext.attributes.as_object() {
499 for (k, v) in obj {
500 map.serialize_entry(k, v)?;
501 }
502 }
503 map.end()
504 }
505 }
506 }
507}
508
509impl<'de> Deserialize<'de> for Mark {
510 #[allow(clippy::too_many_lines)] fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
512 struct MarkVisitor;
513
514 impl<'de> Visitor<'de> for MarkVisitor {
515 type Value = Mark;
516
517 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
518 formatter.write_str("a string (simple mark) or an object (complex mark)")
519 }
520
521 fn visit_str<E: de::Error>(self, v: &str) -> Result<Mark, E> {
523 match v {
524 "bold" => Ok(Mark::Bold),
525 "italic" => Ok(Mark::Italic),
526 "underline" => Ok(Mark::Underline),
527 "strikethrough" => Ok(Mark::Strikethrough),
528 "code" => Ok(Mark::Code),
529 "superscript" => Ok(Mark::Superscript),
530 "subscript" => Ok(Mark::Subscript),
531 other => {
532 let (ns, mt) = if let Some((ns, mt)) = other.split_once(':') {
534 (ns.to_string(), mt.to_string())
535 } else {
536 (infer_mark_namespace(other).to_string(), other.to_string())
537 };
538 Ok(Mark::Extension(ExtensionMark::new(ns, mt)))
539 }
540 }
541 }
542
543 #[allow(clippy::too_many_lines)] fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Mark, A::Error> {
546 let mut type_str: Option<String> = None;
547 let mut fields = serde_json::Map::new();
548
549 while let Some(key) = map.next_key::<String>()? {
550 if key == "type" {
551 type_str = Some(map.next_value()?);
552 } else {
553 let value: serde_json::Value = map.next_value()?;
554 fields.insert(key, value);
555 }
556 }
557
558 let type_str = type_str.ok_or_else(|| de::Error::missing_field("type"))?;
559
560 match type_str.as_str() {
561 "bold" => Ok(Mark::Bold),
563 "italic" => Ok(Mark::Italic),
564 "underline" => Ok(Mark::Underline),
565 "strikethrough" => Ok(Mark::Strikethrough),
566 "code" => Ok(Mark::Code),
567 "superscript" => Ok(Mark::Superscript),
568 "subscript" => Ok(Mark::Subscript),
569
570 "link" => {
572 let href = fields
573 .get("href")
574 .and_then(serde_json::Value::as_str)
575 .ok_or_else(|| de::Error::missing_field("href"))?
576 .to_string();
577 let title = fields
578 .get("title")
579 .and_then(serde_json::Value::as_str)
580 .map(ToString::to_string);
581 Ok(Mark::Link { href, title })
582 }
583 "anchor" => {
584 let id = fields
585 .get("id")
586 .and_then(serde_json::Value::as_str)
587 .ok_or_else(|| de::Error::missing_field("id"))?
588 .to_string();
589 Ok(Mark::Anchor { id })
590 }
591 "footnote" => {
592 let number = fields
593 .get("number")
594 .and_then(serde_json::Value::as_u64)
595 .ok_or_else(|| de::Error::missing_field("number"))?;
596 let id = fields
597 .get("id")
598 .and_then(serde_json::Value::as_str)
599 .map(ToString::to_string);
600 Ok(Mark::Footnote {
601 number: u32::try_from(number)
602 .map_err(|_| de::Error::custom("footnote number too large"))?,
603 id,
604 })
605 }
606 "math" => {
607 let format_val = fields
608 .get("format")
609 .ok_or_else(|| de::Error::missing_field("format"))?;
610 let format: MathFormat = serde_json::from_value(format_val.clone())
611 .map_err(de::Error::custom)?;
612 let source = fields
614 .get("source")
615 .or_else(|| fields.get("value"))
616 .and_then(serde_json::Value::as_str)
617 .ok_or_else(|| de::Error::missing_field("source"))?
618 .to_string();
619 Ok(Mark::Math { format, source })
620 }
621
622 "extension" => {
624 let namespace = fields
625 .get("namespace")
626 .and_then(serde_json::Value::as_str)
627 .unwrap_or("")
628 .to_string();
629 let mark_type = fields
630 .get("markType")
631 .and_then(serde_json::Value::as_str)
632 .unwrap_or("")
633 .to_string();
634 let attributes = fields
635 .get("attributes")
636 .cloned()
637 .unwrap_or(serde_json::Value::Null);
638 Ok(Mark::Extension(ExtensionMark {
639 namespace,
640 mark_type,
641 attributes,
642 }))
643 }
644
645 other => {
647 let (namespace, mark_type) = if let Some((ns, mt)) = other.split_once(':') {
648 (ns.to_string(), mt.to_string())
649 } else {
650 (infer_mark_namespace(other).to_string(), other.to_string())
651 };
652 let attributes = if fields.is_empty() {
653 serde_json::Value::Null
654 } else {
655 serde_json::Value::Object(fields)
656 };
657 Ok(Mark::Extension(ExtensionMark {
658 namespace,
659 mark_type,
660 attributes,
661 }))
662 }
663 }
664 }
665 }
666
667 deserializer.deserialize_any(MarkVisitor)
668 }
669}
670
671impl Mark {
672 #[must_use]
674 pub fn mark_type(&self) -> MarkType {
675 match self {
676 Self::Bold => MarkType::Bold,
677 Self::Italic => MarkType::Italic,
678 Self::Underline => MarkType::Underline,
679 Self::Strikethrough => MarkType::Strikethrough,
680 Self::Code => MarkType::Code,
681 Self::Superscript => MarkType::Superscript,
682 Self::Subscript => MarkType::Subscript,
683 Self::Link { .. } => MarkType::Link,
684 Self::Anchor { .. } => MarkType::Anchor,
685 Self::Footnote { .. } => MarkType::Footnote,
686 Self::Math { .. } => MarkType::Math,
687 Self::Extension(_) => MarkType::Extension,
688 }
689 }
690
691 #[must_use]
693 pub fn is_extension(&self) -> bool {
694 matches!(self, Self::Extension(_))
695 }
696
697 #[must_use]
699 pub fn as_extension(&self) -> Option<&ExtensionMark> {
700 match self {
701 Self::Extension(ext) => Some(ext),
702 _ => None,
703 }
704 }
705}
706
707#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
709pub enum MarkType {
710 Bold,
712 Italic,
714 Underline,
716 Strikethrough,
718 Code,
720 Superscript,
722 Subscript,
724 Link,
726 Anchor,
728 Footnote,
730 Math,
732 Extension,
734}
735
736#[cfg(test)]
737mod tests {
738 use super::*;
739
740 #[test]
741 fn test_text_plain() {
742 let text = Text::plain("Hello");
743 assert_eq!(text.value, "Hello");
744 assert!(text.marks.is_empty());
745 assert!(!text.has_marks());
746 }
747
748 #[test]
749 fn test_text_bold() {
750 let text = Text::bold("Important");
751 assert_eq!(text.marks, vec![Mark::Bold]);
752 assert!(text.has_marks());
753 assert!(text.has_mark(MarkType::Bold));
754 assert!(!text.has_mark(MarkType::Italic));
755 }
756
757 #[test]
758 fn test_text_link() {
759 let text = Text::link("Click", "https://example.com");
760 assert!(text.has_mark(MarkType::Link));
761 if let Mark::Link { href, title } = &text.marks[0] {
762 assert_eq!(href, "https://example.com");
763 assert!(title.is_none());
764 } else {
765 panic!("Expected Link mark");
766 }
767 }
768
769 #[test]
770 fn test_text_serialization() {
771 let text = Text::bold("Test");
772 let json = serde_json::to_string(&text).unwrap();
773 assert!(json.contains("\"value\":\"Test\""));
774 assert!(json.contains("\"bold\""));
776 }
777
778 #[test]
779 fn test_text_deserialization() {
780 let json = r#"{"value":"Test","marks":["bold","italic"]}"#;
782 let text: Text = serde_json::from_str(json).unwrap();
783 assert_eq!(text.value, "Test");
784 assert_eq!(text.marks.len(), 2);
785 assert_eq!(text.marks[0], Mark::Bold);
786 assert_eq!(text.marks[1], Mark::Italic);
787 }
788
789 #[test]
790 fn test_text_deserialization_object_format() {
791 let json = r#"{"value":"Test","marks":[{"type":"bold"},{"type":"italic"}]}"#;
793 let text: Text = serde_json::from_str(json).unwrap();
794 assert_eq!(text.value, "Test");
795 assert_eq!(text.marks.len(), 2);
796 assert_eq!(text.marks[0], Mark::Bold);
797 assert_eq!(text.marks[1], Mark::Italic);
798 }
799
800 #[test]
801 fn test_link_with_title() {
802 let json = r#"{"type":"link","href":"https://example.com","title":"Example"}"#;
803 let mark: Mark = serde_json::from_str(json).unwrap();
804 if let Mark::Link { href, title } = mark {
805 assert_eq!(href, "https://example.com");
806 assert_eq!(title, Some("Example".to_string()));
807 } else {
808 panic!("Expected Link mark");
809 }
810 }
811
812 #[test]
813 fn test_text_footnote() {
814 let text = Text::footnote("important claim", 1);
815 assert!(text.has_mark(MarkType::Footnote));
816 if let Mark::Footnote { number, id } = &text.marks[0] {
817 assert_eq!(*number, 1);
818 assert!(id.is_none());
819 } else {
820 panic!("Expected Footnote mark");
821 }
822 }
823
824 #[test]
825 fn test_footnote_mark_serialization() {
826 let mark = Mark::Footnote {
827 number: 1,
828 id: Some("fn1".to_string()),
829 };
830 let json = serde_json::to_string(&mark).unwrap();
831 assert!(json.contains("\"type\":\"footnote\""));
832 assert!(json.contains("\"number\":1"));
833 assert!(json.contains("\"id\":\"fn1\""));
834 }
835
836 #[test]
837 fn test_footnote_mark_deserialization() {
838 let json = r#"{"type":"footnote","number":2,"id":"fn-2"}"#;
839 let mark: Mark = serde_json::from_str(json).unwrap();
840 if let Mark::Footnote { number, id } = mark {
841 assert_eq!(number, 2);
842 assert_eq!(id, Some("fn-2".to_string()));
843 } else {
844 panic!("Expected Footnote mark");
845 }
846 }
847
848 #[test]
849 fn test_footnote_mark_without_id() {
850 let json = r#"{"type":"footnote","number":3}"#;
851 let mark: Mark = serde_json::from_str(json).unwrap();
852 if let Mark::Footnote { number, id } = mark {
853 assert_eq!(number, 3);
854 assert!(id.is_none());
855 } else {
856 panic!("Expected Footnote mark");
857 }
858 }
859
860 #[test]
861 fn test_math_mark() {
862 use crate::content::block::MathFormat;
863
864 let mark = Mark::Math {
865 format: MathFormat::Latex,
866 source: "E = mc^2".to_string(),
867 };
868 assert_eq!(mark.mark_type(), MarkType::Math);
869 }
870
871 #[test]
872 fn test_math_mark_serialization() {
873 use crate::content::block::MathFormat;
874
875 let mark = Mark::Math {
876 format: MathFormat::Latex,
877 source: "\\frac{1}{2}".to_string(),
878 };
879 let json = serde_json::to_string(&mark).unwrap();
880 assert!(json.contains("\"type\":\"math\""));
881 assert!(json.contains("\"format\":\"latex\""));
882 assert!(json.contains("\"source\":\"\\\\frac{1}{2}\""));
883 }
884
885 #[test]
886 fn test_math_mark_deserialization() {
887 use crate::content::block::MathFormat;
888
889 let json = r#"{"type":"math","format":"mathml","source":"<math>...</math>"}"#;
890 let mark: Mark = serde_json::from_str(json).unwrap();
891 if let Mark::Math { format, source } = mark {
892 assert_eq!(format, MathFormat::Mathml);
893 assert_eq!(source, "<math>...</math>");
894 } else {
895 panic!("Expected Math mark");
896 }
897 }
898
899 #[test]
900 fn test_text_with_math_mark() {
901 use crate::content::block::MathFormat;
902
903 let text = Text::with_marks(
904 "x²",
905 vec![Mark::Math {
906 format: MathFormat::Latex,
907 source: "x^2".to_string(),
908 }],
909 );
910 assert!(text.has_mark(MarkType::Math));
911 }
912
913 #[test]
916 fn test_extension_mark_new() {
917 let ext = ExtensionMark::new("semantic", "citation");
918 assert_eq!(ext.namespace, "semantic");
919 assert_eq!(ext.mark_type, "citation");
920 assert_eq!(ext.full_type(), "semantic:citation");
921 }
922
923 #[test]
924 fn test_extension_mark_parse_type() {
925 assert_eq!(
926 ExtensionMark::parse_type("semantic:citation"),
927 Some(("semantic", "citation"))
928 );
929 assert_eq!(
930 ExtensionMark::parse_type("legal:cite"),
931 Some(("legal", "cite"))
932 );
933 assert_eq!(ExtensionMark::parse_type("bold"), None);
934 }
935
936 #[test]
937 fn test_extension_mark_with_attributes() {
938 let ext = ExtensionMark::new("semantic", "citation").with_attributes(serde_json::json!({
939 "ref": "smith2023",
940 "page": "42"
941 }));
942
943 assert_eq!(ext.get_string_attribute("ref"), Some("smith2023"));
944 assert_eq!(ext.get_string_attribute("page"), Some("42"));
945 }
946
947 #[test]
948 fn test_extension_mark_namespace_check() {
949 let ext = ExtensionMark::new("semantic", "citation");
950 assert!(ext.is_namespace("semantic"));
951 assert!(!ext.is_namespace("legal"));
952 assert!(ext.is_type("semantic", "citation"));
953 assert!(!ext.is_type("semantic", "entity"));
954 }
955
956 #[test]
957 fn test_mark_extension_variant() {
958 let ext = ExtensionMark::new("semantic", "citation");
959 let mark = Mark::Extension(ext.clone());
960
961 assert!(mark.is_extension());
962 assert_eq!(mark.mark_type(), MarkType::Extension);
963 assert_eq!(
964 mark.as_extension().unwrap().full_type(),
965 "semantic:citation"
966 );
967 }
968
969 #[test]
970 fn test_extension_mark_serialization() {
971 let ext = ExtensionMark::new("semantic", "citation").with_attributes(serde_json::json!({
972 "ref": "smith2023"
973 }));
974 let mark = Mark::Extension(ext);
975
976 let json = serde_json::to_string(&mark).unwrap();
977 assert!(json.contains("\"type\":\"semantic:citation\""));
979 assert!(json.contains("\"ref\":\"smith2023\""));
980 assert!(!json.contains("\"namespace\""));
982 assert!(!json.contains("\"markType\""));
983 }
984
985 #[test]
986 fn test_extension_mark_deserialization_new_format() {
987 let json = r#"{
989 "type": "legal:cite",
990 "citation": "Brown v. Board of Education"
991 }"#;
992 let mark: Mark = serde_json::from_str(json).unwrap();
993
994 if let Mark::Extension(ext) = mark {
995 assert_eq!(ext.namespace, "legal");
996 assert_eq!(ext.mark_type, "cite");
997 assert_eq!(
998 ext.get_string_attribute("citation"),
999 Some("Brown v. Board of Education")
1000 );
1001 } else {
1002 panic!("Expected Extension mark");
1003 }
1004 }
1005
1006 #[test]
1007 fn test_extension_mark_deserialization_old_format() {
1008 let json = r#"{
1010 "type": "extension",
1011 "namespace": "legal",
1012 "markType": "cite",
1013 "attributes": {
1014 "citation": "Brown v. Board of Education"
1015 }
1016 }"#;
1017 let mark: Mark = serde_json::from_str(json).unwrap();
1018
1019 if let Mark::Extension(ext) = mark {
1020 assert_eq!(ext.namespace, "legal");
1021 assert_eq!(ext.mark_type, "cite");
1022 assert_eq!(
1023 ext.get_string_attribute("citation"),
1024 Some("Brown v. Board of Education")
1025 );
1026 } else {
1027 panic!("Expected Extension mark");
1028 }
1029 }
1030
1031 #[test]
1032 fn test_text_with_extension_mark() {
1033 let mark = Mark::Extension(ExtensionMark::citation("smith2023"));
1034 let text = Text::with_marks("important claim", vec![mark]);
1035
1036 assert!(text.has_mark(MarkType::Extension));
1037 if let Mark::Extension(ext) = &text.marks[0] {
1038 assert_eq!(ext.namespace, "semantic");
1039 assert_eq!(ext.mark_type, "citation");
1040 } else {
1041 panic!("Expected Extension mark");
1042 }
1043 }
1044
1045 #[test]
1046 fn test_citation_convenience() {
1047 let ext = ExtensionMark::citation("smith2023");
1048 assert!(ext.is_type("semantic", "citation"));
1049 assert_eq!(ext.get_string_attribute("ref"), Some("smith2023"));
1050 }
1051
1052 #[test]
1053 fn test_citation_with_page_convenience() {
1054 let ext = ExtensionMark::citation_with_page("smith2023", "42-45");
1055 assert!(ext.is_type("semantic", "citation"));
1056 assert_eq!(ext.get_string_attribute("ref"), Some("smith2023"));
1057 assert_eq!(ext.get_string_attribute("locator"), Some("42-45"));
1058 assert_eq!(ext.get_string_attribute("locatorType"), Some("page"));
1059 }
1060
1061 #[test]
1062 fn test_entity_convenience() {
1063 let ext = ExtensionMark::entity("https://www.wikidata.org/wiki/Q937", "person");
1064 assert!(ext.is_type("semantic", "entity"));
1065 assert_eq!(
1066 ext.get_string_attribute("uri"),
1067 Some("https://www.wikidata.org/wiki/Q937")
1068 );
1069 assert_eq!(ext.get_string_attribute("entityType"), Some("person"));
1070 }
1071
1072 #[test]
1073 fn test_glossary_convenience() {
1074 let ext = ExtensionMark::glossary("api-term");
1075 assert!(ext.is_type("semantic", "glossary"));
1076 assert_eq!(ext.get_string_attribute("termId"), Some("api-term"));
1077 }
1078
1079 #[test]
1080 fn test_index_convenience() {
1081 let ext = ExtensionMark::index("algorithm");
1082 assert!(ext.is_type("presentation", "index"));
1083 assert_eq!(ext.get_string_attribute("term"), Some("algorithm"));
1084 }
1085
1086 #[test]
1087 fn test_index_with_subterm_convenience() {
1088 let ext = ExtensionMark::index_with_subterm("algorithm", "sorting");
1089 assert!(ext.is_type("presentation", "index"));
1090 assert_eq!(ext.get_string_attribute("term"), Some("algorithm"));
1091 assert_eq!(ext.get_string_attribute("subterm"), Some("sorting"));
1092 }
1093
1094 #[test]
1095 fn test_non_extension_mark_as_extension() {
1096 let mark = Mark::Bold;
1097 assert!(!mark.is_extension());
1098 assert!(mark.as_extension().is_none());
1099 }
1100
1101 #[test]
1102 fn test_equation_ref_convenience() {
1103 let ext = ExtensionMark::equation_ref("#eq-pythagoras");
1104 assert!(ext.is_type("academic", "equation-ref"));
1105 assert_eq!(ext.get_string_attribute("target"), Some("#eq-pythagoras"));
1106 assert!(ext.get_string_attribute("format").is_none());
1107 }
1108
1109 #[test]
1110 fn test_equation_ref_formatted_convenience() {
1111 let ext = ExtensionMark::equation_ref_formatted("#eq-1", "Equation ({number})");
1112 assert!(ext.is_type("academic", "equation-ref"));
1113 assert_eq!(ext.get_string_attribute("target"), Some("#eq-1"));
1114 assert_eq!(
1115 ext.get_string_attribute("format"),
1116 Some("Equation ({number})")
1117 );
1118 }
1119
1120 #[test]
1121 fn test_algorithm_ref_convenience() {
1122 let ext = ExtensionMark::algorithm_ref("#alg-quicksort");
1123 assert!(ext.is_type("academic", "algorithm-ref"));
1124 assert_eq!(ext.get_string_attribute("target"), Some("#alg-quicksort"));
1125 assert!(ext.get_string_attribute("line").is_none());
1126 }
1127
1128 #[test]
1129 fn test_algorithm_ref_line_convenience() {
1130 let ext = ExtensionMark::algorithm_ref_line("#alg-bisection", "loop");
1131 assert!(ext.is_type("academic", "algorithm-ref"));
1132 assert_eq!(ext.get_string_attribute("target"), Some("#alg-bisection"));
1133 assert_eq!(ext.get_string_attribute("line"), Some("loop"));
1134 }
1135
1136 #[test]
1137 fn test_algorithm_ref_formatted_convenience() {
1138 let ext = ExtensionMark::algorithm_ref_formatted("#alg-1", "Algorithm {number}");
1139 assert!(ext.is_type("academic", "algorithm-ref"));
1140 assert_eq!(ext.get_string_attribute("target"), Some("#alg-1"));
1141 assert_eq!(
1142 ext.get_string_attribute("format"),
1143 Some("Algorithm {number}")
1144 );
1145 }
1146
1147 #[test]
1148 fn test_algorithm_ref_line_formatted_convenience() {
1149 let ext = ExtensionMark::algorithm_ref_line_formatted("#alg-1", "pivot", "line {line}");
1150 assert!(ext.is_type("academic", "algorithm-ref"));
1151 assert_eq!(ext.get_string_attribute("target"), Some("#alg-1"));
1152 assert_eq!(ext.get_string_attribute("line"), Some("pivot"));
1153 assert_eq!(ext.get_string_attribute("format"), Some("line {line}"));
1154 }
1155
1156 #[test]
1157 fn test_theorem_ref_convenience() {
1158 let ext = ExtensionMark::theorem_ref("#thm-pythagoras");
1159 assert!(ext.is_type("academic", "theorem-ref"));
1160 assert_eq!(ext.get_string_attribute("target"), Some("#thm-pythagoras"));
1161 }
1162
1163 #[test]
1164 fn test_theorem_ref_formatted_convenience() {
1165 let ext = ExtensionMark::theorem_ref_formatted("#thm-1", "{variant} {number}");
1166 assert!(ext.is_type("academic", "theorem-ref"));
1167 assert_eq!(ext.get_string_attribute("target"), Some("#thm-1"));
1168 assert_eq!(
1169 ext.get_string_attribute("format"),
1170 Some("{variant} {number}")
1171 );
1172 }
1173
1174 #[test]
1175 fn test_highlight_mark_convenience() {
1176 let ext = ExtensionMark::highlight("yellow");
1177 assert!(ext.is_type("collaboration", "highlight"));
1178 assert_eq!(ext.get_string_attribute("color"), Some("yellow"));
1179 }
1180
1181 #[test]
1182 fn test_highlight_yellow_convenience() {
1183 let ext = ExtensionMark::highlight_yellow();
1184 assert!(ext.is_type("collaboration", "highlight"));
1185 assert_eq!(ext.get_string_attribute("color"), Some("yellow"));
1186 }
1187
1188 #[test]
1189 fn test_highlight_colored_convenience() {
1190 let ext = ExtensionMark::highlight_colored("green");
1192 assert!(ext.is_type("collaboration", "highlight"));
1193 assert_eq!(ext.get_string_attribute("color"), Some("green"));
1194 }
1195}
1196
1197#[cfg(test)]
1198mod proptests {
1199 use super::*;
1200 use proptest::prelude::*;
1201
1202 fn arb_text_value() -> impl Strategy<Value = String> {
1204 "[a-zA-Z0-9 .,!?]{0,100}".prop_map(|s| s)
1205 }
1206
1207 fn arb_url() -> impl Strategy<Value = String> {
1209 "(https?://[a-z]{3,10}\\.[a-z]{2,4}(/[a-z0-9]{0,10})?)".prop_map(|s| s)
1210 }
1211
1212 fn arb_simple_mark() -> impl Strategy<Value = Mark> {
1214 prop_oneof![
1215 Just(Mark::Bold),
1216 Just(Mark::Italic),
1217 Just(Mark::Underline),
1218 Just(Mark::Strikethrough),
1219 Just(Mark::Code),
1220 Just(Mark::Superscript),
1221 Just(Mark::Subscript),
1222 ]
1223 }
1224
1225 fn arb_link_mark() -> impl Strategy<Value = Mark> {
1227 (arb_url(), prop::option::of("[a-zA-Z ]{0,20}"))
1228 .prop_map(|(href, title)| Mark::Link { href, title })
1229 }
1230
1231 fn arb_footnote_mark() -> impl Strategy<Value = Mark> {
1233 (1u32..1000u32, prop::option::of("[a-z]{2,10}"))
1234 .prop_map(|(number, id)| Mark::Footnote { number, id })
1235 }
1236
1237 fn arb_mark() -> impl Strategy<Value = Mark> {
1239 prop_oneof![arb_simple_mark(), arb_link_mark(), arb_footnote_mark(),]
1240 }
1241
1242 fn arb_text() -> impl Strategy<Value = Text> {
1244 (arb_text_value(), prop::collection::vec(arb_mark(), 0..3))
1245 .prop_map(|(value, marks)| Text { value, marks })
1246 }
1247
1248 proptest! {
1249 #[test]
1251 fn plain_text_no_marks(value in arb_text_value()) {
1252 let text = Text::plain(&value);
1253 prop_assert_eq!(&text.value, &value);
1254 prop_assert!(text.marks.is_empty());
1255 prop_assert!(!text.has_marks());
1256 }
1257
1258 #[test]
1260 fn bold_text_has_bold_mark(value in arb_text_value()) {
1261 let text = Text::bold(&value);
1262 prop_assert_eq!(&text.value, &value);
1263 prop_assert_eq!(text.marks.len(), 1);
1264 prop_assert!(text.has_mark(MarkType::Bold));
1265 }
1266
1267 #[test]
1269 fn italic_text_has_italic_mark(value in arb_text_value()) {
1270 let text = Text::italic(&value);
1271 prop_assert_eq!(&text.value, &value);
1272 prop_assert_eq!(text.marks.len(), 1);
1273 prop_assert!(text.has_mark(MarkType::Italic));
1274 }
1275
1276 #[test]
1278 fn code_text_has_code_mark(value in arb_text_value()) {
1279 let text = Text::code(&value);
1280 prop_assert_eq!(&text.value, &value);
1281 prop_assert_eq!(text.marks.len(), 1);
1282 prop_assert!(text.has_mark(MarkType::Code));
1283 }
1284
1285 #[test]
1287 fn link_text_has_link_mark(value in arb_text_value(), href in arb_url()) {
1288 let text = Text::link(&value, &href);
1289 prop_assert_eq!(&text.value, &value);
1290 prop_assert_eq!(text.marks.len(), 1);
1291 prop_assert!(text.has_mark(MarkType::Link));
1292 if let Mark::Link { href: actual_href, .. } = &text.marks[0] {
1293 prop_assert_eq!(actual_href, &href);
1294 }
1295 }
1296
1297 #[test]
1299 fn text_json_roundtrip(text in arb_text()) {
1300 let json = serde_json::to_string(&text).unwrap();
1301 let parsed: Text = serde_json::from_str(&json).unwrap();
1302 prop_assert_eq!(text, parsed);
1303 }
1304
1305 #[test]
1307 fn mark_json_roundtrip(mark in arb_mark()) {
1308 let json = serde_json::to_string(&mark).unwrap();
1309 let parsed: Mark = serde_json::from_str(&json).unwrap();
1310 prop_assert_eq!(mark, parsed);
1311 }
1312
1313 #[test]
1315 fn simple_mark_types(mark in arb_simple_mark()) {
1316 let expected = match mark {
1317 Mark::Bold => MarkType::Bold,
1318 Mark::Italic => MarkType::Italic,
1319 Mark::Underline => MarkType::Underline,
1320 Mark::Strikethrough => MarkType::Strikethrough,
1321 Mark::Code => MarkType::Code,
1322 Mark::Superscript => MarkType::Superscript,
1323 Mark::Subscript => MarkType::Subscript,
1324 Mark::Link { .. }
1325 | Mark::Anchor { .. }
1326 | Mark::Footnote { .. }
1327 | Mark::Math { .. }
1328 | Mark::Extension(_) => {
1329 prop_assert!(false, "arb_simple_mark() generated non-simple mark: {mark:?}");
1331 return Ok(());
1332 }
1333 };
1334 prop_assert_eq!(mark.mark_type(), expected);
1335 }
1336
1337 #[test]
1339 fn link_mark_type(mark in arb_link_mark()) {
1340 prop_assert_eq!(mark.mark_type(), MarkType::Link);
1341 }
1342
1343 #[test]
1345 fn footnote_mark_type(mark in arb_footnote_mark()) {
1346 prop_assert_eq!(mark.mark_type(), MarkType::Footnote);
1347 }
1348
1349 #[test]
1351 fn has_marks_consistent(text in arb_text()) {
1352 prop_assert_eq!(text.has_marks(), !text.marks.is_empty());
1353 }
1354 }
1355}