mbdown 0.1.1

Parser and abstract syntax tree for the MBDown markup language
Documentation
use std::env;
use std::ffi::OsString;
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;

const HELP: &str = "\
Format MBDown source

Usage: mbfmt [OPTIONS] [FILE]

Arguments:
  [FILE]  Input file; omit or use - to read UTF-8 text from stdin

Options:
      --check  Exit unsuccessfully when the input is not formatted
  -w, --write  Format the input file in place
  -h, --help   Print help
  -V, --version  Print version
";

#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
enum Mode {
    #[default]
    Output,
    Check,
    Write,
}

#[derive(Debug, Default, Eq, PartialEq)]
struct Args {
    mode: Mode,
    input: Option<PathBuf>,
}

enum Command {
    Run(Args),
    Help,
    Version,
}

fn main() -> ExitCode {
    let command = match parse_args(env::args_os().skip(1)) {
        Ok(command) => command,
        Err(error) => {
            eprintln!("mbfmt: {error}");
            eprintln!("Try 'mbfmt --help' for more information.");
            return ExitCode::from(2);
        }
    };
    match command {
        Command::Help => {
            print!("{HELP}");
            ExitCode::SUCCESS
        }
        Command::Version => {
            println!("mbfmt {}", env!("CARGO_PKG_VERSION"));
            ExitCode::SUCCESS
        }
        Command::Run(args) => match run(args) {
            Ok(()) => ExitCode::SUCCESS,
            Err(error) => {
                eprintln!("mbfmt: {error}");
                ExitCode::FAILURE
            }
        },
    }
}

fn parse_args(args: impl IntoIterator<Item = OsString>) -> Result<Command, String> {
    let mut parsed = Args::default();
    for argument in args {
        match argument.to_str() {
            Some("-h" | "--help") => return Ok(Command::Help),
            Some("-V" | "--version") => return Ok(Command::Version),
            Some("--check") => set_mode(&mut parsed, Mode::Check)?,
            Some("-w" | "--write") => set_mode(&mut parsed, Mode::Write)?,
            Some(value) if value.starts_with('-') && value != "-" => {
                return Err(format!("unknown option {value:?}"));
            }
            _ if parsed.input.is_none() => parsed.input = Some(PathBuf::from(argument)),
            _ => return Err("only one input file is supported".to_string()),
        }
    }
    if parsed.mode == Mode::Write
        && parsed
            .input
            .as_deref()
            .is_none_or(|path| path == Path::new("-"))
    {
        return Err("--write requires an input file".to_string());
    }
    Ok(Command::Run(parsed))
}

fn set_mode(args: &mut Args, mode: Mode) -> Result<(), String> {
    if args.mode != Mode::Output && args.mode != mode {
        return Err("--check and --write cannot be used together".to_string());
    }
    args.mode = mode;
    Ok(())
}

fn run(args: Args) -> Result<(), String> {
    let (source, label) = read_source(args.input.as_deref())?;
    let formatted = mbdown::format(&source).map_err(|error| format!("{label}: {error}"))?;
    match args.mode {
        Mode::Output => write_stdout(&formatted),
        Mode::Check if source == formatted => Ok(()),
        Mode::Check => Err(format!("{label} is not formatted")),
        Mode::Write if source == formatted => Ok(()),
        Mode::Write => {
            let path = args.input.expect("write mode requires a file path");
            std::fs::write(&path, formatted)
                .map_err(|error| format!("failed to write {}: {error}", path.display()))
        }
    }
}

fn read_source(path: Option<&Path>) -> Result<(String, String), String> {
    match path {
        None => read_stdin(),
        Some(path) if path == Path::new("-") => read_stdin(),
        Some(path) => std::fs::read_to_string(path)
            .map(|source| (source, path.display().to_string()))
            .map_err(|error| format!("failed to read {}: {error}", path.display())),
    }
}

fn read_stdin() -> Result<(String, String), String> {
    let mut source = String::new();
    io::stdin()
        .read_to_string(&mut source)
        .map_err(|error| format!("failed to read stdin: {error}"))?;
    Ok((source, "stdin".to_string()))
}

fn write_stdout(output: &str) -> Result<(), String> {
    let mut stdout = io::stdout().lock();
    stdout
        .write_all(output.as_bytes())
        .and_then(|()| stdout.flush())
        .map_err(|error| format!("failed to write stdout: {error}"))
}

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

    fn strings<'a>(values: &'a [&'a str]) -> impl Iterator<Item = OsString> + 'a {
        values.iter().map(OsString::from)
    }

    #[test]
    fn parses_input_and_modes() {
        let Command::Run(args) = parse_args(strings(&["--check", "document.mb"])).unwrap() else {
            panic!("run command");
        };
        assert_eq!(
            args,
            Args {
                mode: Mode::Check,
                input: Some(PathBuf::from("document.mb")),
            }
        );

        let Command::Run(args) = parse_args(strings(&["--write", "document.mb"])).unwrap() else {
            panic!("run command");
        };
        assert_eq!(args.mode, Mode::Write);
    }

    #[test]
    fn rejects_conflicts_and_unsupported_inputs() {
        assert!(parse_args(strings(&["--check", "--write", "a.mb"])).is_err());
        assert!(parse_args(strings(&["--write"])).is_err());
        assert!(parse_args(strings(&["--write", "-"])).is_err());
        assert!(parse_args(strings(&["a.mb", "b.mb"])).is_err());
        assert!(parse_args(strings(&["--unknown"])).is_err());
    }
}