Skip to main content

stryke/
builtins_validate.rs

1//! Validation / input-check primitives.
2//!
3//! Every function takes `&[StrykeValue]` and returns `StrykeValue`.
4//! Predicates return `1` / `0` integers (stryke truthy/falsy). Format
5//! / convert helpers return strings or undef on parse failure.
6//!
7//! Standards followed where applicable:
8//!   * IBAN — ISO 13616 + MOD-97-10 check (only check digits validated;
9//!     country-specific format strings come from a compact table)
10//!   * Luhn — ISO/IEC 7812-1 §A
11//!   * IMEI — Luhn on 15 digits (incl. check digit)
12//!   * UUID — RFC 9562 (versioned, dash form)
13//!   * VIN — ISO 3779 transliteration table + weight vector
14//!   * SemVer — semver.org v2.0.0 grammar (subset; uses regex for parse)
15//!   * EAN-13 / UPC — GS1 General Specifications (right-to-left weights 3/1)
16
17use crate::value::StrykeValue;
18
19#[inline]
20fn b(v: bool) -> StrykeValue {
21    StrykeValue::integer(if v { 1 } else { 0 })
22}
23
24fn arg_str(args: &[StrykeValue]) -> String {
25    args.first().map(|v| v.to_string()).unwrap_or_default()
26}
27
28// ══════════════════════════════════════════════════════════════════════
29// Character-class predicates
30// ══════════════════════════════════════════════════════════════════════
31
32pub fn is_alpha_only(args: &[StrykeValue]) -> StrykeValue {
33    let s = arg_str(args);
34    b(!s.is_empty() && s.chars().all(|c| c.is_ascii_alphabetic()))
35}
36
37pub fn is_alphanumeric_only(args: &[StrykeValue]) -> StrykeValue {
38    let s = arg_str(args);
39    b(!s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric()))
40}
41
42pub fn is_numeric_only(args: &[StrykeValue]) -> StrykeValue {
43    let s = arg_str(args);
44    b(!s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
45}
46
47pub fn is_ascii_only(args: &[StrykeValue]) -> StrykeValue {
48    b(arg_str(args).is_ascii())
49}
50
51pub fn is_printable_ascii(args: &[StrykeValue]) -> StrykeValue {
52    let s = arg_str(args);
53    b(!s.is_empty() && s.chars().all(|c| c.is_ascii() && !c.is_ascii_control()))
54}
55
56pub fn is_utf8(args: &[StrykeValue]) -> StrykeValue {
57    // strings in stryke are already utf-8; the question is whether they
58    // contain valid utf-8 when interpreted as bytes. always true for
59    // a StrykeValue::string but check raw bytes for completeness.
60    let s = arg_str(args);
61    b(std::str::from_utf8(s.as_bytes()).is_ok())
62}
63
64pub fn is_lowercase(args: &[StrykeValue]) -> StrykeValue {
65    let s = arg_str(args);
66    let has_letter = s.chars().any(|c| c.is_alphabetic());
67    b(has_letter
68        && s.chars()
69            .filter(|c| c.is_alphabetic())
70            .all(|c| c.is_lowercase()))
71}
72
73pub fn is_uppercase(args: &[StrykeValue]) -> StrykeValue {
74    let s = arg_str(args);
75    let has_letter = s.chars().any(|c| c.is_alphabetic());
76    b(has_letter
77        && s.chars()
78            .filter(|c| c.is_alphabetic())
79            .all(|c| c.is_uppercase()))
80}
81
82pub fn is_titlecase(args: &[StrykeValue]) -> StrykeValue {
83    let s = arg_str(args);
84    if s.is_empty() {
85        return b(false);
86    }
87    // Each word starts with uppercase, rest lowercase.
88    for word in s.split_whitespace() {
89        let mut chars = word.chars();
90        match chars.next() {
91            Some(c) if c.is_uppercase() => {}
92            _ => return b(false),
93        }
94        if !chars.all(|c| !c.is_alphabetic() || c.is_lowercase()) {
95            return b(false);
96        }
97    }
98    b(true)
99}
100
101pub fn is_palindrome_str(args: &[StrykeValue]) -> StrykeValue {
102    let s = arg_str(args);
103    let clean: Vec<char> = s
104        .chars()
105        .filter(|c| c.is_alphanumeric())
106        .flat_map(|c| c.to_lowercase())
107        .collect();
108    if clean.is_empty() {
109        return b(false);
110    }
111    let n = clean.len();
112    for i in 0..n / 2 {
113        if clean[i] != clean[n - 1 - i] {
114            return b(false);
115        }
116    }
117    b(true)
118}
119
120// ══════════════════════════════════════════════════════════════════════
121// Numeric / encoding predicates
122// ══════════════════════════════════════════════════════════════════════
123
124pub fn is_hex(args: &[StrykeValue]) -> StrykeValue {
125    let s = arg_str(args);
126    let cleaned = s.trim_start_matches("0x").trim_start_matches("0X");
127    b(!cleaned.is_empty() && cleaned.chars().all(|c| c.is_ascii_hexdigit()))
128}
129
130pub fn is_octal(args: &[StrykeValue]) -> StrykeValue {
131    let s = arg_str(args);
132    let cleaned = s.trim_start_matches("0o").trim_start_matches("0O");
133    b(!cleaned.is_empty() && cleaned.chars().all(|c| ('0'..='7').contains(&c)))
134}
135
136pub fn is_binary(args: &[StrykeValue]) -> StrykeValue {
137    let s = arg_str(args);
138    let cleaned = s.trim_start_matches("0b").trim_start_matches("0B");
139    b(!cleaned.is_empty() && cleaned.chars().all(|c| c == '0' || c == '1'))
140}
141
142pub fn is_base32(args: &[StrykeValue]) -> StrykeValue {
143    let s = arg_str(args);
144    let cleaned = s.trim_end_matches('=');
145    b(!cleaned.is_empty() && cleaned.chars().all(|c| matches!(c, 'A'..='Z' | '2'..='7')))
146}
147
148pub fn is_md5_hash(args: &[StrykeValue]) -> StrykeValue {
149    let s = arg_str(args);
150    b(s.len() == 32 && s.chars().all(|c| c.is_ascii_hexdigit()))
151}
152
153pub fn is_sha1_hash(args: &[StrykeValue]) -> StrykeValue {
154    let s = arg_str(args);
155    b(s.len() == 40 && s.chars().all(|c| c.is_ascii_hexdigit()))
156}
157
158pub fn is_sha256_hash(args: &[StrykeValue]) -> StrykeValue {
159    let s = arg_str(args);
160    b(s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit()))
161}
162
163// ══════════════════════════════════════════════════════════════════════
164// Address-form predicates
165// ══════════════════════════════════════════════════════════════════════
166
167pub fn is_ipv6(args: &[StrykeValue]) -> StrykeValue {
168    b(arg_str(args).parse::<std::net::Ipv6Addr>().is_ok())
169}
170
171pub fn is_cidr(args: &[StrykeValue]) -> StrykeValue {
172    let s = arg_str(args);
173    let s = s.trim();
174    let Some((addr, prefix)) = s.split_once('/') else {
175        return b(false);
176    };
177    let Ok(ip) = addr.parse::<std::net::IpAddr>() else {
178        return b(false);
179    };
180    let Ok(p) = prefix.parse::<u8>() else {
181        return b(false);
182    };
183    let max = match ip {
184        std::net::IpAddr::V4(_) => 32,
185        std::net::IpAddr::V6(_) => 128,
186    };
187    b(p <= max)
188}
189
190pub fn is_mac(args: &[StrykeValue]) -> StrykeValue {
191    let s = arg_str(args);
192    let cleaned: String = s.chars().filter(|c| c.is_ascii_hexdigit()).collect();
193    b(cleaned.len() == 12)
194}
195
196// ══════════════════════════════════════════════════════════════════════
197// URL / UUID / JWT
198// ══════════════════════════════════════════════════════════════════════
199
200pub fn is_url_http(args: &[StrykeValue]) -> StrykeValue {
201    let s = arg_str(args);
202    let lower = s.trim().to_ascii_lowercase();
203    b(lower.starts_with("http://") && s.len() > 7)
204}
205
206pub fn is_url_https(args: &[StrykeValue]) -> StrykeValue {
207    let s = arg_str(args);
208    let lower = s.trim().to_ascii_lowercase();
209    b(lower.starts_with("https://") && s.len() > 8)
210}
211
212/// Validate a UUID with a specific version digit.
213fn uuid_version_check(s: &str, expected: u8) -> bool {
214    // Strict dash form: 8-4-4-4-12 hex digits
215    if s.len() != 36 {
216        return false;
217    }
218    let bytes = s.as_bytes();
219    let dashes = [8, 13, 18, 23];
220    for (i, b) in bytes.iter().enumerate() {
221        if dashes.contains(&i) {
222            if *b != b'-' {
223                return false;
224            }
225        } else if !b.is_ascii_hexdigit() {
226            return false;
227        }
228    }
229    // Version digit is byte 14 (0-indexed)
230    let version_char = bytes[14] as char;
231    version_char.to_digit(16) == Some(expected as u32)
232}
233
234pub fn is_uuid_v4(args: &[StrykeValue]) -> StrykeValue {
235    b(uuid_version_check(&arg_str(args), 4))
236}
237
238pub fn is_uuid_v7(args: &[StrykeValue]) -> StrykeValue {
239    b(uuid_version_check(&arg_str(args), 7))
240}
241
242pub fn is_jwt(args: &[StrykeValue]) -> StrykeValue {
243    let s = arg_str(args);
244    let parts: Vec<&str> = s.split('.').collect();
245    if parts.len() != 3 {
246        return b(false);
247    }
248    // Each part must be non-empty base64url
249    for p in &parts {
250        if p.is_empty() {
251            return b(false);
252        }
253        if !p
254            .chars()
255            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
256        {
257            return b(false);
258        }
259    }
260    b(true)
261}
262
263pub fn is_email_strict(args: &[StrykeValue]) -> StrykeValue {
264    // RFC 5322 dot-atom local-part + domain with at least one dot.
265    let s = arg_str(args);
266    let s = s.trim();
267    if s.len() > 254 {
268        return b(false);
269    }
270    let Some(at_pos) = s.rfind('@') else {
271        return b(false);
272    };
273    let local = &s[..at_pos];
274    let domain = &s[at_pos + 1..];
275    if local.is_empty() || local.len() > 64 {
276        return b(false);
277    }
278    if domain.is_empty() || !domain.contains('.') {
279        return b(false);
280    }
281    // Local-part: dot-atom (atext) only, no consecutive dots, no leading/trailing dot.
282    if local.starts_with('.') || local.ends_with('.') || local.contains("..") {
283        return b(false);
284    }
285    for c in local.chars() {
286        if !matches!(c,
287            'a'..='z' | 'A'..='Z' | '0'..='9'
288                | '!' | '#' | '$' | '%' | '&' | '\''
289                | '*' | '+' | '-' | '/' | '=' | '?' | '^'
290                | '_' | '`' | '{' | '|' | '}' | '~' | '.'
291        ) {
292            return b(false);
293        }
294    }
295    // Domain: labels of 1..=63 alphanumeric/hyphens, no leading/trailing hyphen.
296    for label in domain.split('.') {
297        if label.is_empty() || label.len() > 63 {
298            return b(false);
299        }
300        if label.starts_with('-') || label.ends_with('-') {
301            return b(false);
302        }
303        if !label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
304            return b(false);
305        }
306    }
307    b(true)
308}
309
310// ══════════════════════════════════════════════════════════════════════
311// Identification numbers / barcodes (Luhn / mod-10 / mod-97)
312// ══════════════════════════════════════════════════════════════════════
313
314/// Generic Luhn check on a string of digits.
315fn luhn_valid(s: &str) -> bool {
316    let digits: Vec<u32> = s
317        .chars()
318        .filter(|c| c.is_ascii_digit())
319        .map(|c| c.to_digit(10).unwrap())
320        .collect();
321    if digits.len() < 2 {
322        return false;
323    }
324    let mut sum = 0u32;
325    for (i, d) in digits.iter().rev().enumerate() {
326        if i % 2 == 1 {
327            let doubled = d * 2;
328            sum += if doubled > 9 { doubled - 9 } else { doubled };
329        } else {
330            sum += d;
331        }
332    }
333    sum.is_multiple_of(10)
334}
335
336/// Compute the Luhn check digit for a partial number (digits without check).
337pub fn luhn_digit(args: &[StrykeValue]) -> StrykeValue {
338    let s = arg_str(args);
339    let digits: Vec<u32> = s
340        .chars()
341        .filter(|c| c.is_ascii_digit())
342        .map(|c| c.to_digit(10).unwrap())
343        .collect();
344    if digits.is_empty() {
345        return StrykeValue::UNDEF;
346    }
347    // Compute the check digit by simulating an extra zero on the right.
348    let mut sum = 0u32;
349    for (i, d) in digits.iter().rev().enumerate() {
350        if i % 2 == 0 {
351            let doubled = d * 2;
352            sum += if doubled > 9 { doubled - 9 } else { doubled };
353        } else {
354            sum += d;
355        }
356    }
357    let check = (10 - (sum % 10)) % 10;
358    StrykeValue::integer(check as i64)
359}
360
361pub fn is_imei(args: &[StrykeValue]) -> StrykeValue {
362    let s = arg_str(args);
363    let digits_only: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
364    b(digits_only.len() == 15 && luhn_valid(&digits_only))
365}
366
367pub fn is_imsi(args: &[StrykeValue]) -> StrykeValue {
368    let s = arg_str(args);
369    let digits_only: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
370    b(matches!(digits_only.len(), 14..=15) && digits_only.chars().all(|c| c.is_ascii_digit()))
371}
372
373pub fn is_vin(args: &[StrykeValue]) -> StrykeValue {
374    let s = arg_str(args).to_ascii_uppercase();
375    if s.len() != 17 {
376        return b(false);
377    }
378    // No I, O, or Q.
379    if s.chars().any(|c| c == 'I' || c == 'O' || c == 'Q') {
380        return b(false);
381    }
382    // Transliteration values per ISO 3779.
383    let v = |c: char| -> Option<u32> {
384        match c {
385            '0'..='9' => Some(c.to_digit(10).unwrap()),
386            'A' | 'J' => Some(1),
387            'B' | 'K' | 'S' => Some(2),
388            'C' | 'L' | 'T' => Some(3),
389            'D' | 'M' | 'U' => Some(4),
390            'E' | 'N' | 'V' => Some(5),
391            'F' | 'W' => Some(6),
392            'G' | 'P' | 'X' => Some(7),
393            'H' | 'Y' => Some(8),
394            'R' | 'Z' => Some(9),
395            _ => None,
396        }
397    };
398    let weights: [u32; 17] = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2];
399    let mut sum = 0u32;
400    for (i, c) in s.chars().enumerate() {
401        let Some(vv) = v(c) else {
402            return b(false);
403        };
404        sum += vv * weights[i];
405    }
406    let check = sum % 11;
407    let expected = s.chars().nth(8).unwrap();
408    let check_char = if check == 10 {
409        'X'
410    } else {
411        std::char::from_digit(check, 10).unwrap()
412    };
413    b(expected == check_char)
414}
415
416/// `vin_decode(VIN)` — parse a VIN into `{ wmi, vds, vis, year, plant }`.
417/// Year decoding uses the ISO 3779 30-year cycle starting at 1980.
418pub fn vin_decode(args: &[StrykeValue]) -> StrykeValue {
419    use indexmap::IndexMap;
420    use parking_lot::RwLock;
421    use std::sync::Arc;
422    let s = arg_str(args).to_ascii_uppercase();
423    if s.len() != 17 {
424        return StrykeValue::UNDEF;
425    }
426    let wmi = &s[0..3];
427    let vds = &s[3..9];
428    let vis = &s[9..17];
429    let year_char = s.chars().nth(9).unwrap();
430    let plant = s.chars().nth(10).unwrap();
431    // Year letter → base year (30-year cycle; we pick the most recent past).
432    let year_letter_to_offset = |c: char| -> Option<u32> {
433        match c {
434            'A' => Some(10),
435            'B' => Some(11),
436            'C' => Some(12),
437            'D' => Some(13),
438            'E' => Some(14),
439            'F' => Some(15),
440            'G' => Some(16),
441            'H' => Some(17),
442            'J' => Some(18),
443            'K' => Some(19),
444            'L' => Some(20),
445            'M' => Some(21),
446            'N' => Some(22),
447            'P' => Some(23),
448            'R' => Some(24),
449            'S' => Some(25),
450            'T' => Some(26),
451            'V' => Some(27),
452            'W' => Some(28),
453            'X' => Some(29),
454            'Y' => Some(0),
455            '1' => Some(1),
456            '2' => Some(2),
457            '3' => Some(3),
458            '4' => Some(4),
459            '5' => Some(5),
460            '6' => Some(6),
461            '7' => Some(7),
462            '8' => Some(8),
463            '9' => Some(9),
464            _ => None,
465        }
466    };
467    let year = year_letter_to_offset(year_char).map(|o| 2000 + o);
468    let mut h: IndexMap<String, StrykeValue> = IndexMap::new();
469    h.insert("wmi".to_string(), StrykeValue::string(wmi.to_string()));
470    h.insert("vds".to_string(), StrykeValue::string(vds.to_string()));
471    h.insert("vis".to_string(), StrykeValue::string(vis.to_string()));
472    if let Some(y) = year {
473        h.insert("year".to_string(), StrykeValue::integer(y as i64));
474    }
475    h.insert("plant".to_string(), StrykeValue::string(plant.to_string()));
476    StrykeValue::hash_ref(Arc::new(RwLock::new(h)))
477}
478
479pub fn is_ean13(args: &[StrykeValue]) -> StrykeValue {
480    let s = arg_str(args);
481    let digits: Vec<u32> = s
482        .chars()
483        .filter(|c| c.is_ascii_digit())
484        .map(|c| c.to_digit(10).unwrap())
485        .collect();
486    if digits.len() != 13 {
487        return b(false);
488    }
489    let mut sum = 0u32;
490    for (i, d) in digits.iter().take(12).enumerate() {
491        sum += d * if i % 2 == 0 { 1 } else { 3 };
492    }
493    let check = (10 - (sum % 10)) % 10;
494    b(check == digits[12])
495}
496
497pub fn is_upc(args: &[StrykeValue]) -> StrykeValue {
498    let s = arg_str(args);
499    let digits: Vec<u32> = s
500        .chars()
501        .filter(|c| c.is_ascii_digit())
502        .map(|c| c.to_digit(10).unwrap())
503        .collect();
504    if digits.len() != 12 {
505        return b(false);
506    }
507    let mut sum = 0u32;
508    for (i, d) in digits.iter().take(11).enumerate() {
509        sum += d * if i % 2 == 0 { 3 } else { 1 };
510    }
511    let check = (10 - (sum % 10)) % 10;
512    b(check == digits[11])
513}
514
515pub fn is_isbn(args: &[StrykeValue]) -> StrykeValue {
516    let s = arg_str(args);
517    let cleaned: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect();
518    match cleaned.len() {
519        10 => b(isbn10_valid(&cleaned)),
520        13 => b(isbn13_valid(&cleaned)),
521        _ => b(false),
522    }
523}
524
525fn isbn10_valid(s: &str) -> bool {
526    if s.len() != 10 {
527        return false;
528    }
529    let mut sum = 0u32;
530    for (i, c) in s.chars().enumerate() {
531        let v = if i == 9 && c == 'X' {
532            10
533        } else if c.is_ascii_digit() {
534            c.to_digit(10).unwrap()
535        } else {
536            return false;
537        };
538        sum += v * (10 - i as u32);
539    }
540    sum.is_multiple_of(11)
541}
542
543fn isbn13_valid(s: &str) -> bool {
544    if s.len() != 13 {
545        return false;
546    }
547    let digits: Vec<u32> = match s
548        .chars()
549        .map(|c| c.to_digit(10).ok_or(()))
550        .collect::<Result<Vec<_>, _>>()
551    {
552        Ok(d) => d,
553        Err(_) => return false,
554    };
555    let mut sum = 0u32;
556    for (i, d) in digits.iter().take(12).enumerate() {
557        sum += d * if i % 2 == 0 { 1 } else { 3 };
558    }
559    let check = (10 - (sum % 10)) % 10;
560    check == digits[12]
561}
562
563pub fn isbn10_to_isbn13(args: &[StrykeValue]) -> StrykeValue {
564    let s = arg_str(args);
565    let cleaned: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect();
566    if cleaned.len() != 10 || !isbn10_valid(&cleaned) {
567        return StrykeValue::UNDEF;
568    }
569    let body: String = format!("978{}", &cleaned[..9]);
570    let digits: Vec<u32> = body.chars().map(|c| c.to_digit(10).unwrap()).collect();
571    let mut sum = 0u32;
572    for (i, d) in digits.iter().enumerate() {
573        sum += d * if i % 2 == 0 { 1 } else { 3 };
574    }
575    let check = (10 - (sum % 10)) % 10;
576    StrykeValue::string(format!("{}{}", body, check))
577}
578
579pub fn isbn13_to_isbn10(args: &[StrykeValue]) -> StrykeValue {
580    let s = arg_str(args);
581    let cleaned: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
582    if cleaned.len() != 13 || !isbn13_valid(&cleaned) {
583        return StrykeValue::UNDEF;
584    }
585    if !cleaned.starts_with("978") {
586        // ISBN-13s that start with 979 don't have an ISBN-10 equivalent.
587        return StrykeValue::UNDEF;
588    }
589    let body = &cleaned[3..12];
590    let digits: Vec<u32> = body.chars().map(|c| c.to_digit(10).unwrap()).collect();
591    let mut sum = 0u32;
592    for (i, d) in digits.iter().enumerate() {
593        sum += d * (10 - i as u32);
594    }
595    let r = sum % 11;
596    let check = (11 - r) % 11;
597    let check_char = if check == 10 {
598        'X'
599    } else {
600        std::char::from_digit(check, 10).unwrap()
601    };
602    StrykeValue::string(format!("{}{}", body, check_char))
603}
604
605// ══════════════════════════════════════════════════════════════════════
606// IBAN / BIC / SWIFT
607// ══════════════════════════════════════════════════════════════════════
608
609/// IBAN MOD-97-10 check (after country/check-digit rearrangement).
610pub fn iban_format(args: &[StrykeValue]) -> StrykeValue {
611    let s = arg_str(args);
612    let cleaned: String = s
613        .chars()
614        .filter(|c| !c.is_whitespace())
615        .map(|c| c.to_ascii_uppercase())
616        .collect();
617    // Format in groups of 4
618    let mut out = String::new();
619    for (i, c) in cleaned.chars().enumerate() {
620        if i > 0 && i % 4 == 0 {
621            out.push(' ');
622        }
623        out.push(c);
624    }
625    StrykeValue::string(out)
626}
627
628pub fn iban_country(args: &[StrykeValue]) -> StrykeValue {
629    let s = arg_str(args);
630    let cleaned: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect();
631    if cleaned.len() < 2 {
632        return StrykeValue::UNDEF;
633    }
634    let cc: String = cleaned
635        .chars()
636        .take(2)
637        .collect::<String>()
638        .to_ascii_uppercase();
639    if cc.chars().all(|c| c.is_ascii_alphabetic()) {
640        StrykeValue::string(cc)
641    } else {
642        StrykeValue::UNDEF
643    }
644}
645
646/// BIC (also called SWIFT code): 8 or 11 characters.
647///   AAAA — institution (letters)
648///   BB   — country (letters)
649///   CC   — location (alnum)
650///   DDD  — branch (alnum, optional)
651pub fn is_bic(args: &[StrykeValue]) -> StrykeValue {
652    let s = arg_str(args).to_ascii_uppercase();
653    if s.len() != 8 && s.len() != 11 {
654        return b(false);
655    }
656    let chars: Vec<char> = s.chars().collect();
657    // 1-4: letters
658    for &c in &chars[0..4] {
659        if !c.is_ascii_alphabetic() {
660            return b(false);
661        }
662    }
663    // 5-6: country letters
664    for &c in &chars[4..6] {
665        if !c.is_ascii_alphabetic() {
666            return b(false);
667        }
668    }
669    // 7-8: location alnum
670    for &c in &chars[6..8] {
671        if !c.is_ascii_alphanumeric() {
672            return b(false);
673        }
674    }
675    if chars.len() == 11 {
676        // 9-11: branch alnum
677        for &c in &chars[8..11] {
678            if !c.is_ascii_alphanumeric() {
679                return b(false);
680            }
681        }
682    }
683    b(true)
684}
685
686pub fn is_swift(args: &[StrykeValue]) -> StrykeValue {
687    is_bic(args)
688}
689
690// ══════════════════════════════════════════════════════════════════════
691// Phone / postal / SSN
692// ══════════════════════════════════════════════════════════════════════
693
694pub fn is_phone(args: &[StrykeValue]) -> StrykeValue {
695    let s = arg_str(args);
696    let digit_count = s.chars().filter(|c| c.is_ascii_digit()).count();
697    // Lenient: 7..=15 digits, allows spaces/dashes/parens/+/.
698    b((7..=15).contains(&digit_count)
699        && s.chars()
700            .all(|c| c.is_ascii_digit() || c.is_whitespace() || "+-().".contains(c)))
701}
702
703pub fn is_phone_e164(args: &[StrykeValue]) -> StrykeValue {
704    let s = arg_str(args);
705    let s = s.trim();
706    if !s.starts_with('+') {
707        return b(false);
708    }
709    let digits: String = s[1..].chars().filter(|c| c.is_ascii_digit()).collect();
710    b(digits.len() >= 8 && digits.len() <= 15 && s[1..].chars().all(|c| c.is_ascii_digit()))
711}
712
713pub fn is_zip_us(args: &[StrykeValue]) -> StrykeValue {
714    let s = arg_str(args).trim().to_string();
715    b(s.len() == 5 && s.chars().all(|c| c.is_ascii_digit()))
716}
717
718pub fn is_zip_plus4(args: &[StrykeValue]) -> StrykeValue {
719    let s = arg_str(args).trim().to_string();
720    if s.len() != 10 {
721        return b(false);
722    }
723    let bytes = s.as_bytes();
724    b(bytes[5] == b'-'
725        && s[0..5].chars().all(|c| c.is_ascii_digit())
726        && s[6..10].chars().all(|c| c.is_ascii_digit()))
727}
728
729/// `is_postal_code(CODE, COUNTRY?)` — lenient pattern. Country defaults
730/// to "US". Knows a small set of common patterns; falls back to a
731/// 3..=10-char alphanumeric check for unknown countries.
732pub fn is_postal_code(args: &[StrykeValue]) -> StrykeValue {
733    let code = arg_str(args).trim().to_ascii_uppercase();
734    let country = args
735        .get(1)
736        .map(|v| v.to_string().trim().to_ascii_uppercase())
737        .unwrap_or_else(|| "US".to_string());
738    let ok = match country.as_str() {
739        "US" => {
740            code.len() == 5 && code.chars().all(|c| c.is_ascii_digit())
741                || (code.len() == 10
742                    && code.chars().nth(5) == Some('-')
743                    && code[..5].chars().all(|c| c.is_ascii_digit())
744                    && code[6..].chars().all(|c| c.is_ascii_digit()))
745        }
746        "CA" => {
747            // A1A 1A1 or A1A1A1
748            let cleaned: String = code.chars().filter(|c| !c.is_whitespace()).collect();
749            cleaned.len() == 6
750                && cleaned.chars().enumerate().all(|(i, c)| {
751                    if i % 2 == 0 {
752                        c.is_ascii_alphabetic()
753                    } else {
754                        c.is_ascii_digit()
755                    }
756                })
757        }
758        "UK" | "GB" => {
759            let cleaned: String = code.chars().filter(|c| !c.is_whitespace()).collect();
760            (5..=7).contains(&cleaned.len())
761        }
762        "DE" | "FR" | "IT" | "ES" => code.len() == 5 && code.chars().all(|c| c.is_ascii_digit()),
763        "JP" => {
764            let cleaned: String = code.chars().filter(|c| c.is_ascii_digit()).collect();
765            cleaned.len() == 7
766        }
767        "AU" | "BE" | "DK" | "NO" | "CH" | "AT" => {
768            code.len() == 4 && code.chars().all(|c| c.is_ascii_digit())
769        }
770        "NL" => {
771            let cleaned: String = code.chars().filter(|c| !c.is_whitespace()).collect();
772            cleaned.len() == 6
773                && cleaned[..4].chars().all(|c| c.is_ascii_digit())
774                && cleaned[4..].chars().all(|c| c.is_ascii_alphabetic())
775        }
776        "BR" => {
777            let cleaned: String = code.chars().filter(|c| c.is_ascii_digit()).collect();
778            cleaned.len() == 8
779        }
780        _ => {
781            let cleaned: String = code.chars().filter(|c| !c.is_whitespace()).collect();
782            (3..=10).contains(&cleaned.len()) && cleaned.chars().all(|c| c.is_ascii_alphanumeric())
783        }
784    };
785    b(ok)
786}
787
788pub fn is_ssn_us(args: &[StrykeValue]) -> StrykeValue {
789    let s = arg_str(args).trim().to_string();
790    if s.len() != 11 && s.len() != 9 {
791        return b(false);
792    }
793    let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
794    if digits.len() != 9 {
795        return b(false);
796    }
797    // Disallow obvious invalids: area 000 / 666 / 9xx, group 00, serial 0000.
798    let area = &digits[0..3];
799    let group = &digits[3..5];
800    let serial = &digits[5..9];
801    if area == "000" || area == "666" || area.starts_with('9') {
802        return b(false);
803    }
804    if group == "00" || serial == "0000" {
805        return b(false);
806    }
807    if s.len() == 11 {
808        // Must be NNN-NN-NNNN
809        let bytes = s.as_bytes();
810        if bytes[3] != b'-' || bytes[6] != b'-' {
811            return b(false);
812        }
813    }
814    b(true)
815}
816
817// ══════════════════════════════════════════════════════════════════════
818// SemVer
819// ══════════════════════════════════════════════════════════════════════
820
821/// Parse a SemVer string into (major, minor, patch, prerelease, build).
822fn parse_semver(s: &str) -> Option<(u64, u64, u64, String, String)> {
823    let s = s.trim();
824    let (core, build) = match s.split_once('+') {
825        Some((c, b)) => (c, b.to_string()),
826        None => (s, String::new()),
827    };
828    let (core, pre) = match core.split_once('-') {
829        Some((c, p)) => (c, p.to_string()),
830        None => (core, String::new()),
831    };
832    let parts: Vec<&str> = core.split('.').collect();
833    if parts.len() != 3 {
834        return None;
835    }
836    let major = parts[0].parse::<u64>().ok()?;
837    let minor = parts[1].parse::<u64>().ok()?;
838    let patch = parts[2].parse::<u64>().ok()?;
839    Some((major, minor, patch, pre, build))
840}
841
842pub fn semver_compare(args: &[StrykeValue]) -> StrykeValue {
843    let a = args.first().map(|v| v.to_string()).unwrap_or_default();
844    let bs = args.get(1).map(|v| v.to_string()).unwrap_or_default();
845    let Some((a_maj, a_min, a_pat, a_pre, _)) = parse_semver(&a) else {
846        return StrykeValue::UNDEF;
847    };
848    let Some((b_maj, b_min, b_pat, b_pre, _)) = parse_semver(&bs) else {
849        return StrykeValue::UNDEF;
850    };
851    use std::cmp::Ordering;
852    let ord = (a_maj, a_min, a_pat).cmp(&(b_maj, b_min, b_pat));
853    let ord = if ord != Ordering::Equal {
854        ord
855    } else {
856        // Pre-release ordering: no-pre > has-pre; otherwise lexicographic
857        match (a_pre.is_empty(), b_pre.is_empty()) {
858            (true, true) => Ordering::Equal,
859            (true, false) => Ordering::Greater,
860            (false, true) => Ordering::Less,
861            (false, false) => a_pre.cmp(&b_pre),
862        }
863    };
864    StrykeValue::integer(match ord {
865        Ordering::Less => -1,
866        Ordering::Equal => 0,
867        Ordering::Greater => 1,
868    })
869}
870
871/// `semver_satisfies(VERSION, RANGE)` — simple comparator support:
872/// `=`, `<`, `<=`, `>`, `>=`, `!=`. No tilde/caret/star ranges yet —
873/// those are TODO for a later batch.
874pub fn semver_satisfies(args: &[StrykeValue]) -> StrykeValue {
875    let v = args.first().map(|v| v.to_string()).unwrap_or_default();
876    let range = args.get(1).map(|v| v.to_string()).unwrap_or_default();
877    let range = range.trim();
878    let (op, rhs) = if let Some(r) = range.strip_prefix(">=") {
879        (">=", r.trim())
880    } else if let Some(r) = range.strip_prefix("<=") {
881        ("<=", r.trim())
882    } else if let Some(r) = range.strip_prefix("!=") {
883        ("!=", r.trim())
884    } else if let Some(r) = range.strip_prefix('>') {
885        (">", r.trim())
886    } else if let Some(r) = range.strip_prefix('<') {
887        ("<", r.trim())
888    } else if let Some(r) = range.strip_prefix('=') {
889        ("=", r.trim())
890    } else {
891        ("=", range)
892    };
893    let cmp = semver_compare(&[StrykeValue::string(v), StrykeValue::string(rhs.to_string())]);
894    if cmp.is_undef() {
895        return StrykeValue::UNDEF;
896    }
897    let c = cmp.to_int();
898    let ok = match op {
899        "=" => c == 0,
900        "!=" => c != 0,
901        ">" => c > 0,
902        ">=" => c >= 0,
903        "<" => c < 0,
904        "<=" => c <= 0,
905        _ => false,
906    };
907    b(ok)
908}
909
910pub fn semver_increment_major(args: &[StrykeValue]) -> StrykeValue {
911    let Some((maj, _, _, _, _)) = parse_semver(&arg_str(args)) else {
912        return StrykeValue::UNDEF;
913    };
914    StrykeValue::string(format!("{}.0.0", maj + 1))
915}
916
917pub fn semver_increment_minor(args: &[StrykeValue]) -> StrykeValue {
918    let Some((maj, min, _, _, _)) = parse_semver(&arg_str(args)) else {
919        return StrykeValue::UNDEF;
920    };
921    StrykeValue::string(format!("{}.{}.0", maj, min + 1))
922}
923
924pub fn semver_increment_patch(args: &[StrykeValue]) -> StrykeValue {
925    let Some((maj, min, pat, _, _)) = parse_semver(&arg_str(args)) else {
926        return StrykeValue::UNDEF;
927    };
928    StrykeValue::string(format!("{}.{}.{}", maj, min, pat + 1))
929}
930
931#[cfg(test)]
932mod tests {
933    use super::*;
934
935    fn s(s: &str) -> StrykeValue {
936        StrykeValue::string(s.to_string())
937    }
938
939    #[test]
940    fn character_class_predicates() {
941        assert_eq!(is_alpha_only(&[s("hello")]).to_int(), 1);
942        assert_eq!(is_alpha_only(&[s("hi42")]).to_int(), 0);
943        assert_eq!(is_alphanumeric_only(&[s("abc123")]).to_int(), 1);
944        assert_eq!(is_alphanumeric_only(&[s("abc-123")]).to_int(), 0);
945        assert_eq!(is_numeric_only(&[s("12345")]).to_int(), 1);
946        assert_eq!(is_lowercase(&[s("hello")]).to_int(), 1);
947        assert_eq!(is_lowercase(&[s("HELLO")]).to_int(), 0);
948        assert_eq!(is_uppercase(&[s("HELLO")]).to_int(), 1);
949        assert_eq!(is_titlecase(&[s("Hello World")]).to_int(), 1);
950        assert_eq!(is_titlecase(&[s("hello world")]).to_int(), 0);
951    }
952
953    #[test]
954    fn palindrome_check() {
955        assert_eq!(is_palindrome_str(&[s("racecar")]).to_int(), 1);
956        assert_eq!(
957            is_palindrome_str(&[s("A man a plan a canal Panama")]).to_int(),
958            1
959        );
960        assert_eq!(is_palindrome_str(&[s("hello")]).to_int(), 0);
961    }
962
963    #[test]
964    fn hex_octal_binary() {
965        assert_eq!(is_hex(&[s("0xDEADBEEF")]).to_int(), 1);
966        assert_eq!(is_hex(&[s("xyz")]).to_int(), 0);
967        assert_eq!(is_octal(&[s("0o755")]).to_int(), 1);
968        assert_eq!(is_octal(&[s("999")]).to_int(), 0);
969        assert_eq!(is_binary(&[s("0b101010")]).to_int(), 1);
970        assert_eq!(is_binary(&[s("0b102")]).to_int(), 0);
971    }
972
973    #[test]
974    fn hash_lengths() {
975        assert_eq!(is_md5_hash(&[s(&"a".repeat(32))]).to_int(), 1);
976        assert_eq!(is_md5_hash(&[s(&"a".repeat(31))]).to_int(), 0);
977        assert_eq!(is_sha1_hash(&[s(&"f".repeat(40))]).to_int(), 1);
978        assert_eq!(is_sha256_hash(&[s(&"0".repeat(64))]).to_int(), 1);
979    }
980
981    #[test]
982    fn uuid_versions() {
983        // v4 has version digit 4 at position 14
984        let uuid_v4 = "550e8400-e29b-41d4-a716-446655440000";
985        assert_eq!(is_uuid_v4(&[s(uuid_v4)]).to_int(), 1);
986        let uuid_v7 = "017f22e2-79b0-7cc3-98c4-dc0c0c07398f";
987        assert_eq!(is_uuid_v7(&[s(uuid_v7)]).to_int(), 1);
988        assert_eq!(is_uuid_v4(&[s(uuid_v7)]).to_int(), 0);
989    }
990
991    #[test]
992    fn jwt_basic() {
993        // header.payload.signature — 3 segments
994        let jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0In0.SflKxwRJSMeKKF2QT4f";
995        assert_eq!(is_jwt(&[s(jwt)]).to_int(), 1);
996        assert_eq!(is_jwt(&[s("not.a.jwt.too.many.dots")]).to_int(), 0);
997        assert_eq!(is_jwt(&[s("only.two")]).to_int(), 0);
998    }
999
1000    #[test]
1001    fn email_strict_dot_atom() {
1002        assert_eq!(is_email_strict(&[s("a@b.co")]).to_int(), 1);
1003        assert_eq!(is_email_strict(&[s("alice+tag@example.com")]).to_int(), 1);
1004        assert_eq!(is_email_strict(&[s("no-at-symbol")]).to_int(), 0);
1005        assert_eq!(is_email_strict(&[s("..@bad.com")]).to_int(), 0);
1006        assert_eq!(is_email_strict(&[s("foo@bar")]).to_int(), 0); // no dot in domain
1007    }
1008
1009    #[test]
1010    fn imei_luhn() {
1011        // 490154203237518 — valid IMEI
1012        assert_eq!(is_imei(&[s("490154203237518")]).to_int(), 1);
1013        assert_eq!(is_imei(&[s("490154203237519")]).to_int(), 0);
1014    }
1015
1016    #[test]
1017    fn vin_valid() {
1018        // Known valid VIN with X check digit
1019        assert_eq!(is_vin(&[s("1M8GDM9AXKP042788")]).to_int(), 1);
1020    }
1021
1022    #[test]
1023    fn isbn_round_trips() {
1024        // 0306406152 → 9780306406157
1025        assert_eq!(
1026            isbn10_to_isbn13(&[s("0306406152")]).to_string(),
1027            "9780306406157"
1028        );
1029        assert_eq!(
1030            isbn13_to_isbn10(&[s("9780306406157")]).to_string(),
1031            "0306406152"
1032        );
1033    }
1034
1035    #[test]
1036    fn ean13_upc() {
1037        assert_eq!(is_ean13(&[s("4006381333931")]).to_int(), 1);
1038        assert_eq!(is_upc(&[s("036000291452")]).to_int(), 1);
1039    }
1040
1041    #[test]
1042    fn zip_us_variants() {
1043        assert_eq!(is_zip_us(&[s("12345")]).to_int(), 1);
1044        assert_eq!(is_zip_us(&[s("1234")]).to_int(), 0);
1045        assert_eq!(is_zip_plus4(&[s("12345-6789")]).to_int(), 1);
1046        assert_eq!(is_zip_plus4(&[s("123456789")]).to_int(), 0);
1047    }
1048
1049    #[test]
1050    fn semver_ops() {
1051        assert_eq!(semver_compare(&[s("1.2.3"), s("1.2.4")]).to_int(), -1);
1052        assert_eq!(semver_compare(&[s("2.0.0"), s("1.999.999")]).to_int(), 1);
1053        assert_eq!(semver_compare(&[s("1.0.0-alpha"), s("1.0.0")]).to_int(), -1);
1054        assert_eq!(semver_satisfies(&[s("1.2.3"), s(">=1.0.0")]).to_int(), 1);
1055        assert_eq!(semver_satisfies(&[s("1.2.3"), s("<2.0.0")]).to_int(), 1);
1056        assert_eq!(semver_increment_major(&[s("1.2.3")]).to_string(), "2.0.0");
1057        assert_eq!(semver_increment_minor(&[s("1.2.3")]).to_string(), "1.3.0");
1058        assert_eq!(semver_increment_patch(&[s("1.2.3")]).to_string(), "1.2.4");
1059    }
1060
1061    #[test]
1062    fn phone_e164() {
1063        assert_eq!(is_phone_e164(&[s("+12025551234")]).to_int(), 1);
1064        assert_eq!(is_phone_e164(&[s("+44 20 7946 0958")]).to_int(), 0); // spaces invalid
1065        assert_eq!(is_phone_e164(&[s("12025551234")]).to_int(), 0); // missing +
1066    }
1067
1068    #[test]
1069    fn bic_swift() {
1070        assert_eq!(is_bic(&[s("DEUTDEFF")]).to_int(), 1);
1071        assert_eq!(is_bic(&[s("DEUTDEFF500")]).to_int(), 1);
1072        assert_eq!(is_bic(&[s("DEUTDEFF50")]).to_int(), 0);
1073    }
1074}