katana-document-viewer 0.1.0

UI-independent document artifact, render evaluation, and export foundation for KatanA.
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>,
    {
        let mut args = args.into_iter();
        let mut theme = KdvThemeSnapshot::katana_light();
        let first = required_arg(&mut args, "missing input markdown path")?;
        let input_path = match first.as_str() {
            "--light" => required_arg(&mut args, "missing input markdown path")?,
            "--dark" => {
                theme = KdvThemeSnapshot::katana_dark();
                required_arg(&mut args, "missing input markdown path")?
            }
            "--theme" => return Err(invalid_input("--theme is tracked by a later change").into()),
            "--thema" => return Err(invalid_input("--thema is not supported").into()),
            value => value.to_string(),
        };
        let output_dir = required_arg(&mut args, "missing output directory")?;
        if args.next().is_some() {
            return Err(invalid_input("too many arguments").into());
        }
        Ok(CommandArgs {
            input_path: PathBuf::from(input_path),
            output_dir: PathBuf::from(output_dir),
            theme,
        })
    }
}

fn required_arg(
    args: &mut impl Iterator<Item = String>,
    message: &'static str,
) -> Result<String, std::io::Error> {
    args.next().ok_or_else(|| invalid_input(message))
}

fn invalid_input(message: &'static str) -> std::io::Error {
    std::io::Error::new(std::io::ErrorKind::InvalidInput, message)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn default_theme_is_katana_light() -> Result<(), Box<dyn Error>> {
        let args = parse_args(&["input.md", "out"])?;

        assert_eq!(args.theme.name, "katana-light");
        assert_eq!(args.input_path, PathBuf::from("input.md"));
        assert_eq!(args.output_dir, PathBuf::from("out"));
        Ok(())
    }

    #[test]
    fn light_theme_is_katana_light() -> Result<(), Box<dyn Error>> {
        let args = parse_args(&["--light", "input.md", "out"])?;

        assert_eq!(args.theme.name, "katana-light");
        assert_eq!(args.input_path, PathBuf::from("input.md"));
        Ok(())
    }

    #[test]
    fn dark_theme_is_katana_dark() -> Result<(), Box<dyn Error>> {
        let args = parse_args(&["--dark", "input.md", "out"])?;

        assert_eq!(args.theme.name, "katana-dark");
        assert_eq!(args.output_dir, PathBuf::from("out"));
        Ok(())
    }

    #[test]
    fn theme_json_entry_is_rejected_until_later_change() {
        let error = parse_args(&["--theme", "theme.json", "input.md", "out"])
            .expect_err("theme JSON entry must be rejected in this change");

        assert_eq!(error.to_string(), "--theme is tracked by a later change");
    }

    #[test]
    fn thema_spelling_is_rejected() {
        let error = parse_args(&["--thema", "theme.json", "input.md", "out"])
            .expect_err("--thema must not be accepted");

        assert_eq!(error.to_string(), "--thema is not supported");
    }

    fn parse_args(values: &[&str]) -> Result<CommandArgs, Box<dyn Error>> {
        CommandArgsParser::parse_from(values.iter().map(|value| value.to_string()))
    }
}