mod build;
mod cert;
mod diff;
mod document;
#[cfg(feature = "excel")]
mod excel;
mod fix;
mod merge;
mod report;
use std::path::{Path, PathBuf};
use laterite_ags4_core::ags4_codec::{
DuplicateHeadings, ExcessFields, ReadOptions, read_ags4_bytes_with,
};
use laterite_ags4_emit::{EmitMode, EmitOpts, GroupInput, TranStamp, emit_ags4};
use laterite_ags4_reference::dict::DictVersion;
use laterite_ags4_validator::parse::parse_bytes;
use laterite_ags4_validator::{CheckOptions, WorldScope, check_file, check_parsed_with_dict};
pub use build::{
Build, BuildSaved, BuildUnchecked, Cell, GroupData, build, build_document, build_unchecked,
build_unchecked_document,
};
pub use cert::Certify;
pub use diff::{
CellChange, Change, Delta, Diff, GroupChange, RowChange, diff, diff_bytes, diff_documents,
};
pub use document::{Document, Group, Row, Rows};
#[cfg(feature = "excel")]
pub use excel::{
Converted, FromExcel, ToExcel, Workbook, from_excel, from_excel_bytes, to_excel, to_excel_bytes,
};
pub use fix::{Fix, Fixed, Repair, fix, fix_bytes, fix_str, fixable_rules};
pub use merge::{
Merge, Merged, MissingTran, Note, Revision, TypeClash, merge, merge_bytes, merge_documents,
};
pub use report::{Finding, Report, Severity};
use crate::{Error, ErrorKind};
#[must_use]
pub fn editions() -> Vec<&'static str> {
DictVersion::ALL.iter().map(|v| v.as_str()).collect()
}
fn resolve_edition(label: &str) -> Result<DictVersion, Error> {
DictVersion::from_edition(label).ok_or_else(|| {
Error::new(
ErrorKind::BadDictionary,
format!(
"unknown AGS4 edition `{label}` — this build has {}",
editions().join(", ")
),
)
})
}
fn bad_encoding(label: &str) -> Error {
Error::new(
ErrorKind::InvalidArgument,
format!("unknown encoding label `{label}` (WHATWG names, e.g. `windows-1252`)"),
)
}
fn validator_kind(token: &str) -> ErrorKind {
match token {
"io" | "not_found" => ErrorKind::Io,
"not_ags4" => ErrorKind::NotAgs4,
"bad_dict" | "unsupported_edition" => ErrorKind::BadDictionary,
"world_check_requires_source" => ErrorKind::InvalidArgument,
_ => ErrorKind::Other,
}
}
fn convert(findings: laterite_ags4_validator::Findings) -> Vec<Finding> {
findings
.into_iter()
.flat_map(|(rule, group_findings)| {
group_findings.into_iter().map(move |f| Finding {
rule: rule.clone(),
group: f.group,
description: f.desc,
line: f.line,
severity: match f.severity {
laterite_ags4_validator::findings::Severity::Error => Severity::Error,
laterite_ags4_validator::findings::Severity::Warning => Severity::Warning,
laterite_ags4_validator::findings::Severity::Fyi => Severity::Fyi,
},
})
})
.collect()
}
enum Source {
Path(PathBuf),
Bytes(Vec<u8>),
Text(String),
}
impl Source {
fn describe(&self) -> String {
match self {
Source::Path(p) => format!("path {}", p.display()),
Source::Bytes(b) => format!("{} bytes", b.len()),
Source::Text(s) => format!("{} characters", s.chars().count()),
}
}
}
pub struct Read {
source: Source,
encoding: Option<String>,
recover_duplicate_headings: bool,
truncate_excess_fields: bool,
cert: Option<CertInput>,
only: Vec<String>,
}
pub fn read(path: impl AsRef<Path>) -> Read {
Read {
source: Source::Path(path.as_ref().to_path_buf()),
encoding: None,
recover_duplicate_headings: false,
truncate_excess_fields: false,
cert: None,
only: Vec::new(),
}
}
pub fn read_bytes(bytes: impl Into<Vec<u8>>) -> Read {
Read {
source: Source::Bytes(bytes.into()),
encoding: None,
recover_duplicate_headings: false,
truncate_excess_fields: false,
cert: None,
only: Vec::new(),
}
}
pub fn read_str(text: impl Into<String>) -> Read {
Read {
source: Source::Text(text.into()),
encoding: None,
recover_duplicate_headings: false,
truncate_excess_fields: false,
cert: None,
only: Vec::new(),
}
}
impl Read {
#[must_use]
pub fn encoding(mut self, label: impl Into<String>) -> Read {
self.encoding = Some(label.into());
self
}
#[must_use]
pub fn recover_duplicate_headings(mut self, yes: bool) -> Read {
self.recover_duplicate_headings = yes;
self
}
#[must_use]
pub fn truncate_excess_fields(mut self, yes: bool) -> Read {
self.truncate_excess_fields = yes;
self
}
#[must_use]
pub fn only<I, S>(mut self, codes: I) -> Read
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.only = codes.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn index(mut self, path: impl AsRef<Path>) -> Read {
self.cert = Some(CertInput::Path(path.as_ref().to_path_buf()));
self
}
#[must_use]
pub fn index_bytes(mut self, bytes: impl Into<Vec<u8>>) -> Read {
self.cert = Some(CertInput::Bytes(bytes.into()));
self
}
pub fn run(self) -> Result<Document, Error> {
let raw = match &self.source {
Source::Path(p) => std::fs::read(p).map_err(|e| {
Error::with_source(ErrorKind::Io, format!("cannot read {}", p.display()), e)
})?,
Source::Bytes(b) => b.clone(),
Source::Text(s) => s.clone().into_bytes(),
};
let raw_for_cert = raw.clone();
let bytes = match (&self.source, &self.encoding) {
(Source::Text(_), _) | (_, None) => raw,
(_, Some(label)) => {
let enc = laterite_ags4_parse::resolve_encoding(Some(label))
.ok_or_else(|| bad_encoding(label))?;
enc.decode(&raw).0.into_owned().into_bytes()
}
};
let opts = ReadOptions {
duplicate_headings: if self.recover_duplicate_headings {
DuplicateHeadings::Recover
} else {
DuplicateHeadings::Error
},
excess_fields: if self.truncate_excess_fields {
ExcessFields::Truncate
} else {
ExcessFields::Error
},
};
let can_slice = self.cert.is_some() && !self.only.is_empty() && raw_for_cert == bytes;
if can_slice {
let input = self
.cert
.as_ref()
.expect("can_slice is false when there is no certificate");
let cert_bytes = match input {
CertInput::Path(p) => std::fs::read(p).map_err(|e| {
Error::with_source(ErrorKind::Io, format!("cannot read {}", p.display()), e)
})?,
CertInput::Bytes(b) => b.clone(),
};
let sidecar = cert::parse_cert(&cert_bytes)?;
let index = sidecar.index();
let ranges: Option<Vec<_>> = self
.only
.iter()
.map(|code| index.range(code).map(|r| (code.clone(), r)))
.collect();
if let (true, Some(ranges)) = (sidecar.is_fresh_for(&raw_for_cert), ranges) {
let mut groups = Vec::with_capacity(ranges.len());
for (code, range) in ranges {
let group = laterite_ags4_core::index::parse_group_slice_with(
&bytes, range, &code, opts,
)
.map_err(|e| {
Error::with_source(
ErrorKind::NotAgs4,
format!("cannot read group `{code}` from its byte range"),
e,
)
})?;
groups.push(group);
}
let parsed = laterite_ags4_core::ags4_codec::ParsedAgs4::from_groups(groups);
let mut doc = Document::new(parsed, raw_for_cert, self.encoding.clone());
doc.sliced = true;
return Ok(doc);
}
}
read_ags4_bytes_with(&bytes, opts)
.map(|parsed| Document::new(parsed, raw_for_cert, self.encoding.clone()))
.map(|mut doc| {
if !self.only.is_empty() {
doc.retain_only(&self.only);
}
doc
})
.map_err(|e| Error::with_source(ErrorKind::NotAgs4, "cannot read as AGS4", e))
}
}
enum CertInput {
Path(PathBuf),
Bytes(Vec<u8>),
}
pub struct Validate {
cert: Option<CertInput>,
source: Source,
warnings: bool,
fyi: bool,
edition: Option<String>,
encoding: Option<String>,
check_files: bool,
}
pub fn validate(path: impl AsRef<Path>) -> Validate {
Validate {
cert: None,
source: Source::Path(path.as_ref().to_path_buf()),
warnings: false,
fyi: false,
edition: None,
encoding: None,
check_files: false,
}
}
pub fn validate_bytes(bytes: impl Into<Vec<u8>>) -> Validate {
Validate {
cert: None,
source: Source::Bytes(bytes.into()),
warnings: false,
fyi: false,
edition: None,
encoding: None,
check_files: false,
}
}
pub fn validate_str(text: impl Into<String>) -> Validate {
Validate {
cert: None,
source: Source::Text(text.into()),
warnings: false,
fyi: false,
edition: None,
encoding: None,
check_files: false,
}
}
impl Validate {
#[must_use]
pub fn warnings(mut self, yes: bool) -> Validate {
self.warnings = yes;
self
}
#[must_use]
pub fn fyi(mut self, yes: bool) -> Validate {
self.fyi = yes;
self
}
#[must_use]
pub fn edition(mut self, edition: impl Into<String>) -> Validate {
self.edition = Some(edition.into());
self
}
#[must_use]
pub fn encoding(mut self, label: impl Into<String>) -> Validate {
self.encoding = Some(label.into());
self
}
#[must_use]
pub fn index(mut self, path: impl AsRef<Path>) -> Validate {
self.cert = Some(CertInput::Path(path.as_ref().to_path_buf()));
self
}
#[must_use]
pub fn index_bytes(mut self, bytes: impl Into<Vec<u8>>) -> Validate {
self.cert = Some(CertInput::Bytes(bytes.into()));
self
}
#[must_use]
pub fn check_files(mut self, yes: bool) -> Validate {
self.check_files = yes;
self
}
pub fn run(self) -> Result<Report, Error> {
let opts = CheckOptions {
dict_version: self.edition.as_deref().map(resolve_edition).transpose()?,
custom_dict: None,
include_warnings: self.warnings,
include_fyi: self.fyi,
check_files: self.check_files,
encoding: laterite_ags4_parse::resolve_encoding(self.encoding.as_deref())
.ok_or_else(|| bad_encoding(self.encoding.as_deref().unwrap_or_default()))?,
};
let utf8 =
laterite_ags4_parse::resolve_encoding(None).ok_or_else(|| bad_encoding("utf-8"))?;
if let Some(input) = &self.cert {
let raw = match input {
CertInput::Path(p) => &std::fs::read(p).map_err(|e| {
Error::with_source(ErrorKind::Io, format!("cannot read {}", p.display()), e)
})?,
CertInput::Bytes(b) => b,
};
let sidecar = cert::parse_cert(raw)?;
let (bytes, world) = match &self.source {
Source::Path(p) => (
std::fs::read(p).map_err(|e| {
Error::with_source(ErrorKind::Io, format!("cannot read {}", p.display()), e)
})?,
WorldScope::OnDisk(p.clone()),
),
Source::Bytes(b) => (b.clone(), WorldScope::None),
Source::Text(s) => (s.clone().into_bytes(), WorldScope::None),
};
let outcome = laterite_ags4_trust::check(laterite_ags4_trust::Request {
bytes: &bytes,
opts: &opts,
cert: Some(&sidecar),
world,
compat: None,
})
.map_err(|e| Error::with_source(validator_kind(e.kind()), "cannot validate", e))?;
return Ok(Report {
findings: convert(outcome.findings),
certified: outcome.certified,
revalidate_reason: outcome.revalidate_reason.map(|r| r.as_str().to_string()),
});
}
let result = match &self.source {
Source::Path(p) => check_file(p, &opts),
Source::Bytes(b) => parse_bytes(b, opts.encoding).and_then(|parsed| {
check_parsed_with_dict(&parsed, &opts, &WorldScope::None).map(|(f, _, _)| f)
}),
Source::Text(s) => parse_bytes(s.as_bytes(), utf8).and_then(|parsed| {
check_parsed_with_dict(&parsed, &opts, &WorldScope::None).map(|(f, _, _)| f)
}),
};
let findings = result.map_err(|e| {
let kind = validator_kind(e.kind());
let subject = match &self.source {
Source::Path(p) => format!("cannot validate {}", p.display()),
Source::Bytes(b) => format!("cannot validate {} bytes", b.len()),
Source::Text(s) => format!("cannot validate {} characters", s.chars().count()),
};
if e.kind() == "world_check_requires_source" {
Error::new(
kind,
format!(
"{subject}: the on-disk file check (Rule 20) needs a path to look \
beside, so `check_files` works with `validate` and not with \
`validate_bytes` — drop it, or validate the file from disk"
),
)
} else {
Error::with_source(kind, subject, e)
}
})?;
Ok(Report {
findings: convert(findings),
certified: false,
revalidate_reason: None,
})
}
}
impl Document {
#[must_use]
pub fn certify(&self) -> Certify<'_> {
Certify {
doc: self,
edition: None,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum WriteMode {
#[default]
AutoFix,
Report,
Strict,
}
pub struct Write<'a> {
doc: &'a Document,
mode: WriteMode,
edition: Option<String>,
synthesise_metadata: bool,
tran: Option<TranStamp>,
}
#[must_use]
pub fn write(doc: &Document) -> Write<'_> {
Write {
doc,
mode: WriteMode::default(),
edition: None,
synthesise_metadata: true,
tran: None,
}
}
impl<'a> Write<'a> {
#[must_use]
pub fn mode(mut self, mode: WriteMode) -> Write<'a> {
self.mode = mode;
self
}
#[must_use]
pub fn edition(mut self, edition: impl Into<String>) -> Write<'a> {
self.edition = Some(edition.into());
self
}
#[must_use]
pub fn synthesise_metadata(mut self, yes: bool) -> Write<'a> {
self.synthesise_metadata = yes;
self
}
#[must_use]
pub fn transmission(
mut self,
issue_number: impl Into<String>,
date: impl Into<String>,
producer: impl Into<String>,
recipient: impl Into<String>,
status: impl Into<String>,
) -> Write<'a> {
self.tran = Some(TranStamp::new(
issue_number,
date,
producer,
recipient,
status,
));
self
}
fn emit(self) -> Result<Written, Error> {
let groups: Vec<GroupInput> = self
.doc
.groups()
.iter()
.map(|g| {
let headings = g.headings();
GroupInput {
code: g.code().to_string(),
headings: headings.iter().map(|h| (*h).to_string()).collect(),
units: Some(g.units().iter().map(|u| (*u).to_string()).collect()),
types: Some(g.types().iter().map(|t| (*t).to_string()).collect()),
rows: g
.rows()
.map(|r| {
headings
.iter()
.map(|h| {
laterite_ags4_emit::Cell::Text(
r.cell(h).unwrap_or("").to_string(),
)
})
.collect()
})
.collect(),
}
})
.collect();
emit_groups(
&groups,
self.mode,
self.edition.as_deref(),
self.synthesise_metadata,
self.tran,
)
}
pub fn to_bytes(self) -> Result<Written, Error> {
self.emit()
}
pub fn to_path(self, path: impl AsRef<Path>) -> Result<Written, Error> {
let path = path.as_ref();
let written = self.emit()?;
std::fs::write(path, written.bytes()).map_err(|e| {
Error::with_source(ErrorKind::Io, format!("cannot write {}", path.display()), e)
})?;
Ok(written)
}
}
fn emit_groups(
groups: &[GroupInput],
mode: WriteMode,
edition: Option<&str>,
synthesise_metadata: bool,
tran: Option<TranStamp>,
) -> Result<Written, Error> {
let edition = match edition {
Some(label) => resolve_edition(label)?,
None => laterite_ags4_reference::dict::FALLBACK,
};
let opts = EmitOpts {
mode: match mode {
WriteMode::AutoFix => EmitMode::AutoFix,
WriteMode::Report => EmitMode::Report,
WriteMode::Strict => EmitMode::Strict,
},
edition,
tran,
synthesise_metadata,
};
let result = emit_ags4(groups, &opts)
.map_err(|e| Error::with_source(ErrorKind::Emit, "cannot write as AGS4", e))?;
Ok(Written {
fixes_applied: result.fixes_applied,
findings: convert(result.findings),
text: String::from_utf8(result.bytes).map_err(|e| {
Error::with_source(
ErrorKind::Emit,
"the emitter produced bytes that are not UTF-8",
e,
)
})?,
})
}
pub struct Written {
text: String,
findings: Vec<Finding>,
fixes_applied: usize,
}
impl Written {
#[must_use]
pub fn bytes(&self) -> &[u8] {
self.text.as_bytes()
}
#[must_use]
pub fn into_bytes(self) -> Vec<u8> {
self.text.into_bytes()
}
#[must_use]
pub fn text(&self) -> &str {
&self.text
}
#[must_use]
pub fn into_text(self) -> String {
self.text
}
#[must_use]
pub fn findings(&self) -> &[Finding] {
&self.findings
}
#[must_use]
pub fn fixes_applied(&self) -> usize {
self.fixes_applied
}
}
impl std::fmt::Debug for Read {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Read")
.field("source", &self.source.describe())
.field("encoding", &self.encoding)
.field(
"recover_duplicate_headings",
&self.recover_duplicate_headings,
)
.field("truncate_excess_fields", &self.truncate_excess_fields)
.field("only", &self.only)
.field(
"index",
&match &self.cert {
None => "none".to_string(),
Some(CertInput::Path(p)) => format!("path {}", p.display()),
Some(CertInput::Bytes(b)) => format!("{} bytes", b.len()),
},
)
.finish()
}
}
impl std::fmt::Debug for Validate {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Validate")
.field("source", &self.source.describe())
.field("warnings", &self.warnings)
.field("fyi", &self.fyi)
.field("edition", &self.edition)
.field("encoding", &self.encoding)
.field(
"index",
&match &self.cert {
None => "none".to_string(),
Some(CertInput::Path(p)) => format!("path {}", p.display()),
Some(CertInput::Bytes(b)) => format!("{} bytes", b.len()),
},
)
.field("check_files", &self.check_files)
.finish()
}
}
impl std::fmt::Debug for Write<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Write")
.field("mode", &self.mode)
.field("edition", &self.edition)
.field("synthesise_metadata", &self.synthesise_metadata)
.field("transmission", &self.tran.is_some())
.finish()
}
}
impl std::fmt::Debug for Written {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Written")
.field("bytes", &self.text.len())
.field("findings", &self.findings.len())
.field("fixes_applied", &self.fixes_applied)
.finish_non_exhaustive()
}
}