Skip to main content

german_impressum_extractor/
lib.rs

1//! # german-impressum-extractor
2//!
3//! Extract structured data from German B2B website text — specifically the
4//! **Impressum** page that German websites are legally required to expose
5//! under TMG §5.
6//!
7//! ## Why this crate exists
8//!
9//! Germany requires every commercial website to publish a structured
10//! "Impressum" listing the legal entity, managing directors, address,
11//! contact info, and tax identifiers. This metadata is invaluable for
12//! B2B research, lead enrichment, compliance checks, and CRM tooling —
13//! yet existing scrapers usually leave you with raw HTML and a few hopeful
14//! regexes. This crate gives you a battle-tested, well-tested parser that
15//! handles the common cases (and many edge cases).
16//!
17//! ## Features
18//!
19//! - 🇩🇪 German-aware: handles `ä`, `ö`, `ü`, `ß`, common abbreviations,
20//!   obfuscated emails (`info [at] firma [dot] de`), German phone formats
21//!   (E.164 normalization), and address patterns.
22//! - 📦 Zero async runtime needed; pure synchronous parsing.
23//! - 🔍 Granular: each extractor (emails, phones, address, persons,
24//!   register numbers, etc.) is separately callable.
25//! - 🪪 Pulls out: HR-Nummer, HR-Court, USt-IdNr., Steuernummer, IBAN, BIC.
26//! - 📠 Fax / Telefax detection, kept separate from phone numbers.
27//! - 👥 Geschäftsführer / Inhaber / Vorstand / Verantwortlicher (§18 MStV)
28//!   name extraction with role tag.
29//! - 🏢 Legal-form detection: GmbH, GmbH & Co. KG, UG, AG, KG, OHG, GbR, e.K., eG, SE.
30//! - 📅 Year-founded heuristics ("gegründet 1973" / "seit 1985" / "founded in 1990").
31//!
32//! ## Quick start
33//!
34//! ```rust
35//! use german_impressum_extractor::extract_all;
36//!
37//! let text = "
38//!     Musterreinigung GmbH & Co. KG
39//!     Geschäftsführer: Dr. Hans Müller und Anna Schmidt
40//!     Hauptstraße 12, 10115 Berlin
41//!     Tel: +49 30 1234567
42//!     E-Mail: info@musterreinigung.de
43//!     Eingetragen im Handelsregister Berlin HRB 12345 B
44//!     USt-IdNr.: DE 123456789
45//!     Gegründet 1985
46//! ";
47//!
48//! let data = extract_all(text);
49//!
50//! assert_eq!(data.emails, vec!["info@musterreinigung.de"]);
51//! assert!(data.phones.contains(&"+493012345-67".replace('-', "")));
52//! assert_eq!(data.legal_form.as_deref(), Some("GmbH & Co. KG"));
53//! assert_eq!(data.hr_number.as_deref(), Some("HRB 12345 B"));
54//! assert_eq!(data.vat_id.as_deref(), Some("DE123456789"));
55//! assert_eq!(data.year_founded, Some(1985));
56//! assert!(data.persons.iter().any(|p| p.last_name.as_deref() == Some("Müller")));
57//! ```
58//!
59//! ## Granular use
60//!
61//! All field extractors are public if you want to call them independently:
62//!
63//! ```rust
64//! use german_impressum_extractor::{extract_emails, extract_phones};
65//!
66//! let emails = extract_emails("Schreiben Sie an info [at] beispiel [dot] de");
67//! assert_eq!(emails, vec!["info@beispiel.de"]);
68//!
69//! let phones = extract_phones("Telefon: 030/123 45 67");
70//! assert_eq!(phones[0], "+49301234567");
71//! ```
72
73#![forbid(unsafe_code)]
74#![warn(missing_docs)]
75
76use std::collections::BTreeSet;
77
78use regex::Regex;
79use std::sync::LazyLock;
80use unicode_normalization::UnicodeNormalization;
81
82mod normalize;
83mod score;
84mod scored;
85
86#[cfg(feature = "html")]
87mod html;
88
89// `Segment.label`/`span`/`value_span` and some `LabelKind` variants (e.g.
90// `LegalName`) are not read by TP1's address demonstrator — they are the
91// scoring substrate that TP2 consumes. Interim allow keeps the
92// `-D warnings` clippy gate green until then.
93#[allow(dead_code)]
94mod segment;
95
96// `Candidate.span`/`block`/`label` are not read by TP1 (only `.value` is);
97// they are the provenance substrate that TP2's confidence scoring consumes.
98// Interim allow keeps the -D warnings clippy gate green until then.
99#[allow(dead_code)]
100mod candidate;
101
102/// Container for everything `extract_all` returns.
103#[derive(Debug, Clone, Default, PartialEq, Eq)]
104#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
105#[non_exhaustive]
106pub struct Extracted {
107    /// Raw email addresses found, normalized to lowercase. De-duplicated.
108    pub emails: Vec<String>,
109    /// Phone numbers in `+49…` form, deduplicated. Numbers detected as a
110    /// fax (see [`Extracted::fax`]) are removed from this list.
111    pub phones: Vec<String>,
112    /// Fax / Telefax number in `+49…` form, if one was explicitly labeled.
113    pub fax: Option<String>,
114    /// First detected German postcode (5 digits).
115    pub postcode: Option<String>,
116    /// First detected city following the postcode.
117    pub city: Option<String>,
118    /// First detected street + house number combination.
119    pub street: Option<String>,
120    /// HR (Handelsregister) number, e.g. "HRB 12345 B".
121    pub hr_number: Option<String>,
122    /// HR court, e.g. "Berlin (Charlottenburg)".
123    pub hr_court: Option<String>,
124    /// USt-IdNr. (DE + 9 digits, no spaces).
125    pub vat_id: Option<String>,
126    /// Steuernummer.
127    pub tax_number: Option<String>,
128    /// German IBAN, normalized without spaces (e.g. `DE89370400440532013000`).
129    pub iban: Option<String>,
130    /// BIC / SWIFT code, uppercased (e.g. `COBADEFFXXX`).
131    pub bic: Option<String>,
132    /// Detected legal form (canonicalized).
133    pub legal_form: Option<String>,
134    /// Year company was founded.
135    pub year_founded: Option<i32>,
136    /// Persons mentioned in the role of Geschäftsführer / Inhaber / Vorstand /
137    /// Verantwortlicher / Vertretungsberechtigt.
138    pub persons: Vec<Person>,
139    /// Handelsregister section: "HRA" or "HRB", if determinable from the HR number.
140    pub register_type: Option<String>,
141    /// Supervisory authority ("Aufsichtsbehörde"), if labeled.
142    pub supervisory_authority: Option<String>,
143    /// Responsible professional chamber ("zuständige Kammer" / "Berufskammer"), if labeled.
144    pub professional_chamber: Option<String>,
145    /// De-Mail address, if labeled "De-Mail:".
146    pub de_mail: Option<String>,
147    /// EU Online-Dispute-Resolution (OS-Plattform) URL, if present.
148    pub dispute_resolution_url: Option<String>,
149}
150
151/// A single person mentioned in the Impressum (typically a managing director).
152#[derive(Debug, Clone, Default, PartialEq, Eq)]
153#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
154pub struct Person {
155    /// Best-effort first name (after stripping titles like Dr., Prof.).
156    pub first_name: Option<String>,
157    /// Last name (last token in the name string).
158    pub last_name: Option<String>,
159    /// The raw matched string from the source, useful for debugging.
160    pub full_raw: String,
161    /// Detected role: "Geschäftsführer" | "Inhaber" | "Vorstand" | "Verantwortlich" | None.
162    pub role: Option<String>,
163}
164
165/// A single postal address (one per address-bearing text block).
166///
167/// Each field is independent: a block containing only a street yields an
168/// `Address` with `street: Some(..)` and the rest `None`.
169#[derive(Debug, Clone, Default, PartialEq, Eq)]
170#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
171pub struct Address {
172    /// German postcode (5 digits), if present in the block.
173    pub postcode: Option<String>,
174    /// City following the postcode, if present.
175    pub city: Option<String>,
176    /// Street + house number, if present in the block.
177    pub street: Option<String>,
178}
179
180pub use scored::{Scored, ScoredExtracted};
181
182// ───────────────────────── Regexes ─────────────────────────
183
184static EMAIL_RE: LazyLock<Regex> =
185    LazyLock::new(|| Regex::new(r"(?i)[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}").unwrap());
186
187static EMAIL_OBFUSCATED_RE: LazyLock<Regex> = LazyLock::new(|| {
188    Regex::new(
189        r"(?i)\b([a-z0-9._%+\-]+)\s*[\[\(]?\s*(?:at|@)\s*[\]\)]?\s*([a-z0-9.\-]+)\s*[\[\(]?\s*(?:dot|\.)\s*[\]\)]?\s*([a-z]{2,})\b",
190    )
191    .unwrap()
192});
193
194static PHONE_RE: LazyLock<Regex> = LazyLock::new(|| {
195    Regex::new(
196        r"(?x)
197        (?:\+49|0049|0)
198        [\s\-/]?
199        (?:\(0?\)|\d{1,5})
200        [\s\-/]?
201        \d{2,4}
202        [\s\-/]?
203        \d{2,4}
204        (?:[\s\-/]?\d{0,4})?
205        ",
206    )
207    .unwrap()
208});
209
210static GERMAN_POSTCODE_AND_CITY_RE: LazyLock<Regex> =
211    LazyLock::new(|| Regex::new(r"\b(\d{5})\s+([A-ZÄÖÜ][A-Za-zÄÖÜäöüß\-/. ]{1,40})\b").unwrap());
212
213static STREET_RE: LazyLock<Regex> = LazyLock::new(|| {
214    Regex::new(
215        r"\b([A-ZÄÖÜ][A-Za-zÄÖÜäöüß\-./ ]{2,60}?(?:str(?:asse|aße|\.)|weg|allee|platz|ring|gasse|damm))\s+(\d{1,4}[a-zA-Z]?(?:[\-–]\d{1,4}[a-zA-Z]?)?)\b",
216    )
217    .unwrap()
218});
219
220static HR_NUMBER_RE: LazyLock<Regex> = LazyLock::new(|| {
221    Regex::new(r"(?i)\bHR[AB]?\s*[:\-]?\s*(?:Nr\.?\s*)?(\d{2,7}(?:\s*[A-Z])?)\b").unwrap()
222});
223
224// The court-name capture stops at the first digit, comma or newline (none of
225// those characters are in the class). A trailing Handelsregister prefix
226// (`HRB`/`HRA`) that shares the line with the court is stripped afterwards by
227// `clean_hr_court`. See issue #26.
228static HR_COURT_RE: LazyLock<Regex> = LazyLock::new(|| {
229    Regex::new(r"(?i)Amtsgericht\s+([A-ZÄÖÜ][A-Za-zÄÖÜäöüß\-./ ()]{1,60})").unwrap()
230});
231
232// A Handelsregister prefix (`HRA`/`HRB`/bare `HR`) that the greedy court-name
233// capture may have swallowed when no separator preceded it. Case-sensitive so
234// it never clips a city name like "Ahrensburg". See issue #26.
235static HR_SUFFIX_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s+HR[AB]?\b").unwrap());
236
237// USt-IdNr.: `DE` followed by exactly 9 digits, which may be grouped with
238// internal spaces (e.g. `DE 123 456 789`). Normalization strips the spaces.
239static VAT_DE_RE: LazyLock<Regex> =
240    LazyLock::new(|| Regex::new(r"(?i)\bDE[\s\-]?(?:\d[\s\-]?){8}\d\b").unwrap());
241
242// German IBAN: `DE` + 2 check digits + 18 BBAN digits (22 chars total),
243// commonly written in groups of four with spaces.
244static IBAN_DE_RE: LazyLock<Regex> =
245    LazyLock::new(|| Regex::new(r"(?i)\bDE(?:[\s\-]?\d){20}\b").unwrap());
246
247// BIC / SWIFT: 4 letters (bank) + 2 letters (country) + 2 alphanumerics
248// (location) + optional 3 alphanumerics (branch). Matched case-insensitively
249// and only accepted in a banking context (see `extract_bic`).
250static BIC_RE: LazyLock<Regex> =
251    LazyLock::new(|| Regex::new(r"(?i)\b[A-Z]{4}[A-Z]{2}[A-Z0-9]{2}(?:[A-Z0-9]{3})?\b").unwrap());
252
253// A fax/telefax label immediately followed by a German phone number.
254static FAX_RE: LazyLock<Regex> = LazyLock::new(|| {
255    Regex::new(
256        r"(?ix)
257        (?:tele)?fax\.?\s*[:\-]?\s*
258        ((?:\+49|0049|0)
259         [\s\-/]?(?:\(0?\)|\d{1,5})
260         [\s\-/]?\d{2,4}
261         [\s\-/]?\d{2,4}
262         (?:[\s\-/]?\d{0,4})?)
263        ",
264    )
265    .unwrap()
266});
267
268// Matches "Steuernummer" plus the common abbreviations "Steuer-Nr.",
269// "St.-Nr.", "StNr." and "St.Nr.". See issue #32.
270static TAX_NUMBER_RE: LazyLock<Regex> = LazyLock::new(|| {
271    Regex::new(
272        r"(?ix)
273        (?:Steuer-?nummer | Steuer-?Nr\.? | St\.?-?Nr\.?)
274        [:\s]*
275        ([\d/\s]{8,20})
276        ",
277    )
278    .unwrap()
279});
280
281static GF_REGEX: LazyLock<Regex> = LazyLock::new(|| {
282    Regex::new(
283        r"(?ix)
284        (?:
285            Gesch(?:ä|ae)ftsf(?:ü|ue)hrer(?:in)?
286          | Inhaber(?:in)?
287          | Vorstand
288          | Vertretungsberechtigt(?:e[rn]?)?
289          | vertreten\s+durch
290          | Verantwortlich(?:e[rn]?)?\s+f(?:ü|ue)r\s+den\s+Inhalt
291          | Redaktionell\s+verantwortlich
292          | Verantwortlich(?:e[rn]?)?(?:\s+(?:i\.?\s?S\.?\s?[dv]\.?|gem(?:ä|ae)ß|nach)[^:\n]{0,40})?
293        )
294        [\s:\-]+
295        (?:sind\s+|ist\s+)?
296        ([A-ZÄÖÜ][\w\s.\-,&/äöüÄÖÜß]{2,160})
297        ",
298    )
299    .unwrap()
300});
301
302static YEAR_FOUNDED_RE: LazyLock<Regex> = LazyLock::new(|| {
303    Regex::new(
304        r"(?ix)
305        \b
306        (?:gegr(?:ü|ue)ndet|seit|founded\s*in?|since|established\s*in?|gründungsjahr)
307        \s*[:\-]?\s*
308        (\d{4})
309        \b
310        ",
311    )
312    .unwrap()
313});
314
315static LEGAL_FORM_RE: LazyLock<Regex> = LazyLock::new(|| {
316    Regex::new(
317        r"(?ix)
318        \b
319        (
320            GmbH\s*&\s*Co\.?\s*KGaA
321          | GmbH\s*&\s*Co\.?\s*KG
322          | KGaA
323          | GmbH
324          | UG\s*\(?haftungsbeschr(?:ä|ae)nkt\)?
325          | UG
326          | AG
327          | KG
328          | OHG
329          | GbR
330          | e\.K\.
331          | eG
332          | SE
333        )
334        \b
335        ",
336    )
337    .unwrap()
338});
339
340static SUPERVISORY_AUTHORITY_RE: LazyLock<Regex> = LazyLock::new(|| {
341    Regex::new(r"(?im)^\s*Aufsichtsbeh(?:ö|oe)rde\s*[:\-]?\s*([^\n]{2,100})").unwrap()
342});
343
344static CHAMBER_RE: LazyLock<Regex> = LazyLock::new(|| {
345    Regex::new(r"(?im)^\s*(?:zust(?:ä|ae)ndige\s+Kammer|Berufskammer)\s*[:\-]?\s*([^\n]{2,100})")
346        .unwrap()
347});
348
349static DE_MAIL_RE: LazyLock<Regex> = LazyLock::new(|| {
350    Regex::new(r"(?i)\bDe-?Mail\s*[:\-]?\s*([a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,})").unwrap()
351});
352
353static ODR_URL_RE: LazyLock<Regex> =
354    LazyLock::new(|| Regex::new(r"(?i)https?://(?:www\.)?ec\.europa\.eu/consumers/odr/?").unwrap());
355
356// ───────────────────────── Blocklists ─────────────────────────
357
358/// Allowlist of real top-level domains an email address may end in.
359///
360/// The email regex accepts any `[a-z]{2,}` sequence as a TLD, which lets
361/// code fragments (`…@….css`, `…@….js`) *and* prose words picked out of
362/// HTML-to-text output (`…matters. Discover…` → `th@matters.discover`) leak
363/// through as bogus addresses. Validating the TLD against a compact set of
364/// genuine TLDs — country-code plus the common generics — removes both
365/// classes of false positive. See issues #3 and #15.
366const REAL_TLDS: &[&str] = &[
367    // Generic
368    "com", "net", "org", "info", "biz", "name", "pro", "io", "co", "gov", "edu", "mil", "int", "eu",
369    "app", "dev", "shop", "online", "site", "tech", "gmbh", "email", "de", "berlin",
370    // German-speaking / neighbouring Europe (primary market)
371    "at", "ch", "li", "lu", "nl", "be", "fr", "it", "es", "pt", "pl", "cz", "sk", "hu", "dk", "se",
372    "no", "fi", "is", "ie", "uk", "gr", "ro", "bg", "hr", "si", "rs", "ua",
373    // Rest of world (common in international B2B)
374    "us", "ca", "mx", "br", "ar", "au", "nz", "jp", "cn", "kr", "in", "sg", "hk", "tw", "za", "ru",
375    "tr", "ae", "sa", "il",
376];
377
378/// Domains observed as false positives from theme/widget code. See issue #3.
379const BLOCKED_EMAIL_DOMAINS: &[&str] = &["sapp.com", "tet.soweit"];
380
381/// Tokens that are never a person name: German articles, prepositions,
382/// pronouns, common verbs, and legal/role terms. See issue #4.
383const NOT_A_NAME: &[&str] = &[
384    // Articles
385    "der",
386    "die",
387    "das",
388    "den",
389    "dem",
390    "des",
391    "ein",
392    "eine",
393    "einem",
394    "einer",
395    "einen",
396    "eines",
397    // Prepositions
398    "in",
399    "an",
400    "auf",
401    "zu",
402    "zum",
403    "zur",
404    "von",
405    "bei",
406    "mit",
407    "nach",
408    "vor",
409    "aus",
410    "durch",
411    "ohne",
412    "gegen",
413    "um",
414    "für",
415    "über",
416    "unter",
417    "zwischen",
418    "als",
419    // Pronouns / particles
420    "sich",
421    "uns",
422    "wir",
423    "ihre",
424    "ihren",
425    "ihrer",
426    "seinen",
427    "seine",
428    "seiner",
429    "deren",
430    "dessen",
431    "nicht",
432    "auch",
433    "nur",
434    "noch",
435    "schon",
436    "sowie",
437    "und",
438    "oder",
439    // Common verbs near role keywords
440    "ist",
441    "hat",
442    "sind",
443    "haben",
444    "wird",
445    "werden",
446    "kann",
447    "soll",
448    "bemühen",
449    "sollten",
450    "vertreten",
451    // Legal / role terms
452    "geschäftsführer",
453    "geschäftsführerin",
454    "geschäftsführung",
455    "gesellschafterin",
456    "gesellschafter",
457    "vorstand",
458    "vorständin",
459    "inhaber",
460    "inhaberin",
461    "vertretungsberechtigt",
462    "verantwortlich",
463    "domain",
464    "inhalt",
465    "inhalte",
466    "gmbh",
467    "ug",
468    "ag",
469    "ohg",
470    "gbr",
471    "kg",
472    "eg",
473    // Impressum footer / contact noise nouns that leak in as fake names.
474    "team",
475    "kontakt",
476    "impressum",
477    "datenschutz",
478    "vertrieb",
479    "büro",
480    "sekretariat",
481    "webdesign",
482    "webseite",
483    "homepage",
484    "copyright",
485    "firma",
486    "unternehmen",
487    "postfach",
488    "telefon",
489    "telefax",
490    "mobil",
491    "adresse",
492];
493
494// ───────────────────────── Public API ─────────────────────────
495
496/// Extract every supported field from a free-form text blob.
497///
498/// `text` should be the visible text of an Impressum page (or the whole
499/// site — the extractors are forgiving).
500pub fn extract_all(text: &str) -> Extracted {
501    let doc = segment::Document::parse(normalize::normalize_text(text));
502    build_extracted(&doc)
503}
504
505/// Extract every supported field from an HTML Impressum page.
506///
507/// Available with the `html` feature. Equivalent to running [`extract_all`] on
508/// [`html_to_impressum_text`].
509#[cfg(feature = "html")]
510pub fn extract_all_html(html: &str) -> Extracted {
511    let doc = segment::Document::parse(html::html_to_impressum_text(html));
512    build_extracted(&doc)
513}
514
515/// Flatten an HTML document to the crate's canonical Impressum text.
516///
517/// Available with the `html` feature.
518#[cfg(feature = "html")]
519pub use html::html_to_impressum_text;
520
521/// Extract every supported field with a heuristic confidence score per field.
522///
523/// Additive companion to [`extract_all`]: same extraction, each field wrapped
524/// in a [`Scored`] with a confidence in `0.0..=1.0`.
525pub fn extract_all_scored(text: &str) -> ScoredExtracted {
526    let doc = segment::Document::parse(normalize::normalize_text(text));
527    score::score_extracted(build_extracted(&doc), &doc)
528}
529
530/// Like [`extract_all_scored`], but from an HTML page. Available with the `html` feature.
531#[cfg(feature = "html")]
532pub fn extract_all_scored_html(html: &str) -> ScoredExtracted {
533    let doc = segment::Document::parse(html::html_to_impressum_text(html));
534    score::score_extracted(build_extracted(&doc), &doc)
535}
536
537fn build_extracted(doc: &segment::Document) -> Extracted {
538    let text = doc.text();
539    // Fax (labeled) is extracted first so it can be excluded from phones.
540    let fax = extract_fax_core(text);
541    // IBANs contain long digit runs that the phone regex would otherwise pick
542    // up as bogus numbers, so skip any phone match overlapping an IBAN.
543    let iban_spans: Vec<(usize, usize)> = IBAN_DE_RE
544        .find_iter(text)
545        .map(|m| (m.start(), m.end()))
546        .collect();
547    let phones = collect_phones(text, &iban_spans)
548        .into_iter()
549        .filter(|p| fax.as_deref() != Some(p.as_str()))
550        .collect();
551
552    let (postcode, city, street) = address_from_document(doc);
553
554    let hr_number = extract_hr_number_core(text);
555    let hr_court = extract_hr_court_core(text);
556    let tax_number = extract_tax_number_core(text);
557
558    Extracted {
559        emails: extract_emails_core(text),
560        phones,
561        fax,
562        postcode,
563        city,
564        street,
565        hr_number,
566        hr_court,
567        vat_id: extract_vat_id_core(text),
568        tax_number,
569        iban: extract_iban_core(text),
570        bic: extract_bic_core(text),
571        legal_form: extract_legal_form_core(text),
572        year_founded: extract_year_founded_core(text),
573        persons: extract_persons_core(text),
574        register_type: extract_register_type_core(text),
575        supervisory_authority: extract_supervisory_authority_core(text),
576        professional_chamber: extract_professional_chamber_core(text),
577        de_mail: extract_de_mail_core(text),
578        dispute_resolution_url: extract_dispute_resolution_url_core(text),
579    }
580}
581
582/// Return all email addresses (plain and obfuscated `info [at] domain [dot] de`),
583/// lowercased and deduplicated. Code-fragment false positives (invalid TLDs
584/// like `.css`/`.js`, known junk domains) are filtered out.
585pub fn extract_emails(text: &str) -> Vec<String> {
586    extract_emails_core(&normalize::normalize_text(text))
587}
588
589fn extract_emails_core(text: &str) -> Vec<String> {
590    let mut emails: BTreeSet<String> = BTreeSet::new();
591    for m in EMAIL_RE.find_iter(text) {
592        let e = m.as_str().to_ascii_lowercase();
593        if is_plausible_email(&e) {
594            emails.insert(e);
595        }
596    }
597    for cap in EMAIL_OBFUSCATED_RE.captures_iter(text) {
598        if let (Some(local), Some(host), Some(tld)) = (cap.get(1), cap.get(2), cap.get(3)) {
599            let e = format!("{}@{}.{}", local.as_str(), host.as_str(), tld.as_str())
600                .to_ascii_lowercase();
601            if is_plausible_email(&e) {
602                emails.insert(e);
603            }
604        }
605    }
606    emails.into_iter().collect()
607}
608
609/// Return all German phone numbers normalized to `+49…` (digits only).
610///
611/// Note: this returns *every* matched number, including any labeled as a fax.
612/// [`extract_all`] additionally removes the detected [`Extracted::fax`] from
613/// its `phones` list; use [`extract_fax`] to obtain it separately.
614pub fn extract_phones(text: &str) -> Vec<String> {
615    let normalized = normalize::normalize_text(text);
616    collect_phones(normalized.as_str(), &[])
617}
618
619/// Collect normalized phone numbers, skipping matches that overlap any of the
620/// given byte spans (used by [`extract_all`] to drop IBAN digit runs).
621fn collect_phones(text: &str, skip_spans: &[(usize, usize)]) -> Vec<String> {
622    let mut out: BTreeSet<String> = BTreeSet::new();
623    for m in PHONE_RE.find_iter(text) {
624        if skip_spans
625            .iter()
626            .any(|(s, e)| m.start() < *e && m.end() > *s)
627        {
628            continue;
629        }
630        let p = clean_phone(m.as_str());
631        if p.len() >= 7 {
632            out.insert(p);
633        }
634    }
635    out.into_iter().collect()
636}
637
638/// Extract a labeled Fax / Telefax number, normalized to `+49…`.
639pub fn extract_fax(text: &str) -> Option<String> {
640    extract_fax_core(&normalize::normalize_text(text))
641}
642
643fn extract_fax_core(text: &str) -> Option<String> {
644    let cap = FAX_RE.captures(text)?;
645    let p = clean_phone(cap.get(1)?.as_str());
646    if p.len() >= 7 { Some(p) } else { None }
647}
648
649/// Extract a German IBAN, normalized without spaces (e.g. `DE89370400440532013000`).
650pub fn extract_iban(text: &str) -> Option<String> {
651    extract_iban_core(&normalize::normalize_text(text))
652}
653
654fn extract_iban_core(text: &str) -> Option<String> {
655    let m = IBAN_DE_RE.find(text)?;
656    let normalized: String = m
657        .as_str()
658        .chars()
659        .filter(|c| c.is_ascii_alphanumeric())
660        .collect::<String>()
661        .to_ascii_uppercase();
662    // German IBAN is exactly 22 characters.
663    if normalized.len() == 22 {
664        Some(normalized)
665    } else {
666        None
667    }
668}
669
670/// Extract a BIC / SWIFT code (uppercased). Only accepted when it appears in a
671/// banking context (`BIC`, `SWIFT`, or `Bankverbindung` nearby) to avoid
672/// matching ordinary uppercase words.
673pub fn extract_bic(text: &str) -> Option<String> {
674    extract_bic_core(&normalize::normalize_text(text))
675}
676
677fn extract_bic_core(text: &str) -> Option<String> {
678    for m in BIC_RE.find_iter(text) {
679        let candidate = m.as_str().to_ascii_uppercase();
680        // The country part (chars 5-6) must be letters; require a banking
681        // keyword within the preceding ~40 chars of context.
682        let start = m.start();
683        // Snap to a char boundary so multi-byte codepoints in the preceding
684        // context (e.g. a zero-width space) can't cause a mid-codepoint slice
685        // panic. See issue #13.
686        let ctx_start = floor_char_boundary(text, start.saturating_sub(40));
687        let ctx = text[ctx_start..start].to_ascii_lowercase();
688        if ctx.contains("bic") || ctx.contains("swift") || ctx.contains("bankverbindung") {
689            return Some(candidate);
690        }
691    }
692    None
693}
694
695/// Extract the first German address (`(postcode, city, street)`) from text.
696///
697/// Postcode/city and street are drawn from the *same* block when possible, so
698/// pages listing multiple addresses do not mix parts across entities.
699pub fn extract_address(text: &str) -> (Option<String>, Option<String>, Option<String>) {
700    let doc = segment::Document::parse(normalize::normalize_text(text));
701    address_from_document(&doc)
702}
703
704/// Extract every postal address on the page — one [`Address`] per text block
705/// that contains a postcode/city and/or a street, in document order, with exact
706/// duplicates removed.
707///
708/// Unlike [`extract_address`] (which returns only the first address), this is
709/// intended for pages listing multiple locations/branches. Address components
710/// are only ever combined within a single block, so parts from different
711/// entities are never mixed.
712///
713/// Address parts are grouped strictly per text block (blocks are separated by
714/// blank lines). A single address whose street and "postcode city" lines are
715/// split by a blank line therefore yields two partial [`Address`] values here,
716/// while [`extract_address`] merges them into one; and two addresses within one
717/// block collapse to a single [`Address`]. For the common single-block layout,
718/// `extract_addresses(t)[0]` matches the components of [`extract_address`].
719pub fn extract_addresses(text: &str) -> Vec<Address> {
720    let doc = segment::Document::parse(normalize::normalize_text(text));
721    addresses_from_document(&doc)
722}
723
724fn addresses_from_document(doc: &segment::Document) -> Vec<Address> {
725    let mut out: Vec<Address> = Vec::new();
726    for block in doc.block_texts() {
727        let pc = parse_postcode_city(block);
728        let street = parse_street(block);
729        if pc.is_none() && street.is_none() {
730            continue;
731        }
732        let (postcode, city) = match pc {
733            Some((code, city)) => (Some(code), Some(city)),
734            None => (None, None),
735        };
736        let addr = Address {
737            postcode,
738            city,
739            street,
740        };
741        if !out.contains(&addr) {
742            out.push(addr);
743        }
744    }
745    out
746}
747
748fn parse_postcode_city(block: &str) -> Option<(String, String)> {
749    let cap = GERMAN_POSTCODE_AND_CITY_RE.captures(block)?;
750    Some((
751        cap.get(1)?.as_str().to_string(),
752        cap.get(2)?.as_str().trim().to_string(),
753    ))
754}
755
756fn parse_street(block: &str) -> Option<String> {
757    let cap = STREET_RE.captures(block)?;
758    Some(format!(
759        "{} {}",
760        cap.get(1).map(|m| m.as_str().trim()).unwrap_or(""),
761        cap.get(2).map(|m| m.as_str().trim()).unwrap_or("")
762    ))
763}
764
765fn address_from_document(
766    doc: &segment::Document,
767) -> (Option<String>, Option<String>, Option<String>) {
768    use candidate::Candidate;
769    use segment::LabelKind;
770
771    let mut pc_cands: Vec<Candidate<(String, String)>> = Vec::new();
772    let mut street_cands: Vec<Candidate<String>> = Vec::new();
773
774    for (idx, block) in doc.block_texts().enumerate() {
775        let pc = parse_postcode_city(block);
776        let st = parse_street(block);
777        // Same-block hit: strongest signal, return immediately.
778        if let (Some((code, city)), Some(street)) = (&pc, &st) {
779            return (Some(code.clone()), Some(city.clone()), Some(street.clone()));
780        }
781        if let Some(pcv) = pc {
782            pc_cands.push(Candidate::new(pcv, 0..0, idx, Some(LabelKind::Postal)));
783        }
784        if let Some(sv) = st {
785            // TODO(TP2): real span + label when scoring consumes these
786            street_cands.push(Candidate::new(sv, 0..0, idx, None));
787        }
788    }
789
790    // Fallback: first postcode/city and first street seen anywhere.
791    let (postcode, city) = match pc_cands.into_iter().next() {
792        Some(c) => (Some(c.value.0), Some(c.value.1)),
793        None => (None, None),
794    };
795    let street = street_cands.into_iter().next().map(|c| c.value);
796    (postcode, city, street)
797}
798
799/// Detect the German legal form mentioned in the text (e.g. "GmbH", "GmbH & Co. KG", "AG").
800pub fn extract_legal_form(text: &str) -> Option<String> {
801    extract_legal_form_core(&normalize::normalize_text(text))
802}
803
804fn extract_legal_form_core(text: &str) -> Option<String> {
805    LEGAL_FORM_RE
806        .find(text)
807        .map(|m| canonicalize_legal_form(m.as_str()))
808}
809
810/// Extract the HR (Handelsregister) number (e.g. `HRB 12345 B`).
811pub fn extract_hr_number(text: &str) -> Option<String> {
812    extract_hr_number_core(&normalize::normalize_text(text))
813}
814
815fn extract_hr_number_core(text: &str) -> Option<String> {
816    HR_NUMBER_RE.captures(text).map(|c| {
817        c.get(0)
818            .unwrap()
819            .as_str()
820            .split_whitespace()
821            .collect::<Vec<_>>()
822            .join(" ")
823    })
824}
825
826/// Extract the Handelsregister court that follows an `Amtsgericht` label
827/// (e.g. `"Berlin"`, `"Berlin (Charlottenburg)"`).
828///
829/// A Handelsregister prefix sharing the same line as the court name
830/// (`Amtsgericht Berlin HRB 12345`) is stripped, so this returns `"Berlin"`
831/// rather than `"Berlin HRB"`. See issue #26.
832pub fn extract_hr_court(text: &str) -> Option<String> {
833    extract_hr_court_core(&normalize::normalize_text(text))
834}
835
836fn extract_hr_court_core(text: &str) -> Option<String> {
837    HR_COURT_RE
838        .captures(text)
839        .and_then(|c| c.get(1))
840        .map(|m| clean_hr_court(m.as_str()))
841        .filter(|s| !s.is_empty())
842}
843
844/// Extract a Steuernummer (local German tax number), e.g. `"28/815/0815 1"`.
845///
846/// Recognizes the full word `Steuernummer` as well as the common abbreviations
847/// `Steuer-Nr.`, `St.-Nr.`, `StNr.` and `St.Nr.`. See issues #31 and #32.
848pub fn extract_tax_number(text: &str) -> Option<String> {
849    extract_tax_number_core(&normalize::normalize_text(text))
850}
851
852fn extract_tax_number_core(text: &str) -> Option<String> {
853    TAX_NUMBER_RE
854        .captures(text)
855        .and_then(|c| c.get(1).map(|m| m.as_str().trim().to_string()))
856}
857
858/// Extract the German VAT-ID (USt-IdNr., format `DE` + 9 digits).
859///
860/// Matches are allowed to contain internal grouping spaces (`DE 123 456 789`).
861/// Any candidate that overlaps a detected IBAN is skipped, since an IBAN also
862/// starts with `DE` followed by digits.
863pub fn extract_vat_id(text: &str) -> Option<String> {
864    extract_vat_id_core(&normalize::normalize_text(text))
865}
866
867fn extract_vat_id_core(text: &str) -> Option<String> {
868    let iban_spans: Vec<(usize, usize)> = IBAN_DE_RE
869        .find_iter(text)
870        .map(|m| (m.start(), m.end()))
871        .collect();
872    for m in VAT_DE_RE.find_iter(text) {
873        let overlaps_iban = iban_spans
874            .iter()
875            .any(|(s, e)| m.start() < *e && m.end() > *s);
876        if overlaps_iban {
877            continue;
878        }
879        let normalized: String = m
880            .as_str()
881            .chars()
882            .filter(|c| !c.is_whitespace() && *c != '-')
883            .collect();
884        return Some(normalized.to_ascii_uppercase());
885    }
886    None
887}
888
889/// Extract the company's founding year.
890pub fn extract_year_founded(text: &str) -> Option<i32> {
891    extract_year_founded_core(&normalize::normalize_text(text))
892}
893
894fn extract_year_founded_core(text: &str) -> Option<i32> {
895    let cap = YEAR_FOUNDED_RE.captures(text)?;
896    let y: i32 = cap.get(1)?.as_str().parse().ok()?;
897    if (1700..=2100).contains(&y) {
898        Some(y)
899    } else {
900        None
901    }
902}
903
904/// Extract Geschäftsführer / Inhaber / Vorstand / Verantwortlicher persons.
905pub fn extract_persons(text: &str) -> Vec<Person> {
906    extract_persons_core(&normalize::normalize_text(text))
907}
908
909fn extract_persons_core(text: &str) -> Vec<Person> {
910    let mut out = Vec::new();
911    let mut seen: BTreeSet<String> = BTreeSet::new();
912    for cap in GF_REGEX.captures_iter(text) {
913        let Some(m) = cap.get(1) else { continue };
914        // The role keyword is in the full match (group 0), not group 1.
915        let role = detect_role(cap.get(0).map(|g| g.as_str()).unwrap_or(""));
916        let trimmed = truncate_at_sentence_end(m.as_str().trim());
917        for piece in split_persons(&trimmed) {
918            let p = parse_person(&piece, role.clone());
919            if p.last_name.is_none() {
920                continue;
921            }
922            let key = format!(
923                "{}_{}",
924                p.first_name.clone().unwrap_or_default(),
925                p.last_name.clone().unwrap_or_default()
926            );
927            if seen.insert(key) {
928                out.push(p);
929            }
930        }
931    }
932    out
933}
934
935/// Extract the supervisory authority ("Aufsichtsbehörde"), when labeled.
936pub fn extract_supervisory_authority(text: &str) -> Option<String> {
937    extract_supervisory_authority_core(&normalize::normalize_text(text))
938}
939
940fn extract_supervisory_authority_core(text: &str) -> Option<String> {
941    SUPERVISORY_AUTHORITY_RE
942        .captures(text)
943        .and_then(|c| c.get(1))
944        .map(|m| m.as_str().trim().to_string())
945        .filter(|s| !s.is_empty())
946}
947
948/// Extract the responsible professional chamber ("zuständige Kammer" / "Berufskammer"), when labeled.
949pub fn extract_professional_chamber(text: &str) -> Option<String> {
950    extract_professional_chamber_core(&normalize::normalize_text(text))
951}
952
953fn extract_professional_chamber_core(text: &str) -> Option<String> {
954    CHAMBER_RE
955        .captures(text)
956        .and_then(|c| c.get(1))
957        .map(|m| m.as_str().trim().to_string())
958        .filter(|s| !s.is_empty())
959}
960
961/// Extract a De-Mail address, when labeled "De-Mail:".
962pub fn extract_de_mail(text: &str) -> Option<String> {
963    extract_de_mail_core(&normalize::normalize_text(text))
964}
965
966fn extract_de_mail_core(text: &str) -> Option<String> {
967    DE_MAIL_RE
968        .captures(text)
969        .and_then(|c| c.get(1))
970        .map(|m| m.as_str().to_ascii_lowercase())
971}
972
973/// Extract the EU Online-Dispute-Resolution (OS-Plattform) URL, if present.
974pub fn extract_dispute_resolution_url(text: &str) -> Option<String> {
975    extract_dispute_resolution_url_core(&normalize::normalize_text(text))
976}
977
978fn extract_dispute_resolution_url_core(text: &str) -> Option<String> {
979    ODR_URL_RE.find(text).map(|m| m.as_str().to_string())
980}
981
982/// Extract the Handelsregister section — `"HRA"` or `"HRB"` — from the HR number.
983pub fn extract_register_type(text: &str) -> Option<String> {
984    extract_register_type_core(&normalize::normalize_text(text))
985}
986
987fn extract_register_type_core(text: &str) -> Option<String> {
988    let hr = extract_hr_number_core(text)?;
989    let upper = hr.to_ascii_uppercase();
990    if upper.starts_with("HRB") {
991        Some("HRB".to_string())
992    } else if upper.starts_with("HRA") {
993        Some("HRA".to_string())
994    } else {
995        None
996    }
997}
998
999// ───────────────────────── Helpers ─────────────────────────
1000
1001/// Snap a byte `index` down to the nearest UTF-8 character boundary at or
1002/// below it, returning a value that is always safe to slice `s` at.
1003///
1004/// This is a hand-rolled, MSRV-safe equivalent of `str::floor_char_boundary`
1005/// (only stabilized in Rust 1.91, above this crate's 1.85 MSRV). Prevents the
1006/// mid-codepoint slice panics reported in issues #13 and #14.
1007fn floor_char_boundary(s: &str, index: usize) -> usize {
1008    if index >= s.len() {
1009        return s.len();
1010    }
1011    let mut i = index;
1012    while i > 0 && !s.is_char_boundary(i) {
1013        i -= 1;
1014    }
1015    i
1016}
1017
1018fn clean_phone(s: &str) -> String {
1019    let s = s.nfc().collect::<String>();
1020    let s: String = s
1021        .chars()
1022        .filter(|c| c.is_ascii_digit() || *c == '+')
1023        .collect();
1024    if let Some(rest) = s.strip_prefix("0049") {
1025        format!("+49{}", rest)
1026    } else if let Some(rest) = s.strip_prefix('0') {
1027        if rest.starts_with('+') {
1028            s
1029        } else {
1030            format!("+49{}", rest)
1031        }
1032    } else {
1033        s
1034    }
1035}
1036
1037/// Strip a trailing Handelsregister prefix (`HRB`/`HRA`/`HR`) that the greedy
1038/// court-name capture may have swallowed, then trim. See issue #26.
1039fn clean_hr_court(raw: &str) -> String {
1040    let trimmed = raw.trim();
1041    let cut = HR_SUFFIX_RE
1042        .find(trimmed)
1043        .map(|m| m.start())
1044        .unwrap_or(trimmed.len());
1045    trimmed[..cut].trim().to_string()
1046}
1047
1048fn canonicalize_legal_form(raw: &str) -> String {
1049    let r = raw.trim();
1050    let lower = r.to_ascii_lowercase();
1051    // KGaA variants must be checked before the plain KG / GmbH cases, since
1052    // "GmbH & Co. KGaA" also contains "co", "kg", and "gmbh". See issue #30.
1053    if lower.contains("co") && lower.contains("kgaa") {
1054        "GmbH & Co. KGaA".to_string()
1055    } else if lower.contains("kgaa") {
1056        "KGaA".to_string()
1057    } else if lower.contains("co") && lower.contains("kg") {
1058        "GmbH & Co. KG".to_string()
1059    } else if r.to_ascii_uppercase().contains("GMBH") {
1060        "GmbH".to_string()
1061    } else if r.to_ascii_uppercase().contains("UG") {
1062        "UG".to_string()
1063    } else {
1064        r.to_string()
1065    }
1066}
1067
1068fn truncate_at_sentence_end(s: &str) -> String {
1069    let stop_words = [
1070        " Sitz",
1071        " Tel",
1072        " Telefon",
1073        " Fax",
1074        " E-Mail",
1075        " Email",
1076        " USt",
1077        " Eingetragen",
1078        " HRB",
1079        " HRA",
1080        " Steuer",
1081        " Adresse",
1082        " Anschrift",
1083        " Datenschutz",
1084        " Web",
1085        " Geschäfts",
1086        " Handelsregister",
1087        " Amtsgericht",
1088        " UID",
1089    ];
1090    let mut end = s.len();
1091    for sw in stop_words {
1092        if let Some(pos) = s.find(sw) {
1093            end = end.min(pos);
1094        }
1095    }
1096    // Stop at first newline — names rarely span line breaks.
1097    if let Some(pos) = s.find(['\n', '\r']) {
1098        end = end.min(pos);
1099    }
1100    // A stop-word/newline byte offset can land inside a multi-byte codepoint
1101    // (soft hyphens, non-breaking spaces, em-dashes …); snap back to a valid
1102    // boundary before slicing to avoid a panic. See issue #14.
1103    let end = floor_char_boundary(s, end);
1104    s[..end]
1105        .trim()
1106        .trim_end_matches(',')
1107        .trim_end_matches('.')
1108        .to_string()
1109}
1110
1111fn split_persons(raw: &str) -> Vec<String> {
1112    let separators = [" und ", " sowie ", " & ", ",", ";", " / "];
1113    let mut parts = vec![raw.to_string()];
1114    for sep in separators {
1115        let mut next = Vec::new();
1116        for p in parts.drain(..) {
1117            for sub in p.split(sep) {
1118                let s = sub.trim();
1119                if !s.is_empty() {
1120                    next.push(s.to_string());
1121                }
1122            }
1123        }
1124        parts = next;
1125    }
1126    parts
1127}
1128
1129fn parse_person(raw: &str, role: Option<String>) -> Person {
1130    let cleaned = strip_titles(raw);
1131    // Keep only tokens that plausibly belong to a person's name.
1132    let parts: Vec<&str> = cleaned
1133        .split_whitespace()
1134        .filter(|t| is_valid_name_part(t))
1135        .collect();
1136    match parts.len() {
1137        // No usable name tokens — drop it (last_name stays None, filtered upstream).
1138        0 => Person {
1139            full_raw: raw.to_string(),
1140            role,
1141            ..Default::default()
1142        },
1143        1 => Person {
1144            last_name: Some(parts[0].to_string()),
1145            full_raw: raw.to_string(),
1146            role,
1147            ..Default::default()
1148        },
1149        _ => Person {
1150            first_name: Some(parts[0].to_string()),
1151            last_name: Some(parts[parts.len() - 1].to_string()),
1152            full_raw: raw.to_string(),
1153            role,
1154        },
1155    }
1156}
1157
1158/// Detect the role from the full regex match (which still contains the keyword).
1159fn detect_role(full_match: &str) -> Option<String> {
1160    let lower = full_match.to_ascii_lowercase();
1161    if lower.contains("gesch") {
1162        Some("Geschäftsführer".to_string())
1163    } else if lower.contains("vorstand") {
1164        Some("Vorstand".to_string())
1165    } else if lower.contains("verantwortlich") {
1166        Some("Verantwortlich".to_string())
1167    } else if lower.contains("inhaber") {
1168        Some("Inhaber".to_string())
1169    } else {
1170        None
1171    }
1172}
1173
1174/// Reject tokens that are obviously not name parts: blocklisted words,
1175/// single characters (truncation artifacts), or lowercase-initial tokens
1176/// (German names are always capitalized). See issue #4.
1177fn is_valid_name_part(s: &str) -> bool {
1178    let trimmed = s.trim().trim_matches(|c: char| !c.is_alphanumeric());
1179    if trimmed.chars().count() <= 1 {
1180        return false;
1181    }
1182    if trimmed.chars().any(|c| c.is_ascii_digit()) {
1183        return false;
1184    }
1185    if NOT_A_NAME.contains(&trimmed.to_lowercase().as_str()) {
1186        return false;
1187    }
1188    match trimmed.chars().next() {
1189        Some(first) => first.is_uppercase(),
1190        None => false,
1191    }
1192}
1193
1194/// Strip academic/courtesy titles using whole-token matching so that names
1195/// embedding a title substring (e.g. "Herrmann", "Draxler") are preserved.
1196/// See issue #2.
1197fn strip_titles(s: &str) -> String {
1198    const TITLES: &[&str] = &[
1199        "dr",
1200        "dr.",
1201        "prof",
1202        "prof.",
1203        "dipl.-ing.",
1204        "dipl.-kfm.",
1205        "dipl.-ök.",
1206        "dipl",
1207        "b.sc.",
1208        "m.sc.",
1209        "mba",
1210        "b.a.",
1211        "m.a.",
1212        "ll.m.",
1213        "ll.b.",
1214        "herr",
1215        "frau",
1216        "ra",
1217        "h.c.",
1218    ];
1219    s.split_whitespace()
1220        .filter(|token| {
1221            let key = token.to_ascii_lowercase();
1222            !TITLES.contains(&key.as_str())
1223        })
1224        .collect::<Vec<_>>()
1225        .join(" ")
1226}
1227
1228/// Reject emails whose domain is blocklisted or whose TLD is not a real
1229/// top-level domain (filters both code fragments and prose false positives).
1230/// See issues #3 and #15.
1231fn is_plausible_email(email: &str) -> bool {
1232    let Some((_, domain)) = email.rsplit_once('@') else {
1233        return false;
1234    };
1235    if BLOCKED_EMAIL_DOMAINS.contains(&domain) {
1236        return false;
1237    }
1238    match domain.rsplit_once('.') {
1239        Some((_, tld)) => REAL_TLDS.contains(&tld),
1240        None => false,
1241    }
1242}
1243
1244#[cfg(test)]
1245mod tests {
1246    use super::*;
1247
1248    #[test]
1249    fn email_basic_and_obfuscated() {
1250        let txt = "info@a.de info [at] b [dot] de support@a.de";
1251        let e = extract_emails(txt);
1252        assert!(e.contains(&"info@a.de".to_string()));
1253        assert!(e.contains(&"info@b.de".to_string()));
1254        assert!(e.contains(&"support@a.de".to_string()));
1255    }
1256
1257    #[test]
1258    fn phones_normalised() {
1259        let p = extract_phones("Tel +49 30 12345-67 oder 0049 30 12345 68 oder 030/123 45 69");
1260        assert!(p.iter().any(|x| x.starts_with("+493012345")));
1261    }
1262
1263    #[test]
1264    fn legal_forms() {
1265        assert_eq!(
1266            extract_legal_form("Muster GmbH & Co. KG"),
1267            Some("GmbH & Co. KG".into())
1268        );
1269        assert_eq!(extract_legal_form("Muster GmbH"), Some("GmbH".into()));
1270        assert_eq!(
1271            extract_legal_form("Muster UG (haftungsbeschränkt)"),
1272            Some("UG".into())
1273        );
1274    }
1275
1276    #[test]
1277    fn year_founded_pattern() {
1278        assert_eq!(
1279            extract_year_founded("Unser Unternehmen wurde gegründet 1973"),
1280            Some(1973)
1281        );
1282        assert_eq!(extract_year_founded("Founded in 2010"), Some(2010));
1283    }
1284
1285    #[test]
1286    fn vat_id_extracted() {
1287        assert_eq!(
1288            extract_vat_id("USt-ID: DE 123456789"),
1289            Some("DE123456789".into())
1290        );
1291    }
1292
1293    #[test]
1294    fn hr_number_extracted() {
1295        assert!(extract_hr_number("Eingetragen im Handelsregister Berlin HRB 12345 B").is_some());
1296    }
1297
1298    #[test]
1299    fn persons_with_titles_and_connectors() {
1300        let p = extract_persons("Geschäftsführer: Dr. Hans Müller und Anna Schmidt");
1301        assert!(p.iter().any(|x| x.last_name.as_deref() == Some("Müller")));
1302        assert!(p.iter().any(|x| x.last_name.as_deref() == Some("Schmidt")));
1303    }
1304
1305    #[test]
1306    fn full_extract() {
1307        let text = "
1308            Musterreinigung GmbH & Co. KG
1309            Geschäftsführer: Hans Müller
1310            Hauptstraße 12, 10115 Berlin
1311            Tel: +49 30 1234567
1312            E-Mail: info@musterreinigung.de
1313            Eingetragen im Handelsregister Berlin HRB 12345 B
1314            USt-IdNr.: DE 123456789
1315            Gegründet 1985
1316        ";
1317        let d = extract_all(text);
1318        assert_eq!(d.legal_form.as_deref(), Some("GmbH & Co. KG"));
1319        assert_eq!(d.postcode.as_deref(), Some("10115"));
1320        assert!(d.emails.contains(&"info@musterreinigung.de".to_string()));
1321        assert_eq!(d.year_founded, Some(1985));
1322        assert_eq!(d.vat_id.as_deref(), Some("DE123456789"));
1323        assert!(
1324            d.persons
1325                .iter()
1326                .any(|p| p.last_name.as_deref() == Some("Müller"))
1327        );
1328    }
1329}