en16931-cli 0.7.3

The EN 16931 validator as a command. Validates UBL, CII and ZUGFeRD/Factur-X PDFs against the core model, XRechnung and Peppol BIS Billing 3.0; converts between the two syntaxes; compares two documents as invoices rather than as XML; reports as text, JSON or SVRL.
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
//! `en16931` — the European e-invoice, on the command line.
//!
//! Everything the libraries do, without writing any Rust: read a UBL, CII or
//! ZUGFeRD document, validate it against the core model or a national CIUS,
//! convert between the two syntaxes, and print the verdict as text, JSON or
//! SVRL.
//!
//! # Exit codes, because this belongs in a pipeline
//!
//! | | |
//! |---|---|
//! | `0` | every document passed |
//! | `1` | a document was read and is **invalid** |
//! | `2` | a document could not be read at all, or the command was misused |
//!
//! Telling `1` from `2` is the whole point: a CI job that treats "this invoice
//! is invalid" and "that path does not exist" the same way will eventually ship
//! an invoice because a mount was missing.

mod input;
mod output;

use std::path::{Path, PathBuf};
use std::process::ExitCode;

use clap::{Parser, Subcommand, ValueEnum};
use en16931::validation::profile::Profile;
use en16931::{Severity, profiles};

/// Exit code for "read it, and it is not valid".
const INVALID: u8 = 1;
/// Exit code for "could not read it, or the command was wrong".
const ERROR: u8 = 2;

