string-le 0.2.1

Extract every string in a codebase, with its position, so a person can read them
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
//! The exit codes and the stdout contract, driven against the built
//! binary.
//!
//! These are the API: a shell branches on the exit code and parses
//! stdout, so both are pinned here rather than inferred from unit tests
//! of the functions behind them. Nothing here needs a network or a
//! privileged filesystem operation, so it runs everywhere on every push.
//!
//! A new refusal adds its case here.

use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicUsize, Ordering};

const BINARY: &str = env!("CARGO_BIN_EXE_string-le");
static COUNTER: AtomicUsize = AtomicUsize::new(0);

struct Tree {
    root: PathBuf,
}

impl Tree {
    fn new(name: &str) -> Self {
        let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
        let root = std::env::temp_dir().join(format!(
            "string-le-contract-{name}-{}-{unique}",
            std::process::id()
        ));
        let _ = std::fs::remove_dir_all(&root);
        std::fs::create_dir_all(&root).expect("a temporary directory");
        Self {
            root: std::fs::canonicalize(&root).expect("a canonical directory"),
        }
    }

    fn path(&self) -> &Path {
        &self.root
    }

    fn write(&self, relative: &str, contents: &str) -> PathBuf {
        self.write_bytes(relative, contents.as_bytes())
    }

    fn write_bytes(&self, relative: &str, contents: &[u8]) -> PathBuf {
        let target = self.root.join(relative);
        if let Some(parent) = target.parent() {
            std::fs::create_dir_all(parent).expect("a parent directory");
        }
        std::fs::write(&target, contents).expect("a file");
        target
    }
}

impl Drop for Tree {
    fn drop(&mut self) {
        let _ = std::fs::remove_dir_all(&self.root);
    }
}

struct Run {
    code: i32,
    stdout: String,
    stderr: String,
}

fn run(args: &[&str]) -> Run {
    let output = Command::new(BINARY)
        .args(args)
        .output()
        .expect("the binary runs");
    Run {
        code: output.status.code().expect("an exit code"),
        stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
        stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
    }
}

/// Every line of stdout, parsed. Doubles as the assertion that stdout
/// is JSON Lines and nothing else — a stray human message there would
/// fail to parse.
fn reports(run: &Run) -> Vec<serde_json::Value> {
    run.stdout
        .lines()
        .filter(|line| !line.trim().is_empty())
        .map(|line| serde_json::from_str(line).expect("stdout carries only JSON"))
        .collect()
}

/// A JSON config, a TypeScript file carrying the copy, and prose with
/// nothing quoted in it — the three shapes an audit walks past.
fn audit_tree(name: &str) -> Tree {
    let tree = Tree::new(name);
    tree.write("config.json", "{\"title\":\"Settings\",\"count\":42}\n");
    tree.write(
        "src/messages.ts",
        "const m = {\n  confirm: 'Delete this permanently?',\n  cancel: \"Never mind\",\n};\n",
    );
    tree.write("notes.md", "no quoted strings in this prose at all\n");
    tree
}

#[test]
fn a_tree_with_strings_exits_zero() {
    let tree = audit_tree("found");
    let run = run(&[&tree.path().to_string_lossy()]);
    assert_eq!(run.code, 0, "{}", run.stderr);
    let total: u64 = reports(&run)
        .iter()
        .filter_map(|report| report["summary"]["strings"].as_u64())
        .sum();
    assert_eq!(
        total, 3,
        "the JSON title, and both messages the fallback finds in the .ts"
    );
}

/// grep's convention, and the reason it is worth having: finding nothing
/// is an answer, not an error.
#[test]
fn a_tree_with_none_exits_one() {
    let tree = Tree::new("none");
    tree.write("docs/a.md", "nothing quoted here\n");
    let run = run(&[&tree.path().to_string_lossy()]);
    assert_eq!(run.code, 1);
    assert!(run.stderr.contains("0 strings"), "{}", run.stderr);
}

/// The audit case, end to end: a source file's copy comes out, and the
/// report names the language it was read as rather than a shrug.
#[test]
fn a_source_file_yields_its_copy_as_its_own_language() {
    let tree = audit_tree("source");
    let run = run(&[&tree.path().to_string_lossy()]);
    let source = reports(&run)
        .into_iter()
        .find(|report| {
            report["file"]
                .as_str()
                .is_some_and(|file| file.ends_with("messages.ts"))
        })
        .expect("the .ts file was read");
    assert_eq!(source["format"], "typescript");
    let values: Vec<&str> = source["strings"]
        .as_array()
        .expect("strings")
        .iter()
        .filter_map(|found| found["value"].as_str())
        .collect();
    assert_eq!(values, ["Delete this permanently?", "Never mind"]);
}

/// A template literal spans lines because TypeScript says it does, so
/// an email body or a consent notice — the copy an audit least wants to
/// miss — needs no flag to be read.
#[test]
fn a_language_that_spans_lines_needs_no_flag() {
    let tree = Tree::new("spanning");
    tree.write(
        "src/email.ts",
        "const body = `Dear reader,\n\nWelcome aboard.`;\nconst short = 'Hi';\n",
    );
    let plain = run(&["--values", &tree.path().to_string_lossy()]);
    assert!(plain.stdout.contains("Dear reader,"), "{}", plain.stdout);
    assert!(plain.stdout.contains("Hi"), "{}", plain.stdout);
}

