use super::funejson::Value; const ALGORITHMS: [&str; 3] = ["MOD10", "MOD11", "DBLAL"]; /// The fields of each non-blank line, with its 1-based number. Only ASCII /// spaces and tabs separate fields, and a trailing carriage return is /// dropped, so a file saved with Windows line endings reads the same and /// every language splits alike. fn lines(text: &str) -> Vec<(usize, Vec<&str>)> { let body = text.strip_prefix('\u{FEFF}').unwrap_or(text); let mut out = Vec::new(); for (i, raw) in body.split('\n').enumerate() { let line = raw.strip_suffix('\r').unwrap_or(raw); let fields: Vec<&str> = line.split(|c| c == ' ' || c == '\t').filter(|f| !f.is_empty()).collect(); if !fields.is_empty() { out.push((i + 1, fields)); } } out } fn sort_code(file: &str, line: usize, value: &str) -> String { if value.len() != 6 || !value.bytes().all(|b| b.is_ascii_digit()) { panic!("{} line {}: \"{}\" is not a six-digit sort code", file, line, value); } value.to_string() } /// `-?[0-9]{1,4}`, parsed; None for anything else. fn weight(value: &str) -> Option { let digits = value.strip_prefix('-').unwrap_or(value); if digits.is_empty() || digits.len() > 4 || !digits.bytes().all(|b| b.is_ascii_digit()) { return None; } let n: i64 = digits.parse().ok()?; Some(if value.starts_with('-') { -n } else { n }) } fn parse_row(line: usize, fields: &[&str]) -> UkModulusRow { let wh = format!("VALACDOS.txt line {}", line); if fields.len() != 17 && fields.len() != 18 { panic!( "{}: expected 17 or 18 fields (start, end, algorithm, 14 weights, optional exception), found {}", wh, fields.len() ); } let start = sort_code("VALACDOS.txt", line, fields[0]); let end = sort_code("VALACDOS.txt", line, fields[1]); if end < start { panic!("{}: range {} to {} ends before it starts", wh, start, end); } let algorithm = fields[2]; if !ALGORITHMS.contains(&algorithm) { panic!("{}: unknown algorithm \"{}\"; expected MOD10, MOD11 or DBLAL", wh, algorithm); } let weights: Vec = fields[3..17] .iter() .map(|w| match weight(w) { Some(n) => n, None => panic!("{}: weight \"{}\" is not a whole number", wh, w), }) .collect(); // A digit sum of a negative product means different things in different // languages' remainder rules; the specification never needs one. if algorithm == "DBLAL" && weights.iter().any(|w| *w < 0) { panic!("{}: a DBLAL row cannot have a negative weight", wh); } let mut exception = None; if fields.len() == 18 { let e = fields[17]; let n = if e.is_empty() || e.len() > 2 || !e.bytes().all(|b| b.is_ascii_digit()) { 0 } else { e.parse::().unwrap_or(0) }; if !(1..=14).contains(&n) { panic!("{}: exception \"{}\" is not a number from 1 to 14", wh, e); } exception = Some(n); } UkModulusRow { start, end, algorithm: algorithm.to_string(), weights, exception, } } fn parse_substitution(line: usize, fields: &[&str]) -> UkSortCodeSubstitution { if fields.len() != 2 { panic!("SCSUBTAB.txt line {}: expected two sort codes, found {} fields", line, fields.len()); } UkSortCodeSubstitution { original: sort_code("SCSUBTAB.txt", line, fields[0]), substitute: sort_code("SCSUBTAB.txt", line, fields[1]), } } /// Parse the text of Vocalink's VALACDOS.txt (the modulus weight table) and /// SCSUBTAB.txt (exception 5's sort code substitutions) into the table /// `validate_uk_sort_code_account` checks against. /// /// Rows keep their file order, which matters: where a sort code falls in two /// rows, the first is the first check. Blank lines are skipped; anything else /// malformed panics naming the file and the line, because a silently dropped /// row turns a checkable sort code into an unchecked one. pub fn parse_uk_modulus_table(valacdos: &str, scsubtab: &str) -> UkModulusTable { let rows: Vec = lines(valacdos).iter().map(|(n, f)| parse_row(*n, f)).collect(); if rows.is_empty() { panic!("VALACDOS.txt has no rows"); } let substitutions = lines(scsubtab).iter().map(|(n, f)| parse_substitution(*n, f)).collect(); UkModulusTable { rows, substitutions } } /// Object keys are camelCase to match the shared vectors. pub fn uk_modulus_table_to_value(table: &UkModulusTable) -> Value { Value::obj(vec![ ( "rows", Value::Arr( table .rows .iter() .map(|r| { Value::obj(vec![ ("start", Value::str(&r.start)), ("end", Value::str(&r.end)), ("algorithm", Value::str(&r.algorithm)), ("weights", Value::Arr(r.weights.iter().map(|w| Value::Int(*w)).collect())), ("exception", r.exception.map_or(Value::Null, Value::Int)), ]) }) .collect(), ), ), ( "substitutions", Value::Arr( table .substitutions .iter() .map(|s| { Value::obj(vec![ ("original", Value::str(&s.original)), ("substitute", Value::str(&s.substitute)), ]) }) .collect(), ), ), ]) } /// The table from its JSON form, for the vector adapters of capabilities that /// take one. A missing list is empty and a missing exception is null. pub fn uk_modulus_table_from_value(v: &Value) -> UkModulusTable { let list = |key: &str| -> Vec { let field = v.get(key); if field.is_null() { Vec::new() } else { field.as_arr().to_vec() } }; UkModulusTable { rows: list("rows") .iter() .map(|r| UkModulusRow { start: r.get("start").as_str().to_string(), end: r.get("end").as_str().to_string(), algorithm: r.get("algorithm").as_str().to_string(), weights: r.get("weights").as_arr().iter().map(|w| w.as_i64()).collect(), exception: if r.get("exception").is_null() { None } else { Some(r.get("exception").as_i64()) }, }) .collect(), substitutions: list("substitutions") .iter() .map(|s| UkSortCodeSubstitution { original: s.get("original").as_str().to_string(), substitute: s.get("substitute").as_str().to_string(), }) .collect(), } } pub fn fune_vector(args: &[Value]) -> Value { uk_modulus_table_to_value(&parse_uk_modulus_table(args[0].as_str(), args[1].as_str())) }