Profile Packs

Author, compose, merge, and filter business-rule packs with ProfileRulePack, including group-scoped rules.

On this page
  1. What is a profile pack?
  2. Creating a pack
  3. Plugging into ValidationContext
  4. Merging packs
    1. extend_from — inherit a base pack, then append specialisations
    2. merge_with_override — delta upgrades (override specific rules by ID)
  5. Scoping by message type
  6. Filtering reports by rule ID prefix
  7. Mapping violations to application errors
  8. Implementing ProfileRule as a struct
  9. Group-scoped rules
    1. Convenience builders
    2. Custom group rule closure
  10. Pack introspection
  11. Best practices
  12. Next steps

ProfileRulePack is the primary extension point for downstream MIG/profile crates and trading-partner–specific validation logic. Packs are authored using only public APIs, can be composed from multiple sources, and plugged into any ValidationContext.


What is a profile pack?

A profile rule pack is a named collection of validation rules scoped to one or more EDIFACT message types. Each rule is a closure that receives a segment slice and a &mut Vec<ValidationIssue> into which it pushes violations. The closure pushes nothing when the segments pass.

Packs can be:

  • Authored in downstream crates (your application or a MIG library)
  • Merged from multiple sources into a combined pack
  • Filtered by rule ID prefix for fine-grained reporting
  • Scoped to specific message types (ORDERS, INVOIC, …)

Creating a pack

use edifact_rs::{ProfileRulePack, ValidationIssue, ValidationSeverity};

let pack = ProfileRulePack::new("ORDERS-RULES")
    .for_message_type("ORDERS")
    .with_stateless_rule_fn(|segments, issues| {
        // Find the BGM segment; skip if absent
        let Some(bgm) = segments.iter().find(|s| s.tag == "BGM") else { return };
        let Some(code) = bgm.get_element(0).and_then(|e| e.get_component(0)) else { return };

        // Rule: only codes 220 (original) and 231 (quote) are accepted
        if !matches!(code, "220" | "231") {
            issues.push(
                ValidationIssue::new(
                    ValidationSeverity::Error,
                    format!("unsupported BGM document code '{code}'"),
                )
                .with_rule_id("ORDERS-BGM-P001")
                .with_segment("BGM")
                .with_element_index(0)
                .with_suggestion("Use document code 220 (original order) or 231 (quotation)"),
            );
        }
    })
    .with_stateless_rule_fn(|segments, issues| {
        // Rule: a buyer NAD is mandatory
        let has_buyer = segments
            .iter()
            .filter(|s| s.tag == "NAD")
            .any(|s| s.element_str(0) == Some("BY"));
        if !has_buyer {
            issues.push(
                ValidationIssue::new(ValidationSeverity::Error, "buyer NAD+BY is required")
                    .with_rule_id("ORDERS-NAD-P001")
                    .with_segment("NAD")
                    .with_element_index(0),
            );
        }
    });

Plugging into ValidationContext

use edifact_rs::{ValidationContext, from_bytes};

# use edifact_rs::{ProfileRulePack, ValidationIssue, ValidationSeverity};
# let pack = ProfileRulePack::new("ORDERS-RULES").for_message_type("ORDERS");
let segs: Vec<_> = from_bytes(b"UNH+1+ORDERS:D:96A:UN'BGM+220+PO-4711+9'UNT+3+1'")
    .collect::<Result<_, _>>()?;

let report = ValidationContext::builder()
    .with_profile_pack(pack)
    .build()
    .validate_lenient(&segs);

println!("{} error(s)", report.errors().len());
# Ok::<(), edifact_rs::EdifactError>(())

Merging packs

Two composition strategies are available depending on whether you want to layer rules or override specific ones:

extend_from — inherit a base pack, then append specialisations

use edifact_rs::{ProfileRulePack, ValidationIssue, ValidationSeverity};

let base_pack = ProfileRulePack::new("ORDERS-BASE")
    .for_message_type("ORDERS")
    .with_named_stateless_rule_fn("BASE-BGM", |_segs, _issues| {
        // shared baseline rules …
    });

