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}