From ac1389e2e54ec366a6b4b14144596346b8e78836 Mon Sep 17 00:00:00 2001 From: Joel Natividad <1980690+jqnatividad@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:18:03 -0400 Subject: [PATCH 1/2] fix(search, searchset): --quick reports no row when nothing matched The sequential path printed row_ctr -- by then the total row count -- to stderr whenever --quick found nothing: `search` before returning NoMatch, and `searchset` when --not-one turned the no-match into a success. That reads as the row of a match. Print only when there was a match, as the indexed (parallel) path already does. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + src/cmd/search.rs | 9 ++++++--- src/cmd/searchset.rs | 3 ++- tests/test_search.rs | 34 ++++++++++++++++++++++++++++++++++ tests/test_searchset.rs | 35 +++++++++++++++++++++++++++++++++++ 5 files changed, 78 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c380beb081..2a1f6e9365 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Building qsv from source now also patches `hashbrown` to Polars' fork (the same pin Polars itself uses), because Polars 2.0 relies on prefetch APIs that aren't in a hashbrown release yet ([rust-lang/hashbrown#727](https://github.com/rust-lang/hashbrown/pull/727)). ### Fixed +- **`search --quick` and `searchset --quick` no longer report a row when nothing matched.** On an unindexed file, they printed the total row count to stderr where the first match's row number goes: `search` did so on every no-match, and `searchset` with `--not-one`. A caller couldn't tell that from a match on the last row. (DataPusher+'s quick PII screen tripped on this.) Now no match prints nothing. Without `--not-one` it still exits 1; with it, it exits 0. The indexed (parallel) path already behaved this way. - **`describegpt` no longer writes your API key into the files it generates** ([#4766](https://github.com/dathere/qsv/pull/4766)). The attribution it adds to each data dictionary, description and tag set records the command line that ran, and that included the value of `--api-key` (or `-k`) and `--ckan-token`. Those values are now replaced with `[REDACTED]`, however they were written (`--api-key KEY`, `--api-key=KEY`, `-kKEY`, `-Ak KEY`). Files generated by earlier versions still contain the key, so redact or regenerate any you have shared, and consider rotating the key. Passing it in `QSV_LLM_APIKEY` (or `QSV_CKAN_TOKEN`) instead keeps it out of the command line, and out of `ps`, altogether. - **`viz --geojson auto` (and `census:*`) handles an explicit `--feature-id-key` consistently on every path** ([#4735](https://github.com/dathere/qsv/issues/4735), [#4763](https://github.com/dathere/qsv/pull/4763)). [#4734](https://github.com/dathere/qsv/pull/4734) fixed this for state columns; three other paths still used the key auto picked: - **County names and geocoded place names** resolve to Census GEOIDs, so they can only match on `properties.GEOID`. With another key (e.g. `properties.NAME`) they passed the coverage check and then drew a map that dropped every row. They now refuse the run and say to omit `--feature-id-key`. This applies to `viz choropleth` and `viz smart`. diff --git a/src/cmd/search.rs b/src/cmd/search.rs index 821f7969a2..2048c38714 100644 --- a/src/cmd/search.rs +++ b/src/cmd/search.rs @@ -566,10 +566,13 @@ impl Args { // Handle quick mode separately. --json implies --quiet per USAGE, // so the row-number print is also suppressed when --json is set. if self.flag_quick { - if !(self.flag_quiet || self.flag_json) { - eprintln!("{row_ctr}"); + // with no match there is no row to report (row_ctr is then the row count) + if match_ctr > 0 { + if !(self.flag_quiet || self.flag_json) { + eprintln!("{row_ctr}"); + } + info!("quick search first match at {row_ctr}"); } - info!("quick search first match at {row_ctr}"); if match_ctr == 0 && !self.flag_not_one { return Err(CliError::NoMatch()); } diff --git a/src/cmd/searchset.rs b/src/cmd/searchset.rs index f7c0a231a2..b68c160f58 100644 --- a/src/cmd/searchset.rs +++ b/src/cmd/searchset.rs @@ -421,7 +421,8 @@ impl Args { if match_row_ctr == 0 && !flag_not_one { return Err(CliError::NoMatch()); - } else if flag_quick { + } else if flag_quick && match_row_ctr > 0 { + // with --not-one and no match, there is no row to report if !self.flag_quiet { eprintln!("{row_ctr}"); } diff --git a/tests/test_search.rs b/tests/test_search.rs index c1abafd270..131c8416da 100644 --- a/tests/test_search.rs +++ b/tests/test_search.rs @@ -245,6 +245,40 @@ fn search_match_quick() { assert_eq!(got, ""); } +/// With no match, --quick reports no row: plain --quick fails silently (exit 1), and +/// --not-one succeeds silently. Before, the sequential path printed the row COUNT in +/// both cases, which a caller reads as the row of a match. +#[test] +fn search_quick_nomatch_reports_no_row() { + let wrk = Workdir::new("search_quick_nomatch_reports_no_row"); + wrk.create("data.csv", data(true)); + + let mut cmd = wrk.command("search"); + cmd.arg("^zzz").arg("--quick").arg("data.csv"); + assert_eq!(wrk.stderr_on_error(&mut cmd), ""); + + let mut cmd = wrk.command("search"); + cmd.arg("^zzz") + .arg("--quick") + .arg("--not-one") + .arg("data.csv"); + let (got, got_err): (String, String) = wrk.stdout_and_stderr_on_success(&mut cmd); + assert_eq!(got_err, ""); + assert_eq!(got, ""); + + // the indexed (parallel) path agrees + let mut idx = wrk.command("index"); + idx.arg("data.csv"); + wrk.assert_success(&mut idx); + let mut cmd = wrk.command("search"); + cmd.arg("^zzz") + .arg("--quick") + .arg("--not-one") + .args(["--jobs", "2"]) + .arg("data.csv"); + assert_eq!(wrk.stderr_on_success(&mut cmd), ""); +} + #[test] fn search_match_quick_json() { // regression: --quick + --json must not emit an unclosed JSON array `[`, diff --git a/tests/test_searchset.rs b/tests/test_searchset.rs index d467fdb816..6756d450da 100644 --- a/tests/test_searchset.rs +++ b/tests/test_searchset.rs @@ -305,6 +305,41 @@ fn searchset_quick_nomatch() { wrk.assert_err(&mut cmd); } +/// With no match, --quick reports no row: plain --quick fails silently (exit 1), and +/// --not-one succeeds silently. Before, the sequential path printed the row COUNT with +/// --not-one, which a caller reads as the row of a match. +#[test] +fn searchset_quick_nomatch_reports_no_row() { + let wrk = Workdir::new("searchset_quick_nomatch_reports_no_row"); + wrk.create("data.csv", data(true)); + wrk.create("regexset.txt", regexset_no_match_file()); + + let mut cmd = wrk.command("searchset"); + cmd.arg("regexset.txt").arg("--quick").arg("data.csv"); + assert_eq!(wrk.stderr_on_error(&mut cmd), ""); + + let mut cmd = wrk.command("searchset"); + cmd.arg("regexset.txt") + .arg("--quick") + .arg("--not-one") + .arg("data.csv"); + let (got, got_err): (String, String) = wrk.stdout_and_stderr_on_success(&mut cmd); + assert_eq!(got_err, ""); + assert_eq!(got, ""); + + // the indexed (parallel) path agrees + let mut idx = wrk.command("index"); + idx.arg("data.csv"); + wrk.assert_success(&mut idx); + let mut cmd = wrk.command("searchset"); + cmd.arg("regexset.txt") + .arg("--quick") + .arg("--not-one") + .args(["--jobs", "2"]) + .arg("data.csv"); + assert_eq!(wrk.stderr_on_success(&mut cmd), ""); +} + #[test] fn searchset_unicode() { let wrk = Workdir::new("searchset"); From 9bc62ab63b98061ef65bfe9583586f9850b6a61f Mon Sep 17 00:00:00 2001 From: Joel Natividad <1980690+jqnatividad@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:18:12 -0400 Subject: [PATCH 2/2] docs(changelog): cite #4767 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a1f6e9365..a1af224786 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Building qsv from source now also patches `hashbrown` to Polars' fork (the same pin Polars itself uses), because Polars 2.0 relies on prefetch APIs that aren't in a hashbrown release yet ([rust-lang/hashbrown#727](https://github.com/rust-lang/hashbrown/pull/727)). ### Fixed -- **`search --quick` and `searchset --quick` no longer report a row when nothing matched.** On an unindexed file, they printed the total row count to stderr where the first match's row number goes: `search` did so on every no-match, and `searchset` with `--not-one`. A caller couldn't tell that from a match on the last row. (DataPusher+'s quick PII screen tripped on this.) Now no match prints nothing. Without `--not-one` it still exits 1; with it, it exits 0. The indexed (parallel) path already behaved this way. +- **`search --quick` and `searchset --quick` no longer report a row when nothing matched** ([#4767](https://github.com/dathere/qsv/pull/4767)). On an unindexed file, they printed the total row count to stderr where the first match's row number goes: `search` did so on every no-match, and `searchset` with `--not-one`. A caller couldn't tell that from a match on the last row. (DataPusher+'s quick PII screen tripped on this.) Now no match prints nothing. Without `--not-one` it still exits 1; with it, it exits 0. The indexed (parallel) path already behaved this way. - **`describegpt` no longer writes your API key into the files it generates** ([#4766](https://github.com/dathere/qsv/pull/4766)). The attribution it adds to each data dictionary, description and tag set records the command line that ran, and that included the value of `--api-key` (or `-k`) and `--ckan-token`. Those values are now replaced with `[REDACTED]`, however they were written (`--api-key KEY`, `--api-key=KEY`, `-kKEY`, `-Ak KEY`). Files generated by earlier versions still contain the key, so redact or regenerate any you have shared, and consider rotating the key. Passing it in `QSV_LLM_APIKEY` (or `QSV_CKAN_TOKEN`) instead keeps it out of the command line, and out of `ps`, altogether. - **`viz --geojson auto` (and `census:*`) handles an explicit `--feature-id-key` consistently on every path** ([#4735](https://github.com/dathere/qsv/issues/4735), [#4763](https://github.com/dathere/qsv/pull/4763)). [#4734](https://github.com/dathere/qsv/pull/4734) fixed this for state columns; three other paths still used the key auto picked: - **County names and geocoded place names** resolve to Census GEOIDs, so they can only match on `properties.GEOID`. With another key (e.g. `properties.NAME`) they passed the coverage check and then drew a map that dropped every row. They now refuse the run and say to omit `--feature-id-key`. This applies to `viz choropleth` and `viz smart`.