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