Skip to main content

cdx_core/content/
text.rs

1//! Text nodes and formatting marks.
2
3use serde::{Deserialize, Serialize};
4
5use crate::content::block::MathFormat;
6
7/// A text node containing content and optional formatting marks.
8///
9/// Text nodes are the leaf nodes in the content tree, containing
10/// actual text content along with formatting information.
11///
12/// # Example
13///
14/// ```
15/// use cdx_core::content::{Text, Mark};
16///
17/// // Plain text
18/// let plain = Text::plain("Hello");
19///
20/// // Bold text
21/// let bold = Text::with_marks("Important", vec![Mark::Bold]);
22///
23/// // Text with multiple marks
24/// let bold_italic = Text::with_marks("Emphasis", vec![Mark::Bold, Mark::Italic]);
25/// ```
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct Text {
28    /// The text content.
29    pub value: String,
30
31    /// Formatting marks applied to this text.
32    #[serde(default, skip_serializing_if = "Vec::is_empty")]
33    pub marks: Vec<Mark>,
34}
35
36impl Text {
37    /// Create a plain text node without any marks.
38    #[must_use]
39    pub fn plain(value: impl Into<String>) -> Self {
40        Self {
41            value: value.into(),
42            marks: Vec::new(),
43        }
44    }
45
46    /// Create a text node with formatting marks.
47    #[must_use]
48    pub fn with_marks(value: impl Into<String>, marks: Vec<Mark>) -> Self {
49        Self {
50            value: value.into(),
51            marks,
52        }
53    }
54
55    /// Create a bold text node.
56    #[must_use]
57    pub fn bold(value: impl Into<String>) -> Self {
58        Self::with_marks(value, vec![Mark::Bold])
59    }
60
61    /// Create an italic text node.
62    #[must_use]
63    pub fn italic(value: impl Into<String>) -> Self {
64        Self::with_marks(value, vec![Mark::Italic])
65    }
66
67    /// Create a code text node (inline code).
68    #[must_use]
69    pub fn code(value: impl Into<String>) -> Self {
70        Self::with_marks(value, vec![Mark::Code])
71    }
72
73    /// Create a link text node.
74    #[must_use]
75    pub fn link(value: impl Into<String>, href: impl Into<String>) -> Self {
76        Self::with_marks(
77            value,
78            vec![Mark::Link {
79                href: href.into(),
80                title: None,
81            }],
82        )
83    }
84
85    /// Create a footnote reference text node.
86    #[must_use]
87    pub fn footnote(value: impl Into<String>, number: u32) -> Self {
88        Self::with_marks(value, vec![Mark::Footnote { number, id: None }])
89    }
90
91    /// Check if this text has any marks.
92    #[must_use]
93    pub fn has_marks(&self) -> bool {
94        !self.marks.is_empty()
95    }
96
97    /// Check if this text has a specific mark type.
98    #[must_use]
99    pub fn has_mark(&self, mark_type: MarkType) -> bool {
100        self.marks.iter().any(|m| m.mark_type() == mark_type)
101    }
102}
103
104/// Formatting marks that can be applied to text.
105///
106/// Marks represent inline formatting such as bold, italic, links, etc.
107/// Multiple marks can be applied to the same text node.
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109#[serde(tag = "type", rename_all = "camelCase")]
110pub enum Mark {
111    /// Bold/strong text.
112    Bold,
113
114    /// Italic/emphasized text.
115    Italic,
116
117    /// Underlined text.
118    Underline,
119
120    /// Strikethrough text.
121    Strikethrough,
122
123    /// Inline code (monospace).
124    Code,
125
126    /// Superscript text.
127    Superscript,
128
129    /// Subscript text.
130    Subscript,
131
132    /// Hyperlink.
133    Link {
134        /// Link destination URL.
135        href: String,
136
137        /// Optional link title.
138        #[serde(default, skip_serializing_if = "Option::is_none")]
139        title: Option<String>,
140    },
141
142    /// Named anchor mark for creating anchor points in text.
143    Anchor {
144        /// Unique identifier for this anchor.
145        id: String,
146    },
147
148    /// Footnote reference mark (semantic extension).
149    ///
150    /// Links text to a footnote block elsewhere in the document.
151    Footnote {
152        /// Sequential footnote number.
153        number: u32,
154
155        /// Optional unique identifier for cross-referencing.
156        #[serde(default, skip_serializing_if = "Option::is_none")]
157        id: Option<String>,
158    },
159
160    /// Inline mathematical expression.
161    Math {
162        /// Math format (latex or mathml).
163        format: MathFormat,
164
165        /// The mathematical expression.
166        value: String,
167    },
168
169    /// Extension mark for custom/unknown mark types.
170    ///
171    /// Extension marks use namespaced types like "semantic:citation" or
172    /// "legal:cite". This enables extensions to add custom inline marks
173    /// without modifying the core Mark enum.
174    Extension(ExtensionMark),
175}
176
177/// An extension mark for unsupported or unknown mark types.
178///
179/// When parsing a document with extension marks (e.g., "semantic:citation"),
180/// this struct preserves the raw data so it can be:
181/// - Passed through unchanged when saving
182/// - Processed by extension-aware applications
183#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
184#[serde(rename_all = "camelCase")]
185pub struct ExtensionMark {
186    /// The extension namespace (e.g., "semantic", "legal", "presentation").
187    pub namespace: String,
188
189    /// The mark type within the namespace (e.g., "citation", "entity", "index").
190    pub mark_type: String,
191
192    /// Extension-specific attributes.
193    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
194    pub attributes: serde_json::Value,
195}
196
197impl ExtensionMark {
198    /// Create a new extension mark.
199    #[must_use]
200    pub fn new(namespace: impl Into<String>, mark_type: impl Into<String>) -> Self {
201        Self {
202            namespace: namespace.into(),
203            mark_type: mark_type.into(),
204            attributes: serde_json::Value::Null,
205        }
206    }
207
208    /// Parse an extension type string like "semantic:citation" into (namespace, `mark_type`).
209    ///
210    /// Returns `None` if the type doesn't contain a colon.
211    #[must_use]
212    pub fn parse_type(type_str: &str) -> Option<(&str, &str)> {
213        type_str.split_once(':')
214    }
215
216    /// Get the full type string (e.g., "semantic:citation").
217    #[must_use]
218    pub fn full_type(&self) -> String {
219        format!("{}:{}", self.namespace, self.mark_type)
220    }
221
222    /// Check if this extension is from a specific namespace.
223    #[must_use]
224    pub fn is_namespace(&self, namespace: &str) -> bool {
225        self.namespace == namespace
226    }
227
228    /// Check if this is a specific extension type.
229    #[must_use]
230    pub fn is_type(&self, namespace: &str, mark_type: &str) -> bool {
231        self.namespace == namespace && self.mark_type == mark_type
232    }
233
234    /// Set the attributes.
235    #[must_use]
236    pub fn with_attributes(mut self, attributes: serde_json::Value) -> Self {
237        self.attributes = attributes;
238        self
239    }
240
241    /// Get an attribute value by key.
242    #[must_use]
243    pub fn get_attribute(&self, key: &str) -> Option<&serde_json::Value> {
244        self.attributes.get(key)
245    }
246
247    /// Get a string attribute.
248    #[must_use]
249    pub fn get_string_attribute(&self, key: &str) -> Option<&str> {
250        self.attributes.get(key).and_then(serde_json::Value::as_str)
251    }
252
253    // ===== Convenience constructors for common extension marks =====
254
255    /// Create a citation mark (semantic extension).
256    #[must_use]
257    pub fn citation(reference: impl Into<String>) -> Self {
258        Self::new("semantic", "citation").with_attributes(serde_json::json!({
259            "ref": reference.into()
260        }))
261    }
262
263    /// Create a citation mark with page locator.
264    #[must_use]
265    pub fn citation_with_page(reference: impl Into<String>, page: impl Into<String>) -> Self {
266        Self::new("semantic", "citation").with_attributes(serde_json::json!({
267            "ref": reference.into(),
268            "locator": page.into(),
269            "locatorType": "page"
270        }))
271    }
272
273    /// Create an entity link mark (semantic extension).
274    #[must_use]
275    pub fn entity(uri: impl Into<String>, entity_type: impl Into<String>) -> Self {
276        Self::new("semantic", "entity").with_attributes(serde_json::json!({
277            "uri": uri.into(),
278            "entityType": entity_type.into()
279        }))
280    }
281
282    /// Create a glossary reference mark (semantic extension).
283    #[must_use]
284    pub fn glossary(term_id: impl Into<String>) -> Self {
285        Self::new("semantic", "glossary").with_attributes(serde_json::json!({
286            "termId": term_id.into()
287        }))
288    }
289
290    /// Create an index mark (presentation extension).
291    #[must_use]
292    pub fn index(term: impl Into<String>) -> Self {
293        Self::new("presentation", "index").with_attributes(serde_json::json!({
294            "term": term.into()
295        }))
296    }
297
298    /// Create an index mark with subterm.
299    #[must_use]
300    pub fn index_with_subterm(term: impl Into<String>, subterm: impl Into<String>) -> Self {
301        Self::new("presentation", "index").with_attributes(serde_json::json!({
302            "term": term.into(),
303            "subterm": subterm.into()
304        }))
305    }
306
307    // ===== Academic extension marks =====
308
309    /// Create an equation reference mark (academic extension).
310    ///
311    /// References an equation by its ID (e.g., "#eq-pythagoras").
312    #[must_use]
313    pub fn equation_ref(target: impl Into<String>) -> Self {
314        Self::new("academic", "equation-ref").with_attributes(serde_json::json!({
315            "target": target.into()
316        }))
317    }
318
319    /// Create an equation reference mark with custom format.
320    ///
321    /// The format string can use `{number}` as a placeholder for the equation number.
322    #[must_use]
323    pub fn equation_ref_formatted(target: impl Into<String>, format: impl Into<String>) -> Self {
324        Self::new("academic", "equation-ref").with_attributes(serde_json::json!({
325            "target": target.into(),
326            "format": format.into()
327        }))
328    }
329
330    /// Create an algorithm reference mark (academic extension).
331    ///
332    /// References an algorithm by its ID (e.g., "#alg-quicksort").
333    #[must_use]
334    pub fn algorithm_ref(target: impl Into<String>) -> Self {
335        Self::new("academic", "algorithm-ref").with_attributes(serde_json::json!({
336            "target": target.into()
337        }))
338    }
339
340    /// Create an algorithm reference mark with line reference.
341    ///
342    /// References a specific line within an algorithm.
343    #[must_use]
344    pub fn algorithm_ref_line(target: impl Into<String>, line: impl Into<String>) -> Self {
345        Self::new("academic", "algorithm-ref").with_attributes(serde_json::json!({
346            "target": target.into(),
347            "line": line.into()
348        }))
349    }
350
351    /// Create an algorithm reference mark with custom format.
352    ///
353    /// The format string can use `{number}` and `{line}` as placeholders.
354    #[must_use]
355    pub fn algorithm_ref_formatted(target: impl Into<String>, format: impl Into<String>) -> Self {
356        Self::new("academic", "algorithm-ref").with_attributes(serde_json::json!({
357            "target": target.into(),
358            "format": format.into()
359        }))
360    }
361
362    /// Create an algorithm reference mark with line and custom format.
363    #[must_use]
364    pub fn algorithm_ref_line_formatted(
365        target: impl Into<String>,
366        line: impl Into<String>,
367        format: impl Into<String>,
368    ) -> Self {
369        Self::new("academic", "algorithm-ref").with_attributes(serde_json::json!({
370            "target": target.into(),
371            "line": line.into(),
372            "format": format.into()
373        }))
374    }
375
376    /// Create a theorem reference mark (academic extension).
377    ///
378    /// References a theorem by its ID (e.g., "#thm-pythagoras").
379    #[must_use]
380    pub fn theorem_ref(target: impl Into<String>) -> Self {
381        Self::new("academic", "theorem-ref").with_attributes(serde_json::json!({
382            "target": target.into()
383        }))
384    }
385
386    /// Create a theorem reference mark with custom format.
387    ///
388    /// The format string can use `{number}` and `{variant}` as placeholders.
389    #[must_use]
390    pub fn theorem_ref_formatted(target: impl Into<String>, format: impl Into<String>) -> Self {
391        Self::new("academic", "theorem-ref").with_attributes(serde_json::json!({
392            "target": target.into(),
393            "format": format.into()
394        }))
395    }
396
397    // ===== Collaboration extension marks =====
398
399    /// Create a highlight mark (collaboration extension).
400    ///
401    /// Applies a colored highlight to text for collaborative annotation.
402    /// Default color is yellow if not specified.
403    #[must_use]
404    pub fn highlight(color: impl Into<String>) -> Self {
405        Self::new("collaboration", "highlight").with_attributes(serde_json::json!({
406            "color": color.into()
407        }))
408    }
409
410    /// Create a highlight mark with default yellow color.
411    #[must_use]
412    pub fn highlight_yellow() -> Self {
413        Self::highlight("yellow")
414    }
415
416    /// Create a highlight mark with a specific color.
417    ///
418    /// Convenience method that accepts the `HighlightColor` display string.
419    #[must_use]
420    pub fn highlight_colored(color: impl std::fmt::Display) -> Self {
421        Self::highlight(color.to_string())
422    }
423}
424
425impl Mark {
426    /// Get the type of this mark.
427    #[must_use]
428    pub fn mark_type(&self) -> MarkType {
429        match self {
430            Self::Bold => MarkType::Bold,
431            Self::Italic => MarkType::Italic,
432            Self::Underline => MarkType::Underline,
433            Self::Strikethrough => MarkType::Strikethrough,
434            Self::Code => MarkType::Code,
435            Self::Superscript => MarkType::Superscript,
436            Self::Subscript => MarkType::Subscript,
437            Self::Link { .. } => MarkType::Link,
438            Self::Anchor { .. } => MarkType::Anchor,
439            Self::Footnote { .. } => MarkType::Footnote,
440            Self::Math { .. } => MarkType::Math,
441            Self::Extension(_) => MarkType::Extension,
442        }
443    }
444
445    /// Check if this mark is an extension mark.
446    #[must_use]
447    pub fn is_extension(&self) -> bool {
448        matches!(self, Self::Extension(_))
449    }
450
451    /// Get the extension mark if this is one.
452    #[must_use]
453    pub fn as_extension(&self) -> Option<&ExtensionMark> {
454        match self {
455            Self::Extension(ext) => Some(ext),
456            _ => None,
457        }
458    }
459}
460
461/// Type identifier for marks (without associated data).
462#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
463pub enum MarkType {
464    /// Bold mark type.
465    Bold,
466    /// Italic mark type.
467    Italic,
468    /// Underline mark type.
469    Underline,
470    /// Strikethrough mark type.
471    Strikethrough,
472    /// Code mark type.
473    Code,
474    /// Superscript mark type.
475    Superscript,
476    /// Subscript mark type.
477    Subscript,
478    /// Link mark type.
479    Link,
480    /// Anchor mark type.
481    Anchor,
482    /// Footnote mark type.
483    Footnote,
484    /// Math mark type.
485    Math,
486    /// Extension mark type.
487    Extension,
488}
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493
494    #[test]
495    fn test_text_plain() {
496        let text = Text::plain("Hello");
497        assert_eq!(text.value, "Hello");
498        assert!(text.marks.is_empty());
499        assert!(!text.has_marks());
500    }
501
502    #[test]
503    fn test_text_bold() {
504        let text = Text::bold("Important");
505        assert_eq!(text.marks, vec![Mark::Bold]);
506        assert!(text.has_marks());
507        assert!(text.has_mark(MarkType::Bold));
508        assert!(!text.has_mark(MarkType::Italic));
509    }
510
511    #[test]
512    fn test_text_link() {
513        let text = Text::link("Click", "https://example.com");
514        assert!(text.has_mark(MarkType::Link));
515        if let Mark::Link { href, title } = &text.marks[0] {
516            assert_eq!(href, "https://example.com");
517            assert!(title.is_none());
518        } else {
519            panic!("Expected Link mark");
520        }
521    }
522
523    #[test]
524    fn test_text_serialization() {
525        let text = Text::bold("Test");
526        let json = serde_json::to_string(&text).unwrap();
527        assert!(json.contains("\"value\":\"Test\""));
528        assert!(json.contains("\"type\":\"bold\""));
529    }
530
531    #[test]
532    fn test_text_deserialization() {
533        let json = r#"{"value":"Test","marks":[{"type":"bold"},{"type":"italic"}]}"#;
534        let text: Text = serde_json::from_str(json).unwrap();
535        assert_eq!(text.value, "Test");
536        assert_eq!(text.marks.len(), 2);
537    }
538
539    #[test]
540    fn test_link_with_title() {
541        let json = r#"{"type":"link","href":"https://example.com","title":"Example"}"#;
542        let mark: Mark = serde_json::from_str(json).unwrap();
543        if let Mark::Link { href, title } = mark {
544            assert_eq!(href, "https://example.com");
545            assert_eq!(title, Some("Example".to_string()));
546        } else {
547            panic!("Expected Link mark");
548        }
549    }
550
551    #[test]
552    fn test_text_footnote() {
553        let text = Text::footnote("important claim", 1);
554        assert!(text.has_mark(MarkType::Footnote));
555        if let Mark::Footnote { number, id } = &text.marks[0] {
556            assert_eq!(*number, 1);
557            assert!(id.is_none());
558        } else {
559            panic!("Expected Footnote mark");
560        }
561    }
562
563    #[test]
564    fn test_footnote_mark_serialization() {
565        let mark = Mark::Footnote {
566            number: 1,
567            id: Some("fn1".to_string()),
568        };
569        let json = serde_json::to_string(&mark).unwrap();
570        assert!(json.contains("\"type\":\"footnote\""));
571        assert!(json.contains("\"number\":1"));
572        assert!(json.contains("\"id\":\"fn1\""));
573    }
574
575    #[test]
576    fn test_footnote_mark_deserialization() {
577        let json = r#"{"type":"footnote","number":2,"id":"fn-2"}"#;
578        let mark: Mark = serde_json::from_str(json).unwrap();
579        if let Mark::Footnote { number, id } = mark {
580            assert_eq!(number, 2);
581            assert_eq!(id, Some("fn-2".to_string()));
582        } else {
583            panic!("Expected Footnote mark");
584        }
585    }
586
587    #[test]
588    fn test_footnote_mark_without_id() {
589        let json = r#"{"type":"footnote","number":3}"#;
590        let mark: Mark = serde_json::from_str(json).unwrap();
591        if let Mark::Footnote { number, id } = mark {
592            assert_eq!(number, 3);
593            assert!(id.is_none());
594        } else {
595            panic!("Expected Footnote mark");
596        }
597    }
598
599    #[test]
600    fn test_math_mark() {
601        use crate::content::block::MathFormat;
602
603        let mark = Mark::Math {
604            format: MathFormat::Latex,
605            value: "E = mc^2".to_string(),
606        };
607        assert_eq!(mark.mark_type(), MarkType::Math);
608    }
609
610    #[test]
611    fn test_math_mark_serialization() {
612        use crate::content::block::MathFormat;
613
614        let mark = Mark::Math {
615            format: MathFormat::Latex,
616            value: "\\frac{1}{2}".to_string(),
617        };
618        let json = serde_json::to_string(&mark).unwrap();
619        assert!(json.contains("\"type\":\"math\""));
620        assert!(json.contains("\"format\":\"latex\""));
621        assert!(json.contains("\"value\":\"\\\\frac{1}{2}\""));
622    }
623
624    #[test]
625    fn test_math_mark_deserialization() {
626        use crate::content::block::MathFormat;
627
628        let json = r#"{"type":"math","format":"mathml","value":"<math>...</math>"}"#;
629        let mark: Mark = serde_json::from_str(json).unwrap();
630        if let Mark::Math { format, value } = mark {
631            assert_eq!(format, MathFormat::Mathml);
632            assert_eq!(value, "<math>...</math>");
633        } else {
634            panic!("Expected Math mark");
635        }
636    }
637
638    #[test]
639    fn test_text_with_math_mark() {
640        use crate::content::block::MathFormat;
641
642        let text = Text::with_marks(
643            "x²",
644            vec![Mark::Math {
645                format: MathFormat::Latex,
646                value: "x^2".to_string(),
647            }],
648        );
649        assert!(text.has_mark(MarkType::Math));
650    }
651
652    // Extension mark tests
653
654    #[test]
655    fn test_extension_mark_new() {
656        let ext = ExtensionMark::new("semantic", "citation");
657        assert_eq!(ext.namespace, "semantic");
658        assert_eq!(ext.mark_type, "citation");
659        assert_eq!(ext.full_type(), "semantic:citation");
660    }
661
662    #[test]
663    fn test_extension_mark_parse_type() {
664        assert_eq!(
665            ExtensionMark::parse_type("semantic:citation"),
666            Some(("semantic", "citation"))
667        );
668        assert_eq!(
669            ExtensionMark::parse_type("legal:cite"),
670            Some(("legal", "cite"))
671        );
672        assert_eq!(ExtensionMark::parse_type("bold"), None);
673    }
674
675    #[test]
676    fn test_extension_mark_with_attributes() {
677        let ext = ExtensionMark::new("semantic", "citation").with_attributes(serde_json::json!({
678            "ref": "smith2023",
679            "page": "42"
680        }));
681
682        assert_eq!(ext.get_string_attribute("ref"), Some("smith2023"));
683        assert_eq!(ext.get_string_attribute("page"), Some("42"));
684    }
685
686    #[test]
687    fn test_extension_mark_namespace_check() {
688        let ext = ExtensionMark::new("semantic", "citation");
689        assert!(ext.is_namespace("semantic"));
690        assert!(!ext.is_namespace("legal"));
691        assert!(ext.is_type("semantic", "citation"));
692        assert!(!ext.is_type("semantic", "entity"));
693    }
694
695    #[test]
696    fn test_mark_extension_variant() {
697        let ext = ExtensionMark::new("semantic", "citation");
698        let mark = Mark::Extension(ext.clone());
699
700        assert!(mark.is_extension());
701        assert_eq!(mark.mark_type(), MarkType::Extension);
702        assert_eq!(
703            mark.as_extension().unwrap().full_type(),
704            "semantic:citation"
705        );
706    }
707
708    #[test]
709    fn test_extension_mark_serialization() {
710        let ext = ExtensionMark::new("semantic", "citation").with_attributes(serde_json::json!({
711            "ref": "smith2023"
712        }));
713        let mark = Mark::Extension(ext);
714
715        let json = serde_json::to_string(&mark).unwrap();
716        assert!(json.contains("\"type\":\"extension\""));
717        assert!(json.contains("\"namespace\":\"semantic\""));
718        assert!(json.contains("\"markType\":\"citation\""));
719        assert!(json.contains("\"ref\":\"smith2023\""));
720    }
721
722    #[test]
723    fn test_extension_mark_deserialization() {
724        let json = r#"{
725            "type": "extension",
726            "namespace": "legal",
727            "markType": "cite",
728            "attributes": {
729                "citation": "Brown v. Board of Education"
730            }
731        }"#;
732        let mark: Mark = serde_json::from_str(json).unwrap();
733
734        if let Mark::Extension(ext) = mark {
735            assert_eq!(ext.namespace, "legal");
736            assert_eq!(ext.mark_type, "cite");
737            assert_eq!(
738                ext.get_string_attribute("citation"),
739                Some("Brown v. Board of Education")
740            );
741        } else {
742            panic!("Expected Extension mark");
743        }
744    }
745
746    #[test]
747    fn test_text_with_extension_mark() {
748        let mark = Mark::Extension(ExtensionMark::citation("smith2023"));
749        let text = Text::with_marks("important claim", vec![mark]);
750
751        assert!(text.has_mark(MarkType::Extension));
752        if let Mark::Extension(ext) = &text.marks[0] {
753            assert_eq!(ext.namespace, "semantic");
754            assert_eq!(ext.mark_type, "citation");
755        } else {
756            panic!("Expected Extension mark");
757        }
758    }
759
760    #[test]
761    fn test_citation_convenience() {
762        let ext = ExtensionMark::citation("smith2023");
763        assert!(ext.is_type("semantic", "citation"));
764        assert_eq!(ext.get_string_attribute("ref"), Some("smith2023"));
765    }
766
767    #[test]
768    fn test_citation_with_page_convenience() {
769        let ext = ExtensionMark::citation_with_page("smith2023", "42-45");
770        assert!(ext.is_type("semantic", "citation"));
771        assert_eq!(ext.get_string_attribute("ref"), Some("smith2023"));
772        assert_eq!(ext.get_string_attribute("locator"), Some("42-45"));
773        assert_eq!(ext.get_string_attribute("locatorType"), Some("page"));
774    }
775
776    #[test]
777    fn test_entity_convenience() {
778        let ext = ExtensionMark::entity("https://www.wikidata.org/wiki/Q937", "person");
779        assert!(ext.is_type("semantic", "entity"));
780        assert_eq!(
781            ext.get_string_attribute("uri"),
782            Some("https://www.wikidata.org/wiki/Q937")
783        );
784        assert_eq!(ext.get_string_attribute("entityType"), Some("person"));
785    }
786
787    #[test]
788    fn test_glossary_convenience() {
789        let ext = ExtensionMark::glossary("api-term");
790        assert!(ext.is_type("semantic", "glossary"));
791        assert_eq!(ext.get_string_attribute("termId"), Some("api-term"));
792    }
793
794    #[test]
795    fn test_index_convenience() {
796        let ext = ExtensionMark::index("algorithm");
797        assert!(ext.is_type("presentation", "index"));
798        assert_eq!(ext.get_string_attribute("term"), Some("algorithm"));
799    }
800
801    #[test]
802    fn test_index_with_subterm_convenience() {
803        let ext = ExtensionMark::index_with_subterm("algorithm", "sorting");
804        assert!(ext.is_type("presentation", "index"));
805        assert_eq!(ext.get_string_attribute("term"), Some("algorithm"));
806        assert_eq!(ext.get_string_attribute("subterm"), Some("sorting"));
807    }
808
809    #[test]
810    fn test_non_extension_mark_as_extension() {
811        let mark = Mark::Bold;
812        assert!(!mark.is_extension());
813        assert!(mark.as_extension().is_none());
814    }
815
816    #[test]
817    fn test_equation_ref_convenience() {
818        let ext = ExtensionMark::equation_ref("#eq-pythagoras");
819        assert!(ext.is_type("academic", "equation-ref"));
820        assert_eq!(ext.get_string_attribute("target"), Some("#eq-pythagoras"));
821        assert!(ext.get_string_attribute("format").is_none());
822    }
823
824    #[test]
825    fn test_equation_ref_formatted_convenience() {
826        let ext = ExtensionMark::equation_ref_formatted("#eq-1", "Equation ({number})");
827        assert!(ext.is_type("academic", "equation-ref"));
828        assert_eq!(ext.get_string_attribute("target"), Some("#eq-1"));
829        assert_eq!(
830            ext.get_string_attribute("format"),
831            Some("Equation ({number})")
832        );
833    }
834
835    #[test]
836    fn test_algorithm_ref_convenience() {
837        let ext = ExtensionMark::algorithm_ref("#alg-quicksort");
838        assert!(ext.is_type("academic", "algorithm-ref"));
839        assert_eq!(ext.get_string_attribute("target"), Some("#alg-quicksort"));
840        assert!(ext.get_string_attribute("line").is_none());
841    }
842
843    #[test]
844    fn test_algorithm_ref_line_convenience() {
845        let ext = ExtensionMark::algorithm_ref_line("#alg-bisection", "loop");
846        assert!(ext.is_type("academic", "algorithm-ref"));
847        assert_eq!(ext.get_string_attribute("target"), Some("#alg-bisection"));
848        assert_eq!(ext.get_string_attribute("line"), Some("loop"));
849    }
850
851    #[test]
852    fn test_algorithm_ref_formatted_convenience() {
853        let ext = ExtensionMark::algorithm_ref_formatted("#alg-1", "Algorithm {number}");
854        assert!(ext.is_type("academic", "algorithm-ref"));
855        assert_eq!(ext.get_string_attribute("target"), Some("#alg-1"));
856        assert_eq!(
857            ext.get_string_attribute("format"),
858            Some("Algorithm {number}")
859        );
860    }
861
862    #[test]
863    fn test_algorithm_ref_line_formatted_convenience() {
864        let ext = ExtensionMark::algorithm_ref_line_formatted("#alg-1", "pivot", "line {line}");
865        assert!(ext.is_type("academic", "algorithm-ref"));
866        assert_eq!(ext.get_string_attribute("target"), Some("#alg-1"));
867        assert_eq!(ext.get_string_attribute("line"), Some("pivot"));
868        assert_eq!(ext.get_string_attribute("format"), Some("line {line}"));
869    }
870
871    #[test]
872    fn test_theorem_ref_convenience() {
873        let ext = ExtensionMark::theorem_ref("#thm-pythagoras");
874        assert!(ext.is_type("academic", "theorem-ref"));
875        assert_eq!(ext.get_string_attribute("target"), Some("#thm-pythagoras"));
876    }
877
878    #[test]
879    fn test_theorem_ref_formatted_convenience() {
880        let ext = ExtensionMark::theorem_ref_formatted("#thm-1", "{variant} {number}");
881        assert!(ext.is_type("academic", "theorem-ref"));
882        assert_eq!(ext.get_string_attribute("target"), Some("#thm-1"));
883        assert_eq!(
884            ext.get_string_attribute("format"),
885            Some("{variant} {number}")
886        );
887    }
888
889    #[test]
890    fn test_highlight_mark_convenience() {
891        let ext = ExtensionMark::highlight("yellow");
892        assert!(ext.is_type("collaboration", "highlight"));
893        assert_eq!(ext.get_string_attribute("color"), Some("yellow"));
894    }
895
896    #[test]
897    fn test_highlight_yellow_convenience() {
898        let ext = ExtensionMark::highlight_yellow();
899        assert!(ext.is_type("collaboration", "highlight"));
900        assert_eq!(ext.get_string_attribute("color"), Some("yellow"));
901    }
902
903    #[test]
904    fn test_highlight_colored_convenience() {
905        // Test with a string that would come from HighlightColor::display()
906        let ext = ExtensionMark::highlight_colored("green");
907        assert!(ext.is_type("collaboration", "highlight"));
908        assert_eq!(ext.get_string_attribute("color"), Some("green"));
909    }
910}
911
912#[cfg(test)]
913mod proptests {
914    use super::*;
915    use proptest::prelude::*;
916
917    /// Generate arbitrary text content.
918    fn arb_text_value() -> impl Strategy<Value = String> {
919        "[a-zA-Z0-9 .,!?]{0,100}".prop_map(|s| s)
920    }
921
922    /// Generate arbitrary URL for links.
923    fn arb_url() -> impl Strategy<Value = String> {
924        "(https?://[a-z]{3,10}\\.[a-z]{2,4}(/[a-z0-9]{0,10})?)".prop_map(|s| s)
925    }
926
927    /// Generate arbitrary basic mark (no associated data).
928    fn arb_simple_mark() -> impl Strategy<Value = Mark> {
929        prop_oneof![
930            Just(Mark::Bold),
931            Just(Mark::Italic),
932            Just(Mark::Underline),
933            Just(Mark::Strikethrough),
934            Just(Mark::Code),
935            Just(Mark::Superscript),
936            Just(Mark::Subscript),
937        ]
938    }
939
940    /// Generate arbitrary link mark.
941    fn arb_link_mark() -> impl Strategy<Value = Mark> {
942        (arb_url(), prop::option::of("[a-zA-Z ]{0,20}"))
943            .prop_map(|(href, title)| Mark::Link { href, title })
944    }
945
946    /// Generate arbitrary footnote mark.
947    fn arb_footnote_mark() -> impl Strategy<Value = Mark> {
948        (1u32..1000u32, prop::option::of("[a-z]{2,10}"))
949            .prop_map(|(number, id)| Mark::Footnote { number, id })
950    }
951
952    /// Generate arbitrary mark.
953    fn arb_mark() -> impl Strategy<Value = Mark> {
954        prop_oneof![arb_simple_mark(), arb_link_mark(), arb_footnote_mark(),]
955    }
956
957    /// Generate arbitrary text node.
958    fn arb_text() -> impl Strategy<Value = Text> {
959        (arb_text_value(), prop::collection::vec(arb_mark(), 0..3))
960            .prop_map(|(value, marks)| Text { value, marks })
961    }
962
963    proptest! {
964        /// Plain text has no marks.
965        #[test]
966        fn plain_text_no_marks(value in arb_text_value()) {
967            let text = Text::plain(&value);
968            prop_assert_eq!(&text.value, &value);
969            prop_assert!(text.marks.is_empty());
970            prop_assert!(!text.has_marks());
971        }
972
973        /// Bold text has exactly one bold mark.
974        #[test]
975        fn bold_text_has_bold_mark(value in arb_text_value()) {
976            let text = Text::bold(&value);
977            prop_assert_eq!(&text.value, &value);
978            prop_assert_eq!(text.marks.len(), 1);
979            prop_assert!(text.has_mark(MarkType::Bold));
980        }
981
982        /// Italic text has exactly one italic mark.
983        #[test]
984        fn italic_text_has_italic_mark(value in arb_text_value()) {
985            let text = Text::italic(&value);
986            prop_assert_eq!(&text.value, &value);
987            prop_assert_eq!(text.marks.len(), 1);
988            prop_assert!(text.has_mark(MarkType::Italic));
989        }
990
991        /// Code text has exactly one code mark.
992        #[test]
993        fn code_text_has_code_mark(value in arb_text_value()) {
994            let text = Text::code(&value);
995            prop_assert_eq!(&text.value, &value);
996            prop_assert_eq!(text.marks.len(), 1);
997            prop_assert!(text.has_mark(MarkType::Code));
998        }
999
1000        /// Link text has exactly one link mark with correct href.
1001        #[test]
1002        fn link_text_has_link_mark(value in arb_text_value(), href in arb_url()) {
1003            let text = Text::link(&value, &href);
1004            prop_assert_eq!(&text.value, &value);
1005            prop_assert_eq!(text.marks.len(), 1);
1006            prop_assert!(text.has_mark(MarkType::Link));
1007            if let Mark::Link { href: actual_href, .. } = &text.marks[0] {
1008                prop_assert_eq!(actual_href, &href);
1009            }
1010        }
1011
1012        /// Text JSON roundtrip - serialize and deserialize should preserve data.
1013        #[test]
1014        fn text_json_roundtrip(text in arb_text()) {
1015            let json = serde_json::to_string(&text).unwrap();
1016            let parsed: Text = serde_json::from_str(&json).unwrap();
1017            prop_assert_eq!(text, parsed);
1018        }
1019
1020        /// Mark JSON roundtrip - serialize and deserialize should preserve data.
1021        #[test]
1022        fn mark_json_roundtrip(mark in arb_mark()) {
1023            let json = serde_json::to_string(&mark).unwrap();
1024            let parsed: Mark = serde_json::from_str(&json).unwrap();
1025            prop_assert_eq!(mark, parsed);
1026        }
1027
1028        /// Simple mark types are identified correctly.
1029        #[test]
1030        fn simple_mark_types(mark in arb_simple_mark()) {
1031            let expected = match mark {
1032                Mark::Bold => MarkType::Bold,
1033                Mark::Italic => MarkType::Italic,
1034                Mark::Underline => MarkType::Underline,
1035                Mark::Strikethrough => MarkType::Strikethrough,
1036                Mark::Code => MarkType::Code,
1037                Mark::Superscript => MarkType::Superscript,
1038                Mark::Subscript => MarkType::Subscript,
1039                Mark::Link { .. }
1040                | Mark::Anchor { .. }
1041                | Mark::Footnote { .. }
1042                | Mark::Math { .. }
1043                | Mark::Extension(_) => {
1044                    // arb_simple_mark() should never generate these variants
1045                    prop_assert!(false, "arb_simple_mark() generated non-simple mark: {mark:?}");
1046                    return Ok(());
1047                }
1048            };
1049            prop_assert_eq!(mark.mark_type(), expected);
1050        }
1051
1052        /// Link marks return Link type.
1053        #[test]
1054        fn link_mark_type(mark in arb_link_mark()) {
1055            prop_assert_eq!(mark.mark_type(), MarkType::Link);
1056        }
1057
1058        /// Footnote marks return Footnote type.
1059        #[test]
1060        fn footnote_mark_type(mark in arb_footnote_mark()) {
1061            prop_assert_eq!(mark.mark_type(), MarkType::Footnote);
1062        }
1063
1064        /// has_marks is consistent with marks vector.
1065        #[test]
1066        fn has_marks_consistent(text in arb_text()) {
1067            prop_assert_eq!(text.has_marks(), !text.marks.is_empty());
1068        }
1069    }
1070}