use chematic::chem;
use chematic::smiles;
use crate::enrichment::ChemicalIdentityCandidate;
use super::{
preferred_smiles, CalculatedIdentityProperties, ChemicalNormalizationResult,
ChemicalNormalizer, NormalizationIssue, NormalizationStatus,
};
pub struct ChematicNormalizer;
impl ChemicalNormalizer for ChematicNormalizer {
fn normalize(&self, candidate: &ChemicalIdentityCandidate) -> ChemicalNormalizationResult {
let Some(source_smiles) = preferred_smiles(candidate) else {
return ChemicalNormalizationResult {
original_smiles: None,
canonical_smiles: None,
status: NormalizationStatus::MissingStructure,
issues: vec![],
calculated: CalculatedIdentityProperties::default(),
screening_alerts: vec![],
};
};
let mol = match smiles::parse(source_smiles) {
Ok(mol) => mol,
Err(_) => {
return ChemicalNormalizationResult {
original_smiles: Some(source_smiles.to_string()),
canonical_smiles: None,
status: NormalizationStatus::InvalidStructure,
issues: vec![NormalizationIssue::InvalidSmiles],
calculated: CalculatedIdentityProperties::default(),
screening_alerts: vec![],
};
}
};
let canonical = smiles::canonical_smiles(&mol);
let mut issues = Vec::new();
let has_multiple_fragments = chem::largest_fragment(&mol).atom_count() < mol.atom_count();
if has_multiple_fragments {
issues.push(NormalizationIssue::MultiFragmentStructure);
}
let formal_charge_sum = chem::formal_charge_sum(&mol);
if formal_charge_sum != 0 {
issues.push(NormalizationIssue::ChargeOrSaltPresent);
}
let calculated_formula = chem::calc_mol_formula(&mol);
if let Some(resolver_formula) = &candidate.molecular_formula {
if !formulas_equivalent(resolver_formula, &calculated_formula) {
issues.push(NormalizationIssue::FormulaMismatch);
}
}
let screening_alerts: Vec<String> = chem::pains_matches(&mol)
.into_iter()
.chain(chem::brenk_matches(&mol))
.map(str::to_string)
.collect();
let status = if issues.iter().any(|i| {
matches!(
i,
NormalizationIssue::FormulaMismatch | NormalizationIssue::MultiFragmentStructure
)
}) {
NormalizationStatus::ReviewRequired
} else {
NormalizationStatus::Normalized
};
ChemicalNormalizationResult {
original_smiles: Some(source_smiles.to_string()),
canonical_smiles: Some(canonical),
status,
issues,
calculated: CalculatedIdentityProperties {
molecular_formula: Some(calculated_formula),
molecular_weight: Some(chem::molecular_weight(&mol)),
formal_charge_sum: Some(formal_charge_sum),
has_multiple_fragments: Some(has_multiple_fragments),
},
screening_alerts,
}
}
}
fn formulas_equivalent(a: &str, b: &str) -> bool {
match (chem::parse_formula(a), chem::parse_formula(b)) {
(Ok(pa), Ok(pb)) => pa == pb,
_ => a == b,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn candidate(smiles: Option<&str>, formula: Option<&str>) -> ChemicalIdentityCandidate {
ChemicalIdentityCandidate {
cas: "test".into(),
pubchem_cid: Some(1),
iupac_name: None,
molecular_formula: formula.map(str::to_string),
smiles: smiles.map(str::to_string),
connectivity_smiles: None,
inchi_key: None,
}
}
#[test]
fn valid_smiles_parses_and_canonicalizes() {
let result = ChematicNormalizer.normalize(&candidate(Some("CCO"), None));
assert_eq!(result.status, NormalizationStatus::Normalized);
assert!(result.canonical_smiles.is_some());
}
#[test]
fn canonicalization_is_idempotent() {
let mol = smiles::parse("c1ccccc1").unwrap(); let c1 = smiles::canonical_smiles(&mol);
let c2 = smiles::canonical_smiles(&smiles::parse(&c1).unwrap());
assert_eq!(c1, c2);
}
#[test]
fn invalid_smiles_produces_invalid_structure_no_panic() {
let result = ChematicNormalizer.normalize(&candidate(Some("not-a-smiles((("), None));
assert_eq!(result.status, NormalizationStatus::InvalidStructure);
assert!(result.issues.contains(&NormalizationIssue::InvalidSmiles));
assert!(result.canonical_smiles.is_none());
}
#[test]
fn missing_smiles_reports_missing_structure_without_fabrication() {
let result = ChematicNormalizer.normalize(&candidate(None, None));
assert_eq!(result.status, NormalizationStatus::MissingStructure);
assert!(result.canonical_smiles.is_none());
assert!(result.calculated.molecular_formula.is_none());
}
#[test]
fn full_smiles_is_preferred_for_normalization() {
let mut c = candidate(Some("CCO"), None);
c.connectivity_smiles = Some("CO".into()); let result = ChematicNormalizer.normalize(&c);
assert_eq!(result.original_smiles.as_deref(), Some("CCO"));
}
#[test]
fn connectivity_smiles_is_used_only_as_fallback() {
let mut c = candidate(None, None);
c.connectivity_smiles = Some("CCO".into());
let result = ChematicNormalizer.normalize(&c);
assert_ne!(result.status, NormalizationStatus::MissingStructure);
assert_eq!(result.original_smiles.as_deref(), Some("CCO"));
}
#[test]
fn salt_is_not_reduced_to_largest_fragment() {
let result = ChematicNormalizer.normalize(&candidate(Some("[Na+].[Cl-]"), None));
assert!(result
.issues
.contains(&NormalizationIssue::MultiFragmentStructure));
assert_eq!(result.calculated.has_multiple_fragments, Some(true));
assert!(result.canonical_smiles.as_deref().unwrap().contains('.'));
}
#[test]
fn multi_fragment_structure_is_review_required_not_blocked_by_normalizer() {
let result = ChematicNormalizer.normalize(&candidate(Some("[Na+].[Cl-]"), None));
assert_eq!(result.status, NormalizationStatus::ReviewRequired);
}
#[test]
fn charged_structure_remains_charged() {
let result = ChematicNormalizer.normalize(&candidate(Some("[NH4+]"), None));
assert!(result
.issues
.contains(&NormalizationIssue::ChargeOrSaltPresent));
assert_eq!(result.calculated.formal_charge_sum, Some(1));
assert!(result.canonical_smiles.as_deref().unwrap().contains('+'));
}
#[test]
fn matching_formula_produces_no_mismatch_issue() {
let result = ChematicNormalizer.normalize(&candidate(Some("CCO"), Some("C2H6O")));
assert!(!result.issues.contains(&NormalizationIssue::FormulaMismatch));
assert_eq!(result.status, NormalizationStatus::Normalized);
}
#[test]
fn mismatched_formula_flagged_and_calculated_value_retained() {
let result = ChematicNormalizer.normalize(&candidate(Some("CCO"), Some("C6H12O6")));
assert!(result.issues.contains(&NormalizationIssue::FormulaMismatch));
assert_eq!(result.status, NormalizationStatus::ReviewRequired);
assert_eq!(
result.calculated.molecular_formula.as_deref(),
Some("C2H6O")
);
}
#[test]
fn no_product_level_property_field_exists_on_the_result() {
let calc = CalculatedIdentityProperties::default();
let CalculatedIdentityProperties {
molecular_formula,
molecular_weight,
formal_charge_sum,
has_multiple_fragments,
} = calc;
let _ = (
molecular_formula,
molecular_weight,
formal_charge_sum,
has_multiple_fragments,
);
}
#[test]
fn repeated_normalization_is_byte_equivalent() {
let c = candidate(Some("c1ccccc1"), Some("C6H6"));
let a = ChematicNormalizer.normalize(&c);
let b = ChematicNormalizer.normalize(&c);
assert_eq!(a.canonical_smiles, b.canonical_smiles);
assert_eq!(
a.calculated.molecular_formula,
b.calculated.molecular_formula
);
}
#[test]
fn stereochemical_smiles_parses_without_panic() {
let result = ChematicNormalizer.normalize(&candidate(Some("F/C=C/F"), None));
assert_ne!(result.status, NormalizationStatus::InvalidStructure);
}
#[test]
fn isotopically_labelled_smiles_parses_without_panic() {
let result = ChematicNormalizer.normalize(&candidate(Some("[13CH4]"), None));
assert_ne!(result.status, NormalizationStatus::InvalidStructure);
}
#[test]
fn very_long_smiles_does_not_panic() {
let long_chain = "C".repeat(500);
let result = ChematicNormalizer.normalize(&candidate(Some(&long_chain), None));
let _ = result.status;
}
#[test]
fn aromatic_molecule_normalizes() {
let result = ChematicNormalizer.normalize(&candidate(Some("c1ccccc1"), Some("C6H6")));
assert_eq!(result.status, NormalizationStatus::Normalized);
}
}