feat: initial text-akoma-normalize v0.1.0 (text.akoma_normalize@0.1.0)

First F∆I capability module that normalises heterogeneous legal
document sources to a structured representation aligned with
Akoma Ntoso 1.0 (OASIS LegalDocML).

v0.1.0 ships one adapter:

  - gesetze-im-internet (BMJ) XML — the BMJ XML schema with
    jurabk / enbez / titel / textdaten elements. Lossy but
    deterministic: SHA-256 of source + SHA-256 of canonicalised
    payload land in the output so the Hub audit log can verify
    that downstream evaluations were computed against the same
    bytes the auditor sees.

Reserved for next versions, without an interface break:

  - 0.2: landesrecht-berlin HTML adapter
  - 0.3: EUR-Lex FORMEX-4 adapter
  - 0.4+: emit akoma_xml (full Akoma Ntoso roundtrip)

ELI synthesis in 0.1 is a deterministic eli/bund/<jurabk>/<para>
fallback because Germany lacks an upstream Bund-level ELI
registrar. Replace once BMJ publishes ELI URIs upstream.

Pure in-WASM (quick-xml + sha2), zero filesystem, zero network —
the empty permissions list in module.yaml makes that explicit
and is enforced by the Hub.

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-06-18 11:24:51 +02:00
commit b73b3e4b73
6 changed files with 1025 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
target/
**/*.rs.bk
.DS_Store

525
Cargo.lock generated Normal file
View file

@ -0,0 +1,525 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "bitflags"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "chain-module-sdk"
version = "0.1.2"
source = "git+https://git.flemming.ai/fai/chain-module-sdk-rust.git?branch=main#98e98e9371f7409560a1ef08bc0923d9a2506449"
dependencies = [
"chain-module-sdk-macros",
"serde",
"serde_json",
"thiserror",
"wit-bindgen",
]
[[package]]
name = "chain-module-sdk-macros"
version = "0.1.2"
source = "git+https://git.flemming.ai/fai/chain-module-sdk-rust.git?branch=main#98e98e9371f7409560a1ef08bc0923d9a2506449"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown 0.17.1",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c83bff1d572d6b9aeef67ddfc8448e4a3737909cb28e81f97c791b9018703e52"
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "log"
version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
[[package]]
name = "memchr"
version = "2.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quick-xml"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe"
dependencies = [
"memchr",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "smallvec"
version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "spdx"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e17e880bafaeb362a7b751ec46bdc5b61445a188f80e0606e68167cd540fa3"
dependencies = [
"smallvec",
]
[[package]]
name = "syn"
version = "2.0.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "text_akoma_normalize"
version = "0.1.0"
dependencies = [
"chain-module-sdk",
"quick-xml",
"serde",
"serde_json",
"sha2",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasm-encoder"
version = "0.220.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e913f9242315ca39eff82aee0e19ee7a372155717ff0eb082c741e435ce25ed1"
dependencies = [
"leb128",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.220.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "185dfcd27fa5db2e6a23906b54c28199935f71d9a27a1a27b3a88d6fee2afae7"
dependencies = [
"anyhow",
"indexmap",
"serde",
"serde_derive",
"serde_json",
"spdx",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.220.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d07b6a3b550fefa1a914b6d54fc175dd11c3392da11eee604e6ffc759805d25"
dependencies = [
"ahash",
"bitflags",
"hashbrown 0.14.5",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a2b3e15cd6068f233926e7d8c7c588b2ec4fb7cc7bf3824115e7c7e2a8485a3"
dependencies = [
"wit-bindgen-rt",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b632a5a0fa2409489bd49c9e6d99fcc61bb3d4ce9d1907d44662e75a28c71172"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rt"
version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7947d0131c7c9da3f01dfde0ab8bd4c4cf3c5bd49b6dba0ae640f1fa752572ea"
dependencies = [
"bitflags",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4329de4186ee30e2ef30a0533f9b3c123c019a237a7c82d692807bf1b3ee2697"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "177fb7ee1484d113b4792cc480b1ba57664bbc951b42a4beebe573502135b1fc"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.220.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b505603761ed400c90ed30261f44a768317348e49f1864e82ecdc3b2744e5627"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.220.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae2a7999ed18efe59be8de2db9cb2b7f84d88b27818c79353dfc53131840fe1a"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]]
name = "zerocopy"
version = "0.8.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

46
Cargo.toml Normal file
View file

@ -0,0 +1,46 @@
# Standalone Cargo.toml — targets wasm32-wasip2.
#
# Build with:
# cargo build --release --target wasm32-wasip2
[package]
name = "text_akoma_normalize"
version = "0.1.0"
edition = "2024"
authors = ["Dr. Stefan Flemming <chain@flemming.ai>"]
license = "Apache-2.0"
publish = false
description = "F∆I module — normalise heterogeneous legal-document sources to a structured Akoma-Ntoso-aligned representation"
repository = "https://git.flemming.ws/chain-modules/text-akoma-normalize"
rust-version = "1.85"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
chain-module-sdk = { git = "https://git.flemming.ai/fai/chain-module-sdk-rust.git", branch = "main" }
serde = { version = "1", features = ["derive"] }
quick-xml = "0.36"
sha2 = "0.10"
[dev-dependencies]
serde_json = "1"
[profile.release]
opt-level = "s"
lto = true
codegen-units = 1
strip = "symbols"
[lints.rust]
unsafe_op_in_unsafe_fn = "warn"
[lints.clippy]
unwrap_used = "deny"
expect_used = "deny"
panic = "deny"
print_stdout = "deny"
print_stderr = "deny"
[workspace]
# Empty workspace table so this package is NOT picked up by a parent.

90
module.yaml Normal file
View file

@ -0,0 +1,90 @@
schema_version: 3
provider: chain
name: text-akoma-normalize
version: 0.1.0
# Capability provided by this module.
#
# Normalises heterogeneous legal-document sources (gesetze-im-internet
# XML, EUR-Lex FORMEX, landesrecht-berlin HTML) to a structured
# representation aligned with Akoma Ntoso 1.0 (OASIS LegalDocML).
#
# v0.1.0 ships the gesetze-im-internet (BMJ) XML adapter only.
# HTML and FORMEX adapters are planned for 0.2/0.3 without an
# interface break — the output schema is forward-compatible.
provides:
- capability: text.akoma_normalize
version: 0.1.0
inputs:
content:
type: bytes
description:
en: |
Source bytes of one legal-document file. Supported MIME types
in v0.1.0:
application/xml (gesetze-im-internet BMJ schema)
text/xml (alias)
Planned for 0.2+:
text/html (landesrecht-berlin)
application/x-formex+xml (EUR-Lex FORMEX-4)
de: |
Rohbytes einer Rechtsdokument-Datei. Unterstützte MIME-Typen
in v0.1.0:
application/xml (gesetze-im-internet BMJ-Schema)
text/xml (alias)
Geplant ab 0.2:
text/html (landesrecht-berlin)
application/x-formex+xml (EUR-Lex FORMEX-4)
mime:
type: text
optional: true
description:
en: |
Optional MIME type hint. If omitted, the module inspects
the leading bytes to choose an adapter. Pass explicitly
when the source content-type is known (e.g. from
http.request's `content_type` output).
de: |
Optionaler MIME-Typ-Hinweis. Ohne Hinweis erkennt das Modul
den Typ aus den Anfangsbytes. Explizit übergeben, wenn der
Quell-Content-Type bekannt ist (z.B. aus http.request's
Output `content_type`).
outputs:
norm:
type: json
description:
en: |
JSON-encoded NormalizedNorm:
{
schema_version: "0.1",
adapter: "gesetze-im-internet-xml",
adapter_version: "0.1.0",
eli: "eli/bund/<jurabk>/<paragraph>",
jurabk: "GewO",
paragraph: "§ 14",
title: "Anzeigepflicht",
paragraphs: [{ number, text }],
source_sha256: "...",
normalized_sha256: "...",
akoma_xml: "<akomaNtoso>...</akomaNtoso>" // v0.2+, omitted in 0.1
}
eli is synthetic in 0.1 — Germany lacks an official Bund-level
ELI registrar. Synthesis rule documented in MODULE.md.
de: |
JSON-codiertes NormalizedNorm (Felder s. EN).
eli ist in 0.1 synthetisch — Deutschland hat noch keinen offiziellen
ELI-Registrar auf Bundesebene. Syntheseregel in MODULE.md.
# Permissions required.
#
# Pure in-WASM normalisation: quick-xml + sha2. No filesystem,
# no network. The empty list makes this explicit; the hub enforces it.
permissions: []
# MIME-type allow-list for the `content` input. Studio reads this
# through ModuleInfoResponse.accepts_mime as the OS file-picker filter.
accepts_mime:
- application/xml
- text/xml

4
rust-toolchain.toml Normal file
View file

@ -0,0 +1,4 @@
[toolchain]
channel = "1.86"
targets = ["wasm32-wasip2"]
components = ["rustfmt", "clippy"]

357
src/lib.rs Normal file
View file

@ -0,0 +1,357 @@
//! `text.akoma_normalize` module — normalise heterogeneous legal
//! document sources to a structured representation aligned with
//! Akoma Ntoso 1.0 (OASIS LegalDocML).
//!
//! # Capability
//!
//! - **Input:**
//! * `content` — `bytes` payload of one legal-document file.
//! * `mime` — optional `text` MIME hint. If omitted, the module
//! sniffs leading bytes.
//! - **Output:**
//! * `norm` — `json` payload conforming to the `NormalizedNorm`
//! schema below.
//!
//! # Adapter coverage (v0.1.0)
//!
//! Ships ONE adapter: gesetze-im-internet (BMJ) XML schema.
//! HTML (landesrecht-berlin) and FORMEX (EUR-Lex) adapters follow
//! in 0.2 / 0.3 without an interface break — the `adapter` field
//! tags which adapter ran, so downstream consumers can route.
//!
//! # ELI synthesis (v0.1.0 caveat)
//!
//! Germany lacks an official Bundesebene ELI registrar. v0.1.0
//! synthesises a deterministic ELI from the source metadata
//! (`jurabk` + paragraph label), prefixed `eli/bund/` to mark it
//! synthetic. Replace once BMJ publishes ELI URIs upstream.
//!
//! # Stability
//!
//! `0.1.0` ships as `alpha` in the store index. The output schema
//! is intentionally forward-compatible: future adapters add
//! fields, never rename or repurpose.
#![allow(clippy::result_large_err)]
use chain_module_sdk::prelude::*;
use quick_xml::events::Event;
use quick_xml::reader::Reader;
use serde::Serialize;
use sha2::{Digest, Sha256};
const XML_MIME: &str = "application/xml";
const XML_MIME_ALT: &str = "text/xml";
const ADAPTER_GII: &str = "gesetze-im-internet-xml";
const ADAPTER_GII_VERSION: &str = "0.1.0";
const SCHEMA_VERSION: &str = "0.1";
#[derive(Debug, Serialize)]
struct NormalizedNorm {
schema_version: &'static str,
adapter: &'static str,
adapter_version: &'static str,
eli: String,
jurabk: Option<String>,
paragraph: Option<String>,
title: Option<String>,
paragraphs: Vec<Paragraph>,
source_sha256: String,
normalized_sha256: String,
/// Akoma Ntoso XML emission is reserved for 0.2+. v0.1 omits.
#[serde(skip_serializing_if = "Option::is_none")]
akoma_xml: Option<String>,
}
#[derive(Debug, Serialize)]
struct Paragraph {
/// Absatz number as it appears in the source (e.g. "(1)", "(2)").
/// Empty when the source provides no numbering.
number: String,
text: String,
}
#[fai_module]
pub fn invoke(_ctx: Context, inputs: Inputs) -> Result<Outputs, ModuleError> {
let bytes = inputs.require_bytes("content")?;
let mime_hint_lower: Option<String> =
inputs.get("mime").and_then(|p| match p {
Payload::Text(s) => Some(s.to_ascii_lowercase()),
_ => None,
});
let mime = mime_hint_lower
.or_else(|| sniff_mime(&bytes.data))
.unwrap_or_else(|| bytes.mime_type.clone());
let norm = match mime.as_str() {
XML_MIME | XML_MIME_ALT => normalize_gii_xml(&bytes.data)?,
other => {
return Err(ModuleError::invalid_input(format!(
"unsupported MIME in v0.1.0: {other} \
(supported: {XML_MIME}, {XML_MIME_ALT}; \
HTML/FORMEX adapters land in 0.2+)",
)));
}
};
Outputs::new().with_json("norm", &norm)
}
/// Naive sniff: if the buffer starts with `<?xml` or `<dokumente`,
/// we call it XML. Real adapter dispatch happens in `invoke` once
/// MIME is known.
fn sniff_mime(data: &[u8]) -> Option<String> {
let head = data.get(..256.min(data.len()))?;
let head_str = core::str::from_utf8(head).ok()?.trim_start();
if head_str.starts_with("<?xml") || head_str.starts_with("<dokumente") {
Some(XML_MIME.to_string())
} else {
None
}
}
/// gesetze-im-internet (BMJ) XML schema adapter.
///
/// Extracts the well-known metadata keys (`jurabk`, `enbez`,
/// `titel`) and joins the body text. Forward-compatible: unknown
/// elements are silently skipped, so a schema extension upstream
/// does not break this adapter.
fn normalize_gii_xml(data: &[u8]) -> Result<NormalizedNorm, ModuleError> {
let source_sha = hex_sha256(data);
let mut reader = Reader::from_reader(data);
reader.config_mut().trim_text(true);
let mut jurabk: Option<String> = None;
let mut paragraph: Option<String> = None;
let mut title: Option<String> = None;
let mut text_buffer = String::new();
let mut in_text = false;
let mut current_tag: Option<Vec<u8>> = None;
let mut buf = Vec::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(e)) => {
let name = e.name().as_ref().to_vec();
if name == b"text" || name == b"textdaten" {
in_text = true;
}
current_tag = Some(name);
}
Ok(Event::End(e)) => {
let name_owned = e.name();
let name = name_owned.as_ref();
if name == b"text" || name == b"textdaten" {
in_text = false;
}
current_tag = None;
}
Ok(Event::Text(t)) => {
let text = t.unescape().map_err(xml_err)?.into_owned();
if in_text {
if !text_buffer.is_empty() {
text_buffer.push('\n');
}
text_buffer.push_str(&text);
} else if let Some(tag) = current_tag.as_deref() {
match tag {
b"jurabk" if jurabk.is_none() => jurabk = Some(text),
b"enbez" if paragraph.is_none() => paragraph = Some(text),
b"titel" if title.is_none() => title = Some(text),
_ => {}
}
}
}
Ok(Event::Eof) => break,
Err(e) => return Err(xml_err(e)),
_ => {}
}
buf.clear();
}
let paragraphs = split_paragraphs(&text_buffer);
let eli = synthesise_eli(jurabk.as_deref(), paragraph.as_deref());
let normalized_payload = format!(
"{}|{}|{}|{}",
eli,
jurabk.as_deref().unwrap_or(""),
paragraph.as_deref().unwrap_or(""),
text_buffer,
);
let normalized_sha = hex_sha256(normalized_payload.as_bytes());
Ok(NormalizedNorm {
schema_version: SCHEMA_VERSION,
adapter: ADAPTER_GII,
adapter_version: ADAPTER_GII_VERSION,
eli,
jurabk,
paragraph,
title,
paragraphs,
source_sha256: source_sha,
normalized_sha256: normalized_sha,
akoma_xml: None,
})
}
/// Split the joined text into Absatz units when leading
/// `(N)` markers are present. Sources without numbered Absatzes
/// land as a single Paragraph with empty number.
fn split_paragraphs(text: &str) -> Vec<Paragraph> {
let text = text.trim();
if text.is_empty() {
return Vec::new();
}
let mut out: Vec<Paragraph> = Vec::new();
let mut current = Paragraph { number: String::new(), text: String::new() };
for line in text.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Some(num) = leading_paragraph_marker(line) {
if !current.text.is_empty() {
out.push(core::mem::replace(
&mut current,
Paragraph { number: String::new(), text: String::new() },
));
}
current.number = num.to_string();
let rest = line.get(num.len()..).unwrap_or("").trim_start();
current.text.push_str(rest);
} else {
if !current.text.is_empty() {
current.text.push('\n');
}
current.text.push_str(line);
}
}
if !current.text.is_empty() || !current.number.is_empty() {
out.push(current);
}
out
}
/// Recognises `(1)`, `(2a)`, `(10)` at the start of a line.
fn leading_paragraph_marker(line: &str) -> Option<&str> {
let bytes = line.as_bytes();
if bytes.first() != Some(&b'(') {
return None;
}
let close = bytes.iter().position(|&b| b == b')')?;
if close < 2 || close > 5 {
return None;
}
let inner = line.get(1..close)?;
if inner.chars().all(|c| c.is_ascii_alphanumeric()) {
line.get(..=close)
} else {
None
}
}
/// Synthetic ELI: `eli/bund/<jurabk-lower>/<paragraph-token>`.
/// Lossy and deterministic. Replace when BMJ publishes upstream
/// ELI URIs.
fn synthesise_eli(jurabk: Option<&str>, paragraph: Option<&str>) -> String {
let abk = jurabk.unwrap_or("unknown").trim().to_ascii_lowercase();
let para = paragraph
.unwrap_or("")
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.collect::<String>()
.to_ascii_lowercase();
if para.is_empty() {
format!("eli/bund/{abk}")
} else {
format!("eli/bund/{abk}/{para}")
}
}
fn hex_sha256(data: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(data);
let digest = hasher.finalize();
let mut s = String::with_capacity(64);
for byte in digest {
let hi = byte >> 4;
let lo = byte & 0x0f;
s.push(nibble_to_hex(hi));
s.push(nibble_to_hex(lo));
}
s
}
fn nibble_to_hex(n: u8) -> char {
match n {
0..=9 => (b'0' + n) as char,
10..=15 => (b'a' + n - 10) as char,
_ => '?',
}
}
fn xml_err<E: core::fmt::Display>(e: E) -> ModuleError {
ModuleError::invalid_input(format!("XML parse failed: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_GEWO_14: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<dokumente>
<norm doknr="BJNR9410000">
<metadaten>
<jurabk>GewO</jurabk>
<enbez>§ 14</enbez>
<titel>Anzeigepflicht</titel>
</metadaten>
<textdaten>
<text>
(1) Wer den selbstständigen Betrieb eines stehenden Gewerbes anfängt, muss dies der zuständigen Behörde gleichzeitig anzeigen.
(2) Die Anzeige hat unverzüglich zu erfolgen.
</text>
</textdaten>
</norm>
</dokumente>"#;
#[test]
fn parses_gewo_14_metadata() {
let norm = normalize_gii_xml(SAMPLE_GEWO_14.as_bytes()).expect("parse ok");
assert_eq!(norm.jurabk.as_deref(), Some("GewO"));
assert_eq!(norm.paragraph.as_deref(), Some("§ 14"));
assert_eq!(norm.title.as_deref(), Some("Anzeigepflicht"));
assert_eq!(norm.eli, "eli/bund/gewo/14");
}
#[test]
fn splits_into_absatz_paragraphs() {
let norm = normalize_gii_xml(SAMPLE_GEWO_14.as_bytes()).expect("parse ok");
assert_eq!(norm.paragraphs.len(), 2);
assert_eq!(norm.paragraphs[0].number, "(1)");
assert!(norm.paragraphs[0].text.starts_with("Wer den selbstständigen"));
assert_eq!(norm.paragraphs[1].number, "(2)");
}
#[test]
fn deterministic_hash() {
let a = normalize_gii_xml(SAMPLE_GEWO_14.as_bytes()).expect("parse ok");
let b = normalize_gii_xml(SAMPLE_GEWO_14.as_bytes()).expect("parse ok");
assert_eq!(a.source_sha256, b.source_sha256);
assert_eq!(a.normalized_sha256, b.normalized_sha256);
assert_eq!(a.source_sha256.len(), 64);
}
#[test]
fn sniffs_xml_from_dokumente() {
assert_eq!(
sniff_mime(b"<?xml version=\"1.0\"?><dokumente>"),
Some("application/xml".into())
);
assert_eq!(sniff_mime(b"<dokumente><norm/>"), Some("application/xml".into()));
assert_eq!(sniff_mime(b"%PDF-1.4 ..."), None);
}
}