use std::collections::BTreeMap;
use std::fs;
use std::io::{self, IsTerminal, Read, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::sync::Arc;
use carta::ast::{Block, MetaValue};
use carta::{
DocxOptions, EpubOptions, Error, MathMethod, MediaBag, Output, ReaderOptions, Result, WrapMode,
WriterOptions, media, read_document, render_document,
};
#[cfg(feature = "write-html")]
use carta::{Resource, inline_resources};
use clap::{ArgAction, CommandFactory, Parser};
mod datadir;
mod filters;
#[cfg(not(feature = "highlight"))]
const LIST_FLAGS: [&str; 6] = [
"list_input_formats",
"list_output_formats",
"list_extensions",
"print_default_template",
"completions",
"man",
];
#[cfg(feature = "highlight")]
const LIST_FLAGS: [&str; 9] = [
"list_input_formats",
"list_output_formats",
"list_extensions",
"print_default_template",
"completions",
"man",
"list_highlight_languages",
"list_highlight_styles",
"print_highlight_style",
];
#[derive(Parser, Debug)]
#[command(
name = "carta",
version,
about = "Document converter",
disable_version_flag = true
)]
#[allow(clippy::struct_excessive_bools)]
struct Cli {
#[arg(short = 'f', long = "from", required_unless_present_any = LIST_FLAGS)]
from: Option<String>,
#[arg(short = 't', long = "to", required_unless_present_any = LIST_FLAGS)]
to: Option<String>,
#[arg(short = 'o', long = "output")]
output: Option<PathBuf>,
#[arg(long = "extract-media", value_name = "DIR")]
extract_media: Option<PathBuf>,
#[arg(long = "resource-path", value_name = "SEARCHPATH")]
resource_path: Vec<String>,
#[arg(long = "embed-resources")]
embed_resources: bool,
#[arg(long = "self-contained")]
self_contained: bool,
#[arg(long = "sandbox")]
sandbox: bool,
#[arg(short = 's', long = "standalone")]
standalone: bool,
#[arg(long = "template", value_name = "FILE")]
template: Option<PathBuf>,
#[arg(short = 'V', long = "variable", value_name = "KEY[:VAL]")]
variable: Vec<String>,
#[arg(long = "wrap", value_name = "auto|none|preserve", default_value = "auto", value_parser = parse_wrap)]
wrap: WrapMode,
#[arg(long = "columns", value_name = "N")]
columns: Option<usize>,
#[arg(short = 'N', long = "number-sections")]
number_sections: bool,
#[arg(long = "toc", visible_alias = "table-of-contents")]
toc: bool,
#[arg(long = "toc-depth", value_name = "N", value_parser = parse_toc_depth)]
toc_depth: Option<usize>,
#[allow(clippy::option_option)]
#[arg(long = "mathjax", value_name = "URL", num_args = 0..=1, require_equals = true)]
mathjax: Option<Option<String>>,
#[allow(clippy::option_option)]
#[arg(long = "katex", value_name = "URL", num_args = 0..=1, require_equals = true)]
katex: Option<Option<String>>,
#[arg(short = 'M', long = "metadata", value_name = "KEY[:VAL]")]
metadata: Vec<String>,
#[arg(long = "metadata-file", value_name = "FILE")]
metadata_file: Vec<PathBuf>,
#[arg(short = 'F', long = "filter", value_name = "PROGRAM")]
filter: Vec<String>,
#[arg(long = "data-dir", value_name = "DIR")]
data_dir: Option<PathBuf>,
#[arg(
short = 'c',
long = "css",
visible_alias = "stylesheet",
value_name = "FILE"
)]
css: Vec<PathBuf>,
#[arg(long = "epub-cover-image", value_name = "FILE")]
epub_cover_image: Option<PathBuf>,
#[arg(long = "epub-embed-font", value_name = "FILE")]
epub_embed_font: Vec<PathBuf>,
#[arg(long = "epub-metadata", value_name = "FILE")]
epub_metadata: Option<PathBuf>,
#[arg(long = "epub-subdirectory", value_name = "DIRNAME")]
epub_subdirectory: Option<String>,
#[arg(long = "split-level", visible_alias = "epub-chapter-level", value_name = "N", value_parser = parse_split_level)]
split_level: Option<usize>,
#[arg(long = "reference-doc", value_name = "FILE")]
reference_doc: Option<PathBuf>,
#[cfg(feature = "highlight")]
#[arg(long = "highlight-style", value_name = "STYLE|FILE")]
highlight_style: Option<String>,
#[cfg(feature = "highlight")]
#[arg(long = "no-highlight")]
no_highlight: bool,
#[cfg(feature = "highlight")]
#[arg(
long = "syntax-highlighting",
value_name = "none|default|idiomatic|STYLE|FILE"
)]
syntax_highlighting: Option<String>,
#[cfg(feature = "highlight")]
#[arg(long = "syntax-definition", value_name = "FILE")]
syntax_definition: Vec<PathBuf>,
#[cfg(feature = "highlight")]
#[arg(long = "list-highlight-languages")]
list_highlight_languages: bool,
#[cfg(feature = "highlight")]
#[arg(long = "list-highlight-styles")]
list_highlight_styles: bool,
#[cfg(feature = "highlight")]
#[arg(long = "print-highlight-style", value_name = "STYLE|FILE")]
print_highlight_style: Option<String>,
#[arg(long = "list-input-formats")]
list_input_formats: bool,
#[arg(long = "list-output-formats")]
list_output_formats: bool,
#[allow(clippy::option_option)]
#[arg(long = "list-extensions", value_name = "FORMAT", num_args = 0..=1, require_equals = true)]
list_extensions: Option<Option<String>>,
#[arg(short = 'D', long = "print-default-template", value_name = "FORMAT")]
print_default_template: Option<String>,
#[arg(long = "completions", value_name = "SHELL", hide = true)]
completions: Option<clap_complete::Shell>,
#[arg(long = "man", hide = true)]
man: bool,
#[arg(long = "version", action = ArgAction::Version)]
version: Option<bool>,
input: Option<PathBuf>,
}
fn main() -> ExitCode {
match run(&Cli::parse()) {
Ok(()) => ExitCode::SUCCESS,
Err(error) if is_broken_pipe(&error) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("carta: {error}");
exit_code(&error)
}
}
}
fn is_broken_pipe(error: &Error) -> bool {
matches!(error, Error::Io(io) if io.kind() == io::ErrorKind::BrokenPipe)
}
fn exit_code(error: &Error) -> ExitCode {
match error {
Error::UnsupportedExtension { .. } => ExitCode::from(23),
Error::Filter(_) => ExitCode::from(83),
_ => ExitCode::FAILURE,
}
}
fn run(cli: &Cli) -> Result<()> {
if let Some(shell) = cli.completions {
clap_complete::generate(
shell,
&mut Cli::command(),
"carta",
&mut io::stdout().lock(),
);
return Ok(());
}
if cli.man {
clap_mangen::Man::new(Cli::command()).render(&mut io::stdout().lock())?;
return Ok(());
}
if cli.list_input_formats {
return print_lines(&carta::input_format_names());
}
if cli.list_output_formats {
return print_lines(&carta::output_format_names());
}
if let Some(format) = &cli.list_extensions {
return list_extensions(format.as_deref());
}
if let Some(format) = &cli.print_default_template {
return print_default_template(format);
}
#[cfg(feature = "highlight")]
{
if cli.list_highlight_languages {
return print_owned_lines(&carta::languages());
}
if cli.list_highlight_styles {
return print_owned_lines(&carta::styles());
}
if let Some(style) = &cli.print_highlight_style {
return print_highlight_style(style);
}
}
match (cli.from.as_deref(), cli.to.as_deref()) {
(Some(from), Some(to)) => convert_document(from, to, cli),
_ => Ok(()),
}
}
fn convert_document(from: &str, to: &str, cli: &Cli) -> Result<()> {
let input = read_input(cli.input.as_deref())?;
let to_base = carta::parse_format_spec(to)?.0;
let data_dir = datadir::resolve(cli.data_dir.as_deref());
if cli.self_contained {
eprintln!("carta: --self-contained is deprecated; use --embed-resources --standalone");
}
#[cfg(feature = "write-html")]
let embed_resources = (cli.embed_resources || cli.self_contained) && to.starts_with("html");
let mut writer_options = WriterOptions::default();
writer_options.standalone = cli.standalone || cli.self_contained;
if let Some((source, dir, ext)) = resolve_template(cli, &to_base, data_dir.as_deref())? {
writer_options.template = Some(source.into());
writer_options.template_dir = Some(dir);
writer_options.template_ext = Some(ext);
}
writer_options.template_datadir = data_dir.as_ref().map(|dir| dir.join("templates"));
writer_options.wrap = cli.wrap;
writer_options.columns = cli.columns;
writer_options.number_sections = cli.number_sections;
writer_options.toc = cli.toc;
writer_options.toc_depth = cli.toc_depth;
writer_options.math_method = math_method(cli);
#[cfg(feature = "highlight")]
{
writer_options.highlight = highlight_options(cli)?;
}
writer_options.variables = parse_variables(&cli.variable);
writer_options.metadata = parse_metadata(&cli.metadata);
writer_options.metadata_defaults = read_metadata_files(&cli.metadata_file)?;
writer_options.source_name = Some(source_name(cli.input.as_deref()));
if is_docx(to) {
writer_options.docx = docx_options(cli)?;
} else if to.starts_with("epub") {
writer_options.epub = Arc::new(epub_options(cli)?);
}
let verbatim = writer_options.standalone || cli.template.is_some();
let (mut document, resources) = read_document(from, &input, &ReaderOptions::default())?;
carta::merge_metadata(&mut document, &writer_options);
writer_options.metadata.clear();
writer_options.metadata_defaults.clear();
let mut resources = match &cli.extract_media {
Some(dir) => {
extract_media(dir, &resources, &mut document.blocks)?;
MediaBag::new()
}
None => resources,
};
filters::run(&mut document, &cli.filter, &to_base, data_dir.as_deref())?;
if cli.extract_media.is_none() && embeds_resources(to) {
let search_path = resource_search_path(cli);
media::embed_referenced_media(&mut document.blocks, &mut resources, |reference| {
resolve_resource(reference, &search_path)
});
}
#[cfg(feature = "write-html")]
let embed_bag = if embed_resources && cli.extract_media.is_none() {
resources.clone()
} else {
MediaBag::new()
};
let output = render_document(to, document, resources, &writer_options)?;
#[cfg(feature = "write-html")]
let output = match output {
Output::Text(html) if embed_resources && cli.extract_media.is_none() => {
let search_path = resource_search_path(cli);
Output::Text(inline_resources(&html, |reference| {
resolve_embed(reference, &embed_bag, &search_path, cli.sandbox)
}))
}
output => output,
};
write_output(cli.output.as_deref(), &output, verbatim)
}
fn try_read(path: &Path) -> Result<Option<String>> {
match fs::read_to_string(path) {
Ok(source) => Ok(Some(source)),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error.into()),
}
}
fn resolve_template(
cli: &Cli,
to_base: &str,
data_dir: Option<&Path>,
) -> Result<Option<(String, PathBuf, String)>> {
if let Some(name) = &cli.template {
return resolve_named_template(name, data_dir).map(Some);
}
if cli.standalone
&& let Some(dir) = data_dir
{
let dir = dir.join("templates");
let extension = default_template_extension(to_base);
if let Some(source) = try_read(&dir.join(format!("default.{extension}")))? {
return Ok(Some((source, dir, extension.to_owned())));
}
}
Ok(None)
}
fn resolve_named_template(
name: &Path,
data_dir: Option<&Path>,
) -> Result<(String, PathBuf, String)> {
let extension = name
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("")
.to_owned();
if let Some(source) = try_read(name)? {
return Ok((source, template_dir(name), extension));
}
if let Some(dir) = data_dir {
let dir = dir.join("templates");
if let Some(source) = try_read(&dir.join(name))? {
return Ok((source, dir, extension));
}
}
Err(Error::Template(format!(
"could not find template '{}'",
name.display()
)))
}
fn default_template_extension(to_base: &str) -> &str {
match to_base {
"html" | "html5" => "html5",
"html4" => "html4",
"gfm" => "commonmark",
other => other,
}
}
fn embeds_resources(to: &str) -> bool {
to.starts_with("epub") || is_docx(to) || is_rtf(to) || is_odt(to)
}
fn is_docx(to: &str) -> bool {
to.starts_with("docx")
}
fn is_odt(to: &str) -> bool {
to.starts_with("odt")
}
fn is_rtf(to: &str) -> bool {
to.starts_with("rtf")
}
fn epub_options(cli: &Cli) -> Result<EpubOptions> {
let mut epub = EpubOptions::default();
for path in &cli.css {
epub.stylesheets.push(fs::read_to_string(path)?);
}
if let Some(path) = &cli.epub_cover_image {
epub.cover_image = Some((base_name(path), fs::read(path)?));
}
for path in &cli.epub_embed_font {
epub.fonts.push((base_name(path), fs::read(path)?));
}
if let Some(path) = &cli.epub_metadata {
epub.metadata_xml = Some(fs::read_to_string(path)?);
}
epub.subdirectory.clone_from(&cli.epub_subdirectory);
epub.split_level = cli.split_level;
epub.source_date_epoch = source_date_epoch();
epub.locale = std::env::var("LANG").ok();
Ok(epub)
}
fn docx_options(cli: &Cli) -> Result<DocxOptions> {
let mut docx = DocxOptions::default();
if let Some(path) = &cli.reference_doc {
docx.reference_doc = Some(fs::read(path)?);
}
docx.source_date_epoch = source_date_epoch();
docx.locale = std::env::var("LANG").ok();
Ok(docx)
}
fn base_name(path: &Path) -> String {
path.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default()
.to_owned()
}
fn source_date_epoch() -> Option<i64> {
std::env::var("SOURCE_DATE_EPOCH")
.ok()
.and_then(|value| value.trim().parse::<i64>().ok())
}
fn resource_search_path(cli: &Cli) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = cli
.resource_path
.iter()
.flat_map(std::env::split_paths)
.collect();
dirs.push(PathBuf::from("."));
dirs
}
fn resolve_resource(reference: &str, search_path: &[PathBuf]) -> Option<Vec<u8>> {
let reference = Path::new(reference);
if reference.is_absolute() {
return fs::read(reference).ok();
}
search_path
.iter()
.find_map(|dir| fs::read(dir.join(reference)).ok())
}
#[cfg(feature = "write-html")]
fn resolve_embed(
reference: &str,
bag: &MediaBag,
search_path: &[PathBuf],
sandbox: bool,
) -> Option<Resource> {
if let Some(item) = bag.get(reference) {
return Some(Resource {
bytes: item.bytes.clone(),
mime: item.mime.clone(),
});
}
if is_remote_url(reference) {
if sandbox {
eprintln!("carta: not fetching {reference} (--sandbox); leaving reference external");
return None;
}
return fetch_remote(reference);
}
let bytes = resolve_resource(reference, search_path)?;
Some(Resource {
bytes,
mime: mime_for_path(reference),
})
}
#[cfg(feature = "write-html")]
fn is_remote_url(reference: &str) -> bool {
reference.starts_with("http://") || reference.starts_with("https://")
}
#[cfg(feature = "write-html")]
fn mime_for_path(reference: &str) -> Option<String> {
let path = reference.split(['?', '#']).next().unwrap_or(reference);
let extension = Path::new(path).extension()?.to_str()?.to_ascii_lowercase();
let mime = match extension.as_str() {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"svg" => "image/svg+xml",
"webp" => "image/webp",
"avif" => "image/avif",
"ico" => "image/x-icon",
"bmp" => "image/bmp",
"css" => "text/css",
"js" | "mjs" => "text/javascript",
"woff" => "font/woff",
"woff2" => "font/woff2",
"ttf" => "font/ttf",
"otf" => "font/otf",
"eot" => "application/vnd.ms-fontobject",
"pdf" => "application/pdf",
"mp4" => "video/mp4",
"webm" => "video/webm",
"mp3" => "audio/mpeg",
"ogg" => "audio/ogg",
"wav" => "audio/wav",
_ => return None,
};
Some(mime.to_owned())
}
#[cfg(all(feature = "write-html", feature = "fetch"))]
fn fetch_remote(url: &str) -> Option<Resource> {
const LIMIT: u64 = 128 * 1024 * 1024;
let config = ureq::Agent::config_builder()
.timeout_global(Some(std::time::Duration::from_secs(30)))
.max_redirects(5)
.build();
let agent = ureq::Agent::from(config);
match agent.get(url).call() {
Ok(mut response) => {
let mime = response.body().mime_type().map(str::to_owned);
match response.body_mut().with_config().limit(LIMIT).read_to_vec() {
Ok(bytes) => Some(Resource { bytes, mime }),
Err(error) => {
eprintln!("carta: could not read {url}: {error}");
None
}
}
}
Err(error) => {
eprintln!("carta: could not fetch {url}: {error}");
None
}
}
}
#[cfg(all(feature = "write-html", not(feature = "fetch")))]
fn fetch_remote(url: &str) -> Option<Resource> {
eprintln!("carta: cannot fetch {url}: built without network support");
None
}
fn extract_media(dir: &Path, media: &MediaBag, blocks: &mut [Block]) -> Result<()> {
for (name, item) in media.iter() {
let safe = media::extraction_target(name, item);
let path = dir.join(&safe);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, &item.bytes)?;
}
media::rewrite_extracted_references(blocks, media, &dir.to_string_lossy());
Ok(())
}
fn source_name(input: Option<&Path>) -> String {
match input {
None => "-".to_owned(),
Some(path) => path
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or("-")
.to_owned(),
}
}
fn template_dir(path: &Path) -> PathBuf {
path.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."))
.to_path_buf()
}
#[cfg(feature = "highlight")]
const DEFAULT_HIGHLIGHT_STYLE: &str = "pygments";
#[allow(clippy::arc_with_non_send_sync)]
#[cfg(feature = "highlight")]
fn highlight_options(cli: &Cli) -> Result<carta::HighlightOptions> {
let mode = cli.syntax_highlighting.as_deref();
if cli.no_highlight || mode == Some("none") {
return Ok(carta::HighlightOptions::default());
}
if mode == Some("idiomatic") {
return Ok(carta::HighlightOptions {
idiomatic: true,
..carta::HighlightOptions::default()
});
}
let style = cli.highlight_style.as_deref().or(match mode {
Some("default") | None => None,
named => named,
});
let theme = resolve_theme(style.unwrap_or(DEFAULT_HIGHLIGHT_STYLE))?;
let mut highlighter = carta::Highlighter::new();
for path in &cli.syntax_definition {
let xml = fs::read_to_string(path)?;
highlighter
.registry_mut()
.add_definition(&xml)
.map_err(|error| Error::Highlight(format!("{}: {error}", path.display())))?;
}
Ok(carta::HighlightOptions {
highlighter: Some(Arc::new(highlighter)),
theme: Some(theme),
idiomatic: false,
})
}
#[cfg(feature = "highlight")]
fn resolve_theme(spec: &str) -> Result<carta::Theme> {
if let Some(result) = carta::builtin_style(spec) {
return result.map_err(|error| Error::Highlight(format!("style '{spec}': {error}")));
}
let bytes = fs::read(spec)?;
carta::Theme::from_json(&bytes).map_err(|error| Error::Highlight(format!("{spec}: {error}")))
}
#[cfg(feature = "highlight")]
fn print_highlight_style(spec: &str) -> Result<()> {
let json = resolve_theme(spec)?
.to_json()
.map_err(|error| Error::Highlight(error.to_string()))?;
let mut out = io::stdout().lock();
writeln!(out, "{json}")?;
Ok(())
}
const DEFAULT_MATHJAX_URL: &str = "https://cdn.jsdelivr.net/npm/mathjax@4/tex-chtml.js";
const DEFAULT_KATEX_URL: &str = "https://cdn.jsdelivr.net/npm/katex@latest/dist/";
fn math_method(cli: &Cli) -> MathMethod {
if let Some(url) = &cli.mathjax {
MathMethod::MathJax(
url.clone()
.unwrap_or_else(|| DEFAULT_MATHJAX_URL.to_owned()),
)
} else if let Some(url) = &cli.katex {
MathMethod::Katex(url.clone().unwrap_or_else(|| DEFAULT_KATEX_URL.to_owned()))
} else {
MathMethod::Plain
}
}
fn parse_wrap(value: &str) -> std::result::Result<WrapMode, String> {
match value {
"auto" => Ok(WrapMode::Auto),
"none" => Ok(WrapMode::None),
"preserve" => Ok(WrapMode::Preserve),
other => Err(format!(
"invalid wrap mode '{other}' (expected auto, none, or preserve)"
)),
}
}
fn parse_toc_depth(value: &str) -> std::result::Result<usize, String> {
match value.parse::<usize>() {
Ok(depth @ 1..=6) => Ok(depth),
_ => Err(format!("'{value}' is not a heading level between 1 and 6")),
}
}
fn parse_split_level(value: &str) -> std::result::Result<usize, String> {
match value.parse::<usize>() {
Ok(level @ 1..=6) => Ok(level),
_ => Err(format!("'{value}' is not a heading level between 1 and 6")),
}
}
fn parse_variables(specs: &[String]) -> Vec<(String, String)> {
specs
.iter()
.map(|spec| match spec.split_once([':', '=']) {
Some((key, value)) => (key.to_owned(), value.to_owned()),
None => (spec.clone(), "true".to_owned()),
})
.collect()
}
fn parse_metadata(specs: &[String]) -> BTreeMap<String, MetaValue> {
let mut map: BTreeMap<String, MetaValue> = BTreeMap::new();
for spec in specs {
let (key, value) = match spec.split_once([':', '=']) {
Some((key, "true")) => (key, MetaValue::MetaBool(true)),
Some((key, "false")) => (key, MetaValue::MetaBool(false)),
Some((key, value)) => (key, MetaValue::MetaString(value.into())),
None => (spec.as_str(), MetaValue::MetaBool(true)),
};
let next = match map.remove(key) {
None => value,
Some(MetaValue::MetaList(mut items)) => {
items.push(value);
MetaValue::MetaList(items)
}
Some(first) => MetaValue::MetaList(vec![first, value]),
};
map.insert(key.to_owned(), next);
}
map
}
fn read_metadata_files(paths: &[PathBuf]) -> Result<BTreeMap<String, MetaValue>> {
let mut defaults = BTreeMap::new();
for path in paths {
let content = fs::read_to_string(path)?;
let json = path.extension().and_then(|ext| ext.to_str()) == Some("json");
for (key, value) in carta::parse_metadata_file(&content, json)? {
defaults.insert(key, value);
}
}
Ok(defaults)
}
fn print_default_template(spec: &str) -> Result<()> {
let (base, _) = carta::parse_format_spec(spec)?;
let writer = carta::any_writer_for(&base)?;
match writer.default_template() {
Some(template) => {
io::stdout().lock().write_all(template.as_bytes())?;
Ok(())
}
None => Err(Error::Template(format!(
"format '{base}' has no default template"
))),
}
}
fn print_lines(lines: &[&str]) -> Result<()> {
let mut out = io::stdout().lock();
for line in lines {
writeln!(out, "{line}")?;
}
Ok(())
}
#[cfg(feature = "highlight")]
fn print_owned_lines(lines: &[String]) -> Result<()> {
let mut out = io::stdout().lock();
for line in lines {
writeln!(out, "{line}")?;
}
Ok(())
}
fn list_extensions(format: Option<&str>) -> Result<()> {
let mut out = io::stdout().lock();
for (extension, enabled) in carta::format_extensions(format)? {
let sign = if enabled { '+' } else { '-' };
writeln!(out, "{sign}{}", extension.name())?;
}
Ok(())
}
fn read_input(path: Option<&Path>) -> Result<Vec<u8>> {
if let Some(path) = path {
Ok(fs::read(path)?)
} else {
let mut buffer = Vec::new();
io::stdin().read_to_end(&mut buffer)?;
Ok(buffer)
}
}
fn write_output(path: Option<&Path>, output: &Output, verbatim: bool) -> Result<()> {
if matches!(output, Output::Bytes(_)) && binary_to_terminal(path) {
return Err(Error::Io(io::Error::other(
"refusing to write binary output to a terminal (use -o FILE or redirect stdout)",
)));
}
let mut writer: Box<dyn Write> = match path {
Some(path) => Box::new(fs::File::create(path)?),
None => Box::new(io::stdout().lock()),
};
match output {
Output::Text(text) => {
writer.write_all(text.as_bytes())?;
if !verbatim {
writer.write_all(b"\n")?;
}
}
Output::Bytes(bytes) => writer.write_all(bytes)?,
}
Ok(())
}
fn binary_to_terminal(path: Option<&Path>) -> bool {
path.is_none() && io::stdout().is_terminal()
}
#[cfg(test)]
mod tests {
#![allow(clippy::indexing_slicing)]
use super::{Cli, parse_metadata, parse_toc_depth, parse_variables, parse_wrap, template_dir};
use carta::WrapMode;
use carta::ast::MetaValue;
use clap::CommandFactory;
use std::path::{Path, PathBuf};
fn vars(args: &[&str]) -> Vec<(String, String)> {
parse_variables(&args.iter().map(|s| (*s).to_owned()).collect::<Vec<_>>())
}
#[test]
fn bare_variable_defaults_to_true() {
assert_eq!(
vars(&["flag", "k=v", "eq=a=b"]),
vec![
("flag".to_owned(), "true".to_owned()),
("k".to_owned(), "v".to_owned()),
("eq".to_owned(), "a=b".to_owned()),
]
);
}
#[test]
fn variable_splits_on_the_first_colon_or_equals() {
assert_eq!(
vars(&["k:v", "colon:a=b", "equals=a:b"]),
vec![
("k".to_owned(), "v".to_owned()),
("colon".to_owned(), "a=b".to_owned()),
("equals".to_owned(), "a:b".to_owned()),
]
);
}
#[test]
fn metadata_splits_on_the_first_colon_or_equals() {
let map = parse_metadata(
&["a:val", "b:true", "c:x=y"]
.iter()
.map(|s| (*s).to_owned())
.collect::<Vec<_>>(),
);
assert_eq!(map["a"], MetaValue::MetaString("val".into()));
assert_eq!(map["b"], MetaValue::MetaBool(true));
assert_eq!(map["c"], MetaValue::MetaString("x=y".into()));
}
#[test]
fn metadata_typing_distinguishes_booleans_from_strings() {
let map = parse_metadata(
&["a=true", "b=false", "c=text", "d", "e=True"]
.iter()
.map(|s| (*s).to_owned())
.collect::<Vec<_>>(),
);
assert_eq!(map["a"], MetaValue::MetaBool(true));
assert_eq!(map["b"], MetaValue::MetaBool(false));
assert_eq!(map["c"], MetaValue::MetaString("text".into()));
assert_eq!(map["d"], MetaValue::MetaBool(true));
assert_eq!(map["e"], MetaValue::MetaString("True".into()));
}
#[test]
fn repeated_metadata_key_accumulates_into_a_list() {
let two = parse_metadata(&["k=first".to_owned(), "k=second".to_owned()]);
assert_eq!(
two["k"],
MetaValue::MetaList(vec![
MetaValue::MetaString("first".into()),
MetaValue::MetaString("second".into()),
])
);
let mixed = parse_metadata(&["k".to_owned(), "k=a".to_owned(), "k=b".to_owned()]);
assert_eq!(
mixed["k"],
MetaValue::MetaList(vec![
MetaValue::MetaBool(true),
MetaValue::MetaString("a".into()),
MetaValue::MetaString("b".into()),
])
);
}
#[test]
fn template_dir_is_the_file_parent_or_current_dir() {
assert_eq!(template_dir(Path::new("bare.html")), PathBuf::from("."));
assert_eq!(
template_dir(Path::new("sub/dir/t.html")),
PathBuf::from("sub/dir")
);
assert_eq!(
template_dir(Path::new("/abs/t.html")),
PathBuf::from("/abs")
);
}
#[test]
fn cli_definition_is_valid() {
Cli::command().debug_assert();
}
#[test]
fn wrap_mode_parses_the_three_names_and_rejects_others() {
assert_eq!(parse_wrap("auto"), Ok(WrapMode::Auto));
assert_eq!(parse_wrap("none"), Ok(WrapMode::None));
assert_eq!(parse_wrap("preserve"), Ok(WrapMode::Preserve));
assert!(parse_wrap("soft").is_err());
}
#[test]
fn toc_depth_accepts_one_through_six_and_rejects_the_rest() {
assert_eq!(parse_toc_depth("1"), Ok(1));
assert_eq!(parse_toc_depth("6"), Ok(6));
assert!(parse_toc_depth("0").is_err());
assert!(parse_toc_depth("7").is_err());
assert!(parse_toc_depth("two").is_err());
}
}