defmodule HL7v2.Validation do @moduledoc """ Validates HL7v2 typed messages. Opt-in validation that returns accumulated errors without blocking parsing. Call `validate/1` on a `HL7v2.TypedMessage` to check message-level and field-level rules. ## Examples {:ok, msg} = HL7v2.parse(text, mode: :typed) :ok = HL7v2.Validation.validate(msg) {:error, errors} = HL7v2.Validation.validate(invalid_msg) # errors is a list of %{level: :error | :warning, location: ..., field: ..., message: ...} """ alias HL7v2.MessageDefinition alias HL7v2.TypedMessage alias HL7v2.Validation.{MessageRules, FieldRules} @doc """ Validates a typed message. Returns `:ok` when no issues are found, `{:ok, warnings}` when only warnings are present (non-fatal), or `{:error, errors}` when errors exist. Runs three validation passes: 1. **Message rules** — MSH presence, required MSH fields 2. **Structural rules** — segment ordering, group anchors, cardinality for all 186 official v2.5.1 message structures. Unsupported structures produce a warning in lenient mode or an error in strict mode. 3. **Field rules** — required fields, max repetitions per segment Each error map has: - `:level` — `:error` or `:warning` - `:location` — segment identifier (e.g., `"MSH"`, `"PID"`, `"message"`) - `:field` — field name atom or `nil` for structural issues - `:message` — human-readable description ## Options - `:mode` — `:lenient` (default) or `:strict`. In lenient mode, ordering and cardinality issues are warnings. In strict mode, all structural violations are errors. - `:validate_tables` — `true` to check coded fields against HL7-defined tables. Defaults to `false`. """ @spec validate(TypedMessage.t(), keyword()) :: :ok | {:error, [map()]} | {:ok, [map()]} def validate(%TypedMessage{} = msg, opts \\ []) do mode = Keyword.get(opts, :mode, :lenient) context = extract_trigger_context(msg.segments) field_opts = Keyword.take(opts, [:validate_tables, :mode]) |> Keyword.put(:mode, mode) |> Keyword.put(:context, context) all = MessageRules.check(msg) ++ structure_errors(msg, mode) ++ Enum.flat_map(msg.segments, &FieldRules.check(&1, field_opts)) errors = Enum.filter(all, &(&1.level == :error)) warnings = Enum.filter(all, &(&1.level == :warning)) case {errors, warnings} do {[], []} -> :ok {[], warnings} -> {:ok, warnings} {errors, warnings} -> {:error, errors ++ warnings} end end defp structure_errors(%TypedMessage{segments: segments}, mode) do structure_name = extract_message_structure(segments) segment_ids = extract_segment_ids(segments) # Prefer structural validation (order + groups) when definition exists case HL7v2.Standard.MessageStructure.get(structure_name) do %{} = struct_def -> HL7v2.Validation.Structural.validate(struct_def, segment_ids, mode: mode) nil -> # No group-aware structure definition exists. # In strict mode, unsupported structures are errors. case MessageDefinition.validate_structure(structure_name, segment_ids) do :ok -> [] {:error, results} -> if mode == :strict do Enum.map(results, fn %{level: :warning} = r -> %{r | level: :error} r -> r end) else results end end end end defp extract_message_structure([%HL7v2.Segment.MSH{message_type: %HL7v2.Type.MSG{} = msg} | _]) do # Always canonicalize via message_code + trigger_event first. MSH-9.3 may # carry a non-canonical alias (e.g., "SIU_S14" instead of "SIU_S12") that # won't match the structure registry. Fall back to MSH-9.3 only when # canonical resolution yields a default "CODE_EVENT" that isn't registered. canonical = canonicalize_structure(msg.message_code, msg.trigger_event) cond do canonical != nil -> canonical msg.message_structure != nil -> msg.message_structure true -> nil end end defp extract_message_structure(_), do: nil defp canonicalize_structure(code, event) when is_binary(code) and is_binary(event) do resolved = MessageDefinition.canonical_structure(code, event) cond do # Canonical resolution found a registered structure HL7v2.Standard.MessageStructure.get(resolved) != nil -> resolved # Fallback: the bare message_code is itself a registered structure. # Handles cases like ACK^A01^ACK_A01 — ACK_A01 isn't registered, but # ACK is. Also covers ACK^A02^ACK_A02, ACK^A08^ACK_A08, etc. HL7v2.Standard.MessageStructure.get(code) != nil -> code true -> nil end end defp canonicalize_structure(_code, _event), do: nil defp extract_trigger_context([%HL7v2.Segment.MSH{message_type: %HL7v2.Type.MSG{} = msg} | _]) do %{trigger_event: msg.trigger_event, message_code: msg.message_code} end defp extract_trigger_context(_), do: %{} defp extract_segment_ids(segments) do Enum.map(segments, fn %HL7v2.Segment.ZXX{segment_id: id} -> id %{__struct__: module} -> module.segment_id() {name, _fields} when is_binary(name) -> name end) end end