6
\$\begingroup\$

I reimplemented cat(1) for fun. I followed the Open Group Base Specifications (Issue 7, 2018 edition), not the GNU variant and its command line arguments.

Buffered and -unbuffered behaviour

While the specification defines the behaviour for -u, it doesn't define how the arguments should be concatenated when the -unbuffered argument is missing. To get used to BufRead and BufWriter, I used a buffered approach that simply uses Rust's already existing methods.

I'm not entirely happy about the return code issue. I currently use io::Result in main to return the last error if it occurred, however, this also means that the last error will get reported twice. I could use std::process::exit, but that would need a wrapper around main.

Argument parsing and additional dependencies

I'll admit, the argument handling part of main won't win a beauty contest. However, I didn't want to add clap or other argument handling libraries and instead focus on using std. The program should follow the Utility Argument Syntax, however, it doesn't follow guidelines 5 (e.g. -uuuu is not the same as -u -u -u -u) and 9 (-u does not need to be the first argument). Argument grouping is a non-goal for this toy program, however.

Also, comparing to GNU cat, unknown options are interpreted as files names, whereas GNU cat will exit with an error message. I'm not sure whether that violates the specifications, though.

Implementation

The application is split into two parts, namely main.rs (mostly argument parsing) and lib.rs (the actual implementation). Any comments on the organisation are fine.

// main.rs
use std::ffi::OsString;
use std::path::Path;

use cat::{cat_buffered_single, cat_unbuffered_single};

fn main() -> std::io::Result<()> {
    let mut args: Vec<OsString> = std::env::args_os().skip(1).collect();

    // Only parse arguments up to "--"
    let args_up_to = if let Some(index) = args.iter().position(|arg| arg == "--") {
        args.remove(index);
        index
    } else {
        args.len()
    };

    // Keep all arguments after "--" as-is
    let verbatim_args = args.split_off(args_up_to);

    // Check for "-u" in valid positions and remove the first one
    let cat_func = if args.iter().any(|arg| arg == "-u") {
        args = args.into_iter().filter(|x| x != "-u").collect();
        cat_unbuffered_single
    } else {
        cat_buffered_single
    };

    // Recombine arguments
    args.extend(verbatim_args);

    // Fallback to stdin behaviour
    if args.is_empty() {
        args.push("-".into());
    }

    let mut result = Ok(());

    for arg in args {
        let path = Path::new(&arg);
        match cat_func(path) {
            Ok(()) => continue,
            Err(e) => {
                eprintln!("cat: {}: {}", path.to_string_lossy(), e);
                result = Err(e);
            }
        }
    }
    result
}
// lib.rs
use std::fs::File;
use std::io::{self, BufRead, BufReader, BufWriter, Read, Write};
use std::path::Path;

// Dumps all bytes from `src` into `dest`, using both buffer functionalities.
fn dump_buffered_single(src: &mut dyn BufRead, dest: &mut BufWriter<impl Write>) -> io::Result<()> {
    loop {
        let buf = src.fill_buf()?;
        if buf.is_empty() {
            break;
        }
        dest.write_all(buf)?;
        let bytes = buf.len();
        src.consume(bytes);
    }
    dest.flush()
}

/// Dumps the file given by `path` on `stdout` using buffered IO.
///
/// If `path` is `"-"`, then `stdin` is used as input instead of a file.
///
/// Example
/// ```
/// # use cat::cat_buffered_single;
/// cat_buffered_single("hello.txt".as_ref());
/// ```
pub fn cat_buffered_single(path: &Path) -> io::Result<()> {
    let stdout = io::stdout();
    let handle = stdout.lock();
    let mut writer = io::BufWriter::new(handle);

    if path == Path::new("-") {
        dump_buffered_single(&mut io::stdin().lock(), &mut writer)?;
    } else {
        let input = BufReader::new(File::open(path)?);
        let mut reader = BufReader::new(input);
        dump_buffered_single(&mut reader, &mut writer)?;
    }
    Ok(writer.flush()?)
}

