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};
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", default_value = "auto")]
profile: String,
#[arg(long, value_enum, default_value_t = Format::Text)]
format: Format,
#[arg(long)]
strict: bool,
#[arg(long = "without", value_name = "RULE")]
without: Vec<String>,
#[arg(long, short)]
quiet: bool,
},
Convert {
#[arg(value_name = "PATH")]
path: PathBuf,
#[arg(long, value_enum, value_name = "SYNTAX")]
to: TargetSyntax,
#[arg(long, short, value_name = "PROFILE")]
profile: 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>,
},
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(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 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,
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),
}
}
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 {
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],
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())?;
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)
}
fn declared(invoice: &en16931::Invoice) -> &'static Profile {
invoice
.specification_id
.as_deref()
.and_then(profiles::for_specification_id)
.unwrap_or(&profiles::EN16931)
}
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(", ")
)
})
}
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,
};
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));
}
};
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)
}
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)
}
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)
}
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)
})
}
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, and the \
standard's `BR-IG-*` / `BR-IP-*` reach the artefacts' `BR-AF-*` / \
`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}")),
}
}
}
}