paperboy 0.6.0

A Rust TUI API tester
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
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
//! Headless CLI report runner:
//! `paperboy -c collection -e env -r report [--dry-run] [-o out.csv|-]`.
//!
//! The report engine ([`crate::report`]) is front-end agnostic, so this module
//! is a thin CLI shell around it: it loads the report / collection / environment
//! files, assembles a [`RunContext`], runs the flow (live, or a no-HTTP dry
//! expansion under `--dry-run`), streams a `done/total` progress line to stderr,
//! and writes the tabular result (CSV in v1) to a file or stdout.
//!
//! Decorative/progress output goes to **stderr** so that `-o -` can emit clean
//! CSV to stdout for piping; a file/derived output prints its human summary to
//! stdout instead.

use std::collections::HashMap;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};

use crate::environment::{looks_like_env, parse_vars};
use crate::postman::{looks_like_postman, parse_collection};
use crate::report::flow::Header;
use crate::report::params::{ParamValues, undeclared};
use crate::report::producers::resolve_path;
use crate::report::report::{expand_output_tokens, name_has_output_token};
use crate::report::run::{DryRunner, LiveRunner, RowEvent, RunContext, finalize, run_flow_raw};
use crate::report::validate::{Context, Severity, validate};
use crate::report::writer::{OUTPUT_EXTENSIONS, writer_for_extension};
use crate::report::{CsvWriter, Report, ReportResult, ReportWriter};
use crate::shared_utils::sanitize_file_stem;

/// A seed for a bare `--shuffle`, from the clock.
///
/// Not cryptographic and not meant to be: it only has to differ between runs so
/// that repeated runs explore different legal orders, and it is printed, which
/// is what makes a failure reproducible.
fn random_seed() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_nanos() as u64)
        .unwrap_or(1)
}

