Improve torrent display formatting

- Use colors
- Use cut-friendly formatting when not writing to terminal
- Show sizes as number of bytes when not writing to terminal

type: changed
This commit is contained in:
Casey Rodarmor 2020-02-04 10:54:41 -08:00
parent 5c5dac1fe5
commit 35a0e8f9b7
No known key found for this signature in database
GPG Key ID: 556186B153EC6FE0
12 changed files with 273 additions and 58 deletions

View File

@ -56,7 +56,6 @@ pub(crate) use crate::{
pub(crate) use std::{ pub(crate) use std::{
cell::RefCell, cell::RefCell,
io::Cursor, io::Cursor,
iter,
ops::{Deref, DerefMut}, ops::{Deref, DerefMut},
rc::Rc, rc::Rc,
time::{Duration, Instant}, time::{Duration, Instant},
@ -68,4 +67,4 @@ pub(crate) use crate::testing;
// test structs and enums // test structs and enums
#[cfg(test)] #[cfg(test)]
pub(crate) use crate::{capture::Capture, test_env::TestEnv}; pub(crate) use crate::{capture::Capture, test_env::TestEnv, test_env_builder::TestEnvBuilder};

View File

@ -6,6 +6,8 @@ pub(crate) struct Env {
pub(crate) err: Box<dyn Write>, pub(crate) err: Box<dyn Write>,
pub(crate) out: Box<dyn Write>, pub(crate) out: Box<dyn Write>,
err_style: Style, err_style: Style,
out_style: Style,
out_is_term: bool,
} }
impl Env { impl Env {
@ -15,16 +17,32 @@ impl Env {
Err(error) => panic!("Failed to get current directory: {}", error), Err(error) => panic!("Failed to get current directory: {}", error),
}; };
let err_style = if env::var_os("NO_COLOR").is_some() let no_color = env::var_os("NO_COLOR").is_some()
|| env::var_os("TERM").as_deref() == Some(OsStr::new("dumb")) || env::var_os("TERM").as_deref() == Some(OsStr::new("dumb"));
|| !atty::is(atty::Stream::Stderr)
{ let err_style = if no_color || !atty::is(atty::Stream::Stderr) {
Style::inactive() Style::inactive()
} else { } else {
Style::active() Style::active()
}; };
Self::new(dir, io::stdout(), io::stderr(), err_style, env::args()) let out_style = if no_color || !atty::is(atty::Stream::Stdout) {
Style::inactive()
} else {
Style::active()
};
let out_is_term = atty::is(atty::Stream::Stdout);
Self::new(
dir,
io::stdout(),
out_style,
out_is_term,
io::stderr(),
err_style,
env::args(),
)
} }
pub(crate) fn run(&mut self) -> Result<(), Error> { pub(crate) fn run(&mut self) -> Result<(), Error> {
@ -50,7 +68,15 @@ impl Env {
opt.run(self) opt.run(self)
} }
pub(crate) fn new<D, O, E, S, I>(dir: D, out: O, err: E, err_style: Style, args: I) -> Self pub(crate) fn new<D, O, E, S, I>(
dir: D,
out: O,
out_style: Style,
out_is_term: bool,
err: E,
err_style: Style,
args: I,
) -> Self
where where
D: AsRef<Path> + 'static, D: AsRef<Path> + 'static,
O: Write + 'static, O: Write + 'static,
@ -63,6 +89,8 @@ impl Env {
dir: Box::new(dir), dir: Box::new(dir),
err: Box::new(err), err: Box::new(err),
out: Box::new(out), out: Box::new(out),
out_style,
out_is_term,
err_style, err_style,
} }
} }
@ -116,6 +144,14 @@ impl Env {
pub(crate) fn resolve(&self, path: impl AsRef<Path>) -> PathBuf { pub(crate) fn resolve(&self, path: impl AsRef<Path>) -> PathBuf {
self.dir().join(path).clean() self.dir().join(path).clean()
} }
pub(crate) fn out_is_term(&self) -> bool {
self.out_is_term
}
pub(crate) fn out_style(&self) -> Style {
self.out_style
}
} }
#[cfg(test)] #[cfg(test)]

