diff --git a/Cargo.toml b/Cargo.toml index bffcbd3..8c01eb8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,11 +13,13 @@ documentation = "https://docs.rs/crate/sumcol/latest" [dependencies] clap = { version = "4.4.7", features = ["derive"] } +fs-err = "2" colored = "2.1.0" -env_logger = "0.10.1" -log = "0.4.20" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } regex = "1.10.2" [dev-dependencies] assert_cmd = "1" predicates = "3" +tempfile = "3" diff --git a/README.md b/README.md index 37793bc..916570d 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,13 @@ `sumcol` is a simple unix-style command-line tool for summing numbers from a column of text. It's a replacement for the tried and true Unix-isms, like `awk '{s += $3} END {print s}'` (prints the sum of the numbers in the third -whitespace delimited column), without all the verbosity. +whitespace delimited column), without all the verbosity. `sumcol` tries to be +smart and interpret hex, float, and decimal values automatically, though you +can force the radix with the `--radix` flag. ## Quick Install ```console -$ cargo install sumcol +$ cargo install --locked sumcol ``` ## Examples @@ -32,10 +34,10 @@ Arguments: Options: -f, --field The field to sum. If not specified, uses the full line [default: 0] - -x, --hex Treat all numbers as hex, not just those with a leading 0x + --radix How to interpret numeric input [default: auto] [possible values: auto, hex, decimal] -d, --delimiter The regex on which to split fields [default: \s+] -v, --verbose Print each number that's being summed, along with some metadata - -h, --help Print help + -h, --help Print help (see more with '--help') -V, --version Print version ``` @@ -56,9 +58,12 @@ The size is shown in column -- or field -- number 5 (starting from 1), so we can ```console $ ls -l | sumcol -f5 + WARN sumcol: Field index out of range, skipping field=5 line="total 48" 17469 ``` -Which is equivalent to (but shorter than) the classic awk incantation: +The warning is from the `total 48` summary line which doesn't have a fifth +field; it's safely skipped and the sum is still correct. Equivalent to (but +shorter than) the classic awk incantation: ```console $ ls -l | awk '{s += $5} END {print s}' 17469 @@ -66,7 +71,7 @@ $ ls -l | awk '{s += $5} END {print s}' ### Sum all input -Sometimes you use other tools to extact a column of numbers, in which case you +Sometimes you use other tools to extract a column of numbers, in which case you can still use sumcol with no arguments to simply sum all of the input. Using the file listing from above, we could do the following: @@ -78,10 +83,10 @@ $ ls -l | awk '{print $5}' | sumcol ### Summing hex numbers Programmers are often dealing with numbers written in hex. Typically in forms -like `0x123abc` or even simply `0000abcd`. When `sumcol` sees a number starting -with `0x` it always assumes it's written in hex and parses it accordingly. -However, a hex number written without that prefix requires that we tell sumcol -to use hex. +like `0x123abc` or even simply `0000abcd`. By default, when `sumcol` sees a +number starting with `0x` it assumes it's written in hex and parses it +accordingly. However, a hex number written without that prefix requires that we +tell sumcol to use hex via `--radix=hex`. For this example we'll sum the sizes of each section in the compiled `sumcol` binary. We can see this information with the `objdump` command. @@ -161,47 +166,40 @@ LOAD, 00000148 ``` -Yuck. That has numbers, and non-numbers. Luckily, `sumcol` will easily handle -this! It quietly ignores non-numbers treating them as if they're a `0`. So -let's see what answer we get: +Yuck. That has numbers, and non-numbers. The numeric values are hex without a +`0x` prefix, so we need to pass `--radix=hex` to tell `sumcol` to parse them as +hex. Non-numeric tokens (table headers, comma-separated description tags) will +emit warnings and be treated as `0`: ```console -$ objdump -h target/release/sumcol | sumcol -f3 -[2023-11-10T21:02:06Z WARN sumcol] Failed to parse "0014c350". Consider using -x -[2023-11-10T21:02:06Z WARN sumcol] Failed to parse "000003b4". Consider using -x -[2023-11-10T21:02:06Z WARN sumcol] Failed to parse "0004f458". Consider using -x -[2023-11-10T21:02:06Z WARN sumcol] Failed to parse "0000cae8". Consider using -x -[2023-11-10T21:02:06Z WARN sumcol] Failed to parse "000087c8". Consider using -x -[2023-11-10T21:02:06Z WARN sumcol] Failed to parse "0002e5e0". Consider using -x -[2023-11-10T21:02:06Z WARN sumcol] Failed to parse "0002c9c0". Consider using -x -732 +$ objdump -h target/release/sumcol | sumcol -f3 --radix=hex + WARN sumcol: Failed to parse as hex, treating as 0 clean_str="format" + WARN sumcol: Field index out of range, skipping field=3 line="Sections:" + WARN sumcol: Failed to parse as hex, treating as 0 clean_str="Size" + WARN sumcol: Stripped commas from value original="LOAD," clean="LOAD" + WARN sumcol: Failed to parse as hex, treating as 0 clean_str="LOAD" + ... (similar warnings for each header and description line) ... +0x20C3AC ``` -Interesting. Sumcol quietly ignores non-numbers like `LOAD` in the above -example, but here it's warning us that it's seeing strings that _look like_ hex -numbers but we didn't tell it to parse the numbers as hex. Let's try again -following the recommendation to use `-x`. +The warnings here are expected and benign -- `format`, `Size`, `LOAD,`, etc. are +not hex values and contribute `0` to the sum, so the final answer is correct. -```console -$ objdump -h target/release/sumcol | sumcol -f3 -x -0x20C3AC -``` -NOTE: If the hex numbers started with a leading `'0x`, `sumcol` would have -silently parsed them correctly and omitted the warning. +If the values had been written with a `0x` prefix, `sumcol` would have +auto-detected them as hex with no flag needed. ## Debugging If `sumcol` doesn't seem to be working right, feel free to look at the code on github (it's pretty straight forward), or run it with the `-v` or `--verbose` -flag, or even enable the `RUST_LOG=debug` environment variable set. For -example: +flag, or run with the `RUST_LOG=debug` environment variable set. For example: -```console: +```console $ printf "1\n2.5\nOOPS\n3" | sumcol -v -1 # n=Integer(1) sum=Integer(1) cnt=1 radix=10 raw_str="1" -2.5 # n=Float(2.5) sum=Float(3.5) cnt=2 radix=10 raw_str="2.5" -0 # n=Integer(0) sum=Float(3.5) cnt=2 radix=10 raw_str="OOPS" err="ParseFloatError { kind: Invalid }" -3 # n=Integer(3) sum=Float(6.5) cnt=3 radix=10 raw_str="3" +1 # n=Integer(1) sum=Integer(1) radix=Decimal raw_str="1" +2.5 # n=Float(2.5) sum=Float(3.5) radix=Decimal raw_str="2.5" +0 # n=Integer(0) sum=Float(3.5) radix=Decimal raw_str="OOPS" err="Failed to parse (use --radix=hex if hex), treating as 0" +3 # n=Integer(3) sum=Float(6.5) radix=Decimal raw_str="3" == 6.5 ``` @@ -212,10 +210,9 @@ The metadata that's displayed on each line is |------|-------------| | `n` | The parsed numeric value | | `sum` | The running sum up to and including the current `n` | -| `cnt` | The running count of _successfully_ parsed numbers. If a number fails to parse and 0 is used instead, it will not be included in `cnt` | -| `radix` | The radix used when trying to parse the number as an integer | +| `radix` | The effective radix used when parsing the value (`Hex` or `Decimal`) | | `raw_str` | The raw string data that was parsed | -| `err` | If present, this shows the error from trying to parse the string into a number | +| `err` | If present, the warning message from a failed parse | This should be enough to help you debug the problem you're seeing. However, if that's not enough, give it a try with `RUST_LOG=debug`. \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 06d39af..1ad9849 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,7 +16,9 @@ impl Add for Sum { /// Adds two Sums. If either is a Float, the result will be a Float. fn add(self, other: Self) -> Self { match (self, other) { - (Sum::Integer(a), Sum::Integer(b)) => Sum::Integer(a + b), + (Sum::Integer(a), Sum::Integer(b)) => { + Sum::Integer(a.checked_add(b).expect("integer overflow")) + } (Sum::Float(a), Sum::Float(b)) => Sum::Float(a + b), (Sum::Integer(a), Sum::Float(b)) => Sum::Float(a as f64 + b), (Sum::Float(a), Sum::Integer(b)) => Sum::Float(a + b as f64), @@ -78,4 +80,22 @@ mod tests { assert_eq!(a + b, Sum::Float(1.2)); assert_eq!(b + a, Sum::Float(1.2)); } + + #[test] + fn sum_mixed_add_assign_works() { + let mut a = Sum::Integer(1); + a += Sum::Float(0.2); + assert_eq!(a, Sum::Float(1.2)); + + let mut b = Sum::Float(0.2); + b += Sum::Integer(1); + assert_eq!(b, Sum::Float(1.2)); + } + + #[test] + #[should_panic] + fn sum_integer_overflow_panics() { + let mut a = Sum::Integer(i128::MAX); + a += Sum::Integer(1); + } } diff --git a/src/main.rs b/src/main.rs index 2595736..0c8128c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,25 @@ -use clap::Parser; +use clap::{Parser, ValueEnum}; use colored::Colorize; -use env_logger::Env; use regex::Regex; -use std::fs; use std::io::{self, BufRead, BufReader}; use sumcol::Sum; +/// How to interpret numeric input. +#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)] +enum Radix { + /// Decimal unless the value has a leading 0x prefix (default). + Auto, + /// Always hex; values with a 0x prefix have it stripped first. + Hex, + /// Always decimal; 0x-prefixed values fail to parse. + Decimal, +} + +/// Sum a column of numbers from text input. +/// +/// Examples: +/// ls -l | sumcol -f 5 +/// sumcol -f 3 -d : /etc/passwd #[derive(Parser, Debug)] #[command(version, about)] struct Args { @@ -13,9 +27,9 @@ struct Args { #[arg(long, short, default_value("0"))] field: usize, - /// Treat all numbers as hex, not just those with a leading 0x. - #[arg(long, short = 'x')] - hex: bool, + /// How to interpret numeric input. + #[arg(long, value_enum, default_value_t = Radix::Auto)] + radix: Radix, /// The regex on which to split fields. #[arg(long, short, default_value(r"\s+"))] @@ -30,105 +44,187 @@ struct Args { pub files: Vec, } -fn fmt_sum(sum: Sum, is_hex: bool) -> String { - if is_hex { - format!("{sum:#X}") - } else { - format!("{sum}") +fn fmt_sum(sum: Sum, radix: Radix) -> String { + match radix { + Radix::Hex => format!("{sum:#X}"), + _ => format!("{sum}"), } } fn main() -> std::result::Result<(), Box> { - env_logger::Builder::from_env(Env::default().default_filter_or("warn")).init(); + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")), + ) + .without_time() + .with_writer(std::io::stderr) + .init(); let args = Args::parse(); - log::debug!("args={args:?}"); + tracing::debug!(?args, "Starting sumcol"); - // Gets a list of input sources. Either the file(s) specified on the command line or stdin. - type Reader = Box; - let readers: Vec = if args.files.is_empty() { + let readers: Vec> = if args.files.is_empty() { vec![Box::new(BufReader::new(io::stdin()))] } else { - let mut v: Vec = vec![]; - for f in args.files { - v.push(Box::new(BufReader::new(fs::File::open(f)?))); - } - v + args.files + .iter() + .map(|f| fs_err::File::open(f).map(|f| Box::new(BufReader::new(f)) as Box)) + .collect::>()? }; let mut sum = Sum::Integer(0); - let mut cnt = 0; // Count of numbers we parse successfully. - for reader in readers { - for (i, line) in reader.lines().enumerate() { - let line = line?.trim().to_string(); - log::debug!("{i}: line={line:?}"); - if line.is_empty() { - continue; - } - let raw_str = match args.field { - 0 => line.as_str(), - f => args.delimiter.split(&line).nth(f - 1).unwrap_or_default(), - }; - // Trim and remove commas. This may break localized numbers. - let clean_str = raw_str.trim().replace(',', ""); - let (clean_str, radix) = match clean_str.strip_prefix("0x") { - Some(s) => (s, 16), - None => (&clean_str as &str, if args.hex { 16 } else { 10 }), - }; - // Holds an optional error string from parsing that we may display in verbose output. - let mut err = None; - let n = match i128::from_str_radix(clean_str, radix) { - Ok(n) => { - cnt += 1; - Sum::Integer(n) - } - Err(e) => { - log::info!("Not integer. {e:?}, clean={clean_str:?}, radix={radix:?}."); - // Try parsing as a float - match clean_str.parse::() { - Ok(n) => { - cnt += 1; - Sum::Float(n) - } - Err(e) => { - log::info!("Not float. {e:?}, clean={clean_str:?}."); - // If it parses as hex, warn the user that they may want to use -x. - if i128::from_str_radix(clean_str, 16).is_ok() { - log::warn!( - "Failed to parse {clean_str:?}, but it may be hex. Consider using -x" - ); - } - err = Some(format!("{e:?}")); - Sum::Integer(0) - } - } - } - }; - sum += n; - if args.verbose { - // Print each number that we're summing, along with some metadata. - let mut metadata = vec![]; - metadata.push(format!("n={}", format!("{:?}", n).bold()).cyan()); - metadata.push(format!("sum={}", format!("{:?}", sum).bold()).cyan()); - metadata.push(format!("cnt={}", format!("{cnt}").bold()).cyan()); - metadata.push(format!("radix={}", format!("{radix}").bold()).cyan()); - metadata.push(format!("raw_str={}", format!("{raw_str:?}").bold()).cyan()); - if let Some(err) = err { - metadata.push(format!("err={}", format!("{err:?}").bold()).red()); - } - print!("{}\t", fmt_sum(n, args.hex)); - ["#".cyan()] - .into_iter() - .chain(metadata.into_iter()) - .for_each(|x| print!(" {}", x)); - println!(); + for line in readers.into_iter().flat_map(|r| r.lines()) { + let line = line?.trim().to_string(); + tracing::debug!(?line, "Read line"); + if line.is_empty() { + continue; + } + let raw_str = match args.field { + 0 => Some(line.as_str()), + f => args.delimiter.split(&line).nth(f - 1), + }; + let Some(raw_str) = raw_str else { + tracing::warn!( + field = args.field, + line, + "Field index out of range, skipping" + ); + continue; + }; + let trimmed = raw_str.trim(); + let clean_str = trimmed.replace(',', ""); + if clean_str != trimmed { + tracing::warn!( + original = trimmed, + clean = clean_str.as_str(), + "Stripped commas from value" + ); + } + let (clean_str, radix) = match (args.radix, clean_str.strip_prefix("0x")) { + (Radix::Decimal, _) => (clean_str.as_str(), Radix::Decimal), + (_, Some(s)) => (s, Radix::Hex), + (Radix::Hex, None) => (clean_str.as_str(), Radix::Hex), + (Radix::Auto, None) => (clean_str.as_str(), Radix::Decimal), + }; + let (n, err) = match parse_value(clean_str, radix) { + Ok(n) => (n, None), + Err(msg) => { + tracing::warn!(?clean_str, "{msg}"); + (Sum::Integer(0), Some(msg)) } + }; + sum += n; + if args.verbose { + let meta = format!("# n={n:?} sum={sum:?} radix={radix:?} raw_str={raw_str:?}").cyan(); + let err_str = err + .map(|e| format!(" err={e:?}").red().to_string()) + .unwrap_or_default(); + println!("{}\t {meta}{err_str}", fmt_sum(n, radix)); } } if args.verbose { println!("{}", "==".cyan()); } - println!("{}", fmt_sum(sum, args.hex)); + println!("{}", fmt_sum(sum, args.radix)); Ok(()) } + +/// Parses `s` according to the given `radix`. With `Radix::Hex`, only integers +/// are accepted (no float fallback) -- this keeps hex mode strict so users can +/// trust that a successful parse means the value was treated as hex. `Radix::Auto` +/// is treated the same as `Radix::Decimal` here; the caller is responsible for +/// resolving 0x-prefix detection before calling. +fn parse_value(s: &str, radix: Radix) -> Result { + let hex = radix == Radix::Hex; + if let Ok(n) = i128::from_str_radix(s, if hex { 16 } else { 10 }) { + return Ok(Sum::Integer(n)); + } + if hex { + return Err("Failed to parse as hex, treating as 0"); + } + if let Ok(n) = s.parse::() { + if !s.contains(['.', 'e', 'E']) { + tracing::warn!( + clean_str = s, + "Value too large for integer, using float (may lose precision)" + ); + } + return Ok(Sum::Float(n)); + } + Err("Failed to parse (use --radix=hex if hex), treating as 0") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_integer_decimal() { + assert_eq!(parse_value("42", Radix::Decimal), Ok(Sum::Integer(42))); + } + + #[test] + fn parse_integer_hex() { + assert_eq!(parse_value("FF", Radix::Hex), Ok(Sum::Integer(255))); + } + + #[test] + fn parse_negative_integer() { + assert_eq!(parse_value("-5", Radix::Decimal), Ok(Sum::Integer(-5))); + } + + #[test] + fn parse_float() { + assert_eq!(parse_value("1.5", Radix::Decimal), Ok(Sum::Float(1.5))); + } + + #[test] + fn parse_negative_float() { + assert_eq!(parse_value("-1.5", Radix::Decimal), Ok(Sum::Float(-1.5))); + } + + #[test] + fn parse_scientific_notation() { + assert_eq!(parse_value("3e0", Radix::Decimal), Ok(Sum::Float(3.0))); + } + + #[test] + fn parse_float_in_hex_mode_fails() { + assert_eq!( + parse_value("1.5", Radix::Hex), + Err("Failed to parse as hex, treating as 0") + ); + } + + #[test] + fn parse_invalid_decimal() { + assert_eq!( + parse_value("OOPS", Radix::Decimal), + Err("Failed to parse (use --radix=hex if hex), treating as 0") + ); + } + + #[test] + fn parse_empty_string() { + assert_eq!( + parse_value("", Radix::Decimal), + Err("Failed to parse (use --radix=hex if hex), treating as 0") + ); + } + + #[test] + fn parse_overflow_falls_back_to_float() { + let result = parse_value("999999999999999999999999999999999999999999", Radix::Decimal); + assert!(matches!(result, Ok(Sum::Float(_)))); + } + + #[test] + fn parse_invalid_hex() { + assert_eq!( + parse_value("GG", Radix::Hex), + Err("Failed to parse as hex, treating as 0") + ); + } +} diff --git a/tests/cli.rs b/tests/cli.rs index b9f28ed..de049c7 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1,5 +1,6 @@ use assert_cmd::Command; use predicates::prelude::*; +use std::io::Write; type TestResult = Result<(), Box>; @@ -45,6 +46,155 @@ fn simple_column_sum() -> TestResult { Ok(()) } +#[test] +fn sum_empty_input() -> TestResult { + let mut cmd = Command::cargo_bin("sumcol")?; + cmd.write_stdin("") + .assert() + .success() + .stdout(predicate::str::contains("0")); + Ok(()) +} + +#[test] +fn sum_field_zero() -> TestResult { + let mut cmd = Command::cargo_bin("sumcol")?; + let input = "1\n2\n3\n"; + cmd.write_stdin(input) + .args(["-f=0"]) + .assert() + .success() + .stdout(predicate::str::contains("6")); + Ok(()) +} + +#[test] +fn sum_field_out_of_range() -> TestResult { + let mut cmd = Command::cargo_bin("sumcol")?; + let input = "1 2 3\n4 5 6\n"; + cmd.write_stdin(input) + .args(["-f=99"]) + .assert() + .success() + .stdout(predicate::str::contains("0")) + .stderr(predicate::str::contains( + "Field index out of range, skipping", + )); + Ok(()) +} + +#[test] +fn sum_negative_integers() -> TestResult { + let mut cmd = Command::cargo_bin("sumcol")?; + cmd.write_stdin("3\n-1\n-2\n") + .assert() + .success() + .stdout(predicate::str::contains("0")); + Ok(()) +} + +#[test] +fn sum_negative_floats() -> TestResult { + let mut cmd = Command::cargo_bin("sumcol")?; + cmd.write_stdin("1.5\n-0.5\n-0.5\n") + .assert() + .success() + .stdout(predicate::str::contains("0.5")); + Ok(()) +} + +#[test] +fn sum_negative_mixed() -> TestResult { + let mut cmd = Command::cargo_bin("sumcol")?; + cmd.write_stdin("3\n-1.5\n") + .assert() + .success() + .stdout(predicate::str::contains("1.5")); + Ok(()) +} + +#[test] +fn sum_comma_numbers() -> TestResult { + let mut cmd = Command::cargo_bin("sumcol")?; + cmd.write_stdin("1,000\n2,000\n3,000\n") + .assert() + .success() + .stdout(predicate::str::contains("6000")) + .stderr(predicate::str::contains("Stripped commas from value")); + Ok(()) +} + +#[test] +fn sum_invalid_0x_prefix() -> TestResult { + let mut cmd = Command::cargo_bin("sumcol")?; + cmd.write_stdin("0xGG\n") + .assert() + .success() + .stdout(predicate::str::contains("0")) + .stderr(predicate::str::contains( + "Failed to parse as hex, treating as 0", + )); + Ok(()) +} + +#[test] +fn sum_field_out_of_range_no_double_warn() -> TestResult { + let mut cmd = Command::cargo_bin("sumcol")?; + cmd.write_stdin("1 2 3\n") + .args(["-f=99"]) + .assert() + .success() + .stderr(predicate::str::contains( + "Field index out of range, skipping", + )) + .stderr( + predicate::str::contains("Failed to parse (use --radix=hex if hex), treating as 0") + .not(), + ); + Ok(()) +} + +#[test] +fn sum_large_integer_warns() -> TestResult { + let mut cmd = Command::cargo_bin("sumcol")?; + // i128::MAX is 170141183460469231731687303715884105727, so one digit more overflows + cmd.write_stdin("999999999999999999999999999999999999999999\n") + .assert() + .success() + .stderr(predicate::str::contains( + "Value too large for integer, using float (may lose precision)", + )); + Ok(()) +} + +#[test] +fn sum_radix_decimal_rejects_0x_prefix() -> TestResult { + // With --radix=decimal, a 0x-prefixed value must not be auto-detected as hex. + let mut cmd = Command::cargo_bin("sumcol")?; + cmd.write_stdin("5\n0xFF\n") + .args(["--radix=decimal"]) + .assert() + .success() + .stdout(predicate::str::contains("5")) + .stderr(predicate::str::contains("Failed to parse")); + Ok(()) +} + +#[test] +fn sum_hex_flag_no_float_fallback() -> TestResult { + // With -x, values that look like floats must not silently fall back to float parsing. + let mut cmd = Command::cargo_bin("sumcol")?; + cmd.write_stdin("1.5\n2.5\n") + .args(["--radix=hex"]) + .assert() + .success() + .stdout(predicate::str::contains("0")) + .stderr(predicate::str::contains( + "Failed to parse as hex, treating as 0", + )); + Ok(()) +} + #[test] fn sum_implicit_hex() -> TestResult { let mut cmd = Command::cargo_bin("sumcol")?; @@ -71,7 +221,7 @@ fn sum_explicit_hex() -> TestResult { hello 0xB foo "; cmd.write_stdin(input) - .args(["-f2", "-x"]) + .args(["-f2", "--radix=hex"]) .assert() .success() .stdout(predicate::str::contains("0x21")); // 33 in decimal @@ -122,7 +272,10 @@ fn sum_mixed_column() -> TestResult { .args(["-f=2"]) .assert() .success() - .stdout(predicate::str::contains("4")); + .stdout(predicate::str::contains("4")) + .stderr(predicate::str::contains( + "Failed to parse (use --radix=hex if hex), treating as 0", + )); Ok(()) } @@ -139,7 +292,9 @@ fn sum_mixed_column_looks_like_number() -> TestResult { .assert() .success() .stdout(predicate::str::contains("4")) - .stderr(predicate::str::contains("Consider using")); + .stderr(predicate::str::contains( + "Failed to parse (use --radix=hex if hex), treating as 0", + )); Ok(()) } @@ -161,10 +316,13 @@ fn sum_float() -> TestResult { // With -x, 3e0 will be parsed as 0x3e0 (decimal 992) let mut cmd = Command::cargo_bin("sumcol")?; cmd.write_stdin(input) - .args(["-f=2", "-x"]) + .args(["-f=2", "--radix=hex"]) .assert() .success() - .stdout(predicate::str::contains("997.2")); + .stdout(predicate::str::contains("0x3E2")) // 2 + 0x3e0=992 = 994; 1.0 and 2.2 fail hex + .stderr(predicate::str::contains( + "Failed to parse as hex, treating as 0", + )); Ok(()) } @@ -201,13 +359,49 @@ fn sum_float_hex_flag() -> TestResult { .success() .stdout(predicate::str::contains("5.2")); - // With -x, A will be treated as 0xA + // With -x, A=10 and 2 are parsed as hex; 1.0 and 2.2 fail hex parsing and warn. let mut cmd = Command::cargo_bin("sumcol")?; cmd.write_stdin(input) - .args(["-f=2", "-x"]) + .args(["-f=2", "--radix=hex"]) .assert() .success() - .stdout(predicate::str::contains("15.2")); + .stdout(predicate::str::contains("0xC")) + .stderr(predicate::str::contains( + "Failed to parse as hex, treating as 0", + )); + Ok(()) +} + +#[test] +fn sum_nonexistent_file() -> TestResult { + let mut cmd = Command::cargo_bin("sumcol")?; + cmd.arg("/nonexistent/path/to/file.txt").assert().failure(); + Ok(()) +} + +#[test] +fn sum_one_file() -> TestResult { + let mut file = tempfile::NamedTempFile::new()?; + writeln!(file, "1\n2\n3")?; + let mut cmd = Command::cargo_bin("sumcol")?; + cmd.arg(file.path()) + .assert() + .success() + .stdout(predicate::str::contains("6")); + Ok(()) +} + +#[test] +fn sum_two_files() -> TestResult { + let mut file1 = tempfile::NamedTempFile::new()?; + writeln!(file1, "1\n2\n3")?; + let mut file2 = tempfile::NamedTempFile::new()?; + writeln!(file2, "4\n5\n6")?; + let mut cmd = Command::cargo_bin("sumcol")?; + cmd.args([file1.path(), file2.path()]) + .assert() + .success() + .stdout(predicate::str::contains("21")); Ok(()) } @@ -227,13 +421,13 @@ fn sum_verbose_flag() -> TestResult { .assert() .success() .stdout(predicate::str::contains( - r#"n=Integer(2) sum=Integer(2) cnt=1 radix=10 raw_str="2""#, + r#"n=Integer(2) sum=Integer(2) radix=Decimal raw_str="2""#, )) .stdout(predicate::str::contains( - r#"n=Integer(0) sum=Integer(2) cnt=1 radix=10 raw_str="OOPS" err="ParseFloatError { kind: Invalid }""#, + r#"n=Integer(0) sum=Integer(2) radix=Decimal raw_str="OOPS" err="Failed to parse (use --radix=hex if hex), treating as 0""#, )) .stdout(predicate::str::contains( - r#"n=Float(2.2) sum=Float(5.2) cnt=3 radix=10 raw_str="2.2""# + r#"n=Float(2.2) sum=Float(5.2) radix=Decimal raw_str="2.2""# )) .stdout(predicate::str::contains( r#"=="#))