#[derive(Parser)]
#[command(
    name = "en16931",
    version,
    about = "Validate, convert, compare and inspect European e-invoices (EN 16931)",
    long_about = None,
    max_term_width = 100,
)]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    /// Validate one or more documents.
    ///
    /// Reads UBL, CII and ZUGFeRD/Factur-X PDFs, picking the rule set from the
    /// document's own BT-24 unless told otherwise.
    Validate {
        /// Documents to validate. `-` reads standard input.
        #[arg(required = true, value_name = "PATH")]
        paths: Vec<PathBuf>,

        /// Which rule set to apply: `auto`, or a name from `en16931 profiles`.
        ///
        /// The default reads BT-24 and uses the profile the document declares,
        /// which is what a receiving system does — validating an XRechnung
        /// against the bare core model is the most common way to ship a
        /// document a counterparty then rejects.
        #[arg(long, short, value_name = "PROFILE", default_value = "auto")]
        profile: String,

        /// Output shape.
        #[arg(long, value_enum, default_value_t = Format::Text)]
        format: Format,

        /// Treat warnings and information as failures.
        ///
        /// Off by default because the authorities do not: KoSIT reports
        /// `BR-CL-23` at warning on purpose, and a build that fails on it fails
        /// on invoices Germany accepts.
        #[arg(long)]
        strict: bool,

        /// Skip a rule, by any of its spellings. Repeatable.
        ///
        /// Recorded on the report and printed, and it makes the run unable to
        /// produce a proof — a deviation you cannot see is worse than one you
        /// argued for.
        #[arg(long = "without", value_name = "RULE")]
        without: Vec<String>,

        /// Print nothing; use the exit code.
        #[arg(long, short)]
        quiet: bool,
    },

    /// Convert a document to the other syntax.
    ///
    /// The conversion goes through the semantic model, so the output is what
    /// EN 16931 says the document means — not a transliteration of its
    /// elements.
    Convert {
        /// The document to convert. `-` reads standard input.
        #[arg(value_name = "PATH")]
        path: PathBuf,

        /// Target syntax.
        #[arg(long, value_enum, value_name = "SYNTAX")]
        to: TargetSyntax,

        /// Validate against this profile first, and stamp BT-24 from it.
        ///
        /// Without it the document is written as it stands, which is right when
        /// you are converting something you did not author. With it, nothing is
        /// written unless the model passes — see `--profile` on `validate`.
        #[arg(long, short, value_name = "PROFILE")]
        profile: Option<String>,

        /// Write here instead of standard output.
        #[arg(long, short, value_name = "PATH")]
        output: Option<PathBuf>,
    },

    /// Extract the XML payload from a ZUGFeRD / Factur-X PDF.
    ///
    /// The bytes come out verbatim: whoever diagnoses a rejected invoice
    /// needs what the counterparty sent, not a reconstruction of it.
    Extract {
        /// The hybrid PDF. `-` reads standard input.
        #[arg(value_name = "PATH")]
        path: PathBuf,

        /// Write here instead of standard output.
        #[arg(long, short, value_name = "PATH")]
        output: Option<PathBuf>,
    },

    /// Compare two documents as invoices, not as XML.
    ///
    /// Both are read into the semantic model first, so a UBL invoice and its
    /// CII translation compare equal where an XML diff would show two
    /// unrelated files. That is the question worth asking of a conversion, a
    /// migration, or a counterparty who says they received something else.
    Diff {
        /// The document to compare from. `-` reads standard input.
        #[arg(value_name = "LEFT")]
        left: PathBuf,

        /// The document to compare to.
        #[arg(value_name = "RIGHT")]
        right: PathBuf,

        /// Output shape. SVRL describes a verdict, not a comparison, so it is
        /// refused.
        #[arg(long, value_enum, default_value_t = Format::Text)]
        format: Format,
    },

    /// Say what a document is, without validating it.
    ///
    /// Syntax, declared profile, the totals, and anything the reader could not
    /// map. The first command to run on a document you were sent.
    Inspect {
        /// Documents to inspect. `-` reads standard input.
        #[arg(required = true, value_name = "PATH")]
        paths: Vec<PathBuf>,

        /// Output shape. SVRL is not a document description, so it is refused.
        #[arg(long, value_enum, default_value_t = Format::Text)]
        format: Format,
    },

    /// Look a business rule up by id — `BR-CO-14`, `br-co-14`, `BR-CO-3`.
    Explain {
        /// The rule id, in any of its spellings.
        #[arg(value_name = "RULE")]
        rule: String,
    },

    /// List the profiles this build can validate against.
    Profiles,

    /// May these VAT categories share one invoice? Answered before you build it
    ///
    /// Two of the ten categories are exclusive, and they are the only rules in
    /// EN 16931 that a set of category codes decides on its own — so this needs
    /// no document, no amounts and no parties, which is when the question is
    /// actually asked.
    ///
    ///     en16931 categories S Z      # ordinary: standard beside zero-rated
    ///     en16931 categories S O      # refused — BR-O-11…14
    ///
    /// Exit 0 if they can share a document, 1 if they cannot, 2 if a code is
    /// not a VAT category — the same three answers as validate.
    #[command(verbatim_doc_comment)]
    Categories {
        /// UNCL 5305 codes: `S`, `Z`, `E`, `AE`, `K`, `G`, `O`, `L`, `M`, `B`.
        #[arg(required = true, value_name = "CODE")]
        codes: Vec<String>,
    },

    /// Print the whole rule catalogue — every id, severity, provenance and text.
    ///
    /// The thing to diff across releases, feed to a documentation build, or grep
    /// when a counterparty quotes an id you do not recognise.
    Rules {
        /// Only rules a profile runs, by name or BT-24. Default: everything.
        #[arg(long, short, value_name = "PROFILE")]
        profile: Option<String>,

        /// Only rules touching this business term — `BT-117`, or `117`.
        #[arg(long, value_name = "TERM")]
        term: Option<String>,

        /// Output shape.
        #[arg(long, value_enum, default_value_t = CatalogueFormat::Text)]
        format: CatalogueFormat,
    },

    /// Print a shell completion script, or the man page.
    ///
    ///     en16931 generate bash > /usr/share/bash-completion/completions/en16931
    ///     en16931 generate man  > /usr/share/man/man1/en16931.1
    //
    // `verbatim_doc_comment`, because clap reflows long help to the terminal
    // width and a reflowed shell command is not one. Everything above is
    // already wrapped for a narrow terminal, so nothing else loses by it.
    #[command(verbatim_doc_comment)]
    Generate {
        /// What to write to standard output.
        #[arg(value_name = "WHAT")]
        what: Generate,
    },
}