View File

@ -42,6 +42,9 @@ mod testing;
#[cfg(test)] #[cfg(test)]
mod test_env; mod test_env;
#[cfg(test)]
mod test_env_builder;
#[cfg(test)] #[cfg(test)]
mod capture; mod capture;

View File

@ -21,6 +21,7 @@ impl Metainfo {
Self::deserialize(path, &bytes) Self::deserialize(path, &bytes)
} }
#[cfg(test)]
pub(crate) fn dump(&self, path: impl AsRef<Path>) -> Result<(), Error> { pub(crate) fn dump(&self, path: impl AsRef<Path>) -> Result<(), Error> {
let path = path.as_ref(); let path = path.as_ref();
let bytes = serde_bencode::ser::to_bytes(&self).context(error::MetainfoSerialize)?; let bytes = serde_bencode::ser::to_bytes(&self).context(error::MetainfoSerialize)?;

View File

@ -27,4 +27,12 @@ impl Style {
ansi_term::Style::new() ansi_term::Style::new()
} }
} }
pub(crate) fn blue(self) -> ansi_term::Style {
if self.active {
ansi_term::Style::new().fg(ansi_term::Color::Blue)
} else {
ansi_term::Style::new()
}
}
} }

View File

@ -13,6 +13,10 @@ impl Table {
self.rows.push((name, Value::Scalar(value.to_string()))); self.rows.push((name, Value::Scalar(value.to_string())));
} }
pub(crate) fn size(&mut self, name: &'static str, bytes: Bytes) {
self.rows.push((name, Value::Size(bytes)));
}
pub(crate) fn tiers( pub(crate) fn tiers(
&mut self, &mut self,
name: &'static str, name: &'static str,
@ -47,7 +51,7 @@ impl Table {
.unwrap_or(0) .unwrap_or(0)
} }
pub(crate) fn write_human_readable(&self, out: &mut dyn Write) -> io::Result<()> { pub(crate) fn write_human_readable(&self, out: &mut dyn Write, style: Style) -> io::Result<()> {
fn padding(out: &mut dyn Write, n: usize) -> io::Result<()> { fn padding(out: &mut dyn Write, n: usize) -> io::Result<()> {
write!(out, "{:width$}", "", width = n) write!(out, "{:width$}", "", width = n)
} }
@ -55,10 +59,17 @@ impl Table {
let name_width = self.name_width(); let name_width = self.name_width();
for (name, value) in self.rows() { for (name, value) in self.rows() {
write!(out, "{:>width$}", name, width = name_width)?; write!(
out,
"{:width$}{}",
"",
style.blue().paint(*name),
width = name_width - UnicodeWidthStr::width(*name),
)?;
match value { match value {
Value::Scalar(value) => writeln!(out, " {}", value)?, Value::Scalar(scalar) => writeln!(out, " {}", scalar)?,
Value::Size(bytes) => writeln!(out, " {}", bytes)?,
Value::Tiers(tiers) => { Value::Tiers(tiers) => {
let tier_name_width = tiers let tier_name_width = tiers
.iter() .iter()
@ -92,31 +103,81 @@ impl Table {
Ok(()) Ok(())
} }
pub(crate) fn write_tab_delimited(&self, out: &mut dyn Write) -> io::Result<()> {
for (name, value) in self.rows() {
write!(out, "{}\t", name)?;
match value {
Value::Scalar(scalar) => writeln!(out, "{}", scalar)?,
Value::Size(Bytes(value)) => writeln!(out, "{}", value)?,
Value::Tiers(tiers) => {
for (i, value) in tiers.iter().flat_map(|(_name, values)| values).enumerate() {
if i > 0 {
write!(out, "\t")?;
}
write!(out, "{}", value)?;
}
writeln!(out)?;
}
}
}
Ok(())
}
} }
enum Value { enum Value {
Scalar(String), Scalar(String),
Tiers(Vec<(String, Vec<String>)>), Tiers(Vec<(String, Vec<String>)>),
Size(Bytes),
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
fn human_readable(table: Table, want: &str) { fn human_readable(table: &Table, want: &str) {
let mut cursor = Cursor::new(Vec::new()); let mut cursor = Cursor::new(Vec::new());
table.write_human_readable(&mut cursor).unwrap(); table
.write_human_readable(&mut cursor, Style::inactive())
.unwrap();
let have = String::from_utf8(cursor.into_inner()).unwrap();
if have != want {
panic!("have != want:\nHAVE:\n{}\nWANT:\n{}", have, want);
}
}
fn tab_delimited(table: &Table, want: &str) {
let mut cursor = Cursor::new(Vec::new());
table.write_tab_delimited(&mut cursor).unwrap();
let have = String::from_utf8(cursor.into_inner()).unwrap(); let have = String::from_utf8(cursor.into_inner()).unwrap();
if have != want { if have != want {
panic!("have != want:\nHAVE:\n{}\nWANT:\n{}", have, want); panic!("have != want:\nHAVE:\n{}\nWANT:\n{}", have, want);
} }
} }
#[test]
fn color() {
let mut table = Table::new();
table.row("Here", "bar");
table.row("There", "baz");
let mut cursor = Cursor::new(Vec::new());
table
.write_human_readable(&mut cursor, Style::active())
.unwrap();
let have = String::from_utf8(cursor.into_inner()).unwrap();
assert_eq!(
have,
" \u{1b}[34mHere\u{1b}[0m bar\n\u{1b}[34mThere\u{1b}[0m baz\n"
);
}
#[test] #[test]
fn single_row() { fn single_row() {
let mut table = Table::new(); let mut table = Table::new();
table.row("Foo", "bar"); table.row("Foo", "bar");
human_readable(table, "Foo bar\n"); human_readable(&table, "Foo bar\n");
tab_delimited(&table, "Foo\tbar\n");
} }
#[test] #[test]
@ -124,7 +185,8 @@ mod tests {
let mut table = Table::new(); let mut table = Table::new();
table.row("Foo", "bar"); table.row("Foo", "bar");
table.row("X", "y"); table.row("X", "y");
human_readable(table, "Foo bar\n X y\n"); human_readable(&table, "Foo bar\n X y\n");
tab_delimited(&table, "Foo\tbar\nX\ty\n");
} }
#[test] #[test]
@ -132,7 +194,7 @@ mod tests {
let mut table = Table::new(); let mut table = Table::new();
table.tiers("Foo", vec![("Bar", &["a", "b"]), ("Baz", &["x", "y"])]); table.tiers("Foo", vec![("Bar", &["a", "b"]), ("Baz", &["x", "y"])]);
human_readable( human_readable(
table, &table,
"\ "\
Foo Bar: a Foo Bar: a
b b
@ -140,6 +202,7 @@ Foo Bar: a
y y
", ",
); );
tab_delimited(&table, "Foo\ta\tb\tx\ty\n");
} }
#[test] #[test]
@ -157,7 +220,7 @@ Foo Bar: a
], ],
); );
human_readable( human_readable(
table, &table,
" First Some: the " First Some: the
thing thing
Other: about Other: about
@ -168,5 +231,9 @@ Second Row: the
that that
", ",
); );
tab_delimited(
&table,
"First\tthe\tthing\tabout\tthat\nSecond\tthe\tthing\tabout\tthat\n",
);
} }
} }

