diff --git a/README.md b/README.md index 176c677..ade48a9 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,9 @@ Small Unix-style tools for exploring the shape of timestamped data — waveforms distributions, co-occurrence, and window coverage in logs and time series. Written in Rust, born out of incident triage. -Each tool reads from stdin, does one thing, and writes to stdout — so they -compose naturally with `grep` / `jq` / `awk` and with each other. +Each tool does one thing and writes to stdout — so they compose naturally with +`grep` / `jq` / `awk` and with each other. Most read stdin; the ones that take +several inputs (`ts-correlate`, `ts-align`, `ts-timeline`) read files instead. ## Tools @@ -263,8 +264,10 @@ ts-align req.txt lat.txt tasks.txt \ ``` where req / lat / tasks are CloudWatch RequestCount (Sum), TargetResponseTime -(Average) and LiveTaskCount (Minimum). A `common N / per-file totals` line goes to -stderr so dropped (non-common) timestamps are visible. +(Average) and LiveTaskCount (Minimum). A +`N timestamps common to all K series (file=count, …)` line goes to stderr so +dropped (non-common) timestamps are visible. Unparseable lines and duplicate +timestamps (last value kept) are reported per file on stderr too. ## ts-timeline diff --git a/crates/align/src/main.rs b/crates/align/src/main.rs index 351fe9a..199560d 100644 --- a/crates/align/src/main.rs +++ b/crates/align/src/main.rs @@ -14,6 +14,7 @@ //! | ts-series use std::collections::BTreeMap; +use std::io::{self, Write}; use std::process::ExitCode; use align::inner_join; @@ -80,15 +81,26 @@ fn run() -> Result { let rows = inner_join(&maps); - for (instant, values) in &rows { - // render in the input's display offset; values go out at full precision - // (align feeds downstream math — rounding here would corrupt it) - let mut line = instant.with_timezone(&offset).to_rfc3339(); - for v in values { - line.push('\t'); - line.push_str(&v.to_string()); + let mut out = io::BufWriter::new(io::stdout().lock()); + let written = rows + .iter() + .try_for_each(|(instant, values)| { + // render in the input's display offset; values go out at full precision + // (align feeds downstream math — rounding here would corrupt it) + let mut line = instant.with_timezone(&offset).to_rfc3339(); + for v in values { + line.push('\t'); + line.push_str(&v.to_string()); + } + writeln!(out, "{line}") + }) + .and_then(|()| out.flush()); + if let Err(e) = written { + // Downstream closed the pipe (e.g. `| head`): stop quietly. + if e.kind() == io::ErrorKind::BrokenPipe { + return Ok(ExitCode::SUCCESS); } - println!("{line}"); + return Err(format!("write error: {e}")); } // report what the inner join dropped, so non-common timestamps aren't silent @@ -109,18 +121,38 @@ fn run() -> Result { /// Read a file of `timestamp value` points into a map keyed by UTC instant /// (last value wins on a duplicate timestamp), plus the input's offset and point /// count. Errors if the file holds no points. +/// +/// Unparseable lines and duplicate timestamps are reported on stderr: the +/// per-file count is of distinct timestamps, so without this both would vanish +/// from the summary unnoticed. fn load(path: &str) -> Result<(Series, FixedOffset, usize), String> { let text = std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?; let mut map = BTreeMap::new(); let mut offset = None; + let (mut skipped, mut duplicates) = (0u64, 0u64); for line in text.lines() { - if let Some((dt, value)) = parse_point(line) { - offset.get_or_insert_with(|| *dt.offset()); - // key by the UTC instant (full sub-second precision): truncating to - // seconds would collide distinct points and join non-equal instants - map.insert(dt.to_utc(), value); + match parse_point(line) { + Some((dt, value)) => { + offset.get_or_insert_with(|| *dt.offset()); + // key by the UTC instant (full sub-second precision): truncating to + // seconds would collide distinct points and join non-equal instants + if map.insert(dt.to_utc(), value).is_some() { + duplicates += 1; + } + } + None => { + if !line.trim().is_empty() { + skipped += 1; + } + } } } + if skipped > 0 { + eprintln!("ts-align: {path}: skipped {skipped} unparseable line(s)"); + } + if duplicates > 0 { + eprintln!("ts-align: {path}: {duplicates} duplicate timestamp(s), last value kept"); + } match offset { Some(off) => { let n = map.len(); diff --git a/crates/align/tests/cli.rs b/crates/align/tests/cli.rs index 290d18d..cbbb8f3 100644 --- a/crates/align/tests/cli.rs +++ b/crates/align/tests/cli.rs @@ -1,7 +1,7 @@ //! End-to-end smoke tests: run the built binary against fixture files. //! Uses `CARGO_BIN_EXE_ts-align` and `CARGO_MANIFEST_DIR` (set by Cargo). -use std::process::{Command, Output}; +use std::process::{Command, Output, Stdio}; fn run(args: &[&str]) -> Output { Command::new(env!("CARGO_BIN_EXE_ts-align")) @@ -76,3 +76,42 @@ fn needs_two_files() { let stderr = String::from_utf8(out.stderr).unwrap(); assert!(stderr.contains("at least two files"), "stderr:\n{stderr}"); } + +#[test] +fn reports_skipped_lines_and_duplicates() { + let a = fixture("dup.txt"); + let out = run(&[a.as_str(), a.as_str()]); + assert!(out.status.success()); + let stdout = String::from_utf8(out.stdout).unwrap(); + // last value wins on the duplicated 10:20:00 + assert!( + stdout.starts_with("2026-09-03T10:20:00+09:00\t2\t2\n"), + "stdout:\n{stdout}" + ); + let stderr = String::from_utf8(out.stderr).unwrap(); + assert!( + stderr.contains("dup.txt: skipped 1 unparseable line(s)"), + "stderr:\n{stderr}" + ); + assert!( + stderr.contains("dup.txt: 1 duplicate timestamp(s), last value kept"), + "stderr:\n{stderr}" + ); +} + +#[test] +fn closed_stdout_exits_quietly() { + // reader dropped before spawn, so every write hits a closed pipe (`| head`) + let (reader, writer) = std::io::pipe().unwrap(); + drop(reader); + let req = fixture("req.txt"); + let out = Command::new(env!("CARGO_BIN_EXE_ts-align")) + .args([req.as_str(), req.as_str()]) + .stdout(writer) + .stderr(Stdio::piped()) + .output() + .expect("run align"); + let stderr = String::from_utf8(out.stderr).unwrap(); + assert!(out.status.success(), "stderr:\n{stderr}"); + assert!(!stderr.contains("Broken pipe"), "stderr:\n{stderr}"); +} diff --git a/crates/align/tests/fixtures/dup.txt b/crates/align/tests/fixtures/dup.txt new file mode 100644 index 0000000..66fd5b1 --- /dev/null +++ b/crates/align/tests/fixtures/dup.txt @@ -0,0 +1,5 @@ +2026-09-03T10:20:00+09:00 1 +2026-09-03T10:20:00+09:00 2 +not a point + +2026-09-03T10:21:00+09:00 3 diff --git a/crates/burst/src/main.rs b/crates/burst/src/main.rs index 0bd5831..7fcdb27 100644 --- a/crates/burst/src/main.rs +++ b/crates/burst/src/main.rs @@ -14,6 +14,7 @@ use std::collections::BTreeMap; use std::io::{self, BufRead, Write}; +use std::process::ExitCode; use burst::{Summary, counts_by_second, summarize}; use chrono::{FixedOffset, TimeZone}; @@ -39,16 +40,16 @@ plus total / peak / first / last / span. All lines must share one UTC offset. example: jq -r 'select(.status==502) | .time' alb.jsonl | ts-burst"; -fn main() -> io::Result<()> { +fn main() -> ExitCode { if let Some(arg) = std::env::args().nth(1) { match arg.as_str() { "-h" | "--help" => { println!("{HELP}"); - return Ok(()); + return ExitCode::SUCCESS; } other => { eprintln!("ts-burst: unexpected argument: {other}\n\n{HELP}"); - std::process::exit(1); + return ExitCode::FAILURE; } } } @@ -58,7 +59,13 @@ fn main() -> io::Result<()> { let stdin = io::stdin(); for line in stdin.lock().lines() { - let line = line?; + let line = match line { + Ok(line) => line, + Err(e) => { + eprintln!("ts-burst: read error: {e}"); + return ExitCode::FAILURE; + } + }; match parse_line(&line) { Some(dt) => { let this = *dt.offset(); @@ -68,7 +75,7 @@ fn main() -> io::Result<()> { // times stay aligned with the source log. Some(first) if first != this => { eprintln!("ts-burst: mixed UTC offsets: {first} and {this}"); - std::process::exit(1); + return ExitCode::FAILURE; } Some(_) => {} } @@ -92,12 +99,20 @@ fn main() -> io::Result<()> { "ts-burst: no parseable timestamps on stdin \ (expected RFC3339 with offset, e.g. 2026-09-03T10:21:31.124+09:00)" ); - std::process::exit(1); + return ExitCode::FAILURE; }; // summarize returned Some, so at least one line parsed and set the offset. let offset = offset.expect("offset is set whenever a timestamp parses"); - render(&counts, &summary, offset) + if let Err(e) = render(&counts, &summary, offset) { + // Downstream closed the pipe (e.g. `| head`): stop quietly. + if e.kind() == io::ErrorKind::BrokenPipe { + return ExitCode::SUCCESS; + } + eprintln!("ts-burst: write error: {e}"); + return ExitCode::FAILURE; + } + ExitCode::SUCCESS } fn render(counts: &BTreeMap, summary: &Summary, offset: FixedOffset) -> io::Result<()> { @@ -109,8 +124,7 @@ fn render(counts: &BTreeMap, summary: &Summary, offset: FixedOffset) - ); } - let stdout = io::stdout(); - let mut out = stdout.lock(); + let mut out = io::BufWriter::new(io::stdout().lock()); let scale = summary.peak_count.max(1); if fill { @@ -140,7 +154,7 @@ fn render(counts: &BTreeMap, summary: &Summary, offset: FixedOffset) - writeln!(out, "first {}", fmt_time(offset, summary.first_sec))?; writeln!(out, "last {}", fmt_time(offset, summary.last_sec))?; writeln!(out, "span {span}s (first→last)")?; - Ok(()) + out.flush() } fn write_row( diff --git a/crates/burst/tests/cli.rs b/crates/burst/tests/cli.rs index 96baf36..b2fe7c4 100644 --- a/crates/burst/tests/cli.rs +++ b/crates/burst/tests/cli.rs @@ -54,3 +54,25 @@ fn empty_input_fails_cleanly() { "stderr:\n{stderr}" ); } + +#[test] +fn closed_stdout_exits_quietly() { + // reader dropped before spawn, so every write hits a closed pipe (`| head`) + let (reader, writer) = std::io::pipe().unwrap(); + drop(reader); + let mut child = Command::new(env!("CARGO_BIN_EXE_ts-burst")) + .stdin(Stdio::piped()) + .stdout(writer) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn burst"); + let _ = child + .stdin + .take() + .unwrap() + .write_all(include_str!("fixtures/simple.txt").as_bytes()); + let out = child.wait_with_output().expect("wait burst"); + let stderr = String::from_utf8(out.stderr).unwrap(); + assert!(out.status.success(), "stderr:\n{stderr}"); + assert!(!stderr.contains("Broken pipe"), "stderr:\n{stderr}"); +} diff --git a/crates/correlate/src/main.rs b/crates/correlate/src/main.rs index 255bd12..bd90f1b 100644 --- a/crates/correlate/src/main.rs +++ b/crates/correlate/src/main.rs @@ -43,7 +43,12 @@ fn run() -> Result { let mut args = std::env::args().skip(1); while let Some(arg) = args.next() { match arg.as_str() { - "--window" => window_arg = Some(args.next().ok_or("--window requires a value")?), + "--window" => { + if window_arg.is_some() { + return Err("--window given more than once".into()); + } + window_arg = Some(args.next().ok_or("--window requires a value")?); + } "-h" | "--help" => { println!("{HELP}"); return Ok(ExitCode::SUCCESS); diff --git a/crates/correlate/tests/cli.rs b/crates/correlate/tests/cli.rs index 036c538..f4dda75 100644 --- a/crates/correlate/tests/cli.rs +++ b/crates/correlate/tests/cli.rs @@ -68,3 +68,16 @@ fn rejects_bad_window() { let stderr = String::from_utf8(out.stderr).unwrap(); assert!(stderr.contains("invalid duration"), "stderr:\n{stderr}"); } + +#[test] +fn rejects_repeated_window() { + let a = fixture("a.txt"); + let b = fixture("b.txt"); + let out = run(&[a.as_str(), b.as_str(), "--window", "5s", "--window", "1s"]); + assert!(!out.status.success()); + let stderr = String::from_utf8(out.stderr).unwrap(); + assert!( + stderr.contains("--window given more than once"), + "stderr:\n{stderr}" + ); +} diff --git a/crates/dist/src/main.rs b/crates/dist/src/main.rs index 12490f2..76cd8a0 100644 --- a/crates/dist/src/main.rs +++ b/crates/dist/src/main.rs @@ -8,10 +8,10 @@ //! jq -r .target_processing_time alb.jsonl | ts-dist //! grep responseTime app.log | awk '{print $NF}' | ts-dist -use std::io::{self, BufRead}; +use std::io::{self, BufRead, Write}; use std::process::ExitCode; -use dist::{parse_number, summarize}; +use dist::{Stats, parse_number, summarize}; const HELP: &str = "\ ts-dist — summarize the distribution of a column of numbers @@ -69,14 +69,26 @@ fn main() -> ExitCode { return ExitCode::FAILURE; }; - println!("count {:>10}", s.count); - println!("min {:>10.3}", s.min); - println!("mean {:>10.3}", s.mean); - println!("p50 {:>10.3}", s.p50); - println!("p90 {:>10.3}", s.p90); - println!("p95 {:>10.3}", s.p95); - println!("p99 {:>10.3}", s.p99); - println!("max {:>10.3}", s.max); - println!("stddev {:>10.3}", s.stddev); + let mut out = io::BufWriter::new(io::stdout().lock()); + if let Err(e) = render(&mut out, &s).and_then(|()| out.flush()) { + // Downstream closed the pipe (e.g. `| head`): stop quietly. + if e.kind() == io::ErrorKind::BrokenPipe { + return ExitCode::SUCCESS; + } + eprintln!("ts-dist: write error: {e}"); + return ExitCode::FAILURE; + } ExitCode::SUCCESS } + +fn render(out: &mut impl Write, s: &Stats) -> io::Result<()> { + writeln!(out, "count {:>10}", s.count)?; + writeln!(out, "min {:>10.3}", s.min)?; + writeln!(out, "mean {:>10.3}", s.mean)?; + writeln!(out, "p50 {:>10.3}", s.p50)?; + writeln!(out, "p90 {:>10.3}", s.p90)?; + writeln!(out, "p95 {:>10.3}", s.p95)?; + writeln!(out, "p99 {:>10.3}", s.p99)?; + writeln!(out, "max {:>10.3}", s.max)?; + writeln!(out, "stddev {:>10.3}", s.stddev) +} diff --git a/crates/dist/tests/cli.rs b/crates/dist/tests/cli.rs index fa57247..8ce187b 100644 --- a/crates/dist/tests/cli.rs +++ b/crates/dist/tests/cli.rs @@ -40,3 +40,25 @@ fn empty_input_fails_cleanly() { let stderr = String::from_utf8(out.stderr).unwrap(); assert!(stderr.contains("no numbers"), "stderr:\n{stderr}"); } + +#[test] +fn closed_stdout_exits_quietly() { + // reader dropped before spawn, so every write hits a closed pipe (`| head`) + let (reader, writer) = std::io::pipe().unwrap(); + drop(reader); + let mut child = Command::new(env!("CARGO_BIN_EXE_ts-dist")) + .stdin(Stdio::piped()) + .stdout(writer) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn dist"); + let _ = child + .stdin + .take() + .unwrap() + .write_all(include_str!("fixtures/numbers.txt").as_bytes()); + let out = child.wait_with_output().expect("wait dist"); + let stderr = String::from_utf8(out.stderr).unwrap(); + assert!(out.status.success(), "stderr:\n{stderr}"); + assert!(!stderr.contains("Broken pipe"), "stderr:\n{stderr}"); +} diff --git a/crates/series/src/main.rs b/crates/series/src/main.rs index f8d918b..30fc1f4 100644 --- a/crates/series/src/main.rs +++ b/crates/series/src/main.rs @@ -11,7 +11,7 @@ //! Times are shown in the input's offset (like `burst`), and all points must //! share one offset — mixed offsets are rejected. -use std::io::{self, BufRead}; +use std::io::{self, BufRead, Write}; use std::process::ExitCode; use chrono::{DateTime, FixedOffset}; @@ -90,11 +90,19 @@ fn main() -> ExitCode { } points.sort_by_key(|(ts, _)| *ts); - render(&points); + let mut out = io::BufWriter::new(io::stdout().lock()); + if let Err(e) = render(&mut out, &points).and_then(|()| out.flush()) { + // Downstream closed the pipe (e.g. `| head`): stop quietly. + if e.kind() == io::ErrorKind::BrokenPipe { + return ExitCode::SUCCESS; + } + eprintln!("ts-series: write error: {e}"); + return ExitCode::FAILURE; + } ExitCode::SUCCESS } -fn render(points: &[(DateTime, f64)]) { +fn render(out: &mut impl Write, points: &[(DateTime, f64)]) -> io::Result<()> { let min = points.iter().map(|(_, v)| *v).fold(f64::INFINITY, f64::min); let max = points .iter() @@ -114,24 +122,40 @@ fn render(points: &[(DateTime, f64)]) { .unwrap(); for (ts, value) in points { - println!( + writeln!( + out, "{} {} {:>10}", fmt_time(*ts), RAMP[block_index(*value, min, max)], fmt_val(*value) - ); + )?; } - println!(); - println!("points {:>10}", points.len()); - println!("sum {:>10}", fmt_val(sum)); - println!("min {:>10} at {}", fmt_val(min), fmt_time(min_ts)); - println!("max {:>10} at {}", fmt_val(max), fmt_time(max_ts)); + writeln!(out)?; + writeln!(out, "points {:>10}", points.len())?; + writeln!(out, "sum {:>10}", fmt_val(sum))?; + writeln!( + out, + "min {:>10} at {}", + fmt_val(min), + fmt_time(min_ts) + )?; + writeln!( + out, + "max {:>10} at {}", + fmt_val(max), + fmt_time(max_ts) + )?; + Ok(()) } /// Format an integer-valued float without a decimal tail (`103`, not `103.000`), /// otherwise with three decimals (`0.145`). fn fmt_val(v: f64) -> String { + if v == 0.0 { + // -0.0 compares equal to 0.0 but formats as "-0" + return "0".to_string(); + } if v.fract() == 0.0 && v.abs() < 1e15 { format!("{v:.0}") } else { diff --git a/crates/series/tests/cli.rs b/crates/series/tests/cli.rs index 10f1a96..1b0a41c 100644 --- a/crates/series/tests/cli.rs +++ b/crates/series/tests/cli.rs @@ -48,6 +48,15 @@ fn sorts_within_the_same_second() { assert_eq!(values, ["1", "9"], "stdout:\n{stdout}"); } +#[test] +fn negative_zero_prints_as_zero() { + let out = run("2026-09-03T10:20:00+09:00 -0\n2026-09-03T10:21:00+09:00 1\n"); + assert!(out.status.success()); + let stdout = String::from_utf8(out.stdout).unwrap(); + assert!(!stdout.contains("-0"), "stdout:\n{stdout}"); + assert!(stdout.contains("0 at 10:20:00"), "stdout:\n{stdout}"); +} + #[test] fn skips_garbage_but_still_succeeds() { let out = run(include_str!("fixtures/invalid.txt")); @@ -63,3 +72,25 @@ fn empty_input_fails_cleanly() { let stderr = String::from_utf8(out.stderr).unwrap(); assert!(stderr.contains("no 'timestamp value'"), "stderr:\n{stderr}"); } + +#[test] +fn closed_stdout_exits_quietly() { + // reader dropped before spawn, so every write hits a closed pipe (`| head`) + let (reader, writer) = std::io::pipe().unwrap(); + drop(reader); + let mut child = Command::new(env!("CARGO_BIN_EXE_ts-series")) + .stdin(Stdio::piped()) + .stdout(writer) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn series"); + let _ = child + .stdin + .take() + .unwrap() + .write_all(include_str!("fixtures/series.txt").as_bytes()); + let out = child.wait_with_output().expect("wait series"); + let stderr = String::from_utf8(out.stderr).unwrap(); + assert!(out.status.success(), "stderr:\n{stderr}"); + assert!(!stderr.contains("Broken pipe"), "stderr:\n{stderr}"); +} diff --git a/crates/timeline/src/lib.rs b/crates/timeline/src/lib.rs index 268d4a6..48efaa6 100644 --- a/crates/timeline/src/lib.rs +++ b/crates/timeline/src/lib.rs @@ -218,7 +218,7 @@ fn fmt_minmax(min: f64, max: f64) -> (String, String) { // compare the rounded labels as numbers, not as strings: "1" and "1.000" // read as the same value, so a distinct min/max must still fall back to full. let reads_same = lo.parse::().ok() == hi.parse::().ok(); - let values_differ = format!("{min}") != format!("{max}"); + let values_differ = min != max; if reads_same && values_differ { return (format!("{min}"), format!("{max}")); } @@ -226,6 +226,10 @@ fn fmt_minmax(min: f64, max: f64) -> (String, String) { } fn fmt_val(v: f64) -> String { + if v == 0.0 { + // -0.0 compares equal to 0.0 but formats as "-0" + return "0".to_string(); + } if v.fract() == 0.0 && v.abs() < 1e15 { return format!("{v:.0}"); } @@ -336,6 +340,7 @@ mod tests { assert_eq!(fmt_val(3.049744988363931), "3.050"); assert_eq!(fmt_val(13158.0), "13158"); assert_eq!(fmt_val(0.0004), "0.0004"); // tiny: not shown as 0.000 + assert_eq!(fmt_val(-0.0), "0"); // distinct values that both round to 1.000 fall back to full assert_eq!( fmt_minmax(1.0001, 1.0002), @@ -348,6 +353,8 @@ mod tests { ); // a genuinely flat series shows them equal assert_eq!(fmt_minmax(5.0, 5.0), ("5".to_string(), "5".to_string())); + // -0 and 0 are the same value: no fallback to "-0" + assert_eq!(fmt_minmax(-0.0, 0.0), ("0".to_string(), "0".to_string())); } #[test] diff --git a/crates/timeline/src/main.rs b/crates/timeline/src/main.rs index 4e54145..81df885 100644 --- a/crates/timeline/src/main.rs +++ b/crates/timeline/src/main.rs @@ -51,6 +51,9 @@ fn run() -> Result { match arg.as_str() { "--series" => files.push(args.next().ok_or("--series requires a file")?), "--display-offset" => { + if display_offset.is_some() { + return Err("--display-offset given more than once".into()); + } let v = args.next().ok_or("--display-offset requires a value")?; display_offset = Some(parse_offset(&v)?); } diff --git a/crates/timeline/tests/cli.rs b/crates/timeline/tests/cli.rs index 14ccdd1..216e82e 100644 --- a/crates/timeline/tests/cli.rs +++ b/crates/timeline/tests/cli.rs @@ -88,3 +88,22 @@ fn errors_on_a_file_with_no_points() { "stderr:\n{stderr}" ); } + +#[test] +fn rejects_repeated_display_offset() { + let cpu = fixture("cpu.txt"); + let out = run(&[ + "--display-offset", + "+09:00", + "--display-offset", + "+00:00", + "--series", + cpu.as_str(), + ]); + assert!(!out.status.success()); + let stderr = String::from_utf8(out.stderr).unwrap(); + assert!( + stderr.contains("--display-offset given more than once"), + "stderr:\n{stderr}" + ); +} diff --git a/crates/window-audit/src/main.rs b/crates/window-audit/src/main.rs index 367594e..233453e 100644 --- a/crates/window-audit/src/main.rs +++ b/crates/window-audit/src/main.rs @@ -55,14 +55,23 @@ fn parse_args() -> Result { while let Some(arg) = args.next() { match arg.as_str() { "--from" => { + if from.is_some() { + return Err("--from given more than once".into()); + } let v = args.next().ok_or("--from requires an RFC3339 value")?; from = Some(DateTime::parse_from_rfc3339(&v).map_err(|e| format!("--from: {e}"))?); } "--to" => { + if to.is_some() { + return Err("--to given more than once".into()); + } let v = args.next().ok_or("--to requires an RFC3339 value")?; to = Some(DateTime::parse_from_rfc3339(&v).map_err(|e| format!("--to: {e}"))?); } "--limit" => { + if limit.is_some() { + return Err("--limit given more than once".into()); + } let v = args.next().ok_or("--limit requires a number")?; let n = v.parse::().map_err(|e| format!("--limit: {e}"))?; if n == 0 { diff --git a/crates/window-audit/tests/cli.rs b/crates/window-audit/tests/cli.rs index 5e79f34..5ce1ad1 100644 --- a/crates/window-audit/tests/cli.rs +++ b/crates/window-audit/tests/cli.rs @@ -158,3 +158,14 @@ fn rejects_zero_limit() { let stderr = String::from_utf8(out.stderr).unwrap(); assert!(stderr.contains("--limit must be a positive"), "{stderr}"); } + +#[test] +fn rejects_repeated_flag() { + let out = run(&with(&["--limit", "10", "--limit", "20"]), ""); + assert!(!out.status.success()); + let stderr = String::from_utf8(out.stderr).unwrap(); + assert!( + stderr.contains("--limit given more than once"), + "stderr:\n{stderr}" + ); +} diff --git a/crates/window/src/main.rs b/crates/window/src/main.rs index f5e7c7a..0dad7b6 100644 --- a/crates/window/src/main.rs +++ b/crates/window/src/main.rs @@ -47,14 +47,23 @@ fn parse_args() -> Result { while let Some(arg) = args.next() { match arg.as_str() { "--from" => { + if from.is_some() { + return Err("--from given more than once".into()); + } let v = args.next().ok_or("--from requires an RFC3339 value")?; from = Some(DateTime::parse_from_rfc3339(&v).map_err(|e| format!("--from: {e}"))?); } "--to" => { + if to.is_some() { + return Err("--to given more than once".into()); + } let v = args.next().ok_or("--to requires an RFC3339 value")?; to = Some(DateTime::parse_from_rfc3339(&v).map_err(|e| format!("--to: {e}"))?); } "--field" => { + if field.is_some() { + return Err("--field given more than once".into()); + } let name = args.next().ok_or("--field requires a field name")?; if name.is_empty() { return Err("--field requires a non-empty field name".into()); diff --git a/crates/window/tests/cli.rs b/crates/window/tests/cli.rs index e1f1e07..59546e3 100644 --- a/crates/window/tests/cli.rs +++ b/crates/window/tests/cli.rs @@ -100,3 +100,22 @@ fn rejects_empty_field() { let stderr = String::from_utf8(out.stderr).unwrap(); assert!(stderr.contains("non-empty field name"), "stderr:\n{stderr}"); } + +#[test] +fn rejects_repeated_flag() { + let out = run( + &[ + "--from", + "2026-09-03T10:21:30+09:00", + "--from", + "2026-09-03T10:00:00+09:00", + ], + "", + ); + assert!(!out.status.success()); + let stderr = String::from_utf8(out.stderr).unwrap(); + assert!( + stderr.contains("--from given more than once"), + "stderr:\n{stderr}" + ); +}