diff --git a/Cargo.lock b/Cargo.lock index cf1dea9..cfa7273 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,9 +2,9 @@ # It is not intended for manual editing. [[package]] name = "aho-corasick" -version = "0.7.7" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f56c476256dc249def911d6f7580b5fc7e875895b5d7ee88f5d602208035744" +checksum = "743ad5a418686aad3b87fd14c43badd828cf26e214a00f92a384291cf22e1811" dependencies = [ "memchr", ] @@ -38,6 +38,12 @@ dependencies = [ "winapi 0.3.8", ] +[[package]] +name = "autocfg" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8aac770f1885fd7e387acedd76065302551364496e46b3dd00860b2f8359b9d" + [[package]] name = "bitflags" version = "1.2.1" @@ -59,6 +65,17 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" +[[package]] +name = "chrono" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31850b4a4d6bae316f7a09e691c944c28299298837edc0a03f755618c23cbc01" +dependencies = [ + "num-integer", + "num-traits", + "time", +] + [[package]] name = "clap" version = "2.33.0" @@ -75,6 +92,22 @@ dependencies = [ "vec_map", ] +[[package]] +name = "ctor" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd8ce37ad4184ab2ce004c33bf6379185d3b1c95801cab51026bd271bf68eedc" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "difference" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524cbf6897b527295dff137cec09ecf3a05f4fddffd7dfcd1585403449e74198" + [[package]] name = "doc-comment" version = "0.3.1" @@ -155,9 +188,11 @@ version = "0.0.1" dependencies = [ "ansi_term 0.12.1", "atty", + "chrono", "env_logger", "libc", "md5", + "pretty_assertions", "regex", "serde", "serde_bencode", @@ -167,6 +202,7 @@ dependencies = [ "static_assertions", "structopt", "tempfile", + "unicode-width", "url", "walkdir", ] @@ -220,6 +256,34 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3197e20c7edb283f87c071ddfc7a2cca8f8e0b888c242959846a6fce03c72223" +[[package]] +name = "num-integer" +version = "0.1.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6ea62e9d81a77cd3ee9a2a5b9b609447857f3d358704331e4ef39eb247fcba" +dependencies = [ + "autocfg", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62be47e61d1842b9170f0fdeec8eba98e60e90e5446449a0545e5152acd7096" +dependencies = [ + "autocfg", +] + +[[package]] +name = "output_vt100" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53cdc5b785b7a58c5aad8216b3dfa114df64b0b06ae6e1501cef91df2fbdf8f9" +dependencies = [ + "winapi 0.3.8", +] + [[package]] name = "percent-encoding" version = "2.1.0" @@ -233,10 +297,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74490b50b9fbe561ac330df47c08f3f33073d2d00c150f719147d7c54522fa1b" [[package]] -name = "proc-macro-error" -version = "0.4.6" +name = "pretty_assertions" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a427176b1223957a219780f6bd014fad03f59543ff7feb3854a6fb8e9626ac2b" +checksum = "3f81e1644e1b54f5a68959a29aa86cde704219254669da328ecfdf6a1f09d427" +dependencies = [ + "ansi_term 0.11.0", + "ctor", + "difference", + "output_vt100", +] + +[[package]] +name = "proc-macro-error" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875077759af22fa20b610ad4471d8155b321c89c3f2785526c9839b099be4e0a" dependencies = [ "proc-macro-error-attr", "proc-macro2", @@ -247,9 +323,9 @@ dependencies = [ [[package]] name = "proc-macro-error-attr" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3e4daa0eae3f30db348ecfa5f942281173d441588ebdac871d730acbfc7f16" +checksum = "c5717d9fa2664351a01ed73ba5ef6df09c01a521cb42cb65a061432a826f3c7a" dependencies = [ "proc-macro2", "quote", @@ -471,9 +547,9 @@ checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" [[package]] name = "structopt" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df136b42d76b1fbea72e2ab3057343977b04b4a2e00836c3c7c0673829572713" +checksum = "a1bcbed7d48956fcbb5d80c6b95aedb553513de0a1b451ea92679d999c010e98" dependencies = [ "clap", "lazy_static", @@ -482,9 +558,9 @@ dependencies = [ [[package]] name = "structopt-derive" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd50a87d2f7b8958055f3e73a963d78feaccca3836767a9069844e34b5b03c0a" +checksum = "095064aa1f5b94d14e635d0a5684cf140c43ae40a0fd990708d38f5d669e5f64" dependencies = [ "heck", "proc-macro-error", @@ -568,6 +644,17 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "time" +version = "0.1.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8dcfca086c1143c9270ac42a2bbd8a7ee477b78ac8e45b19abfb0cbede4b6f" +dependencies = [ + "libc", + "redox_syscall", + "winapi 0.3.8", +] + [[package]] name = "unicode-bidi" version = "0.3.4" diff --git a/Cargo.toml b/Cargo.toml index f925a0c..364d86d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,9 +15,11 @@ default-run = "imdl" [dependencies] ansi_term = "0.12" atty = "0.2" +chrono = "0.4.1" env_logger = "0.7" libc = "0.2" md5 = "0.7" +pretty_assertions = "0.6" regex = "1" serde_bencode = "0.2" serde_bytes = "0.11" @@ -25,6 +27,7 @@ sha1 = "0.6" snafu = "0.6" static_assertions = "1" tempfile = "3" +unicode-width = "0.1" url = "2" walkdir = "2.1" diff --git a/README.md b/README.md index 2e24411..e3ae94a 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ - [References](#references) - [Alternatives & Prior Art](#alternatives--prior-art) - [BitTorrent](#bittorrent) +- [Acknowledgments](#acknowledgments) ## General @@ -161,3 +162,9 @@ at any time. | https://wiki.theory.org/index.php/Main_Page | Wiki with lots of information about all aspects of the BitTorrent protocol and implementations. | | https://archive.org/details/2014_torrent_archive_organized) | Massive 158 GiB archive containing 5.5 million torrents, assembled in 2014. | | https://github.com/internetarchive/dweb-transport | Github repository hosting The Internet Archive's distributed web and BitTorrent-related software. | + +## Acknowledgments + +The formatting of `imdl torrent show` is entirely copied from +[torf](https://github.com/rndusr/torf-cli), an excellent command-line torrent +creator, editor, and viewer. diff --git a/bin/lint b/bin/lint index 5ef00c5..d2faa7d 100755 --- a/bin/lint +++ b/bin/lint @@ -2,4 +2,4 @@ set -euxo pipefail -! grep --color -REn 'FIXME|TODO|XXX' src +! grep --color -REni 'FIXME|TODO|XXX' src diff --git a/src/bencode.rs b/src/bencode.rs index d8c1569..ce47d99 100644 --- a/src/bencode.rs +++ b/src/bencode.rs @@ -15,15 +15,13 @@ impl<'buffer> Value<'buffer> { Parser::parse(buffer) } - #[cfg(test)] - fn encode(&self) -> Vec { + pub(crate) fn encode(&self) -> Vec { let mut buffer = Vec::new(); self.encode_into(&mut buffer); buffer } - #[cfg(test)] - fn encode_into(&self, buffer: &mut Vec) { + pub(crate) fn encode_into(&self, buffer: &mut Vec) { match self { Self::Int(value) => { buffer.push(b'i'); @@ -49,7 +47,6 @@ impl<'buffer> Value<'buffer> { } } - #[cfg(test)] fn encode_str(buffer: &mut Vec, contents: &[u8]) { buffer.extend_from_slice(contents.len().to_string().as_bytes()); buffer.push(b':'); diff --git a/src/bytes.rs b/src/bytes.rs index e8814ac..66dd557 100644 --- a/src/bytes.rs +++ b/src/bytes.rs @@ -13,6 +13,10 @@ const YI: u128 = ZI << 10; pub(crate) struct Bytes(pub(crate) u128); impl Bytes { + pub(crate) fn from(bytes: impl Into) -> Bytes { + Bytes(bytes.into()) + } + pub(crate) fn is_power_of_two(self) -> bool { self.0 == 0 || self.0 & (self.0 - 1) == 0 } diff --git a/src/common.rs b/src/common.rs index ba30d6b..003bbd3 100644 --- a/src/common.rs +++ b/src/common.rs @@ -19,6 +19,7 @@ pub(crate) use std::{ }; // dependencies +pub(crate) use chrono::{TimeZone, Utc}; pub(crate) use libc::EXIT_FAILURE; pub(crate) use regex::{Regex, RegexSet}; pub(crate) use serde::{Deserialize, Serialize}; @@ -29,6 +30,7 @@ pub(crate) use structopt::{ clap::{AppSettings, ArgSettings}, StructOpt, }; +pub(crate) use unicode_width::UnicodeWidthStr; pub(crate) use url::Url; pub(crate) use walkdir::WalkDir; @@ -45,13 +47,10 @@ pub(crate) use crate::{ pub(crate) use crate::{ bytes::Bytes, env::Env, error::Error, file_info::FileInfo, hasher::Hasher, info::Info, lint::Lint, metainfo::Metainfo, mode::Mode, opt::Opt, platform::Platform, style::Style, - subcommand::Subcommand, torrent::Torrent, use_color::UseColor, + subcommand::Subcommand, table::Table, torrent::Torrent, torrent_summary::TorrentSummary, + use_color::UseColor, }; -// test modules -#[cfg(test)] -pub(crate) use crate::testing; - // test stdlib types #[cfg(test)] pub(crate) use std::{ @@ -63,6 +62,10 @@ pub(crate) use std::{ time::{Duration, Instant}, }; +// test modules +#[cfg(test)] +pub(crate) use crate::testing; + // test structs and enums #[cfg(test)] pub(crate) use crate::{capture::Capture, test_env::TestEnv}; diff --git a/src/error.rs b/src/error.rs index 245493b..151b037 100644 --- a/src/error.rs +++ b/src/error.rs @@ -9,8 +9,13 @@ pub(crate) enum Error { AnnounceEmpty, #[snafu(display("Failed to parse announce URL: {}", source))] AnnounceUrlParse { source: url::ParseError }, - #[snafu(display("Failed to decode bencode: {}", source))] - BencodeDecode { source: serde_bencode::Error }, + #[snafu(display("Failed to deserialize torrent metainfo from `{}`: {}", path.display(), source))] + MetainfoLoad { + source: serde_bencode::Error, + path: PathBuf, + }, + #[snafu(display("Failed to serialize torrent metainfo: {}", source))] + MetainfoSerialize { source: serde_bencode::Error }, #[snafu(display("Failed to parse byte count `{}`: {}", text, source))] ByteParse { text: String, @@ -44,8 +49,6 @@ pub(crate) enum Error { PieceLengthSmall, #[snafu(display("Piece length cannot be zero"))] PieceLengthZero, - #[snafu(display("Serialization failed: {}", source))] - Serialize { source: serde_bencode::Error }, #[snafu(display("Failed to write to standard error: {}", source))] Stderr { source: io::Error }, #[snafu(display("Failed to write to standard output: {}", source))] @@ -77,12 +80,6 @@ impl From for Error { } } -impl From for Error { - fn from(source: serde_bencode::Error) -> Self { - Self::Serialize { source } - } -} - impl From for Error { fn from(source: SystemTimeError) -> Self { Self::SystemTime { source } diff --git a/src/info.rs b/src/info.rs index bd80a9a..878c42c 100644 --- a/src/info.rs +++ b/src/info.rs @@ -2,7 +2,7 @@ use crate::common::*; #[derive(Deserialize, Serialize)] pub struct Info { - pub private: u8, + pub private: Option, #[serde(rename = "piece length")] pub piece_length: u32, pub name: String, diff --git a/src/main.rs b/src/main.rs index 7e77559..c54c8ff 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,11 +7,15 @@ clippy::implicit_return, clippy::indexing_slicing, clippy::integer_arithmetic, + clippy::integer_division, + clippy::large_enum_variant, clippy::missing_docs_in_private_items, + clippy::needless_pass_by_value, clippy::option_map_unwrap_or_else, clippy::option_unwrap_used, clippy::result_expect_used, clippy::result_unwrap_used, + clippy::shadow_reuse, clippy::unreachable, clippy::wildcard_enum_match_arm )] @@ -62,7 +66,9 @@ mod platform_interface; mod reckoner; mod style; mod subcommand; +mod table; mod torrent; +mod torrent_summary; mod use_color; fn main() { diff --git a/src/metainfo.rs b/src/metainfo.rs index 8e59428..b9453e8 100644 --- a/src/metainfo.rs +++ b/src/metainfo.rs @@ -3,13 +3,33 @@ use crate::common::*; #[derive(Deserialize, Serialize)] pub struct Metainfo { pub announce: String, - #[serde(rename = "announce list")] + #[serde(rename = "announce-list")] pub announce_list: Option>>, pub comment: Option, #[serde(rename = "created by")] pub created_by: Option, #[serde(rename = "creation date")] pub creation_date: Option, - pub encoding: String, + pub encoding: Option, pub info: Info, } + +impl Metainfo { + pub(crate) fn _load(path: impl AsRef) -> Result { + let path = path.as_ref(); + let bytes = fs::read(path).context(error::Filesystem { path })?; + Self::deserialize(path, &bytes) + } + + pub(crate) fn dump(&self, path: impl AsRef) -> Result<(), Error> { + let path = path.as_ref(); + let bytes = serde_bencode::ser::to_bytes(&self).context(error::MetainfoSerialize)?; + fs::write(path, &bytes).context(error::Filesystem { path })?; + Ok(()) + } + + pub(crate) fn deserialize(path: impl AsRef, bytes: &[u8]) -> Result { + let path = path.as_ref(); + serde_bencode::de::from_bytes(&bytes).context(error::MetainfoLoad { path }) + } +} diff --git a/src/mode.rs b/src/mode.rs index 8c6c827..cac84c4 100644 --- a/src/mode.rs +++ b/src/mode.rs @@ -6,3 +6,12 @@ pub enum Mode { Single { length: u64, md5sum: Option }, Multiple { files: Vec }, } + +impl Mode { + pub(crate) fn total_size(&self) -> Bytes { + match self { + Self::Single { length, .. } => Bytes::from(*length), + Self::Multiple { files } => Bytes::from(files.iter().map(|file| file.length).sum::()), + } + } +} diff --git a/src/table.rs b/src/table.rs new file mode 100644 index 0000000..44fb9eb --- /dev/null +++ b/src/table.rs @@ -0,0 +1,172 @@ +use crate::common::*; + +pub(crate) struct Table { + rows: Vec<(&'static str, Value)>, +} + +impl Table { + pub(crate) fn new() -> Self { + Self { rows: Vec::new() } + } + + pub(crate) fn row(&mut self, name: &'static str, value: impl ToString) { + self.rows.push((name, Value::Scalar(value.to_string()))); + } + + pub(crate) fn tiers( + &mut self, + name: &'static str, + tiers: impl IntoIterator)>, + ) { + self.rows.push(( + name, + Value::Tiers( + tiers + .into_iter() + .map(|(name, values)| { + ( + name.to_string(), + values.into_iter().map(|value| value.to_string()).collect(), + ) + }) + .collect(), + ), + )); + } + + fn rows(&self) -> &[(&'static str, Value)] { + &self.rows + } + + pub(crate) fn name_width(&self) -> usize { + self + .rows() + .iter() + .map(|row| UnicodeWidthStr::width(row.0)) + .max() + .unwrap_or(0) + } + + pub(crate) fn write_human_readable(&self, out: &mut dyn Write) -> io::Result<()> { + fn padding(out: &mut dyn Write, n: usize) -> io::Result<()> { + write!(out, "{:width$}", "", width = n) + } + + let name_width = self.name_width(); + + for (name, value) in self.rows() { + write!(out, "{:>width$}", name, width = name_width)?; + + match value { + Value::Scalar(value) => writeln!(out, " {}", value)?, + Value::Tiers(tiers) => { + let tier_name_width = tiers + .iter() + .map(|(name, _values)| UnicodeWidthStr::width(name.as_str())) + .max() + .unwrap_or(0); + + for (i, (name, values)) in tiers.iter().enumerate() { + if i > 0 { + padding(out, name_width)?; + } + + write!( + out, + " {:width$}", + format!("{}:", name).as_str(), + width = tier_name_width + 1 + )?; + + for (i, value) in values.iter().enumerate() { + if i > 0 { + padding(out, name_width + 2 + tier_name_width + 1)?; + } + + writeln!(out, " {}", value)?; + } + } + } + } + } + + Ok(()) + } +} + +enum Value { + Scalar(String), + Tiers(Vec<(String, Vec)>), +} + +#[cfg(test)] +mod tests { + use super::*; + + fn human_readable(table: Table, want: &str) { + let mut cursor = Cursor::new(Vec::new()); + table.write_human_readable(&mut cursor).unwrap(); + let have = String::from_utf8(cursor.into_inner()).unwrap(); + if have != want { + panic!("have != want:\nHAVE:\n{}\nWANT:\n{}", have, want); + } + } + + #[test] + fn single_row() { + let mut table = Table::new(); + table.row("Foo", "bar"); + human_readable(table, "Foo bar\n"); + } + + #[test] + fn multiple_rows() { + let mut table = Table::new(); + table.row("Foo", "bar"); + table.row("X", "y"); + human_readable(table, "Foo bar\n X y\n"); + } + + #[test] + fn tiers_aligned() { + let mut table = Table::new(); + table.tiers("Foo", vec![("Bar", &["a", "b"]), ("Baz", &["x", "y"])]); + human_readable( + table, + "\ +Foo Bar: a + b + Baz: x + y +", + ); + } + + #[test] + fn tiers_unaligned() { + let mut table = Table::new(); + table.tiers( + "First", + vec![("Some", &["the", "thing"]), ("Other", &["about", "that"])], + ); + table.tiers( + "Second", + vec![ + ("Row", &["the", "thing"]), + ("More Stuff", &["about", "that"]), + ], + ); + human_readable( + table, + " First Some: the + thing + Other: about + that +Second Row: the + thing + More Stuff: about + that +", + ); + } +} diff --git a/src/torrent.rs b/src/torrent.rs index 8087f1b..80fef72 100644 --- a/src/torrent.rs +++ b/src/torrent.rs @@ -1,6 +1,7 @@ use crate::common::*; mod create; +mod show; mod stats; #[derive(StructOpt)] @@ -12,6 +13,7 @@ mod stats; pub(crate) enum Torrent { Create(torrent::create::Create), Stats(torrent::stats::Stats), + Show(torrent::show::Show), } impl Torrent { @@ -19,6 +21,7 @@ impl Torrent { match self { Self::Create(create) => create.run(env), Self::Stats(stats) => stats.run(env, unstable), + Self::Show(show) => show.run(env), } } } diff --git a/src/torrent/create.rs b/src/torrent/create.rs index 0a6b594..b6a1d7b 100644 --- a/src/torrent/create.rs +++ b/src/torrent/create.rs @@ -197,7 +197,7 @@ impl Create { input.parent().unwrap().join(torrent_name) }); - let private = if self.private { 1 } else { 0 }; + let private = if self.private { Some(1) } else { None }; let creation_date = if self.no_creation_date { None @@ -227,7 +227,7 @@ impl Create { let metainfo = Metainfo { comment: self.comment, - encoding: consts::ENCODING_UTF8.to_string(), + encoding: Some(consts::ENCODING_UTF8.to_string()), announce: self.announce.to_string(), announce_list: if announce_list.is_empty() { None @@ -239,9 +239,7 @@ impl Create { info, }; - let bytes = serde_bencode::ser::to_bytes(&metainfo)?; - - fs::write(&output, bytes).context(error::Filesystem { path: &output })?; + metainfo.dump(&output)?; if self.open { Platform::open(&output)?; @@ -255,8 +253,6 @@ impl Create { mod tests { use super::*; - use crate::test_env::TestEnv; - fn environment(args: &[&str]) -> TestEnv { testing::env(["torrent", "create"].iter().chain(args).cloned()) } @@ -292,7 +288,7 @@ mod tests { let torrent = env.resolve("foo.torrent"); let bytes = fs::read(torrent).unwrap(); let metainfo = serde_bencode::de::from_bytes::(&bytes).unwrap(); - assert_eq!(metainfo.info.private, 0); + assert_eq!(metainfo.info.private, None); } #[test] @@ -303,7 +299,7 @@ mod tests { let torrent = env.resolve("foo.torrent"); let bytes = fs::read(torrent).unwrap(); let metainfo = serde_bencode::de::from_bytes::(&bytes).unwrap(); - assert_eq!(metainfo.info.private, 1); + assert_eq!(metainfo.info.private, Some(1)); } #[test] @@ -573,7 +569,7 @@ mod tests { let torrent = env.resolve("foo.torrent"); let bytes = fs::read(torrent).unwrap(); let metainfo = serde_bencode::de::from_bytes::(&bytes).unwrap(); - assert_eq!(metainfo.encoding, "UTF-8"); + assert_eq!(metainfo.encoding, Some("UTF-8".into())); } #[test] diff --git a/src/torrent/show.rs b/src/torrent/show.rs new file mode 100644 index 0000000..60c6b97 --- /dev/null +++ b/src/torrent/show.rs @@ -0,0 +1,90 @@ +use crate::common::*; + +#[derive(StructOpt)] +#[structopt( + help_message(consts::HELP_MESSAGE), + version_message(consts::VERSION_MESSAGE), + about("Display information about a `.torrent` file.") +)] +pub(crate) struct Show { + #[structopt( + name = "TORRENT", + long = "input", + help = "Show information about `TORRENT`." + )] + input: PathBuf, +} + +impl Show { + pub(crate) fn run(self, env: &mut Env) -> Result<(), Error> { + let summary = TorrentSummary::load(&env.resolve(self.input))?; + + let table = summary.table(); + + table + .write_human_readable(&mut env.out) + .context(error::Stdout)?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn output() { + let mut env = testing::env( + ["torrent", "show", "--input", "foo.torrent"] + .iter() + .cloned(), + ); + + let metainfo = Metainfo { + announce: "announce".into(), + announce_list: Some(vec![vec!["announce".into(), "b".into()], vec!["c".into()]]), + comment: Some("comment".into()), + created_by: Some("created by".into()), + creation_date: Some(1), + encoding: Some("UTF-8".into()), + info: Info { + private: Some(1), + piece_length: 16 * 1024, + name: "foo".into(), + pieces: vec![ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, + ], + mode: Mode::Single { + length: 20, + md5sum: None, + }, + }, + }; + + let path = env.resolve("foo.torrent"); + + metainfo.dump(path).unwrap(); + + env.run().unwrap(); + + let have = env.out(); + let want = " Name foo + Comment comment + Created 1970-01-01 00:00:01 UTC + Info Hash bd68a8a5ab377e37e8cdbfd37b670408c59a009f +Torrent Size 236 bytes +Content Size 20 bytes + Private yes + Trackers Main: announce + Tier 1: announce + b + Tier 2: c + Piece Size 16 KiB + Piece Count 1 + File Count 1 +"; + + assert_eq!(have, want); + } +} diff --git a/src/torrent_summary.rs b/src/torrent_summary.rs new file mode 100644 index 0000000..5f47fc8 --- /dev/null +++ b/src/torrent_summary.rs @@ -0,0 +1,104 @@ +use crate::common::*; + +pub(crate) struct TorrentSummary { + metainfo: Metainfo, + infohash: sha1::Digest, + size: Bytes, +} + +impl TorrentSummary { + pub(crate) fn load(path: &Path) -> Result { + let bytes = fs::read(path).context(error::Filesystem { path })?; + + let metainfo = Metainfo::deserialize(path, &bytes)?; + + let value = bencode::Value::decode(&bytes).unwrap(); + + let infohash = if let bencode::Value::Dict(items) = value { + let info = items + .iter() + .find(|(key, _value)| key == b"info") + .unwrap() + .1 + .encode(); + Sha1::from(info).digest() + } else { + unreachable!() + }; + + let metadata = path.metadata().context(error::Filesystem { path })?; + + Ok(Self { + size: Bytes(metadata.len().into()), + infohash, + metainfo, + }) + } + + pub(crate) fn table(&self) -> Table { + let mut table = Table::new(); + + table.row("Name", &self.metainfo.info.name); + + if let Some(comment) = &self.metainfo.comment { + table.row("Comment", comment); + } + + if let Some(creation_date) = self.metainfo.creation_date { + #[allow(clippy::as_conversions)] + table.row( + "Created", + Utc.timestamp( + creation_date + .min(i64::max_value() as u64) + .try_into() + .unwrap(), + 0, + ), + ); + } + + table.row("Info Hash", self.infohash); + + table.row("Torrent Size", self.size); + + table.row("Content Size", self.metainfo.info.mode.total_size()); + + table.row( + "Private", + if self.metainfo.info.private.unwrap_or(0) == 1 { + "yes" + } else { + "no" + }, + ); + + match &self.metainfo.announce_list { + Some(tiers) => { + let mut value = Vec::new(); + value.push(("Main".to_owned(), vec![self.metainfo.announce.clone()])); + + for (i, tier) in tiers.iter().enumerate() { + value.push((format!("Tier {}", i + 1), tier.clone())); + } + + table.tiers("Trackers", value); + } + None => table.row("Tracker", &self.metainfo.announce), + } + + table.row("Piece Size", Bytes::from(self.metainfo.info.piece_length)); + + table.row("Piece Count", self.metainfo.info.pieces.len() / 20); + + table.row( + "File Count", + match &self.metainfo.info.mode { + Mode::Single { .. } => 1, + Mode::Multiple { files } => files.len(), + }, + ); + + table + } +}