/// Run a report headlessly. Returns an OS exit code: 0 when the report was
/// produced and every row ran cleanly, 1 on a fatal setup/validation error
/// *or* when the run collected per-row errors. The output is still written in
/// that second case — the non-zero code is there so a CI pipeline can't pass
/// green on a report in which every request failed.
///
/// `collection` names the collection to run against (re-pointable without
/// editing the report); when `None`, the report's own `# collection:` header is
/// used, resolved relative to the report's folder. `env_paths` are zero or more
/// environments used as the base variable layer and (when repeated) the
/// environments an `ENVS` loop can select by name; when empty, the report's
/// `# environment:` header (if any) is used, likewise resolved relative to the
/// report. `--dry-run` expands the flow without sending any request, and `-o`
/// chooses the output (`-` = stdout; a path whose extension selects the format;
/// omitted = the `# output:` format written to a `# name:`-derived file next to
/// the report, honouring the `{time}` token). `outputs` is repeatable: one run
/// renders the same result once per requested format. `params` are the `--param
/// NAME=VALUE` values for the report's `PARAM` declarations; anything not
/// supplied falls back to the default written in the report.
pub fn run(
    collection_path: Option<String>,
    env_paths: Vec<String>,
    report_path: String,
    outputs: Vec<String>,
    dry_run: bool,
    targets: Vec<String>,
    shuffle: Option<Option<u64>>,
    params: ParamValues,
) -> i32 {
    // stdout stays clean for a piped CSV (`-o -`); everything human goes to the
    // "decorative" stream, which is stderr in that case and stdout otherwise.
    let to_stdout = outputs.iter().any(|o| o == "-");

    // --- report ----------------------------------------------------------
    let report = match Report::load_local(&report_path) {
        Ok(r) => r,
        Err(e) => {
            eprintln!("error: cannot read report file: {e}");
            return 1;
        }
    };
    let mut flow = match report.flow() {
        Ok(f) => f,
        Err(e) => {
            eprintln!("error: report '{report_path}' has a syntax error: {e}");
            return 1;
        }
    };
    // The report's folder anchors every relative reference it makes: the
    // `# collection:`/`# environment:` header fallbacks below, and (later) the
    // `# root:` producer/baseline base directory.
    let report_dir = report.path.as_deref().and_then(Path::parent);

    // --- parameters ------------------------------------------------------
    // Checked here, before anything is loaded or sent: a `--param` naming a
    // parameter this report doesn't declare is almost always a caller whose
    // command line has drifted from the script, and letting it through would
    // run the whole report against the default it thought it had replaced.
    let undeclared_params = undeclared(&flow.params(), &params);
    if !undeclared_params.is_empty() {
        let declared = flow.params();
        let known = if declared.is_empty() {
            "it declares none".to_string()
        } else {
            format!(
                "it declares: {}",
                declared
                    .iter()
                    .map(|p| p.name.as_str())
                    .collect::<Vec<_>>()
                    .join(", ")
            )
        };
        eprintln!(
            "error: report '{report_path}' has no parameter named {} ({known})",
            undeclared_params
                .iter()
                .map(|n| format!("'{n}'"))
                .collect::<Vec<_>>()
                .join(", ")
        );
        return 1;
    }

    // --- outputs ---------------------------------------------------------
    // Judged before anything is loaded or sent, for the same reason the
    // parameters above are: a mistyped format is a setup error, and finding it
    // only once the report has been rendered would mean paying for a whole run
    // of live requests to be told where it couldn't be written.
    if let Err(e) = check_outputs(&outputs, &flow.header) {
        eprintln!("error: {e}");
        return 1;
    }

    // --- collection ------------------------------------------------------
    // `-c` re-points the report at any collection; when omitted, fall back to
    // the report's own `# collection:` header (resolved relative to the report's
    // folder) so a workspace report "just runs" without repeating the path.
    // A flow that embeds its own requests needs no collection at all: the
    // `REQUESTS` section *is* the collection, which is the whole point of a
    // monitor that ships as one file.
    let embedded = flow.embedded_entries();
    let collection_path = match collection_path {
        Some(c) => Some(c),
        None => match report.collection_ref() {
            Some(c) => Some(resolve_path(report_dir, &c).to_string_lossy().into_owned()),
            // The *declaration* is what excuses the missing collection, not
            // how many requests it yielded. A section that parsed to nothing
            // must reach validation, which knows why it did.
            None if flow.requests.is_some() => None,
            None => {
                eprintln!(
                    "error: no collection to run against — pass -c/--collection, add a '# collection:' header to '{report_path}', or embed the requests in a REQUESTS section"
                );
                return 1;
            }
        },
    };
    let col_content = match &collection_path {
        Some(path) => match fs::read_to_string(path) {
            Ok(c) => c,
            Err(e) => {
                eprintln!("error: cannot read collection file '{path}': {e}");
                return 1;
            }
        },
        None => String::new(),
    };
    let mut entries = match &collection_path {
        Some(_) => parse_collection(&col_content),
        None => Vec::new(),
    };
    // Appended, so an external collection's requests keep the names they had
    // and a collision is visible to validation rather than resolved silently
    // by whichever list was searched first.
    let embedded_count = embedded.len();
    entries.extend(embedded);
    let collection_path = collection_path.unwrap_or_else(|| {
        let s = if embedded_count == 1 { "" } else { "s" };
        format!("(embedded: {embedded_count} request{s})")
    });
    if entries.is_empty() {
        // As in `cli.rs`: prefer the concrete Hurl parse reason (line + what's
        // wrong) when the source is Hurl — one malformed line rejects the whole
        // file, so "no requests found" alone hides the real cause.
        // A `REQUESTS` section that yielded nothing is the likelier cause when
        // the flow has one, and it knows its own line numbers within the file.
        let embedded_why = flow
            .requests
            .as_deref()
            .and_then(|t| crate::hurl::parse_hurl_error_from(t, flow.requests_line.max(1)));
        match embedded_why.or_else(|| {
            (!looks_like_postman(&col_content))
                .then(|| crate::hurl::parse_hurl_error(&col_content))
                .flatten()
        }) {
            Some(why) => eprintln!("error: no requests found in '{collection_path}' — {why}"),
            None => eprintln!("error: no requests found in '{collection_path}'"),
        }
        return 1;
    }

    // --- environment(s) --------------------------------------------------
    // Zero or more `-e` environments. Each is loaded, named by its file stem,
    // and made selectable by that name in an `ENVS` loop — so a
    // `FOR … IN ENVS BASELINE("prod"), COMPARISON("staging")` comparison runs
    // headlessly by passing `-e prod.vars -e staging.vars`. The first `-e`
    // doubles as the base variable layer for requests outside any `ENVS` loop.
    // Distinct stems are required so an `ENVS` clause names an environment
    // unambiguously. Backward compatible with a single `-e`.
    //
    // When no `-e` is given, fall back to the report's `# environment:` header
    // (resolved relative to the report's folder), mirroring the collection
    // fallback above. Explicit `-e` flags always win.
    let env_paths: Vec<String> = if env_paths.is_empty() {
        match report.environment_ref() {
            Some(e) => vec![resolve_path(report_dir, &e).to_string_lossy().into_owned()],
            None => Vec::new(),
        }
    } else {
        env_paths
    };
    let mut base_vars: HashMap<String, String> = HashMap::new();
    let mut named_envs: HashMap<String, HashMap<String, String>> = HashMap::new();
    let mut env_names_loaded: Vec<String> = Vec::new();
    for env_path in &env_paths {
        let env_content = match fs::read_to_string(env_path) {
            Ok(c) => c,
            Err(e) => {
                eprintln!("error: cannot read environment file '{env_path}': {e}");
                return 1;
            }
        };
        if !looks_like_env(&env_content) {
            eprintln!(
                "error: '{env_path}' is not a valid environment file (expected KEY=value lines)"
            );
            return 1;
        }
        let name = crate::shared_utils::stem(env_path, "env");
        if named_envs.contains_key(&name) {
            eprintln!(
                "error: duplicate environment name '{name}' (from '{env_path}') — each -e file must have a distinct stem so an ENVS clause can name it unambiguously"
            );
            return 1;
        }
        let env = parse_vars(name.clone(), &env_content);
        let flat: HashMap<String, String> = env
            .vars
            .iter()
            .map(|v| (v.key.clone(), v.value.clone()))
            .collect();
        // The first environment is the base variable layer.
        if env_names_loaded.is_empty() {
            base_vars = flat.clone();
        }
        named_envs.insert(name.clone(), flat);
        env_names_loaded.push(name);
    }

    // --- validation ------------------------------------------------------
    // Same checks the TUI runs. A hard error blocks a live run (as it does in
    // the TUI); a dry run proceeds regardless so the projected expansion — and
    // any unresolved names as per-row errors — can still be inspected.
    let titles: Vec<String> = entries.iter().map(|e| e.title.clone()).collect();
    let fields: Vec<(String, Vec<String>)> = entries
        .iter()
        .map(|e| {
            (
                e.title.clone(),
                e.reports.iter().map(|(n, _)| n.clone()).collect(),
            )
        })
        .collect();
    let env_names: Vec<String> = named_envs.keys().cloned().collect();
    // The headless runner has nothing open, so every helper collection is read
    // from disk relative to the report.
    let cli_strings = crate::i18n::Strings::for_language(&crate::i18n::Language::English);
    let (helpers, helper_errors) = crate::report::context::load_helpers(
        &[],
        &flow,
        Some(std::path::Path::new(&report_path)),
        &cli_strings,
    );
    // Relative producer paths (and the `# baseline:` snapshot) resolve against
    // `# root:` if set, else the report file's own directory (`report_dir`,
    // computed above). Computed here so validation's baseline-existence check
    // and the run context agree.
    let root: Option<PathBuf> = match flow.header.root() {
        Some(r) if !r.trim().is_empty() => Some(resolve_path(report_dir, r)),
        _ => report_dir.map(Path::to_path_buf),
    };
    // For the variable-availability check: the base env variables and the union
    // of all loaded env variables.
    let base_var_names_owned: Vec<String> = env_names_loaded
        .first()
        .and_then(|first_name| named_envs.get(first_name))
        .map(|m| {
            let mut keys: Vec<String> = m.keys().cloned().collect();
            keys.sort();
            keys
        })
        .unwrap_or_default();
    let mut all_env_var_names_owned: Vec<String> = named_envs
        .values()
        .flat_map(|m| m.keys().cloned())
        .collect();
    all_env_var_names_owned.sort();
    all_env_var_names_owned.dedup();

    let ctx = Context {
        request_titles: Some(&titles),
        env_names: Some(&env_names),
        request_fields: Some(&fields),
        root: root.as_deref(),
        base_var_names: Some(&base_var_names_owned),
        all_env_var_names: Some(&all_env_var_names_owned),
        request_entries: Some(&entries),
        helpers: &helpers,
        helper_errors: &helper_errors,
        // The headless runner has no language setting of its own — its output
        // is read by scripts and CI logs, which is English territory.
        strings: &cli_strings,
    };
    let diags = validate(&flow, &ctx);
    let has_error = diags.iter().any(|d| d.severity == Severity::Error);
    for d in &diags {
        let tag = match d.severity {
            Severity::Error => "error",
            Severity::Warning => "warning",
        };
        eprintln!("{tag}: {}", d.message);
    }
    if has_error && !dry_run {
        eprintln!("error: the report has validation errors — fix them or use --dry-run to preview");
        return 1;
    }

    // --- targets ---------------------------------------------------------
    // Pruning happens after validation and before the run, on the flow itself,
    // so a dry run previews exactly the subset a live run would send. It is
    // refused outright on an unknown target: running a different set of steps
    // than the one asked for is a worse answer than running none.
    if !targets.is_empty()
        && let Err(errs) = crate::report::graph::prune_to_targets(
            &mut flow,
            &targets,
            &entries,
            &helpers,
            &cli_strings,
        )
    {
        for e in errs {
            eprintln!("error: {e}");
        }
        return 1;
    }

    // --- run context -----------------------------------------------------
    // Live requests are rooted at the collection's directory so relative
    // form-file paths resolve as they would when sent by hand.
    let file_root = Path::new(&collection_path).parent().map(Path::to_path_buf);

    let live = LiveRunner {
        file_root: file_root.clone(),
    };
    let dry = DryRunner;

    // --- header block ----------------------------------------------------
    let mut decor = Decor::new(to_stdout);
    decor.line(&format!("PaperBoy — report \"{}\"", report.name));
    decor.line(&format!("  Collection : {collection_path}"));
    if let [one] = env_names_loaded.as_slice() {
        decor.line(&format!("  Environment: {one}"));
    } else if !env_names_loaded.is_empty() {
        decor.line(&format!(
            "  Environments: {} (base: {})",
            env_names_loaded.join(", "),
            env_names_loaded[0]
        ));
    }
    if dry_run {
        decor.line("  Mode       : DRY RUN (no requests sent)");
    }
    if !targets.is_empty() {
        decor.line(&format!("  Targets    : {}", targets.join(", ")));
    }
    // Echoed for the same reason the seed below is: a report is a script's
    // output as much as a person's, and "which folder did last night's run
    // actually look at?" has to be answerable from the run's own log rather
    // than from the calling shell's history. Sorted, since the values arrive
    // as a map.
    if !params.is_empty() {
        let mut supplied: Vec<String> = params
            .iter()
            .map(|(name, value)| format!("{name}={value}"))
            .collect();
        supplied.sort();
        decor.line(&format!("  Parameters : {}", supplied.join(", ")));
    }
    // Printed whether or not a seed was supplied, because a shuffled run that
    // fails is only useful if it can be repeated, and the seed is the whole of
    // what has to be carried from the failing run to the reproduction.
    let shuffle = shuffle.map(|s| s.unwrap_or_else(random_seed));
    if let Some(seed) = shuffle {
        decor.line(&format!(
            "  Shuffle    : seed {seed} (replay with --shuffle={seed})"
        ));
    }
    // The plan, printed as waves rather than a numbered sequence: a numbered
    // list would imply a total order that a graph does not have, and the reason
    // to print it at all is to show what the graph does and does not constrain.
    if dry_run {
        let lines = crate::report::graph::explain(&flow, &entries, &helpers, &cli_strings);
        if !lines.is_empty() {
            decor.line("");
            for l in lines {
                decor.line(&l);
            }
            decor.line("");
        }
    }

    // --- run -------------------------------------------------------------
    let result = if dry_run {
        let ctx = RunContext {
            entries: &entries,
            helpers: &helpers,
            base_vars,
            named_envs,
            root,
            runner: &dry,
            strings: &cli_strings,
            params: params.clone(),
            sink: None,
            shuffle,
        };
        let mut r = run_flow_raw(&flow, &ctx);
        finalize(&mut r, &flow, &ctx);
        decor.line(&format!("  Rows       : {} projected", r.rows.len()));
        r
    } else {
        // Count the projected rows up front (a cheap no-HTTP expansion) so the
        // progress line has a denominator, then run for real, streaming a
        // `done/total` counter to stderr as each row completes.
        let total = {
            let ctx = RunContext {
                entries: &entries,
                helpers: &helpers,
                base_vars: base_vars.clone(),
                named_envs: named_envs.clone(),
                root: root.clone(),
                runner: &dry,
                strings: &cli_strings,
                params: params.clone(),
                sink: None,
                shuffle,
            };
            run_flow_raw(&flow, &ctx).rows.len()
        };
        decor.line(&format!("  Rows       : {total}"));
        let done = std::sync::atomic::AtomicUsize::new(0);
        let sink = |ev: RowEvent| {
            // Count only completed rows for the progress readout (a row is also
            // announced when it starts, which we ignore here).
            if !matches!(ev, RowEvent::Completed(_)) {
                return;
            }
            let n = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
            // Progress is inherently ephemeral; keep it on stderr regardless of
            // where the CSV goes, redrawing one line in place.
            eprint!("\r  running {n}/{total}   ");
            let _ = std::io::stderr().flush();
        };
        let ctx = RunContext {
            entries: &entries,
            helpers: &helpers,
            base_vars,
            named_envs,
            root,
            runner: &live,
            strings: &cli_strings,
            params,
            sink: Some(&sink),
            shuffle,
        };
        let mut r = run_flow_raw(&flow, &ctx);
        finalize(&mut r, &flow, &ctx);
        eprintln!("\r  running {total}/{total}   done");
        r
    };

    // --- warnings, skips, errors -----------------------------------------
    if !result.warnings.is_empty() {
        decor.line(&format!("  Warnings   : {}", result.warnings.len()));
        for w in &result.warnings {
            decor.line(&format!("    ~ {w}"));
        }
    }
    if !result.skipped.is_empty() {
        decor.line(&format!(
            "  Skipped    : {} ({})",
            result.skipped.len(),
            result.skipped.join(", ")
        ));
    }
    if !result.errors.is_empty() {
        decor.line(&format!("  Errors     : {}", result.errors.len()));
        for e in &result.errors {
            decor.line(&format!("    ! {e}"));
        }
    }

    // --- output ----------------------------------------------------------
    // One run, one result, rendered once per requested format — never re-run.
    // Each file is announced as it lands, and a failure part-way through leaves
    // the ones already written where they are: they are faithful renderings of
    // a run that really happened, and removing them would destroy the only
    // record of it to tidy up after a disk that was full.
    let requested: Vec<Option<&str>> = if outputs.is_empty() {
        vec![None]
    } else {
        outputs.iter().map(|o| Some(o.as_str())).collect()
    };
    let mut write_failed = false;
    for target in requested {
        match write_output(&result, &flow.header, target, &report) {
            Ok(OutputTarget::Stdout) => {
                // The CSV already went to stdout; nothing more to print there.
            }
            Ok(OutputTarget::File(path)) => {
                decor.line(&format!("  Output     : {}", path.display()));
            }
            Err(e) => {
                eprintln!("error: cannot write output: {e}");
                write_failed = true;
            }
        }
    }
    if write_failed {
        return 1;
    }

    // The report was produced either way, but a caller scripting this needs to
    // hear what happened in the exit code rather than by scraping the output.
    //
    // 3 beats 1 because it is the more informative of the two, and a skip only
    // ever arises *from* a failure — so exit 3 already implies exit 1's
    // condition while adding the fact that part of the run never happened at
    // all. (2 is left alone: clap uses it for argument errors, and a caller
    // must be able to tell "you invoked me wrongly" from "your API is broken".)
    match (result.skipped.is_empty(), result.errors.is_empty()) {
        (false, _) => EXIT_SKIPPED,
        (true, false) => 1,
        (true, true) => 0,
    }
}

