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]
260 pub fn get_string_array_attribute(&self, key: &str) -> Option<Vec<&str>> {
261 self.attributes.get(key).and_then(|v| {
262 v.as_array()
263 .map(|arr| arr.iter().filter_map(serde_json::Value::as_str).collect())
264 })
265 }
266
267 #[must_use]
271 pub fn get_citation_refs(&self) -> Option<Vec<&str>> {
272 if let Some(refs) = self.get_string_array_attribute("refs") {
274 return Some(refs);
275 }
276 self.get_string_attribute("ref").map(|r| vec![r])
278 }
279
280 pub fn normalize_citation_attrs(&mut self) {
284 if let Some(obj) = self.attributes.as_object_mut() {
285 if obj.contains_key("refs") {
286 return;
287 }
288 if let Some(single) = obj.remove("ref") {
289 if let Some(s) = single.as_str() {
290 obj.insert(
291 "refs".to_string(),
292 serde_json::Value::Array(vec![serde_json::Value::String(s.to_string())]),
293 );
294 } else {
295 obj.insert("ref".to_string(), single);
297 }
298 }
299 }
300 }
301
302 #[must_use]
306 pub fn citation(reference: impl Into<String>) -> Self {
307 Self::new("semantic", "citation").with_attributes(serde_json::json!({
308 "refs": [reference.into()]
309 }))
310 }
311
312 #[must_use]
314 pub fn citation_with_page(reference: impl Into<String>, page: impl Into<String>) -> Self {
315 Self::new("semantic", "citation").with_attributes(serde_json::json!({
316 "refs": [reference.into()],
317 "locator": page.into(),
318 "locatorType": "page"
319 }))
320 }
321
322 #[must_use]
324 pub fn multi_citation(refs: &[String]) -> Self {
325 Self::new("semantic", "citation").with_attributes(serde_json::json!({
326 "refs": refs
327 }))
328 }
329
330 #[must_use]
332 pub fn entity(uri: impl Into<String>, entity_type: impl Into<String>) -> Self {
333 Self::new("semantic", "entity").with_attributes(serde_json::json!({
334 "uri": uri.into(),
335 "entityType": entity_type.into()
336 }))
337 }
338
339 #[must_use]
341 pub fn glossary(term_id: impl Into<String>) -> Self {
342 Self::new("semantic", "glossary").with_attributes(serde_json::json!({
343 "termId": term_id.into()
344 }))
345 }
346
347 #[must_use]
349 pub fn index(term: impl Into<String>) -> Self {
350 Self::new("presentation", "index").with_attributes(serde_json::json!({
351 "term": term.into()
352 }))
353 }
354
355 #[must_use]
357 pub fn index_with_subterm(term: impl Into<String>, subterm: impl Into<String>) -> Self {
358 Self::new("presentation", "index").with_attributes(serde_json::json!({
359 "term": term.into(),
360 "subterm": subterm.into()
361 }))
362 }
363
364 #[must_use]
370 pub fn equation_ref(target: impl Into<String>) -> Self {
371 Self::new("academic", "equation-ref").with_attributes(serde_json::json!({
372 "target": target.into()
373 }))
374 }
375
376 #[must_use]
380 pub fn equation_ref_formatted(target: impl Into<String>, format: impl Into<String>) -> Self {
381 Self::new("academic", "equation-ref").with_attributes(serde_json::json!({
382 "target": target.into(),
383 "format": format.into()
384 }))
385 }
386
387 #[must_use]
391 pub fn algorithm_ref(target: impl Into<String>) -> Self {
392 Self::new("academic", "algorithm-ref").with_attributes(serde_json::json!({
393 "target": target.into()
394 }))
395 }
396
397 #[must_use]
401 pub fn algorithm_ref_line(target: impl Into<String>, line: impl Into<String>) -> Self {
402 Self::new("academic", "algorithm-ref").with_attributes(serde_json::json!({
403 "target": target.into(),
404 "line": line.into()
405 }))
406 }
407
408 #[must_use]
412 pub fn algorithm_ref_formatted(target: impl Into<String>, format: impl Into<String>) -> Self {
413 Self::new("academic", "algorithm-ref").with_attributes(serde_json::json!({
414 "target": target.into(),
415 "format": format.into()
416 }))
417 }
418
419 #[must_use]
421 pub fn algorithm_ref_line_formatted(
422 target: impl Into<String>,
423 line: impl Into<String>,
424 format: impl Into<String>,
425 ) -> Self {
426 Self::new("academic", "algorithm-ref").with_attributes(serde_json::json!({
427 "target": target.into(),
428 "line": line.into(),
429 "format": format.into()
430 }))
431 }
432
433 #[must_use]
437 pub fn theorem_ref(target: impl Into<String>) -> Self {
438 Self::new("academic", "theorem-ref").with_attributes(serde_json::json!({
439 "target": target.into()
440 }))
441 }
442
443 #[must_use]
447 pub fn theorem_ref_formatted(target: impl Into<String>, format: impl Into<String>) -> Self {
448 Self::new("academic", "theorem-ref").with_attributes(serde_json::json!({
449 "target": target.into(),
450 "format": format.into()
451 }))
452 }
453
454 #[must_use]
461 pub fn highlight(color: impl Into<String>) -> Self {
462 Self::new("collaboration", "highlight").with_attributes(serde_json::json!({
463 "color": color.into()
464 }))
465 }
466
467 #[must_use]
469 pub fn highlight_yellow() -> Self {
470 Self::highlight("yellow")
471 }
472
473 #[must_use]
477 pub fn highlight_colored(color: impl std::fmt::Display) -> Self {
478 Self::highlight(color.to_string())
479 }
480}
481
482fn infer_mark_namespace(mark_type: &str) -> &'static str {
487 match mark_type {
488 "citation" | "entity" | "glossary" => "semantic",
489 "theorem-ref" | "equation-ref" | "algorithm-ref" => "academic",
490 "cite" => "legal",
491 "highlight" => "collaboration",
492 "index" => "presentation",
493 _ => "",
494 }
495}
496
497impl Serialize for Mark {
498 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
499 match self {
500 Self::Bold => serializer.serialize_str("bold"),
502 Self::Italic => serializer.serialize_str("italic"),
503 Self::Underline => serializer.serialize_str("underline"),
504 Self::Strikethrough => serializer.serialize_str("strikethrough"),
505 Self::Code => serializer.serialize_str("code"),
506 Self::Superscript => serializer.serialize_str("superscript"),
507 Self::Subscript => serializer.serialize_str("subscript"),
508
509 Self::Link { href, title } => {
511 let len = 2 + usize::from(title.is_some());
512 let mut map = serializer.serialize_map(Some(len))?;
513 map.serialize_entry("type", "link")?;
514 map.serialize_entry("href", href)?;
515 if let Some(t) = title {
516 map.serialize_entry("title", t)?;
517 }
518 map.end()
519 }
520 Self::Anchor { id } => {
521 let mut map = serializer.serialize_map(Some(2))?;
522 map.serialize_entry("type", "anchor")?;
523 map.serialize_entry("id", id)?;
524 map.end()
525 }
526 Self::Footnote { number, id } => {
527 let len = 2 + usize::from(id.is_some());
528 let mut map = serializer.serialize_map(Some(len))?;
529 map.serialize_entry("type", "footnote")?;
530 map.serialize_entry("number", number)?;
531 if let Some(i) = id {
532 map.serialize_entry("id", i)?;
533 }
534 map.end()
535 }
536 Self::Math { format, source } => {
537 let mut map = serializer.serialize_map(Some(3))?;
538 map.serialize_entry("type", "math")?;
539 map.serialize_entry("format", format)?;
540 map.serialize_entry("source", source)?;
541 map.end()
542 }
543
544 Self::Extension(ext) => {
546 let type_str = ext.full_type();
547 let attr_count = ext.attributes.as_object().map_or(0, serde_json::Map::len);
548 let mut map = serializer.serialize_map(Some(1 + attr_count))?;
549 map.serialize_entry("type", &type_str)?;
550 if let Some(obj) = ext.attributes.as_object() {
551 for (k, v) in obj {
552 map.serialize_entry(k, v)?;
553 }
554 }
555 map.end()
556 }
557 }
558 }
559}
560
561impl<'de> Deserialize<'de> for Mark {
562 #[allow(clippy::too_many_lines)] fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
564 struct MarkVisitor;
565
566 impl<'de> Visitor<'de> for MarkVisitor {
567 type Value = Mark;
568
569 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
570 formatter.write_str("a string (simple mark) or an object (complex mark)")
571 }
572
573 fn visit_str<E: de::Error>(self, v: &str) -> Result<Mark, E> {
575 match v {
576 "bold" => Ok(Mark::Bold),
577 "italic" => Ok(Mark::Italic),
578 "underline" => Ok(Mark::Underline),
579 "strikethrough" => Ok(Mark::Strikethrough),
580 "code" => Ok(Mark::Code),
581 "superscript" => Ok(Mark::Superscript),
582 "subscript" => Ok(Mark::Subscript),
583 other => {
584 let (ns, mt) = if let Some((ns, mt)) = other.split_once(':') {
586 (ns.to_string(), mt.to_string())
587 } else {
588 (infer_mark_namespace(other).to_string(), other.to_string())
589 };
590 Ok(Mark::Extension(ExtensionMark::new(ns, mt)))
591 }
592 }
593 }
594
595 #[allow(clippy::too_many_lines)] fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Mark, A::Error> {
598 let mut type_str: Option<String> = None;
599 let mut fields = serde_json::Map::new();
600
601 while let Some(key) = map.next_key::<String>()? {
602 if key == "type" {
603 type_str = Some(map.next_value()?);
604 } else {
605 let value: serde_json::Value = map.next_value()?;
606 fields.insert(key, value);
607 }
608 }
609
610 let type_str = type_str.ok_or_else(|| de::Error::missing_field("type"))?;
611
612 match type_str.as_str() {
613 "bold" => Ok(Mark::Bold),
615 "italic" => Ok(Mark::Italic),
616 "underline" => Ok(Mark::Underline),
617 "strikethrough" => Ok(Mark::Strikethrough),
618 "code" => Ok(Mark::Code),
619 "superscript" => Ok(Mark::Superscript),
620 "subscript" => Ok(Mark::Subscript),
621
622 "link" => {
624 let href = fields
625 .get("href")
626 .and_then(serde_json::Value::as_str)
627 .ok_or_else(|| de::Error::missing_field("href"))?
628 .to_string();
629 let title = fields
630 .get("title")
631 .and_then(serde_json::Value::as_str)
632 .map(ToString::to_string);
633 Ok(Mark::Link { href, title })
634 }
635 "anchor" => {
636 let id = fields
637 .get("id")
638 .and_then(serde_json::Value::as_str)
639 .ok_or_else(|| de::Error::missing_field("id"))?
640 .to_string();
641 Ok(Mark::Anchor { id })
642 }
643 "footnote" => {
644 let number = fields
645 .get("number")
646 .and_then(serde_json::Value::as_u64)
647 .ok_or_else(|| de::Error::missing_field("number"))?;
648 let id = fields
649 .get("id")
650 .and_then(serde_json::Value::as_str)
651 .map(ToString::to_string);
652 Ok(Mark::Footnote {
653 number: u32::try_from(number)
654 .map_err(|_| de::Error::custom("footnote number too large"))?,
655 id,
656 })
657 }
658 "math" => {
659 let format_val = fields
660 .get("format")
661 .ok_or_else(|| de::Error::missing_field("format"))?;
662 let format: MathFormat = serde_json::from_value(format_val.clone())
663 .map_err(de::Error::custom)?;
664 let source = fields
666 .get("source")
667 .or_else(|| fields.get("value"))
668 .and_then(serde_json::Value::as_str)
669 .ok_or_else(|| de::Error::missing_field("source"))?
670 .to_string();
671 Ok(Mark::Math { format, source })
672 }
673
674 "extension" => {
676 let namespace = fields
677 .get("namespace")
678 .and_then(serde_json::Value::as_str)
679 .unwrap_or("")
680 .to_string();
681 let mark_type = fields
682 .get("markType")
683 .and_then(serde_json::Value::as_str)
684 .unwrap_or("")
685 .to_string();
686 let attributes = fields
687 .get("attributes")
688 .cloned()
689 .unwrap_or(serde_json::Value::Null);
690 Ok(Mark::Extension(ExtensionMark {
691 namespace,
692 mark_type,
693 attributes,
694 }))
695 }
696
697 other => {
699 let (namespace, mark_type) = if let Some((ns, mt)) = other.split_once(':') {
700 (ns.to_string(), mt.to_string())
701 } else {
702 (infer_mark_namespace(other).to_string(), other.to_string())
703 };
704 let attributes = if fields.is_empty() {
705 serde_json::Value::Null
706 } else {
707 serde_json::Value::Object(fields)
708 };
709 Ok(Mark::Extension(ExtensionMark {
710 namespace,
711 mark_type,
712 attributes,
713 }))
714 }
715 }
716 }
717 }
718
719 deserializer.deserialize_any(MarkVisitor)
720 }
721}
722
723impl Mark {
724 #[must_use]
726 pub fn mark_type(&self) -> MarkType {
727 match self {
728 Self::Bold => MarkType::Bold,
729 Self::Italic => MarkType::Italic,
730 Self::Underline => MarkType::Underline,
731 Self::Strikethrough => MarkType::Strikethrough,
732 Self::Code => MarkType::Code,
733 Self::Superscript => MarkType::Superscript,
734 Self::Subscript => MarkType::Subscript,
735 Self::Link { .. } => MarkType::Link,
736 Self::Anchor { .. } => MarkType::Anchor,
737 Self::Footnote { .. } => MarkType::Footnote,
738 Self::Math { .. } => MarkType::Math,
739 Self::Extension(_) => MarkType::Extension,
740 }
741 }
742
743 #[must_use]
745 pub fn is_extension(&self) -> bool {
746 matches!(self, Self::Extension(_))
747 }
748
749 #[must_use]
751 pub fn as_extension(&self) -> Option<&ExtensionMark> {
752 match self {
753 Self::Extension(ext) => Some(ext),
754 _ => None,
755 }
756 }
757}
758
759#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
761pub enum MarkType {
762 Bold,
764 Italic,
766 Underline,
768 Strikethrough,
770 Code,
772 Superscript,
774 Subscript,
776 Link,
778 Anchor,
780 Footnote,
782 Math,
784 Extension,
786}
787
788#[cfg(test)]
789mod tests {
790 use super::*;
791
792 #[test]
793 fn test_text_plain() {
794 let text = Text::plain("Hello");
795 assert_eq!(text.value, "Hello");
796 assert!(text.marks.is_empty());
797 assert!(!text.has_marks());
798 }
799
800 #[test]
801 fn test_text_bold() {
802 let text = Text::bold("Important");
803 assert_eq!(text.marks, vec![Mark::Bold]);
804 assert!(text.has_marks());
805 assert!(text.has_mark(MarkType::Bold));
806 assert!(!text.has_mark(MarkType::Italic));
807 }
808
809 #[test]
810 fn test_text_link() {
811 let text = Text::link("Click", "https://example.com");
812 assert!(text.has_mark(MarkType::Link));
813 if let Mark::Link { href, title } = &text.marks[0] {
814 assert_eq!(href, "https://example.com");
815 assert!(title.is_none());
816 } else {
817 panic!("Expected Link mark");
818 }
819 }
820
821 #[test]
822 fn test_text_serialization() {
823 let text = Text::bold("Test");
824 let json = serde_json::to_string(&text).unwrap();
825 assert!(json.contains("\"value\":\"Test\""));
826 assert!(json.contains("\"bold\""));
828 }
829
830 #[test]
831 fn test_text_deserialization() {
832 let json = r#"{"value":"Test","marks":["bold","italic"]}"#;
834 let text: Text = serde_json::from_str(json).unwrap();
835 assert_eq!(text.value, "Test");
836 assert_eq!(text.marks.len(), 2);
837 assert_eq!(text.marks[0], Mark::Bold);
838 assert_eq!(text.marks[1], Mark::Italic);
839 }
840
841 #[test]
842 fn test_text_deserialization_object_format() {
843 let json = r#"{"value":"Test","marks":[{"type":"bold"},{"type":"italic"}]}"#;
845 let text: Text = serde_json::from_str(json).unwrap();
846 assert_eq!(text.value, "Test");
847 assert_eq!(text.marks.len(), 2);
848 assert_eq!(text.marks[0], Mark::Bold);
849 assert_eq!(text.marks[1], Mark::Italic);
850 }
851
852 #[test]
853 fn test_link_with_title() {
854 let json = r#"{"type":"link","href":"https://example.com","title":"Example"}"#;
855 let mark: Mark = serde_json::from_str(json).unwrap();
856 if let Mark::Link { href, title } = mark {
857 assert_eq!(href, "https://example.com");
858 assert_eq!(title, Some("Example".to_string()));
859 } else {
860 panic!("Expected Link mark");
861 }
862 }
863
864 #[test]
865 fn test_text_footnote() {
866 let text = Text::footnote("important claim", 1);
867 assert!(text.has_mark(MarkType::Footnote));
868 if let Mark::Footnote { number, id } = &text.marks[0] {
869 assert_eq!(*number, 1);
870 assert!(id.is_none());
871 } else {
872 panic!("Expected Footnote mark");
873 }
874 }
875
876 #[test]
877 fn test_footnote_mark_serialization() {
878 let mark = Mark::Footnote {
879 number: 1,
880 id: Some("fn1".to_string()),
881 };
882 let json = serde_json::to_string(&mark).unwrap();
883 assert!(json.contains("\"type\":\"footnote\""));
884 assert!(json.contains("\"number\":1"));
885 assert!(json.contains("\"id\":\"fn1\""));
886 }
887
888 #[test]
889 fn test_footnote_mark_deserialization() {
890 let json = r#"{"type":"footnote","number":2,"id":"fn-2"}"#;
891 let mark: Mark = serde_json::from_str(json).unwrap();
892 if let Mark::Footnote { number, id } = mark {
893 assert_eq!(number, 2);
894 assert_eq!(id, Some("fn-2".to_string()));
895 } else {
896 panic!("Expected Footnote mark");
897 }
898 }
899
900 #[test]
901 fn test_footnote_mark_without_id() {
902 let json = r#"{"type":"footnote","number":3}"#;
903 let mark: Mark = serde_json::from_str(json).unwrap();
904 if let Mark::Footnote { number, id } = mark {
905 assert_eq!(number, 3);
906 assert!(id.is_none());
907 } else {
908 panic!("Expected Footnote mark");
909 }
910 }
911
912 #[test]
913 fn test_math_mark() {
914 use crate::content::block::MathFormat;
915
916 let mark = Mark::Math {
917 format: MathFormat::Latex,
918 source: "E = mc^2".to_string(),
919 };
920 assert_eq!(mark.mark_type(), MarkType::Math);
921 }
922
923 #[test]
924 fn test_math_mark_serialization() {
925 use crate::content::block::MathFormat;
926
927 let mark = Mark::Math {
928 format: MathFormat::Latex,
929 source: "\\frac{1}{2}".to_string(),
930 };
931 let json = serde_json::to_string(&mark).unwrap();
932 assert!(json.contains("\"type\":\"math\""));
933 assert!(json.contains("\"format\":\"latex\""));
934 assert!(json.contains("\"source\":\"\\\\frac{1}{2}\""));
935 }
936
937 #[test]
938 fn test_math_mark_deserialization() {
939 use crate::content::block::MathFormat;
940
941 let json = r#"{"type":"math","format":"mathml","source":"<math>...</math>"}"#;
942 let mark: Mark = serde_json::from_str(json).unwrap();
943 if let Mark::Math { format, source } = mark {
944 assert_eq!(format, MathFormat::Mathml);
945 assert_eq!(source, "<math>...</math>");
946 } else {
947 panic!("Expected Math mark");
948 }
949 }
950
951 #[test]
952 fn test_text_with_math_mark() {
953 use crate::content::block::MathFormat;
954
955 let text = Text::with_marks(
956 "x²",
957 vec![Mark::Math {
958 format: MathFormat::Latex,
959 source: "x^2".to_string(),
960 }],
961 );
962 assert!(text.has_mark(MarkType::Math));
963 }
964
965 #[test]
968 fn test_extension_mark_new() {
969 let ext = ExtensionMark::new("semantic", "citation");
970 assert_eq!(ext.namespace, "semantic");
971 assert_eq!(ext.mark_type, "citation");
972 assert_eq!(ext.full_type(), "semantic:citation");
973 }
974
975 #[test]
976 fn test_extension_mark_parse_type() {
977 assert_eq!(
978 ExtensionMark::parse_type("semantic:citation"),
979 Some(("semantic", "citation"))
980 );
981 assert_eq!(
982 ExtensionMark::parse_type("legal:cite"),
983 Some(("legal", "cite"))
984 );
985 assert_eq!(ExtensionMark::parse_type("bold"), None);
986 }
987
988 #[test]
989 fn test_extension_mark_with_attributes() {
990 let ext = ExtensionMark::new("semantic", "citation").with_attributes(serde_json::json!({
991 "refs": ["smith2023"],
992 "page": "42"
993 }));
994
995 assert_eq!(
996 ext.get_string_array_attribute("refs"),
997 Some(vec!["smith2023"])
998 );
999 assert_eq!(ext.get_string_attribute("page"), Some("42"));
1000 }
1001
1002 #[test]
1003 fn test_extension_mark_namespace_check() {
1004 let ext = ExtensionMark::new("semantic", "citation");
1005 assert!(ext.is_namespace("semantic"));
1006 assert!(!ext.is_namespace("legal"));
1007 assert!(ext.is_type("semantic", "citation"));
1008 assert!(!ext.is_type("semantic", "entity"));
1009 }
1010
1011 #[test]
1012 fn test_mark_extension_variant() {
1013 let ext = ExtensionMark::new("semantic", "citation");
1014 let mark = Mark::Extension(ext.clone());
1015
1016 assert!(mark.is_extension());
1017 assert_eq!(mark.mark_type(), MarkType::Extension);
1018 assert_eq!(
1019 mark.as_extension().unwrap().full_type(),
1020 "semantic:citation"
1021 );
1022 }
1023
1024 #[test]
1025 fn test_extension_mark_serialization() {
1026 let ext = ExtensionMark::new("semantic", "citation").with_attributes(serde_json::json!({
1027 "refs": ["smith2023"]
1028 }));
1029 let mark = Mark::Extension(ext);
1030
1031 let json = serde_json::to_string(&mark).unwrap();
1032 assert!(json.contains("\"type\":\"semantic:citation\""));
1034 assert!(json.contains("\"refs\":[\"smith2023\"]"));
1035 assert!(!json.contains("\"namespace\""));
1037 assert!(!json.contains("\"markType\""));
1038 }
1039
1040 #[test]
1041 fn test_extension_mark_deserialization_new_format() {
1042 let json = r#"{
1044 "type": "legal:cite",
1045 "citation": "Brown v. Board of Education"
1046 }"#;
1047 let mark: Mark = serde_json::from_str(json).unwrap();
1048
1049 if let Mark::Extension(ext) = mark {
1050 assert_eq!(ext.namespace, "legal");
1051 assert_eq!(ext.mark_type, "cite");
1052 assert_eq!(
1053 ext.get_string_attribute("citation"),
1054 Some("Brown v. Board of Education")
1055 );
1056 } else {
1057 panic!("Expected Extension mark");
1058 }
1059 }
1060
1061 #[test]
1062 fn test_extension_mark_deserialization_old_format() {
1063 let json = r#"{
1065 "type": "extension",
1066 "namespace": "legal",
1067 "markType": "cite",
1068 "attributes": {
1069 "citation": "Brown v. Board of Education"
1070 }
1071 }"#;
1072 let mark: Mark = serde_json::from_str(json).unwrap();
1073
1074 if let Mark::Extension(ext) = mark {
1075 assert_eq!(ext.namespace, "legal");
1076 assert_eq!(ext.mark_type, "cite");
1077 assert_eq!(
1078 ext.get_string_attribute("citation"),
1079 Some("Brown v. Board of Education")
1080 );
1081 } else {
1082 panic!("Expected Extension mark");
1083 }
1084 }
1085
1086 #[test]
1087 fn test_text_with_extension_mark() {
1088 let mark = Mark::Extension(ExtensionMark::citation("smith2023"));
1089 let text = Text::with_marks("important claim", vec![mark]);
1090
1091 assert!(text.has_mark(MarkType::Extension));
1092 if let Mark::Extension(ext) = &text.marks[0] {
1093 assert_eq!(ext.namespace, "semantic");
1094 assert_eq!(ext.mark_type, "citation");
1095 } else {
1096 panic!("Expected Extension mark");
1097 }
1098 }
1099
1100 #[test]
1101 fn test_citation_convenience() {
1102 let ext = ExtensionMark::citation("smith2023");
1103 assert!(ext.is_type("semantic", "citation"));
1104 assert_eq!(
1105 ext.get_string_array_attribute("refs"),
1106 Some(vec!["smith2023"])
1107 );
1108 assert_eq!(ext.get_citation_refs(), Some(vec!["smith2023"]));
1109 }
1110
1111 #[test]
1112 fn test_citation_with_page_convenience() {
1113 let ext = ExtensionMark::citation_with_page("smith2023", "42-45");
1114 assert!(ext.is_type("semantic", "citation"));
1115 assert_eq!(
1116 ext.get_string_array_attribute("refs"),
1117 Some(vec!["smith2023"])
1118 );
1119 assert_eq!(ext.get_string_attribute("locator"), Some("42-45"));
1120 assert_eq!(ext.get_string_attribute("locatorType"), Some("page"));
1121 }
1122
1123 #[test]
1124 fn test_multi_citation_convenience() {
1125 let refs = vec!["smith2023".into(), "jones2024".into()];
1126 let ext = ExtensionMark::multi_citation(&refs);
1127 assert!(ext.is_type("semantic", "citation"));
1128 assert_eq!(
1129 ext.get_string_array_attribute("refs"),
1130 Some(vec!["smith2023", "jones2024"])
1131 );
1132 }
1133
1134 #[test]
1135 fn test_get_citation_refs_legacy() {
1136 let ext = ExtensionMark::new("semantic", "citation")
1138 .with_attributes(serde_json::json!({"ref": "smith2023"}));
1139 assert_eq!(ext.get_citation_refs(), Some(vec!["smith2023"]));
1140 }
1141
1142 #[test]
1143 fn test_normalize_citation_attrs() {
1144 let mut ext = ExtensionMark::new("semantic", "citation")
1145 .with_attributes(serde_json::json!({"ref": "smith2023"}));
1146 ext.normalize_citation_attrs();
1147 assert_eq!(
1148 ext.get_string_array_attribute("refs"),
1149 Some(vec!["smith2023"])
1150 );
1151 assert!(ext.get_string_attribute("ref").is_none());
1152 }
1153
1154 #[test]
1155 fn test_normalize_citation_attrs_noop_when_refs_exists() {
1156 let mut ext = ExtensionMark::citation("smith2023");
1157 ext.normalize_citation_attrs();
1158 assert_eq!(
1159 ext.get_string_array_attribute("refs"),
1160 Some(vec!["smith2023"])
1161 );
1162 }
1163
1164 #[test]
1165 fn test_entity_convenience() {
1166 let ext = ExtensionMark::entity("https://www.wikidata.org/wiki/Q937", "person");
1167 assert!(ext.is_type("semantic", "entity"));
1168 assert_eq!(
1169 ext.get_string_attribute("uri"),
1170 Some("https://www.wikidata.org/wiki/Q937")
1171 );
1172 assert_eq!(ext.get_string_attribute("entityType"), Some("person"));
1173 }
1174
1175 #[test]
1176 fn test_glossary_convenience() {
1177 let ext = ExtensionMark::glossary("api-term");
1178 assert!(ext.is_type("semantic", "glossary"));
1179 assert_eq!(ext.get_string_attribute("termId"), Some("api-term"));
1180 }
1181
1182 #[test]
1183 fn test_index_convenience() {
1184 let ext = ExtensionMark::index("algorithm");
1185 assert!(ext.is_type("presentation", "index"));
1186 assert_eq!(ext.get_string_attribute("term"), Some("algorithm"));
1187 }
1188
1189 #[test]
1190 fn test_index_with_subterm_convenience() {
1191 let ext = ExtensionMark::index_with_subterm("algorithm", "sorting");
1192 assert!(ext.is_type("presentation", "index"));
1193 assert_eq!(ext.get_string_attribute("term"), Some("algorithm"));
1194 assert_eq!(ext.get_string_attribute("subterm"), Some("sorting"));
1195 }
1196
1197 #[test]
1198 fn test_non_extension_mark_as_extension() {
1199 let mark = Mark::Bold;
1200 assert!(!mark.is_extension());
1201 assert!(mark.as_extension().is_none());
1202 }
1203
1204 #[test]
1205 fn test_equation_ref_convenience() {
1206 let ext = ExtensionMark::equation_ref("#eq-pythagoras");
1207 assert!(ext.is_type("academic", "equation-ref"));
1208 assert_eq!(ext.get_string_attribute("target"), Some("#eq-pythagoras"));
1209 assert!(ext.get_string_attribute("format").is_none());
1210 }
1211
1212 #[test]
1213 fn test_equation_ref_formatted_convenience() {
1214 let ext = ExtensionMark::equation_ref_formatted("#eq-1", "Equation ({number})");
1215 assert!(ext.is_type("academic", "equation-ref"));
1216 assert_eq!(ext.get_string_attribute("target"), Some("#eq-1"));
1217 assert_eq!(
1218 ext.get_string_attribute("format"),
1219 Some("Equation ({number})")
1220 );
1221 }
1222
1223 #[test]
1224 fn test_algorithm_ref_convenience() {
1225 let ext = ExtensionMark::algorithm_ref("#alg-quicksort");
1226 assert!(ext.is_type("academic", "algorithm-ref"));
1227 assert_eq!(ext.get_string_attribute("target"), Some("#alg-quicksort"));
1228 assert!(ext.get_string_attribute("line").is_none());
1229 }
1230
1231 #[test]
1232 fn test_algorithm_ref_line_convenience() {
1233 let ext = ExtensionMark::algorithm_ref_line("#alg-bisection", "loop");
1234 assert!(ext.is_type("academic", "algorithm-ref"));
1235 assert_eq!(ext.get_string_attribute("target"), Some("#alg-bisection"));
1236 assert_eq!(ext.get_string_attribute("line"), Some("loop"));
1237 }
1238
1239 #[test]
1240 fn test_algorithm_ref_formatted_convenience() {
1241 let ext = ExtensionMark::algorithm_ref_formatted("#alg-1", "Algorithm {number}");
1242 assert!(ext.is_type("academic", "algorithm-ref"));
1243 assert_eq!(ext.get_string_attribute("target"), Some("#alg-1"));
1244 assert_eq!(
1245 ext.get_string_attribute("format"),
1246 Some("Algorithm {number}")
1247 );
1248 }
1249
1250 #[test]
1251 fn test_algorithm_ref_line_formatted_convenience() {
1252 let ext = ExtensionMark::algorithm_ref_line_formatted("#alg-1", "pivot", "line {line}");
1253 assert!(ext.is_type("academic", "algorithm-ref"));
1254 assert_eq!(ext.get_string_attribute("target"), Some("#alg-1"));
1255 assert_eq!(ext.get_string_attribute("line"), Some("pivot"));
1256 assert_eq!(ext.get_string_attribute("format"), Some("line {line}"));
1257 }
1258
1259 #[test]
1260 fn test_theorem_ref_convenience() {
1261 let ext = ExtensionMark::theorem_ref("#thm-pythagoras");
1262 assert!(ext.is_type("academic", "theorem-ref"));
1263 assert_eq!(ext.get_string_attribute("target"), Some("#thm-pythagoras"));
1264 }
1265
1266 #[test]
1267 fn test_theorem_ref_formatted_convenience() {
1268 let ext = ExtensionMark::theorem_ref_formatted("#thm-1", "{variant} {number}");
1269 assert!(ext.is_type("academic", "theorem-ref"));
1270 assert_eq!(ext.get_string_attribute("target"), Some("#thm-1"));
1271 assert_eq!(
1272 ext.get_string_attribute("format"),
1273 Some("{variant} {number}")
1274 );
1275 }
1276
1277 #[test]
1278 fn test_highlight_mark_convenience() {
1279 let ext = ExtensionMark::highlight("yellow");
1280 assert!(ext.is_type("collaboration", "highlight"));
1281 assert_eq!(ext.get_string_attribute("color"), Some("yellow"));
1282 }
1283
1284 #[test]
1285 fn test_highlight_yellow_convenience() {
1286 let ext = ExtensionMark::highlight_yellow();
1287 assert!(ext.is_type("collaboration", "highlight"));
1288 assert_eq!(ext.get_string_attribute("color"), Some("yellow"));
1289 }
1290
1291 #[test]
1292 fn test_highlight_colored_convenience() {
1293 let ext = ExtensionMark::highlight_colored("green");
1295 assert!(ext.is_type("collaboration", "highlight"));
1296 assert_eq!(ext.get_string_attribute("color"), Some("green"));
1297 }
1298}
1299
1300#[cfg(test)]
1301mod proptests {
1302 use super::*;
1303 use proptest::prelude::*;
1304
1305 fn arb_text_value() -> impl Strategy<Value = String> {
1307 "[a-zA-Z0-9 .,!?]{0,100}".prop_map(|s| s)
1308 }
1309
1310 fn arb_url() -> impl Strategy<Value = String> {
1312 "(https?://[a-z]{3,10}\\.[a-z]{2,4}(/[a-z0-9]{0,10})?)".prop_map(|s| s)
1313 }
1314
1315 fn arb_simple_mark() -> impl Strategy<Value = Mark> {
1317 prop_oneof![
1318 Just(Mark::Bold),
1319 Just(Mark::Italic),
1320 Just(Mark::Underline),
1321 Just(Mark::Strikethrough),
1322 Just(Mark::Code),
1323 Just(Mark::Superscript),
1324 Just(Mark::Subscript),
1325 ]
1326 }
1327
1328 fn arb_link_mark() -> impl Strategy<Value = Mark> {
1330 (arb_url(), prop::option::of("[a-zA-Z ]{0,20}"))
1331 .prop_map(|(href, title)| Mark::Link { href, title })
1332 }
1333
1334 fn arb_footnote_mark() -> impl Strategy<Value = Mark> {
1336 (1u32..1000u32, prop::option::of("[a-z]{2,10}"))
1337 .prop_map(|(number, id)| Mark::Footnote { number, id })
1338 }
1339
1340 fn arb_mark() -> impl Strategy<Value = Mark> {
1342 prop_oneof![arb_simple_mark(), arb_link_mark(), arb_footnote_mark(),]
1343 }
1344
1345 fn arb_text() -> impl Strategy<Value = Text> {
1347 (arb_text_value(), prop::collection::vec(arb_mark(), 0..3))
1348 .prop_map(|(value, marks)| Text { value, marks })
1349 }
1350
1351 proptest! {
1352 #[test]
1354 fn plain_text_no_marks(value in arb_text_value()) {
1355 let text = Text::plain(&value);
1356 prop_assert_eq!(&text.value, &value);
1357 prop_assert!(text.marks.is_empty());
1358 prop_assert!(!text.has_marks());
1359 }
1360
1361 #[test]
1363 fn bold_text_has_bold_mark(value in arb_text_value()) {
1364 let text = Text::bold(&value);
1365 prop_assert_eq!(&text.value, &value);
1366 prop_assert_eq!(text.marks.len(), 1);
1367 prop_assert!(text.has_mark(MarkType::Bold));
1368 }
1369
1370 #[test]
1372 fn italic_text_has_italic_mark(value in arb_text_value()) {
1373 let text = Text::italic(&value);
1374 prop_assert_eq!(&text.value, &value);
1375 prop_assert_eq!(text.marks.len(), 1);
1376 prop_assert!(text.has_mark(MarkType::Italic));
1377 }
1378
1379 #[test]
1381 fn code_text_has_code_mark(value in arb_text_value()) {
1382 let text = Text::code(&value);
1383 prop_assert_eq!(&text.value, &value);
1384 prop_assert_eq!(text.marks.len(), 1);
1385 prop_assert!(text.has_mark(MarkType::Code));
1386 }
1387
1388 #[test]
1390 fn link_text_has_link_mark(value in arb_text_value(), href in arb_url()) {
1391 let text = Text::link(&value, &href);
1392 prop_assert_eq!(&text.value, &value);
1393 prop_assert_eq!(text.marks.len(), 1);
1394 prop_assert!(text.has_mark(MarkType::Link));
1395 if let Mark::Link { href: actual_href, .. } = &text.marks[0] {
1396 prop_assert_eq!(actual_href, &href);
1397 }
1398 }
1399
1400 #[test]
1402 fn text_json_roundtrip(text in arb_text()) {
1403 let json = serde_json::to_string(&text).unwrap();
1404 let parsed: Text = serde_json::from_str(&json).unwrap();
1405 prop_assert_eq!(text, parsed);
1406 }
1407
1408 #[test]
1410 fn mark_json_roundtrip(mark in arb_mark()) {
1411 let json = serde_json::to_string(&mark).unwrap();
1412 let parsed: Mark = serde_json::from_str(&json).unwrap();
1413 prop_assert_eq!(mark, parsed);
1414 }
1415
1416 #[test]
1418 fn simple_mark_types(mark in arb_simple_mark()) {
1419 let expected = match mark {
1420 Mark::Bold => MarkType::Bold,
1421 Mark::Italic => MarkType::Italic,
1422 Mark::Underline => MarkType::Underline,
1423 Mark::Strikethrough => MarkType::Strikethrough,
1424 Mark::Code => MarkType::Code,
1425 Mark::Superscript => MarkType::Superscript,
1426 Mark::Subscript => MarkType::Subscript,
1427 Mark::Link { .. }
1428 | Mark::Anchor { .. }
1429 | Mark::Footnote { .. }
1430 | Mark::Math { .. }
1431 | Mark::Extension(_) => {
1432 prop_assert!(false, "arb_simple_mark() generated non-simple mark: {mark:?}");
1434 return Ok(());
1435 }
1436 };
1437 prop_assert_eq!(mark.mark_type(), expected);
1438 }
1439
1440 #[test]
1442 fn link_mark_type(mark in arb_link_mark()) {
1443 prop_assert_eq!(mark.mark_type(), MarkType::Link);
1444 }
1445
1446 #[test]
1448 fn footnote_mark_type(mark in arb_footnote_mark()) {
1449 prop_assert_eq!(mark.mark_type(), MarkType::Footnote);
1450 }
1451
1452 #[test]
1454 fn has_marks_consistent(text in arb_text()) {
1455 prop_assert_eq!(text.has_marks(), !text.marks.is_empty());
1456 }
1457 }
1458}