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());
}
}