#[derive(Clone, Copy, ValueEnum)]
enum CatalogueFormat {
    /// One line per rule, aligned.
    Text,
    /// A JSON array, for a documentation build or a diff.
    Json,
}

#[derive(Clone, Copy, ValueEnum)]
enum Generate {
    Bash,
    Zsh,
    Fish,
    #[value(name = "powershell")]
    PowerShell,
    Elvish,
    /// A `man(1)` page for the top-level command.
    Man,
}

#[derive(Clone, Copy, ValueEnum)]
enum Format {
    /// For a person.
    Text,
    /// The stable interchange shape — see `en16931::report`.
    Json,
    /// What every Schematron tool in this field speaks.
    Svrl,
}

#[derive(Clone, Copy, ValueEnum)]
enum TargetSyntax {
    /// OASIS UBL 2.1.
    Ubl,
    /// UN/CEFACT Cross Industry Invoice D16B.
    Cii,
}

fn main() -> ExitCode {
    match run(Cli::parse()) {
        Ok(code) => code,
        Err(message) => {
            eprintln!("en16931: {message}");
            ExitCode::from(ERROR)
        }
    }
}

/// Whether an I/O failure is just the reader having gone away.
///
/// `en16931 convert x.xml --to cii | head` closes the pipe, and Rust's default
/// is to report that as an error on stderr. It is not one: the user asked for
/// the first few lines and got them. Every well-behaved command-line tool is
/// silent here.
fn is_broken_pipe(e: &std::io::Error) -> bool {
    e.kind() == std::io::ErrorKind::BrokenPipe
}

fn run(cli: Cli) -> Result<ExitCode, String> {
    match cli.command {
        Command::Validate {
            paths,
            profile,
            format,
            strict,
            without,
            quiet,
        } => validate(&paths, &profile, format, strict, &without, quiet),
        Command::Convert {
            path,
            to,
            profile,
            output,
        } => convert(&path, to, profile.as_deref(), output.as_deref()),
        Command::Extract { path, output } => extract(&path, output.as_deref()),
        Command::Inspect { paths, format } => inspect(&paths, format),
        Command::Diff {
            left,
            right,
            format,
        } => diff(&left, &right, format),
        Command::Explain { rule } => explain(&rule),
        Command::Profiles => {
            let mut out = String::new();
            output::profiles(&mut out);
            write_out(None, out.as_bytes())?;
            Ok(ExitCode::SUCCESS)
        }
        Command::Categories { codes } => categories(&codes),
        Command::Rules {
            profile,
            term,
            format,
        } => rules(profile.as_deref(), term.as_deref(), format),
        Command::Generate { what } => generate(what),
    }
}

// ── rules ────────────────────────────────────────────────────────────────────

fn rules(
    profile: Option<&str>,
    term: Option<&str>,
    format: CatalogueFormat,
) -> Result<ExitCode, String> {
    let profile = match profile {
        Some(name) => Some(resolve(name)?.ok_or("--profile auto is not meaningful here")?),
        None => None,
    };
    let term = match term {
        // `BT-117` and `117` are the same question asked by two kinds of user.
        Some(t) => Some(
            t.trim_start_matches("BT-")
                .trim_start_matches("bt-")
                .parse::<u16>()
                .map(en16931::BtId)
                .map_err(|_| format!("not a business term: {t:?}. Try `BT-117` or `117`."))?,
        ),
        None => None,
    };
    let mut out = String::new();
    output::catalogue(&mut out, profile, term, format);
    write_out(None, out.as_bytes())?;
    Ok(ExitCode::SUCCESS)
}

// ── generate ─────────────────────────────────────────────────────────────────

