Skip to main content

carta_core/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![warn(missing_docs)]
3//! Shared carta core: the conversion traits, their option types, and the common error type.
4//!
5//! [`Reader`] turns input text into a [`Document`]; [`Writer`] turns a [`Document`] back into
6//! output text. Readers and writers depend only on the AST contract and this crate, so input and
7//! output formats stay independent.
8
9use std::fmt;
10use std::io;
11use std::sync::Arc;
12
13use carta_ast::{Block, Document, Inline};
14
15#[cfg(feature = "container")]
16#[cfg_attr(docsrs, doc(cfg(feature = "container")))]
17pub mod container;
18pub mod extensions;
19pub mod media;
20pub mod sections;
21#[cfg(feature = "template")]
22#[cfg_attr(docsrs, doc(cfg(feature = "template")))]
23pub mod template;
24pub mod walk;
25
26pub use extensions::{Extension, Extensions, presets};
27pub use media::{MediaBag, MediaItem};
28
29/// The error type returned across the conversion pipeline.
30#[derive(Debug, thiserror::Error)]
31pub enum Error {
32    /// JSON input or output could not be (de)serialized.
33    #[error("JSON error: {0}")]
34    Json(#[from] serde_json::Error),
35    /// An I/O operation failed.
36    #[error("I/O error: {0}")]
37    Io(#[from] io::Error),
38    /// Input handed to a text reader was not valid UTF-8.
39    #[error("input is not valid UTF-8: {0}")]
40    InvalidUtf8(#[from] std::str::Utf8Error),
41    /// A text-only API was asked for a format whose output is binary; use the byte-capable API.
42    #[error("format '{0}' converts binary data; use the byte-capable API (convert)")]
43    BinaryFormat(String),
44    /// The named format is not recognized.
45    #[error("unsupported format: {0}")]
46    UnsupportedFormat(String),
47    /// The named format is recognized but not compiled into this build.
48    #[error("format '{0}' is recognized but not enabled in this build")]
49    FormatNotEnabled(String),
50    /// A `+`/`-` toggle named an extension that is not modeled.
51    #[error("unknown extension: {0}")]
52    UnknownExtension(String),
53    /// A modeled extension does not apply to the given format.
54    #[error(
55        "The extension '{extension}' is not supported for {format}.\nUse --list-extensions={format} to list supported extensions."
56    )]
57    UnsupportedExtension {
58        /// The extension the format does not support.
59        extension: String,
60        /// The format that does not support the extension.
61        format: String,
62    },
63    /// Document metadata could not be parsed.
64    #[error("invalid document metadata: {0}")]
65    InvalidMetadata(String),
66    /// A standalone template failed to parse or render.
67    #[error("template error: {0}")]
68    Template(String),
69    /// The document holds content the target format cannot represent.
70    #[error("cannot represent this content in the target format: {0}")]
71    Unrepresentable(String),
72    /// Building or reading a container archive failed.
73    #[error("container error: {0}")]
74    Container(String),
75    /// A document filter failed to run or returned an unusable result.
76    #[error("filter error: {0}")]
77    Filter(String),
78}
79
80#[cfg(feature = "template")]
81impl From<template::TemplateError> for Error {
82    fn from(error: template::TemplateError) -> Self {
83        Error::Template(error.to_string())
84    }
85}
86
87/// A `Result` whose error is [`Error`].
88pub type Result<T> = std::result::Result<T, Error>;
89
90/// Options controlling a [`Reader`]. Extended (not resignatured) as real options land.
91#[derive(Debug, Clone, Default)]
92#[non_exhaustive]
93pub struct ReaderOptions {
94    /// Format extensions to enable. Strict-CommonMark readers ignore this (the empty preset).
95    pub extensions: Extensions,
96    /// When set, an open paragraph is greedy: a following line that would otherwise open a block —
97    /// a blockquote, heading, list, thematic break, fenced div, or footnote definition — is folded
98    /// into the paragraph as a lazy continuation instead. Only a blank line, a fenced code block, or
99    /// an HTML block ends the paragraph. Unset, every such line interrupts the paragraph.
100    pub greedy_paragraphs: bool,
101}
102
103/// How math is presented by a format that offers a choice of renderers (the HTML family). The
104/// method decides both the inline markup inside a `span.math` and which loader a standalone document
105/// pulls in to typeset it: a MathJax (or plain) document carries the source TeX wrapped in `\(…\)` /
106/// `\[…\]`, whereas a KaTeX document carries the bare TeX, which its in-browser loader reads from the
107/// span directly.
108#[derive(Debug, Clone, PartialEq, Eq, Default)]
109pub enum MathMethod {
110    /// No renderer: the `\(…\)` / `\[…\]` markup is left for the reader to typeset (or read as
111    /// source). The default.
112    #[default]
113    Plain,
114    /// MathJax, loaded from the given script URL. The markup keeps the `\(…\)` / `\[…\]` delimiters.
115    MathJax(String),
116    /// KaTeX, loaded from the given asset base URL (the directory holding `katex.min.js` and its
117    /// stylesheet). The span carries bare TeX without delimiters.
118    Katex(String),
119}
120
121/// How a writer supplies a table of contents.
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
123pub enum TocStyle {
124    /// The contents are rendered as a nested list and placed in the `toc` template variable. The
125    /// default.
126    #[default]
127    List,
128    /// The format assembles its own contents from a directive in its template, so only a boolean
129    /// `toc` flag is exposed and no list is generated.
130    Native,
131}
132
133/// How a text writer lays out the lines of a paragraph.
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
135pub enum WrapMode {
136    /// Reflow inline content, breaking lines to keep them within the fill column. A soft line break
137    /// in the source is just inter-word space and is re-flowed like any other.
138    #[default]
139    Auto,
140    /// Never break a paragraph: each one is a single line, with soft breaks rendered as spaces. Lines
141    /// run as long as their content (only an explicit hard break starts a new line).
142    None,
143    /// Keep the source's own line breaks: a soft break stays a line break and content is not
144    /// reflowed, but lines are not wrapped to a column either.
145    Preserve,
146}
147
148/// Options for the EPUB container writer. Ignored by every other writer. The default is an empty
149/// book: no cover, no embedded fonts, the built-in stylesheet only, and chapters split at the top
150/// heading level.
151#[derive(Debug, Clone, Default)]
152#[non_exhaustive]
153pub struct EpubOptions {
154    /// A cover image as `(file name, bytes)`. Produces a dedicated cover page and marks the image
155    /// as the publication cover.
156    pub cover_image: Option<(String, Vec<u8>)>,
157
158    /// Fonts to embed verbatim, each as `(file name, bytes)`. A stylesheet refers to them by name.
159    pub fonts: Vec<(String, Vec<u8>)>,
160
161    /// User stylesheet contents, linked from every page. When any are given they replace the
162    /// built-in stylesheet entirely; several are linked in order. Empty leaves the built-in in place.
163    pub stylesheets: Vec<String>,
164
165    /// A Dublin Core metadata fragment (bare `<dc:*>` elements) merged into the package metadata.
166    pub metadata_xml: Option<String>,
167
168    /// The container directory holding all publication content. `None` uses the conventional
169    /// `EPUB`; an empty string places the content at the archive root.
170    pub subdirectory: Option<String>,
171
172    /// The heading level at which the book is split into separate chapter files. `None` splits at
173    /// the top level, so each level-one heading starts a new file.
174    pub split_level: Option<usize>,
175
176    /// Seconds since the Unix epoch fixing the publication's modification timestamp. `None` uses a
177    /// fixed epoch so output stays byte-reproducible.
178    pub source_date_epoch: Option<i64>,
179
180    /// The process locale (the `LANG` environment variable) whose language tag stands in when the
181    /// document names no `lang`. `None` falls back to `en-US`, keeping output independent of the
182    /// environment.
183    pub locale: Option<String>,
184}
185
186/// Options for the DOCX container writer. Ignored by every other writer. The default produces a
187/// self-contained document from the built-in template, with reproducible property timestamps and a
188/// language tag drawn from the document or the environment.
189#[derive(Debug, Clone, Default)]
190#[non_exhaustive]
191pub struct DocxOptions {
192    /// A reference document, as raw `.docx` bytes, whose styling parts and document template are
193    /// reused while the converted content replaces its body. `None` uses the built-in template.
194    pub reference_doc: Option<Vec<u8>>,
195
196    /// Seconds since the Unix epoch fixing the document's property timestamps. `None` uses a fixed
197    /// epoch so output stays byte-reproducible.
198    pub source_date_epoch: Option<i64>,
199
200    /// The process locale (the `LANG` environment variable) whose language tag stands in when the
201    /// document names no `lang`. `None` falls back to `en-US`, keeping output independent of the
202    /// environment.
203    pub locale: Option<String>,
204}
205
206/// Options controlling a [`Writer`]. Extended (not resignatured) as real options land.
207#[derive(Debug, Clone, Default)]
208#[non_exhaustive]
209pub struct WriterOptions {
210    /// Format extensions to enable.
211    pub extensions: Extensions,
212
213    /// The embedded resources the document references by name but does not carry inline. A writer
214    /// that re-embeds resource bytes — a notebook re-encoding its image outputs — reads them from
215    /// here; most writers ignore it. Shared cheaply, so cloning the options does not copy the bytes.
216    pub media: Arc<MediaBag>,
217
218    /// Options for the EPUB container writer; ignored by every other writer.
219    pub epub: EpubOptions,
220
221    /// Options for the DOCX container writer; ignored by every other writer.
222    pub docx: DocxOptions,
223
224    /// How paragraphs are laid out: reflowed to the fill column, never wrapped, or with the source's
225    /// own line breaks preserved.
226    pub wrap: WrapMode,
227
228    /// The fill column a wrapping writer reflows to under [`WrapMode::Auto`]. `None` uses the
229    /// writer's built-in default width.
230    pub columns: Option<usize>,
231
232    /// Splice a hierarchical section number into each heading. A format that numbers headings with a
233    /// typesetting counter applies it through its template instead (see
234    /// [`Writer::numbers_sections_natively`]).
235    pub number_sections: bool,
236
237    /// Emit a table of contents in a standalone document.
238    pub toc: bool,
239
240    /// The deepest heading level the table of contents includes. `None` uses the conventional depth
241    /// of three.
242    pub toc_depth: Option<usize>,
243
244    /// How math is presented by a format offering a choice of renderers (the HTML family).
245    pub math_method: MathMethod,
246
247    /// Emit a complete document by wrapping the rendered body in the target format's template,
248    /// rather than a bare fragment.
249    #[cfg(feature = "template")]
250    #[cfg_attr(docsrs, doc(cfg(feature = "template")))]
251    pub standalone: bool,
252
253    /// Template source overriding the format's built-in default. Its presence implies standalone
254    /// output.
255    #[cfg(feature = "template")]
256    #[cfg_attr(docsrs, doc(cfg(feature = "template")))]
257    pub template: Option<String>,
258
259    /// Directory used to resolve template partials (`$name()$`).
260    #[cfg(feature = "template")]
261    #[cfg_attr(docsrs, doc(cfg(feature = "template")))]
262    pub template_dir: Option<std::path::PathBuf>,
263
264    /// A shared directory of partials (`$name()$`) consulted when a partial is not found beside the
265    /// including template — the data directory's `templates/`. `None` when no data directory applies.
266    #[cfg(feature = "template")]
267    #[cfg_attr(docsrs, doc(cfg(feature = "template")))]
268    pub template_datadir: Option<std::path::PathBuf>,
269
270    /// Extension a partial (`$name()$`) inherits from the including template: the `--template`
271    /// file's own extension, so the same partial name resolves to the same kind of file whatever
272    /// the output format. An empty string means the template file had no extension (the partial is
273    /// looked up bare). Absent for a built-in default, where the format name is used instead.
274    #[cfg(feature = "template")]
275    #[cfg_attr(docsrs, doc(cfg(feature = "template")))]
276    pub template_ext: Option<String>,
277
278    /// Raw template variables, in order; a repeated key accumulates into a list. Inserted verbatim
279    /// (unescaped) at the highest precedence when building the template context.
280    #[cfg(feature = "template")]
281    #[cfg_attr(docsrs, doc(cfg(feature = "template")))]
282    pub variables: Vec<(String, String)>,
283
284    /// Metadata layered *above* the document's own (the `-M` layer): each key replaces the reader's
285    /// value for that key when the context is built.
286    #[cfg(feature = "template")]
287    #[cfg_attr(docsrs, doc(cfg(feature = "template")))]
288    pub metadata: std::collections::BTreeMap<String, carta_ast::MetaValue>,
289
290    /// Metadata layered *below* the document's own (the metadata-file layer): supplies defaults the
291    /// reader's values and `-M` override.
292    #[cfg(feature = "template")]
293    #[cfg_attr(docsrs, doc(cfg(feature = "template")))]
294    pub metadata_defaults: std::collections::BTreeMap<String, carta_ast::MetaValue>,
295
296    /// The source name a standalone document falls back to when no `title` metadata is present: an
297    /// input file's stem, or `-` for standard input. `None` outside the command line, where there is
298    /// no source name and the fallback is empty. Consumed by the HTML family (for its `pagetitle`)
299    /// and by the container writer (for the navigation document's title).
300    #[cfg(any(feature = "template", feature = "container"))]
301    #[cfg_attr(docsrs, doc(cfg(any(feature = "template", feature = "container"))))]
302    pub source_name: Option<String>,
303}
304
305/// Parses input text in some source format into the document model.
306pub trait Reader {
307    /// Parses `input` text into a document.
308    ///
309    /// # Errors
310    /// Propagates any error from parsing the input.
311    fn read(&self, input: &str, options: &ReaderOptions) -> Result<Document>;
312
313    /// Reads `input` into a document together with the embedded resources it references. The default
314    /// carries no resources; a container format — a notebook with image outputs — overrides this to
315    /// decode those bytes into the returned [`MediaBag`], and implements [`read`](Reader::read) by
316    /// discarding the bag.
317    ///
318    /// # Errors
319    /// Propagates any error from parsing the input.
320    fn read_media(&self, input: &str, options: &ReaderOptions) -> Result<(Document, MediaBag)> {
321        Ok((self.read(input, options)?, MediaBag::new()))
322    }
323}
324
325/// Which plain-text identity variables a writer's standalone template draws on. The document's
326/// title, authors, and date are exposed as markup-free, target-escaped text for places that cannot
327/// carry markup — a web document head or a PDF document's properties. See [`Writer::meta_var_style`].
328#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
329pub enum MetaVarStyle {
330    /// The format exposes none of these variables.
331    #[default]
332    None,
333    /// A web document head: `pagetitle` (the title, falling back to the source name), `date-meta`
334    /// (the date), and `author-meta` (the authors, one list entry each).
335    Web,
336    /// A PDF document's properties: `title-meta` (the title) and `author-meta` (the authors joined
337    /// into one string with `; `).
338    Pdf,
339}
340
341/// Renders the document model into some target format's text.
342///
343/// The returned string carries no trailing newline; the CLI appends exactly one.
344pub trait Writer {
345    /// Renders `document` into this format's text.
346    ///
347    /// # Errors
348    /// Propagates any error from rendering the document.
349    fn write(&self, document: &Document, options: &WriterOptions) -> Result<String>;
350
351    /// Render an inline sequence in this format, for interpolating inline metadata (a `title`, an
352    /// `author`) into a template variable. Wrapping the inlines in a [`Block::Plain`] yields them
353    /// with no paragraph chrome across formats; a writer whose `Plain` diverges overrides this.
354    ///
355    /// # Errors
356    /// Propagates any error from [`Writer::write`].
357    fn render_meta_inlines(&self, inlines: &[Inline], options: &WriterOptions) -> Result<String> {
358        let document = Document {
359            blocks: vec![Block::Plain(inlines.to_vec())],
360            ..Document::default()
361        };
362        Ok(self
363            .write(&document, options)?
364            .trim_end_matches('\n')
365            .to_string())
366    }
367
368    /// Render a block sequence in this format, for interpolating block metadata (an `abstract`
369    /// authored as Markdown blocks) into a template variable.
370    ///
371    /// # Errors
372    /// Propagates any error from [`Writer::write`].
373    fn render_meta_blocks(&self, blocks: &[Block], options: &WriterOptions) -> Result<String> {
374        let document = Document {
375            blocks: blocks.to_vec(),
376            ..Document::default()
377        };
378        Ok(self
379            .write(&document, options)?
380            .trim_end_matches('\n')
381            .to_string())
382    }
383
384    /// This format's own standalone template, or `None` when standalone output is identical to the
385    /// fragment (no wrapping document exists for the format).
386    fn default_template(&self) -> Option<&'static str> {
387        None
388    }
389
390    /// A standalone document this format assembles structurally, embedding the metadata and block
391    /// list in one value rather than wrapping a text body in a template — the data form is the
392    /// canonical example. Returned in place of template rendering. `None` (the default) when the
393    /// format wraps its body with a text template instead.
394    ///
395    /// # Errors
396    /// Propagates any error from rendering the document.
397    fn standalone_document(
398        &self,
399        document: &Document,
400        options: &WriterOptions,
401    ) -> Result<Option<String>> {
402        let _ = (document, options);
403        Ok(None)
404    }
405
406    /// Which plain-text identity variables this writer's standalone template draws on — the title,
407    /// authors, and date as markup-free text. The default is [`MetaVarStyle::None`]; an HTML-family
408    /// writer returns [`MetaVarStyle::Web`] and a LaTeX-family writer [`MetaVarStyle::Pdf`].
409    fn meta_var_style(&self) -> MetaVarStyle {
410        MetaVarStyle::None
411    }
412
413    /// Whether block-shaped metadata is flattened to its inline content when built into the template
414    /// context. A writer that places title, author, and date into single-line header fields — a man
415    /// page's `.TH` line cannot carry paragraph structure — sets this so a lone-paragraph value
416    /// contributes its inline text and any other block shape contributes nothing. The default `false`
417    /// renders block metadata as blocks.
418    fn flatten_block_metadata(&self) -> bool {
419        false
420    }
421
422    /// A title presentation the template language cannot express from individual variables — an
423    /// underlined title for reStructuredText, say, whose rule length depends on the rendered title
424    /// width. Exposed to the template as the `titleblock` variable. `None` (the default) when the
425    /// format builds its title presentation from individual variables instead.
426    ///
427    /// # Errors
428    /// Propagates any error from rendering the metadata.
429    fn title_block(&self, document: &Document, options: &WriterOptions) -> Result<Option<String>> {
430        let _ = (document, options);
431        Ok(None)
432    }
433
434    /// Whether this writer lays the document out as newline-terminated lines, so a non-empty `body`
435    /// template variable ends with a newline. Writers that build their markup as one string ending
436    /// at its final glyph (HTML, LaTeX, and the like) leave the default `false`.
437    fn body_ends_with_newline(&self) -> bool {
438        false
439    }
440
441    /// How this writer supplies a table of contents. The default renders a nested list into the
442    /// `toc` variable; a format whose template assembles its own contents from a directive overrides
443    /// to [`TocStyle::Native`].
444    fn toc_style(&self) -> TocStyle {
445        TocStyle::List
446    }
447
448    /// Whether a list-style table of contents attaches a back-reference anchor — an `id` on each
449    /// entry's link — so the entries can be linked to. The default includes them; a format that
450    /// cannot represent an inline identifier (so an attributed link would degrade to raw markup)
451    /// overrides to `false`. Honored only when [`toc_style`](Writer::toc_style) is [`TocStyle::List`].
452    fn toc_link_anchors(&self) -> bool {
453        true
454    }
455
456    /// Whether this format numbers sections with its own typesetting counter rather than carrying the
457    /// number in the heading text. The default splices a `header-section-number` span into each
458    /// heading; a format with a native counter (the typesetting formats) overrides to `true` and is
459    /// driven by a `numbersections` template flag instead.
460    fn numbers_sections_natively(&self) -> bool {
461        false
462    }
463
464    /// Whether this writer carries section numbers in the heading text, so the number is spliced into
465    /// each heading before rendering (and contents entries inherit it). The default leaves headings
466    /// untouched; a format that renders the number inline (HTML) overrides to `true`. A format with a
467    /// native counter relies on [`numbers_sections_natively`](Writer::numbers_sections_natively)
468    /// instead and leaves this `false`.
469    fn numbers_sections_in_body(&self) -> bool {
470        false
471    }
472}
473
474/// Parses input bytes in some source format into the document model. The byte-shaped counterpart of
475/// [`Reader`], for formats whose wire form is not text — zip containers and the like.
476pub trait BytesReader {
477    /// Parses `input` bytes into a document.
478    ///
479    /// # Errors
480    /// Propagates any error from parsing the input.
481    fn read(&self, input: &[u8], options: &ReaderOptions) -> Result<Document>;
482
483    /// Reads `input` into a document together with the embedded resources it references. The
484    /// byte-shaped counterpart of [`Reader::read_media`]; the default carries no resources.
485    ///
486    /// # Errors
487    /// Propagates any error from parsing the input.
488    fn read_media(&self, input: &[u8], options: &ReaderOptions) -> Result<(Document, MediaBag)> {
489        Ok((self.read(input, options)?, MediaBag::new()))
490    }
491}
492
493/// Renders the document model into some target format's bytes. The byte-shaped counterpart of
494/// [`Writer`], for formats whose output is not text — zip containers and the like.
495///
496/// This trait carries no decoration hooks (templates, table of contents, metadata rendering): a
497/// container writer produces a complete document by construction. Hooks are added when a real format
498/// needs them.
499pub trait BytesWriter {
500    /// Renders `document` into this format's bytes.
501    ///
502    /// # Errors
503    /// Propagates any error from rendering the document.
504    fn write(&self, document: &Document, options: &WriterOptions) -> Result<Vec<u8>>;
505}
506
507/// The output of a conversion: text from a text writer, bytes from a byte-shaped writer.
508#[derive(Debug, Clone, PartialEq, Eq)]
509pub enum Output {
510    /// Text produced by a text-shaped writer.
511    Text(String),
512    /// Bytes produced by a byte-shaped writer.
513    Bytes(Vec<u8>),
514}
515
516/// A resolved reader, either text-shaped ([`Reader`]) or byte-shaped ([`BytesReader`]).
517pub enum AnyReader {
518    /// A text-shaped reader; input is decoded as UTF-8 before parsing.
519    Text(Box<dyn Reader>),
520    /// A byte-shaped reader; input is parsed from raw bytes.
521    Bytes(Box<dyn BytesReader>),
522}
523
524impl fmt::Debug for AnyReader {
525    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
526        let variant = match self {
527            AnyReader::Text(_) => "Text",
528            AnyReader::Bytes(_) => "Bytes",
529        };
530        f.debug_tuple(variant).finish()
531    }
532}
533
534impl AnyReader {
535    /// Reads `input` into a document. A text reader decodes the bytes as UTF-8 first; a byte reader
536    /// takes the raw slice.
537    ///
538    /// # Errors
539    /// [`Error::InvalidUtf8`] if a text reader is handed input that is not valid UTF-8, plus any error
540    /// the underlying reader returns.
541    pub fn read(&self, input: &[u8], options: &ReaderOptions) -> Result<Document> {
542        match self {
543            AnyReader::Text(reader) => reader.read(std::str::from_utf8(input)?, options),
544            AnyReader::Bytes(reader) => reader.read(input, options),
545        }
546    }
547
548    /// Reads `input` into a document together with the embedded resources it references. A text
549    /// reader decodes the bytes as UTF-8 first; a byte reader takes the raw slice. A reader that
550    /// carries no resources returns an empty [`MediaBag`].
551    ///
552    /// # Errors
553    /// [`Error::InvalidUtf8`] if a text reader is handed input that is not valid UTF-8, plus any
554    /// error the underlying reader returns.
555    pub fn read_media(
556        &self,
557        input: &[u8],
558        options: &ReaderOptions,
559    ) -> Result<(Document, MediaBag)> {
560        match self {
561            AnyReader::Text(reader) => reader.read_media(std::str::from_utf8(input)?, options),
562            AnyReader::Bytes(reader) => reader.read_media(input, options),
563        }
564    }
565}
566
567/// A resolved writer, either text-shaped ([`Writer`]) or byte-shaped ([`BytesWriter`]).
568pub enum AnyWriter {
569    /// A text-shaped writer; rendering produces a string.
570    Text(Box<dyn Writer>),
571    /// A byte-shaped writer; rendering produces raw bytes.
572    Bytes(Box<dyn BytesWriter>),
573}
574
575impl fmt::Debug for AnyWriter {
576    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
577        let variant = match self {
578            AnyWriter::Text(_) => "Text",
579            AnyWriter::Bytes(_) => "Bytes",
580        };
581        f.debug_tuple(variant).finish()
582    }
583}
584
585impl AnyWriter {
586    /// This format's own standalone template, or `None` when standalone output is identical to the
587    /// fragment. A byte-shaped writer never has one.
588    #[must_use]
589    pub fn default_template(&self) -> Option<&'static str> {
590        match self {
591            AnyWriter::Text(writer) => writer.default_template(),
592            AnyWriter::Bytes(_) => None,
593        }
594    }
595}
596
597#[cfg(test)]
598mod tests {
599    use super::{
600        AnyReader, AnyWriter, BytesReader, BytesWriter, Error, Reader, ReaderOptions, Result,
601        WriterOptions,
602    };
603    use carta_ast::Document;
604
605    struct FixedBytesWriter;
606    impl BytesWriter for FixedBytesWriter {
607        fn write(&self, _document: &Document, _options: &WriterOptions) -> Result<Vec<u8>> {
608            Ok(vec![0x00, 0xff, 0x9f])
609        }
610    }
611
612    struct RawBytesReader;
613    impl BytesReader for RawBytesReader {
614        fn read(&self, input: &[u8], _options: &ReaderOptions) -> Result<Document> {
615            assert_eq!(input, &[0xff, 0xfe]);
616            Ok(Document::default())
617        }
618    }
619
620    struct EmptyTextReader;
621    impl Reader for EmptyTextReader {
622        fn read(&self, _input: &str, _options: &ReaderOptions) -> Result<Document> {
623            Ok(Document::default())
624        }
625    }
626
627    #[test]
628    fn bytes_writer_round_trips_bytes() {
629        let writer = AnyWriter::Bytes(Box::new(FixedBytesWriter));
630        assert!(writer.default_template().is_none());
631        let AnyWriter::Bytes(inner) = &writer else {
632            panic!("expected a byte writer");
633        };
634        let output = inner
635            .write(&Document::default(), &WriterOptions::default())
636            .unwrap();
637        assert_eq!(output, vec![0x00, 0xff, 0x9f]);
638    }
639
640    #[test]
641    fn text_reader_rejects_invalid_utf8() {
642        let reader = AnyReader::Text(Box::new(EmptyTextReader));
643        let error = reader
644            .read(&[0xff, 0xfe], &ReaderOptions::default())
645            .unwrap_err();
646        assert!(matches!(error, Error::InvalidUtf8(_)), "{error:?}");
647    }
648
649    #[test]
650    fn bytes_reader_accepts_invalid_utf8() {
651        let reader = AnyReader::Bytes(Box::new(RawBytesReader));
652        assert!(
653            reader
654                .read(&[0xff, 0xfe], &ReaderOptions::default())
655                .is_ok()
656        );
657    }
658
659    #[test]
660    fn default_read_media_carries_no_resources() {
661        let reader = AnyReader::Text(Box::new(EmptyTextReader));
662        let (_, media) = reader
663            .read_media(b"anything", &ReaderOptions::default())
664            .expect("read succeeds");
665        assert!(media.is_empty());
666    }
667}