Skip to main content

typub_ir/
lib.rs

1//! Typub IR v2 (semantic document IR).
2//!
3//! This is a semantic-first IR surface aligned with RFC-0009.
4
5use relative_path::RelativePathBuf;
6use serde::de::{self, Deserializer};
7use serde::ser::Serializer;
8use serde::{Deserialize, Serialize};
9use serde_json::Value as JsonValue;
10use std::collections::BTreeMap;
11use std::path::Path;
12
13pub type AttrMap = BTreeMap<String, String>;
14pub type ExtensionMap = BTreeMap<String, JsonValue>;
15
16pub fn empty_map() -> AttrMap {
17    AttrMap::new()
18}
19
20#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
21pub struct AnchorId(pub String);
22
23#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
24pub struct FootnoteId(pub String);
25
26#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
27pub struct AssetId(pub String);
28
29#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
30pub struct Url(pub String);
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
33pub struct HeadingLevel(u8);
34
35impl HeadingLevel {
36    pub fn new(level: u8) -> Result<Self, String> {
37        if (1..=6).contains(&level) {
38            Ok(Self(level))
39        } else {
40            Err("heading level must be in 1..=6".to_string())
41        }
42    }
43
44    pub fn get(self) -> u8 {
45        self.0
46    }
47}
48
49impl Serialize for HeadingLevel {
50    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
51    where
52        S: Serializer,
53    {
54        serializer.serialize_u8(self.0)
55    }
56}
57
58impl<'de> Deserialize<'de> for HeadingLevel {
59    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
60    where
61        D: Deserializer<'de>,
62    {
63        let value = u8::deserialize(deserializer)?;
64        HeadingLevel::new(value).map_err(de::Error::custom)
65    }
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
69pub struct ExtensionKind(String);
70
71impl ExtensionKind {
72    pub fn new(kind: String) -> Result<Self, String> {
73        let k = kind.trim();
74        if k.is_empty() {
75            return Err("extension kind must not be empty".to_string());
76        }
77        if !(k.contains(':') || k.contains('/')) {
78            return Err("extension kind must be namespaced (use ':' or '/')".to_string());
79        }
80        Ok(Self(k.to_string()))
81    }
82
83    pub fn as_str(&self) -> &str {
84        &self.0
85    }
86}
87
88impl Serialize for ExtensionKind {
89    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
90    where
91        S: Serializer,
92    {
93        serializer.serialize_str(self.as_str())
94    }
95}
96
97impl<'de> Deserialize<'de> for ExtensionKind {
98    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
99    where
100        D: Deserializer<'de>,
101    {
102        let value = String::deserialize(deserializer)?;
103        ExtensionKind::new(value).map_err(de::Error::custom)
104    }
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
108pub struct AssetRef(pub AssetId);
109
110#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
111pub struct Document {
112    pub blocks: Vec<Block>,
113    pub footnotes: BTreeMap<FootnoteId, FootnoteDef>,
114    pub assets: BTreeMap<AssetId, Asset>,
115    pub meta: DocMeta,
116}
117
118#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
119pub struct DocMeta {
120    pub title: Option<String>,
121    pub slug: Option<String>,
122    pub description: Option<String>,
123    pub tags: Vec<String>,
124    pub toc: Option<Vec<TocEntry>>,
125    pub extensions: ExtensionMap,
126}
127
128#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
129pub struct TocEntry {
130    pub level: HeadingLevel,
131    pub id: AnchorId,
132    pub title: InlineSeq,
133}
134
135#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
136pub struct BlockAttrs {
137    pub classes: Vec<String>,
138    pub style: Option<String>,
139    pub passthrough: AttrMap,
140}
141
142#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
143pub struct InlineAttrs {
144    pub classes: Vec<String>,
145    pub style: Option<String>,
146    pub passthrough: AttrMap,
147}
148
149#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
150pub struct ImageAttrs {
151    pub width: Option<u32>,
152    pub height: Option<u32>,
153    pub align: Option<TextAlign>,
154    pub passthrough: AttrMap,
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
158pub enum TextAlign {
159    Left,
160    Center,
161    Right,
162}
163
164pub type InlineSeq = Vec<Inline>;
165
166#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
167pub enum Block {
168    Heading {
169        level: HeadingLevel,
170        id: Option<AnchorId>,
171        content: InlineSeq,
172        attrs: BlockAttrs,
173    },
174    Paragraph {
175        content: InlineSeq,
176        attrs: BlockAttrs,
177    },
178    Quote {
179        blocks: Vec<Block>,
180        cite: Option<Url>,
181        attrs: BlockAttrs,
182    },
183    CodeBlock {
184        code: String,
185        language: Option<String>,
186        filename: Option<String>,
187        highlight_lines: Vec<u32>,
188        highlighted_html: Option<String>,
189        attrs: BlockAttrs,
190    },
191    Divider {
192        attrs: BlockAttrs,
193    },
194    List {
195        list: List,
196        attrs: BlockAttrs,
197    },
198    DefinitionList {
199        items: Vec<DefinitionItem>,
200        attrs: BlockAttrs,
201    },
202    Table {
203        caption: Option<Vec<Block>>,
204        sections: Vec<TableSection>,
205        attrs: BlockAttrs,
206    },
207    Figure {
208        content: Vec<Block>,
209        caption: Option<Vec<Block>>,
210        attrs: BlockAttrs,
211    },
212    Admonition {
213        kind: AdmonitionKind,
214        title: Option<InlineSeq>,
215        blocks: Vec<Block>,
216        attrs: BlockAttrs,
217    },
218    Details {
219        summary: Option<InlineSeq>,
220        blocks: Vec<Block>,
221        open: bool,
222        attrs: BlockAttrs,
223    },
224    MathBlock {
225        math: RenderPayload,
226        attrs: BlockAttrs,
227    },
228    SvgBlock {
229        svg: RenderPayload,
230        attrs: BlockAttrs,
231    },
232    UnknownBlock {
233        tag: String,
234        attrs: BlockAttrs,
235        children: Vec<UnknownChild>,
236        data: ExtensionMap,
237        note: Option<String>,
238        source: Option<String>,
239    },
240    RawBlock {
241        html: String,
242        origin: RawOrigin,
243        trust: RawTrust,
244        attrs: BlockAttrs,
245    },
246}
247
248#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
249pub enum Inline {
250    Text(String),
251    Code(String),
252    SoftBreak,
253    HardBreak,
254    Styled {
255        styles: StyleSet,
256        content: InlineSeq,
257        attrs: InlineAttrs,
258    },
259    Link {
260        content: InlineSeq,
261        href: Url,
262        title: Option<String>,
263        attrs: InlineAttrs,
264    },
265    Image {
266        asset: AssetRef,
267        alt: String,
268        title: Option<String>,
269        attrs: ImageAttrs,
270    },
271    FootnoteRef(FootnoteId),
272    MathInline {
273        math: RenderPayload,
274        attrs: InlineAttrs,
275    },
276    SvgInline {
277        svg: RenderPayload,
278        attrs: InlineAttrs,
279    },
280    UnknownInline {
281        tag: String,
282        attrs: InlineAttrs,
283        content: InlineSeq,
284        data: ExtensionMap,
285        note: Option<String>,
286        source: Option<String>,
287    },
288    RawInline {
289        html: String,
290        origin: RawOrigin,
291        trust: RawTrust,
292        attrs: InlineAttrs,
293    },
294}
295
296#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
297pub struct StyleSet {
298    styles: Vec<TextStyle>,
299}
300
301impl StyleSet {
302    pub fn new(styles: Vec<TextStyle>) -> Result<Self, String> {
303        let mut set = Self { styles };
304        set.canonicalize();
305        if set.styles.is_empty() {
306            return Err("StyleSet must contain at least one style".to_string());
307        }
308        Ok(set)
309    }
310
311    pub fn single(style: TextStyle) -> Self {
312        Self {
313            styles: vec![style],
314        }
315    }
316
317    pub fn styles(&self) -> &[TextStyle] {
318        &self.styles
319    }
320
321    pub fn canonicalize(&mut self) {
322        self.styles.sort_by_key(|s| s.canonical_rank());
323        self.styles.dedup();
324    }
325}
326
327impl<'de> Deserialize<'de> for StyleSet {
328    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
329    where
330        D: Deserializer<'de>,
331    {
332        #[derive(Deserialize)]
333        struct RawStyleSet {
334            styles: Vec<TextStyle>,
335        }
336
337        let raw = RawStyleSet::deserialize(deserializer)?;
338        StyleSet::new(raw.styles).map_err(de::Error::custom)
339    }
340}
341
342#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
343pub enum TextStyle {
344    Bold,
345    Italic,
346    Strikethrough,
347    Underline,
348    Mark,
349    Superscript,
350    Subscript,
351    Kbd,
352}
353
354impl TextStyle {
355    fn canonical_rank(&self) -> u8 {
356        match self {
357            Self::Bold => 0,
358            Self::Italic => 1,
359            Self::Strikethrough => 2,
360            Self::Underline => 3,
361            Self::Mark => 4,
362            Self::Superscript => 5,
363            Self::Subscript => 6,
364            Self::Kbd => 7,
365        }
366    }
367}
368
369#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
370pub struct List {
371    pub kind: ListKind,
372}
373
374#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
375pub enum ListKind {
376    Bullet {
377        items: Vec<FlowListItem>,
378    },
379    Numbered {
380        start: u32,
381        reversed: bool,
382        marker: Option<OrderedListMarker>,
383        items: Vec<FlowListItem>,
384    },
385    Task {
386        items: Vec<TaskListItem>,
387    },
388    Custom {
389        kind: ExtensionKind,
390        items: Vec<CustomListItem>,
391        data: ExtensionMap,
392    },
393}
394
395impl ListKind {
396    pub fn item_blocks_mut(&mut self) -> impl Iterator<Item = &mut Vec<Block>> {
397        enum ItemBlocksMut<'a> {
398            Flow(std::slice::IterMut<'a, FlowListItem>),
399            Task(std::slice::IterMut<'a, TaskListItem>),
400            Custom(std::slice::IterMut<'a, CustomListItem>),
401        }
402
403        impl<'a> Iterator for ItemBlocksMut<'a> {
404            type Item = &'a mut Vec<Block>;
405
406            fn next(&mut self) -> Option<Self::Item> {
407                match self {
408                    Self::Flow(iter) => iter.next().map(|item| &mut item.blocks),
409                    Self::Task(iter) => iter.next().map(|item| &mut item.blocks),
410                    Self::Custom(iter) => iter.next().map(|item| &mut item.blocks),
411                }
412            }
413        }
414
415        match self {
416            Self::Bullet { items } | Self::Numbered { items, .. } => {
417                ItemBlocksMut::Flow(items.iter_mut())
418            }
419            Self::Task { items } => ItemBlocksMut::Task(items.iter_mut()),
420            Self::Custom { items, .. } => ItemBlocksMut::Custom(items.iter_mut()),
421        }
422    }
423}
424
425#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
426pub struct FlowListItem {
427    pub marker: Option<FlowListItemMarker>,
428    pub blocks: Vec<Block>,
429}
430
431#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
432pub struct TaskListItem {
433    pub checked: bool,
434    pub blocks: Vec<Block>,
435}
436
437#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
438pub struct CustomListItem {
439    pub blocks: Vec<Block>,
440    pub data: ExtensionMap,
441}
442
443#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
444pub enum FlowListItemMarker {
445    Bullet,
446    Number(u32),
447}
448
449#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
450pub enum UnknownChild {
451    Block(Block),
452    Inline(Inline),
453}
454
455#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
456pub struct DefinitionItem {
457    pub terms: Vec<Vec<Block>>,
458    pub definitions: Vec<Vec<Block>>,
459}
460
461#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
462pub struct TableCell {
463    pub kind: TableCellKind,
464    pub blocks: Vec<Block>,
465    pub colspan: u32,
466    pub rowspan: u32,
467    pub scope: Option<TableHeaderScope>,
468    pub align: Option<TextAlign>,
469    pub attrs: BlockAttrs,
470}
471
472#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
473pub struct TableRow {
474    pub cells: Vec<TableCell>,
475    pub attrs: BlockAttrs,
476}
477
478#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
479pub struct TableSection {
480    pub kind: TableSectionKind,
481    pub rows: Vec<TableRow>,
482    pub attrs: BlockAttrs,
483}
484
485#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
486pub enum TableSectionKind {
487    Head,
488    Body,
489    Foot,
490}
491
492#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
493pub enum TableCellKind {
494    Header,
495    Data,
496}
497
498#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
499pub enum TableHeaderScope {
500    Row,
501    Col,
502    RowGroup,
503    ColGroup,
504}
505
506#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
507pub enum OrderedListMarker {
508    Decimal,
509    LowerAlpha,
510    UpperAlpha,
511    LowerRoman,
512    UpperRoman,
513}
514
515impl TableCell {
516    pub fn simple(blocks: Vec<Block>) -> Self {
517        Self {
518            kind: TableCellKind::Data,
519            blocks,
520            colspan: 1,
521            rowspan: 1,
522            scope: None,
523            align: None,
524            attrs: BlockAttrs::default(),
525        }
526    }
527}
528
529#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
530pub struct FootnoteDef {
531    pub blocks: Vec<Block>,
532}
533
534#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
535pub enum AdmonitionKind {
536    Note,
537    Tip,
538    Warning,
539    Danger,
540    Info,
541    Custom(ExtensionKind),
542}
543
544impl AdmonitionKind {
545    pub fn default_title(&self) -> &str {
546        match self {
547            Self::Note => "Note",
548            Self::Tip => "Tip",
549            Self::Warning => "Warning",
550            Self::Danger => "Caution",
551            Self::Info => "Important",
552            Self::Custom(s) => s.as_str(),
553        }
554    }
555}
556
557pub type MathPayload = RenderPayload;
558pub type SvgPayload = RenderPayload;
559
560#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
561pub enum MathSource {
562    Typst(String),
563    Latex(String),
564    Custom { kind: ExtensionKind, src: String },
565}
566
567#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
568pub struct RenderPayload {
569    pub src: Option<MathSource>,
570    pub rendered: Option<RenderedArtifact>,
571    pub id: Option<String>,
572}
573
574impl RenderPayload {
575    pub fn has_source_or_rendered(&self) -> bool {
576        self.src.is_some() || self.rendered.is_some()
577    }
578}
579
580#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
581pub enum RenderedArtifact {
582    Svg(String),
583    MathMl(String),
584    Asset {
585        asset: AssetRef,
586        mime: Option<String>,
587        width: Option<u32>,
588        height: Option<u32>,
589    },
590    Custom {
591        kind: ExtensionKind,
592        data: ExtensionMap,
593    },
594}
595
596pub type RenderedMath = RenderedArtifact;
597
598#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
599pub enum Asset {
600    Image(ImageAsset),
601    Video(MediaAsset),
602    Audio(MediaAsset),
603    File(FileAsset),
604    Custom(CustomAsset),
605}
606
607#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
608pub struct ImageAsset {
609    pub source: AssetSource,
610    pub meta: Option<ImageMeta>,
611    pub variants: Vec<AssetVariant>,
612}
613
614#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
615pub struct MediaAsset {
616    pub source: AssetSource,
617    pub mime: Option<String>,
618    pub duration_ms: Option<u64>,
619    pub width: Option<u32>,
620    pub height: Option<u32>,
621    pub sha256: Option<String>,
622    pub variants: Vec<AssetVariant>,
623}
624
625#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
626pub struct FileAsset {
627    pub source: AssetSource,
628    pub mime: Option<String>,
629    pub size_bytes: Option<u64>,
630    pub sha256: Option<String>,
631    pub variants: Vec<AssetVariant>,
632}
633
634#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
635pub struct CustomAsset {
636    pub kind: ExtensionKind,
637    pub source: AssetSource,
638    pub meta: ExtensionMap,
639    pub variants: Vec<AssetVariant>,
640}
641
642#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
643pub enum AssetSource {
644    LocalPath { path: RelativePath },
645    RemoteUrl { url: Url },
646    DataUri { uri: String },
647}
648
649#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
650pub struct RelativePath(RelativePathBuf);
651
652impl RelativePath {
653    pub fn new(path: String) -> Result<Self, String> {
654        if path.trim().is_empty() {
655            return Err("path must not be empty".to_string());
656        }
657
658        let p = Path::new(&path);
659        if p.is_absolute() {
660            return Err("absolute path is not allowed".to_string());
661        }
662
663        // On Windows, "/path" is not absolute (it's relative to current drive root),
664        // but for portability we reject Unix-style absolute paths everywhere.
665        if path.starts_with('/') {
666            return Err("unix absolute path is not allowed".to_string());
667        }
668
669        // Guard against absolute-like Windows forms on non-Windows hosts.
670        // Examples: C:\foo, C:/foo, \\server\share\foo
671        let bytes = path.as_bytes();
672        if bytes.len() >= 3
673            && bytes[1] == b':'
674            && (bytes[2] == b'\\' || bytes[2] == b'/')
675            && bytes[0].is_ascii_alphabetic()
676        {
677            return Err("windows drive absolute path is not allowed".to_string());
678        }
679        if path.starts_with("\\\\") {
680            return Err("windows UNC absolute path is not allowed".to_string());
681        }
682        if path.starts_with('\\') {
683            return Err("windows rooted path is not allowed".to_string());
684        }
685
686        // Canonicalize lexically into a unique project-relative form:
687        // - normalize separators to '/'
688        // - remove empty and '.' segments
689        // - resolve '..' and reject root escape
690        let mut parts: Vec<&str> = Vec::new();
691        for raw in path.split(['/', '\\']) {
692            if raw.is_empty() || raw == "." {
693                continue;
694            }
695            if raw == ".." {
696                if parts.pop().is_none() {
697                    return Err("path escapes root".to_string());
698                }
699                continue;
700            }
701            parts.push(raw);
702        }
703
704        if parts.is_empty() {
705            return Err("path must resolve to a non-empty relative path".to_string());
706        }
707
708        let canonical = parts.join("/");
709        Ok(Self(RelativePathBuf::from(canonical)))
710    }
711
712    pub fn as_str(&self) -> &str {
713        self.0.as_str()
714    }
715}
716
717impl Serialize for RelativePath {
718    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
719    where
720        S: Serializer,
721    {
722        serializer.serialize_str(self.as_str())
723    }
724}
725
726impl<'de> Deserialize<'de> for RelativePath {
727    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
728    where
729        D: Deserializer<'de>,
730    {
731        let value = String::deserialize(deserializer)?;
732        RelativePath::new(value).map_err(de::Error::custom)
733    }
734}
735
736#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
737pub struct ImageMeta {
738    pub width: Option<u32>,
739    pub height: Option<u32>,
740    pub format: Option<String>,
741    pub sha256: Option<String>,
742}
743
744#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
745pub struct AssetVariant {
746    pub name: String,
747    /// Publish-conformance URL only.
748    /// Preview-only resolution data MUST remain outside conformance IR.
749    pub publish_url: Url,
750    pub width: Option<u32>,
751    pub height: Option<u32>,
752}
753
754#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
755pub enum RawOrigin {
756    Typst,
757    Markdown,
758    User,
759    Extension(ExtensionKind),
760}
761
762#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
763pub enum RawTrust {
764    Trusted,
765    Untrusted,
766}
767
768#[cfg(test)]
769mod tests {
770    #![allow(clippy::expect_used)]
771
772    use super::*;
773
774    #[test]
775    fn heading_level_accepts_valid_range() {
776        for n in 1..=6 {
777            let lvl = HeadingLevel::new(n).expect("valid heading level");
778            assert_eq!(lvl.get(), n);
779        }
780    }
781
782    #[test]
783    fn heading_level_rejects_out_of_range() {
784        assert!(HeadingLevel::new(0).is_err());
785        assert!(HeadingLevel::new(7).is_err());
786        assert!(HeadingLevel::new(255).is_err());
787    }
788
789    #[test]
790    fn heading_level_deserialize_enforces_range() {
791        let ok: HeadingLevel = serde_json::from_str("3").expect("deserialize valid level");
792        assert_eq!(ok.get(), 3);
793
794        let bad = serde_json::from_str::<HeadingLevel>("0");
795        assert!(bad.is_err());
796    }
797
798    #[test]
799    fn extension_kind_requires_namespace() {
800        assert!(ExtensionKind::new("".to_string()).is_err());
801        assert!(ExtensionKind::new("custom".to_string()).is_err());
802        assert!(ExtensionKind::new("vendor:feature".to_string()).is_ok());
803        assert!(ExtensionKind::new("vendor/feature".to_string()).is_ok());
804    }
805
806    #[test]
807    fn extension_kind_deserialize_enforces_namespace() {
808        let ok: ExtensionKind =
809            serde_json::from_str("\"vendor:feature\"").expect("deserialize namespaced kind");
810        assert_eq!(ok.as_str(), "vendor:feature");
811
812        let bad = serde_json::from_str::<ExtensionKind>("\"feature\"");
813        assert!(bad.is_err());
814    }
815
816    #[test]
817    fn style_set_canonicalizes_and_deduplicates() {
818        let set = StyleSet::new(vec![
819            TextStyle::Italic,
820            TextStyle::Bold,
821            TextStyle::Italic,
822            TextStyle::Underline,
823        ])
824        .expect("non-empty style set");
825        assert_eq!(
826            set.styles(),
827            &[TextStyle::Bold, TextStyle::Italic, TextStyle::Underline]
828        );
829    }
830
831    #[test]
832    fn style_set_rejects_empty() {
833        assert!(StyleSet::new(vec![]).is_err());
834    }
835
836    #[test]
837    fn style_set_deserialize_rejects_empty() {
838        let bad = serde_json::from_str::<StyleSet>(r#"{"styles":[]}"#);
839        assert!(bad.is_err());
840    }
841
842    #[test]
843    fn style_set_deserialize_canonicalizes() {
844        let set: StyleSet =
845            serde_json::from_str(r#"{"styles":["Underline","Italic","Bold","Italic"]}"#)
846                .expect("deserialize style set");
847        assert_eq!(
848            set.styles(),
849            &[TextStyle::Bold, TextStyle::Italic, TextStyle::Underline]
850        );
851    }
852
853    #[test]
854    fn relative_path_canonicalizes_input() {
855        let p = RelativePath::new("./assets\\images/../hero.png".to_string())
856            .expect("canonicalizable relative path");
857        assert_eq!(p.as_str(), "assets/hero.png");
858    }
859
860    #[test]
861    fn relative_path_rejects_escape_or_absolute_forms() {
862        assert!(RelativePath::new("../outside.png".to_string()).is_err());
863        assert!(RelativePath::new("/abs/path.png".to_string()).is_err());
864        assert!(RelativePath::new("C:\\\\abs\\\\path.png".to_string()).is_err());
865        assert!(RelativePath::new("\\\\server\\\\share\\\\x.png".to_string()).is_err());
866        assert!(RelativePath::new("\\rooted\\\\x.png".to_string()).is_err());
867    }
868
869    #[test]
870    fn relative_path_deserialize_applies_canonicalization() {
871        let p: RelativePath =
872            serde_json::from_str(r#""a/b/../c.png""#).expect("deserialize and canonicalize");
873        assert_eq!(p.as_str(), "a/c.png");
874
875        let bad = serde_json::from_str::<RelativePath>(r#""../../escape.png""#);
876        assert!(bad.is_err());
877    }
878
879    #[test]
880    fn relative_path_rejects_root_escape_after_normalization() {
881        assert!(RelativePath::new("assets/../../escape.png".to_string()).is_err());
882    }
883
884    #[test]
885    fn list_kind_custom_deserialize_requires_namespaced_kind() {
886        let bad = serde_json::from_str::<ListKind>(
887            r#"{"Custom":{"kind":"custom","items":[],"data":{}}}"#,
888        );
889        assert!(bad.is_err());
890
891        let ok = serde_json::from_str::<ListKind>(
892            r#"{"Custom":{"kind":"vendor:custom","items":[],"data":{}}}"#,
893        );
894        assert!(ok.is_ok());
895    }
896
897    #[test]
898    fn render_payload_accepts_source_only() {
899        let payload = RenderPayload {
900            src: Some(MathSource::Latex("x+y".to_string())),
901            rendered: None,
902            id: None,
903        };
904        assert!(payload.has_source_or_rendered());
905    }
906
907    #[test]
908    fn render_payload_accepts_rendered_only() {
909        let payload = RenderPayload {
910            src: None,
911            rendered: Some(RenderedArtifact::Asset {
912                asset: AssetRef(AssetId("asset-1".to_string())),
913                mime: Some("image/png".to_string()),
914                width: Some(10),
915                height: Some(20),
916            }),
917            id: None,
918        };
919        assert!(payload.has_source_or_rendered());
920    }
921
922    #[test]
923    fn render_payload_detects_empty_payload() {
924        let payload = RenderPayload {
925            src: None,
926            rendered: None,
927            id: None,
928        };
929        assert!(!payload.has_source_or_rendered());
930    }
931}