From 84841340ab06f0d373567649d6d2dd054fd4d44b Mon Sep 17 00:00:00 2001 From: honbin Date: Tue, 20 Oct 2026 08:33:52 +0900 Subject: [PATCH 1/6] Handle closed stdout quietly; report align's dropped input ts-series and ts-align panicked (exit 101) and ts-burst exited 1 with a raw `Error: Os { .. BrokenPipe .. }` when piped into `head`; ts-dist had the same latent issue. They now write through a BufWriter and treat BrokenPipe as a clean stop, matching ts-window / ts-window-audit / ts-timeline. ts-align silently ignored unparseable lines and collapsed duplicate timestamps, so its per-file counts hid both. It now reports each per file on stderr, like the other tools' `skipped N` line. --- README.md | 3 +- crates/align/src/main.rs | 58 ++++++++++++++++++++++------- crates/align/tests/cli.rs | 41 +++++++++++++++++++- crates/align/tests/fixtures/dup.txt | 5 +++ crates/burst/src/main.rs | 11 ++++-- crates/burst/tests/cli.rs | 22 +++++++++++ crates/dist/src/main.rs | 34 +++++++++++------ crates/dist/tests/cli.rs | 22 +++++++++++ crates/series/src/main.rs | 40 +++++++++++++++----- crates/series/tests/cli.rs | 22 +++++++++++ 10 files changed, 218 insertions(+), 40 deletions(-) create mode 100644 crates/align/tests/fixtures/dup.txt diff --git a/README.md b/README.md index 176c677..5fa4cbf 100644 --- a/README.md +++ b/README.md @@ -264,7 +264,8 @@ 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. +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..7cf4de7 100644 --- a/crates/burst/src/main.rs +++ b/crates/burst/src/main.rs @@ -97,7 +97,11 @@ fn main() -> io::Result<()> { // 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) + match render(&counts, &summary, offset) { + // Downstream closed the pipe (e.g. `| head`): stop quietly. + Err(e) if e.kind() == io::ErrorKind::BrokenPipe => Ok(()), + result => result, + } } fn render(counts: &BTreeMap, summary: &Summary, offset: FixedOffset) -> io::Result<()> { @@ -109,8 +113,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 +143,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/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..c50fa99 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,19 +122,31 @@ 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`), diff --git a/crates/series/tests/cli.rs b/crates/series/tests/cli.rs index 10f1a96..773538a 100644 --- a/crates/series/tests/cli.rs +++ b/crates/series/tests/cli.rs @@ -63,3 +63,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}"); +} From 2962379e66cced3f04d12dbfe73a8b4921a18a9b Mon Sep 17 00:00:00 2001 From: honbin Date: Tue, 20 Oct 2026 08:35:34 +0900 Subject: [PATCH 2/6] burst: return ExitCode like the other tools main mixed io::Result with std::process::exit; read and write errors now print a ts-burst: message instead of Rust's Debug-formatted Error. --- crates/burst/src/main.rs | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/crates/burst/src/main.rs b/crates/burst/src/main.rs index 7cf4de7..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,16 +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"); - match render(&counts, &summary, offset) { + if let Err(e) = render(&counts, &summary, offset) { // Downstream closed the pipe (e.g. `| head`): stop quietly. - Err(e) if e.kind() == io::ErrorKind::BrokenPipe => Ok(()), - result => result, + 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<()> { From d3b8afb056a1ff5e58b786d1a230284d626c3b88 Mon Sep 17 00:00:00 2001 From: honbin Date: Tue, 20 Oct 2026 08:35:55 +0900 Subject: [PATCH 3/6] series, timeline: print -0 as 0 fmt_val's integer branch rendered -0.0 as "-0" (e.g. a CloudWatch value of -0). Tooltips in timeline still show the raw value. --- crates/series/src/main.rs | 4 ++++ crates/series/tests/cli.rs | 9 +++++++++ crates/timeline/src/lib.rs | 5 +++++ 3 files changed, 18 insertions(+) diff --git a/crates/series/src/main.rs b/crates/series/src/main.rs index c50fa99..30fc1f4 100644 --- a/crates/series/src/main.rs +++ b/crates/series/src/main.rs @@ -152,6 +152,10 @@ fn render(out: &mut impl Write, points: &[(DateTime, f64)]) -> io:: /// 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 773538a..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")); diff --git a/crates/timeline/src/lib.rs b/crates/timeline/src/lib.rs index 268d4a6..630b7f2 100644 --- a/crates/timeline/src/lib.rs +++ b/crates/timeline/src/lib.rs @@ -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), From a3db5644941ec646539720c8b877c512ba080c04 Mon Sep 17 00:00:00 2001 From: honbin Date: Tue, 20 Oct 2026 08:36:25 +0900 Subject: [PATCH 4/6] Reject single-value flags given more than once --from/--to/--field (window), --from/--to/--limit (window-audit), --window (correlate) and --display-offset (timeline) silently kept the last value, so a mistyped repeat changed the query without notice. --series stays repeatable. --- crates/correlate/src/main.rs | 7 ++++++- crates/correlate/tests/cli.rs | 13 +++++++++++++ crates/timeline/src/main.rs | 3 +++ crates/timeline/tests/cli.rs | 19 +++++++++++++++++++ crates/window-audit/src/main.rs | 9 +++++++++ crates/window-audit/tests/cli.rs | 11 +++++++++++ crates/window/src/main.rs | 9 +++++++++ crates/window/tests/cli.rs | 19 +++++++++++++++++++ 8 files changed, 89 insertions(+), 1 deletion(-) 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/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}" + ); +} From 872a5e4e3e06d36abcabfedbfd96bc3e5a5622ba Mon Sep 17 00:00:00 2001 From: honbin Date: Tue, 20 Oct 2026 08:36:34 +0900 Subject: [PATCH 5/6] README: say which tools read files, match align's stderr wording --- README.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 5fa4cbf..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,9 +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. Unparseable lines and -duplicate timestamps (last value kept) are reported per file on stderr too. +(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 From 2d2c5301334c174f31e3622070296256b7f47358 Mon Sep 17 00:00:00 2001 From: honbin Date: Tue, 20 Oct 2026 08:40:23 +0900 Subject: [PATCH 6/6] timeline: compare min/max numerically in fmt_minmax MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fallback compared Display strings, so -0 and 0 counted as distinct and the label read "min -0 · max 0" despite fmt_val normalizing -0. --- crates/timeline/src/lib.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/timeline/src/lib.rs b/crates/timeline/src/lib.rs index 630b7f2..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}")); } @@ -353,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]