/// The run finished, but some steps never ran because something they depended
/// on failed. Documented in the README; changing it is a breaking change for
/// anyone scripting a release check.
pub const EXIT_SKIPPED: i32 = 3;

/// Where the rendered report ended up (for the closing summary line).
enum OutputTarget {
    Stdout,
    File(PathBuf),
}

/// Serialize `result` and write it to the chosen destination:
/// - `Some("-")`  → stdout (clean CSV, for piping);
/// - `Some(path)` → that file (its extension selects the format: csv/json/xlsx);
/// - `None`       → a file derived from the header (`# output:` format,
///   `# name:`-derived stem honouring `{time}`, next to the report file).
///
/// An unrecognised extension/format is an error naming the supported set.
fn write_output(
    result: &ReportResult,
    header: &Header,
    output: Option<&str>,
    report: &Report,
) -> Result<OutputTarget, String> {
    match output {
        Some("-") => {
            // stdout is for piping text, so it always emits CSV (a binary xlsx
            // to a terminal would be useless); write to a named file for other
            // formats.
            let bytes = CsvWriter.write(result, header)?;
            std::io::stdout()
                .write_all(&bytes)
                .map_err(|e| e.to_string())?;
            Ok(OutputTarget::Stdout)
        }
        Some(path) => {
            let ext = output_extension_of(path);
            let writer = writer_for_extension(&ext).ok_or_else(|| unsupported_ext(&ext))?;
            let bytes = writer.write(result, header)?;
            fs::write(path, bytes).map_err(|e| format!("{path}: {e}"))?;
            Ok(OutputTarget::File(PathBuf::from(path)))
        }
        None => {
            // The format comes from a `# output:` directive (default csv).
            let ext = output_extension_from_header(header)?;
            let writer = writer_for_extension(&ext).ok_or_else(|| unsupported_ext(&ext))?;
            let path = derived_output_path(report, &ext);
            let bytes = writer.write(result, header)?;
            fs::write(&path, bytes).map_err(|e| format!("{}: {e}", path.display()))?;
            Ok(OutputTarget::File(path))
        }
    }
}

