katana-document-viewer 0.1.4

KatanA document viewer artifact, render evaluation, and export foundation.
Documentation
use katana_document_viewer::{ExportFormat, KdvThemeSnapshot};
use std::env;
use std::error::Error;
use std::path::PathBuf;

pub(crate) const EXPORT_FORMATS: [ExportFormat; 4] = [
    ExportFormat::Html,
    ExportFormat::Pdf,
    ExportFormat::Png,
    ExportFormat::Jpeg,
];

#[derive(Debug)]
pub(crate) struct CommandArgs {
    pub(crate) input_path: PathBuf,
    pub(crate) output_dir: PathBuf,
    pub(crate) theme: KdvThemeSnapshot,
}

pub(crate) struct CommandArgsParser;

impl CommandArgsParser {
    pub(crate) fn parse() -> Result<CommandArgs, Box<dyn Error>> {
        Self::parse_from(env::args().skip(1))
    }

    pub(crate) fn parse_from<I>(args: I) -> Result<CommandArgs, Box<dyn Error>>
    where
        I: IntoIterator<Item = String>,
    {
        RawCommandArgsParser::new(args.into_iter()).parse()
    }
}

struct RawCommandArgsParser<I> {
    args: I,
    parsed: ParsedCommandArgs,
    parses_options: bool,
}

impl<I> RawCommandArgsParser<I>
where
    I: Iterator<Item = String>,
{
    fn new(args: I) -> Self {
        Self {
            args,
            parsed: ParsedCommandArgs::new(),
            parses_options: true,
        }
    }

    fn parse(mut self) -> Result<CommandArgs, Box<dyn Error>> {
        while let Some(arg) = self.args.next() {
            self.accept(arg)?;
        }
        self.parsed.into_command_args()
    }

    fn accept(&mut self, arg: String) -> Result<(), Box<dyn Error>> {
        if !self.parses_options {
            self.parsed.push_positional(arg);
            return Ok(());
        }
        match arg.as_str() {
            "--" => {
                self.parses_options = false;
                Ok(())
            }
            "--light" => self.parsed.set_theme(KdvThemeSnapshot::katana_light()),
            "--dark" => self.parsed.set_theme(KdvThemeSnapshot::katana_dark()),
            "--theme" => {
                let json = self.required_arg("missing theme JSON")?;
                self.parsed.set_theme(serde_json::from_str(&json)?)
            }
            "--thema" => Err(unknown_option("--thema").into()),
            _ => {
                self.parses_options = false;
                self.parsed.push_positional(arg);
                Ok(())
            }
        }
    }

    fn required_arg(&mut self, message: &'static str) -> Result<String, Box<dyn Error>> {
        self.args
            .next()
            .ok_or_else(|| invalid_input(message).into())
    }
}

struct ParsedCommandArgs {
    theme: KdvThemeSnapshot,
    theme_is_set: bool,
    positional_args: Vec<String>,
}

impl ParsedCommandArgs {
    fn new() -> Self {
        Self {
            theme: KdvThemeSnapshot::katana_light(),
            theme_is_set: false,
            positional_args: Vec::new(),
        }
    }

    fn set_theme(&mut self, theme: KdvThemeSnapshot) -> Result<(), Box<dyn Error>> {
        if self.theme_is_set {
            return Err(invalid_input("theme option cannot be combined").into());
        }
        self.theme_is_set = true;
        self.theme = theme;
        Ok(())
    }

    fn push_positional(&mut self, value: String) {
        self.positional_args.push(value);
    }

    fn into_command_args(self) -> Result<CommandArgs, Box<dyn Error>> {
        if self.positional_args.len() != 2 {
            return Err(positional_error(self.positional_args.len()).into());
        }
        Ok(CommandArgs {
            input_path: PathBuf::from(self.positional_args[0].clone()),
            output_dir: PathBuf::from(self.positional_args[1].clone()),
            theme: self.theme,
        })
    }
}

fn positional_error(count: usize) -> std::io::Error {
    match count {
        0 => invalid_input("missing input markdown path"),
        1 => invalid_input("missing output directory"),
        _ => invalid_input("too many arguments"),
    }
}

fn unknown_option(value: &str) -> std::io::Error {
    invalid_input(format!("unknown option: {value}"))
}

fn invalid_input(message: impl Into<String>) -> std::io::Error {
    std::io::Error::new(std::io::ErrorKind::InvalidInput, message.into())
}

#[cfg(test)]
#[path = "args_tests.rs"]
mod tests;