swift_mt_message/
utils.rs

1//! Shared utilities for SWIFT MT field validation and parsing
2//!
3//! This module contains common validation logic, BIC validation, account parsing,
4//! and other utilities shared across multiple field implementations.
5
6use crate::config::{ConfigLoader, FieldValidationRule, ValidationPattern};
7use crate::errors::{FieldParseError, Result};
8use std::sync::OnceLock;
9
10/// BIC (Bank Identifier Code) validation utility
11pub mod bic {
12    use super::*;
13
14    /// Validate a BIC code according to SWIFT standards
15    pub fn validate_bic(bic: &str) -> Result<()> {
16        if bic.is_empty() {
17            return Err(FieldParseError::invalid_format("BIC", "BIC cannot be empty").into());
18        }
19
20        // BIC must be 8 or 11 characters
21        if bic.len() != 8 && bic.len() != 11 {
22            return Err(FieldParseError::invalid_format(
23                "BIC",
24                "BIC must be 8 or 11 characters long",
25            )
26            .into());
27        }
28
29        // First 4 characters: Institution Code (letters only)
30        let institution_code = &bic[0..4];
31        if !institution_code
32            .chars()
33            .all(|c| c.is_alphabetic() && c.is_ascii())
34        {
35            return Err(FieldParseError::invalid_format(
36                "BIC",
37                "Institution code (first 4 characters) must be alphabetic",
38            )
39            .into());
40        }
41
42        // Next 2 characters: Country Code (letters only)
43        let country_code = &bic[4..6];
44        if !country_code
45            .chars()
46            .all(|c| c.is_alphabetic() && c.is_ascii())
47        {
48            return Err(FieldParseError::invalid_format(
49                "BIC",
50                "Country code (characters 5-6) must be alphabetic",
51            )
52            .into());
53        }
54
55        // Validate country code exists
56        if !is_valid_country_code(country_code) {
57            return Err(FieldParseError::invalid_format(
58                "BIC",
59                &format!("Invalid country code: {}", country_code),
60            )
61            .into());
62        }
63
64        // Next 2 characters: Location Code (alphanumeric)
65        let location_code = &bic[6..8];
66        if !location_code
67            .chars()
68            .all(|c| c.is_alphanumeric() && c.is_ascii())
69        {
70            return Err(FieldParseError::invalid_format(
71                "BIC",
72                "Location code (characters 7-8) must be alphanumeric",
73            )
74            .into());
75        }
76
77        // If 11 characters, last 3 are branch code (alphanumeric)
78        if bic.len() == 11 {
79            let branch_code = &bic[8..11];
80            if !branch_code
81                .chars()
82                .all(|c| c.is_alphanumeric() && c.is_ascii())
83            {
84                return Err(FieldParseError::invalid_format(
85                    "BIC",
86                    "Branch code (characters 9-11) must be alphanumeric",
87                )
88                .into());
89            }
90        }
91
92        Ok(())
93    }
94
95    /// Check if a country code is valid (simplified list for common codes)
96    fn is_valid_country_code(code: &str) -> bool {
97        // This is a simplified list. In production, you'd want a comprehensive list
98        // of ISO 3166-1 alpha-2 country codes
99        const VALID_CODES: &[&str] = &[
100            "AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AQ", "AR", "AS", "AT", "AU", "AW",
101            "AX", "AZ", "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BL", "BM", "BN",
102            "BO", "BQ", "BR", "BS", "BT", "BV", "BW", "BY", "BZ", "CA", "CC", "CD", "CF", "CG",
103            "CH", "CI", "CK", "CL", "CM", "CN", "CO", "CR", "CU", "CV", "CW", "CX", "CY", "CZ",
104            "DE", "DJ", "DK", "DM", "DO", "DZ", "EC", "EE", "EG", "EH", "ER", "ES", "ET", "FI",
105            "FJ", "FK", "FM", "FO", "FR", "GA", "GB", "GD", "GE", "GF", "GG", "GH", "GI", "GL",
106            "GM", "GN", "GP", "GQ", "GR", "GS", "GT", "GU", "GW", "GY", "HK", "HM", "HN", "HR",
107            "HT", "HU", "ID", "IE", "IL", "IM", "IN", "IO", "IQ", "IR", "IS", "IT", "JE", "JM",
108            "JO", "JP", "KE", "KG", "KH", "KI", "KM", "KN", "KP", "KR", "KW", "KY", "KZ", "LA",
109            "LB", "LC", "LI", "LK", "LR", "LS", "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME",
110            "MF", "MG", "MH", "MK", "ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", "MT", "MU",
111            "MV", "MW", "MX", "MY", "MZ", "NA", "NC", "NE", "NF", "NG", "NI", "NL", "NO", "NP",
112            "NR", "NU", "NZ", "OM", "PA", "PE", "PF", "PG", "PH", "PK", "PL", "PM", "PN", "PR",
113            "PS", "PT", "PW", "PY", "QA", "RE", "RO", "RS", "RU", "RW", "SA", "SB", "SC", "SD",
114            "SE", "SG", "SH", "SI", "SJ", "SK", "SL", "SM", "SN", "SO", "SR", "SS", "ST", "SV",
115            "SX", "SY", "SZ", "TC", "TD", "TF", "TG", "TH", "TJ", "TK", "TL", "TM", "TN", "TO",
116            "TR", "TT", "TV", "TW", "TZ", "UA", "UG", "UM", "US", "UY", "UZ", "VA", "VC", "VE",
117            "VG", "VI", "VN", "VU", "WF", "WS", "YE", "YT", "ZA", "ZM", "ZW",
118        ];
119        VALID_CODES.contains(&code)
120    }
121}
122
123/// Account validation utilities
124pub mod account {
125    use super::*;
126
127    /// Validate account line indicator (single character)
128    pub fn validate_account_line_indicator(indicator: &str) -> Result<()> {
129        if indicator.len() != 1 {
130            return Err(FieldParseError::invalid_format(
131                "Account Line Indicator",
132                "Must be exactly 1 character",
133            )
134            .into());
135        }
136
137        let ch = indicator.chars().next().unwrap();
138        if !ch.is_alphanumeric() || !ch.is_ascii() {
139            return Err(FieldParseError::invalid_format(
140                "Account Line Indicator",
141                "Must be alphanumeric ASCII character",
142            )
143            .into());
144        }
145
146        Ok(())
147    }
148
149    /// Validate account number (up to 34 characters, ASCII printable)
150    pub fn validate_account_number(account: &str) -> Result<()> {
151        if account.is_empty() {
152            return Err(
153                FieldParseError::missing_data("Account", "Account number cannot be empty").into(),
154            );
155        }
156
157        if account.len() > 34 {
158            return Err(FieldParseError::invalid_format(
159                "Account",
160                "Account number cannot exceed 34 characters",
161            )
162            .into());
163        }
164
165        if !account.chars().all(|c| c.is_ascii() && !c.is_control()) {
166            return Err(FieldParseError::invalid_format(
167                "Account",
168                "Account number contains invalid characters",
169            )
170            .into());
171        }
172
173        Ok(())
174    }
175
176    /// Parse account line format: [/indicator][/account]
177    /// Returns (account_line_indicator, account_number, remaining_content)
178    pub fn parse_account_line_and_content(
179        content: &str,
180    ) -> Result<(Option<String>, Option<String>, String)> {
181        if content.is_empty() {
182            return Ok((None, None, content.to_string()));
183        }
184
185        let lines: Vec<&str> = content.lines().collect();
186        if lines.is_empty() {
187            return Ok((None, None, content.to_string()));
188        }
189
190        let first_line = lines[0];
191        let remaining_content = if lines.len() > 1 {
192            lines[1..].join("\n")
193        } else {
194            String::new()
195        };
196
197        // Check if first line starts with account information
198        if !first_line.starts_with('/') {
199            return Ok((None, None, content.to_string()));
200        }
201
202        // Parse account information
203        let account_part = &first_line[1..]; // Remove leading '/'
204        let parts: Vec<&str> = account_part.split('/').collect();
205
206        match parts.len() {
207            1 => {
208                // Just account indicator or account number
209                if parts[0].len() == 1 {
210                    // Single character = account line indicator
211                    Ok((Some(parts[0].to_string()), None, remaining_content))
212                } else {
213                    // Multiple characters = account number
214                    Ok((None, Some(parts[0].to_string()), remaining_content))
215                }
216            }
217            2 => {
218                // Both account line indicator and account number
219                Ok((
220                    Some(parts[0].to_string()),
221                    Some(parts[1].to_string()),
222                    remaining_content,
223                ))
224            }
225            _ => Err(FieldParseError::invalid_format(
226                "Account Line",
227                "Invalid account line format",
228            )
229            .into()),
230        }
231    }
232}
233
234/// Multi-line field validation utilities
235pub mod multiline {
236    use super::*;
237
238    /// Validate multi-line field content
239    pub fn validate_multiline_field(
240        field_tag: &str,
241        lines: &[String],
242        max_lines: usize,
243        max_chars_per_line: usize,
244    ) -> Result<()> {
245        if lines.is_empty() {
246            return Err(FieldParseError::missing_data(field_tag, "Content cannot be empty").into());
247        }
248
249        if lines.len() > max_lines {
250            return Err(FieldParseError::invalid_format(
251                field_tag,
252                &format!("Too many lines (max {})", max_lines),
253            )
254            .into());
255        }
256
257        for (i, line) in lines.iter().enumerate() {
258            if line.len() > max_chars_per_line {
259                return Err(FieldParseError::invalid_format(
260                    field_tag,
261                    &format!("Line {} exceeds {} characters", i + 1, max_chars_per_line),
262                )
263                .into());
264            }
265
266            if line.trim().is_empty() {
267                return Err(FieldParseError::invalid_format(
268                    field_tag,
269                    &format!("Line {} cannot be empty", i + 1),
270                )
271                .into());
272            }
273
274            if !line.chars().all(|c| c.is_ascii() && !c.is_control()) {
275                return Err(FieldParseError::invalid_format(
276                    field_tag,
277                    &format!("Line {} contains invalid characters", i + 1),
278                )
279                .into());
280            }
281        }
282
283        Ok(())
284    }
285
286    /// Parse content into lines, filtering empty lines
287    pub fn parse_lines(content: &str) -> Vec<String> {
288        content
289            .lines()
290            .map(|line| line.trim().to_string())
291            .filter(|line| !line.is_empty())
292            .collect()
293    }
294}
295
296/// Character validation utilities
297pub mod character {
298    use super::*;
299
300    /// Validate that content contains only ASCII printable characters
301    pub fn validate_ascii_printable(
302        field_tag: &str,
303        content: &str,
304        description: &str,
305    ) -> Result<()> {
306        if !content.chars().all(|c| c.is_ascii() && !c.is_control()) {
307            return Err(FieldParseError::invalid_format(
308                field_tag,
309                &format!("{} contains invalid characters", description),
310            )
311            .into());
312        }
313        Ok(())
314    }
315
316    /// Validate that content contains only alphanumeric characters
317    pub fn validate_alphanumeric(field_tag: &str, content: &str, description: &str) -> Result<()> {
318        if !content.chars().all(|c| c.is_alphanumeric() && c.is_ascii()) {
319            return Err(FieldParseError::invalid_format(
320                field_tag,
321                &format!("{} must contain only alphanumeric characters", description),
322            )
323            .into());
324        }
325        Ok(())
326    }
327
328    /// Validate that content contains only alphabetic characters
329    pub fn validate_alphabetic(field_tag: &str, content: &str, description: &str) -> Result<()> {
330        if !content.chars().all(|c| c.is_alphabetic() && c.is_ascii()) {
331            return Err(FieldParseError::invalid_format(
332                field_tag,
333                &format!("{} must contain only alphabetic characters", description),
334            )
335            .into());
336        }
337        Ok(())
338    }
339
340    /// Validate exact length
341    pub fn validate_exact_length(
342        field_tag: &str,
343        content: &str,
344        expected_length: usize,
345        _description: &str,
346    ) -> Result<()> {
347        if content.len() != expected_length {
348            return Err(FieldParseError::InvalidLength {
349                field: field_tag.to_string(),
350                max_length: expected_length,
351                actual_length: content.len(),
352            }
353            .into());
354        }
355        Ok(())
356    }
357
358    /// Validate maximum length
359    pub fn validate_max_length(
360        field_tag: &str,
361        content: &str,
362        max_length: usize,
363        description: &str,
364    ) -> Result<()> {
365        if content.len() > max_length {
366            return Err(FieldParseError::invalid_format(
367                field_tag,
368                &format!("{} exceeds {} characters", description, max_length),
369            )
370            .into());
371        }
372        Ok(())
373    }
374}
375
376/// Global configuration instance
377static CONFIG_LOADER: OnceLock<ConfigLoader> = OnceLock::new();
378
379/// Get or initialize the global configuration
380pub fn get_config() -> &'static ConfigLoader {
381    CONFIG_LOADER.get_or_init(|| {
382        // Try to load from config directory first, fall back to defaults
383        match ConfigLoader::load_from_directory("config") {
384            Ok(loader) => loader,
385            Err(_) => {
386                // If config directory doesn't exist or fails to load, use defaults
387                ConfigLoader::load_defaults()
388            }
389        }
390    })
391}
392
393/// Configuration-based validation utilities
394pub mod validation {
395    use super::*;
396
397    /// Validate field using configuration rules
398    pub fn validate_field_with_config(field_tag: &str, content: &str) -> Result<()> {
399        let config = get_config();
400
401        // Try to get base field tag for field options (e.g., "50A" -> "50")
402        let base_tag = if field_tag.len() > 2 {
403            let chars: Vec<char> = field_tag.chars().collect();
404            if chars.len() == 3
405                && chars[0].is_ascii_digit()
406                && chars[1].is_ascii_digit()
407                && chars[2].is_alphabetic()
408            {
409                &field_tag[..2]
410            } else {
411                field_tag
412            }
413        } else {
414            field_tag
415        };
416
417        // First try exact field tag, then base tag
418        let rule = config.get_field_validation(field_tag).or_else(|| {
419            if base_tag != field_tag {
420                config.get_field_validation(base_tag)
421            } else {
422                None
423            }
424        });
425
426        if let Some(rule) = rule {
427            validate_with_rule(field_tag, content, rule, config)?;
428        }
429
430        Ok(())
431    }
432
433    /// Validate content against a specific validation rule
434    pub fn validate_with_rule(
435        field_tag: &str,
436        content: &str,
437        rule: &FieldValidationRule,
438        config: &ConfigLoader,
439    ) -> Result<()> {
440        // Check empty content
441        if content.is_empty() && rule.allow_empty == Some(false) {
442            return Err(FieldParseError::missing_data(field_tag, "Field cannot be empty").into());
443        }
444
445        // Length validations
446        if let Some(max_length) = rule.max_length {
447            if content.len() > max_length {
448                return Err(
449                    FieldParseError::invalid_length(field_tag, max_length, content.len()).into(),
450                );
451            }
452        }
453
454        if let Some(exact_length) = rule.exact_length {
455            if content.len() != exact_length {
456                return Err(FieldParseError::invalid_format(
457                    field_tag,
458                    &format!("Must be exactly {} characters", exact_length),
459                )
460                .into());
461            }
462        }
463
464        if let Some(min_length) = rule.min_length {
465            if content.len() < min_length {
466                return Err(FieldParseError::invalid_format(
467                    field_tag,
468                    &format!("Must be at least {} characters", min_length),
469                )
470                .into());
471            }
472        }
473
474        // Pattern-based validation
475        if let Some(pattern_ref) = &rule.pattern_ref {
476            if let Some(pattern) = config.get_validation_pattern(pattern_ref) {
477                validate_with_pattern(field_tag, content, pattern)?;
478            }
479        }
480
481        // Valid values check
482        if let Some(valid_values) = &rule.valid_values {
483            let normalized_content = match rule.case_normalization.as_deref() {
484                Some("upper") => content.to_uppercase(),
485                Some("lower") => content.to_lowercase(),
486                _ => content.to_string(),
487            };
488
489            if !valid_values.contains(&normalized_content) {
490                return Err(FieldParseError::invalid_format(
491                    field_tag,
492                    &format!("Must be one of: {:?}", valid_values),
493                )
494                .into());
495            }
496        }
497
498        // Multi-line validation
499        if rule.max_lines.is_some() || rule.max_chars_per_line.is_some() {
500            let lines: Vec<&str> = content.lines().collect();
501
502            if let Some(max_lines) = rule.max_lines {
503                if lines.len() > max_lines {
504                    return Err(FieldParseError::invalid_format(
505                        field_tag,
506                        &format!("Too many lines: {} (max {})", lines.len(), max_lines),
507                    )
508                    .into());
509                }
510            }
511
512            if let Some(max_chars) = rule.max_chars_per_line {
513                for (i, line) in lines.iter().enumerate() {
514                    if line.len() > max_chars {
515                        return Err(FieldParseError::invalid_format(
516                            field_tag,
517                            &format!(
518                                "Line {} too long: {} chars (max {})",
519                                i + 1,
520                                line.len(),
521                                max_chars
522                            ),
523                        )
524                        .into());
525                    }
526                }
527            }
528        }
529
530        Ok(())
531    }
532
533    /// Validate content against a validation pattern
534    pub fn validate_with_pattern(
535        field_tag: &str,
536        content: &str,
537        pattern: &ValidationPattern,
538    ) -> Result<()> {
539        // Regex validation
540        if let Some(regex_str) = &pattern.regex {
541            match regex::Regex::new(regex_str) {
542                Ok(regex) => {
543                    if !regex.is_match(content) {
544                        return Err(FieldParseError::invalid_format(
545                            field_tag,
546                            &format!("Does not match pattern: {}", pattern.description),
547                        )
548                        .into());
549                    }
550                }
551                Err(e) => {
552                    return Err(FieldParseError::invalid_format(
553                        field_tag,
554                        &format!("Invalid regex pattern: {}", e),
555                    )
556                    .into());
557                }
558            }
559        }
560
561        // Character set validation
562        if let Some(charset) = &pattern.charset {
563            if charset.ascii_printable == Some(true) {
564                super::character::validate_ascii_printable(
565                    field_tag,
566                    content,
567                    &pattern.description,
568                )?;
569            }
570            if charset.alphanumeric == Some(true) {
571                super::character::validate_alphanumeric(field_tag, content, &pattern.description)?;
572            }
573            if charset.alphabetic == Some(true) {
574                super::character::validate_alphabetic(field_tag, content, &pattern.description)?;
575            }
576            if charset.numeric == Some(true) && !content.chars().all(|c| c.is_ascii_digit()) {
577                return Err(FieldParseError::invalid_format(
578                    field_tag,
579                    "Must contain only numeric characters",
580                )
581                .into());
582            }
583        }
584
585        Ok(())
586    }
587}
588
589/// Check if a field is mandatory for a specific message type using configuration
590pub fn is_field_mandatory(field_tag: &str, message_type: &str) -> bool {
591    get_config().is_field_mandatory(field_tag, message_type)
592}
593
594/// Get all mandatory fields for a message type using configuration
595pub fn get_mandatory_fields(message_type: &str) -> Vec<String> {
596    get_config().get_mandatory_fields(message_type)
597}
598
599#[cfg(test)]
600mod tests {
601    use super::*;
602
603    mod bic_tests {
604        use super::*;
605
606        #[test]
607        fn test_valid_bic_8_chars() {
608            assert!(bic::validate_bic("BANKDEFF").is_ok());
609        }
610
611        #[test]
612        fn test_valid_bic_11_chars() {
613            assert!(bic::validate_bic("BANKDEFFXXX").is_ok());
614        }
615
616        #[test]
617        fn test_invalid_bic_length() {
618            assert!(bic::validate_bic("BANK").is_err());
619            assert!(bic::validate_bic("BANKDEFFTOOLONG").is_err());
620        }
621
622        #[test]
623        fn test_invalid_bic_characters() {
624            assert!(bic::validate_bic("BAN1DEFF").is_err()); // Number in institution code
625            assert!(bic::validate_bic("BANKD3FF").is_err()); // Number in country code
626        }
627    }
628
629    mod account_tests {
630        use super::*;
631
632        #[test]
633        fn test_valid_account_line_indicator() {
634            assert!(account::validate_account_line_indicator("A").is_ok());
635            assert!(account::validate_account_line_indicator("1").is_ok());
636        }
637
638        #[test]
639        fn test_invalid_account_line_indicator() {
640            assert!(account::validate_account_line_indicator("").is_err());
641            assert!(account::validate_account_line_indicator("AB").is_err());
642            assert!(account::validate_account_line_indicator("@").is_err());
643        }
644
645        #[test]
646        fn test_parse_account_line() {
647            let (indicator, account, content) =
648                account::parse_account_line_and_content("/A/12345\nBANKDEFF").unwrap();
649            assert_eq!(indicator, Some("A".to_string()));
650            assert_eq!(account, Some("12345".to_string()));
651            assert_eq!(content, "BANKDEFF");
652        }
653    }
654
655    mod config_tests {
656        use super::*;
657
658        #[test]
659        fn test_config_based_mandatory_fields() {
660            assert!(is_field_mandatory("20", "103"));
661            assert!(is_field_mandatory("50A", "103")); // Option handling
662            assert!(is_field_mandatory("50K", "103")); // Option handling
663            assert!(!is_field_mandatory("72", "103")); // Optional field
664            assert!(!is_field_mandatory("20", "999")); // Unknown message type
665        }
666
667        #[test]
668        fn test_config_based_validation() {
669            // Test valid field validation
670            assert!(validation::validate_field_with_config("20", "TESTREF123").is_ok());
671
672            // Test field too long
673            assert!(
674                validation::validate_field_with_config("20", "TESTREF123456789012345").is_err()
675            );
676
677            // Test field with exact length requirement
678            assert!(validation::validate_field_with_config("23B", "CRED").is_ok());
679            assert!(validation::validate_field_with_config("23B", "CRE").is_err()); // Too short
680        }
681    }
682}