fn generate(what: Generate) -> Result<ExitCode, String> {
    use clap::CommandFactory as _;
    let mut cmd = Cli::command();
    let mut out = Vec::new();
    match what {
        Generate::Man => clap_mangen::Man::new(cmd)
            .render(&mut out)
            .map_err(|e| format!("man: {e}"))?,
        shell => {
            let shell = match shell {
                Generate::Bash => clap_complete::Shell::Bash,
                Generate::Zsh => clap_complete::Shell::Zsh,
                Generate::Fish => clap_complete::Shell::Fish,
                Generate::PowerShell => clap_complete::Shell::PowerShell,
                Generate::Elvish => clap_complete::Shell::Elvish,
                Generate::Man => unreachable!("handled above"),
            };
            clap_complete::generate(shell, &mut cmd, "en16931", &mut out);
        }
    }
    write_out(None, &out)?;
    Ok(ExitCode::SUCCESS)
}

// ── validate ─────────────────────────────────────────────────────────────────

fn validate(
    paths: &[PathBuf],
    profile: &str,
    format: Format,
    strict: bool,
    without: &[String],
    quiet: bool,
) -> Result<ExitCode, String> {
    let selected = resolve(profile)?;
    let mut worst = ExitCode::SUCCESS;
    let mut reports = Vec::new();

    for path in paths {
        let loaded = input::load(path).map_err(|e| e.to_string())?;
        // `auto` asks the document. §7.6 exists for exactly this: BT-24 is there
        // so a receiver can apply the rules the sender generated under.
        let profile = selected.unwrap_or_else(|| declared(&loaded.invoice));
        let report = if without.is_empty() {
            profile.validate(&loaded.invoice)
        } else {
            let mut check = en16931::validation::Check::new(profile);
            for rule in without {
                check = check.without(rule.clone());
            }
            check.run(&loaded.invoice)
        };
        let failed = !report.is_valid()
            || (strict
                && report
                    .findings()
                    .iter()
                    .any(|f| f.severity != Severity::Fatal));
        if failed {
            worst = ExitCode::from(INVALID);
        }
        reports.push((loaded, report));
    }

    if !quiet {
        let mut out = String::new();
        output::validation(&mut out, &reports, format);
        write_out(None, out.as_bytes())?;
    }
    Ok(worst)
}

/// The profile a document declares, falling back to the core model.
///
/// Never a guess: an unknown BT-24 means the sender used a usage specification
/// this build does not carry, and checking it against the core rules is the
/// most that can honestly be said about it.
fn declared(invoice: &en16931::Invoice) -> &'static Profile {
    invoice
        .specification_id
        .as_deref()
        .and_then(profiles::for_specification_id)
        .unwrap_or(&profiles::EN16931)
}

/// Resolve `--profile`. `auto` yields `None`, meaning "ask each document".
///
/// By slug, display name or BT-24 — see [`profiles::lookup`]. The slug is the
/// one a person types: the display name is `XRechnung 3.0`, and a profile
/// selector that every shell needs quoted is a profile selector people get
/// wrong.
fn resolve(name: &str) -> Result<Option<&'static Profile>, String> {
    if name.eq_ignore_ascii_case("auto") {
        return Ok(None);
    }
    profiles::lookup(name).map(Some).ok_or_else(|| {
        let known: Vec<&str> = profiles::ALL.iter().map(|p| p.slug).collect();
        format!(
            "unknown profile {name:?}. Known: auto, {} \
             (or a profile's full name, or its BT-24 identifier)",
            known.join(", ")
        )
    })
}

// ── convert ──────────────────────────────────────────────────────────────────

