Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ in [`crate/`](crate/README.md), sharing one corpus with the extension —
document differently.

```bash
string-le . # every string in the tree, as JSON
string-le . # every string in the tree; JSON on stdout when piped
string-le --values src/ # just the values, one per line
string-le --dedupe --values . # each distinct string once
string-le mcp # the same extraction over MCP on stdio
Expand Down
5 changes: 5 additions & 0 deletions crate/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,11 @@ crate/src/
string that appears forty times is a different finding from one that
appears once.
- **stdout is protocol, stderr is human. There is no `--json` flag.**
stdout stays silent when it is a terminal, because the stderr listing
already covers a person reading it. Anything that can parse gets every
report, and the human listing is a projection of the same reports so
the two cannot drift. `--values` is the exception: it is a request to
see the values, not a report, and prints them at a terminal too.
- **Parity scope is extraction only** — `src/extraction/**`. Positions
are outside it: the shared tool carries none, and the extension's
opt-in positions come from its own scanner, not from this crate's
Expand Down
11 changes: 11 additions & 0 deletions crate/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ this repository release on their own cadence.
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.4.4] - 2026-10-15

### Changed

- **stdout is silent at a terminal.** The JSON reports printed on the same
screen as the stderr listing and said the same thing again. Piped or
redirected, stdout carries every report exactly as before. `--values`
still prints the values at a terminal, because it is a request to see
them.

## [0.4.3] - 2026-10-13

The crate's half of a bug fix and hardening pass over the whole repository.
Expand Down Expand Up @@ -334,6 +344,7 @@ auditor saw it. A contract test asserts no flag asks for a judgment.
vanish from the report entirely, which reads to whoever ran it as
"that file was clean".

[0.4.4]: https://crates.io/crates/string-le/0.4.4
[0.4.3]: https://crates.io/crates/string-le/0.4.3
[0.4.2]: https://crates.io/crates/string-le/0.4.2
[0.4.1]: https://crates.io/crates/string-le/0.4.1
Expand Down
2 changes: 1 addition & 1 deletion crate/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion crate/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "string-le"
version = "0.4.3"
version = "0.4.4"
edition = "2024"
rust-version = "1.88"
license = "MIT"
Expand Down
6 changes: 5 additions & 1 deletion crate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ string-le --values --dedupe src/ > strings.txt
## Sixty seconds

```bash
string-le . # every string in the tree, as JSON
string-le . # every string in the tree; JSON on stdout when piped
string-le --values src/ # just the values, one per line
string-le --dedupe --values . # each distinct string once
cat config.toml | string-le --stdin --format toml
Expand All @@ -69,6 +69,10 @@ diff before.txt after.txt # what changed in the copy this release
3 strings in 3 files
```

The listing above is stderr. The JSON report, one object per line, goes to
stdout when it is piped or redirected and is left out at a terminal.
`--values` prints the values on stdout either way.

**Exit codes follow grep** — `0` strings found, `1` none found, `2` the
question was malformed. Finding none is an answer, not an error.

Expand Down
5 changes: 4 additions & 1 deletion crate/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,10 @@ the whole reason it earned spans.
## Output contract

**stdout is protocol, stderr is human.** One JSON report per line, one
line per file.
line per file. stdout carries the reports when it is piped or redirected
and stays silent when it is a terminal, where the stderr listing already
says the same thing. `--values` is not a report: asking for the values
is asking to see them, so they print to stdout whatever it is.

```json
{
Expand Down
67 changes: 57 additions & 10 deletions crate/src/cli.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
//! The terminal surface.
//!
//! stdout is always protocol — one JSON report per line, one line per
//! file. stderr is always for the human, and is a projection of the same
//! reports rather than parallel prose.
//! stdout is protocol — one JSON report per line, one line per file —
//! whenever something is reading it, and silent when it is a terminal.
//! `--values` is the exception, because asking for the values is asking
//! to see them. stderr is always for the human, and is a projection of
//! the same reports rather than parallel prose.

use std::io::{Read, Write};
use std::io::{IsTerminal, Read, Write};
use std::path::PathBuf;
use std::process::ExitCode;

Expand All @@ -26,6 +28,10 @@ than fragments of it.
It reports what is there and nothing else. Which strings matter is yours
to decide.

One JSON report per line on stdout when it is piped or redirected, human
summary on stderr. At a terminal stdout stays silent, except that
--values always prints the values.

Options:
--dedupe collapse repeated values to their first occurrence
--format <format> force a format instead of inferring it from the
Expand Down Expand Up @@ -132,19 +138,32 @@ fn report(reports: &[FileReport], binary: usize, options: &Cli) -> Result<u8, St
if options.values_only {
write_values(reports)?;
} else {
write_reports(reports)?;
let stdout = std::io::stdout();
let terminal = stdout.is_terminal();
write_reports(&mut stdout.lock(), reports, terminal)
.map_err(|error| format!("could not write the report: {error}"))?;
}

summarise(reports, binary, options.values_only);
Ok(scan::exit_code(reports, options.strict))
}

fn write_reports(reports: &[FileReport]) -> Result<(), String> {
let mut stdout = std::io::stdout().lock();
/// A terminal gets nothing here. The stderr summary already says
/// everything a person reads, and the JSON beside it on the same screen
/// is the same answer a second time in a form nobody reads by eye.
/// Anything that can parse — a pipe, a file, a subprocess — still gets
/// every report, so no script sees a difference.
fn write_reports(
out: &mut impl Write,
reports: &[FileReport],
terminal: bool,
) -> std::io::Result<()> {
if terminal {
return Ok(());
}
for report in reports {
let line = serde_json::to_string(report).expect("a report serializes");
writeln!(stdout, "{line}")
.map_err(|error| format!("could not write the report: {error}"))?;
writeln!(out, "{line}")?;
}
Ok(())
}
Expand Down Expand Up @@ -240,7 +259,7 @@ fn parse(args: &[String]) -> Result<Cli, String> {
Ok(options)
}

/// The human half. Every line restates something already on stdout.
/// The human half. Every line restates something already in the reports.
fn summarise(reports: &[FileReport], binary: usize, values_only: bool) {
let mut stderr = std::io::stderr().lock();
let mut strings = 0;
Expand Down Expand Up @@ -295,6 +314,34 @@ mod tests {
use super::*;
use crate::extract::SUPPORTED_FORMATS;

fn sample_reports() -> Vec<FileReport> {
let options = ScanOptions::default();
vec![scan::scan_content(
"{\"a\": \"hello\"}",
"a.json".to_string(),
"json",
options,
)]
}

#[test]
fn a_pipe_gets_one_report_per_line() {
let reports = sample_reports();
let mut out = Vec::new();
write_reports(&mut out, &reports, false).expect("a write");
let text = String::from_utf8(out).expect("utf-8");
assert_eq!(text.lines().count(), reports.len());
assert!(text.starts_with('{'), "{text}");
}

/// The summary on stderr already covers a person at a terminal.
#[test]
fn a_terminal_gets_no_json() {
let mut out = Vec::new();
write_reports(&mut out, &sample_reports(), true).expect("a write");
assert!(out.is_empty());
}

#[test]
fn every_documented_flag_is_parsed_and_the_reverse() {
let mut documented: Vec<&str> = USAGE
Expand Down
Loading