diff --git a/src/common.rs b/src/common.rs index b768c9c..d88d44a 100644 --- a/src/common.rs +++ b/src/common.rs @@ -2,7 +2,7 @@ pub(crate) use std::{ borrow::Cow, char, - cmp::Reverse, + cmp::{Ordering, Reverse}, collections::{BTreeMap, BTreeSet, HashMap, HashSet}, convert::{Infallible, TryInto}, env, @@ -59,14 +59,15 @@ pub(crate) use crate::{ // structs and enums pub(crate) use crate::{ arguments::Arguments, bytes::Bytes, env::Env, error::Error, file_error::FileError, - file_info::FileInfo, file_path::FilePath, file_status::FileStatus, files::Files, hasher::Hasher, - host_port::HostPort, host_port_parse_error::HostPortParseError, info::Info, infohash::Infohash, - input::Input, input_target::InputTarget, lint::Lint, linter::Linter, magnet_link::MagnetLink, - md5_digest::Md5Digest, metainfo::Metainfo, metainfo_error::MetainfoError, mode::Mode, - options::Options, output_stream::OutputStream, output_target::OutputTarget, - piece_length_picker::PieceLengthPicker, piece_list::PieceList, platform::Platform, - sha1_digest::Sha1Digest, status::Status, style::Style, subcommand::Subcommand, table::Table, - torrent_summary::TorrentSummary, use_color::UseColor, verifier::Verifier, walker::Walker, + file_info::FileInfo, file_order::FileOrder, file_path::FilePath, file_status::FileStatus, + files::Files, hasher::Hasher, host_port::HostPort, host_port_parse_error::HostPortParseError, + info::Info, infohash::Infohash, input::Input, input_target::InputTarget, lint::Lint, + linter::Linter, magnet_link::MagnetLink, md5_digest::Md5Digest, metainfo::Metainfo, + metainfo_error::MetainfoError, mode::Mode, options::Options, output_stream::OutputStream, + output_target::OutputTarget, piece_length_picker::PieceLengthPicker, piece_list::PieceList, + platform::Platform, sha1_digest::Sha1Digest, status::Status, style::Style, + subcommand::Subcommand, table::Table, torrent_summary::TorrentSummary, use_color::UseColor, + verifier::Verifier, walker::Walker, }; // type aliases diff --git a/src/error.rs b/src/error.rs index ec1b2be..5969c7b 100644 --- a/src/error.rs +++ b/src/error.rs @@ -24,6 +24,8 @@ pub(crate) enum Error { FilenameDecode { filename: PathBuf }, #[snafu(display("Path had no file name: {}", path.display()))] FilenameExtract { path: PathBuf }, + #[snafu(display("Unknown file ordering: {}", text))] + FileOrderUnknown { text: String }, #[snafu(display("I/O error at `{}`: {}", path.display(), source))] Filesystem { source: io::Error, path: PathBuf }, #[snafu(display("Invalid glob: {}", source))] diff --git a/src/file_order.rs b/src/file_order.rs new file mode 100644 index 0000000..3b8c683 --- /dev/null +++ b/src/file_order.rs @@ -0,0 +1,110 @@ +use crate::common::*; + +#[derive(Eq, PartialEq, Debug, Copy, Clone, Ord, PartialOrd)] +pub(crate) enum FileOrder { + AlphabeticalDesc, + AlphabeticalAsc, + SizeDesc, + SizeAsc, +} + +impl FileOrder { + pub(crate) const ALPHABETICAL_ASC: &'static str = "alphabetical-asc"; + pub(crate) const ALPHABETICAL_DESC: &'static str = "alphabetical-desc"; + pub(crate) const SIZE_ASC: &'static str = "size-asc"; + pub(crate) const SIZE_DESC: &'static str = "size-desc"; + pub(crate) const VALUES: &'static [&'static str] = &[ + Self::ALPHABETICAL_DESC, + Self::ALPHABETICAL_ASC, + Self::SIZE_DESC, + Self::SIZE_ASC, + ]; + + pub(crate) fn name(self) -> &'static str { + match self { + Self::AlphabeticalDesc => Self::ALPHABETICAL_DESC, + Self::AlphabeticalAsc => Self::ALPHABETICAL_ASC, + Self::SizeDesc => Self::SIZE_DESC, + Self::SizeAsc => Self::SIZE_ASC, + } + } + + pub(crate) fn compare_file_info(self, a: &FileInfo, b: &FileInfo) -> Ordering { + match self { + Self::AlphabeticalAsc => a.path.cmp(&b.path), + Self::AlphabeticalDesc => a.path.cmp(&b.path).reverse(), + Self::SizeAsc => a.length.cmp(&b.length).then_with(|| a.path.cmp(&b.path)), + Self::SizeDesc => a + .length + .cmp(&b.length) + .reverse() + .then_with(|| a.path.cmp(&b.path)), + } + } +} + +impl FromStr for FileOrder { + type Err = Error; + + fn from_str(text: &str) -> Result { + match text.replace('_', "-").to_lowercase().as_str() { + Self::ALPHABETICAL_DESC => Ok(Self::AlphabeticalDesc), + Self::ALPHABETICAL_ASC => Ok(Self::AlphabeticalAsc), + Self::SIZE_DESC => Ok(Self::SizeDesc), + Self::SIZE_ASC => Ok(Self::SizeAsc), + _ => Err(Error::FileOrderUnknown { + text: text.to_string(), + }), + } + } +} + +impl Display for FileOrder { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + write!(f, "{}", self.name()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn from_str_ok() { + assert_eq!( + FileOrder::AlphabeticalDesc, + "alphabetical_desc".parse().unwrap() + ); + + assert_eq!( + FileOrder::AlphabeticalDesc, + "alphabetical-desc".parse().unwrap() + ); + + assert_eq!( + FileOrder::AlphabeticalDesc, + "ALPHABETICAL-desc".parse().unwrap() + ); + } + + #[test] + fn convert() { + fn case(text: &str, value: FileOrder) { + assert_eq!(value, text.parse().unwrap()); + assert_eq!(value.name(), text); + } + + case("alphabetical-desc", FileOrder::AlphabeticalDesc); + case("alphabetical-asc", FileOrder::AlphabeticalAsc); + case("size-desc", FileOrder::SizeDesc); + case("size-asc", FileOrder::SizeAsc); + } + + #[test] + fn from_str_err() { + assert_matches!( + "foo".parse::(), + Err(Error::FileOrderUnknown { text }) if text == "foo" + ); + } +} diff --git a/src/main.rs b/src/main.rs index 13857f4..448c43f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -56,6 +56,7 @@ mod env; mod error; mod file_error; mod file_info; +mod file_order; mod file_path; mod file_status; mod files; diff --git a/src/metainfo.rs b/src/metainfo.rs index cb5be9c..392543f 100644 --- a/src/metainfo.rs +++ b/src/metainfo.rs @@ -79,6 +79,18 @@ impl Metainfo { Self::deserialize(&InputTarget::File("".into()), bytes).unwrap() } + #[cfg(test)] + pub(crate) fn file_paths(&self) -> Vec { + let files = match &self.info.mode { + Mode::Single { .. } => panic!(), + Mode::Multiple { files } => files, + }; + + let paths: Vec = files.iter().map(|f| f.path.to_string()).collect(); + + paths + } + pub(crate) fn verify(&self, base: &Path, progress_bar: Option) -> Result { Verifier::verify(self, base, progress_bar) } diff --git a/src/subcommand/torrent/create.rs b/src/subcommand/torrent/create.rs index 3e2512a..77a5eba 100644 --- a/src/subcommand/torrent/create.rs +++ b/src/subcommand/torrent/create.rs @@ -153,6 +153,15 @@ pub(crate) struct Create { Linux; `open` on macOS; and `cmd /C start` on Windows" )] open: bool, + #[structopt( + long = "order", + value_name = "ORDER", + possible_values = FileOrder::VALUES, + set(ArgSettings::CaseInsensitive), + help = "Specify the file order within the torrent. \ + Defaults to ascending alphabetical order." + )] + order: Option, #[structopt( long = "output", short = "o", @@ -246,6 +255,7 @@ impl Create { .include_junk(self.include_junk) .include_hidden(self.include_hidden) .follow_symlinks(self.follow_symlinks) + .file_order(self.order.unwrap_or(FileOrder::AlphabeticalAsc)) .globs(&self.globs)? .spinner(spinner) .files()?; @@ -2385,4 +2395,147 @@ Content Size 9 bytes let err = fs::read(torrent).unwrap_err(); assert_eq!(err.kind(), io::ErrorKind::NotFound); } + + #[test] + fn file_ordering_by_default() { + let mut env = test_env! { + args: [ + "torrent", + "create", + "--input", + "foo", + ], + tree: { + foo: { + a: "aa", + b: "b", + c: "ccc", + d: { + e: "eeee", + }, + }, + } + }; + + assert_matches!(env.run(), Ok(())); + + let torrent = env.load_metainfo("foo.torrent"); + assert_eq!(torrent.file_paths(), &["a", "b", "c", "d/e"]); + } + + #[test] + fn file_ordering_by_alpha_asc() { + let mut env = test_env! { + args: [ + "torrent", + "create", + "--input", + "foo", + "--order", + "alphabetical-asc", + ], + tree: { + foo: { + a: "aa", + b: "b", + c: "ccc", + d: { + e: "eeee", + }, + }, + } + }; + + assert_matches!(env.run(), Ok(())); + + let torrent = env.load_metainfo("foo.torrent"); + assert_eq!(torrent.file_paths(), &["a", "b", "c", "d/e"]); + } + + #[test] + fn file_ordering_by_alpha_desc() { + let mut env = test_env! { + args: [ + "torrent", + "create", + "--input", + "foo", + "--order", + "alphabetical-desc", + ], + tree: { + foo: { + a: "aa", + b: "b", + c: "ccc", + d: { + a: "aaaa", + }, + }, + } + }; + + assert_matches!(env.run(), Ok(())); + + let torrent = env.load_metainfo("foo.torrent"); + assert_eq!(torrent.file_paths(), &["d/a", "c", "b", "a"]); + } + + #[test] + fn file_ordering_by_size_asc() { + let mut env = test_env! { + args: [ + "torrent", + "create", + "--input", + "foo", + "--order", + "size-asc", + ], + tree: { + foo: { + a: "aa", + b: "b", + c: "ccc", + d: { + e: "e", + }, + }, + } + }; + + assert_matches!(env.run(), Ok(())); + + let torrent = env.load_metainfo("foo.torrent"); + assert_eq!(torrent.file_paths(), &["b", "d/e", "a", "c"]); + } + + #[test] + fn file_ordering_by_size_desc() { + let mut env = test_env! { + args: [ + "torrent", + "create", + "--input", + "foo", + "--order", + "size-desc", + ], + tree: { + foo: { + a: "aa", + b: "b", + c: "ccc", + d: { + e: "e", + }, + }, + } + }; + + assert_matches!(env.run(), Ok(())); + + let torrent = env.load_metainfo("foo.torrent"); + assert_eq!(torrent.file_paths(), &["c", "a", "b", "d/e"]); + } } diff --git a/src/walker.rs b/src/walker.rs index 61c73c4..43220da 100644 --- a/src/walker.rs +++ b/src/walker.rs @@ -12,6 +12,7 @@ pub(crate) struct Walker { follow_symlinks: bool, include_hidden: bool, include_junk: bool, + file_order: FileOrder, patterns: Vec, root: PathBuf, spinner: Option, @@ -23,6 +24,7 @@ impl Walker { follow_symlinks: false, include_hidden: false, include_junk: false, + file_order: FileOrder::AlphabeticalAsc, patterns: Vec::new(), root: root.to_owned(), spinner: None, @@ -43,6 +45,10 @@ impl Walker { } } + pub(crate) fn file_order(self, file_order: FileOrder) -> Self { + Self { file_order, ..self } + } + pub(crate) fn globs(mut self, globs: &[String]) -> Result { for glob in globs { let exclude = glob.starts_with('!'); @@ -112,11 +118,10 @@ impl Walker { true }; - let mut paths = Vec::new(); + let mut file_infos = Vec::new(); let mut total_size = 0; for result in WalkDir::new(&self.root) .follow_links(self.follow_symlinks) - .sort_by(|a, b| a.file_name().cmp(b.file_name())) .into_iter() .filter_entry(filter) { @@ -154,12 +159,26 @@ impl Walker { continue; } - total_size += metadata.len(); + let len = metadata.len(); + total_size += len; - paths.push(file_path); + file_infos.push(FileInfo { + path: file_path, + length: Bytes(len), + md5sum: None, + }); } - Ok(Files::dir(self.root, Bytes::from(total_size), paths)) + file_infos.sort_by(|a, b| self.file_order.compare_file_info(a, b)); + + Ok(Files::dir( + self.root, + Bytes::from(total_size), + file_infos + .into_iter() + .map(|file_info| file_info.path) + .collect(), + )) } fn pattern_filter(&self, relative: &Path) -> bool {