// Partner-specific pack that prepends the base rules before its own:
let partner_pack = ProfileRulePack::new("ORDERS-ACME")
    .for_message_type("ORDERS")
    .with_stateless_rule_fn(|_segs, _issues| {
        // ACME-specific rules — appended after base rules …
    })
    .extend_from(&base_pack)?;  // base rules run first
# Ok::<(), edifact_rs::EdifactError>(())

merge_with_override — delta upgrades (override specific rules by ID)

When you have a versioned base and want to ship a delta that replaces only changed rules (identified by their stable rule ID):

use edifact_rs::{ProfileRulePack, ValidationIssue, ValidationSeverity};

let base = ProfileRulePack::new("ORDERS-5.4")
    .for_message_type("ORDERS")
    .with_named_stateless_rule_fn("PROFILE-4711-BGM-M", |_segs, _issues| {
        // 5.4 BGM rule …
    });

let delta = ProfileRulePack::new("ORDERS-5.5-delta")
    .with_named_stateless_rule_fn("PROFILE-4711-BGM-M", |_segs, _issues| {
        // updated 5.5 BGM rule — replaces the 5.4 version
    });

// Result has rule_count() == 1 (the 5.4 rule is replaced, not duplicated)
let result = base.merge_with_override(delta)?;
assert_eq!(result.rule_count(), 1);
# Ok::<(), edifact_rs::EdifactError>(())

Both methods return Result<ProfileRulePack, EdifactError> and fail with EdifactError::IncompatibleReleaseScopes when the two packs carry different release values.


Scoping by message type

Use .for_message_type("TYPE") to restrict a pack to a specific message type. Packs check the UNH segment element 1 component 0 (the message identifier):

# use edifact_rs::{ProfileRulePack, ValidationIssue, ValidationSeverity};

// This pack's rules run ONLY when the message type is "INVOIC"
let invoic_pack = ProfileRulePack::new("INVOIC-RULES")
    .for_message_type("INVOIC")
    .with_stateless_rule_fn(|_segs, _issues| {
        // Will not run for ORDERS, ORDERS, etc.
    });

// Without for_message_type, rules run for all message types:
let universal_pack = ProfileRulePack::new("UNIVERSAL")
    .with_stateless_rule_fn(|_segs, _issues| {});

Call .for_message_type multiple times to include multiple types:

# use edifact_rs::{ProfileRulePack, ValidationIssue, ValidationSeverity};
let multi_pack = ProfileRulePack::new("TRADE-DOCS")
    .for_message_type("ORDERS")
    .for_message_type("ORDRSP")
    .for_message_type("INVOIC")
    .with_stateless_rule_fn(|_segs, _issues| {});

Filtering reports by rule ID prefix

Assign stable, namespaced rule IDs and filter them independently:

# use edifact_rs::{ProfileRulePack, ValidationContext, ValidationIssue, ValidationSeverity, from_bytes};
let segs: Vec<_> = from_bytes(b"UNH+1+ORDERS:D:96A:UN'BGM+220+PO-4711+9'UNT+3+1'")
    .collect::<Result<_, _>>()?;

let pack = ProfileRulePack::new("ORDERS")
    .for_message_type("ORDERS")
    .with_stateless_rule_fn(|_segs, issues| {
        issues.push(
            ValidationIssue::new(ValidationSeverity::Warning, "demo warning")
                .with_rule_id("ORDERS-DOC-P001"),
        );
    })
    .with_stateless_rule_fn(|_segs, issues| {
        issues.push(
            ValidationIssue::new(ValidationSeverity::Info, "demo info")
                .with_rule_id("ORDERS-REF-P001"),
        );
    });

let report = ValidationContext::builder()
    .with_profile_pack(pack)
    .build()
    .validate_lenient(&segs);

// All findings:
println!("total: {}", report.total_issues());

// Only document-level findings:
let doc_issues = report.filter_by_rule_prefix("ORDERS-DOC-");
println!("document issues: {}", doc_issues.total_issues());

// Only reference-level findings:
let ref_issues = report.filter_by_rule_prefix("ORDERS-REF-");
println!("reference issues: {}", ref_issues.total_issues());

// Exact rule ID lookup (returns a lazy iterator; use count() or collect()):
let p001_count = report.issues_for_rule_id("ORDERS-DOC-P001").count();
println!("ORDERS-DOC-P001 findings: {p001_count}");
# Ok::<(), edifact_rs::EdifactError>(())