fn convert(
    path: &std::path::Path,
    to: TargetSyntax,
    profile: Option<&str>,
    out: Option<&std::path::Path>,
) -> Result<ExitCode, String> {
    let loaded = input::load(path).map_err(|e| e.to_string())?;
    let profile = match profile {
        Some(name) => Some(resolve(name)?.ok_or("--profile auto is not meaningful here")?),
        None => None,
    };

    // `ubl::Written` and `cii::Written` are distinct types with the same two
    // fields, so each arm reduces to that pair rather than to a common type
    // neither crate declares.
    let written: Result<(String, Vec<String>), String> = match (to, profile) {
        (TargetSyntax::Ubl, None) => {
            let w = en16931_formats::ubl::write(&loaded.invoice);
            Ok((w.xml, w.dropped))
        }
        (TargetSyntax::Cii, None) => {
            let w = en16931_formats::cii::write(&loaded.invoice);
            Ok((w.xml, w.dropped))
        }
        (TargetSyntax::Ubl, Some(p)) => en16931_formats::ubl::write_for(&loaded.invoice, p)
            .map(|w| (w.xml, w.dropped))
            .map_err(|e| format!("{e}\n{}", e.report())),
        (TargetSyntax::Cii, Some(p)) => en16931_formats::cii::write_for(&loaded.invoice, p)
            .map(|w| (w.xml, w.dropped))
            .map_err(|e| format!("{e}\n{}", e.report())),
    };
    let (xml, dropped) = match written {
        Ok(pair) => pair,
        Err(message) => {
            eprintln!("en16931: {message}");
            return Ok(ExitCode::from(INVALID));
        }
    };

    // Anything the target syntax could not carry goes to stderr, so a redirected
    // stdout is the document and nothing else — and the loss is still visible.
    for d in &dropped {
        eprintln!("en16931: dropped, unrepresentable in the target syntax: {d}");
    }
    for n in &loaded.notes {
        eprintln!("en16931: {n}");
    }
    write_out(out, xml.as_bytes())?;
    Ok(ExitCode::SUCCESS)
}

// ── extract ──────────────────────────────────────────────────────────────────

fn extract(path: &std::path::Path, out: Option<&std::path::Path>) -> Result<ExitCode, String> {
    let bytes = if path == std::path::Path::new("-") {
        use std::io::Read as _;
        let mut buf = Vec::new();
        std::io::stdin()
            .read_to_end(&mut buf)
            .map_err(|e| format!("-: {e}"))?;
        buf
    } else {
        std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?
    };
    let got = en16931_formats::zugferd::extract(&bytes)
        .map_err(|e| format!("{}: {e}", path.display()))?;
    for d in &got.divergence {
        eprintln!("en16931: {d}");
    }
    write_out(out, got.xml.as_bytes())?;
    Ok(ExitCode::SUCCESS)
}

// ── inspect ──────────────────────────────────────────────────────────────────

fn inspect(paths: &[PathBuf], format: Format) -> Result<ExitCode, String> {
    let mut loaded = Vec::new();
    for path in paths {
        loaded.push(input::load(path).map_err(|e| e.to_string())?);
    }
    let mut out = String::new();
    match format {
        Format::Text => output::inspect_text(&mut out, &loaded),
        Format::Json => output::inspect_json(&mut out, &loaded),
        Format::Svrl => {
            return Err(
                "SVRL is a validation report format; `inspect` does not validate. \
                 Use --format json, or `validate --format svrl`."
                    .to_owned(),
            );
        }
    }
    write_out(None, out.as_bytes())?;
    Ok(ExitCode::SUCCESS)
}

// ── diff ─────────────────────────────────────────────────────────────────────

/// Compare two documents as invoices.
///
/// # Why this is a comparison nothing XML-first can make
///
/// `diff a.xml b.xml` on two e-invoices answers a question nobody asked. The
/// same invoice written as UBL and as CII shares almost no text; the same
/// invoice written twice by the same system differs in whitespace, element
/// order and optional wrappers. What a person actually wants to know is
/// *"do these say the same thing?"*, and that is a question about the model.
///
/// So both sides are read into an [`Invoice`](en16931::Invoice) first. Two
/// documents in **different syntaxes** compare equal when they carry the same
/// invoice — which is the whole point of a conversion, and the thing you would
/// otherwise verify by reading.
///
/// # Exit codes
///
/// `0` identical, `1` they differ, `2` one could not be read. Same shape as
/// `validate`, and for the same reason: a pipeline has to tell "the answer is
/// no" from "I could not answer".
fn diff(left: &Path, right: &Path, format: Format) -> Result<ExitCode, String> {
    let a = input::load(left).map_err(|e| e.to_string())?;
    let b = input::load(right).map_err(|e| e.to_string())?;

    let differences = output::model_differences(&a.invoice, &b.invoice)?;
    let mut out = String::new();
    match format {
        Format::Text => output::diff_text(&mut out, &a, &b, &differences),
        Format::Json => output::diff_json(&mut out, &a, &b, &differences)?,
        Format::Svrl => {
            return Err(
                "SVRL describes a verdict, not a comparison. Use --format json.".to_owned(),
            );
        }
    }
    write_out(None, out.as_bytes())?;
    Ok(if differences.is_empty() {
        ExitCode::SUCCESS
    } else {
        ExitCode::from(INVALID)
    })
}

