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