Mapping violations to application errors

Instead of returning ValidationIssue to your application layer, map rule IDs to your own domain error type:

use edifact_rs::{ProfileRulePack, ValidationContext, ValidationIssue, ValidationSeverity, from_bytes};

#[derive(Debug)]
enum TradeError {
    UnsupportedDocumentCode(String),
    MissingBuyerNad,
}

fn validate_orders(input: &[u8]) -> Result<(), Vec<TradeError>> {
    let segs: Vec<_> = from_bytes(input)
        .collect::<Result<_, _>>()
        .map_err(|_| vec![])?;

    let pack = ProfileRulePack::new("ORDERS")
        .for_message_type("ORDERS")
        .with_stateless_rule_fn(|segs, issues| {
            let Some(code) = segs.iter()
                .find(|s| s.tag == "BGM")
                .and_then(|s| s.element_str(0))
            else { return };
            if !matches!(code, "220" | "231") {
                issues.push(
                    ValidationIssue::new(ValidationSeverity::Error, format!("code {code}"))
                        .with_rule_id("ORDERS-DOC-P001"),
                );
            }
        })
        .with_stateless_rule_fn(|segs, issues| {
            let has_buyer = segs.iter()
                .filter(|s| s.tag == "NAD")
                .any(|s| s.element_str(0) == Some("BY"));
            if !has_buyer {
                issues.push(
                    ValidationIssue::new(ValidationSeverity::Error, "missing NAD+BY")
                        .with_rule_id("ORDERS-NAD-P001"),
                );
            }
        });

    let report = ValidationContext::builder()
        .with_profile_pack(pack)
        .build()
        .validate_lenient(&segs);

    if report.is_valid() {
        return Ok(());
    }

    let errors: Vec<TradeError> = report
        .errors()
        .iter()
        .filter_map(|issue| match issue.rule_id.as_deref() {
            Some("ORDERS-DOC-P001") => {
                Some(TradeError::UnsupportedDocumentCode(issue.message.clone()))
            }
            Some("ORDERS-NAD-P001") => Some(TradeError::MissingBuyerNad),
            _ => None,
        })
        .collect();

    Err(errors)
}

See cookbook_profile_error_mapping.rs for a complete example with a custom Validator implementation alongside profile packs.


Implementing ProfileRule as a struct

For rules with shared state or complex initialization, implement the ProfileRule trait directly:

use edifact_rs::{
    ProfileRule, ProfileRulePack, ValidationIssue, ValidationRuleContext,
    ValidationSeverity, Segment,
};

struct AllowedCodesRule {
    allowed: Vec<String>,
}

