edifact-randomize 0.11.0

Deterministic field randomization for German energy market EDIFACT data
Documentation
pub mod generators;
pub mod seed_data;

use thiserror::Error;

#[derive(Debug, Error)]
pub enum RandomizeError {
    #[error("no randomization function available for field '{bo4e_name}'")]
    NoGeneratorForField { bo4e_name: String },
}

/// Derive a deterministic per-field sub-seed from a base seed and a field name.
///
/// Uses FNV-1a-style hashing so different field names get different seeds.
pub fn field_seed(base_seed: u64, bo4e_name: &str) -> u64 {
    let mut h = base_seed;
    for b in bo4e_name.bytes() {
        h = h.wrapping_mul(1_099_511_628_211).wrapping_add(b as u64);
    }
    h
}

/// Randomize a single field. Returns the generated string value.
///
/// Dispatch order:
/// 1. Exact bo4e_name match → domain-specific generator
/// 2. valid_codes non-empty → uniform random pick from the list
/// 3. format == "date" → YYYY-MM-DD (HTML5 date input)
/// 4. bo4e_name ends with date suffixes (datum/Datum/Ab/Bis/Von) or starts with "gueltig" → YYYY-MM-DD
/// 5. bo4e_name ends with "Referenz" or "referenz" → reference ID
/// 6. Otherwise → Err(RandomizeError::NoGeneratorForField)
pub fn randomize_field(
    bo4e_name: &str,
    valid_codes: &[String],
    format: Option<&str>,
    seed: u64,
) -> Result<String, RandomizeError> {
    use generators::*;
    use seed_data::*;

    match bo4e_name {
        // Location IDs
        "marktlokationsId" => Ok(generate_malo_id(seed)),
        "messlokationsId" => Ok(generate_melo_id(seed)),
        "netzlokationsId" => Ok(generate_nelo_id(seed)),
        "steuerbareRessourceId" => Ok(generate_steuress_id(seed)),
        "technischeRessourceId" => Ok(generate_techress_id(seed)),
        "tranchenId" => Ok(generate_malo_id(seed.wrapping_add(7))),

        // GLN identifiers
        "identifikation" | "absenderCode" | "empfaengerCode" => Ok(generate_gln(seed)),

        // Interchange-level fields
        "interchangeRef" => Ok(generate_reference_id(seed)),
        "datum" => Ok(generate_datum(seed)),
        "zeit" => Ok(generate_zeit(seed)),

        // Name fields
        "nachname" => Ok(pick(NACHNAMEN, seed).to_string()),
        "vorname" => Ok(pick(VORNAMEN, seed).to_string()),
        "anrede" => Ok(pick(ANREDEN, seed).to_string()),
        "titel" => Ok(pick(TITEL, seed).to_string()),

        // Address fields — coherent from same address tuple
        "strasse" => Ok(pick_address(seed).strasse.to_string()),
        "hausnummer" => Ok(pick_address(seed).hausnummer.to_string()),
        "ort" => Ok(pick_address(seed).ort.to_string()),
        "postleitzahl" => Ok(pick_address(seed).plz.to_string()),
        "region" => Ok(pick_address(seed).bundesland.to_string()),
        "land" => Ok("DE".to_string()),

        // Reference IDs
        "vorgangId" => Ok(generate_reference_id(seed)),

        // Enum: uniform pick from valid_codes (checked before suffix rules so dropdown fields
        // always get a valid code even when their name matches a suffix pattern)
        _ if !valid_codes.is_empty() => {
            let idx = seed as usize % valid_codes.len();
            Ok(valid_codes[idx].clone())
        }

        // Date fields by format hint
        _ if format == Some("date") => Ok(generate_date_ymd(seed)),

        // Date fields by name suffix (when no format hint and no valid_codes)
        _ if bo4e_name.ends_with("datum")
            || bo4e_name.ends_with("Datum")
            || bo4e_name.ends_with("Ab")
            || bo4e_name.ends_with("Bis")
            || bo4e_name.ends_with("Von")
            || bo4e_name.starts_with("gueltig") =>
        {
            Ok(generate_date_ymd(seed))
        }

        // Suffix-based reference rule
        _ if bo4e_name.ends_with("Referenz") || bo4e_name.ends_with("referenz") => {
            Ok(generate_reference_id(seed))
        }

        // No generator available
        _ => Err(RandomizeError::NoGeneratorForField {
            bo4e_name: bo4e_name.to_string(),
        }),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_malo_id_dispatch() {
        let result = randomize_field("marktlokationsId", &[], None, 42).unwrap();
        assert_eq!(result.len(), 11, "MaLo ID must be 11 digits");
        assert!(result.chars().all(|c| c.is_ascii_digit()));
    }

    #[test]
    fn test_melo_id_dispatch() {
        let result = randomize_field("messlokationsId", &[], None, 42).unwrap();
        assert!(result.starts_with("DE"));
        assert_eq!(result.len(), 33);
    }

    #[test]
    fn test_gln_dispatch() {
        let result = randomize_field("absenderCode", &[], None, 42).unwrap();
        assert_eq!(result.len(), 13);
    }

    #[test]
    fn test_datum_dispatch() {
        let result = randomize_field("datum", &[], None, 42).unwrap();
        assert_eq!(result.len(), 6, "datum must be YYMMDD");
        assert!(result.chars().all(|c| c.is_ascii_digit()));
    }

    #[test]
    fn test_zeit_dispatch() {
        let result = randomize_field("zeit", &[], None, 42).unwrap();
        assert_eq!(result.len(), 4, "zeit must be HHMM");
    }

    #[test]
    fn test_enum_pick_dispatch() {
        let codes = vec!["E01".to_string(), "ZW4".to_string(), "E03".to_string()];
        let result = randomize_field("someEnumField", &codes, None, 0).unwrap();
        assert!(codes.contains(&result), "result must be from valid_codes");
    }

    #[test]
    fn test_enum_pick_uniform() {
        let codes = vec!["A".to_string(), "B".to_string(), "C".to_string()];
        let r0 = randomize_field("field", &codes, None, 0).unwrap();
        let r1 = randomize_field("field", &codes, None, 1).unwrap();
        let r2 = randomize_field("field", &codes, None, 2).unwrap();
        assert_eq!(r0, "A"); // 0 % 3 = 0
        assert_eq!(r1, "B"); // 1 % 3 = 1
        assert_eq!(r2, "C"); // 2 % 3 = 2
    }

    #[test]
    fn test_date_format_dispatch() {
        let result = randomize_field("someDate", &[], Some("date"), 42).unwrap();
        assert_eq!(
            result.len(),
            10,
            "date_ymd must be YYYY-MM-DD, got: {result}"
        );
        assert!(result.contains('-'));
    }

    #[test]
    fn test_referenz_suffix_dispatch() {
        let result = randomize_field("nachrichtenReferenz", &[], None, 42).unwrap();
        assert_eq!(result.len(), 12, "reference must be 12 chars");
        assert!(!result.contains('+'));
        assert!(!result.contains(':'));
    }

    #[test]
    fn test_referenz_with_valid_codes_picks_code_not_reference() {
        // A dropdown field whose name ends in "Referenz" must pick from valid_codes, not generate a ref ID
        let codes = vec!["E01".to_string(), "E02".to_string()];
        let result = randomize_field("nachrichtenReferenz", &codes, None, 0).unwrap();
        assert!(
            codes.contains(&result),
            "must pick from valid_codes, got: {result}"
        );
    }

    #[test]
    fn test_datum_suffix_dispatch() {
        let result = randomize_field("lieferdatum", &[], None, 42).unwrap();
        assert_eq!(result.len(), 10, "must be YYYY-MM-DD, got: {result}");
        assert!(result.contains('-'));
    }

    #[test]
    fn test_bis_suffix_dispatch() {
        let result = randomize_field("gueltigkeitsBis", &[], None, 42).unwrap();
        assert_eq!(result.len(), 10, "must be YYYY-MM-DD, got: {result}");
    }

    #[test]
    fn test_gueltig_prefix_dispatch() {
        let result = randomize_field("gueltigAb", &[], None, 42).unwrap();
        assert_eq!(result.len(), 10, "must be YYYY-MM-DD, got: {result}");
    }

    #[test]
    fn test_unknown_field_error() {
        let err = randomize_field("unknownField", &[], None, 42).unwrap_err();
        assert!(err.to_string().contains("unknownField"));
    }

    #[test]
    fn test_field_seed_deterministic() {
        let s1 = field_seed(42, "marktlokationsId");
        let s2 = field_seed(42, "marktlokationsId");
        assert_eq!(s1, s2);
    }

    #[test]
    fn test_field_seed_different_fields() {
        let s1 = field_seed(42, "marktlokationsId");
        let s2 = field_seed(42, "messlokationsId");
        assert_ne!(s1, s2);
    }

    #[test]
    fn test_randomize_field_deterministic() {
        let r1 = randomize_field("marktlokationsId", &[], None, 99).unwrap();
        let r2 = randomize_field("marktlokationsId", &[], None, 99).unwrap();
        assert_eq!(r1, r2);
    }
}