Skip to main content

edifact_randomize/
generators.rs

1//! Deterministic ID generators for German energy market identifiers.
2//!
3//! Each generator takes a `seed: u64` and produces a valid identifier of the
4//! appropriate format. Same seed always produces the same output.
5
6/// Generate a Marktlokation ID: 11 digits, last digit is a Luhn check digit.
7pub fn generate_malo_id(seed: u64) -> String {
8    // Use seed to derive 10 base digits
9    let base = seed % 10_000_000_000;
10    let base_str = format!("{base:010}");
11    let check = luhn_check_digit(&base_str);
12    format!("{base_str}{check}")
13}
14
15/// Generate a Messlokation ID: "DE" + 31 digits = 33 chars total.
16pub fn generate_melo_id(seed: u64) -> String {
17    // Split the seed into parts to fill 31 digits
18    let part1 = seed % 10_000_000_000; // 10 digits
19    let part2 = (seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1)) % 10_000_000_000; // 10 digits
20    let part3 = (seed.wrapping_mul(2_862_933_555_777_941_757).wrapping_add(3)) % 100_000_000_000; // 11 digits
21    format!("DE{part1:010}{part2:010}{part3:011}")
22}
23
24/// Generate a Netzlokation ID: "E" + 10 digits = 11 chars.
25pub fn generate_nelo_id(seed: u64) -> String {
26    let digits = seed % 10_000_000_000;
27    format!("E{digits:010}")
28}
29
30/// Generate a Steuerbare Ressource ID: "C" + 10 digits = 11 chars.
31pub fn generate_steuress_id(seed: u64) -> String {
32    let digits = seed % 10_000_000_000;
33    format!("C{digits:010}")
34}
35
36/// Generate a Technische Ressource ID: "D" + 10 digits = 11 chars.
37pub fn generate_techress_id(seed: u64) -> String {
38    let digits = seed % 10_000_000_000;
39    format!("D{digits:010}")
40}
41
42/// Generate a Global Location Number (GLN): 13 digits with valid GS1 check digit.
43///
44/// The check digit uses the GS1 algorithm: alternating weights 1 and 3 from
45/// the rightmost position, sum mod 10, then (10 - sum % 10) % 10.
46pub fn generate_gln(seed: u64) -> String {
47    let base = seed % 1_000_000_000_000;
48    let base_str = format!("{base:012}");
49    let check = gs1_check_digit(&base_str);
50    format!("{base_str}{check}")
51}
52
53/// Generate an alphanumeric business reference ID safe for EDIFACT.
54///
55/// Uses only characters that don't conflict with EDIFACT delimiters
56/// (no `+`, `:`, `'`, or `?`).
57pub fn generate_reference_id(seed: u64) -> String {
58    const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
59    let len = 12;
60    let mut result = Vec::with_capacity(len);
61    let mut state = seed;
62    for _ in 0..len {
63        // Simple LCG for deterministic pseudo-random selection
64        state = state
65            .wrapping_mul(6_364_136_223_846_793_005)
66            .wrapping_add(1);
67        let idx = (state >> 33) as usize % CHARSET.len();
68        result.push(CHARSET[idx]);
69    }
70    String::from_utf8(result).expect("charset is ASCII")
71}
72
73/// Compute a Luhn check digit for a string of ASCII digits.
74fn luhn_check_digit(digits: &str) -> u8 {
75    let mut sum: u32 = 0;
76    // Process from right to left; the check digit position is odd (1-indexed),
77    // so digits at even 0-indexed positions from the right get doubled.
78    for (i, ch) in digits.chars().rev().enumerate() {
79        let mut d = ch.to_digit(10).expect("input must be all digits");
80        if i % 2 == 0 {
81            d *= 2;
82            if d > 9 {
83                d -= 9;
84            }
85        }
86        sum += d;
87    }
88    ((10 - (sum % 10)) % 10) as u8
89}
90
91/// Compute a GS1 check digit for a string of ASCII digits.
92///
93/// Alternating weights 1 and 3 from the rightmost digit of the input,
94/// then check = (10 - sum % 10) % 10.
95fn gs1_check_digit(digits: &str) -> u8 {
96    let mut sum: u32 = 0;
97    for (i, ch) in digits.chars().rev().enumerate() {
98        let d = ch.to_digit(10).expect("input must be all digits");
99        let weight = if i % 2 == 0 { 3 } else { 1 };
100        sum += d * weight;
101    }
102    ((10 - (sum % 10)) % 10) as u8
103}
104
105/// Generate an interchange datum in YYMMDD format (used in UNB segment).
106pub fn generate_datum(seed: u64) -> String {
107    let (year, month, day) = compute_ymd(seed, seed % 730);
108    let year2 = year % 100;
109    format!("{year2:02}{month:02}{day:02}")
110}
111
112/// Generate an interchange time in HHMM format (used in UNB segment).
113pub fn generate_zeit(seed: u64) -> String {
114    let hour = seed % 24;
115    let s2 = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
116    let minute = (s2 >> 33) % 60;
117    format!("{hour:02}{minute:02}")
118}
119
120/// Generate a date in YYYY-MM-DD format (HTML5 date input format).
121pub fn generate_date_ymd(seed: u64) -> String {
122    let (year, month, day) = compute_ymd(seed, seed % 730);
123    format!("{year:04}-{month:02}-{day:02}")
124}
125
126/// Generate a date in CCYYMMDDHHMMSS+00 format (EDIFACT 303).
127pub fn generate_date_edifact303(seed: u64) -> String {
128    let (year, month, day) = compute_ymd(seed, seed % 730);
129    format!("{year:04}{month:02}{day:02}120000+00")
130}
131
132/// Generate a date in CCYYMMDD format (EDIFACT 102).
133pub fn generate_date_edifact102(seed: u64) -> String {
134    let (year, month, day) = compute_ymd(seed, seed % 730);
135    format!("{year:04}{month:02}{day:02}")
136}
137
138/// Compute (year, month, day) from 2024-01-01 + day_offset days.
139pub fn compute_ymd(seed: u64, day_offset: u64) -> (u64, u64, u64) {
140    let _ = seed;
141    let base_year = 2024u64;
142    let mut year = base_year;
143    let mut remaining = day_offset;
144    loop {
145        let days_in_year = if is_leap_year(year) { 366 } else { 365 };
146        if remaining < days_in_year {
147            break;
148        }
149        remaining -= days_in_year;
150        year += 1;
151    }
152    let month_days = days_per_month(year);
153    let mut month = 1u64;
154    for &md in &month_days {
155        if remaining < md {
156            break;
157        }
158        remaining -= md;
159        month += 1;
160    }
161    let day = remaining + 1;
162    (year, month, day)
163}
164
165/// Return true if `year` is a leap year.
166pub fn is_leap_year(year: u64) -> bool {
167    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
168}
169
170/// Days per month for the given year (January = index 0).
171pub fn days_per_month(year: u64) -> [u64; 12] {
172    let feb = if is_leap_year(year) { 29 } else { 28 };
173    [31, feb, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn test_malo_id_format() {
182        let id = generate_malo_id(12345);
183        assert_eq!(id.len(), 11, "MaLo ID must be 11 chars, got: {id}");
184        assert!(
185            id.chars().all(|c| c.is_ascii_digit()),
186            "MaLo ID must be all digits, got: {id}"
187        );
188    }
189
190    #[test]
191    fn test_malo_id_deterministic() {
192        let a = generate_malo_id(42);
193        let b = generate_malo_id(42);
194        assert_eq!(a, b, "Same seed must produce same MaLo ID");
195    }
196
197    #[test]
198    fn test_malo_id_valid_check_digit() {
199        let id = generate_malo_id(99999);
200        // Verify by running Luhn over all 11 digits — must yield 0
201        let base = &id[..10];
202        let expected_check = luhn_check_digit(base);
203        let actual_check = id.chars().last().unwrap().to_digit(10).unwrap() as u8;
204        assert_eq!(
205            expected_check, actual_check,
206            "Luhn check digit mismatch for {id}"
207        );
208    }
209
210    #[test]
211    fn test_melo_id_format() {
212        let id = generate_melo_id(67890);
213        assert!(
214            id.starts_with("DE"),
215            "MeLo ID must start with DE, got: {id}"
216        );
217        assert_eq!(id.len(), 33, "MeLo ID must be 33 chars, got: {id}");
218        assert!(
219            id[2..].chars().all(|c| c.is_ascii_digit()),
220            "MeLo ID digits portion must be all digits, got: {id}"
221        );
222    }
223
224    #[test]
225    fn test_melo_id_deterministic() {
226        let a = generate_melo_id(42);
227        let b = generate_melo_id(42);
228        assert_eq!(a, b, "Same seed must produce same MeLo ID");
229    }
230
231    #[test]
232    fn test_nelo_id_format() {
233        let id = generate_nelo_id(11111);
234        assert_eq!(
235            id.chars().next().unwrap(),
236            'E',
237            "NeLo ID must start with E, got: {id}"
238        );
239        assert_eq!(id.len(), 11, "NeLo ID must be 11 chars, got: {id}");
240        assert!(
241            id[1..].chars().all(|c| c.is_ascii_digit()),
242            "NeLo ID digits portion must be all digits, got: {id}"
243        );
244    }
245
246    #[test]
247    fn test_steuress_id_format() {
248        let id = generate_steuress_id(22222);
249        assert_eq!(
250            id.chars().next().unwrap(),
251            'C',
252            "SteuRess ID must start with C, got: {id}"
253        );
254        assert_eq!(id.len(), 11, "SteuRess ID must be 11 chars, got: {id}");
255        assert!(
256            id[1..].chars().all(|c| c.is_ascii_digit()),
257            "SteuRess ID digits portion must be all digits, got: {id}"
258        );
259    }
260
261    #[test]
262    fn test_techress_id_format() {
263        let id = generate_techress_id(33333);
264        assert_eq!(
265            id.chars().next().unwrap(),
266            'D',
267            "TechRess ID must start with D, got: {id}"
268        );
269        assert_eq!(id.len(), 11, "TechRess ID must be 11 chars, got: {id}");
270        assert!(
271            id[1..].chars().all(|c| c.is_ascii_digit()),
272            "TechRess ID digits portion must be all digits, got: {id}"
273        );
274    }
275
276    #[test]
277    fn test_gln_format() {
278        let id = generate_gln(44444);
279        assert_eq!(id.len(), 13, "GLN must be 13 chars, got: {id}");
280        assert!(
281            id.chars().all(|c| c.is_ascii_digit()),
282            "GLN must be all digits, got: {id}"
283        );
284    }
285
286    #[test]
287    fn test_gln_valid_check_digit() {
288        for seed in [0, 1, 42, 999, 123456789, u64::MAX] {
289            let id = generate_gln(seed);
290            // Verify GS1 check: sum of (digit * weight) for all 13 digits should be divisible by 10
291            let mut sum: u32 = 0;
292            for (i, ch) in id.chars().rev().enumerate() {
293                let d = ch.to_digit(10).unwrap();
294                let weight = if i % 2 == 0 { 1 } else { 3 };
295                sum += d * weight;
296            }
297            assert_eq!(
298                sum % 10,
299                0,
300                "GLN {id} (seed={seed}) fails GS1 check digit validation: sum={sum}"
301            );
302        }
303    }
304
305    #[test]
306    fn test_gln_deterministic() {
307        let a = generate_gln(42);
308        let b = generate_gln(42);
309        assert_eq!(a, b, "Same seed must produce same GLN");
310    }
311
312    #[test]
313    fn test_reference_id_format() {
314        let id = generate_reference_id(55555);
315        assert!(!id.is_empty(), "Reference ID must not be empty");
316        assert!(
317            !id.contains('+'),
318            "Reference ID must not contain +, got: {id}"
319        );
320        assert!(
321            !id.contains(':'),
322            "Reference ID must not contain :, got: {id}"
323        );
324        assert!(
325            !id.contains('\''),
326            "Reference ID must not contain ', got: {id}"
327        );
328        assert!(
329            !id.contains('?'),
330            "Reference ID must not contain ?, got: {id}"
331        );
332    }
333
334    #[test]
335    fn test_reference_id_deterministic() {
336        let a = generate_reference_id(42);
337        let b = generate_reference_id(42);
338        assert_eq!(a, b, "Same seed must produce same reference ID");
339    }
340
341    #[test]
342    fn test_different_seeds_produce_different_ids() {
343        // Sanity check: different seeds should generally produce different output
344        let a = generate_malo_id(1);
345        let b = generate_malo_id(2);
346        assert_ne!(a, b, "Different seeds should produce different MaLo IDs");
347
348        let a = generate_gln(1);
349        let b = generate_gln(2);
350        assert_ne!(a, b, "Different seeds should produce different GLNs");
351
352        let a = generate_reference_id(1);
353        let b = generate_reference_id(2);
354        assert_ne!(
355            a, b,
356            "Different seeds should produce different reference IDs"
357        );
358    }
359
360    #[test]
361    fn test_generate_datum_format() {
362        let d = generate_datum(42);
363        assert_eq!(d.len(), 6, "datum must be 6 chars YYMMDD, got: {d}");
364        assert!(
365            d.chars().all(|c| c.is_ascii_digit()),
366            "datum must be digits"
367        );
368    }
369
370    #[test]
371    fn test_generate_zeit_format() {
372        let z = generate_zeit(42);
373        assert_eq!(z.len(), 4, "zeit must be 4 chars HHMM, got: {z}");
374        let hour: u64 = z[..2].parse().unwrap();
375        let min: u64 = z[2..].parse().unwrap();
376        assert!(hour < 24, "hour out of range: {hour}");
377        assert!(min < 60, "minute out of range: {min}");
378    }
379
380    #[test]
381    fn test_generate_date_ymd_format() {
382        let d = generate_date_ymd(42);
383        assert_eq!(d.len(), 10, "date_ymd must be YYYY-MM-DD, got: {d}");
384        assert!(d.contains('-'), "date_ymd must contain dashes");
385    }
386
387    #[test]
388    fn test_generate_date_edifact303_format() {
389        let d = generate_date_edifact303(42);
390        assert!(d.ends_with("+00"), "edifact303 must end with +00, got: {d}");
391        assert!(
392            d.len() >= 15,
393            "edifact303 must be at least 15 chars, got: {d}"
394        );
395    }
396
397    #[test]
398    fn test_generate_date_edifact102_format() {
399        let d = generate_date_edifact102(42);
400        assert_eq!(d.len(), 8, "edifact102 must be 8 chars CCYYMMDD, got: {d}");
401        assert!(
402            d.chars().all(|c| c.is_ascii_digit()),
403            "edifact102 must be digits"
404        );
405    }
406}