engiffen 0.8.1

Encodes GIF images from sequences of images. Includes an installable command line bin as well as the library.
Documentation
extern crate getopts;
#[cfg(feature = "globbing")] extern crate glob;

use getopts::Options;
use std::path::{Path, PathBuf};
use std::{error, fmt};
use std::str::FromStr;
use std::io::Write;
use std;

use self::SourceImages::*;
use engiffen::Quantizer;

#[derive(Debug, Eq, PartialEq)]
pub enum SourceImages {
    StartEnd(PathBuf, PathBuf, PathBuf),
    List(Vec<String>),
    #[cfg(feature = "globbing")] Glob(String),
}

#[derive(Debug, Eq, PartialEq)]
pub enum Modifier {
    Reverse,
    Shuffle
}

#[derive(Debug, Eq, PartialEq)]
pub struct Args {
    pub source: SourceImages,
    pub fps: usize,
    pub out_file: Option<String>,
    pub quantizer: Quantizer,
    pub modifiers: Vec<Modifier>,
}

#[derive(Debug, PartialEq)]
pub enum ArgsError {
    Parse(getopts::Fail),
    ParseInt(std::num::ParseIntError),
    #[cfg(feature = "globbing")] GlobPattern,
    ImageRange(String),
    DisplayHelp(String),
}

impl From<getopts::Fail> for ArgsError {
    fn from(err: getopts::Fail) -> ArgsError {
        ArgsError::Parse(err)
    }
}

impl From<std::num::ParseIntError> for ArgsError {
    fn from(err: std::num::ParseIntError) -> ArgsError {
        ArgsError::ParseInt(err)
    }
}

#[cfg(feature = "globbing")]
impl From<glob::PatternError> for ArgsError {
    fn from(_: glob::PatternError) -> ArgsError {
        ArgsError::GlobPattern
    }
}

impl fmt::Display for ArgsError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match *self {
            ArgsError::Parse(ref err) => write!(f, "Options parse error: {}", err),
            ArgsError::ParseInt(_) => write!(f, "Unable to parse argument as an integer"),
            #[cfg(feature = "globbing")] ArgsError::GlobPattern => write!(f, "Unable to parse glob pattern"),
            ArgsError::ImageRange(ref s) => write!(f, "Bad image range: {}", s),
            ArgsError::DisplayHelp(ref msg) => write!(f, "{}", msg),
        }
    }
}

impl error::Error for ArgsError {
    fn description(&self) -> &str {
        match *self {
            ArgsError::Parse(ref err) => err.description(),
            ArgsError::ParseInt(ref err) => err.description(),
            #[cfg(feature = "globbing")] ArgsError::GlobPattern => "Bad glob pattern",
            ArgsError::ImageRange(_) => "Bad image range",
            ArgsError::DisplayHelp(_) => "Display help message"
        }
    }

    fn cause(&self) -> Option<&error::Error> {
        match *self {
            ArgsError::Parse(ref err) => Some(err),
            ArgsError::ParseInt(ref err) => Some(err),
            #[cfg(feature = "globbing")] ArgsError::GlobPattern => None,
            ArgsError::ImageRange(_) => None,
            ArgsError::DisplayHelp(_) => None,
        }
    }
}

pub fn parse_args(args: &[String]) -> Result<Args, ArgsError> {
    let program = args[0].clone();

    let mut opts = Options::new();
    opts.optopt("o", "outfile", "engiffen to this filename", "FILE");
    opts.optopt("f", "framerate", "frames per second", "30");
    opts.optopt("s", "sample-rate", "reduces how many pixels are analyzed when generating palette, higher means faster", "2");
    opts.optopt("q", "quantizer", "pick quantizer algorithm (default: neuquant)", "naive");
    opts.optflag("r", "range", "arguments specify start and end images");
    opts.optmulti("n", "reorder", "reorder frames before processing", "reverse");
    opts.optflag("h", "help", "display this help");

    let matches = opts.parse(&args[1..])?;
    if matches.opt_present("h") {
        let brief = format!("Usage: {} <files ...>", program);
        return Err(ArgsError::DisplayHelp(opts.usage(&brief)));
    }

    let sample_rate = if let Some(sample_rate_str) = matches.opt_str("s") {
        u32::from_str(&sample_rate_str)?
    } else {
        1
    };

    let quantizer = match matches.opt_str("q").map(|s| s.to_lowercase()) {
        Some(ref s) if s == "naive" => Quantizer::Naive,
        Some(_) => {
            Quantizer::NeuQuant(sample_rate)
        },
        None => Quantizer::NeuQuant(sample_rate),
    };

    let fps: usize = if let Some(fps_str) = matches.opt_str("f") {
        usize::from_str(&fps_str)?
    } else {
        30
    };

    let mut modifiers = vec![];
    for opt_str in matches.opt_strs("n") {
        match opt_str.as_str() {
            "reverse" | "rev" => modifiers.push(Modifier::Reverse),
            "shuffle" => modifiers.push(Modifier::Shuffle),
            m @ _ => printerr!("Ignoring unknown modifier `{}`", m),
        }
    }

    let out_file = matches.opt_str("o").map(|f| f.clone());
    let source = if matches.opt_present("r") {
        if matches.free.len() >= 2 {
            let (path_start, filename_start) = path_and_filename(&matches.free[0])?;
            let (path_end, filename_end) = path_and_filename(&matches.free[1])?;
            if path_start != path_end {
                return Err(ArgsError::ImageRange("start and end files are from different directories".to_string()));
            }
            StartEnd(path_start, filename_start, filename_end)
        } else if matches.free.len() == 1 {
            return Err(ArgsError::ImageRange("missing end filename".to_string()));
        } else {
            return Err(ArgsError::ImageRange("missing start and end filenames".to_string()));
        }
    } else {
        if matches.free.len() == 1 {
            #[cfg(feature = "globbing")]
            {
                glob::Pattern::new(&matches.free[0])?;
                Glob(matches.free[0].clone())
            }
            #[cfg(not(feature = "globbing"))] List(matches.free)
        } else {
            List(matches.free)
        }
    };

    Ok(Args {
        source: source,
        fps: fps,
        out_file: out_file,
        quantizer: quantizer,
        modifiers: modifiers,
    })
}

