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