From b73b3e4b73d865a9ada30c54e793ddb233ec3cbd Mon Sep 17 00:00:00 2001 From: flemming-it Date: Thu, 18 Jun 2026 11:24:51 +0200 Subject: [PATCH] feat: initial text-akoma-normalize v0.1.0 (text.akoma_normalize@0.1.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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// 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 --- .gitignore | 3 + Cargo.lock | 525 ++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 46 ++++ module.yaml | 90 ++++++++ rust-toolchain.toml | 4 + src/lib.rs | 357 ++++++++++++++++++++++++++++++ 6 files changed, 1025 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 module.yaml create mode 100644 rust-toolchain.toml create mode 100644 src/lib.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5e66a2d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +target/ +**/*.rs.bk +.DS_Store diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..4e2256f --- /dev/null +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..2dcc96a --- /dev/null +++ b/Cargo.toml @@ -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 "] +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. diff --git a/module.yaml b/module.yaml new file mode 100644 index 0000000..9ae7784 --- /dev/null +++ b/module.yaml @@ -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: "GewO", + paragraph: "§ 14", + title: "Anzeigepflicht", + paragraphs: [{ number, text }], + source_sha256: "...", + normalized_sha256: "...", + akoma_xml: "..." // 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 diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..32add0c --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.86" +targets = ["wasm32-wasip2"] +components = ["rustfmt", "clippy"] diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..a9642c4 --- /dev/null +++ b/src/lib.rs @@ -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, + paragraph: Option, + title: Option, + paragraphs: Vec, + 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, +} + +#[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 { + let bytes = inputs.require_bytes("content")?; + let mime_hint_lower: Option = + 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 ` Option { + let head = data.get(..256.min(data.len()))?; + let head_str = core::str::from_utf8(head).ok()?.trim_start(); + if head_str.starts_with(" Result { + let source_sha = hex_sha256(data); + + let mut reader = Reader::from_reader(data); + reader.config_mut().trim_text(true); + + let mut jurabk: Option = None; + let mut paragraph: Option = None; + let mut title: Option = None; + let mut text_buffer = String::new(); + let mut in_text = false; + let mut current_tag: Option> = 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 { + let text = text.trim(); + if text.is_empty() { + return Vec::new(); + } + let mut out: Vec = 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//`. +/// 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::() + .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: E) -> ModuleError { + ModuleError::invalid_input(format!("XML parse failed: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE_GEWO_14: &str = r#" + + + + GewO + § 14 + Anzeigepflicht + + + +(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. + + + +"#; + + #[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""), + Some("application/xml".into()) + ); + assert_eq!(sniff_mime(b""), Some("application/xml".into())); + assert_eq!(sniff_mime(b"%PDF-1.4 ..."), None); + } +}