use std::collections::{HashMap, HashSet, VecDeque};
use chematic_core::{Atom, AtomIdx, BondIdx, BondOrder, Molecule, MoleculeBuilder};
use chematic_smarts::{MatchConfig, find_matches_with_config, parse_smarts};
use chematic_smiles::canonical_smiles;
#[derive(Debug, Clone, PartialEq)]
pub enum RGroupError {
InvalidSmarts(String),
}
impl std::fmt::Display for RGroupError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidSmarts(s) => write!(f, "invalid SMARTS: {s}"),
}
}
}
impl std::error::Error for RGroupError {}
#[derive(Debug, Clone)]
pub struct RGroupResult {
pub mol_idx: usize,
pub core_smiles: String,
pub r_groups: HashMap<u8, String>,
}
pub fn rgroup_decompose(
scaffold_smarts: &str,
mols: &[&Molecule],
) -> Result<Vec<Option<RGroupResult>>, RGroupError> {
let query =
parse_smarts(scaffold_smarts).map_err(|e| RGroupError::InvalidSmarts(e.to_string()))?;
let cfg = MatchConfig {
max_matches: Some(1),
..Default::default()
};
let mut results = Vec::with_capacity(mols.len());
for (mol_idx, &mol) in mols.iter().enumerate() {
let matches = find_matches_with_config(&query, mol, &cfg);
if matches.is_empty() {
results.push(None);
continue;
}
let core: HashSet<AtomIdx> = matches[0].values().copied().collect();
let mut attachment_pairs: Vec<(AtomIdx, AtomIdx)> = Vec::new();
{
let mut seen_bonds: HashSet<(u32, u32)> = HashSet::new();
let mut core_sorted: Vec<AtomIdx> = core.iter().copied().collect();
core_sorted.sort_by_key(|a| a.0);
for &ca in &core_sorted {
for (nb, _) in mol.neighbors(ca) {
if !core.contains(&nb) {
let key = (ca.0.min(nb.0), ca.0.max(nb.0));
if seen_bonds.insert(key) {
attachment_pairs.push((ca, nb));
}
}
}
}
}
let mut r_groups: HashMap<u8, String> = HashMap::new();
for (rg_idx, &(_core_atom, rg_root)) in attachment_pairs.iter().enumerate() {
let rg_num = (rg_idx + 1) as u8;
let fragment = extract_rgroup(mol, rg_root, &core);
r_groups.insert(rg_num, canonical_smiles(&fragment));
}
let core_mol = build_core_mol(mol, &core, &attachment_pairs);
let core_smiles = canonical_smiles(&core_mol);
results.push(Some(RGroupResult {
mol_idx,
core_smiles,
r_groups,
}));
}
Ok(results)
}
fn extract_rgroup(mol: &Molecule, root: AtomIdx, core: &HashSet<AtomIdx>) -> Molecule {
let mut rg_atoms: HashSet<AtomIdx> = HashSet::new();
let mut queue: VecDeque<AtomIdx> = VecDeque::new();
rg_atoms.insert(root);
queue.push_back(root);
while let Some(cur) = queue.pop_front() {
for (nb, _) in mol.neighbors(cur) {
if !core.contains(&nb) && !rg_atoms.contains(&nb) {
rg_atoms.insert(nb);
queue.push_back(nb);
}
}
}
let mut builder = MoleculeBuilder::new();
let mut remap: HashMap<AtomIdx, AtomIdx> = HashMap::new();
let new_root = builder.add_atom(mol.atom(root).clone());
remap.insert(root, new_root);
let mut sorted: Vec<AtomIdx> = rg_atoms.iter().copied().filter(|&a| a != root).collect();
sorted.sort_by_key(|a| a.0);
for &old_idx in &sorted {
let new_idx = builder.add_atom(mol.atom(old_idx).clone());
remap.insert(old_idx, new_idx);
}
for bidx in 0..mol.bond_count() {
let bond = mol.bond(BondIdx(bidx as u32));
if let (Some(&new_a), Some(&new_b)) = (remap.get(&bond.atom1), remap.get(&bond.atom2)) {
let _ = builder.add_bond(new_a, new_b, bond.order);
}
}
let wc = builder.add_atom(Atom::wildcard());
let _ = builder.add_bond(new_root, wc, BondOrder::Single);
builder.build()
}
fn build_core_mol(
mol: &Molecule,
core: &HashSet<AtomIdx>,
attachment_pairs: &[(AtomIdx, AtomIdx)],
) -> Molecule {
let mut builder = MoleculeBuilder::new();
let mut remap: HashMap<AtomIdx, AtomIdx> = HashMap::new();
let mut core_sorted: Vec<AtomIdx> = core.iter().copied().collect();
core_sorted.sort_by_key(|a| a.0);
for &old_idx in &core_sorted {
let new_idx = builder.add_atom(mol.atom(old_idx).clone());
remap.insert(old_idx, new_idx);
}
for bidx in 0..mol.bond_count() {
let bond = mol.bond(BondIdx(bidx as u32));
if let (Some(&na), Some(&nb)) = (remap.get(&bond.atom1), remap.get(&bond.atom2)) {
let _ = builder.add_bond(na, nb, bond.order);
}
}
for &(core_atom, _rg_root) in attachment_pairs {
if let Some(&new_ca) = remap.get(&core_atom) {
let wc = builder.add_atom(Atom::wildcard());
let _ = builder.add_bond(new_ca, wc, BondOrder::Single);
}
}
builder.build()
}
#[cfg(test)]
mod tests {
use super::*;
use chematic_smiles::parse;
fn mol(s: &str) -> Molecule {
parse(s).unwrap_or_else(|e| panic!("parse '{s}': {e}"))
}
fn decompose<'a>(
scaffold: &str,
smiles: impl IntoIterator<Item = &'a str>,
) -> Vec<Option<RGroupResult>> {
let mols: Vec<Molecule> = smiles.into_iter().map(mol).collect();
let refs: Vec<&Molecule> = mols.iter().collect();
rgroup_decompose(scaffold, &refs).expect("decompose failed")
}
#[test]
fn test_rgroup_monosubstituted_benzene() {
let results = decompose("c1ccccc1", ["Cc1ccccc1", "CCc1ccccc1", "CCCc1ccccc1"]);
assert_eq!(results.len(), 3);
for (i, r) in results.iter().enumerate() {
let r = r.as_ref().unwrap_or_else(|| panic!("mol {i} should match"));
assert_eq!(r.mol_idx, i);
assert_eq!(r.r_groups.len(), 1, "mol {i}: expected 1 R-group");
let r1 = r.r_groups.get(&1).expect("R1 missing");
assert!(
r1.contains('*'),
"R1 must contain [*] attachment, got {r1:?}"
);
}
}
#[test]
fn test_rgroup_mol_idx_preserved() {
let results = decompose("c1ccccc1", ["Cc1ccccc1", "CCCC", "CCCc1ccccc1"]);
assert!(results[0].is_some());
assert!(results[1].is_none());
assert!(results[2].is_some());
assert_eq!(results[0].as_ref().unwrap().mol_idx, 0);
assert_eq!(results[2].as_ref().unwrap().mol_idx, 2);
}
#[test]
fn test_rgroup_no_substituents() {
let results = decompose("c1ccccc1", ["c1ccccc1"]);
let r = results[0]
.as_ref()
.expect("benzene should match its own scaffold");
assert_eq!(r.r_groups.len(), 0, "benzene has no R-groups vs itself");
}
#[test]
fn test_rgroup_two_substituents() {
let results = decompose("c1ccccc1", ["Cc1ccc(C)cc1"]);
let r = results[0].as_ref().expect("should match");
assert_eq!(r.r_groups.len(), 2, "para-xylene has two R-groups");
}
#[test]
fn test_rgroup_invalid_smarts() {
let mols = [mol("c1ccccc1")];
let refs: Vec<&Molecule> = mols.iter().collect();
let err = rgroup_decompose("((invalid", &refs);
assert!(matches!(err, Err(RGroupError::InvalidSmarts(_))));
}
#[test]
fn test_rgroup_different_heteroatom_substituents() {
let results = decompose("c1ccccc1", ["Nc1ccccc1", "Oc1ccccc1"]);
let aniline = results[0].as_ref().expect("aniline matches");
let phenol = results[1].as_ref().expect("phenol matches");
assert_eq!(aniline.r_groups.len(), 1);
assert_eq!(phenol.r_groups.len(), 1);
let r1_a = aniline.r_groups.get(&1).unwrap();
let r1_p = phenol.r_groups.get(&1).unwrap();
assert!(
r1_a.starts_with('N') || r1_a.contains('N'),
"aniline R1 should contain N, got {r1_a}"
);
assert!(
r1_p.starts_with('O') || r1_p.contains('O'),
"phenol R1 should contain O, got {r1_p}"
);
}
}