rbfmt 0.0.2

Ruby code formatter
Documentation
use std::{
    error::Error,
    ffi::OsStr,
    io::{Read, Write},
    path::PathBuf,
};

use crate::config;

const VERSION: &str = "0.0.2";

#[derive(Debug)]
enum Action {
    Print(String),
    Format(FormatRequest),
}

#[derive(Debug)]
struct FormatRequest {
    write_to_file: bool,
    target: FormatTarget,
}

#[derive(Debug)]
struct SomeError(String);

#[derive(Debug)]
enum FormatTarget {
    Files { paths: Vec<String> },
    Stdin,
}

impl std::fmt::Display for SomeError {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        write!(f, "{}", self.0)?;
        Ok(())
    }
}
impl std::error::Error for SomeError {}

pub fn run(
    r: &mut impl Read,
    w: &mut impl Write,
    args: impl IntoIterator<Item = impl AsRef<OsStr>>,
) -> Result<(), Box<dyn Error>> {
    let action = parse_args(args)?;
    match action {
        Action::Print(message) => {
            writeln!(w, "{}", message.trim())?;
            Ok(())
        }
        Action::Format(request) => run_format(r, w, request),
    }
}

fn run_format(
    r: &mut impl Read,
    w: &mut impl Write,
    request: FormatRequest,
) -> Result<(), Box<dyn Error>> {
    match request.target {
        FormatTarget::Stdin => {
            let mut source = Vec::new();
            r.read_to_end(&mut source)?;
            let cwd = std::env::current_dir()?;
            let config = config::config_of_dir(&cwd)?;
            let result = crate::format_source(source, config.format)?;
            write!(w, "{}", result)?;
            Ok(())
        }
        FormatTarget::Files { ref paths } => {
            let target_paths = flatten_target_paths(paths)?;
            let need_file_separator = paths.len() > 1;
            for path in target_paths {
                let source = std::fs::read(&path)?;
                let config = config::config_of_path(&path)?;
                let result = crate::format_source(source, config.format);
                match result {
                    Ok(result) => {
                        if request.write_to_file {
                            std::fs::write(&path, result)?;
                        } else {
                            if need_file_separator {
                                writeln!(w, "\n------ {:?} -----", &path)?;
                            }
                            write!(w, "{}", result)?;
                        }
                    }
                    Err(err) => {
                        writeln!(w, "failed to format {:?}", &path)?;
                        writeln!(w, "error: {err}")?;
                    }
                }
            }
            Ok(())
        }
    }
}

fn flatten_target_paths(target_paths: &Vec<String>) -> Result<Vec<PathBuf>, Box<dyn Error>> {
    let mut paths = vec![];
    for path in target_paths {
        let path = PathBuf::from(path);
        append_paths_recursively(path, &mut paths, false)?;
    }
    Ok(paths)
}

fn append_paths_recursively(
    path: PathBuf,
    paths: &mut Vec<PathBuf>,
    ignore_non_existiing_path: bool,
) -> Result<(), Box<dyn Error>> {
    if !path.exists() {
        if ignore_non_existiing_path {
            return Ok(());
        } else {
            let message = format!("file not exist: {}", path.as_os_str().to_string_lossy());
            return Err(Box::new(SomeError(message)));
        }
    }
    if path.is_file() {
        if let Some(ext) = path.extension() {
            if ext == "rb" {
                paths.push(path);
            }
        }
    } else if path.is_dir() {
        let entries = std::fs::read_dir(path)?;
        for entry in entries {
            let path = entry?.path();
            append_paths_recursively(path, paths, true)?;
        }
    }
    Ok(())
}

const USAGE_FOOTER: &str = "
To configure formatting, put .rbfmt.yml file.
ref: https://github.com/ryym/rbfmt
";

fn parse_args(args: impl IntoIterator<Item = impl AsRef<OsStr>>) -> Result<Action, Box<dyn Error>> {
    let args = args.into_iter();
    let options = build_options();

    let matches = options.parse(args)?;
    if matches.opt_present("v") {
        return Ok(Action::Print(VERSION.to_string()));
    }
    if matches.opt_present("h") || matches.free.is_empty() {
        let mut usage = options.usage("Usage: rbfmt [options] [path/-]...");
        usage.push_str(USAGE_FOOTER);
        return Ok(Action::Print(usage));
    }

    let write_to_file = matches.opt_present("w");
    let target = if matches.free.iter().any(|s| s == "-") {
        FormatTarget::Stdin
    } else {
        FormatTarget::Files {
            paths: matches.free,
        }
    };

    let fmt_request = FormatRequest {
        write_to_file,
        target,
    };
    Ok(Action::Format(fmt_request))
}

fn build_options() -> getopts::Options {
    let mut o = getopts::Options::new();
    o.optflag("h", "help", "Print this help message");
    o.optflag("w", "write", "Write output to files instead of STDOUT");
    o.optflag("v", "version", "Print version");
    o
}

#[cfg(test)]
mod test {
    use std::error::Error;

    use similar_asserts::assert_eq;

    #[test]
    fn print_version() -> Result<(), Box<dyn Error>> {
        let mut output = Vec::new();
        super::run(&mut std::io::empty(), &mut output, ["-v"])?;

        let output = String::from_utf8(output)?.to_string();
        assert_eq!(output, format!("{}\n", super::VERSION));
        Ok(())
    }

    #[test]
    fn print_help_when_no_args_provided() -> Result<(), Box<dyn Error>> {
        let mut output = Vec::new();
        super::run(&mut std::io::empty(), &mut output, [] as [&str; 0])?;

        let output = String::from_utf8(output)?.to_string();
        assert!(output.starts_with("Usage:"));
        Ok(())
    }

    #[test]
    fn read_source_from_input() -> Result<(), Box<dyn Error>> {
        let input = b"foo  . bar(1  ,2+3,  4 )";
        let mut output = Vec::new();
        super::run(&mut &input[..], &mut output, ["-"])?;

        let output = String::from_utf8(output)?.to_string();
        assert_eq!(&output, "foo.bar(1, 2 + 3, 4)\n");
        Ok(())
    }
}