diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 701b424..def5c49 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -10,6 +10,12 @@ on: branches: - master +env: + # Increment to invalidate github actions caches if they become corrupt. + # Errors of the form "can't find crate for `snafu_derive` which `snafu` depends on" + # can usually be fixed by incrementing this value. + CACHE_KEY_PREFIX: 2 + jobs: all: name: All @@ -45,23 +51,31 @@ jobs: with: fetch-depth: 0 + # An issue with BSD Tar causes sporadic failures on macOS. + # c.f https://github.com/actions/cache/issues/403 + - name: Install GNU Tar + if: matrix.build == 'macos' + run: | + brew install gnu-tar + echo "::add-path::/usr/local/opt/gnu-tar/libexec/gnubin" + - name: Cache cargo registry uses: actions/cache@v1 with: path: ~/.cargo/registry - key: v1-${{ runner.os }}-cargo-registry + key: ${{ env.CACHE_KEY_PREFIX }}-${{ runner.os }}-cargo-registry - name: Cache cargo index uses: actions/cache@v1 with: path: ~/.cargo/git - key: v1-${{ runner.os }}-cargo-index + key: ${{ env.CACHE_KEY_PREFIX }}-${{ runner.os }}-cargo-index - name: Cache cargo build uses: actions/cache@v1 with: path: target - key: v1-${{ runner.os }}-cargo-build-target + key: ${{ env.CACHE_KEY_PREFIX }}-${{ runner.os }}-cargo-build-target - name: Install Stable uses: actions-rs/toolchain@v1 diff --git a/Cargo.lock b/Cargo.lock index 4479f82..c0ef95c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -626,6 +626,7 @@ dependencies = [ "serde", "serde-hex", "serde_bytes", + "serde_json", "serde_with", "sha1", "snafu", diff --git a/Cargo.toml b/Cargo.toml index 1a9a3df..2aa6154 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ pretty_env_logger = "0.4.0" regex = "1.0.0" serde-hex = "0.1.0" serde_bytes = "0.11.0" +serde_json = "1.0.57" serde_with = "1.4.0" sha1 = "0.6.0" snafu = "0.6.0" diff --git a/src/error.rs b/src/error.rs index 8c9cca3..e6c2130 100644 --- a/src/error.rs +++ b/src/error.rs @@ -136,6 +136,8 @@ pub(crate) enum Error { Unstable { feature: &'static str }, #[snafu(display("Torrent verification failed."))] Verify, + #[snafu(display("Failed to serialize JSON: {}", source))] + JsonSerialize { source: serde_json::Error }, } impl Error { diff --git a/src/subcommand/torrent/show.rs b/src/subcommand/torrent/show.rs index 346180b..e4216e4 100644 --- a/src/subcommand/torrent/show.rs +++ b/src/subcommand/torrent/show.rs @@ -9,6 +9,10 @@ const INPUT_POSITIONAL: &str = ""; const INPUT_VALUE: &str = "INPUT"; +const JSON_OUTPUT: &str = "json"; + +const JSON_HELP: &str = "Output data as JSON instead of the default format."; + #[derive(StructOpt)] #[structopt( help_message(consts::HELP_MESSAGE), @@ -36,6 +40,13 @@ pub(crate) struct Show { help = INPUT_HELP, )] input_positional: Option, + #[structopt( + name = JSON_OUTPUT, + long = "json", + short = "j", + help = JSON_HELP, + )] + json: bool, } impl Show { @@ -49,7 +60,11 @@ impl Show { let input = env.read(target)?; let summary = TorrentSummary::from_input(&input)?; - summary.write(env)?; + if self.json { + summary.write_json(env)?; + } else { + summary.write(env)?; + } Ok(()) } } @@ -559,4 +574,63 @@ files\tNAME Ok(()) } + + #[test] + fn output_json() { + { + let metainfo = Metainfo::test_value_single(); + let mut want = r#"{"name":"NAME","comment":"COMMENT","creation_date":1, +"created_by":"CREATED BY","source":"SOURCE","info_hash":"5d6f53772b4c20536fcce0c4c364d764a6efa39c", +"torrent_size":509,"content_size":32768,"private":true,"tracker": +"udp://announce.example:1337","announce_list":[["http://a.example:4567", +"https://b.example:77"],["udp://c.example:88"]],"update_url":"https://update.example/", +"dht_nodes":["node.example:12","1.1.1.1:16","[2001:db8:85a3::8a2e:370]:7334"], +"piece_size":16384,"piece_count":2,"file_count":1,"files":["NAME"]}"# + .replace('\n', ""); + want.push('\n'); + let mut env = TestEnvBuilder::new() + .arg_slice(&[ + "imdl", + "torrent", + "show", + "--input", + "foo.torrent", + "--json", + ]) + .out_is_term() + .build(); + let path = env.resolve("foo.torrent").unwrap(); + metainfo.dump(path).unwrap(); + env.assert_ok(); + let have = env.out(); + assert_eq!(have, want); + } + + { + let metainfo = Metainfo::test_value_single_unset(); + let mut want = r#"{"name":"NAME","comment":null,"creation_date":null, +"created_by":null,"source":null,"info_hash":"a9105b0ff5f7cefeee5599ed7831749be21cc04e", +"torrent_size":85,"content_size":5,"private":false,"tracker":null,"announce_list":[], +"update_url":null,"dht_nodes":[],"piece_size":1024,"piece_count":1,"file_count":1, +"files":["NAME"]}"# + .replace('\n', ""); + want.push('\n'); + let mut env = TestEnvBuilder::new() + .arg_slice(&[ + "imdl", + "torrent", + "show", + "--input", + "foo.torrent", + "--json", + ]) + .out_is_term() + .build(); + let path = env.resolve("foo.torrent").unwrap(); + metainfo.dump(path).unwrap(); + env.assert_ok(); + let have = env.out(); + assert_eq!(have, want); + } + } } diff --git a/src/torrent_summary.rs b/src/torrent_summary.rs index 2081e78..43366f7 100644 --- a/src/torrent_summary.rs +++ b/src/torrent_summary.rs @@ -6,6 +6,27 @@ pub(crate) struct TorrentSummary { size: Bytes, } +#[derive(Serialize)] +pub(crate) struct TorrentSummaryJson { + name: String, + comment: Option, + creation_date: Option, + created_by: Option, + source: Option, + info_hash: String, + torrent_size: u64, + content_size: u64, + private: bool, + tracker: Option, + announce_list: Vec>, + update_url: Option, + dht_nodes: Vec, + piece_size: u64, + piece_count: usize, + file_count: usize, + files: Vec, +} + impl TorrentSummary { fn new(metainfo: Metainfo, infohash: Infohash, size: Bytes) -> Self { Self { @@ -146,4 +167,70 @@ impl TorrentSummary { table } + + pub(crate) fn write_json(&self, env: &mut Env) -> Result<()> { + let data = self.torrent_summary_data(); + let json = serde_json::to_string(&data).context(error::JsonSerialize)?; + outln!(env, "{}", json)?; + Ok(()) + } + + fn torrent_summary_data(&self) -> TorrentSummaryJson { + let (file_count, files) = match &self.metainfo.info.mode { + Mode::Single { .. } => (1, vec![self.metainfo.info.name.to_string()]), + Mode::Multiple { files } => ( + files.len(), + files + .iter() + .map(|file_info| { + format!( + "{}", + file_info + .path + .absolute(Path::new(&self.metainfo.info.name)) + .as_path() + .display() + ) + }) + .collect(), + ), + }; + + TorrentSummaryJson { + name: self.metainfo.info.name.to_string(), + comment: self.metainfo.comment.clone(), + creation_date: self.metainfo.creation_date, + created_by: self.metainfo.created_by.clone(), + source: self.metainfo.info.source.clone(), + info_hash: self.infohash.to_string(), + torrent_size: self.size.count(), + content_size: self.metainfo.content_size().count(), + private: self.metainfo.info.private.unwrap_or_default(), + tracker: self.metainfo.announce.clone(), + announce_list: self + .metainfo + .announce_list + .as_ref() + .map(Clone::clone) + .unwrap_or_default(), + update_url: self + .metainfo + .info + .update_url + .as_ref() + .map(ToString::to_string), + dht_nodes: self + .metainfo + .nodes + .as_ref() + .unwrap_or(&Vec::new()) + .iter() + .map(ToString::to_string) + .collect::>(), + piece_size: self.metainfo.info.piece_length.count(), + piece_count: self.metainfo.info.pieces.count(), + file_count, + files, + } + } }