diff --git a/bin/generate-completions b/bin/generate-completions index a2b8f69..771af2b 100755 --- a/bin/generate-completions +++ b/bin/generate-completions @@ -2,7 +2,9 @@ set -euxo pipefail +cargo build + for script in completions/*; do shell=${script##*.} - cargo run -- completions --shell $shell > $script + ./target/debug/imdl completions --shell $shell > $script done diff --git a/completions/imdl.elvish b/completions/imdl.elvish index d6885b7..f04f3c8 100644 --- a/completions/imdl.elvish +++ b/completions/imdl.elvish @@ -70,10 +70,10 @@ Examples: --node [2001:db8:4275:7920:6269:7463:6f69:6e21]:8832' cand -g 'Include or exclude files that match `GLOB`. Multiple glob may be provided, with the last one taking precedence. Precede a glob with `!` to exclude it.' cand --glob 'Include or exclude files that match `GLOB`. Multiple glob may be provided, with the last one taking precedence. Precede a glob with `!` to exclude it.' - cand -i 'Read torrent contents from `PATH`. If `PATH` is a file, torrent will be a single-file torrent, if `PATH` is a directory, torrent will be a multi-file torrent.' - cand --input 'Read torrent contents from `PATH`. If `PATH` is a file, torrent will be a single-file torrent, if `PATH` is a directory, torrent will be a multi-file torrent.' - cand -N 'Set name of torrent to `TEXT`. Defaults to the filename of the argument to `--input`.' - cand --name 'Set name of torrent to `TEXT`. Defaults to the filename of the argument to `--input`.' + cand -i 'Read torrent contents from `PATH`. If `PATH` is a file, torrent will be a single-file torrent. If `PATH` is a directory, torrent will be a multi-file torrent. If `PATH` is `-`, read from standard input. Piece length defaults to 256KiB when reading from standard input if `--piece-length` is not given.' + cand --input 'Read torrent contents from `PATH`. If `PATH` is a file, torrent will be a single-file torrent. If `PATH` is a directory, torrent will be a multi-file torrent. If `PATH` is `-`, read from standard input. Piece length defaults to 256KiB when reading from standard input if `--piece-length` is not given.' + cand -N 'Set name of torrent to `TEXT`. Defaults to the filename of the argument to `--input`. Required when `--input -`.' + cand --name 'Set name of torrent to `TEXT`. Defaults to the filename of the argument to `--input`. Required when `--input -`.' cand --sort-by 'Set the order of files within a torrent. `SPEC` should be of the form `KEY:ORDER`, with `KEY` being one of `path` or `size`, and `ORDER` being `ascending` or `descending`. `:ORDER` defaults to `ascending` if omitted. The `--sort-by` flag may be given more than once, with later values being used to break ties. Ties that remain are broken in ascending path order. Sort in ascending order by path, the default: @@ -87,8 +87,8 @@ Sort in ascending order by path, more concisely: Sort in ascending order by size, break ties in descending path order: --sort-by size:ascending --sort-by path:descending' - cand -o 'Save `.torrent` file to `TARGET`, or print to standard output if `TARGET` is `-`. Defaults to `$INPUT.torrent`.' - cand --output 'Save `.torrent` file to `TARGET`, or print to standard output if `TARGET` is `-`. Defaults to `$INPUT.torrent`.' + cand -o 'Save `.torrent` file to `TARGET`, or print to standard output if `TARGET` is `-`. Defaults to the argument to `--input` with an `.torrent` extension appended. Required when `--input -`.' + cand --output 'Save `.torrent` file to `TARGET`, or print to standard output if `TARGET` is `-`. Defaults to the argument to `--input` with an `.torrent` extension appended. Required when `--input -`.' cand --peer 'Add `PEER` to magnet link.' cand -p 'Set piece length to `BYTES`. Accepts SI units, e.g. kib, mib, and gib.' cand --piece-length 'Set piece length to `BYTES`. Accepts SI units, e.g. kib, mib, and gib.' diff --git a/completions/imdl.fish b/completions/imdl.fish index 9b0e2f0..c95ae02 100644 --- a/completions/imdl.fish +++ b/completions/imdl.fish @@ -34,8 +34,8 @@ Examples: --node [2001:db8:4275:7920:6269:7463:6f69:6e21]:8832' complete -c imdl -n "__fish_seen_subcommand_from create" -s g -l glob -d 'Include or exclude files that match `GLOB`. Multiple glob may be provided, with the last one taking precedence. Precede a glob with `!` to exclude it.' -complete -c imdl -n "__fish_seen_subcommand_from create" -s i -l input -d 'Read torrent contents from `PATH`. If `PATH` is a file, torrent will be a single-file torrent, if `PATH` is a directory, torrent will be a multi-file torrent.' -complete -c imdl -n "__fish_seen_subcommand_from create" -s N -l name -d 'Set name of torrent to `TEXT`. Defaults to the filename of the argument to `--input`.' +complete -c imdl -n "__fish_seen_subcommand_from create" -s i -l input -d 'Read torrent contents from `PATH`. If `PATH` is a file, torrent will be a single-file torrent. If `PATH` is a directory, torrent will be a multi-file torrent. If `PATH` is `-`, read from standard input. Piece length defaults to 256KiB when reading from standard input if `--piece-length` is not given.' +complete -c imdl -n "__fish_seen_subcommand_from create" -s N -l name -d 'Set name of torrent to `TEXT`. Defaults to the filename of the argument to `--input`. Required when `--input -`.' complete -c imdl -n "__fish_seen_subcommand_from create" -l sort-by -d 'Set the order of files within a torrent. `SPEC` should be of the form `KEY:ORDER`, with `KEY` being one of `path` or `size`, and `ORDER` being `ascending` or `descending`. `:ORDER` defaults to `ascending` if omitted. The `--sort-by` flag may be given more than once, with later values being used to break ties. Ties that remain are broken in ascending path order. Sort in ascending order by path, the default: @@ -49,7 +49,7 @@ Sort in ascending order by path, more concisely: Sort in ascending order by size, break ties in descending path order: --sort-by size:ascending --sort-by path:descending' -complete -c imdl -n "__fish_seen_subcommand_from create" -s o -l output -d 'Save `.torrent` file to `TARGET`, or print to standard output if `TARGET` is `-`. Defaults to `$INPUT.torrent`.' +complete -c imdl -n "__fish_seen_subcommand_from create" -s o -l output -d 'Save `.torrent` file to `TARGET`, or print to standard output if `TARGET` is `-`. Defaults to the argument to `--input` with an `.torrent` extension appended. Required when `--input -`.' complete -c imdl -n "__fish_seen_subcommand_from create" -l peer -d 'Add `PEER` to magnet link.' complete -c imdl -n "__fish_seen_subcommand_from create" -s p -l piece-length -d 'Set piece length to `BYTES`. Accepts SI units, e.g. kib, mib, and gib.' complete -c imdl -n "__fish_seen_subcommand_from create" -s s -l source -d 'Set torrent source to `TEXT`. Stored under `source` key of info dictionary. This is useful for keeping statistics from being mis-reported when participating in swarms with the same contents, but with different trackers. When source is set to a unique value for torrents with the same contents, torrent clients will treat them as distinct torrents, and not share peers between them, and will correctly report download and upload statistics to multiple trackers.' diff --git a/completions/imdl.powershell b/completions/imdl.powershell index 47b1e0b..bc28259 100644 --- a/completions/imdl.powershell +++ b/completions/imdl.powershell @@ -77,10 +77,10 @@ Examples: --node [2001:db8:4275:7920:6269:7463:6f69:6e21]:8832') [CompletionResult]::new('-g', 'g', [CompletionResultType]::ParameterName, 'Include or exclude files that match `GLOB`. Multiple glob may be provided, with the last one taking precedence. Precede a glob with `!` to exclude it.') [CompletionResult]::new('--glob', 'glob', [CompletionResultType]::ParameterName, 'Include or exclude files that match `GLOB`. Multiple glob may be provided, with the last one taking precedence. Precede a glob with `!` to exclude it.') - [CompletionResult]::new('-i', 'i', [CompletionResultType]::ParameterName, 'Read torrent contents from `PATH`. If `PATH` is a file, torrent will be a single-file torrent, if `PATH` is a directory, torrent will be a multi-file torrent.') - [CompletionResult]::new('--input', 'input', [CompletionResultType]::ParameterName, 'Read torrent contents from `PATH`. If `PATH` is a file, torrent will be a single-file torrent, if `PATH` is a directory, torrent will be a multi-file torrent.') - [CompletionResult]::new('-N', 'N', [CompletionResultType]::ParameterName, 'Set name of torrent to `TEXT`. Defaults to the filename of the argument to `--input`.') - [CompletionResult]::new('--name', 'name', [CompletionResultType]::ParameterName, 'Set name of torrent to `TEXT`. Defaults to the filename of the argument to `--input`.') + [CompletionResult]::new('-i', 'i', [CompletionResultType]::ParameterName, 'Read torrent contents from `PATH`. If `PATH` is a file, torrent will be a single-file torrent. If `PATH` is a directory, torrent will be a multi-file torrent. If `PATH` is `-`, read from standard input. Piece length defaults to 256KiB when reading from standard input if `--piece-length` is not given.') + [CompletionResult]::new('--input', 'input', [CompletionResultType]::ParameterName, 'Read torrent contents from `PATH`. If `PATH` is a file, torrent will be a single-file torrent. If `PATH` is a directory, torrent will be a multi-file torrent. If `PATH` is `-`, read from standard input. Piece length defaults to 256KiB when reading from standard input if `--piece-length` is not given.') + [CompletionResult]::new('-N', 'N', [CompletionResultType]::ParameterName, 'Set name of torrent to `TEXT`. Defaults to the filename of the argument to `--input`. Required when `--input -`.') + [CompletionResult]::new('--name', 'name', [CompletionResultType]::ParameterName, 'Set name of torrent to `TEXT`. Defaults to the filename of the argument to `--input`. Required when `--input -`.') [CompletionResult]::new('--sort-by', 'sort-by', [CompletionResultType]::ParameterName, 'Set the order of files within a torrent. `SPEC` should be of the form `KEY:ORDER`, with `KEY` being one of `path` or `size`, and `ORDER` being `ascending` or `descending`. `:ORDER` defaults to `ascending` if omitted. The `--sort-by` flag may be given more than once, with later values being used to break ties. Ties that remain are broken in ascending path order. Sort in ascending order by path, the default: @@ -94,8 +94,8 @@ Sort in ascending order by path, more concisely: Sort in ascending order by size, break ties in descending path order: --sort-by size:ascending --sort-by path:descending') - [CompletionResult]::new('-o', 'o', [CompletionResultType]::ParameterName, 'Save `.torrent` file to `TARGET`, or print to standard output if `TARGET` is `-`. Defaults to `$INPUT.torrent`.') - [CompletionResult]::new('--output', 'output', [CompletionResultType]::ParameterName, 'Save `.torrent` file to `TARGET`, or print to standard output if `TARGET` is `-`. Defaults to `$INPUT.torrent`.') + [CompletionResult]::new('-o', 'o', [CompletionResultType]::ParameterName, 'Save `.torrent` file to `TARGET`, or print to standard output if `TARGET` is `-`. Defaults to the argument to `--input` with an `.torrent` extension appended. Required when `--input -`.') + [CompletionResult]::new('--output', 'output', [CompletionResultType]::ParameterName, 'Save `.torrent` file to `TARGET`, or print to standard output if `TARGET` is `-`. Defaults to the argument to `--input` with an `.torrent` extension appended. Required when `--input -`.') [CompletionResult]::new('--peer', 'peer', [CompletionResultType]::ParameterName, 'Add `PEER` to magnet link.') [CompletionResult]::new('-p', 'p', [CompletionResultType]::ParameterName, 'Set piece length to `BYTES`. Accepts SI units, e.g. kib, mib, and gib.') [CompletionResult]::new('--piece-length', 'piece-length', [CompletionResultType]::ParameterName, 'Set piece length to `BYTES`. Accepts SI units, e.g. kib, mib, and gib.') diff --git a/completions/imdl.zsh b/completions/imdl.zsh index 26ba890..e034b2d 100644 --- a/completions/imdl.zsh +++ b/completions/imdl.zsh @@ -79,10 +79,10 @@ Examples: --node \[2001:db8:4275:7920:6269:7463:6f69:6e21\]:8832]' \ '*-g+[Include or exclude files that match `GLOB`. Multiple glob may be provided, with the last one taking precedence. Precede a glob with `!` to exclude it.]' \ '*--glob=[Include or exclude files that match `GLOB`. Multiple glob may be provided, with the last one taking precedence. Precede a glob with `!` to exclude it.]' \ -'-i+[Read torrent contents from `PATH`. If `PATH` is a file, torrent will be a single-file torrent, if `PATH` is a directory, torrent will be a multi-file torrent.]' \ -'--input=[Read torrent contents from `PATH`. If `PATH` is a file, torrent will be a single-file torrent, if `PATH` is a directory, torrent will be a multi-file torrent.]' \ -'-N+[Set name of torrent to `TEXT`. Defaults to the filename of the argument to `--input`.]' \ -'--name=[Set name of torrent to `TEXT`. Defaults to the filename of the argument to `--input`.]' \ +'-i+[Read torrent contents from `PATH`. If `PATH` is a file, torrent will be a single-file torrent. If `PATH` is a directory, torrent will be a multi-file torrent. If `PATH` is `-`, read from standard input. Piece length defaults to 256KiB when reading from standard input if `--piece-length` is not given.]' \ +'--input=[Read torrent contents from `PATH`. If `PATH` is a file, torrent will be a single-file torrent. If `PATH` is a directory, torrent will be a multi-file torrent. If `PATH` is `-`, read from standard input. Piece length defaults to 256KiB when reading from standard input if `--piece-length` is not given.]' \ +'-N+[Set name of torrent to `TEXT`. Defaults to the filename of the argument to `--input`. Required when `--input -`.]' \ +'--name=[Set name of torrent to `TEXT`. Defaults to the filename of the argument to `--input`. Required when `--input -`.]' \ '*--sort-by=[Set the order of files within a torrent. `SPEC` should be of the form `KEY:ORDER`, with `KEY` being one of `path` or `size`, and `ORDER` being `ascending` or `descending`. `:ORDER` defaults to `ascending` if omitted. The `--sort-by` flag may be given more than once, with later values being used to break ties. Ties that remain are broken in ascending path order. Sort in ascending order by path, the default: @@ -96,8 +96,8 @@ Sort in ascending order by path, more concisely: Sort in ascending order by size, break ties in descending path order: --sort-by size:ascending --sort-by path:descending]' \ -'-o+[Save `.torrent` file to `TARGET`, or print to standard output if `TARGET` is `-`. Defaults to `$INPUT.torrent`.]' \ -'--output=[Save `.torrent` file to `TARGET`, or print to standard output if `TARGET` is `-`. Defaults to `$INPUT.torrent`.]' \ +'-o+[Save `.torrent` file to `TARGET`, or print to standard output if `TARGET` is `-`. Defaults to the argument to `--input` with an `.torrent` extension appended. Required when `--input -`.]' \ +'--output=[Save `.torrent` file to `TARGET`, or print to standard output if `TARGET` is `-`. Defaults to the argument to `--input` with an `.torrent` extension appended. Required when `--input -`.]' \ '*--peer=[Add `PEER` to magnet link.]' \ '-p+[Set piece length to `BYTES`. Accepts SI units, e.g. kib, mib, and gib.]' \ '--piece-length=[Set piece length to `BYTES`. Accepts SI units, e.g. kib, mib, and gib.]' \ diff --git a/src/common.rs b/src/common.rs index ee584d9..2ff4900 100644 --- a/src/common.rs +++ b/src/common.rs @@ -10,7 +10,7 @@ pub(crate) use std::{ fmt::{self, Display, Formatter}, fs::{self, File}, hash::Hash, - io::{self, Cursor, Read, Write}, + io::{self, BufRead, BufReader, Cursor, Read, Write}, iter::{self, Sum}, num::{ParseFloatError, ParseIntError, TryFromIntError}, ops::{AddAssign, Div, DivAssign, Mul, MulAssign, Sub, SubAssign}, @@ -35,7 +35,10 @@ pub(crate) use serde_with::rust::unwrap_or_skip; pub(crate) use sha1::Sha1; pub(crate) use snafu::{ResultExt, Snafu}; pub(crate) use static_assertions::const_assert; -pub(crate) use structopt::{clap::AppSettings, StructOpt}; +pub(crate) use structopt::{ + clap::{self, AppSettings}, + StructOpt, +}; pub(crate) use strum::VariantNames; pub(crate) use strum_macros::{EnumString, EnumVariantNames, IntoStaticStr}; pub(crate) use unicode_width::UnicodeWidthStr; @@ -51,7 +54,7 @@ pub(crate) use crate::{consts, error, host_port_parse_error}; // traits pub(crate) use crate::{ - into_u64::IntoU64, into_usize::IntoUsize, path_ext::PathExt, + input_stream::InputStream, into_u64::IntoU64, into_usize::IntoUsize, path_ext::PathExt, platform_interface::PlatformInterface, print::Print, reckoner::Reckoner, step::Step, }; diff --git a/src/env.rs b/src/env.rs index 4acd1a5..e5d1990 100644 --- a/src/env.rs +++ b/src/env.rs @@ -3,7 +3,7 @@ use crate::common::*; pub(crate) struct Env { args: Vec, dir: PathBuf, - input: Box, + input: Box, err: OutputStream, out: OutputStream, } @@ -79,7 +79,7 @@ impl Env { pub(crate) fn new( dir: PathBuf, args: I, - input: Box, + input: Box, out: OutputStream, err: OutputStream, ) -> Self @@ -147,6 +147,10 @@ impl Env { &self.err } + pub(crate) fn input<'a>(&'a mut self) -> Box { + self.input.as_mut().buf_read() + } + pub(crate) fn err_mut(&mut self) -> &mut OutputStream { &mut self.err } @@ -165,13 +169,17 @@ impl Env { pub(crate) fn read(&mut self, source: InputTarget) -> Result { let data = match &source { - InputTarget::File(path) => { + InputTarget::Path(path) => { let absolute = self.resolve(path); fs::read(absolute).context(error::Filesystem { path })? } InputTarget::Stdin => { let mut buffer = Vec::new(); - self.input.read_to_end(&mut buffer).context(error::Stdin)?; + self + .input + .buf_read() + .read_to_end(&mut buffer) + .context(error::Stdin)?; buffer } }; diff --git a/src/error.rs b/src/error.rs index 5969c7b..d26532a 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,7 +1,5 @@ use crate::common::*; -use structopt::clap; - #[derive(Debug, Snafu)] #[snafu(visibility(pub(crate)))] pub(crate) enum Error { diff --git a/src/hasher.rs b/src/hasher.rs index c90ca2a..3a733f6 100644 --- a/src/hasher.rs +++ b/src/hasher.rs @@ -12,16 +12,7 @@ pub(crate) struct Hasher { } impl Hasher { - pub(crate) fn hash( - files: &Files, - md5sum: bool, - piece_length: usize, - progress_bar: Option, - ) -> Result<(Mode, PieceList), Error> { - Self::new(md5sum, piece_length, progress_bar).hash_files(files) - } - - fn new(md5sum: bool, piece_length: usize, progress_bar: Option) -> Self { + pub(crate) fn new(md5sum: bool, piece_length: usize, progress_bar: Option) -> Self { Self { buffer: vec![0; piece_length], length: 0, @@ -34,7 +25,7 @@ impl Hasher { } } - fn hash_files(mut self, files: &Files) -> Result<(Mode, PieceList), Error> { + pub(crate) fn hash_files(mut self, files: &Files) -> Result<(Mode, PieceList), Error> { let mode = if let Some(contents) = files.contents() { let files = self.hash_contents(&files.root(), contents)?; @@ -42,19 +33,30 @@ impl Hasher { } else { let (md5sum, length) = self.hash_file(files.root())?; - Mode::Single { - md5sum: md5sum.map(|md5sum| md5sum.into()), - length, - } + Mode::Single { md5sum, length } }; + self.finish(); + + Ok((mode, self.pieces)) + } + + pub(crate) fn hash_stdin(mut self, stdin: &mut dyn BufRead) -> Result<(Mode, PieceList), Error> { + let (md5sum, length) = self.hash_read_io(stdin).context(error::Stdin)?; + + let mode = Mode::Single { md5sum, length }; + + self.finish(); + + Ok((mode, self.pieces)) + } + + fn finish(&mut self) { if self.piece_bytes_hashed > 0 { self.pieces.push(self.sha1.digest().into()); self.sha1.reset(); self.piece_bytes_hashed = 0; } - - Ok((mode, self.pieces)) } fn hash_contents( @@ -70,8 +72,8 @@ impl Hasher { let (md5sum, length) = self.hash_file(&path)?; files.push(FileInfo { - md5sum: md5sum.map(|md5sum| md5sum.into()), path: file_path.clone(), + md5sum, length, }); } @@ -79,18 +81,17 @@ impl Hasher { Ok(files) } - fn hash_file(&mut self, file: &Path) -> Result<(Option, Bytes), Error> { + fn hash_file(&mut self, path: &Path) -> Result<(Option, Bytes), Error> { + let file = File::open(path).context(error::Filesystem { path })?; + self - .hash_file_io(file) - .context(error::Filesystem { path: file }) + .hash_read_io(&mut BufReader::new(file)) + .context(error::Filesystem { path }) } - fn hash_file_io(&mut self, file: &Path) -> io::Result<(Option, Bytes)> { - let length = file.metadata()?.len(); - - let mut remaining = length; - - let mut file = File::open(file)?; + fn hash_read_io(&mut self, file: &mut dyn BufRead) -> io::Result<(Option, Bytes)> { + let buffer_len = self.buffer.len(); + let mut bytes_hashed = 0; let mut md5 = if self.md5sum { Some(md5::Context::new()) @@ -98,17 +99,20 @@ impl Hasher { None }; - while remaining > 0 { - let to_buffer: usize = remaining - .min(self.buffer.len().into_u64()) - .try_into() - .unwrap(); + loop { + let buffer = &mut self.buffer[..buffer_len]; - let buffer = &mut self.buffer[0..to_buffer]; + let bytes_read = file.read(buffer)?; - file.read_exact(buffer)?; + if bytes_read == 0 { + break; + } - for byte in buffer.iter().cloned() { + bytes_hashed += bytes_read; + + let read = &buffer[0..bytes_read]; + + for byte in read.iter().cloned() { self.sha1.update(&[byte]); self.piece_bytes_hashed += 1; @@ -121,18 +125,19 @@ impl Hasher { } if let Some(md5) = md5.as_mut() { - md5.consume(&buffer); + md5.consume(read); } - remaining -= buffer.len().into_u64(); - if let Some(progress_bar) = &self.progress_bar { - progress_bar.inc(to_buffer.into_u64()); + progress_bar.inc(bytes_read.into_u64()); } } - self.length += length; + self.length += bytes_hashed.into_u64(); - Ok((md5.map(md5::Context::compute), Bytes::from(length))) + Ok(( + md5.map(|context| context.compute().into()), + Bytes::from(bytes_hashed.into_u64()), + )) } } diff --git a/src/input.rs b/src/input.rs index d03b7ac..b3bbdf3 100644 --- a/src/input.rs +++ b/src/input.rs @@ -22,7 +22,7 @@ impl Input { pub(crate) fn from_path(path: &Path) -> Result { let data = fs::read(path).context(error::Filesystem { path })?; Ok(Input { - source: InputTarget::File(path.to_owned()), + source: InputTarget::Path(path.to_owned()), data, }) } diff --git a/src/input_stream.rs b/src/input_stream.rs new file mode 100644 index 0000000..0995b27 --- /dev/null +++ b/src/input_stream.rs @@ -0,0 +1,23 @@ +use crate::common::*; + +pub(crate) trait InputStream { + fn buf_read<'a>(&'a mut self) -> Box; +} + +impl InputStream for io::Stdin { + fn buf_read<'a>(&'a mut self) -> Box { + Box::new(self.lock()) + } +} + +impl InputStream for io::Empty { + fn buf_read<'a>(&'a mut self) -> Box { + Box::new(BufReader::new(self)) + } +} + +impl InputStream for Cursor> { + fn buf_read<'a>(&'a mut self) -> Box { + Box::new(BufReader::new(self)) + } +} diff --git a/src/input_target.rs b/src/input_target.rs index 75269e8..d4e689e 100644 --- a/src/input_target.rs +++ b/src/input_target.rs @@ -2,16 +2,25 @@ use crate::common::*; #[derive(PartialEq, Debug, Clone)] pub(crate) enum InputTarget { - File(PathBuf), + Path(PathBuf), Stdin, } +impl InputTarget { + pub(crate) fn resolve(&self, env: &Env) -> Self { + match self { + Self::Path(path) => Self::Path(env.resolve(path)), + Self::Stdin => Self::Stdin, + } + } +} + impl From<&OsStr> for InputTarget { fn from(text: &OsStr) -> Self { if text == OsStr::new("-") { Self::Stdin } else { - Self::File(text.into()) + Self::Path(text.into()) } } } @@ -20,16 +29,15 @@ impl Display for InputTarget { fn fmt(&self, f: &mut Formatter) -> fmt::Result { match self { Self::Stdin => write!(f, "standard input"), - Self::File(path) => write!(f, "`{}`", path.display()), + Self::Path(path) => write!(f, "`{}`", path.display()), } } } -#[cfg(test)] impl> PartialEq

for InputTarget { fn eq(&self, other: &P) -> bool { match self { - Self::File(path) => path == other.as_ref(), + Self::Path(path) => path == other.as_ref(), Self::Stdin => Path::new("-") == other.as_ref(), } } @@ -43,7 +51,7 @@ mod tests { fn file() { assert_eq!( InputTarget::from(OsStr::new("foo")), - InputTarget::File("foo".into()) + InputTarget::Path("foo".into()) ); } @@ -55,7 +63,7 @@ mod tests { #[test] fn display_file() { let path = PathBuf::from("./path"); - let have = InputTarget::File(path).to_string(); + let have = InputTarget::Path(path).to_string(); let want = "`./path`"; assert_eq!(have, want); } diff --git a/src/main.rs b/src/main.rs index 4210f88..239c99d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -65,6 +65,7 @@ mod host_port_parse_error; mod info; mod infohash; mod input; +mod input_stream; mod input_target; mod into_u64; mod into_usize; diff --git a/src/metainfo.rs b/src/metainfo.rs index 392543f..eb15a14 100644 --- a/src/metainfo.rs +++ b/src/metainfo.rs @@ -76,7 +76,7 @@ impl Metainfo { #[cfg(test)] pub(crate) fn from_bytes(bytes: &[u8]) -> Metainfo { - Self::deserialize(&InputTarget::File("".into()), bytes).unwrap() + Self::deserialize(&InputTarget::Path("".into()), bytes).unwrap() } #[cfg(test)] diff --git a/src/subcommand/torrent/create.rs b/src/subcommand/torrent/create.rs index 5122d80..d53185f 100644 --- a/src/subcommand/torrent/create.rs +++ b/src/subcommand/torrent/create.rs @@ -117,10 +117,12 @@ Examples: short = "i", value_name = "PATH", help = "Read torrent contents from `PATH`. If `PATH` is a file, torrent will be a single-file \ - torrent, if `PATH` is a directory, torrent will be a multi-file torrent.", + torrent. If `PATH` is a directory, torrent will be a multi-file torrent. If `PATH` \ + is `-`, read from standard input. Piece length defaults to 256KiB when reading from \ + standard input if `--piece-length` is not given.", parse(from_os_str) )] - input: PathBuf, + input: InputTarget, #[structopt( long = "link", help = "Print created torrent `magnet:` URL to standard output" @@ -137,7 +139,9 @@ Examples: long = "name", short = "N", value_name = "TEXT", - help = "Set name of torrent to `TEXT`. Defaults to the filename of the argument to `--input`." + help = "Set name of torrent to `TEXT`. Defaults to the filename of the argument to `--input`. \ + Required when `--input -`.", + required_if("input", "-") )] name: Option, #[structopt( @@ -184,7 +188,9 @@ Sort in ascending order by size, break ties in descending path order: short = "o", value_name = "TARGET", help = "Save `.torrent` file to `TARGET`, or print to standard output if `TARGET` is `-`. \ - Defaults to `$INPUT.torrent`.", + Defaults to the argument to `--input` with an `.torrent` extension appended. Required \ + when `--input -`.", + required_if("input", "-"), parse(from_os_str) )] output: Option, @@ -234,7 +240,8 @@ Sort in ascending order by size, break ties in descending path order: impl Create { pub(crate) fn run(self, env: &mut Env) -> Result<(), Error> { - let input = env.resolve(&self.input); + let input = self.input.resolve(env); + let output = self.output.map(|output| output.resolve(env)); let mut linter = Linter::new(); linter.allow(self.allowed_lints.iter().cloned()); @@ -268,18 +275,79 @@ impl Create { None }; - let files = Walker::new(&input) - .include_junk(self.include_junk) - .include_hidden(self.include_hidden) - .follow_symlinks(self.follow_symlinks) - .sort_by(self.sort_by) - .globs(&self.globs)? - .spinner(spinner) - .files()?; + let files; + let piece_length; + let progress_bar; + let name; + let output = match &input { + InputTarget::Path(path) => { + let files_inner = Walker::new(&path) + .include_junk(self.include_junk) + .include_hidden(self.include_hidden) + .follow_symlinks(self.follow_symlinks) + .sort_by(self.sort_by) + .globs(&self.globs)? + .spinner(spinner) + .files()?; - let piece_length = self - .piece_length - .unwrap_or_else(|| PieceLengthPicker::from_content_size(files.total_size())); + piece_length = self + .piece_length + .unwrap_or_else(|| PieceLengthPicker::from_content_size(files_inner.total_size())); + + let style = ProgressStyle::default_bar() + .template( + "{spinner:.green} ⟪{elapsed_precise}⟫ ⟦{bar:40.cyan}⟧ \ + {binary_bytes}/{binary_total_bytes} ⟨{binary_bytes_per_sec}, {eta}⟩", + ) + .tick_chars(consts::TICK_CHARS) + .progress_chars(consts::PROGRESS_CHARS); + + progress_bar = ProgressBar::new(files_inner.total_size().count()).with_style(style); + + let filename = path + .file_name() + .ok_or_else(|| Error::FilenameExtract { path: path.clone() })?; + + name = match &self.name { + Some(name) => name.clone(), + None => filename + .to_str() + .ok_or_else(|| Error::FilenameDecode { + filename: PathBuf::from(filename), + })? + .to_owned(), + }; + + files = Some(files_inner); + + output + .as_ref() + .map(|output| output.resolve(env)) + .unwrap_or_else(|| { + let mut torrent_name = name.to_owned(); + torrent_name.push_str(".torrent"); + + OutputTarget::File(path.parent().unwrap().join(torrent_name)) + }) + } + + InputTarget::Stdin => { + files = None; + piece_length = self.piece_length.unwrap_or(Bytes::kib() * 256); + + let style = ProgressStyle::default_bar() + .template("{spinner:.green} ⟪{elapsed_precise}⟫ {binary_bytes} ⟨{binary_bytes_per_sec}⟩") + .tick_chars(consts::TICK_CHARS); + + progress_bar = ProgressBar::new_spinner().with_style(style); + + name = self + .name + .ok_or_else(|| Error::internal("Expected `--name` to be set when `--input -`."))?; + + output.ok_or_else(|| Error::internal("Expected `--output` to be set when `--input -`."))? + } + }; if piece_length.count() == 0 { return Err(Error::PieceLengthZero); @@ -295,31 +363,6 @@ impl Create { return Err(Error::PieceLengthSmall); } - let filename = input.file_name().ok_or_else(|| Error::FilenameExtract { - path: input.clone(), - })?; - - let name = match &self.name { - Some(name) => name.clone(), - None => filename - .to_str() - .ok_or_else(|| Error::FilenameDecode { - filename: PathBuf::from(filename), - })? - .to_owned(), - }; - - let output = self - .output - .as_ref() - .map(|output| output.resolve(env)) - .unwrap_or_else(|| { - let mut torrent_name = name.to_owned(); - torrent_name.push_str(".torrent"); - - OutputTarget::File(input.parent().unwrap().join(torrent_name)) - }); - if let OutputTarget::File(path) = &output { if !self.force && path.exists() { return Err(Error::OutputExists { @@ -348,26 +391,21 @@ impl Create { CreateStep::Hashing.print(env)?; - let progress_bar = if env.err().is_styled_term() { - let style = ProgressStyle::default_bar() - .template( - "{spinner:.green} ⟪{elapsed_precise}⟫ ⟦{bar:40.cyan}⟧ \ - {binary_bytes}/{binary_total_bytes} ⟨{binary_bytes_per_sec}, {eta}⟩", - ) - .tick_chars(consts::TICK_CHARS) - .progress_chars(consts::PROGRESS_CHARS); - - Some(ProgressBar::new(files.total_size().count()).with_style(style)) - } else { - None - }; - - let (mode, pieces) = Hasher::hash( - &files, + let hasher = Hasher::new( self.md5sum, piece_length.as_piece_length()?.into_usize(), - progress_bar, - )?; + if env.err().is_styled_term() { + Some(progress_bar) + } else { + None + }, + ); + + let (mode, pieces) = if let Some(files) = files { + hasher.hash_files(&files)? + } else { + hasher.hash_stdin(&mut env.input())? + }; CreateStep::Writing { output: &output }.print(env)?; @@ -423,14 +461,18 @@ impl Create { #[cfg(test)] { - let deserialized = bendy::serde::de::from_bytes::(&bytes).unwrap(); + if let InputTarget::Path(path) = &input { + let deserialized = bendy::serde::de::from_bytes::(&bytes).unwrap(); - assert_eq!(deserialized, metainfo); + assert_eq!(deserialized, metainfo); - let status = metainfo.verify(&input, None)?; + let status = metainfo.verify(path, None)?; - if !status.good() { - return Err(Error::Verify); + status.print(env)?; + + if !status.good() { + return Err(Error::Verify); + } } } @@ -1349,7 +1391,7 @@ mod tests { }, }, }; - env.run().unwrap(); + env.assert_ok(); let metainfo = env.load_metainfo("foo.torrent"); assert_eq!(metainfo.info.pieces, PieceList::from_pieces(&["abchijxyz"])); match metainfo.info.mode { @@ -1917,7 +1959,7 @@ Content Size 9 bytes }, } }; - env.run().unwrap(); + env.assert_ok(); let metainfo = env.load_metainfo("foo.torrent"); assert_matches!( metainfo.info.mode, @@ -2586,4 +2628,75 @@ Content Size 9 bytes let torrent = env.load_metainfo("foo.torrent"); assert_eq!(torrent.file_paths(), &["d/e", "b", "a", "c"]); } + + #[test] + fn name_required_when_input_is_stdin() { + let mut env = test_env! { + args: [ + "torrent", + "create", + "--input", + "-", + "--announce", + "http://bar", + "--output", + "foo.torrent", + ], + tree: {}, + }; + assert!(matches!(env.run(), Err(Error::Clap { .. }))); + } + + #[test] + fn foo_required_when_input_is_stdin() { + let mut env = test_env! { + args: [ + "torrent", + "create", + "--input", + "-", + "--announce", + "http://bar", + "--name", + "foo", + ], + tree: {}, + }; + assert!(matches!(env.run(), Err(Error::Clap { .. }))); + } + + #[test] + fn create_from_stdin() { + let mut env = test_env! { + args: [ + "torrent", + "create", + "--input", + "-", + "--announce", + "http://bar", + "--name", + "foo", + "--output", + "foo.torrent", + "--md5", + ], + input: "hello", + tree: {}, + }; + + env.assert_ok(); + + let metainfo = env.load_metainfo("foo.torrent"); + + assert_eq!(metainfo.info.pieces, PieceList::from_pieces(&["hello"])); + + assert_eq!( + metainfo.info.mode, + Mode::Single { + length: Bytes(5), + md5sum: Some(Md5Digest::from_data("hello")), + } + ); + } } diff --git a/src/subcommand/torrent/verify.rs b/src/subcommand/torrent/verify.rs index a5ff2f9..449e1f9 100644 --- a/src/subcommand/torrent/verify.rs +++ b/src/subcommand/torrent/verify.rs @@ -45,7 +45,7 @@ impl Verify { content.clone() } else { match &self.metainfo { - InputTarget::File(path) => path.parent().unwrap().join(&metainfo.info.name), + InputTarget::Path(path) => path.parent().unwrap().join(&metainfo.info.name), InputTarget::Stdin => PathBuf::from(&metainfo.info.name), } }; diff --git a/src/test_env.rs b/src/test_env.rs index e481bfc..dfe9396 100644 --- a/src/test_env.rs +++ b/src/test_env.rs @@ -77,6 +77,18 @@ impl TestEnv { fs::set_permissions(self.env.resolve(path), permissions).unwrap(); } + pub(crate) fn assert_ok(&mut self) { + match self.run() { + Ok(()) => {} + Err(err) => { + eprintln!("Run failed: {}", err); + eprintln!("Std error:\n{}", self.err()); + eprintln!("Std output:\n{}", self.out()); + panic!(); + } + } + } + pub(crate) fn load_metainfo(&mut self, filename: impl AsRef) -> Metainfo { let input = self.env.read(filename.as_ref().as_os_str().into()).unwrap(); Metainfo::from_input(&input).unwrap() diff --git a/src/test_env_builder.rs b/src/test_env_builder.rs index 583fc1e..1aa1dfa 100644 --- a/src/test_env_builder.rs +++ b/src/test_env_builder.rs @@ -4,7 +4,7 @@ pub(crate) struct TestEnvBuilder { args: Vec, current_dir: Option, err_style: bool, - input: Option>, + input: Option>, out_is_term: bool, tempdir: Option, use_color: bool,