fn path_and_filename(input: &str) -> Result<(PathBuf, PathBuf), ArgsError> {
    let p = Path::new(&input);
    let parent = match p.parent() {
        Some(s) => {
            if s == Path::new("") {
                Path::new(".")
            } else {
                s
            }
        },
        None => Path::new(".")
    };
    if let Some(filename) = p.file_name() {
        Ok((parent.to_owned(), PathBuf::from(filename)))
    } else {
        Err(ArgsError::ImageRange(format!("Invalid filename {:?}", input)))
    }
}

#[cfg(test)]
#[allow(unused_must_use)]
mod tests {
    use super::{parse_args, SourceImages, ArgsError, Args, Quantizer};
    use std::path::PathBuf;
    use std::str::FromStr;

    fn make_args(args: &str) -> Vec<String> {
        args.split(" ").map(|s| s.to_owned()).collect()
    }

    fn assert_err_eq(actual: Result<Args, ArgsError>, expected: ArgsError) {
        assert!(actual.is_err());
        assert_eq!(actual.err().unwrap(), expected);
    }

    #[test]
    fn test_outfile() {
        let args = parse_args(&make_args("engiffen -o bees.gif"));
        assert!(args.is_ok());
        assert_eq!(args.unwrap().out_file, Some("bees.gif".to_owned()));
    }

    #[test]
    fn test_fps() {
        let args = parse_args(&make_args("engiffen -f 45"));
        assert!(args.is_ok());
        assert_eq!(args.unwrap().fps, 45);
    }

    #[test]
    fn test_fps_missing() {
        use std::str::FromStr;

        let args = parse_args(&make_args("engiffen -f barry"));
        let parse_error = usize::from_str("barry").err().unwrap();
        assert_err_eq(args, ArgsError::ParseInt(parse_error));
    }

    #[test]
    fn test_sample_rate() {
        let args = parse_args(&make_args("engiffen -s 2"));
        assert!(args.is_ok());
        assert_eq!(args.unwrap().quantizer, Quantizer::NeuQuant(2));
    }

    #[test]
    fn test_sample_rate_missing() {
        let args = parse_args(&make_args("engiffen -s barry"));
        let parse_error = u32::from_str("barry").err().unwrap();
        assert_err_eq(args, ArgsError::ParseInt(parse_error));
    }

    #[test]
    fn test_file_list() {
        let args = parse_args(&make_args("engiffen this.jpg that.jpg other.jpg"));
        assert!(args.is_ok());
        assert_eq!(
            args.unwrap().source,
            SourceImages::List(vec![
                "this.jpg".to_owned(),
                "that.jpg".to_owned(),
                "other.jpg".to_owned()
            ])
        );
    }

    #[test]
    fn test_file_range() {
        let args = parse_args(&make_args("engiffen -r thing001.jpg thing010.jpg"));
        assert!(args.is_ok());
        assert_eq!(
            args.unwrap().source,
            SourceImages::StartEnd(
                PathBuf::from("."),
                PathBuf::from("thing001.jpg"),
                PathBuf::from("thing010.jpg")
            )
        );
    }

    #[test]
    fn test_file_range_remote_directory() {
        let args = parse_args(&make_args("engiffen -r ../dir/thing001.jpg ../dir/thing010.jpg"));
        assert!(args.is_ok());
        assert_eq!(
            args.unwrap().source,
            SourceImages::StartEnd(
                PathBuf::from("../dir"),
                PathBuf::from("thing001.jpg"),
                PathBuf::from("thing010.jpg")
            )
        );
    }

    #[test]
    fn test_file_range_different_directories() {
        let args = parse_args(&make_args("engiffen -r ./thing001.jpg ../thing010.jpg"));
        assert_err_eq(args, ArgsError::ImageRange("start and end files are from different directories".to_string()));
    }

    #[test]
    fn test_file_range_incomplete() {
        let args = parse_args(&make_args("engiffen -r ./thing001.jpg"));
        assert_err_eq(args, ArgsError::ImageRange("missing end filename".to_string()));
    }

    #[test]
    fn test_file_range_missing() {
        let args = parse_args(&make_args("engiffen -r"));
        assert_err_eq(args, ArgsError::ImageRange("missing start and end filenames".to_string()));
    }

    #[test]
    fn test_file_glob() {
        let args = parse_args(&make_args("engiffen *.bmp"));
        assert_eq!(
            args.unwrap().source,
            SourceImages::Glob("*.bmp".to_string())
        );
    }

    #[test]
    fn test_help() {
        let args = parse_args(&make_args("engiffen -h"));
        // Such a long DisplayHelp message that will probably change as more
        // options get added. Just check the error's type instead.
        match args {
            Err(ArgsError::DisplayHelp(_)) => assert!(true),
            Err(_) => panic!("Wrong error type returned"),
            Ok(_) => panic!("Should not have returned an Ok args result"),
        }
    }
}