#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct ActRef {
pub(super) celex: String,
pub(super) text: String,
}
pub(super) fn cites_article_or_annex(prose: &str) -> bool {
let has_roman_annex = prose.match_indices("Annex ").any(|(i, _)| {
prose[i + "Annex ".len()..]
.chars()
.next()
.is_some_and(|c| "IVXLCDM".contains(c))
});
let has_article = ["Art. ", "Article "].iter().any(|marker| {
prose.match_indices(marker).any(|(i, _)| {
prose[i + marker.len()..]
.chars()
.next()
.is_some_and(|c| c.is_ascii_digit())
})
});
has_roman_annex || has_article
}
pub(super) fn act_refs(prose: &str) -> Vec<ActRef> {
let bytes = prose.as_bytes();
let mut found = Vec::new();
let mut i = 0;
while i < bytes.len() {
if !bytes[i].is_ascii_digit() {
i += 1;
continue;
}
if i > 0 && matches!(bytes[i - 1], b'-' | b'.' | b',') {
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
continue;
}
let first_start = i;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
let first_len = i - first_start;
let Ok(first) = prose[first_start..i].parse::<u32>() else {
continue;
};
if i >= bytes.len() || bytes[i] != b'/' {
continue;
}
let after_slash = i + 1;
i = after_slash;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
if i == after_slash {
continue;
}
let second_len = i - after_slash;
let Ok(second) = prose[after_slash..i].parse::<u32>() else {
continue;
};
if i + 1 < bytes.len() && matches!(bytes[i], b'-' | b'.') && bytes[i + 1].is_ascii_digit() {
continue;
}
let mut end = i;
let mut directive_by_form = false;
if i < bytes.len() && bytes[i] == b'/' {
let suffix_start = i + 1;
let mut j = suffix_start;
while j < bytes.len() && bytes[j].is_ascii_uppercase() {
j += 1;
}
if matches!(&prose[suffix_start..j], "EU" | "EC" | "EEC") {
directive_by_form = true;
end = j;
}
}
let is_year = |y: u32| (1950..=2099).contains(&y);
let (year, number) = if first_len == 4 && is_year(first) {
(first, second)
} else if second_len == 4 && is_year(second) {
(second, first)
} else {
i = end;
continue;
};
if number == 0 || number > 9999 {
i = end;
continue;
}
let kind = if directive_by_form || nearest_kind_word_is_directive(&prose[..first_start]) {
'L'
} else {
'R'
};
found.push(ActRef {
celex: format!("3{year}{kind}{number:04}"),
text: prose[first_start..end].to_owned(),
});
i = end;
}
found
}
fn nearest_kind_word_is_directive(before: &str) -> bool {
let lowered = before.to_lowercase();
let within_reach = |found: Option<usize>| {
found.filter(|position| lowered.len().saturating_sub(*position) <= 60)
};
let directive = within_reach(lowered.rfind("directive"));
let regulation = within_reach(lowered.rfind("regulation"));
match (directive, regulation) {
(Some(d), Some(r)) => d > r,
(Some(_), None) => true,
_ => false,
}
}
#[test]
fn the_detectors_catch_what_they_are_for() {
let invented = act_refs("Per Regulation (EU) 2027/9999 Annex II.");
assert_eq!(
invented.first().map(|a| a.celex.as_str()),
Some("32027R9999"),
"an invented act number must still parse, or Rule B never sees it"
);
assert!(cites_article_or_annex(
"SVHC substances per REACH Article 33."
));
assert!(cites_article_or_annex(
"Contact allergens under Annex XVII entry 72."
));
assert!(!cites_article_or_annex(
"Recycled content share as a percentage of total mass."
));
for (prose, expected) in [
("Regulation (EU) 2023/1670", "32023R1670"),
("Regulation (EC) No 1907/2006", "32006R1907"),
("Directive 2011/65/EU", "32011L0065"),
("Directive (EU) 2017/1132", "32017L1132"),
("EU Battery Regulation 2023/1542", "32023R1542"),
("replacing 1222/2009", "32009R1222"),
("Toy fields per Regulation (EU) 2025/2509.", "32025R2509"),
] {
assert_eq!(
act_refs(prose).first().map(|a| a.celex.as_str()),
Some(expected),
"{prose} should resolve to {expected}"
);
}
for prose in [
"ISO/IEC 15459-1:2014, -2:2015 and -3:2014",
"v1.1.0 renames countryOfManufacture",
"above 0,1 % w/w",
"placed on the market from 2031-08-18",
"Annex VI Part A point 10",
"ranked 1/2 in the working plan",
] {
assert!(
act_refs(prose).is_empty(),
"{prose} must not be read as an act reference, got {:?}",
act_refs(prose)
);
}
}
pub(super) const CITED_NOT_MODELLED: &[(&str, &str)] = &[
(
"32004R0648",
"The old Detergents Regulation. Cited only as the act Regulation (EU) \
2026/405 repeals with effect from 23 September 2029 — a repealed act is \
still worth naming, because the transition is what a reader needs.",
),
(
"32006R1907",
"REACH. Cited for Art. 33 (SVHC communication duty above 0,1 % w/w) and \
Annex XVII entry 72 (restricted substances in textiles). A horizontal \
chemicals regime rather than a passport instrument, so it binds no \
product group in the catalog sense.",
),
(
"32009L0048",
"Toy Safety Directive. Cited for CE marking. Superseded for passport \
purposes by Regulation (EU) 2025/2509, which is modelled.",
),
(
"32009R0661",
"General safety of motor vehicles. Cited only as the source of the tyre \
noise limit values (LV) that Regulation (EU) 2020/740 Annex I Part C \
grades against — a threshold this crate reads, not an obligation it \
carries.",
),
(
"32009R1222",
"The old tyre labelling regulation. Cited only as the act Regulation \
(EU) 2020/740 replaced, repealed with effect from 1 May 2021.",
),
(
"32011L0065",
"RoHS. Cited for the substance restrictions an electronics declaration \
references. Not a passport instrument.",
),
(
"32017L1132",
"Company law directive. Cited for Art. 16, which establishes the unique \
company identifier the unsold-goods schema uses for the EUID.",
),
(
"32020R0740",
"Tyre labelling. Cited for the Annex I grading scales. A labelling \
regime, not a passport obligation — the tyre passport duty, when one \
exists, will come from an ESPR delegated act.",
),
(
"32023R1669",
"Energy labelling for smartphones and slate tablets. The sibling of \
Regulation (EU) 2023/1670, which is modelled; this one sets label \
classes rather than passport content.",
),
(
"32024R1252",
"Critical Raw Materials Act. Cited as the source of the canonical CRM \
list. It defines which materials are critical; it does not govern \
their disclosure.",
),
];