#![forbid(unsafe_code)]
mod input;
mod output;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use clap::{Args, Parser, Subcommand, ValueEnum};
use en16931::validation::profile::Profile;
use en16931::{Severity, profiles};
use en16931_formats::{Loss, Policy, Recommendation, Syntax, zugferd};
const INVALID: u8 = 1;
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 {
#[arg(required = true, value_name = "PATH")]
paths: Vec<PathBuf>,
#[arg(long, short, value_name = "PROFILE")]
require: Option<String>,
#[arg(long, value_name = "RULE=LEVEL")]
relevel: Vec<String>,
#[arg(long = "without", value_name = "RULE")]
without: Vec<String>,
#[arg(long, value_enum, default_value_t = Format::Text)]
format: Format,
#[arg(long)]
strict: bool,
#[command(flatten)]
limits: LimitArgs,
#[arg(long, short)]
quiet: bool,
},
Convert {
#[arg(value_name = "PATH")]
path: PathBuf,
#[arg(long, value_enum, value_name = "SYNTAX")]
to: TargetSyntax,
#[arg(long)]
allow_loss: bool,
#[arg(long, short, value_name = "PROFILE")]
require: Option<String>,
#[arg(long, short, value_name = "PATH")]
output: Option<PathBuf>,
},
Extract {
#[arg(value_name = "PATH")]
path: PathBuf,
#[arg(long, short, value_name = "PATH")]
output: Option<PathBuf>,
#[command(flatten)]
limits: LimitArgs,
},
#[command(verbatim_doc_comment)]
Embed {
#[arg(value_name = "PDF")]
pdf: PathBuf,
#[arg(value_name = "INVOICE")]
invoice: PathBuf,
#[arg(long, short, value_name = "PATH")]
output: PathBuf,
#[arg(long, short, value_name = "PROFILE")]
profile: Option<String>,
#[arg(long, value_enum, default_value_t = RelationshipArg::Alternative)]
relationship: RelationshipArg,
},
Diff {
#[arg(value_name = "LEFT")]
left: PathBuf,
#[arg(value_name = "RIGHT")]
right: PathBuf,
#[arg(long, value_enum, default_value_t = Format::Text)]
format: Format,
},
Inspect {
#[arg(required = true, value_name = "PATH")]
paths: Vec<PathBuf>,
#[arg(long, value_enum, default_value_t = Format::Text)]
format: Format,
},
Explain {
#[arg(value_name = "RULE")]
rule: String,
},
Profiles,
#[command(verbatim_doc_comment)]
Categories {
#[arg(required = true, value_name = "CODE")]
codes: Vec<String>,
},
Rules {
#[arg(long, short, value_name = "PROFILE")]
profile: Option<String>,
#[arg(long, value_name = "TERM")]
term: Option<String>,
#[arg(long, value_enum, default_value_t = CatalogueFormat::Text)]
format: CatalogueFormat,
},
#[command(verbatim_doc_comment)]
Generate {
#[arg(value_name = "WHAT")]
what: Generate,
},
}
#[derive(Args)]
struct LimitArgs {
#[arg(long, value_name = "BYTES", default_value_t = zugferd::Limits::DEFAULT_MAX_STREAM_BYTES)]
max_stream_bytes: usize,
#[arg(long, value_name = "BYTES", default_value_t = zugferd::Limits::DEFAULT_MAX_TOTAL_BYTES)]
max_total_bytes: usize,
#[arg(long, value_name = "COUNT", default_value_t = zugferd::Limits::DEFAULT_MAX_OBJECTS)]
max_objects: usize,
}
impl LimitArgs {
fn limits(&self) -> zugferd::Limits {
zugferd::Limits::new()
.with_max_stream_bytes(self.max_stream_bytes)
.with_max_total_bytes(self.max_total_bytes)
.with_max_objects(self.max_objects)
}
}
#[derive(Clone, Copy, ValueEnum)]
enum CatalogueFormat {
Text,
Json,
}
#[derive(Clone, Copy, ValueEnum)]
enum Generate {
Bash,
Zsh,
Fish,
#[value(name = "powershell")]
PowerShell,
Elvish,
Man,
}
#[derive(Clone, Copy, ValueEnum)]
enum Format {
Text,
Json,
Svrl,
}
#[derive(Clone, Copy, ValueEnum)]
enum RelationshipArg {
Alternative,
Source,
}
#[derive(Clone, Copy, ValueEnum)]
enum TargetSyntax {
Ubl,
Cii,
}
fn main() -> ExitCode {
match run(Cli::parse()) {
Ok(code) => code,
Err(message) => {
eprintln!("en16931: {message}");
ExitCode::from(ERROR)
}
}
}
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,
require,
relevel,
without,
format,
strict,
limits,
quiet,
} => {
let policy = Policy {
require: require.as_deref().map(resolve).transpose()?,
relevel: relevel
.iter()
.map(|r| relevelling(r))
.collect::<Result<_, _>>()?,
without,
limits: limits.limits(),
};
validate(&paths, &policy, format, strict, quiet)
}
Command::Convert {
path,
to,
allow_loss,
require,
output,
} => convert(&path, to, allow_loss, require.as_deref(), output.as_deref()),
Command::Extract {
path,
output,
limits,
} => extract(&path, output.as_deref(), &limits.limits()),
Command::Embed {
pdf,
invoice,
output,
profile,
relationship,
} => embed(&pdf, &invoice, &output, profile.as_deref(), relationship),
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),
}
}
fn rules(
profile: Option<&str>,
term: Option<&str>,
format: CatalogueFormat,
) -> Result<ExitCode, String> {
let profile = match profile {
Some(name) => Some(resolve(name)?),
None => None,
};
let term = match term {
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)
}
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)
}
fn validate(
paths: &[PathBuf],
policy: &Policy,
format: Format,
strict: bool,
quiet: bool,
) -> Result<ExitCode, String> {
let mut worst = ExitCode::SUCCESS;
let mut results = Vec::new();
for path in paths {
let bytes = input::bytes(path)?;
let received = en16931_formats::receive(&bytes, policy);
let accepted = received.recommendation == Recommendation::Accept
&& !(strict && reports_anything(&received));
if !accepted {
worst = ExitCode::from(INVALID);
}
results.push((path.clone(), received));
}
if !quiet {
let mut out = String::new();
output::validation(&mut out, &results, format);
write_out(None, out.as_bytes())?;
}
Ok(worst)
}
fn reports_anything(r: &en16931_formats::Received) -> bool {
std::iter::once(&r.report_declared)
.chain(&r.report_required)
.any(|rep| rep.most_severe().is_some())
}
fn resolve(name: &str) -> Result<&'static Profile, String> {
name.parse()
.map_err(|e: profiles::UnknownProfile| e.to_string())
}
fn relevelling(arg: &str) -> Result<(String, Severity), String> {
let (rule, level) = arg
.split_once('=')
.ok_or_else(|| format!("--relevel {arg:?}: expected RULE=LEVEL, as BR-CL-23=warning"))?;
let level = level
.parse::<Severity>()
.map_err(|e| format!("--relevel {arg:?}: {e}"))?;
Ok((rule.trim().to_owned(), level))
}
fn convert(
path: &Path,
to: TargetSyntax,
allow_loss: bool,
require: Option<&str>,
out: Option<&Path>,
) -> Result<ExitCode, String> {
let bytes = input::bytes(path)?;
if let Some(name) = require {
let policy = Policy {
require: Some(resolve(name)?),
..Policy::default()
};
let received = en16931_formats::receive(&bytes, &policy);
if received.recommendation != Recommendation::Accept {
eprintln!("en16931: not converted: {}\n{received}", path.display());
return Ok(ExitCode::from(INVALID));
}
}
let syntax = match to {
TargetSyntax::Ubl => Syntax::Ubl,
TargetSyntax::Cii => Syntax::Cii,
};
let loss = if allow_loss {
Loss::Allow
} else {
Loss::Refuse
};
let converted = match en16931_formats::convert(&bytes, syntax, loss) {
Ok(c) => c,
Err(en16931_formats::ConvertError::Read(e)) => {
return Err(format!("{}: {e}", path.display()));
}
Err(en16931_formats::ConvertError::Lossy { lost }) => {
eprintln!(
"en16931: {}: not converted — {} item(s) would be lost; \
--allow-loss converts and lists them",
path.display(),
lost.len()
);
for e in &lost {
eprintln!(" {e}");
}
return Ok(ExitCode::from(INVALID));
}
Err(en16931_formats::ConvertError::Unwritable(u)) => {
eprintln!("en16931: {}: {u}", path.display());
for e in &u.entries {
eprintln!(" {e}");
}
return Ok(ExitCode::from(INVALID));
}
};
for e in converted.report.entries().chain(&converted.written) {
eprintln!("en16931: {e}");
}
if !converted.report.is_valid() {
eprintln!(
"en16931: the source is not valid as it declares itself; \
`en16931 validate` says why"
);
}
write_out(out, converted.document.as_bytes())?;
Ok(ExitCode::SUCCESS)
}
fn extract(path: &Path, out: Option<&Path>, limits: &zugferd::Limits) -> Result<ExitCode, String> {
let bytes = input::bytes(path)?;
let got = zugferd::extract(&bytes, limits).map_err(|e| format!("{}: {e}", path.display()))?;
for d in &got.divergence {
eprintln!("en16931: {d}");
}
write_out(out, &got.xml)?;
Ok(ExitCode::SUCCESS)
}
fn embed(
pdf: &Path,
invoice: &Path,
out: &Path,
profile: Option<&str>,
relationship: RelationshipArg,
) -> Result<ExitCode, String> {
use en16931::profiles::{En16931, XRechnung};
use en16931::validation::profile::Validated;
let pdf_bytes = input::bytes(pdf)?;
let bytes = input::bytes(invoice)?;
let model = if bytes.trim_ascii_start().starts_with(b"{") {
serde_json::from_slice::<en16931::Invoice>(&bytes)
.map_err(|e| format!("{}: {e}", invoice.display()))?
} else {
en16931_formats::read(&bytes)
.map_err(|e| format!("{}: {e}", invoice.display()))?
.invoice
};
let target = match profile {
Some(name) => resolve(name)?,
None => match profiles::classify(model.specification_id.as_deref()).profile() {
Some(p) => p,
None => {
eprintln!(
"en16931: {}: BT-24 names no profile this build knows; pass --profile en16931 or xrechnung",
invoice.display()
);
return Ok(ExitCode::from(INVALID));
}
},
};
let options = zugferd::EmbedOptions {
relationship: match relationship {
RelationshipArg::Alternative => zugferd::Relationship::Alternative,
RelationshipArg::Source => zugferd::Relationship::Source,
},
..zugferd::EmbedOptions::default()
};
let result = if std::ptr::eq(target, &profiles::EN16931) {
match Validated::<En16931>::new(model) {
Ok(proof) => zugferd::embed(&pdf_bytes, &proof, &options),
Err(rejected) => return Ok(not_valid(invoice, rejected.report())),
}
} else if std::ptr::eq(target, &profiles::XRECHNUNG) {
match Validated::<XRechnung>::new(model) {
Ok(proof) => zugferd::embed(&pdf_bytes, &proof, &options),
Err(rejected) => return Ok(not_valid(invoice, rejected.report())),
}
} else {
eprintln!(
"en16931: {}: {} has no ZUGFeRD / Factur-X carrier; embed writes EN 16931 and XRechnung",
invoice.display(),
target.id
);
return Ok(ExitCode::from(INVALID));
};
match result {
Ok(hybrid) => {
write_out(Some(out), &hybrid)?;
Ok(ExitCode::SUCCESS)
}
Err(e) => {
eprintln!("en16931: {}: not embedded: {e}", pdf.display());
Ok(ExitCode::from(INVALID))
}
}
}
fn not_valid(path: &Path, report: &en16931::ValidationReport) -> ExitCode {
eprintln!(
"en16931: {}: not embedded — the invoice is not valid\n{report}",
path.display()
);
ExitCode::from(INVALID)
}
fn inspect(paths: &[PathBuf], format: Format) -> Result<ExitCode, String> {
let mut loaded = Vec::new();
for path in paths {
loaded.push(input::load(path)?);
}
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)
}
fn diff(left: &Path, right: &Path, format: Format) -> Result<ExitCode, String> {
let a = input::load(left)?;
let b = input::load(right)?;
let differences = output::model_differences(&a.read.invoice, &b.read.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)
})
}
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);
}
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. The IGIC and \
IPSI rules are `BR-AF-*` and `BR-AG-*`."
))
}
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(|| {
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),
})
}
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}")),
}
}
}
}