use alloc::{format, string::String, vec::Vec};
use super::cas::validate_cas_format;
use crate::common::numeric::percentage_in_range;
pub const SVHC_THRESHOLD_PCT: f64 = 0.1;
pub const ECHA_CANDIDATE_LIST: &[&str] = &[
"117-81-7", "84-74-2", "85-68-7", "84-69-5", "80-05-7", "80-09-1", "620-92-8", "1333-82-0", "10588-01-9", "7778-50-9", "7789-09-5", "7789-06-2", "7758-97-6", "18454-12-1", "13530-65-9", "7646-79-9", "7440-48-4", "7439-92-1", "7440-43-9", "1309-64-4", "120-12-7", "91-20-3", "79-01-6", "127-18-4", "75-12-7", "68-12-2", "127-19-5", "872-50-4", "79-06-1", "1763-23-1", "110-54-3", "1478-61-1", ];
pub const ECHA_CANDIDATE_LIST_AS_OF: &str = "2026-02-04";
pub const ECHA_CANDIDATE_LIST_OFFICIAL_COUNT: usize = 253;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CandidateListProvenance {
pub as_of: &'static str,
pub embedded_count: usize,
pub official_count: usize,
}
impl CandidateListProvenance {
#[must_use]
pub const fn is_complete(&self) -> bool {
self.embedded_count >= self.official_count
}
}
#[must_use]
pub const fn candidate_list_provenance() -> CandidateListProvenance {
CandidateListProvenance {
as_of: ECHA_CANDIDATE_LIST_AS_OF,
embedded_count: ECHA_CANDIDATE_LIST.len(),
official_count: ECHA_CANDIDATE_LIST_OFFICIAL_COUNT,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SvhcFindingKind {
MustDeclare,
BelowThreshold,
NotInEmbeddedList,
}
#[derive(Debug, Clone, Copy)]
pub struct SvhcFinding<'a> {
pub index: usize,
pub cas_number: &'a str,
pub substance_name: &'a str,
pub concentration_pct: f64,
pub kind: SvhcFindingKind,
}
#[derive(Debug, Clone, Copy)]
pub struct SvhcInput<'a> {
pub cas_number: &'a str,
pub substance_name: &'a str,
pub concentration_pct: f64,
}
pub fn validate_svhc_substances(substances: &[SvhcInput<'_>]) -> Result<(), String> {
for (i, s) in substances.iter().enumerate() {
if s.cas_number.is_empty() {
return Err(format!(
"svhc_substances[{i}]: cas_number must not be empty"
));
}
if let Err(e) = validate_cas_format(s.cas_number) {
return Err(format!("svhc_substances[{i}]: invalid CAS number: {e}"));
}
if s.substance_name.is_empty() {
return Err(format!(
"svhc_substances[{i}]: substance_name must not be empty"
));
}
if !percentage_in_range(s.concentration_pct) {
return Err(format!(
"svhc_substances[{i}]: concentration_pct must be a finite value in 0–100, got {}",
s.concentration_pct
));
}
}
Ok(())
}
pub fn check_svhc_declarations<'a>(substances: &[SvhcInput<'a>]) -> Vec<SvhcFinding<'a>> {
substances
.iter()
.enumerate()
.map(|(index, s)| {
let kind = if !ECHA_CANDIDATE_LIST.contains(&s.cas_number) {
SvhcFindingKind::NotInEmbeddedList
} else if s.concentration_pct >= SVHC_THRESHOLD_PCT {
SvhcFindingKind::MustDeclare
} else {
SvhcFindingKind::BelowThreshold
};
SvhcFinding {
index,
cas_number: s.cas_number,
substance_name: s.substance_name,
concentration_pct: s.concentration_pct,
kind,
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn bpa(pct: f64) -> SvhcInput<'static> {
SvhcInput {
cas_number: "80-05-7",
substance_name: "Bisphenol A",
concentration_pct: pct,
}
}
#[test]
fn svhc_valid_and_invalid() {
assert!(validate_svhc_substances(&[bpa(0.15)]).is_ok());
assert!(validate_svhc_substances(&[]).is_ok());
let empty_cas = SvhcInput {
cas_number: "",
substance_name: "x",
concentration_pct: 0.5,
};
assert!(validate_svhc_substances(&[empty_cas]).is_err());
let bad_conc = SvhcInput {
cas_number: "80-05-7",
substance_name: "x",
concentration_pct: -1.0,
};
assert!(validate_svhc_substances(&[bad_conc]).is_err());
}
#[test]
fn nan_concentration_rejected() {
assert!(validate_svhc_substances(&[bpa(f64::NAN)]).is_err());
}
#[test]
fn infinity_concentration_rejected() {
assert!(validate_svhc_substances(&[bpa(f64::INFINITY)]).is_err());
}
#[test]
fn malformed_cas_rejected_by_structural_validator() {
let bad = SvhcInput {
cas_number: "80-05-8",
substance_name: "BPA wrong CAS",
concentration_pct: 0.5,
};
let err = validate_svhc_substances(&[bad]).unwrap_err();
assert!(err.contains("check digit"), "unexpected: {err}");
}
#[test]
fn wrong_cas_format_rejected() {
let bad = SvhcInput {
cas_number: "NOTACAS",
substance_name: "X",
concentration_pct: 0.1,
};
assert!(validate_svhc_substances(&[bad]).is_err());
}
#[test]
fn bpa_above_threshold_is_must_declare() {
let findings = check_svhc_declarations(&[bpa(0.15)]);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].kind, SvhcFindingKind::MustDeclare);
}
#[test]
fn bpa_below_threshold_is_informational() {
let findings = check_svhc_declarations(&[bpa(0.05)]);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].kind, SvhcFindingKind::BelowThreshold);
}
#[test]
fn bpa_exactly_at_threshold_is_must_declare() {
let findings = check_svhc_declarations(&[bpa(SVHC_THRESHOLD_PCT)]);
assert_eq!(findings[0].kind, SvhcFindingKind::MustDeclare);
}
#[test]
fn unknown_cas_flagged_as_not_in_embedded_list() {
let zinc = SvhcInput {
cas_number: "7440-66-6",
substance_name: "Zinc",
concentration_pct: 5.0,
};
let findings = check_svhc_declarations(&[zinc]);
assert_eq!(findings[0].kind, SvhcFindingKind::NotInEmbeddedList);
}
#[test]
fn mixed_declarations_produce_correct_findings() {
let above = bpa(0.2);
let below = SvhcInput {
cas_number: "84-74-2",
substance_name: "DBP",
concentration_pct: 0.05,
};
let unknown = SvhcInput {
cas_number: "7440-66-6",
substance_name: "Zinc",
concentration_pct: 5.0,
};
let findings = check_svhc_declarations(&[above, below, unknown]);
assert_eq!(findings[0].kind, SvhcFindingKind::MustDeclare);
assert_eq!(findings[1].kind, SvhcFindingKind::BelowThreshold);
assert_eq!(findings[2].kind, SvhcFindingKind::NotInEmbeddedList);
assert_eq!(findings[0].index, 0);
assert_eq!(findings[1].index, 1);
assert_eq!(findings[2].index, 2);
}
#[test]
fn threshold_constant_is_0_1_pct() {
assert!(
(SVHC_THRESHOLD_PCT - 0.1).abs() < f64::EPSILON,
"REACH Art. 33 threshold must be exactly 0.1 % w/w"
);
}
#[test]
fn bpa_on_candidate_list() {
assert!(
ECHA_CANDIDATE_LIST.contains(&"80-05-7"),
"BPA (80-05-7) must be on the embedded candidate list"
);
}
#[test]
fn all_embedded_cas_numbers_have_valid_format() {
for &cas in ECHA_CANDIDATE_LIST {
let s = [SvhcInput {
cas_number: cas,
substance_name: "test",
concentration_pct: 0.5,
}];
assert!(
validate_svhc_substances(&s).is_ok(),
"embedded candidate list entry fails CAS format check: {cas}"
);
}
}
}
#[cfg(test)]
mod provenance_tests {
use super::*;
#[test]
fn provenance_reports_the_embedded_count() {
let p = candidate_list_provenance();
assert_eq!(p.embedded_count, ECHA_CANDIDATE_LIST.len());
assert_eq!(p.as_of, "2026-02-04");
}
#[test]
fn snapshot_is_known_incomplete() {
assert!(
!candidate_list_provenance().is_complete(),
"snapshot now claims completeness — update the module docs and the \
adjacent-obligations research note if that is genuinely true"
);
}
#[test]
fn the_2026_02_04_additions_are_present() {
assert!(
ECHA_CANDIDATE_LIST.contains(&"110-54-3"),
"n-Hexane missing"
);
assert!(ECHA_CANDIDATE_LIST.contains(&"1478-61-1"), "BPAF missing");
}
#[test]
fn embedded_list_has_no_duplicates() {
for (i, cas) in ECHA_CANDIDATE_LIST.iter().enumerate() {
assert!(
!ECHA_CANDIDATE_LIST[..i].contains(cas),
"duplicate CAS {cas} in the embedded candidate list"
);
}
}
#[test]
fn absent_cas_is_unknown_not_cleared() {
let unknown = SvhcInput {
cas_number: "50-00-0", substance_name: "Formaldehyde",
concentration_pct: 5.0,
};
let f = check_svhc_declarations(&[unknown]);
assert_eq!(f[0].kind, SvhcFindingKind::NotInEmbeddedList);
}
}