heddle-cli 0.8.0

An AI-native version control system
Documentation
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
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
// SPDX-License-Identifier: Apache-2.0
//! `--output auto` and `output.format = "auto"` are gone — regression cover.
//!
//! Background: PR #251 fixed the *default* surface (no flag → text regardless
//! of pipe state) but left the `--output auto` value and its
//! TTY-detect-then-pick behaviour in place. Round 5 of the persona eval
//! confirmed that under a config with `output.format = "auto"`,
//! `heddle status > file` still emitted JSON — the exact ergonomic surprise
//! we tried to delete. Heddle #271 deletes `auto` entirely from both the
//! CLI surface and the repo/user config; this file pins the contract.
//!
//! No migration alias. Pre-1.0; legacy configs error loudly with a typed
//! `Next:` envelope rather than silently mapping to text.

use std::{process::Command, str};

use serde_json::Value;
use tempfile::TempDir;

use super::{assert_json_recovery_advice_fields, heddle, heddle_output};

/// `EX_DATAERR` from BSD `sysexits.h` — the exit code Heddle assigns to a
/// `toml::de::Error` in the error chain (see `HeddleExitCode::from_error`).
/// A config-parse failure is well-formed-but-rejected input, not an IO error.
const EX_DATAERR: i32 = 65;

