edifact_rs/group.rs
1//! Segment group tree model for structured EDIFACT message navigation.
2//!
3//! Provides a recursive group schema ([`GroupDef`]) and a segment-slice-to-tree
4//! function ([`group_segments_indexed`]) that partitions a flat segment slice into a
5//! [`SegmentGroupIndexed`] tree according to the schema.
6//!
7//! # Model overview
8//!
9//! Every UN/EDIFACT message type has a fixed set of **segment groups**: named,
10//! optionally-repeating sets of segments delimited by a specific *trigger*
11//! segment tag. For example, ORDERS D.11A has an `SG1` group starting with
12//! `RFF`, an `SG2` group starting with `NAD`, and so on.
13//!
14//! This module provides lightweight, allocation-efficient types for defining
15//! and working with these groups without requiring message-type-specific
16//! generated code.
17//!
18//! # Example
19//!
20//! ```rust,ignore
21//! use edifact_rs::group::{GroupDef, group_segments_indexed};
22//!
23//! static SG32: &[GroupDef] = &[GroupDef::new("SG32", "PRI")];
24//! static ORDERS_GROUPS: &[GroupDef] = &[
25//! GroupDef::new("SG2", "NAD"),
26//! GroupDef::with_children("SG7", "LIN", SG32),
27//! ];
28//!
29//! let root = group_segments_indexed(&segments, ORDERS_GROUPS, "ROOT");
30//! for child in &root.children {
31//! let child_segs = &segments[child.total_span.clone()];
32//! println!("{} #{}: {} segments", child.definition, child.occurrence_index, child_segs.len());
33//! }
34//! ```
35
36use crate::{OwnedSegment, Segment};
37use smallvec::SmallVec;
38use std::ops::Range;
39
40// ── GroupDef ──────────────────────────────────────────────────────────────────
41
42/// Schema describing one segment group within an EDIFACT message.
43///
44/// The lifetime `'a` is what the schema's strings and nested slices borrow
45/// from. A `const`/`static` table is `GroupDef<'static>` and costs no
46/// allocation; a schema deserialized from a MIG at startup borrows from an
47/// arena the caller owns. Earlier releases hard-coded `&'static str`, which
48/// made runtime-loaded schemas impossible even though the directory side of the
49/// crate ([`OwnedSegmentDef`][crate::OwnedSegmentDef],
50/// [`DirectoryValidatorBuilder`][crate::DirectoryValidatorBuilder]) has always
51/// supported them.
52#[derive(Debug, Clone, Copy)]
53pub struct GroupDef<'a> {
54 /// Human-readable group name, e.g. `"SG2"`.
55 pub name: &'a str,
56 /// The segment tag whose appearance starts a new instance of this group.
57 pub trigger: &'a str,
58 /// Nested child groups within this group.
59 ///
60 /// The first trigger encountered among `children` ends the current child
61 /// and starts a new one; a trigger that matches a sibling or ancestor group
62 /// ends this group entirely.
63 pub children: &'a [GroupDef<'a>],
64}
65
66impl<'a> GroupDef<'a> {
67 /// A leaf group: `name` is opened by `trigger` and has no nested groups.
68 #[must_use]
69 pub const fn new(name: &'a str, trigger: &'a str) -> Self {
70 Self {
71 name,
72 trigger,
73 children: &[],
74 }
75 }
76
77 /// A group with nested child groups.
78 #[must_use]
79 pub const fn with_children(
80 name: &'a str,
81 trigger: &'a str,
82 children: &'a [GroupDef<'a>],
83 ) -> Self {
84 Self {
85 name,
86 trigger,
87 children,
88 }
89 }
90}
91
92// ── SegmentGroupIndexed ───────────────────────────────────────────────────────
93
94/// Zero-copy segment group tree. Stores index ranges into the original flat
95/// segment slice rather than cloning each segment.
96///
97/// Produced by [`group_segments_indexed`]. To access the actual segments use
98/// the original `&[Segment<'a>]` together with [`total_span`]:
99///
100/// ```rust,ignore
101/// let indexed = group_segments_indexed(&segments, MY_SCHEMA, "ROOT");
102/// for child in &indexed.children {
103/// let child_segs = &segments[child.total_span.clone()];
104/// }
105/// ```
106///
107/// [`total_span`]: SegmentGroupIndexed::total_span
108#[derive(Debug)]
109pub struct SegmentGroupIndexed<'a> {
110 /// Group name from the schema, e.g. `"SG2"`, or the root name.
111 ///
112 /// Borrows from the schema, so it lives exactly as long as the schema does.
113 pub definition: &'a str,
114 /// Contiguous span `[start, end)` of absolute indices into the original flat
115 /// segment slice covering **all** segments in this group instance — trigger
116 /// segment, direct segments, and all descendant groups combined.
117 ///
118 /// Use this to slice the original `&[Segment<'_>]` to get every segment
119 /// belonging to this group:
120 ///
121 /// ```rust,ignore
122 /// let all_sg2_segs = &segments[sg2.total_span.clone()];
123 /// ```
124 ///
125 /// To iterate over only the segments that belong *directly* to this group
126 /// (excluding descendants), use [`direct_segment_indices`].
127 ///
128 /// [`direct_segment_indices`]: SegmentGroupIndexed::direct_segment_indices
129 pub total_span: Range<usize>,
130 /// Child group instances, in message order.
131 pub children: Vec<SegmentGroupIndexed<'a>>,
132 /// Zero-based occurrence index of this group instance among all siblings
133 /// with the same `definition` at this level.
134 ///
135 /// For example, the first `SG5` child at a given level has `occurrence_index = 0`,
136 /// the second `SG5` has `occurrence_index = 1`, etc. Siblings with a
137 /// *different* definition have independent counters.
138 ///
139 /// This field is essential for producing unambiguous rule-violation IDs
140 /// (e.g. `"SG5[2]/DTM"`) when the same group type repeats.
141 pub occurrence_index: usize,
142}
143
144impl SegmentGroupIndexed<'_> {
145 /// Iterate over the absolute indices of segments that belong *directly* to
146 /// this group — i.e. those within [`total_span`] that are **not** covered
147 /// by any child group's [`total_span`].
148 ///
149 /// Complexity: `O(total_span.len() × children.len())`. For typical EDIFACT
150 /// message structures (≤ 8 children per group) this is negligible.
151 ///
152 /// [`total_span`]: SegmentGroupIndexed::total_span
153 pub fn direct_segment_indices(&self) -> impl Iterator<Item = usize> + '_ {
154 self.total_span.clone().filter(|i| {
155 !self
156 .children
157 .iter()
158 .any(|child| child.total_span.contains(i))
159 })
160 }
161}
162
163/// Partition `segments` into a [`SegmentGroupIndexed`] tree without cloning.
164///
165/// Stores `Range<usize>` indices into the original flat slice rather than
166/// copying each [`Segment`] into the tree. Use the original slice together
167/// with [`SegmentGroupIndexed::total_span`] to access segments.
168///
169/// # Worked Example
170///
171/// Consider a simplified 3-level multi-level schema:
172///
173/// ```rust
174/// use edifact_rs::group::{GroupDef, group_segments_indexed};
175/// use edifact_rs::from_bytes;
176///
177/// // Schema: ROOT → SG1 (trigger: RFF) → SG5 (trigger: LOC) → SG6 (trigger: QTY)
178/// static SG6: &[GroupDef] = &[GroupDef::new("SG6", "QTY")];
179/// static SCHEMA: &[GroupDef] = &[
180/// GroupDef::new("SG1", "RFF"),
181/// GroupDef::with_children("SG5", "LOC", SG6),
182/// ];
183///
184/// // A small multi-level message fragment (no envelope for clarity).
185/// let input = b"RFF+Z13:REF1'LOC+172+DE123'DTM+163:20230101:102'QTY+220:100:KWH'";
186/// let segments: Vec<_> = from_bytes(input)
187/// .collect::<Result<_, _>>()
188/// .unwrap();
189///
190/// let tree = group_segments_indexed(&segments, SCHEMA, "ROOT");
191///
192/// // The root contains no direct segments (all consumed by SG1 / SG5).
193/// assert!(tree.direct_segment_indices().next().is_none());
194///
195/// // One SG1 group and one SG5 group at root level.
196/// let sg1 = tree.children.iter().find(|g| g.definition == "SG1").unwrap();
197/// let sg5 = tree.children.iter().find(|g| g.definition == "SG5").unwrap();
198///
199/// // SG1 spans the RFF segment only.
200/// assert_eq!(&segments[sg1.total_span.clone()].iter().map(|s| s.tag).collect::<Vec<_>>(),
201/// &["RFF"]);
202///
203/// // SG5 spans LOC + DTM + QTY (all three segments, including the SG6 child).
204/// let sg5_tags: Vec<_> = segments[sg5.total_span.clone()].iter().map(|s| s.tag).collect();
205/// assert_eq!(sg5_tags, &["LOC", "DTM", "QTY"]);
206///
207/// // SG5's direct segments (LOC + DTM) exclude the SG6 child (QTY).
208/// let sg5_direct: Vec<_> = sg5.direct_segment_indices()
209/// .map(|i| segments[i].tag)
210/// .collect();
211/// assert_eq!(sg5_direct, &["LOC", "DTM"]);
212///
213/// // SG6 contains only QTY.
214/// let sg6 = sg5.children.iter().find(|g| g.definition == "SG6").unwrap();
215/// assert_eq!(segments[sg6.total_span.clone()].iter().map(|s| s.tag).collect::<Vec<_>>(),
216/// &["QTY"]);
217/// ```
218///
219/// # Group validation
220///
221/// `group_segments_indexed` pairs naturally with
222/// [`crate::validator::ValidationContext::validate_lenient_grouped`] to enforce group-presence rules:
223///
224/// ```rust,ignore
225/// use edifact_rs::{ProfileRulePack, ValidationContext};
226///
227/// let pack = ProfileRulePack::new("MY-PROFILE")
228/// .require_segment_in_group("SG5", "DTM", "SG5-DTM-M")
229/// .forbid_segment_in_group("SG1", "LOC", "SG1-LOC-F");
230/// let ctx = ValidationContext::builder().with_profile_pack(pack).build();
231///
232/// let tree = group_segments_indexed(&segments, SCHEMA, "ORDERS");
233/// let report = ctx.validate_lenient_grouped(&tree, &segments);
234/// ```
235///
236/// # What a group spans
237///
238/// Grouping is driven purely by trigger tags: a group runs from its trigger to
239/// the next trigger belonging to a sibling or ancestor, or to the end of the
240/// slice. Nothing stops the final group at `UNT`, because the trailer is not a
241/// trigger of anything — pass the message *body* when the group boundaries
242/// matter, or accept that the trailer lands inside the last group.
243///
244/// # Complexity
245///
246/// `O(n × schema_depth)` time, `O(tree_nodes)` space. No `Segment` clones.
247pub fn group_segments_indexed<'g>(
248 segments: &[Segment<'_>],
249 schema: &'g [GroupDef<'g>],
250 root_name: &'g str,
251) -> SegmentGroupIndexed<'g> {
252 let mut root = SegmentGroupIndexed {
253 definition: root_name,
254 total_span: 0..0,
255 children: Vec::new(),
256 occurrence_index: 0,
257 };
258 group_recursive_indexed(segments, &mut root, schema, &[], 0);
259 root
260}
261
262/// Partition an owned-segment slice into a [`SegmentGroupIndexed`] tree according to `schema`.
263///
264/// Equivalent to [`group_segments_indexed`] but accepts `&[OwnedSegment]`.
265pub fn group_owned_segments_indexed<'g>(
266 segments: &[OwnedSegment],
267 schema: &'g [GroupDef<'g>],
268 root_name: &'g str,
269) -> SegmentGroupIndexed<'g> {
270 let borrowed: Vec<Segment<'_>> = segments.iter().map(|s| s.as_borrowed()).collect();
271 group_segments_indexed(&borrowed, schema, root_name)
272}
273
274/// Internal recursive indexed grouping. Returns the number of segments consumed.
275fn group_recursive_indexed<'g>(
276 segments: &[Segment<'_>],
277 parent: &mut SegmentGroupIndexed<'g>,
278 schema: &'g [GroupDef<'g>],
279 stop_triggers: &[&'g str],
280 offset: usize,
281) -> usize {
282 let combined_stop: SmallVec<[&'g str; 16]> = {
283 let mut v: SmallVec<[&'g str; 16]> = SmallVec::from_slice(stop_triggers);
284 for d in schema {
285 if !v.contains(&d.trigger) {
286 v.push(d.trigger);
287 }
288 }
289 v
290 };
291
292 // `span_start` is the absolute index of the first segment in this group.
293 // For child groups the caller pre-seeds `parent.total_span.start` with the
294 // trigger segment position; for the root (or any group with no pre-seeded
295 // trigger) we start at `offset`.
296 let span_start = if !parent.total_span.is_empty() {
297 parent.total_span.start // pre-seeded trigger position
298 } else {
299 offset
300 };
301
302 let mut i = 0;
303 // Track how many children of each definition have been pushed at this level,
304 // so we can stamp `occurrence_index` on each new child.
305 let mut occ_counts: std::collections::HashMap<&'g str, usize> =
306 std::collections::HashMap::new();
307 while i < segments.len() {
308 let tag = segments[i].tag;
309
310 if stop_triggers.iter().copied().any(|t| t == tag) {
311 break;
312 }
313
314 if let Some(def) = schema.iter().find(|d| d.trigger == tag) {
315 let child_offset = offset + i;
316 let occ_idx = {
317 let c = occ_counts.entry(def.name).or_insert(0);
318 let idx = *c;
319 *c += 1;
320 idx
321 };
322 let mut child = SegmentGroupIndexed {
323 definition: def.name,
324 // Pre-seed the trigger segment; the recursive call extends
325 // total_span to cover the full child subtree.
326 total_span: child_offset..child_offset + 1,
327 children: Vec::new(),
328 occurrence_index: occ_idx,
329 };
330 i += 1;
331
332 let consumed = group_recursive_indexed(
333 &segments[i..],
334 &mut child,
335 def.children,
336 &combined_stop,
337 offset + i,
338 );
339 i += consumed;
340
341 parent.children.push(child);
342 } else {
343 i += 1;
344 }
345 }
346
347 // Total span covers everything from the first segment (trigger or first
348 // direct segment) to the last segment consumed in this call.
349 parent.total_span = span_start..(offset + i);
350
351 i
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357 use crate::Span;
358 use crate::model::Element;
359
360 fn seg(tag: &'static str) -> Segment<'static> {
361 Segment {
362 tag,
363 span: Span::new(0, 0),
364 tag_span: Span::new(0, 0),
365 elements: vec![Element::of(&["x"])],
366 }
367 }
368
369 static SCHEMA: &[GroupDef] = &[
370 GroupDef {
371 name: "SG1",
372 trigger: "NAD",
373 children: &[GroupDef {
374 name: "SG2",
375 trigger: "CTA",
376 children: &[],
377 }],
378 },
379 GroupDef {
380 name: "SG3",
381 trigger: "LIN",
382 children: &[],
383 },
384 ];
385
386 #[test]
387 fn root_segments_before_first_trigger() {
388 let segs = vec![seg("UNH"), seg("BGM"), seg("NAD")];
389 let tree = group_segments_indexed(&segs, SCHEMA, "ROOT");
390 // UNH (0) and BGM (1) are direct root segments; NAD (2) is in SG1.
391 let direct: Vec<_> = tree.direct_segment_indices().collect();
392 assert_eq!(direct, vec![0, 1], "UNH + BGM should be direct in root");
393 assert_eq!(tree.children.len(), 1);
394 assert_eq!(tree.children[0].definition, "SG1");
395 }
396
397 #[test]
398 fn repeated_trigger_creates_multiple_children() {
399 let segs = vec![seg("UNH"), seg("NAD"), seg("NAD"), seg("UNT")];
400 let tree = group_segments_indexed(&segs, SCHEMA, "ROOT");
401 // Two NAD triggers → two SG1 children
402 assert_eq!(
403 tree.children
404 .iter()
405 .filter(|c| c.definition == "SG1")
406 .count(),
407 2
408 );
409 }
410
411 #[test]
412 fn repeated_trigger_occurrence_index_is_stamped() {
413 let segs = vec![seg("NAD"), seg("NAD"), seg("NAD")];
414 let tree = group_segments_indexed(&segs, SCHEMA, "ROOT");
415 let indices: Vec<_> = tree.children.iter().map(|c| c.occurrence_index).collect();
416 assert_eq!(indices, vec![0, 1, 2]);
417 }
418
419 #[test]
420 fn nested_child_groups() {
421 let segs = vec![seg("NAD"), seg("CTA"), seg("CTA")];
422 let tree = group_segments_indexed(&segs, SCHEMA, "ROOT");
423 let sg1 = &tree.children[0];
424 assert_eq!(sg1.definition, "SG1");
425 // Two CTA triggers → two SG2 children inside SG1
426 assert_eq!(sg1.children.len(), 2);
427 assert!(sg1.children.iter().all(|c| c.definition == "SG2"));
428 }
429
430 #[test]
431 fn total_span_covers_all_segments() {
432 let segs = vec![seg("UNH"), seg("NAD"), seg("CTA")];
433 let tree = group_segments_indexed(&segs, SCHEMA, "ROOT");
434 // Root span covers all 3 segments
435 let all_tags: Vec<_> = segs[tree.total_span.clone()]
436 .iter()
437 .map(|s| s.tag)
438 .collect();
439 assert!(all_tags.contains(&"UNH"));
440 assert!(all_tags.contains(&"NAD"));
441 assert!(all_tags.contains(&"CTA"));
442 }
443}