// ── explain ──────────────────────────────────────────────────────────────────

fn explain(query: &str) -> Result<ExitCode, String> {
    let mut out = String::new();
    if let Some(rule) = en16931::validation::rules::explain(query) {
        output::rule(&mut out, rule);
        write_out(None, out.as_bytes())?;
        return Ok(ExitCode::SUCCESS);
    }
    // Restrictions are data rather than predicates, so they are not rules — and
    // they appear in reports under their real ids, so a user holding one
    // deserves an answer rather than "no such rule".
    if let Some((profile, restriction)) = en16931::validation::rules::explain_restriction(query) {
        output::restriction(&mut out, profile, restriction);
        write_out(None, out.as_bytes())?;
        return Ok(ExitCode::SUCCESS);
    }
    Err(format!(
        "no rule or restriction called {query:?}. Ids are matched loosely — \
         `BR-CO-3`, `BR-CO-03` and `br-co-3` are the same rule, and the \
         standard's `BR-IG-*` / `BR-IP-*` reach the artefacts' `BR-AF-*` / \
         `BR-AG-*`."
    ))
}

/// `en16931 categories S O` — may these share one invoice?
///
/// # Why this is a command and not a flag on `validate`
///
/// It takes **no document**. The whole value is answering before one exists:
/// a caller choosing what to put on an invoice, or a service deciding whether
/// a set of charges can be billed together at all. Wiring it into `validate`
/// would require the document the question exists to avoid building.
fn categories(codes: &[String]) -> Result<ExitCode, String> {
    let mut cats = Vec::with_capacity(codes.len());
    for code in codes {
        let cat = en16931::VatCategory::from_code(code).ok_or_else(|| {
            // Built rather than continued with `\`. A `\`-continued literal
            // keeps its own indentation and `rustfmt` bakes it in — which is
            // how a run of 32 spaces once shipped inside this workspace's
            // attribution notice, and how it got into this message on the first
            // attempt at writing it.
            let known: Vec<&str> = en16931::VatCategory::ALL.iter().map(|c| c.code()).collect();
            let mut msg = format!("{code:?} is not a VAT category code. ");
            msg.push_str("EN 16931 has ten, and BR-CL-17 compares them literally, so they are ");
            msg.push_str("case-sensitive: ");
            msg.push_str(&known.join(", "));
            msg
        })?;
        cats.push(cat);
    }

    let mut out = String::new();
    output::categories(&mut out, &cats);
    write_out(None, out.as_bytes())?;
    Ok(match en16931::VatCategory::can_share_document(&cats) {
        Ok(()) => ExitCode::SUCCESS,
        Err(_) => ExitCode::from(INVALID),
    })
}

// ── shared ───────────────────────────────────────────────────────────────────

/// The one place anything reaches standard output.
///
/// Every command builds its output into a `String` and hands it here, rather
/// than calling `println!`. That is not tidiness: `println!` **panics** when the
/// reader has gone away, so `en16931 rules | head` printed a Rust backtrace
/// where every other command-line tool prints nothing. Funnelling the writes
/// makes the one place that has to know about `BrokenPipe` the one place that
/// does, and makes each command a single `write(2)`.
fn write_out(path: Option<&std::path::Path>, bytes: &[u8]) -> Result<(), String> {
    match path {
        Some(p) => std::fs::write(p, bytes).map_err(|e| format!("{}: {e}", p.display())),
        None => {
            use std::io::Write as _;
            match std::io::stdout().write_all(bytes) {
                Ok(()) => Ok(()),
                Err(e) if is_broken_pipe(&e) => Ok(()),
                Err(e) => Err(format!("stdout: {e}")),
            }
        }
    }
}