#[test]
fn piped_status_with_no_output_flag_renders_text() {
    // Post-PR251 default — re-asserted here because the failure mode this
    // regression-protects (silent JSON on a pipe) was the entire reason
    // #271 exists. `Command::output()` always pipes stdout/stderr, so
    // `heddle_output` is already the "piped" shape.
    let temp = TempDir::new().unwrap();
    heddle(&["init"], Some(temp.path())).expect("init should succeed");

    let output =
        heddle_output(&["status"], Some(temp.path())).expect("status with no --output flag");
    assert!(
        output.status.success(),
        "default status under a pipe should succeed: stdout={}; stderr={}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );

    let stdout = str::from_utf8(&output.stdout).expect("stdout utf8");
    let trimmed = stdout.trim_start();
    assert!(
        !trimmed.starts_with('{') && !trimmed.starts_with('['),
        "piped default status must stay text, not JSON: {stdout}"
    );
    assert!(
        serde_json::from_str::<Value>(stdout).is_err(),
        "piped default status must not parse as JSON: {stdout}"
    );
}

#[test]
fn piped_status_with_explicit_json_flag_emits_json() {
    let temp = TempDir::new().unwrap();
    heddle(&["init"], Some(temp.path())).expect("init should succeed");

    let stdout = heddle(&["status", "--output", "json"], Some(temp.path()))
        .expect("status --output json should succeed under a pipe");
    let parsed: Value = serde_json::from_str(&stdout)
        .unwrap_or_else(|err| panic!("JSON parse failed: {err}: {stdout}"));
    assert!(
        parsed.is_object(),
        "status --output json should emit a JSON object: {stdout}"
    );
}

#[test]
fn output_auto_flag_errors_at_parse_with_helpful_message() {
    // `--output auto` is gone. clap should reject it at parse time with a
    // value-list hint that names the remaining valid values.
    let output = heddle_output(&["--output", "auto", "status"], None)
        .expect("heddle should run even when args reject");
    assert!(
        !output.status.success(),
        "--output auto should fail at parse: stdout={}; stderr={}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    let stderr = String::from_utf8_lossy(&output.stderr);
    let stderr_lower = stderr.to_ascii_lowercase();
    assert!(
        stderr_lower.contains("auto"),
        "parse error should name the rejected value 'auto': {stderr}"
    );
    assert!(
        stderr_lower.contains("text") && stderr_lower.contains("json"),
        "parse error should list the remaining valid values 'text' and 'json': {stderr}"
    );
}

#[test]
fn repo_config_with_output_format_auto_errors_with_typed_envelope() {
    // Loud error, typed envelope. The repo config file is the place a
    // legacy `auto` value can still appear in the wild (anyone who copied
    // the example config from before the rip will have it). We refuse to
    // silently map it to text — that's the bug class #271 closes.
    let temp = TempDir::new().unwrap();
    heddle(&["init"], Some(temp.path())).expect("init should succeed");

    let config_path = temp.path().join(".heddle").join("config.toml");
    let existing = std::fs::read_to_string(&config_path).expect("repo config exists after init");
    // `init` writes an empty `[output]` section. Inject `format = "auto"`
    // into it so the toml parser hits the rejection path.
    let mutated = existing.replace("[output]\n", "[output]\nformat = \"auto\"\n");
    assert_ne!(
        mutated, existing,
        "test fixture should mutate the [output] section: {existing}"
    );
    std::fs::write(&config_path, &mutated).expect("write mutated config");

    // Any command that loads the repo config exercises the parse path.
    // Use `status` because it's the one R5/A1 personas hit on the JSON-pipe
    // bug.
    let text_out =
        heddle_output(&["status"], Some(temp.path())).expect("status should run with bad config");
    assert!(
        !text_out.status.success(),
        "status with output.format='auto' must fail loudly: stdout={}; stderr={}",
        String::from_utf8_lossy(&text_out.stdout),
        String::from_utf8_lossy(&text_out.stderr)
    );
    let stderr = String::from_utf8_lossy(&text_out.stderr);
    assert!(
        stderr.contains("output.format") && stderr.contains("'auto'"),
        "text envelope should name the field and the rejected value: {stderr}"
    );
    assert!(
        stderr.contains("'text'") && stderr.contains("'json'"),
        "text envelope should list the valid values: {stderr}"
    );
    assert!(
        stderr.contains("Next:"),
        "text envelope should carry a typed Next: line: {stderr}"
    );

    // JSON envelope path. Same failure must surface a structured body
    // with the recovery-advice fields the rest of the CLI envelope
    // contract requires.
    let json_out = heddle_output(&["--output", "json", "status"], Some(temp.path()))
        .expect("status --output json should run with bad config");
    assert!(
        !json_out.status.success(),
        "status with output.format='auto' must fail under --output json too"
    );
    let stderr_json_text = String::from_utf8_lossy(&json_out.stderr);
    let last_line = stderr_json_text
        .lines()
        .rfind(|line| line.trim_start().starts_with('{'))
        .unwrap_or_else(|| panic!("expected a JSON envelope on stderr; got: {stderr_json_text}"));
    let envelope: Value = serde_json::from_str(last_line.trim())
        .unwrap_or_else(|err| panic!("stderr JSON envelope should parse: {err}: {last_line}"));
    assert_eq!(
        envelope["kind"], "invalid_repo_config_output_format",
        "JSON envelope kind should classify the field-specific failure: {envelope}"
    );
    assert!(
        envelope["error"]
            .as_str()
            .is_some_and(|err| err.contains("output.format") && err.contains("'auto'")),
        "JSON envelope error message should name field and value: {envelope}"
    );
    assert_json_recovery_advice_fields(&envelope, "repo config output.format=auto");

    // Codex R3 (cid 3313132711): the recovery hint must cite the actual
    // file that produced the parse failure — not a hard-coded
    // `.heddle/config.toml`. Resolve the repo config path to its
    // absolute form (mirrors the `canonicalize()` in `RepoConfig::load`)
    // and assert it appears verbatim in the hint.
    let expected_path = config_path
        .canonicalize()
        .expect("repo config path canonicalizes");
    let expected_path = expected_path.display().to_string();
    let hint = envelope["hint"]
        .as_str()
        .expect("envelope.hint should be a string");
    assert!(
        hint.contains(&expected_path),
        "hint should cite the actual repo config path {expected_path}: hint={hint}"
    );
    assert!(
        hint.contains("output.format"),
        "hint should name the field: {hint}"
    );
    assert!(
        hint.contains("'text'") && hint.contains("'json'"),
        "hint should list the valid values: {hint}"
    );
    let stderr_text_after = String::from_utf8_lossy(&text_out.stderr);
    assert!(
        stderr_text_after.contains(&expected_path),
        "text envelope should also surface the path so the user knows which file to edit: stderr={stderr_text_after}"
    );
}

#[test]
fn user_config_with_output_format_auto_via_heddle_config_env_errors_with_typed_envelope() {
    // Codex R2: the deserializer rejection only produced a typed envelope
    // when `output.format = "auto"` lived in `.heddle/config.toml`. The
    // same value in the GLOBAL user config (`HEDDLE_CONFIG` / `~/.config`)
    // hit a parse failure during `UserConfig::load_default` in `main`
    // before the error printer was wired, so every command exited with
    // a raw TOML parse error instead of the promised `Next:` envelope.
    // This test pins the contract for the `HEDDLE_CONFIG` route.
    let temp = TempDir::new().unwrap();
    let bad_user_config = temp.path().join("user-config.toml");
    std::fs::write(&bad_user_config, "[output]\nformat = \"auto\"\n")
        .expect("write bad user config");

    let text_out = run_with_bad_user_config(&bad_user_config, None, &["status"]);
    assert!(
        !text_out.status.success(),
        "status with user output.format='auto' must fail loudly: stdout={}; stderr={}",
        String::from_utf8_lossy(&text_out.stdout),
        String::from_utf8_lossy(&text_out.stderr)
    );
    let stderr = String::from_utf8_lossy(&text_out.stderr);
    assert_typed_output_format_envelope(&stderr, "HEDDLE_CONFIG user config");

    let json_out =
        run_with_bad_user_config(&bad_user_config, None, &["--output", "json", "status"]);
    assert!(
        !json_out.status.success(),
        "status --output json with user output.format='auto' must fail too: stderr={}",
        String::from_utf8_lossy(&json_out.stderr)
    );
    let envelope = parse_envelope(&json_out.stderr);
    assert_eq!(
        envelope["kind"], "invalid_repo_config_output_format",
        "JSON envelope kind should classify the field-specific failure: {envelope}"
    );
    assert!(
        envelope["error"]
            .as_str()
            .is_some_and(|err| err.contains("output.format") && err.contains("'auto'")),
        "JSON envelope error message should name field and value: {envelope}"
    );
    assert_json_recovery_advice_fields(&envelope, "user config HEDDLE_CONFIG output.format=auto");

    // Codex R3 (cid 3313132711): when the bad value lives in the user
    // config reached via `HEDDLE_CONFIG`, the hint must cite that exact
    // file — not a hard-coded `.heddle/config.toml` the user does not
    // even have. Use the canonical form so the rendered hint is
    // copy/paste-safe across symlinks.
    let expected_path = bad_user_config
        .canonicalize()
        .expect("HEDDLE_CONFIG path canonicalizes");
    let expected_path = expected_path.display().to_string();
    let hint = envelope["hint"]
        .as_str()
        .expect("envelope.hint should be a string");
    assert!(
        hint.contains(&expected_path),
        "hint should cite the HEDDLE_CONFIG path {expected_path}: hint={hint}"
    );
    assert!(
        !hint.contains(".heddle/config.toml"),
        "hint must not point at the repo config when the bad value is in the user config: hint={hint}"
    );
    let stderr_text_after = String::from_utf8_lossy(&text_out.stderr);
    assert!(
        stderr_text_after.contains(&expected_path),
        "text envelope should also surface the HEDDLE_CONFIG path so the user knows which file to edit: stderr={stderr_text_after}"
    );
}

#[test]
fn user_config_with_output_format_auto_via_home_path_errors_with_typed_envelope() {
    // Mirror case: the same failure must surface a typed envelope when
    // the user config is discovered via `$HOME/.config/heddle/config.toml`
    // (the no-env-var fallback). Without `HEDDLE_CONFIG` and
    // `XDG_CONFIG_HOME` overrides, `UserConfig::default_path` walks down
    // to the HOME-based path; we set HOME explicitly so the test does
    // not depend on the developer's real `~/.config/heddle/`.
    let temp = TempDir::new().unwrap();
    let fake_home = temp.path();
    let config_path = fake_home.join(".config").join("heddle").join("config.toml");
    std::fs::create_dir_all(config_path.parent().unwrap()).expect("mkdir config parent");
    std::fs::write(&config_path, "[output]\nformat = \"auto\"\n").expect("write bad home config");

    let text_out = run_with_home_user_config(fake_home, None, &["status"]);
    assert!(
        !text_out.status.success(),
        "status with HOME user output.format='auto' must fail loudly: stdout={}; stderr={}",
        String::from_utf8_lossy(&text_out.stdout),
        String::from_utf8_lossy(&text_out.stderr)
    );
    let stderr = String::from_utf8_lossy(&text_out.stderr);
    assert_typed_output_format_envelope(&stderr, "HOME-based user config");

    // Codex R3 (cid 3313132711): the recovery hint must cite the actual
    // discovered file — `$HOME/.config/heddle/config.toml` in this case
    // — so the user does not have to guess which of `HEDDLE_CONFIG`,
    // `XDG_CONFIG_HOME`-derived, HOME-derived, or repo config holds the
    // bad value. Mirrors `UserConfig::load`'s `canonicalize()`.
    let expected_path = config_path
        .canonicalize()
        .expect("HOME-based user config path canonicalizes");
    let expected_path = expected_path.display().to_string();
    assert!(
        stderr.contains(&expected_path),
        "text envelope should cite the HOME-based config path {expected_path}: stderr={stderr}"
    );

    let json_out = run_with_home_user_config(fake_home, None, &["--output", "json", "status"]);
    assert!(
        !json_out.status.success(),
        "status --output json with HOME user output.format='auto' must fail too: stderr={}",
        String::from_utf8_lossy(&json_out.stderr)
    );
    let envelope = parse_envelope(&json_out.stderr);
    let hint = envelope["hint"]
        .as_str()
        .expect("envelope.hint should be a string");
    assert!(
        hint.contains(&expected_path),
        "JSON envelope hint should cite the HOME-based config path {expected_path}: hint={hint}"
    );
    assert!(
        !hint.contains(".heddle/config.toml"),
        "hint must not point at the repo config when the bad value is in the HOME user config: hint={hint}"
    );
}

#[test]
fn repo_config_output_format_auto_exits_data_err() {
    // Codex R4 (cid 3315305484): the r3 `ConfigParse` wrapping stored only
    // `err.to_string()`, flattening the source `toml::de::Error` out of the
    // chain. `HeddleExitCode::from_error` classifies config-parse failures by
    // downcasting to `toml::de::Error`; with the source gone it fell through
    // to `EX_IOERR` (74). The exit code must stay `EX_DATAERR` (65) so retry
    // agents and the JSON envelope's `exit_code` report the failure class
    // correctly.
    let temp = TempDir::new().unwrap();
    heddle(&["init"], Some(temp.path())).expect("init should succeed");
    let config_path = temp.path().join(".heddle").join("config.toml");
    let existing = std::fs::read_to_string(&config_path).expect("repo config exists after init");
    let mutated = existing.replace("[output]\n", "[output]\nformat = \"auto\"\n");
    std::fs::write(&config_path, &mutated).expect("write mutated config");

    let out = heddle_output(&["status"], Some(temp.path())).expect("status runs with bad config");
    assert_eq!(
        out.status.code(),
        Some(EX_DATAERR),
        "repo config output.format='auto' must exit EX_DATAERR (65), not EX_IOERR (74): stderr={}",
        String::from_utf8_lossy(&out.stderr)
    );

    let json_out = heddle_output(&["--output", "json", "status"], Some(temp.path()))
        .expect("status --output json runs with bad config");
    assert_eq!(
        json_out.status.code(),
        Some(EX_DATAERR),
        "JSON-mode exit code must also be EX_DATAERR (65): stderr={}",
        String::from_utf8_lossy(&json_out.stderr)
    );
    let envelope = parse_envelope(&json_out.stderr);
    assert_eq!(
        envelope["exit_code"].as_u64(),
        Some(EX_DATAERR as u64),
        "JSON envelope exit_code must report EX_DATAERR (65): {envelope}"
    );
}

#[test]
fn user_config_heddle_config_output_format_auto_exits_data_err() {
    // Mirror of the repo-config case for the `HEDDLE_CONFIG` source.
    let temp = TempDir::new().unwrap();
    let bad_user_config = temp.path().join("user-config.toml");
    std::fs::write(&bad_user_config, "[output]\nformat = \"auto\"\n")
        .expect("write bad user config");

    let out = run_with_bad_user_config(&bad_user_config, None, &["status"]);
    assert_eq!(
        out.status.code(),
        Some(EX_DATAERR),
        "HEDDLE_CONFIG output.format='auto' must exit EX_DATAERR (65), not EX_IOERR (74): stderr={}",
        String::from_utf8_lossy(&out.stderr)
    );

    let json_out =
        run_with_bad_user_config(&bad_user_config, None, &["--output", "json", "status"]);
    assert_eq!(
        json_out.status.code(),
        Some(EX_DATAERR),
        "JSON-mode exit code must also be EX_DATAERR (65): stderr={}",
        String::from_utf8_lossy(&json_out.stderr)
    );
    let envelope = parse_envelope(&json_out.stderr);
    assert_eq!(
        envelope["exit_code"].as_u64(),
        Some(EX_DATAERR as u64),
        "JSON envelope exit_code must report EX_DATAERR (65): {envelope}"
    );
}

#[test]
fn user_config_home_output_format_auto_exits_data_err() {
    // Mirror of the repo-config case for the `$HOME/.config` source.
    let temp = TempDir::new().unwrap();
    let fake_home = temp.path();
    let config_path = fake_home.join(".config").join("heddle").join("config.toml");
    std::fs::create_dir_all(config_path.parent().unwrap()).expect("mkdir config parent");
    std::fs::write(&config_path, "[output]\nformat = \"auto\"\n").expect("write bad home config");

    let out = run_with_home_user_config(fake_home, None, &["status"]);
    assert_eq!(
        out.status.code(),
        Some(EX_DATAERR),
        "HOME user output.format='auto' must exit EX_DATAERR (65), not EX_IOERR (74): stderr={}",
        String::from_utf8_lossy(&out.stderr)
    );

    let json_out = run_with_home_user_config(fake_home, None, &["--output", "json", "status"]);
    assert_eq!(
        json_out.status.code(),
        Some(EX_DATAERR),
        "JSON-mode exit code must also be EX_DATAERR (65): stderr={}",
        String::from_utf8_lossy(&json_out.stderr)
    );
    let envelope = parse_envelope(&json_out.stderr);
    assert_eq!(
        envelope["exit_code"].as_u64(),
        Some(EX_DATAERR as u64),
        "JSON envelope exit_code must report EX_DATAERR (65): {envelope}"
    );
}

fn run_with_bad_user_config(
    config_path: &std::path::Path,
    cwd: Option<&std::path::Path>,
    args: &[&str],
) -> std::process::Output {
    let temp;
    let dir = match cwd {
        Some(dir) => dir.to_path_buf(),
        None => {
            temp = TempDir::new().expect("tempdir for cwd");
            temp.path().to_path_buf()
        }
    };
    Command::new(env!("CARGO_BIN_EXE_heddle"))
        .args(args)
        .current_dir(&dir)
        .env("HEDDLE_CONFIG", config_path)
        .output()
        .expect("spawn heddle")
}

fn run_with_home_user_config(
    home: &std::path::Path,
    cwd: Option<&std::path::Path>,
    args: &[&str],
) -> std::process::Output {
    let temp;
    let dir = match cwd {
        Some(dir) => dir.to_path_buf(),
        None => {
            temp = TempDir::new().expect("tempdir for cwd");
            temp.path().to_path_buf()
        }
    };
    Command::new(env!("CARGO_BIN_EXE_heddle"))
        .args(args)
        .current_dir(&dir)
        .env_remove("HEDDLE_CONFIG")
        .env_remove("XDG_CONFIG_HOME")
        .env("HOME", home)
        .output()
        .expect("spawn heddle")
}

fn assert_typed_output_format_envelope(stderr: &str, context: &str) {
    assert!(
        stderr.contains("output.format") && stderr.contains("'auto'"),
        "{context}: text envelope should name the field and the rejected value: {stderr}"
    );
    assert!(
        stderr.contains("'text'") && stderr.contains("'json'"),
        "{context}: text envelope should list the valid values: {stderr}"
    );
    assert!(
        stderr.contains("Next:"),
        "{context}: text envelope should carry a typed Next: line: {stderr}"
    );
    assert!(
        !stderr.contains("TOML parse error"),
        "{context}: raw TOML parse error must not leak past the typed envelope: {stderr}"
    );
}

fn parse_envelope(stderr_bytes: &[u8]) -> Value {
    let stderr = String::from_utf8_lossy(stderr_bytes);
    let line = stderr
        .lines()
        .rfind(|line| line.trim_start().starts_with('{'))
        .unwrap_or_else(|| panic!("expected JSON envelope on stderr; got: {stderr}"));
    serde_json::from_str(line.trim())
        .unwrap_or_else(|err| panic!("stderr JSON envelope should parse: {err}: {line}"))
}

#[test]
fn output_mode_auto_variant_is_absent_from_source() {
    // Belt-and-braces grep: if anyone re-adds `OutputMode::Auto` or
    // `OutputFormat::Auto`, this test fails before runtime behaviour does.
    // We scan the cli + repo source trees relative to CARGO_MANIFEST_DIR.
    let cli_src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
    let repo_src = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .expect("crates dir")
        .join("repo")
        .join("src");
    for root in [cli_src, repo_src] {
        for entry in walkdir(&root) {
            if entry.extension().and_then(|ext| ext.to_str()) != Some("rs") {
                continue;
            }
            let contents = std::fs::read_to_string(&entry).expect("read rs file");
            assert!(
                !contents.contains("OutputMode::Auto"),
                "{} still references OutputMode::Auto",
                entry.display()
            );
            assert!(
                !contents.contains("OutputFormat::Auto"),
                "{} still references OutputFormat::Auto",
                entry.display()
            );
        }
    }
}

fn walkdir(root: &std::path::Path) -> Vec<std::path::PathBuf> {
    let mut out = Vec::new();
    let mut stack = vec![root.to_path_buf()];
    while let Some(dir) = stack.pop() {
        let read = match std::fs::read_dir(&dir) {
            Ok(read) => read,
            Err(_) => continue,
        };
        for entry in read.flatten() {
            let path = entry.path();
            if path.is_dir() {
                stack.push(path);
            } else {
                out.push(path);
            }
        }
    }
    out
}