View File

@ -7,18 +7,7 @@ pub(crate) struct TestEnv {
} }
impl TestEnv { impl TestEnv {
pub(crate) fn new(iter: impl IntoIterator<Item = impl Into<String>>) -> Self { pub(crate) fn new(env: Env, err: Capture, out: Capture) -> TestEnv {
let err = Capture::new();
let out = Capture::new();
let env = Env::new(
tempfile::tempdir().unwrap(),
out.clone(),
err.clone(),
Style::inactive(),
iter::once(String::from("imdl")).chain(iter.into_iter().map(|item| item.into())),
);
Self { err, env, out } Self { err, env, out }
} }

62
src/test_env_builder.rs Normal file
View File

@ -0,0 +1,62 @@
use crate::common::*;
pub(crate) struct TestEnvBuilder {
args: Vec<String>,
out_is_term: bool,
use_color: bool,
}
impl TestEnvBuilder {
pub(crate) fn new() -> TestEnvBuilder {
TestEnvBuilder {
args: Vec::new(),
out_is_term: false,
use_color: false,
}
}
pub(crate) fn out_is_term(mut self) -> Self {
self.out_is_term = true;
self
}
pub(crate) fn arg(mut self, arg: impl Into<String>) -> Self {
self.args.push(arg.into());
self
}
pub(crate) fn args(mut self, args: impl IntoIterator<Item = impl Into<String>>) -> Self {
for arg in args {
self.args.push(arg.into());
}
self
}
pub(crate) fn arg_slice(mut self, args: &[&str]) -> Self {
for arg in args.iter().cloned() {
self.args.push(arg.to_owned());
}
self
}
pub(crate) fn build(self) -> TestEnv {
let err = Capture::new();
let out = Capture::new();
let env = Env::new(
tempfile::tempdir().unwrap(),
out.clone(),
if self.use_color && self.out_is_term {
Style::active()
} else {
Style::inactive()
},
self.out_is_term,
err.clone(),
Style::inactive(),
self.args,
);
TestEnv::new(env, err, out)
}
}

View File

@ -1,5 +1,5 @@
use crate::common::*; use crate::common::*;
pub(crate) fn env(iter: impl IntoIterator<Item = impl Into<String>>) -> TestEnv { pub(crate) fn env(iter: impl IntoIterator<Item = impl Into<String>>) -> TestEnv {
TestEnv::new(iter) TestEnvBuilder::new().arg("imdl").args(iter).build()
} }

View File

@ -872,13 +872,20 @@ mod tests {
#[test] #[test]
fn output() { fn output() {
let mut env = environment(&[ let mut env = TestEnvBuilder::new()
"--input", .arg_slice(&[
"foo", "imdl",
"--announce", "torrent",
"http://bar", "create",
"--no-creation-date", "--input",
]); "foo",
"--announce",
"http://bar",
"--no-creation-date",
])
.out_is_term()
.build();
let dir = env.resolve("foo"); let dir = env.resolve("foo");
fs::create_dir(&dir).unwrap(); fs::create_dir(&dir).unwrap();
fs::write(dir.join("a"), "abc").unwrap(); fs::write(dir.join("a"), "abc").unwrap();

View File

@ -33,12 +33,6 @@ mod tests {
#[test] #[test]
fn output() { fn output() {
let mut env = testing::env(
["torrent", "show", "--input", "foo.torrent"]
.iter()
.cloned(),
);
let metainfo = Metainfo { let metainfo = Metainfo {
announce: "announce".into(), announce: "announce".into(),
announce_list: Some(vec![vec!["announce".into(), "b".into()], vec!["c".into()]]), announce_list: Some(vec![vec!["announce".into(), "b".into()], vec!["c".into()]]),
@ -61,14 +55,20 @@ mod tests {
}, },
}; };
let path = env.resolve("foo.torrent"); {
let mut env = TestEnvBuilder::new()
.arg_slice(&["imdl", "torrent", "show", "--input", "foo.torrent"])
.out_is_term()
.build();
metainfo.dump(path).unwrap(); let path = env.resolve("foo.torrent");
env.run().unwrap(); metainfo.dump(path).unwrap();
let have = env.out(); env.run().unwrap();
let want = " Name foo
let have = env.out();
let want = " Name foo
Comment comment Comment comment
Created 1970-01-01 00:00:01 UTC Created 1970-01-01 00:00:01 UTC
Source source Source source
@ -76,8 +76,7 @@ mod tests {
Torrent Size 252 bytes Torrent Size 252 bytes
Content Size 20 bytes Content Size 20 bytes
Private yes Private yes
Trackers Main: announce Trackers Tier 1: announce
Tier 1: announce
b b
Tier 2: c Tier 2: c
Piece Size 16 KiB Piece Size 16 KiB
@ -85,6 +84,37 @@ Content Size 20 bytes
File Count 1 File Count 1
"; ";
assert_eq!(have, want); assert_eq!(have, want);
}
{
let mut env = TestEnvBuilder::new()
.arg_slice(&["imdl", "torrent", "show", "--input", "foo.torrent"])
.build();
let path = env.resolve("foo.torrent");
metainfo.dump(path).unwrap();
env.run().unwrap();
let have = env.out();
let want = "\
Name\tfoo
Comment\tcomment
Created\t1970-01-01 00:00:01 UTC
Source\tsource
Info Hash\tb7595205a46491b3e8686e10b28efe7144d066cc
Torrent Size\t252
Content Size\t20
Private\tyes
Trackers\tannounce\tb\tc
Piece Size\t16384
Piece Count\t1
File Count\t1
";
assert_eq!(have, want);
}
} }
} }

View File

@ -45,9 +45,16 @@ impl TorrentSummary {
pub(crate) fn write(&self, env: &mut Env) -> Result<(), Error> { pub(crate) fn write(&self, env: &mut Env) -> Result<(), Error> {
let table = self.table(); let table = self.table();
table if env.out_is_term() {
.write_human_readable(&mut env.out) let out_style = env.out_style();
.context(error::Stdout)?; table
.write_human_readable(&mut env.out, out_style)
.context(error::Stdout)?;
} else {
table
.write_tab_delimited(&mut env.out)
.context(error::Stdout)?;
}
Ok(()) Ok(())
} }
@ -81,9 +88,9 @@ impl TorrentSummary {
table.row("Info Hash", self.infohash); table.row("Info Hash", self.infohash);
table.row("Torrent Size", self.size); table.size("Torrent Size", self.size);
table.row("Content Size", self.metainfo.info.mode.total_size()); table.size("Content Size", self.metainfo.info.mode.total_size());
table.row( table.row(
"Private", "Private",
@ -97,7 +104,13 @@ impl TorrentSummary {
match &self.metainfo.announce_list { match &self.metainfo.announce_list {
Some(tiers) => { Some(tiers) => {
let mut value = Vec::new(); let mut value = Vec::new();
value.push(("Main".to_owned(), vec![self.metainfo.announce.clone()]));
if !tiers
.iter()
.any(|tier| tier.contains(&self.metainfo.announce))
{
value.push(("Main".to_owned(), vec![self.metainfo.announce.clone()]));
}
for (i, tier) in tiers.iter().enumerate() { for (i, tier) in tiers.iter().enumerate() {
value.push((format!("Tier {}", i + 1), tier.clone())); value.push((format!("Tier {}", i + 1), tier.clone()));
@ -108,7 +121,7 @@ impl TorrentSummary {
None => table.row("Tracker", &self.metainfo.announce), None => table.row("Tracker", &self.metainfo.announce),
} }
table.row("Piece Size", Bytes::from(self.metainfo.info.piece_length)); table.size("Piece Size", Bytes::from(self.metainfo.info.piece_length));
table.row("Piece Count", self.metainfo.info.pieces.len() / 20); table.row("Piece Count", self.metainfo.info.pieces.len() / 20);