/// A PNG is not a text file that failed to be read, it was never a text
/// candidate — so it gets no report line and cannot fail `--strict`.
/// Reported as a skip, one image made `--strict` exit 2 on every
/// repository that has one, which is every repository.
#[test]
fn a_binary_file_is_skipped_silently_and_counted() {
    let tree = Tree::new("binary");
    tree.write("src/messages.ts", "const m = 'Delete this?';\n");
    tree.write_bytes("assets/logo.png", &[0x89, 0x50, 0x4e, 0x47, 0x00, 0x0d]);
    let path = tree.path().to_string_lossy().to_string();

    let walked = run(&[&path]);
    let named: Vec<String> = reports(&walked)
        .iter()
        .filter_map(|report| report["file"].as_str().map(str::to_string))
        .collect();
    assert_eq!(named.len(), 1, "{}", walked.stdout);
    assert!(named[0].ends_with("messages.ts"), "{}", walked.stdout);
    assert!(
        walked.stderr.contains("1 binary file skipped"),
        "the count is the coverage line: {}",
        walked.stderr
    );
    assert_eq!(run(&["--strict", &path]).code, 0, "a PNG is not a failure");
}

/// The other half of the same distinction: a file that *is* text and
/// could not be read is named, and still fails `--strict`.
#[test]
fn text_that_cannot_be_read_still_fails_strict() {
    let tree = Tree::new("undecodable");
    tree.write("src/messages.ts", "const m = 'Delete this?';\n");
    tree.write_bytes("notes.txt", &[b'h', b'i', 0xff, 0xfe]);
    let path = tree.path().to_string_lossy().to_string();

    let walked = run(&[&path]);
    assert_eq!(reports(&walked).len(), 2, "{}", walked.stdout);
    assert!(
        walked.stderr.contains("not UTF-8 text"),
        "{}",
        walked.stderr
    );
    assert_eq!(walked.code, 0, "a skip alone is not a failure");
    assert_eq!(run(&["--strict", &path]).code, 2);
}

/// The one flag that makes this answer differently from the extension,
/// and only when asked. It belongs to the fallback, where a run spanning
/// lines really is a divergence rather than the language's own syntax.
#[test]
fn multiline_reads_copy_the_extension_cannot_see() {
    let tree = Tree::new("multiline");
    tree.write(
        "notes.txt",
        "body = `Dear reader,\n\nWelcome aboard.`\nshort = 'Hi'\n",
    );
    let path = tree.path().to_string_lossy().to_string();

    let parity = run(&["--values", &path]);
    assert_eq!(parity.stdout.lines().collect::<Vec<_>>(), ["Hi"]);

    let wider = run(&["--values", "--multiline", &path]);
    assert!(wider.stdout.contains("Dear reader,"), "{}", wider.stdout);
    assert!(wider.stdout.contains("Hi"), "{}", wider.stdout);
}

/// The flag that exists for the person this was built for: values alone,
/// ready to pipe.
#[test]
fn values_only_prints_values_and_no_json() {
    let tree = audit_tree("values");
    let run = run(&["--values", &tree.path().to_string_lossy()]);
    assert_eq!(run.code, 0);
    assert!(!run.stdout.contains('{'), "{}", run.stdout);
    let lines: Vec<&str> = run.stdout.lines().collect();
    assert_eq!(lines.len(), 3, "{}", run.stdout);
    assert!(lines.contains(&"Settings"), "{}", run.stdout);
}

#[test]
fn an_unreadable_input_exits_two() {
    assert_eq!(run(&["/no/such/place-xyz"]).code, 2);
}

/// A broken document is a fact about that file, not a failed run. One
/// malformed config must not fail an audit of ten thousand files.
#[test]
fn a_broken_document_warns_without_failing_the_run() {
    let tree = Tree::new("broken");
    tree.write("bad.json", "{not json\n");
    tree.write("good.json", "{\"a\":\"kept\"}\n");
    let run = run(&[&tree.path().to_string_lossy()]);
    assert_eq!(run.code, 0, "{}", run.stderr);
    assert!(run.stderr.contains("Invalid JSON"), "{}", run.stderr);
}

#[test]
fn an_unknown_flag_exits_two_and_names_itself() {
    let tree = audit_tree("badflag");
    let run = run(&["--dedup", &tree.path().to_string_lossy()]);
    assert_eq!(run.code, 2);
    assert!(run.stderr.contains("--dedup"), "{}", run.stderr);
    assert!(run.stdout.is_empty(), "a refusal writes no report");
}

/// The deliberate leniency: an unknown format is the fallback, not a
/// refusal. This is the one flag value in the family that does not fail.
#[test]
fn an_unknown_format_falls_back_rather_than_exiting_two() {
    let tree = audit_tree("badformat");
    let run = run(&["--format", "klingon", &tree.path().to_string_lossy()]);
    assert_eq!(run.code, 0, "{}", run.stderr);
    assert!(
        reports(&run)
            .iter()
            .all(|report| report["format"] == "fallback")
    );
}