/// Everything about `-o` that can be judged before the run, judged before it.
///
/// With nothing chosen the header decides, so the format it names has to exist;
/// with `-o` given, every path must carry a format PaperTrail can write.
fn check_outputs(outputs: &[String], header: &Header) -> Result<(), String> {
    if outputs.is_empty() {
        output_extension_from_header(header)?;
        return Ok(());
    }
    // Two formats written to one pipe would interleave into something that is
    // neither of them, and there is no second stdout to send the other to.
    if outputs.iter().filter(|o| o.as_str() == "-").count() > 1 {
        return Err("-o - was given more than once, but there is only one stdout".to_string());
    }
    let mut seen: Vec<&str> = Vec::new();
    for out in outputs {
        if out == "-" {
            continue;
        }
        // Writing the same path twice means the second write destroys the
        // first, so the run would quietly produce one file where two were
        // asked for. Far more likely a typo in one of them than an intent.
        if seen.contains(&out.as_str()) {
            return Err(format!("-o {out} was given more than once"));
        }
        seen.push(out);
        let ext = output_extension_of(out);
        if writer_for_extension(&ext).is_none() {
            return Err(unsupported_ext(&ext));
        }
    }
    Ok(())
}

/// The format an output path selects: its extension, lowercased, defaulting to
/// CSV for a path that has none.
fn output_extension_of(path: &str) -> String {
    Path::new(path)
        .extension()
        .and_then(|e| e.to_str())
        .unwrap_or("csv")
        .to_ascii_lowercase()
}

