Skip to main content

edi_energy/
custom_rule_pack.rs

1// Segment occurrence indices (from `enumerate()`) are bounded by EDIFACT message
2// limits (~10 000 segments max) and therefore fit safely in u16/u8.
3#![allow(clippy::cast_possible_truncation)]
4
5use std::sync::Arc;
6
7use edifact_rs::{ProfileRulePack, ValidationIssue, ValidationSeverity};
8
9/// A caller-supplied validation rule pack that can be merged on top of all
10/// built-in validation layers when calling `EdiEnergyMessage::validate_with_pack`.
11///
12/// `CustomRulePack` insulates callers from the internal `edifact-rs`
13/// `ProfileRulePack` type: no direct dependency on `edifact-rs` is required
14/// to construct a `CustomRulePack`.
15///
16/// # Example
17///
18/// ```rust
19/// use edi_energy::CustomRulePack;
20///
21/// let pack = CustomRulePack::new("my-business-rules")
22///     .require_segment("STS")
23///     .forbid_segment("CNT");
24/// ```
25#[must_use]
26pub struct CustomRulePack(ProfileRulePack);
27
28impl CustomRulePack {
29    /// Create an empty rule pack with a human-readable name used in rule IDs.
30    pub fn new(name: impl Into<String>) -> Self {
31        Self(ProfileRulePack::new(name))
32    }
33
34    /// Add a rule that requires the given EDIFACT segment tag to be present at
35    /// least once.  Emits an error-severity issue when the segment is absent.
36    pub fn require_segment(mut self, tag: &'static str) -> Self {
37        let rule_id: Arc<str> = format!("CUSTOM-{tag}-REQUIRED").into();
38        let msg: Arc<str> = format!("required segment {tag} is missing").into();
39        let rule_id_inner = Arc::clone(&rule_id);
40        let msg_inner = Arc::clone(&msg);
41        self.0 = self
42            .0
43            .with_named_stateless_rule_fn(rule_id, move |segs, issues| {
44                if !segs.iter().any(|s| s.tag == tag) {
45                    issues.push(
46                        ValidationIssue::new(ValidationSeverity::Error, (*msg_inner).to_owned())
47                            .with_rule_id((*rule_id_inner).to_owned())
48                            .with_segment(tag.to_owned()),
49                    );
50                }
51            });
52        self
53    }
54
55    /// Add a rule that forbids the given EDIFACT segment tag.
56    /// Emits an error-severity issue when the segment is present.
57    pub fn forbid_segment(mut self, tag: &'static str) -> Self {
58        let rule_id: Arc<str> = format!("CUSTOM-{tag}-FORBIDDEN").into();
59        let msg: Arc<str> = format!("segment {tag} must not appear").into();
60        let rule_id_inner = Arc::clone(&rule_id);
61        let msg_inner = Arc::clone(&msg);
62        self.0 = self
63            .0
64            .with_named_stateless_rule_fn(rule_id, move |segs, issues| {
65                for (occ, seg) in segs.iter().enumerate().filter(|(_, s)| s.tag == tag) {
66                    issues.push(
67                        ValidationIssue::new(ValidationSeverity::Error, (*msg_inner).to_owned())
68                            .with_span(seg.span)
69                            .with_rule_id((*rule_id_inner).to_owned())
70                            .with_segment(tag.to_owned())
71                            .with_segment_occurrence(occ as u16),
72                    );
73                }
74            });
75        self
76    }
77
78    /// Add a rule that requires the given segment's first element (element 0,
79    /// component 0) to be present and contain one of the `allowed` qualifier values.
80    /// Emits an error-severity issue when the qualifier is absent or not in the set.
81    pub fn require_qualifier(
82        mut self,
83        tag: &'static str,
84        allowed: &'static [&'static str],
85    ) -> Self {
86        let rule_id: Arc<str> = format!("CUSTOM-{tag}-QUALIFIER").into();
87        let rule_id_inner = Arc::clone(&rule_id);
88        self.0 = self.0.with_named_stateless_rule_fn(rule_id, move |segs, issues| {
89            for (occ, seg) in segs.iter().enumerate().filter(|(_, s)| s.tag == tag) {
90                match seg.element_str(0) {
91                    Some(q) if allowed.contains(&q) => {}
92                    Some(_) => {
93                        let msg = format!(
94                            "segment {tag} element 0: qualifier is not in the allowed set ({} allowed value(s))",
95                            allowed.len()
96                        );
97                        issues.push(
98                            ValidationIssue::new(ValidationSeverity::Error, msg)
99                                .with_span(seg.span)
100                                .with_rule_id((*rule_id_inner).to_owned())
101                                .with_segment(tag.to_owned())
102                                .with_segment_occurrence(occ as u16)
103                                .with_element_index(0)
104                                .with_component_index(0),
105                        );
106                    }
107                    None => {
108                        let msg = format!(
109                            "segment {tag} is missing the qualifier (element 0)"
110                        );
111                        issues.push(
112                            ValidationIssue::new(ValidationSeverity::Error, msg)
113                                .with_span(seg.span)
114                                .with_rule_id((*rule_id_inner).to_owned())
115                                .with_segment(tag.to_owned())
116                                .with_segment_occurrence(occ as u16)
117                                .with_element_index(0),
118                        );
119                    }
120                }
121            }
122        });
123        self
124    }
125
126    /// Convert into the internal `ProfileRulePack`.
127    ///
128    /// This is `pub(crate)` to keep the `edifact-rs` dependency internal.
129    #[allow(dead_code)]
130    pub(crate) fn into_inner(self) -> ProfileRulePack {
131        self.0
132    }
133
134    /// Add a rule that is evaluated once per occurrence of the named segment group
135    /// (e.g. `"SG2"`, `"SG5"`).
136    ///
137    /// The closure receives:
138    /// - `occurrence` — 0-based index of this group occurrence within its parent
139    ///   (first `SG5` = 0, second `SG5` = 1, …).
140    /// - `segs` — the flat slice of [`edifact_rs::Segment`] values contained
141    ///   within this group occurrence.  Only segments that belong to this
142    ///   occurrence are included; no additional filtering is needed.
143    /// - `issues` — append [`ValidationIssue`] values here to report violations.
144    ///
145    /// The `group_id` must match a group name defined in the message type's MIG
146    /// (e.g. `"SG2"` for UTILMD, `"SG6"` for MSCONS).  If no group with that ID
147    /// exists in the validated message, the rule is silently skipped.
148    ///
149    /// # Example
150    ///
151    /// ```rust
152    /// use edi_energy::CustomRulePack;
153    /// use edifact_rs::{ValidationIssue, ValidationSeverity};
154    ///
155    /// // Require that every SG5 occurrence in MSCONS contains at least one LOC.
156    /// let pack = CustomRulePack::new("my-mscons-rules")
157    ///     .add_group_rule("SG6", "MY-SG6-LOC-REQ", |_occ, segs, issues| {
158    ///         if !segs.iter().any(|s| s.tag == "LOC") {
159    ///             issues.push(
160    ///                 ValidationIssue::new(
161    ///                     ValidationSeverity::Error,
162    ///                     "SG6 group is missing required LOC segment".to_owned(),
163    ///                 )
164    ///                 .with_rule_id("MY-SG6-LOC-REQ"),
165    ///             );
166    ///         }
167    ///     });
168    /// ```
169    pub fn add_group_rule<F>(
170        mut self,
171        group_id: impl Into<std::sync::Arc<str>>,
172        rule_id: impl Into<std::sync::Arc<str>>,
173        rule: F,
174    ) -> Self
175    where
176        F: Fn(usize, &[edifact_rs::Segment<'_>], &mut Vec<ValidationIssue>) + Send + Sync + 'static,
177    {
178        let scope: Arc<str> = group_id.into();
179        let scope_for_annotation = Arc::clone(&scope);
180        self.0 =
181            self.0
182                .with_scoped_group_rule_fn(scope, rule_id, move |group, segs, _ctx, issues| {
183                    let before = issues.len();
184                    rule(group.occurrence_index, segs, issues);
185                    // Auto-annotate any issues the closure emitted with the group scope
186                    // so callers don't have to set it manually.
187                    for issue in &mut issues[before..] {
188                        if issue.segment_group.is_none() {
189                            issue.segment_group = Some(Arc::clone(&scope_for_annotation));
190                        }
191                    }
192                });
193        self
194    }
195
196    /// Add a rule that requires the given segment tag to be present in every
197    /// occurrence of the named segment group.
198    ///
199    /// Emits an `Error`-severity issue when the segment is absent from a group
200    /// occurrence.  The auto-generated rule ID is `CUSTOM-{group_id}-{tag}-REQUIRED`.
201    ///
202    /// # Example
203    ///
204    /// ```rust
205    /// use edi_energy::CustomRulePack;
206    ///
207    /// // Every SG2 in UTILMD must contain a NAD segment.
208    /// let pack = CustomRulePack::new("my-rules")
209    ///     .require_segment_in_group("SG2", "NAD");
210    /// ```
211    pub fn require_segment_in_group(
212        mut self,
213        group_id: impl Into<Arc<str>>,
214        tag: &'static str,
215    ) -> Self {
216        let scope: Arc<str> = group_id.into();
217        let rule_id: Arc<str> = format!("CUSTOM-{scope}-{tag}-REQUIRED").into();
218        let msg: Arc<str> = format!("mandatory segment {tag} is missing from group {scope}").into();
219        let scope_for_annotation = Arc::clone(&scope);
220        let rule_id_inner = Arc::clone(&rule_id);
221        let msg_inner = Arc::clone(&msg);
222        self.0 =
223            self.0
224                .with_scoped_group_rule_fn(scope, rule_id, move |_group, segs, _ctx, issues| {
225                    if !segs.iter().any(|s| s.tag == tag) {
226                        issues.push(
227                            ValidationIssue::new(
228                                ValidationSeverity::Error,
229                                (*msg_inner).to_owned(),
230                            )
231                            .with_segment(tag.to_owned())
232                            .with_rule_id((*rule_id_inner).to_owned())
233                            .with_segment_group(Arc::clone(&scope_for_annotation)),
234                        );
235                    }
236                });
237        self
238    }
239
240    /// Add a rule that forbids the given segment tag from appearing in any
241    /// occurrence of the named segment group.
242    ///
243    /// Emits an `Error`-severity issue for each occurrence found, with the
244    /// source byte span attached for precise diagnostic highlighting.
245    /// The auto-generated rule ID is `CUSTOM-{group_id}-{tag}-FORBIDDEN`.
246    ///
247    /// # Example
248    ///
249    /// ```rust
250    /// use edi_energy::CustomRulePack;
251    ///
252    /// // SG4 must not contain a FTX segment for this process.
253    /// let pack = CustomRulePack::new("my-rules")
254    ///     .forbid_segment_in_group("SG4", "FTX");
255    /// ```
256    pub fn forbid_segment_in_group(
257        mut self,
258        group_id: impl Into<Arc<str>>,
259        tag: &'static str,
260    ) -> Self {
261        let scope: Arc<str> = group_id.into();
262        let rule_id: Arc<str> = format!("CUSTOM-{scope}-{tag}-FORBIDDEN").into();
263        let msg: Arc<str> = format!("segment {tag} must not appear in group {scope}").into();
264        let scope_for_annotation = Arc::clone(&scope);
265        let rule_id_inner = Arc::clone(&rule_id);
266        let msg_inner = Arc::clone(&msg);
267        self.0 =
268            self.0
269                .with_scoped_group_rule_fn(scope, rule_id, move |_group, segs, _ctx, issues| {
270                    for (occ, seg) in segs.iter().enumerate().filter(|(_, s)| s.tag == tag) {
271                        issues.push(
272                            ValidationIssue::new(
273                                ValidationSeverity::Error,
274                                (*msg_inner).to_owned(),
275                            )
276                            .with_span(seg.span)
277                            .with_segment(tag.to_owned())
278                            .with_segment_occurrence(occ as u16)
279                            .with_rule_id((*rule_id_inner).to_owned())
280                            .with_segment_group(Arc::clone(&scope_for_annotation)),
281                        );
282                    }
283                });
284        self
285    }
286
287    /// Add a rule that checks the value at a specific element and component position
288    /// of the given segment tag.
289    ///
290    /// This is a generalisation of [`require_qualifier`][Self::require_qualifier] that
291    /// works at any `(element_index, component_index)` position rather than only the
292    /// first qualifier element.
293    ///
294    /// # Example
295    ///
296    /// ```rust
297    /// use edi_energy::CustomRulePack;
298    ///
299    /// // Require that CCI element 2 component 0 is one of the approved codes.
300    /// let pack = CustomRulePack::new("my-rules")
301    ///     .check_element("CCI", 2, 0, &["Z01", "Z02"]);
302    /// ```
303    pub fn check_element(
304        mut self,
305        tag: &'static str,
306        element_index: usize,
307        component_index: usize,
308        allowed: &'static [&'static str],
309    ) -> Self {
310        let rule_id: Arc<str> =
311            format!("CUSTOM-{tag}-E{element_index}C{component_index}-VALUE").into();
312        let rule_id_inner = Arc::clone(&rule_id);
313        self.0 = self.0.with_named_stateless_rule_fn(rule_id, move |segs, issues| {
314            for (occ, seg) in segs.iter().enumerate().filter(|(_, s)| s.tag == tag) {
315                let actual = seg.component_str(element_index, component_index);
316                match actual {
317                    Some(v) if allowed.contains(&v) => {}
318                    Some(_) => {
319                        let msg = format!(
320                            "segment {tag} element {element_index} component {component_index}: \
321                             value is not in the allowed set ({} allowed value(s))",
322                            allowed.len()
323                        );
324                        issues.push(
325                            ValidationIssue::new(ValidationSeverity::Error, msg)
326                                .with_span(seg.span)
327                                .with_rule_id((*rule_id_inner).to_owned())
328                                .with_segment(tag.to_owned())
329                                .with_segment_occurrence(occ as u16)
330                                .with_element_index(element_index as u8)
331                                .with_component_index(component_index as u8),
332                        );
333                    }
334                    None => {
335                        let msg = format!(
336                            "segment {tag} element {element_index} component {component_index} is absent"
337                        );
338                        issues.push(
339                            ValidationIssue::new(ValidationSeverity::Error, msg)
340                                .with_span(seg.span)
341                                .with_rule_id((*rule_id_inner).to_owned())
342                                .with_segment(tag.to_owned())
343                                .with_segment_occurrence(occ as u16)
344                                .with_element_index(element_index as u8)
345                                .with_component_index(component_index as u8),
346                        );
347                    }
348                }
349            }
350        });
351        self
352    }
353
354    /// Add a rule that validates the value at a specific element position using a
355    /// caller-supplied predicate function.
356    ///
357    /// Use this to enforce format constraints that cannot be expressed as a fixed
358    /// code list — for example OBIS code structure, GLN check-digit, or date format.
359    ///
360    /// The `description` string is included in the error message to describe the
361    /// expected format (e.g. `"OBIS code (format: A-B:C.D.E*F)"`).
362    ///
363    /// # Example
364    ///
365    /// ```rust
366    /// use edi_energy::CustomRulePack;
367    ///
368    /// // Require that PIA element 1 component 0 looks like an OBIS code.
369    /// let pack = CustomRulePack::new("my-rules")
370    ///     .check_format("PIA", 1, 0, |v| v.contains(':'), "OBIS code (must contain ':')");
371    /// ```
372    pub fn check_format<F>(
373        mut self,
374        tag: &'static str,
375        element_index: usize,
376        component_index: usize,
377        validator: F,
378        description: &'static str,
379    ) -> Self
380    where
381        F: Fn(&str) -> bool + Send + Sync + 'static,
382    {
383        let rule_id: Arc<str> =
384            format!("CUSTOM-{tag}-E{element_index}C{component_index}-FORMAT").into();
385        let rule_id_inner = Arc::clone(&rule_id);
386        self.0 = self.0.with_named_stateless_rule_fn(rule_id, move |segs, issues| {
387            for (occ, seg) in segs.iter().enumerate().filter(|(_, s)| s.tag == tag) {
388                if let Some(v) = seg.component_str(element_index, component_index) {
389                    if !validator(v) {
390                        let msg = format!(
391                            "segment {tag} element {element_index} component {component_index}: \
392                             value does not match expected format ({description})"
393                        );
394                        issues.push(
395                            ValidationIssue::new(ValidationSeverity::Error, msg)
396                                .with_span(seg.span)
397                                .with_rule_id((*rule_id_inner).to_owned())
398                                .with_segment(tag.to_owned())
399                                .with_segment_occurrence(occ as u16)
400                                .with_element_index(element_index as u8)
401                                .with_component_index(component_index as u8),
402                        );
403                    }
404                }
405            }
406        });
407        self
408    }
409
410    /// Add a rule that requires both `tag_a` and `tag_b` to be present together.
411    ///
412    /// Emits an error if `tag_a` is present but `tag_b` is absent, or vice versa.
413    /// Use this for segments that must always appear in pairs (e.g. `CTA` and `COM`
414    /// in contact-information patterns).
415    ///
416    /// # Example
417    ///
418    /// ```rust
419    /// use edi_energy::CustomRulePack;
420    ///
421    /// let pack = CustomRulePack::new("my-rules")
422    ///     .require_segment_combination("CTA", "COM");
423    /// ```
424    pub fn require_segment_combination(mut self, tag_a: &'static str, tag_b: &'static str) -> Self {
425        let rule_id: Arc<str> = format!("CUSTOM-{tag_a}-{tag_b}-PAIR").into();
426        let rule_id_inner = Arc::clone(&rule_id);
427        self.0 = self.0.with_named_stateless_rule_fn(rule_id, move |segs, issues| {
428            let has_a = segs.iter().any(|s| s.tag == tag_a);
429            let has_b = segs.iter().any(|s| s.tag == tag_b);
430            if has_a && !has_b {
431                issues.push(
432                    ValidationIssue::new(
433                        ValidationSeverity::Error,
434                        format!("segment {tag_a} is present but its required companion {tag_b} is absent"),
435                    )
436                    .with_rule_id((*rule_id_inner).to_owned())
437                    .with_segment(tag_b.to_owned()),
438                );
439            } else if has_b && !has_a {
440                issues.push(
441                    ValidationIssue::new(
442                        ValidationSeverity::Error,
443                        format!("segment {tag_b} is present but its required companion {tag_a} is absent"),
444                    )
445                    .with_rule_id((*rule_id_inner).to_owned())
446                    .with_segment(tag_a.to_owned()),
447                );
448            }
449        });
450        self
451    }
452}
453
454impl std::fmt::Debug for CustomRulePack {
455    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
456        f.debug_struct("CustomRulePack").finish_non_exhaustive()
457    }
458}