use alloc::{format, string::String, vec::Vec};
use super::cas::validate_cas_format;
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", ];
#[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 !s.concentration_pct.is_finite()
|| s.concentration_pct < 0.0
|| s.concentration_pct > 100.0
{
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}"
);
}
}
}