/// The output extension implied by a `# output:` directive: its value lowercased
/// and trimmed (empty ⇒ `csv`). Errors when the named format isn't supported.
fn output_extension_from_header(header: &Header) -> Result<String, String> {
    let ext = header
        .output()
        .map(|f| f.trim().to_ascii_lowercase())
        .filter(|f| !f.is_empty())
        .unwrap_or_else(|| "csv".to_string());
    if writer_for_extension(&ext).is_none() {
        return Err(format!(
            "unsupported '# output:' format '{ext}' (supported: {})",
            OUTPUT_EXTENSIONS.join(", ")
        ));
    }
    Ok(ext)
}

/// The error for an output extension PaperTrail can't write.
fn unsupported_ext(ext: &str) -> String {
    format!(
        "unsupported output extension '.{ext}' (supported: {})",
        OUTPUT_EXTENSIONS.join(", ")
    )
}

/// The default output path when `-o` is omitted: alongside the report file with
/// the `ext` extension, unless the report *name* carries the `{time}` token, in
/// which case the token-expanded, sanitised name wins (a distinct file per run)
/// — placed in the report's own folder. Mirrors the TUI's `csv_export_path`.
fn derived_output_path(report: &Report, ext: &str) -> PathBuf {
    if name_has_output_token(&report.name) {
        let stem = sanitize_file_stem(&expand_output_tokens(&report.name));
        let file = format!("{stem}.{ext}");
        return match report.path.as_deref().and_then(Path::parent) {
            Some(dir) => dir.join(file),
            None => PathBuf::from(file),
        };
    }
    if let Some(path) = &report.path {
        return path.with_extension(ext);
    }
    PathBuf::from(format!("{}.{ext}", sanitize_file_stem(&report.name)))
}

/// Routes human-readable lines to the right stream: stderr when the CSV is going
/// to stdout (`-o -`, so stdout stays clean for piping), stdout otherwise.
struct Decor {
    to_stderr: bool,
}

