1use crate::json::escape;
24use crate::manifest::ArtifactCategory;
25use std::collections::BTreeSet;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum ZoneTableKind {
31 ZoneTab,
34 Zone1970Tab,
36 ZonenowTab,
38 Iso3166Tab,
40}
41
42impl ZoneTableKind {
43 pub fn as_str(self) -> &'static str {
44 match self {
45 ZoneTableKind::ZoneTab => "zone_tab",
46 ZoneTableKind::Zone1970Tab => "zone1970_tab",
47 ZoneTableKind::ZonenowTab => "zonenow_tab",
48 ZoneTableKind::Iso3166Tab => "iso3166_tab",
49 }
50 }
51
52 pub fn artifact_category(self) -> ArtifactCategory {
55 match self {
56 ZoneTableKind::Iso3166Tab => ArtifactCategory::ReferenceInput,
57 _ => ArtifactCategory::PolicyInput,
58 }
59 }
60
61 pub fn coverage(self) -> &'static str {
63 match self {
64 ZoneTableKind::ZoneTab => "country_zone_index_all_eras",
65 ZoneTableKind::Zone1970Tab => "post_1970_country_location",
66 ZoneTableKind::ZonenowTab => "now_future_agreement_only",
67 ZoneTableKind::Iso3166Tab => "country_code_reference",
68 }
69 }
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum ZoneTableFinding {
75 NonUtf8,
76 EmptyTable,
77 InvalidColumnCount,
78 InvalidCountryCode,
79 InvalidCoordinateFormat,
80 EmptyNameField,
82 DuplicateSemanticRow,
87 DuplicateCodeInRow,
89}
90
91impl ZoneTableFinding {
92 pub fn as_str(self) -> &'static str {
93 match self {
94 ZoneTableFinding::NonUtf8 => "non_utf8",
95 ZoneTableFinding::EmptyTable => "empty_table",
96 ZoneTableFinding::InvalidColumnCount => "invalid_column_count",
97 ZoneTableFinding::InvalidCountryCode => "invalid_country_code",
98 ZoneTableFinding::InvalidCoordinateFormat => "invalid_coordinate_format",
99 ZoneTableFinding::EmptyNameField => "empty_name_field",
100 ZoneTableFinding::DuplicateSemanticRow => "duplicate_semantic_row",
101 ZoneTableFinding::DuplicateCodeInRow => "duplicate_code_in_row",
102 }
103 }
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum ZoneUniverse {
113 NotResolvedStructuralOnly,
115 AdmittedSourceDefinitions,
116 CompiledOutputTree,
117 ReferenceDistributionTable,
118 SourcePlusBackwardLinks,
119 Unknown,
120}
121
122impl ZoneUniverse {
123 pub fn as_str(self) -> &'static str {
124 match self {
125 ZoneUniverse::NotResolvedStructuralOnly => "not_resolved_structural_only",
126 ZoneUniverse::AdmittedSourceDefinitions => "admitted_source_definitions",
127 ZoneUniverse::CompiledOutputTree => "compiled_output_tree",
128 ZoneUniverse::ReferenceDistributionTable => "reference_distribution_table",
129 ZoneUniverse::SourcePlusBackwardLinks => "source_plus_backward_links",
130 ZoneUniverse::Unknown => "unknown",
131 }
132 }
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum CountryCodeAuthority {
140 SameAdmittedReleaseIso3166Tab,
141 ExternalIsoRegistry,
142 HostLibrary,
143 NotCrossValidated,
144}
145
146impl CountryCodeAuthority {
147 pub fn as_str(self) -> &'static str {
148 match self {
149 CountryCodeAuthority::SameAdmittedReleaseIso3166Tab => {
150 "same_admitted_release_iso3166_tab"
151 }
152 CountryCodeAuthority::ExternalIsoRegistry => "external_iso_registry",
153 CountryCodeAuthority::HostLibrary => "host_library",
154 CountryCodeAuthority::NotCrossValidated => "not_cross_validated",
155 }
156 }
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub enum ZoneTableStructuralVerdict {
163 Conformant,
164 Violation,
165}
166
167impl ZoneTableStructuralVerdict {
168 pub fn as_str(self) -> &'static str {
169 match self {
170 ZoneTableStructuralVerdict::Conformant => "conformant",
171 ZoneTableStructuralVerdict::Violation => "violation",
172 }
173 }
174}
175
176#[derive(Debug, Clone)]
178pub struct AuxTableValidation {
179 pub kind: ZoneTableKind,
180 pub verdict: ZoneTableStructuralVerdict,
181 pub rows_checked: usize,
182 pub country_code_authority: CountryCodeAuthority,
184 pub findings: Vec<(usize, ZoneTableFinding)>,
186}
187
188impl AuxTableValidation {
189 fn to_json(&self) -> String {
190 let mut findings = String::from("[");
191 for (i, (line, f)) in self.findings.iter().enumerate() {
192 if i > 0 {
193 findings.push_str(", ");
194 }
195 findings.push_str(&format!(
196 "{{ \"line\": {}, \"finding\": {} }}",
197 line,
198 escape(f.as_str())
199 ));
200 }
201 findings.push(']');
202 format!(
203 "{{ \"kind\": {}, \"artifact_category\": {}, \"coverage\": {}, \"verdict\": {}, \
204 \"rows_checked\": {}, \"country_code_authority\": {}, \"findings\": {} }}",
205 escape(self.kind.as_str()),
206 escape(self.kind.artifact_category().as_str()),
207 escape(self.kind.coverage()),
208 escape(self.verdict.as_str()),
209 self.rows_checked,
210 escape(self.country_code_authority.as_str()),
211 findings
212 )
213 }
214}
215
216fn is_country_code(s: &str) -> bool {
218 s.len() == 2 && s.bytes().all(|b| b.is_ascii_uppercase())
219}
220
221fn is_coordinate(s: &str) -> bool {
224 let b = s.as_bytes();
225 if b.is_empty() || (b[0] != b'+' && b[0] != b'-') {
226 return false;
227 }
228 let rest = &s[1..];
230 let lon_sign = match rest.find(['+', '-']) {
231 Some(i) => i,
232 None => return false,
233 };
234 let lat_digits = &rest[..lon_sign];
235 let lon_part = &rest[lon_sign + 1..];
236 let lat_ok = (lat_digits.len() == 4 || lat_digits.len() == 6)
237 && lat_digits.bytes().all(|c| c.is_ascii_digit());
238 let lon_ok = (lon_part.len() == 5 || lon_part.len() == 7)
239 && lon_part.bytes().all(|c| c.is_ascii_digit());
240 lat_ok && lon_ok
241}
242
243pub fn validate_zone_table(
247 kind: ZoneTableKind,
248 bytes: &[u8],
249 iso3166_codes: Option<&BTreeSet<String>>,
250) -> AuxTableValidation {
251 let mut findings: Vec<(usize, ZoneTableFinding)> = Vec::new();
252 let push = |findings: &mut Vec<(usize, ZoneTableFinding)>, line: usize, f: ZoneTableFinding| {
253 if findings.len() < 32 {
254 findings.push((line, f));
255 }
256 };
257
258 let cross_validates = matches!(kind, ZoneTableKind::ZoneTab | ZoneTableKind::Zone1970Tab);
262 let country_code_authority = if cross_validates && iso3166_codes.is_some() {
263 CountryCodeAuthority::SameAdmittedReleaseIso3166Tab
264 } else {
265 CountryCodeAuthority::NotCrossValidated
266 };
267
268 let text = match std::str::from_utf8(bytes) {
269 Ok(t) => t,
270 Err(_) => {
271 return AuxTableValidation {
272 kind,
273 verdict: ZoneTableStructuralVerdict::Violation,
274 rows_checked: 0,
275 country_code_authority,
276 findings: vec![(0, ZoneTableFinding::NonUtf8)],
277 };
278 }
279 };
280
281 let mut rows_checked = 0usize;
282 let mut seen_identity: BTreeSet<String> = BTreeSet::new();
286 for (idx, raw) in text.lines().enumerate() {
287 let line = idx + 1;
288 if raw.is_empty() || raw.starts_with('#') {
290 continue;
291 }
292 rows_checked += 1;
293 let fields: Vec<&str> = raw.split('\t').collect();
294
295 match kind {
296 ZoneTableKind::Iso3166Tab => {
297 if fields.len() != 2 {
299 push(&mut findings, line, ZoneTableFinding::InvalidColumnCount);
300 continue;
301 }
302 if !is_country_code(fields[0]) {
303 push(&mut findings, line, ZoneTableFinding::InvalidCountryCode);
304 } else if !seen_identity.insert(fields[0].to_string()) {
305 push(&mut findings, line, ZoneTableFinding::DuplicateSemanticRow);
306 }
307 if fields[1].is_empty() {
308 push(&mut findings, line, ZoneTableFinding::EmptyNameField);
309 }
310 }
311 ZoneTableKind::ZoneTab | ZoneTableKind::Zone1970Tab | ZoneTableKind::ZonenowTab => {
312 if fields.len() < 3 {
314 push(&mut findings, line, ZoneTableFinding::InvalidColumnCount);
315 continue;
316 }
317 let codes = fields[0];
320 let code_ok = codes.split(',').all(|c| {
321 is_country_code(c) || (kind == ZoneTableKind::ZonenowTab && c == "XX")
322 });
323 if !code_ok {
324 push(&mut findings, line, ZoneTableFinding::InvalidCountryCode);
325 } else {
326 if kind == ZoneTableKind::Zone1970Tab {
329 let mut in_row: BTreeSet<&str> = BTreeSet::new();
330 for c in codes.split(',') {
331 if !in_row.insert(c) {
332 push(&mut findings, line, ZoneTableFinding::DuplicateCodeInRow);
333 }
334 }
335 }
336 if cross_validates {
339 if let Some(set) = iso3166_codes {
340 if !codes.split(',').all(|c| set.contains(c)) {
341 push(&mut findings, line, ZoneTableFinding::InvalidCountryCode);
342 }
343 }
344 }
345 }
346 if !is_coordinate(fields[1]) {
347 push(
348 &mut findings,
349 line,
350 ZoneTableFinding::InvalidCoordinateFormat,
351 );
352 }
353 if fields[2].is_empty() {
355 push(&mut findings, line, ZoneTableFinding::EmptyNameField);
356 }
357 let identity = format!("{}\t{}\t{}", fields[0], fields[1], fields[2]);
360 if !seen_identity.insert(identity) {
361 push(&mut findings, line, ZoneTableFinding::DuplicateSemanticRow);
362 }
363 }
364 }
365 }
366
367 if rows_checked == 0 {
368 push(&mut findings, 0, ZoneTableFinding::EmptyTable);
369 }
370 let verdict = if findings.is_empty() {
371 ZoneTableStructuralVerdict::Conformant
372 } else {
373 ZoneTableStructuralVerdict::Violation
374 };
375 AuxTableValidation {
376 kind,
377 verdict,
378 rows_checked,
379 country_code_authority,
380 findings,
381 }
382}
383
384pub fn iso3166_codes(bytes: &[u8]) -> BTreeSet<String> {
387 let mut set = BTreeSet::new();
388 if let Ok(text) = std::str::from_utf8(bytes) {
389 for raw in text.lines() {
390 if raw.is_empty() || raw.starts_with('#') {
391 continue;
392 }
393 if let Some(code) = raw.split('\t').next() {
394 if is_country_code(code) {
395 set.insert(code.to_string());
396 }
397 }
398 }
399 }
400 set
401}
402
403#[derive(Debug, Clone, Copy, PartialEq, Eq)]
408pub enum InstallEcologyStatus {
409 NotClaimed,
411 InventoryOnly,
413 CompileOutputTreeOnly,
415}
416
417impl InstallEcologyStatus {
418 pub fn as_str(self) -> &'static str {
419 match self {
420 InstallEcologyStatus::NotClaimed => "not_claimed",
421 InstallEcologyStatus::InventoryOnly => "inventory_only",
422 InstallEcologyStatus::CompileOutputTreeOnly => "compile_output_tree_only",
423 }
424 }
425 pub fn current() -> Self {
427 InstallEcologyStatus::CompileOutputTreeOnly
428 }
429}
430
431#[derive(Debug, Clone)]
434pub struct AuxTableValidationReport {
435 pub tables: Vec<AuxTableValidation>,
436 pub install_ecology: InstallEcologyStatus,
437}
438
439impl AuxTableValidationReport {
440 pub fn to_json(&self) -> String {
441 let mut tables = String::from("[");
442 for (i, t) in self.tables.iter().enumerate() {
443 if i > 0 {
444 tables.push_str(", ");
445 }
446 tables.push_str(&t.to_json());
447 }
448 tables.push(']');
449 format!(
450 "{{\n \"schema\": \"zic-rs-aux-table-validation-v1\",\n \
451 \"non_claim\": \"a conformant table row proves table structural admissibility only — NOT \
452 that the named zone was compiled, semantically witnessed, historically equivalent, or \
453 installed; coordinate syntax does NOT claim geodetic accuracy; a public-domain notice is not \
454 provenance; release identity is never inferred from table comments\",\n \
455 \"zone_universe\": {},\n \"table_diagnostic_code_space\": \"separate_table_codes\",\n \
456 \"coordinate_verdict\": \"syntax_only_geodetic_truth_not_claimed\",\n \
457 \"table_comment_disposition\": \"ignored_for_validation\",\n \
458 \"install_ecology_status\": {},\n \"tables\": {}\n}}\n",
459 escape(ZoneUniverse::NotResolvedStructuralOnly.as_str()),
461 escape(self.install_ecology.as_str()),
462 tables
463 )
464 }
465}