// Dumps all bytes from `src` into `dest`, byte by byte.
fn dump_unbuffered_single(src: &mut dyn Read, dest: &mut dyn Write) -> io::Result<()> {
    for byte in src.bytes() {
        dest.write_all(std::slice::from_ref(&byte?))?;
    }
    Ok(())
}

/// Dumps the file given by `path` on `stdout` without buffering
///
/// If `path` is `"-"`, then `stdin` is used as input instead of a file.
///
/// Example
/// ```
/// # use cat::cat_unbuffered_single;
/// cat_unbuffered_single("hello.txt".as_ref());
/// ```
pub fn cat_unbuffered_single(path: &Path) -> io::Result<()> {
    let stdout = io::stdout();
    let mut handle = stdout.lock();

    if path == Path::new("-") {
        dump_unbuffered_single(&mut io::stdin().lock(), &mut handle)?;
    } else {
        let mut file = File::open(path)?;
        dump_unbuffered_single(&mut file, &mut handle)?;
    }
    Ok(())
}

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

    fn test_dump_buffered_single(test_bytes: &[u8]) {
        let src = test_bytes;
        let mut reader = BufReader::new(src);
        let mut dest = vec![];
        {
            let mut writer = BufWriter::new(&mut dest);
            dump_buffered_single(&mut reader, &mut writer).unwrap();
        }
        assert_eq!(dest, src);
    }

    #[test]
    fn dumps_all_buffered_data() {
        test_dump_buffered_single(b"Hello, World");
    }
    #[test]
    fn dumps_no_buffered_data() {
        test_dump_buffered_single(b"");
    }

    fn test_dump_unbuffered_single(bytes: &[u8]) {
        let source = bytes;
        let mut reader = source.clone();
        let mut destination = vec![];
        dump_unbuffered_single(&mut reader, &mut destination).unwrap();
        assert_eq!(destination, source);
    }
    #[test]
    fn dumps_all_unbuffered_data() {
        test_dump_unbuffered_single(b"Hello, World");
    }
    #[test]
    fn dumps_no_unbuffered_data() {
        test_dump_unbuffered_single(b"");
    }
}

I've used cargo fmt and cargo clippy on the code above. By the way, I see myself as a Rust beginner, so feel free to comment any part of the code.

\$\endgroup\$

1 Answer 1

2
\$\begingroup\$

First of all, I would like to point out that it has been a great pleasure to read the code with consistent formatting, helpful comments, and clear logic. The following points might be subjective and nitpicky, but they don't represent my general impression.

Argument parsing

The program should follow the Utility Argument Syntax, however, it doesn't follow guidelines [...] 9 (-u does not need to be the first argument).

Wouldn't requiring -u to come before the operands make the implementation easier though? I'm thinking along these lines:

let (buffered, operands) = match args.get(0) {
    None => {
        args.push("-".into());
        (true, &args[..])
    }
    Some(arg) if *arg == "-u" => (false, &args[1..]),
    Some(_) => (true, &args[..]),
};

BufRead and Write

Instead of &mut dyn BufRead, it's more common to take BufRead by value. The reason is that mutable references to BufRead automatically implement BufRead.

Instead of taking an argument of type &mut BufWriter<impl Write>, it suffices to take Write, since the functionality of BufWriter can be accessed via Write.

Result:

fn dump_buffered_single<R, W>(mut src: R, mut dest: W) -> io::Result<()>
where
    R: BufRead,
    W: Write,
{
    // ...
}
\$\endgroup\$
1
  • \$\begingroup\$ Thanks for the review! Yeah, the argument parsing would be easier if I supported -u only at the start. However, -u may be used several times (e.g. -u -u or -uuuu), so it's a little bit more complicated due to guideline 5. But I'll keep that in mind for future minimal argument parsing :) \$\endgroup\$
    – Zeta
    Aug 17, 2020 at 7:28

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.