impl Decor {
    fn new(csv_to_stdout: bool) -> Self {
        Decor {
            to_stderr: csv_to_stdout,
        }
    }
    fn line(&mut self, s: &str) {
        if self.to_stderr {
            eprintln!("{s}");
        } else {
            println!("{s}");
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// A unique scratch directory for a test, cleaned up by the caller.
    fn temp_dir(tag: &str) -> PathBuf {
        let dir = std::env::temp_dir().join(format!(
            "paperboy_report_cli_{tag}_{}",
            uuid::Uuid::new_v4()
        ));
        fs::create_dir_all(&dir).unwrap();
        dir
    }

    #[test]
    fn sanitize_file_stem_replaces_path_and_awkward_chars() {
        assert_eq!(sanitize_file_stem("a/b:c"), "a_b_c");
        assert_eq!(sanitize_file_stem("../escape"), "___escape");
        assert_eq!(sanitize_file_stem("  keep me-1_2  "), "keep me-1_2");
        // Empty / all-punctuation names fall back to a safe default.
        assert_eq!(sanitize_file_stem("   "), "report");
    }

    #[test]
    fn derived_output_path_uses_report_path_for_plain_name() {
        let mut report = Report::from_text("nightly", "# name: nightly\n");
        report.path = Some(PathBuf::from("/reports/nightly.trail"));
        assert_eq!(
            derived_output_path(&report, "csv"),
            PathBuf::from("/reports/nightly.csv")
        );
    }

    #[test]
    fn derived_output_path_expands_time_token_next_to_report() {
        let mut report = Report::from_text("run_{time}", "# name: run_{time}\n");
        report.path = Some(PathBuf::from("/reports/nightly.trail"));
        let out = derived_output_path(&report, "csv");
        let name = out.file_name().unwrap().to_string_lossy();
        // Token expanded (no literal "{time}") and placed in the report's dir.
        assert!(name.starts_with("run_"), "unexpected name: {name}");
        assert!(name.ends_with(".csv"), "unexpected name: {name}");
        assert!(!name.contains("{time}"), "token not expanded: {name}");
        assert_eq!(out.parent(), Some(Path::new("/reports")));
    }

    #[test]
    fn derived_output_path_pathless_report_sanitizes_name() {
        let report = Report::from_text("weird/name", "# name: weird/name\n");
        assert_eq!(
            derived_output_path(&report, "csv"),
            PathBuf::from("weird_name.csv")
        );
    }

    #[test]
    fn dry_run_writes_projected_csv_to_file() {
        let dir = temp_dir("dry");
        let coll = dir.join("api.hurl");
        fs::write(&coll, "# Ping\nGET https://example.test/ping\nHTTP *\n").unwrap();
        let report = dir.join("r.trail");
        fs::write(
            &report,
            "# name: r\n# collection: api.hurl\n# columns: Ping.HttpStatus as Status\nREPORT REQUEST Ping\n",
        )
        .unwrap();
        let out = dir.join("out.csv");

        let code = run(
            Some(coll.to_string_lossy().into_owned()),
            Vec::new(),
            report.to_string_lossy().into_owned(),
            vec![out.to_string_lossy().into_owned()],
            true, // dry-run: no HTTP
            Vec::new(),
            None,
            ParamValues::new(),
        );
        assert_eq!(code, 0, "dry run should succeed");

        let csv = fs::read_to_string(&out).unwrap();
        let mut lines = csv.lines();
        assert_eq!(lines.next(), Some("Status"), "header row");
        // One projected row exists (the dry cell value is a placeholder).
        assert!(lines.next().is_some(), "one projected row expected");

        fs::remove_dir_all(&dir).ok();
    }

    /// The point of `--param` for a caller shelling out to PaperBoy: one
    /// report, pointed at a different folder per run, without editing the
    /// `.trail` or writing a throwaway `.vars` file. The supplied value has to
    /// beat the declared default and reach the producer path, which is what
    /// decides how many rows there are.
    #[test]
    fn a_supplied_param_repoints_a_folders_loop() {
        let dir = temp_dir("param");
        let coll = dir.join("api.hurl");
        fs::write(&coll, "# Ping\nGET https://example.test/ping\nHTTP *\n").unwrap();

        // The batch this run is about, next to a decoy the default points at.
        let batch = dir.join("batch-07");
        fs::create_dir_all(batch.join("case-a")).unwrap();
        fs::create_dir_all(batch.join("case-b")).unwrap();
        fs::create_dir_all(dir.join("empty")).unwrap();

        let report = dir.join("r.trail");
        fs::write(
            &report,
            "# name: r\n# collection: api.hurl\nPARAM FOLDER CASES = \"./empty\"\n\
             FOR CASE IN FOLDERS \"{{CASES}}\"\n    REPORT CASE\n    REPORT REQUEST Ping\nEND\n",
        )
        .unwrap();

        let run_with = |params: ParamValues, out: &Path| {
            run(
                Some(coll.to_string_lossy().into_owned()),
                Vec::new(),
                report.to_string_lossy().into_owned(),
                vec![out.to_string_lossy().into_owned()],
                true, // dry-run: the loop still expands, no HTTP
                Vec::new(),
                None,
                params,
            )
        };

        // The default is honoured when nothing is supplied: an empty folder,
        // so nothing to iterate.
        let default_out = dir.join("default.csv");
        assert_eq!(run_with(ParamValues::new(), &default_out), 0);
        let csv = fs::read_to_string(&default_out).unwrap();
        assert!(
            !csv.contains("case-a"),
            "the declared default should still point at ./empty:\n{csv}"
        );

        // …and is beaten by the value this run was given.
        let chosen_out = dir.join("chosen.csv");
        let mut params = ParamValues::new();
        params.insert("CASES".into(), batch.to_string_lossy().into_owned());
        assert_eq!(run_with(params, &chosen_out), 0);
        let csv = fs::read_to_string(&chosen_out).unwrap();
        assert!(
            csv.contains("case-a") && csv.contains("case-b"),
            "both cases from the supplied folder expected:\n{csv}"
        );

        fs::remove_dir_all(&dir).ok();
    }

    /// A `--param` the report doesn't declare is a caller whose command line
    /// has drifted from the script. Running anyway would produce a full report
    /// built from the default it believed it had replaced, so it stops.
    #[test]
    fn a_param_the_report_does_not_declare_is_refused() {
        let dir = temp_dir("badparam");
        let coll = dir.join("api.hurl");
        fs::write(&coll, "# Ping\nGET https://example.test/ping\nHTTP *\n").unwrap();
        let report = dir.join("r.trail");
        fs::write(
            &report,
            "# name: r\n# collection: api.hurl\nPARAM FOLDER CASES = \"./empty\"\n\
             # columns: Ping.HttpStatus as Status\nREPORT REQUEST Ping\n",
        )
        .unwrap();
        let out = dir.join("out.csv");

        let mut params = ParamValues::new();
        params.insert("CASE_DIR".into(), "./whatever".into());
        let code = run(
            Some(coll.to_string_lossy().into_owned()),
            Vec::new(),
            report.to_string_lossy().into_owned(),
            vec![out.to_string_lossy().into_owned()],
            true,
            Vec::new(),
            None,
            params,
        );
        assert_eq!(code, 1, "an undeclared parameter is a setup error");
        assert!(!out.exists(), "nothing should be written for a refused run");

        fs::remove_dir_all(&dir).ok();
    }

    /// The point of a repeatable `-o` for an application embedding PaperBoy:
    /// one run of the requests yields a rendering to show a user *and* a
    /// structure to parse, instead of running the whole report twice and
    /// hoping the two runs agree.
    #[test]
    fn one_run_writes_every_requested_format() {
        let dir = temp_dir("multiout");
        let coll = dir.join("api.hurl");
        fs::write(&coll, "# Ping\nGET https://example.test/ping\nHTTP *\n").unwrap();
        let report = dir.join("r.trail");
        fs::write(
            &report,
            "# name: r\n# collection: api.hurl\n# columns: Ping.HttpStatus as Status\n\
             REPORT REQUEST Ping\n",
        )
        .unwrap();

        let html = dir.join("out.html");
        let json = dir.join("out.json");
        let csv = dir.join("out.csv");
        let code = run(
            Some(coll.to_string_lossy().into_owned()),
            Vec::new(),
            report.to_string_lossy().into_owned(),
            vec![
                html.to_string_lossy().into_owned(),
                json.to_string_lossy().into_owned(),
                csv.to_string_lossy().into_owned(),
            ],
            true, // dry-run: no HTTP
            Vec::new(),
            None,
            ParamValues::new(),
        );
        assert_eq!(code, 0, "a multi-output dry run should succeed");

        // Each file exists and is actually in its own format, so the extension
        // picked the writer rather than one format being written three times.
        let html_text = fs::read_to_string(&html).unwrap();
        assert!(html_text.contains("<table"), "not HTML:\n{html_text}");
        let json_text = fs::read_to_string(&json).unwrap();
        assert!(
            serde_json::from_str::<serde_json::Value>(&json_text).is_ok(),
            "not JSON:\n{json_text}"
        );
        let csv_text = fs::read_to_string(&csv).unwrap();
        assert!(csv_text.starts_with("Status"), "not CSV:\n{csv_text}");

        fs::remove_dir_all(&dir).ok();
    }

    /// The `-o` combinations that cannot mean what they say, refused before a
    /// single request goes out — a typo in a format should not cost a whole
    /// run of live traffic to discover.
    #[test]
    fn impossible_output_combinations_are_refused_before_the_run() {
        let dir = temp_dir("badout");
        let coll = dir.join("api.hurl");
        fs::write(&coll, "# Ping\nGET https://example.test/ping\nHTTP *\n").unwrap();
        let report = dir.join("r.trail");
        fs::write(
            &report,
            "# name: r\n# collection: api.hurl\n# columns: Ping.HttpStatus as Status\n\
             REPORT REQUEST Ping\n",
        )
        .unwrap();

        let go = |outs: Vec<String>| {
            run(
                Some(coll.to_string_lossy().into_owned()),
                Vec::new(),
                report.to_string_lossy().into_owned(),
                outs,
                true,
                Vec::new(),
                None,
                ParamValues::new(),
            )
        };

        // There is only one stdout, and two formats down it would interleave
        // into neither of them.
        assert_eq!(go(vec!["-".into(), "-".into()]), 1, "two stdouts");

        // The same path twice means the second write destroys the first.
        let dup = dir.join("out.json").to_string_lossy().into_owned();
        assert_eq!(go(vec![dup.clone(), dup.clone()]), 1, "duplicate path");
        assert!(
            !dir.join("out.json").exists(),
            "a refused run writes nothing"
        );

        // A format PaperTrail can't write, alongside one it can: the good one
        // must not be written either, or a caller gets a partial answer from a
        // command line that was rejected.
        let good = dir.join("fine.csv");
        assert_eq!(
            go(vec![
                good.to_string_lossy().into_owned(),
                dir.join("out.docx").to_string_lossy().into_owned(),
            ]),
            1,
            "unsupported extension"
        );
        assert!(!good.exists(), "nothing is written for a refused run");

        fs::remove_dir_all(&dir).ok();
    }

    /// `-o -` mixed with files is the shape an integrator actually uses: pipe
    /// one format onward while keeping another on disk.
    #[test]
    fn stdout_and_a_file_can_be_asked_for_together() {
        let dir = temp_dir("mixedout");
        let coll = dir.join("api.hurl");
        fs::write(&coll, "# Ping\nGET https://example.test/ping\nHTTP *\n").unwrap();
        let report = dir.join("r.trail");
        fs::write(
            &report,
            "# name: r\n# collection: api.hurl\n# columns: Ping.HttpStatus as Status\n\
             REPORT REQUEST Ping\n",
        )
        .unwrap();
        let json = dir.join("out.json");
        let code = run(
            Some(coll.to_string_lossy().into_owned()),
            Vec::new(),
            report.to_string_lossy().into_owned(),
            vec!["-".into(), json.to_string_lossy().into_owned()],
            true,
            Vec::new(),
            None,
            ParamValues::new(),
        );
        assert_eq!(code, 0);
        assert!(json.exists(), "the file output still lands");

        fs::remove_dir_all(&dir).ok();
    }

    #[test]
    fn multi_env_loads_every_env_and_runs_the_envs_loop() {
        // Two `-e` files with distinct stems make a `FOR … IN ENVS` loop
        // resolvable headlessly: both environments load, are selectable by
        // stem, and the flow iterates once per environment.
        let dir = temp_dir("multienv");
        let coll = dir.join("api.hurl");
        fs::write(&coll, "# Ping\nGET https://example.test/ping\nHTTP *\n").unwrap();
        fs::write(dir.join("prod.vars"), "HOST=prod.test\n").unwrap();
        fs::write(dir.join("staging.vars"), "HOST=staging.test\n").unwrap();
        let report = dir.join("r.trail");
        fs::write(
            &report,
            "# name: r\n# collection: api.hurl\nFOR TARGET IN ENVS \"prod\", \"staging\"\n    REPORT TARGET\n    REPORT REQUEST Ping\nEND\n",
        )
        .unwrap();
        let out = dir.join("out.csv");

        let code = run(
            Some(coll.to_string_lossy().into_owned()),
            vec![
                dir.join("prod.vars").to_string_lossy().into_owned(),
                dir.join("staging.vars").to_string_lossy().into_owned(),
            ],
            report.to_string_lossy().into_owned(),
            vec![out.to_string_lossy().into_owned()],
            true, // dry-run: no HTTP, but the ENVS loop still expands per env
            Vec::new(),
            None,
            ParamValues::new(),
        );
        assert_eq!(code, 0, "a multi-env dry run should succeed");

        let csv = fs::read_to_string(&out).unwrap();
        // The ENVS loop iterated once per loaded environment (no "not loaded"
        // errors), so both env names appear in the reported TARGET column.
        assert!(csv.contains("prod"), "prod env row missing:\n{csv}");
        assert!(csv.contains("staging"), "staging env row missing:\n{csv}");

        fs::remove_dir_all(&dir).ok();
    }

    #[test]
    fn duplicate_env_stem_is_rejected() {
        // Two `-e` files that share a stem are ambiguous for an ENVS clause, so
        // the second is a fatal setup error.
        let dir = temp_dir("dupenv");
        let coll = dir.join("api.hurl");
        fs::write(&coll, "# Ping\nGET https://example.test/ping\nHTTP *\n").unwrap();
        let a = dir.join("a");
        let b = dir.join("b");
        fs::create_dir_all(&a).unwrap();
        fs::create_dir_all(&b).unwrap();
        fs::write(a.join("prod.vars"), "HOST=a.test\n").unwrap();
        fs::write(b.join("prod.vars"), "HOST=b.test\n").unwrap();
        let report = dir.join("r.trail");
        fs::write(
            &report,
            "# name: r\n# collection: api.hurl\nREPORT REQUEST Ping\n",
        )
        .unwrap();

        let code = run(
            Some(coll.to_string_lossy().into_owned()),
            vec![
                a.join("prod.vars").to_string_lossy().into_owned(),
                b.join("prod.vars").to_string_lossy().into_owned(),
            ],
            report.to_string_lossy().into_owned(),
            vec!["-".to_string()],
            true,
            Vec::new(),
            None,
            ParamValues::new(),
        );
        assert_eq!(code, 1, "a duplicate env stem is a fatal setup error");

        fs::remove_dir_all(&dir).ok();
    }

    #[test]
    fn missing_collection_is_a_setup_error() {
        let dir = temp_dir("nocoll");
        let report = dir.join("r.trail");
        fs::write(
            &report,
            "# name: r\n# collection: missing.hurl\nREPORT REQUEST Ping\n",
        )
        .unwrap();

        let code = run(
            Some(dir.join("missing.hurl").to_string_lossy().into_owned()),
            Vec::new(),
            report.to_string_lossy().into_owned(),
            vec!["-".to_string()],
            true,
            Vec::new(),
            None,
            ParamValues::new(),
        );
        assert_eq!(code, 1, "a missing collection is a fatal setup error");

        fs::remove_dir_all(&dir).ok();
    }

    #[test]
    fn unsupported_output_extension_is_rejected() {
        let dir = temp_dir("badext");
        let coll = dir.join("api.hurl");
        fs::write(&coll, "# Ping\nGET https://example.test/ping\nHTTP *\n").unwrap();
        let report = dir.join("r.trail");
        fs::write(
            &report,
            "# name: r\n# collection: api.hurl\nREPORT REQUEST Ping\n",
        )
        .unwrap();

        let code = run(
            Some(coll.to_string_lossy().into_owned()),
            Vec::new(),
            report.to_string_lossy().into_owned(),
            vec![dir.join("out.docx").to_string_lossy().into_owned()],
            true,
            Vec::new(),
            None,
            ParamValues::new(),
        );
        assert_eq!(code, 1, "an unsupported extension should fail");

        fs::remove_dir_all(&dir).ok();
    }

    #[test]
    fn dry_run_writes_each_supported_output_format() {
        let dir = temp_dir("fmts");
        let coll = dir.join("api.hurl");
        fs::write(&coll, "# Ping\nGET https://example.test/ping\nHTTP *\n").unwrap();
        let report = dir.join("r.trail");
        fs::write(
            &report,
            "# name: r\n# collection: api.hurl\n# columns: Ping.HttpStatus as Status\nREPORT REQUEST Ping\n",
        )
        .unwrap();

        for (ext, check) in [
            (
                "json",
                &(|b: &[u8]| b.starts_with(b"{")) as &dyn Fn(&[u8]) -> bool,
            ),
            (
                "html",
                &(|b: &[u8]| b.starts_with(b"<!DOCTYPE html>")) as &dyn Fn(&[u8]) -> bool,
            ),
            (
                "xlsx",
                &(|b: &[u8]| b.starts_with(b"PK")) as &dyn Fn(&[u8]) -> bool,
            ),
            (
                "pdf",
                &(|b: &[u8]| b.starts_with(b"%PDF-")) as &dyn Fn(&[u8]) -> bool,
            ),
        ] {
            let out = dir.join(format!("out.{ext}"));
            let code = run(
                Some(coll.to_string_lossy().into_owned()),
                Vec::new(),
                report.to_string_lossy().into_owned(),
                vec![out.to_string_lossy().into_owned()],
                true, // dry-run: no HTTP
                Vec::new(),
                None,
                ParamValues::new(),
            );
            assert_eq!(code, 0, ".{ext} output should succeed");
            let bytes = fs::read(&out).unwrap();
            assert!(!bytes.is_empty(), ".{ext} is non-empty");
            assert!(check(&bytes), ".{ext} has the expected magic/shape");
        }

        fs::remove_dir_all(&dir).ok();
    }

    /// With neither `-c` nor `-e`, the report's own `# collection:` and
    /// `# environment:` headers are honoured, resolved relative to the report's
    /// folder — so a workspace report "just runs" with `paperboy -r report`.
    #[test]
    fn headers_supply_collection_and_environment_when_flags_omitted() {
        let dir = temp_dir("hdrres");
        // Put the report in a sub-folder to prove the header paths resolve
        // relative to the report, not the process CWD.
        let sub = dir.join("reports");
        fs::create_dir_all(&sub).unwrap();
        fs::write(
            dir.join("api.hurl"),
            "# Ping\nGET https://example.test/ping\nHTTP *\n",
        )
        .unwrap();
        fs::write(dir.join("prod.vars"), "HOST=prod.test\n").unwrap();
        let report = sub.join("r.trail");
        fs::write(
            &report,
            "# name: r\n# collection: ../api.hurl\n# environment: ../prod.vars\nREPORT HOST\nREPORT REQUEST Ping\n",
        )
        .unwrap();
        let out = dir.join("out.csv");

        let code = run(
            None,       // no -c → header's `# collection:` is used
            Vec::new(), // no -e → header's `# environment:` is used
            report.to_string_lossy().into_owned(),
            vec![out.to_string_lossy().into_owned()],
            true, // dry-run: no HTTP
            Vec::new(),
            None,
            ParamValues::new(),
        );
        assert_eq!(code, 0, "header-resolved run should succeed");

        let csv = fs::read_to_string(&out).unwrap();
        // The environment loaded (HOST from prod.vars is in the projection).
        assert!(csv.contains("prod.test"), "env not applied:\n{csv}");

        fs::remove_dir_all(&dir).ok();
    }

    /// With no `-c` and no `# collection:` header there is nothing to run
    /// against — a clear, fatal setup error.
    #[test]
    fn missing_collection_and_no_header_is_a_setup_error() {
        let dir = temp_dir("nohdr");
        let report = dir.join("r.trail");
        fs::write(&report, "# name: r\nREPORT REQUEST Ping\n").unwrap();

        let code = run(
            None,
            Vec::new(),
            report.to_string_lossy().into_owned(),
            vec!["-".to_string()],
            true,
            Vec::new(),
            None,
            ParamValues::new(),
        );
        assert_eq!(
            code, 1,
            "no collection flag and no header is a fatal setup error"
        );

        fs::remove_dir_all(&dir).ok();
    }
}