/// The tool has no opinions about which strings matter, so there is no
/// flag that would produce one.
#[test]
fn no_flag_asks_for_a_judgment() {
    let tree = audit_tree("nojudgment");
    for attempt in [
        "--user-facing",
        "--spellcheck",
        "--min-length",
        "--lang",
        "--fix",
    ] {
        assert_eq!(
            run(&[attempt, &tree.path().to_string_lossy()]).code,
            2,
            "{attempt} was accepted"
        );
    }
}

#[test]
fn dedupe_collapses_repeats() {
    let tree = Tree::new("dedupe");
    tree.write("a.json", "{\"a\":\"same\",\"b\":\"same\"}\n");
    let kept: u64 = reports(&run(&[&tree.path().to_string_lossy()]))[0]["summary"]["strings"]
        .as_u64()
        .expect("a count");
    let deduped: u64 =
        reports(&run(&["--dedupe", &tree.path().to_string_lossy()]))[0]["summary"]["strings"]
            .as_u64()
            .expect("a count");
    assert_eq!((kept, deduped), (2, 1));
}

/// The count that says whether the positions are a complete index.
#[test]
fn values_the_source_does_not_spell_are_reported_as_unlocated() {
    let tree = Tree::new("unlocated");
    tree.write("a.yaml", "b: |\n  first\n  second\n");
    let run = run(&[&tree.path().to_string_lossy()]);
    assert_eq!(reports(&run)[0]["summary"]["unlocated"], 1);
    assert!(
        run.stderr.contains("could not be located"),
        "{}",
        run.stderr
    );
}

#[test]
fn version_and_help_exit_clear() {
    let version = run(&["--version"]);
    assert_eq!(version.code, 0);
    assert!(version.stdout.contains("string-le"));
    let help = run(&["--help"]);
    assert_eq!(help.code, 0);
    assert!(help.stdout.contains("usage: string-le"));
    assert!(
        help.stdout.contains("grep"),
        "the exit convention is stated"
    );
}

#[test]
fn stdout_carries_only_reports_and_stderr_only_the_summary() {
    let tree = audit_tree("streams");
    let run = run(&[&tree.path().to_string_lossy()]);
    assert!(!reports(&run).is_empty());
    assert!(!run.stderr.contains('{'), "{}", run.stderr);
    assert!(run.stderr.contains("strings in"), "{}", run.stderr);
}

#[test]
fn a_document_on_stdin_is_scanned() {
    let mut child = Command::new(BINARY)
        .args(["--stdin", "--format", "json"])
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("the binary runs");
    child
        .stdin
        .as_mut()
        .expect("stdin")
        .write_all(br#"{"a":"from stdin"}"#)
        .expect("written");
    let output = child.wait_with_output().expect("finishes");
    assert_eq!(output.status.code(), Some(0));
    let report: serde_json::Value =
        serde_json::from_slice(&output.stdout).expect("stdout carries JSON");
    assert_eq!(report["file"], "<stdin>");
    assert_eq!(report["strings"][0]["value"], "from stdin");
}

/// stdin with no format is the fallback, not a refusal — there is no
/// name to infer from and that is an ordinary situation here.
#[test]
fn stdin_without_a_format_falls_back() {
    let mut child = Command::new(BINARY)
        .args(["--stdin"])
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("the binary runs");
    child
        .stdin
        .as_mut()
        .expect("stdin")
        .write_all(b"const a = 'copy';")
        .expect("written");
    let output = child.wait_with_output().expect("finishes");
    assert_eq!(output.status.code(), Some(0));
    let report: serde_json::Value =
        serde_json::from_slice(&output.stdout).expect("stdout carries JSON");
    assert_eq!(report["format"], "fallback");
    assert_eq!(report["strings"][0]["value"], "copy");
}

/// **The cross-surface contract.** Both surfaces call one entry point,
/// so they must answer identically for the same tree.
#[test]
fn the_cli_and_the_mcp_server_report_the_same_thing() {
    let tree = audit_tree("agreement");
    let cli = run(&[&tree.path().to_string_lossy()]);
    let from_cli = reports(&cli);

    let request = serde_json::json!({
        "jsonrpc": "2.0",
        "id": 1,
        "method": "tools/call",
        "params": {
            "name": "string_le_scan",
            "arguments": { "path": tree.path().to_string_lossy() },
        },
    });
    let mut child = Command::new(BINARY)
        .arg("mcp")
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("the server starts");
    writeln!(child.stdin.as_mut().expect("stdin"), "{request}").expect("written");
    let output = child.wait_with_output().expect("finishes");
    let response: serde_json::Value = serde_json::from_slice(
        output
            .stdout
            .split(|byte| *byte == b'\n')
            .next()
            .expect("a line"),
    )
    .expect("the reply is JSON");

    let from_mcp = response["result"]["structuredContent"]["data"]["reports"]
        .as_array()
        .expect("reports")
        .clone();
    assert_eq!(from_mcp, from_cli, "the two surfaces disagree");
}