impl ProfileRule for AllowedCodesRule {
    fn evaluate(
        &self,
        segments: &[Segment<'_>],
        _context: &ValidationRuleContext<'_>,
        issues: &mut Vec<ValidationIssue>,
    ) {
        let Some(bgm) = segments.iter().find(|s| s.tag == "BGM") else { return };
        let Some(code) = bgm.element_str(0) else { return };
        if !self.allowed.iter().any(|a| a == code) {
            issues.push(
                ValidationIssue::new(
                    ValidationSeverity::Error,
                    format!("code '{code}' is not in the allowed list"),
                )
                .with_rule_id("ORDERS-ALLOWED-CODE"),
            );
        }
    }
}

let pack = ProfileRulePack::new("ORDERS")
    .with_rule(AllowedCodesRule {
        allowed: vec!["220".into(), "231".into()],
    });

ProfileRule requires Send + Sync so packs can be shared across threads (e.g. when validating in a spawn_blocking worker).


Group-scoped rules

Group-scoped rules fire once per occurrence of a named segment group, giving each group instance its own isolated segment list. This lets you enforce intra-group invariants (e.g. every SG5 must contain at least one LIN) without writing manual tree-walking code.

Note on GroupDef: build a leaf group with GroupDef::new(name, trigger) and a nesting one with GroupDef::with_children(name, trigger, children). Both are const, so a schema can be a static; both borrow, so a schema read from a MIG at startup works too. The fields stay public for exhaustive construction.

Convenience builders

use edifact_rs::{ProfileRulePack, group::GroupDef};

// Schema: SG5 contains LIN and zero or more SG6 children.
static SG6: &[GroupDef] = &[GroupDef::new("SG6", "RFF")];
static ORDERS_SCHEMA: &[GroupDef] = &[GroupDef::with_children("SG5", "LIN", SG6)];

let pack = ProfileRulePack::new("ORDERS-GROUPS")
    .for_message_type("ORDERS")
    // Require LIN in every SG5 occurrence:
    .require_segment_in_group("SG5", "LIN", "ORDERS-SG5-LIN-M")
    // Forbid a legacy segment in SG5:
    .forbid_segment_in_group("SG5", "OLD", "ORDERS-SG5-OLD-F")
    // Require qualifier "1" on LIN within SG5 (element 0, component 0):
    .require_qualifier_in_group("SG5", "LIN", 0, 0, "1", "ORDERS-SG5-LIN-Q1");

Use group::group_segments_indexed to build the tree, then pass it to ctx.validate_lenient_grouped(&tree, &segs).

Custom group rule closure

For full control, supply a closure via with_scoped_group_rule_fn(group_scope, rule_id, closure). The closure receives (group: &SegmentGroupIndexed, segs: &[Segment], ctx: &ValidationRuleContext, issues: &mut Vec<ValidationIssue>):

use edifact_rs::{
    ProfileRulePack, ValidationIssue, ValidationSeverity,
    group::SegmentGroupIndexed,
    ValidationRuleContext,
    Segment,
};

let pack = ProfileRulePack::new("ORDERS-GROUPS")
    .for_message_type("ORDERS")
    .with_scoped_group_rule_fn(
        "SG5",              // fires only inside SG5 occurrences
        "ORDERS-SG5-QTY-M", // stable rule id
        |_group: &SegmentGroupIndexed<'_>,
         segs: &[Segment<'_>],
         _ctx: &ValidationRuleContext<'_>,
         issues: &mut Vec<ValidationIssue>| {
            if !segs.iter().any(|s| s.tag == "QTY") {
                issues.push(
                    ValidationIssue::new(
                        ValidationSeverity::Error,
                        "every SG5 must contain a QTY segment",
                    )
                    .with_rule_id("ORDERS-SG5-QTY-M")
                    .with_segment_group("SG5"),
                );
            }
        },
    );

Group rules are run via validate_lenient_grouped / validate_strict_grouped on ValidationContext. See Validation for how to supply a GroupDef schema and run the group pass.


Pack introspection

# use edifact_rs::ProfileRulePack;
let pack = ProfileRulePack::new("MY-PACK")
    .for_message_type("ORDERS")
    .with_stateless_rule_fn(|_segs, _issues| {})
    .with_stateless_rule_fn(|_segs, _issues| {})
    .require_segment_in_group("SG5", "LIN", "SG5-LIN-M");

println!("name:        {}", pack.name());              // "MY-PACK"
println!("rule count:  {}", pack.rule_count());        // 2
println!("group rules: {}", pack.group_rule_count()); // 1
println!("types:       {:?}", pack.message_types().collect::<Vec<_>>()); // ["ORDERS"]

Best practices

  1. Use stable rule IDs — prefix with your pack name: "ORDERS-DOC-P001", not "P001". This allows downstream code to filter and map rules independently.
  2. Push nothing for passing rules — rules receive a &mut Vec<ValidationIssue>; simply not pushing is the zero-cost pass path.
  3. Scope packs to message types — unscoped packs run for every message, even when the logic is type-specific.
  4. Compose with extend_from / merge_with_overrideextend_from prepends a shared base pack (base rules run first); merge_with_override replaces rules by stable ID for versioned delta upgrades. Both are cleaner than registering many loose packs separately.
  5. Keep rules small and focused — one rule per concern makes reporting and debugging easier.
  6. Use with_stateless_rule_fn for simple rules — when you do not need the [ValidationRuleContext], the stateless variant is cleaner; reach for with_rule_fn only when you need per-call metadata.

Next steps

  • Validation — multi-layer ValidationContext and the Validator trait
  • Streaming — progressive per-window validation over reader streams
  • Diagnostics — human-friendly rendering of ValidationReport