Skip to main content

cdx_core/content/
text.rs

1//! Text nodes and formatting marks.
2
3use serde::de::{self, MapAccess, Visitor};
4use serde::ser::SerializeMap;
5use serde::{Deserialize, Serialize};
6
7use crate::content::block::MathFormat;
8
9/// A text node containing content and optional formatting marks.
10///
11/// Text nodes are the leaf nodes in the content tree, containing
12/// actual text content along with formatting information.
13///
14/// # Example
15///
16/// ```
17/// use cdx_core::content::{Text, Mark};
18///
19/// // Plain text
20/// let plain = Text::plain("Hello");
21///
22/// // Bold text
23/// let bold = Text::with_marks("Important", vec![Mark::Bold]);
24///
25/// // Text with multiple marks
26/// let bold_italic = Text::with_marks("Emphasis", vec![Mark::Bold, Mark::Italic]);
27/// ```
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct Text {
30    /// The text content.
31    pub value: String,
32
33    /// Formatting marks applied to this text.
34    #[serde(default, skip_serializing_if = "Vec::is_empty")]
35    pub marks: Vec<Mark>,
36}
37
38impl Text {
39    /// Create a plain text node without any marks.
40    #[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    /// Create a text node with formatting marks.
49    #[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    /// Create a bold text node.
58    #[must_use]
59    pub fn bold(value: impl Into<String>) -> Self {
60        Self::with_marks(value, vec![Mark::Bold])
61    }
62
63    /// Create an italic text node.
64    #[must_use]
65    pub fn italic(value: impl Into<String>) -> Self {
66        Self::with_marks(value, vec![Mark::Italic])
67    }
68
69    /// Create a code text node (inline code).
70    #[must_use]
71    pub fn code(value: impl Into<String>) -> Self {
72        Self::with_marks(value, vec![Mark::Code])
73    }
74
75    /// Create a link text node.
76    #[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    /// Create a footnote reference text node.
88    #[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    /// Check if this text has any marks.
94    #[must_use]
95    pub fn has_marks(&self) -> bool {
96        !self.marks.is_empty()
97    }
98
99    /// Check if this text has a specific mark type.
100    #[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/// Formatting marks that can be applied to text.
107///
108/// Marks represent inline formatting such as bold, italic, links, etc.
109/// Multiple marks can be applied to the same text node.
110///
111/// # Serialization
112///
113/// Simple marks (Bold, Italic, etc.) serialize as plain strings: `"bold"`.
114/// Complex marks (Link, Math, etc.) serialize as objects with a `"type"` field.
115/// Extension marks serialize with their namespaced type as `"type"`.
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum Mark {
118    /// Bold/strong text.
119    Bold,
120
121    /// Italic/emphasized text.
122    Italic,
123
124    /// Underlined text.
125    Underline,
126
127    /// Strikethrough text.
128    Strikethrough,
129
130    /// Inline code (monospace).
131    Code,
132
133    /// Superscript text.
134    Superscript,
135
136    /// Subscript text.
137    Subscript,
138
139    /// Hyperlink.
140    Link {
141        /// Link destination URL.
142        href: String,
143
144        /// Optional link title.
145        title: Option<String>,
146    },
147
148    /// Named anchor mark for creating anchor points in text.
149    Anchor {
150        /// Unique identifier for this anchor.
151        id: String,
152    },
153
154    /// Footnote reference mark (semantic extension).
155    ///
156    /// Links text to a footnote block elsewhere in the document.
157    Footnote {
158        /// Sequential footnote number.
159        number: u32,
160
161        /// Optional unique identifier for cross-referencing.
162        id: Option<String>,
163    },
164
165    /// Inline mathematical expression.
166    Math {
167        /// Math format (latex or mathml).
168        format: MathFormat,
169
170        /// The mathematical expression source.
171        source: String,
172    },
173
174    /// Extension mark for custom/unknown mark types.
175    ///
176    /// Extension marks use namespaced types like "semantic:citation" or
177    /// "legal:cite". This enables extensions to add custom inline marks
178    /// without modifying the core Mark enum.
179    Extension(ExtensionMark),
180}
181
182/// An extension mark for unsupported or unknown mark types.
183///
184/// When parsing a document with extension marks (e.g., "semantic:citation"),
185/// this struct preserves the raw data so it can be:
186/// - Passed through unchanged when saving
187/// - Processed by extension-aware applications
188#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
189#[serde(rename_all = "camelCase")]
190pub struct ExtensionMark {
191    /// The extension namespace (e.g., "semantic", "legal", "presentation").
192    pub namespace: String,
193
194    /// The mark type within the namespace (e.g., "citation", "entity", "index").
195    pub mark_type: String,
196
197    /// Extension-specific attributes.
198    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
199    pub attributes: serde_json::Value,
200}
201
202impl ExtensionMark {
203    /// Create a new extension mark.
204    #[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    /// Parse an extension type string like "semantic:citation" into (namespace, `mark_type`).
214    ///
215    /// Returns `None` if the type doesn't contain a colon.
216    #[must_use]
217    pub fn parse_type(type_str: &str) -> Option<(&str, &str)> {
218        type_str.split_once(':')
219    }
220
221    /// Get the full type string (e.g., "semantic:citation").
222    #[must_use]
223    pub fn full_type(&self) -> String {
224        format!("{}:{}", self.namespace, self.mark_type)
225    }
226
227    /// Check if this extension is from a specific namespace.
228    #[must_use]
229    pub fn is_namespace(&self, namespace: &str) -> bool {
230        self.namespace == namespace
231    }
232
233    /// Check if this is a specific extension type.
234    #[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    /// Set the attributes.
240    #[must_use]
241    pub fn with_attributes(mut self, attributes: serde_json::Value) -> Self {
242        self.attributes = attributes;
243        self
244    }
245
246    /// Get an attribute value by key.
247    #[must_use]
248    pub fn get_attribute(&self, key: &str) -> Option<&serde_json::Value> {
249        self.attributes.get(key)
250    }
251
252    /// Get a string attribute.
253    #[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    /// Get an array-of-strings attribute.
259    #[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    /// Get citation refs, supporting both `"refs"` (array) and legacy `"ref"` (string).
268    ///
269    /// Returns `None` if neither key is present.
270    #[must_use]
271    pub fn get_citation_refs(&self) -> Option<Vec<&str>> {
272        // Try new format first
273        if let Some(refs) = self.get_string_array_attribute("refs") {
274            return Some(refs);
275        }
276        // Fall back to legacy singular "ref"
277        self.get_string_attribute("ref").map(|r| vec![r])
278    }
279
280    /// Rewrite legacy `"ref"` (string) → `"refs"` (array) in the attributes map.
281    ///
282    /// No-op if `"refs"` already exists or `"ref"` is absent.
283    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                    // Put it back if it wasn't a string
296                    obj.insert("ref".to_string(), single);
297                }
298            }
299        }
300    }
301
302    // ===== Convenience constructors for common extension marks =====
303
304    /// Create a citation mark (semantic extension).
305    #[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    /// Create a citation mark with page locator.
313    #[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    /// Create a multi-reference citation mark (e.g., `[smith2023; jones2024]`).
323    #[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    /// Create an entity link mark (semantic extension).
331    #[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    /// Create a glossary reference mark (semantic extension).
340    #[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    /// Create an index mark (presentation extension).
348    #[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    /// Create an index mark with subterm.
356    #[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    // ===== Academic extension marks =====
365
366    /// Create an equation reference mark (academic extension).
367    ///
368    /// References an equation by its ID (e.g., "#eq-pythagoras").
369    #[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    /// Create an equation reference mark with custom format.
377    ///
378    /// The format string can use `{number}` as a placeholder for the equation number.
379    #[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    /// Create an algorithm reference mark (academic extension).
388    ///
389    /// References an algorithm by its ID (e.g., "#alg-quicksort").
390    #[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    /// Create an algorithm reference mark with line reference.
398    ///
399    /// References a specific line within an algorithm.
400    #[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    /// Create an algorithm reference mark with custom format.
409    ///
410    /// The format string can use `{number}` and `{line}` as placeholders.
411    #[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    /// Create an algorithm reference mark with line and custom format.
420    #[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    /// Create a theorem reference mark (academic extension).
434    ///
435    /// References a theorem by its ID (e.g., "#thm-pythagoras").
436    #[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    /// Create a theorem reference mark with custom format.
444    ///
445    /// The format string can use `{number}` and `{variant}` as placeholders.
446    #[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    // ===== Collaboration extension marks =====
455
456    /// Create a highlight mark (collaboration extension).
457    ///
458    /// Applies a colored highlight to text for collaborative annotation.
459    /// Default color is yellow if not specified.
460    #[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    /// Create a highlight mark with default yellow color.
468    #[must_use]
469    pub fn highlight_yellow() -> Self {
470        Self::highlight("yellow")
471    }
472
473    /// Create a highlight mark with a specific color.
474    ///
475    /// Convenience method that accepts the `HighlightColor` display string.
476    #[must_use]
477    pub fn highlight_colored(color: impl std::fmt::Display) -> Self {
478        Self::highlight(color.to_string())
479    }
480}
481
482/// Infer the extension namespace from a mark type string.
483///
484/// Used during deserialization when an unknown type string is encountered
485/// without explicit namespace information.
486fn 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            // Simple marks serialize as plain strings
501            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            // Complex marks serialize as objects with "type" field
510            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            // Extension marks: type is "namespace:markType", attributes flattened
545            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)] // mechanical dispatch across 15+ mark variants — splitting would obscure the mapping
563    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            // Simple marks can be plain strings
574            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                        // Unknown string: treat as extension mark
585                        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            // Complex marks are objects with a "type" field
596            #[allow(clippy::too_many_lines)] // field extraction + type dispatch for 15+ mark variants in one pass
597            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                    // Simple marks in object form
614                    "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                    // Complex core marks
623                    "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                        // Accept both "source" and "value" (backward compat)
665                        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                    // Old format backward compat: {"type": "extension", "namespace": "...", "markType": "..."}
675                    "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                    // Colon-delimited extension type or unknown type
698                    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    /// Get the type of this mark.
725    #[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    /// Check if this mark is an extension mark.
744    #[must_use]
745    pub fn is_extension(&self) -> bool {
746        matches!(self, Self::Extension(_))
747    }
748
749    /// Get the extension mark if this is one.
750    #[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/// Type identifier for marks (without associated data).
760#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
761pub enum MarkType {
762    /// Bold mark type.
763    Bold,
764    /// Italic mark type.
765    Italic,
766    /// Underline mark type.
767    Underline,
768    /// Strikethrough mark type.
769    Strikethrough,
770    /// Code mark type.
771    Code,
772    /// Superscript mark type.
773    Superscript,
774    /// Subscript mark type.
775    Subscript,
776    /// Link mark type.
777    Link,
778    /// Anchor mark type.
779    Anchor,
780    /// Footnote mark type.
781    Footnote,
782    /// Math mark type.
783    Math,
784    /// Extension mark type.
785    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        // Simple marks serialize as strings
827        assert!(json.contains("\"bold\""));
828    }
829
830    #[test]
831    fn test_text_deserialization() {
832        // New format: simple marks as strings
833        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        // Old format: simple marks as objects (backward compat)
844        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    // Extension mark tests
966
967    #[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        // New format: type is "namespace:markType", attributes flattened
1033        assert!(json.contains("\"type\":\"semantic:citation\""));
1034        assert!(json.contains("\"refs\":[\"smith2023\"]"));
1035        // Should NOT contain old wrapper fields
1036        assert!(!json.contains("\"namespace\""));
1037        assert!(!json.contains("\"markType\""));
1038    }
1039
1040    #[test]
1041    fn test_extension_mark_deserialization_new_format() {
1042        // New format: colon-delimited type with flattened attributes
1043        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        // Old format backward compat: "extension" wrapper with namespace/markType
1064        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        // Legacy "ref" key should still be readable via get_citation_refs
1137        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        // Test with a string that would come from HighlightColor::display()
1294        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    /// Generate arbitrary text content.
1306    fn arb_text_value() -> impl Strategy<Value = String> {
1307        "[a-zA-Z0-9 .,!?]{0,100}".prop_map(|s| s)
1308    }
1309
1310    /// Generate arbitrary URL for links.
1311    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    /// Generate arbitrary basic mark (no associated data).
1316    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    /// Generate arbitrary link mark.
1329    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    /// Generate arbitrary footnote mark.
1335    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    /// Generate arbitrary mark.
1341    fn arb_mark() -> impl Strategy<Value = Mark> {
1342        prop_oneof![arb_simple_mark(), arb_link_mark(), arb_footnote_mark(),]
1343    }
1344
1345    /// Generate arbitrary text node.
1346    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        /// Plain text has no marks.
1353        #[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        /// Bold text has exactly one bold mark.
1362        #[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        /// Italic text has exactly one italic mark.
1371        #[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        /// Code text has exactly one code mark.
1380        #[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        /// Link text has exactly one link mark with correct href.
1389        #[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        /// Text JSON roundtrip - serialize and deserialize should preserve data.
1401        #[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        /// Mark JSON roundtrip - serialize and deserialize should preserve data.
1409        #[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        /// Simple mark types are identified correctly.
1417        #[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                    // arb_simple_mark() should never generate these variants
1433                    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        /// Link marks return Link type.
1441        #[test]
1442        fn link_mark_type(mark in arb_link_mark()) {
1443            prop_assert_eq!(mark.mark_type(), MarkType::Link);
1444        }
1445
1446        /// Footnote marks return Footnote type.
1447        #[test]
1448        fn footnote_mark_type(mark in arb_footnote_mark()) {
1449            prop_assert_eq!(mark.mark_type(), MarkType::Footnote);
1450        }
1451
1452        /// has_marks is consistent with marks vector.
1453        #[test]
1454        fn has_marks_consistent(text in arb_text()) {
1455            prop_assert_eq!(text.has_marks(), !text.marks.is_empty());
1456        }
1457    }
1458}