use crate::json::escape;
use crate::manifest::ArtifactCategory;
use std::collections::BTreeSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ZoneTableKind {
ZoneTab,
Zone1970Tab,
ZonenowTab,
Iso3166Tab,
}
impl ZoneTableKind {
pub fn as_str(self) -> &'static str {
match self {
ZoneTableKind::ZoneTab => "zone_tab",
ZoneTableKind::Zone1970Tab => "zone1970_tab",
ZoneTableKind::ZonenowTab => "zonenow_tab",
ZoneTableKind::Iso3166Tab => "iso3166_tab",
}
}
pub fn artifact_category(self) -> ArtifactCategory {
match self {
ZoneTableKind::Iso3166Tab => ArtifactCategory::ReferenceInput,
_ => ArtifactCategory::PolicyInput,
}
}
pub fn coverage(self) -> &'static str {
match self {
ZoneTableKind::ZoneTab => "country_zone_index_all_eras",
ZoneTableKind::Zone1970Tab => "post_1970_country_location",
ZoneTableKind::ZonenowTab => "now_future_agreement_only",
ZoneTableKind::Iso3166Tab => "country_code_reference",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ZoneTableFinding {
NonUtf8,
EmptyTable,
InvalidColumnCount,
InvalidCountryCode,
InvalidCoordinateFormat,
EmptyNameField,
DuplicateSemanticRow,
DuplicateCodeInRow,
}
impl ZoneTableFinding {
pub fn as_str(self) -> &'static str {
match self {
ZoneTableFinding::NonUtf8 => "non_utf8",
ZoneTableFinding::EmptyTable => "empty_table",
ZoneTableFinding::InvalidColumnCount => "invalid_column_count",
ZoneTableFinding::InvalidCountryCode => "invalid_country_code",
ZoneTableFinding::InvalidCoordinateFormat => "invalid_coordinate_format",
ZoneTableFinding::EmptyNameField => "empty_name_field",
ZoneTableFinding::DuplicateSemanticRow => "duplicate_semantic_row",
ZoneTableFinding::DuplicateCodeInRow => "duplicate_code_in_row",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ZoneUniverse {
NotResolvedStructuralOnly,
AdmittedSourceDefinitions,
CompiledOutputTree,
ReferenceDistributionTable,
SourcePlusBackwardLinks,
Unknown,
}
impl ZoneUniverse {
pub fn as_str(self) -> &'static str {
match self {
ZoneUniverse::NotResolvedStructuralOnly => "not_resolved_structural_only",
ZoneUniverse::AdmittedSourceDefinitions => "admitted_source_definitions",
ZoneUniverse::CompiledOutputTree => "compiled_output_tree",
ZoneUniverse::ReferenceDistributionTable => "reference_distribution_table",
ZoneUniverse::SourcePlusBackwardLinks => "source_plus_backward_links",
ZoneUniverse::Unknown => "unknown",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CountryCodeAuthority {
SameAdmittedReleaseIso3166Tab,
ExternalIsoRegistry,
HostLibrary,
NotCrossValidated,
}
impl CountryCodeAuthority {
pub fn as_str(self) -> &'static str {
match self {
CountryCodeAuthority::SameAdmittedReleaseIso3166Tab => {
"same_admitted_release_iso3166_tab"
}
CountryCodeAuthority::ExternalIsoRegistry => "external_iso_registry",
CountryCodeAuthority::HostLibrary => "host_library",
CountryCodeAuthority::NotCrossValidated => "not_cross_validated",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ZoneTableStructuralVerdict {
Conformant,
Violation,
}
impl ZoneTableStructuralVerdict {
pub fn as_str(self) -> &'static str {
match self {
ZoneTableStructuralVerdict::Conformant => "conformant",
ZoneTableStructuralVerdict::Violation => "violation",
}
}
}
#[derive(Debug, Clone)]
pub struct AuxTableValidation {
pub kind: ZoneTableKind,
pub verdict: ZoneTableStructuralVerdict,
pub rows_checked: usize,
pub country_code_authority: CountryCodeAuthority,
pub findings: Vec<(usize, ZoneTableFinding)>,
}
impl AuxTableValidation {
fn to_json(&self) -> String {
let mut findings = String::from("[");
for (i, (line, f)) in self.findings.iter().enumerate() {
if i > 0 {
findings.push_str(", ");
}
findings.push_str(&format!(
"{{ \"line\": {}, \"finding\": {} }}",
line,
escape(f.as_str())
));
}
findings.push(']');
format!(
"{{ \"kind\": {}, \"artifact_category\": {}, \"coverage\": {}, \"verdict\": {}, \
\"rows_checked\": {}, \"country_code_authority\": {}, \"findings\": {} }}",
escape(self.kind.as_str()),
escape(self.kind.artifact_category().as_str()),
escape(self.kind.coverage()),
escape(self.verdict.as_str()),
self.rows_checked,
escape(self.country_code_authority.as_str()),
findings
)
}
}
fn is_country_code(s: &str) -> bool {
s.len() == 2 && s.bytes().all(|b| b.is_ascii_uppercase())
}
fn is_coordinate(s: &str) -> bool {
let b = s.as_bytes();
if b.is_empty() || (b[0] != b'+' && b[0] != b'-') {
return false;
}
let rest = &s[1..];
let lon_sign = match rest.find(['+', '-']) {
Some(i) => i,
None => return false,
};
let lat_digits = &rest[..lon_sign];
let lon_part = &rest[lon_sign + 1..];
let lat_ok = (lat_digits.len() == 4 || lat_digits.len() == 6)
&& lat_digits.bytes().all(|c| c.is_ascii_digit());
let lon_ok = (lon_part.len() == 5 || lon_part.len() == 7)
&& lon_part.bytes().all(|c| c.is_ascii_digit());
lat_ok && lon_ok
}
pub fn validate_zone_table(
kind: ZoneTableKind,
bytes: &[u8],
iso3166_codes: Option<&BTreeSet<String>>,
) -> AuxTableValidation {
let mut findings: Vec<(usize, ZoneTableFinding)> = Vec::new();
let push = |findings: &mut Vec<(usize, ZoneTableFinding)>, line: usize, f: ZoneTableFinding| {
if findings.len() < 32 {
findings.push((line, f));
}
};
let cross_validates = matches!(kind, ZoneTableKind::ZoneTab | ZoneTableKind::Zone1970Tab);
let country_code_authority = if cross_validates && iso3166_codes.is_some() {
CountryCodeAuthority::SameAdmittedReleaseIso3166Tab
} else {
CountryCodeAuthority::NotCrossValidated
};
let text = match std::str::from_utf8(bytes) {
Ok(t) => t,
Err(_) => {
return AuxTableValidation {
kind,
verdict: ZoneTableStructuralVerdict::Violation,
rows_checked: 0,
country_code_authority,
findings: vec![(0, ZoneTableFinding::NonUtf8)],
};
}
};
let mut rows_checked = 0usize;
let mut seen_identity: BTreeSet<String> = BTreeSet::new();
for (idx, raw) in text.lines().enumerate() {
let line = idx + 1;
if raw.is_empty() || raw.starts_with('#') {
continue;
}
rows_checked += 1;
let fields: Vec<&str> = raw.split('\t').collect();
match kind {
ZoneTableKind::Iso3166Tab => {
if fields.len() != 2 {
push(&mut findings, line, ZoneTableFinding::InvalidColumnCount);
continue;
}
if !is_country_code(fields[0]) {
push(&mut findings, line, ZoneTableFinding::InvalidCountryCode);
} else if !seen_identity.insert(fields[0].to_string()) {
push(&mut findings, line, ZoneTableFinding::DuplicateSemanticRow);
}
if fields[1].is_empty() {
push(&mut findings, line, ZoneTableFinding::EmptyNameField);
}
}
ZoneTableKind::ZoneTab | ZoneTableKind::Zone1970Tab | ZoneTableKind::ZonenowTab => {
if fields.len() < 3 {
push(&mut findings, line, ZoneTableFinding::InvalidColumnCount);
continue;
}
let codes = fields[0];
let code_ok = codes.split(',').all(|c| {
is_country_code(c) || (kind == ZoneTableKind::ZonenowTab && c == "XX")
});
if !code_ok {
push(&mut findings, line, ZoneTableFinding::InvalidCountryCode);
} else {
if kind == ZoneTableKind::Zone1970Tab {
let mut in_row: BTreeSet<&str> = BTreeSet::new();
for c in codes.split(',') {
if !in_row.insert(c) {
push(&mut findings, line, ZoneTableFinding::DuplicateCodeInRow);
}
}
}
if cross_validates {
if let Some(set) = iso3166_codes {
if !codes.split(',').all(|c| set.contains(c)) {
push(&mut findings, line, ZoneTableFinding::InvalidCountryCode);
}
}
}
}
if !is_coordinate(fields[1]) {
push(
&mut findings,
line,
ZoneTableFinding::InvalidCoordinateFormat,
);
}
if fields[2].is_empty() {
push(&mut findings, line, ZoneTableFinding::EmptyNameField);
}
let identity = format!("{}\t{}\t{}", fields[0], fields[1], fields[2]);
if !seen_identity.insert(identity) {
push(&mut findings, line, ZoneTableFinding::DuplicateSemanticRow);
}
}
}
}
if rows_checked == 0 {
push(&mut findings, 0, ZoneTableFinding::EmptyTable);
}
let verdict = if findings.is_empty() {
ZoneTableStructuralVerdict::Conformant
} else {
ZoneTableStructuralVerdict::Violation
};
AuxTableValidation {
kind,
verdict,
rows_checked,
country_code_authority,
findings,
}
}
pub fn iso3166_codes(bytes: &[u8]) -> BTreeSet<String> {
let mut set = BTreeSet::new();
if let Ok(text) = std::str::from_utf8(bytes) {
for raw in text.lines() {
if raw.is_empty() || raw.starts_with('#') {
continue;
}
if let Some(code) = raw.split('\t').next() {
if is_country_code(code) {
set.insert(code.to_string());
}
}
}
}
set
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallEcologyStatus {
NotClaimed,
InventoryOnly,
CompileOutputTreeOnly,
}
impl InstallEcologyStatus {
pub fn as_str(self) -> &'static str {
match self {
InstallEcologyStatus::NotClaimed => "not_claimed",
InstallEcologyStatus::InventoryOnly => "inventory_only",
InstallEcologyStatus::CompileOutputTreeOnly => "compile_output_tree_only",
}
}
pub fn current() -> Self {
InstallEcologyStatus::CompileOutputTreeOnly
}
}
#[derive(Debug, Clone)]
pub struct AuxTableValidationReport {
pub tables: Vec<AuxTableValidation>,
pub install_ecology: InstallEcologyStatus,
}
impl AuxTableValidationReport {
pub fn to_json(&self) -> String {
let mut tables = String::from("[");
for (i, t) in self.tables.iter().enumerate() {
if i > 0 {
tables.push_str(", ");
}
tables.push_str(&t.to_json());
}
tables.push(']');
format!(
"{{\n \"schema\": \"zic-rs-aux-table-validation-v1\",\n \
\"non_claim\": \"a conformant table row proves table structural admissibility only — NOT \
that the named zone was compiled, semantically witnessed, historically equivalent, or \
installed; coordinate syntax does NOT claim geodetic accuracy; a public-domain notice is not \
provenance; release identity is never inferred from table comments\",\n \
\"zone_universe\": {},\n \"table_diagnostic_code_space\": \"separate_table_codes\",\n \
\"coordinate_verdict\": \"syntax_only_geodetic_truth_not_claimed\",\n \
\"table_comment_disposition\": \"ignored_for_validation\",\n \
\"install_ecology_status\": {},\n \"tables\": {}\n}}\n",
escape(ZoneUniverse::NotResolvedStructuralOnly.as_str()),
escape(self.install_ecology.as_str()),
tables
)
}
}