Skip to main content

chematic_rxn/
query.rs

1//! Reaction SMARTS querying for chemical reaction matching.
2
3use chematic_smarts::{QueryMolecule, find_matches, parse_smarts};
4use std::collections::HashSet;
5
6use crate::reaction::Reaction;
7
8/// A reaction query consisting of reactant and product SMARTS patterns.
9#[derive(Clone, Debug)]
10pub struct ReactionQuery {
11    /// SMARTS queries for reactant pattern matching.
12    pub reactant_patterns: Vec<QueryMolecule>,
13    /// SMARTS queries for product pattern matching.
14    pub product_patterns: Vec<QueryMolecule>,
15}
16
17/// A reaction SMARTS pattern with atom mapping and agent support.
18#[derive(Clone, Debug)]
19pub struct ReactionSmartsPattern {
20    /// SMARTS queries for reactant pattern matching.
21    pub reactant_patterns: Vec<QueryMolecule>,
22    /// SMARTS queries for agent pattern matching (optional middle section).
23    pub agent_patterns: Vec<QueryMolecule>,
24    /// SMARTS queries for product pattern matching.
25    pub product_patterns: Vec<QueryMolecule>,
26    /// Metadata about atom map numbers (e.g., :1, :2, etc.)
27    pub map_number_info: MapNumberInfo,
28}
29
30/// Information about atom map numbers in a reaction SMARTS pattern.
31#[derive(Clone, Debug)]
32pub struct MapNumberInfo {
33    /// All unique map numbers found in the pattern.
34    pub all_map_numbers: HashSet<u16>,
35    /// Map numbers found in reactants.
36    pub reactant_maps: HashSet<u16>,
37    /// Map numbers found in agents.
38    pub agent_maps: HashSet<u16>,
39    /// Map numbers found in products.
40    pub product_maps: HashSet<u16>,
41}
42
43/// Detailed information about a single reactant or product pattern match.
44#[derive(Clone, Debug)]
45pub struct MoleculeMatch {
46    /// Index of the molecule in the reaction (reactants or products list).
47    pub molecule_index: usize,
48    /// Index of the pattern that matched.
49    pub pattern_index: usize,
50    /// Indices of the matched atoms in the molecule.
51    pub atom_indices: Vec<usize>,
52}
53
54/// Detailed information about reactant pattern matches in a reaction.
55#[derive(Clone, Debug)]
56pub struct ReactantMatches {
57    /// Matches for each reactant pattern.
58    pub pattern_matches: Vec<Vec<MoleculeMatch>>,
59}
60
61impl ReactantMatches {
62    /// Get all molecules that matched a specific reactant pattern.
63    pub fn get_pattern_matches(&self, pattern_index: usize) -> Option<&[MoleculeMatch]> {
64        self.pattern_matches
65            .get(pattern_index)
66            .map(|v| v.as_slice())
67    }
68
69    /// Check if a specific reactant pattern matched any molecule.
70    pub fn pattern_matched(&self, pattern_index: usize) -> bool {
71        self.pattern_matches
72            .get(pattern_index)
73            .is_some_and(|matches| !matches.is_empty())
74    }
75}
76
77/// Detailed information about product pattern matches in a reaction.
78#[derive(Clone, Debug)]
79pub struct ProductMatches {
80    /// Matches for each product pattern.
81    pub pattern_matches: Vec<Vec<MoleculeMatch>>,
82}
83
84impl ProductMatches {
85    /// Get all molecules that matched a specific product pattern.
86    pub fn get_pattern_matches(&self, pattern_index: usize) -> Option<&[MoleculeMatch]> {
87        self.pattern_matches
88            .get(pattern_index)
89            .map(|v| v.as_slice())
90    }
91
92    /// Check if a specific product pattern matched any molecule.
93    pub fn pattern_matched(&self, pattern_index: usize) -> bool {
94        self.pattern_matches
95            .get(pattern_index)
96            .is_some_and(|matches| !matches.is_empty())
97    }
98}
99
100/// Detailed match information for a reaction against a SMARTS pattern.
101#[derive(Clone, Debug)]
102pub struct ReactionSmartsMatch {
103    /// Reactant pattern matches (all patterns must have at least one match for overall match).
104    pub reactant_matches: ReactantMatches,
105    /// Product pattern matches (all patterns must have at least one match for overall match).
106    pub product_matches: ProductMatches,
107    /// Whether all patterns matched (true if reaction is valid against the query).
108    pub is_complete_match: bool,
109}
110
111impl ReactionSmartsMatch {
112    /// Check if all reactant patterns matched.
113    pub fn all_reactants_matched(&self) -> bool {
114        self.reactant_matches
115            .pattern_matches
116            .iter()
117            .all(|m| !m.is_empty())
118    }
119
120    /// Check if all product patterns matched.
121    pub fn all_products_matched(&self) -> bool {
122        self.product_matches
123            .pattern_matches
124            .iter()
125            .all(|m| !m.is_empty())
126    }
127}
128
129impl MapNumberInfo {
130    /// Check if all map numbers are consistent across reactants and products.
131    pub fn validate(&self) -> Result<(), String> {
132        // All map numbers in reactants must appear in products
133        let missing_in_products: Vec<u16> = self
134            .reactant_maps
135            .iter()
136            .filter(|&m| !self.product_maps.contains(m))
137            .copied()
138            .collect();
139
140        if !missing_in_products.is_empty() {
141            return Err(format!(
142                "map numbers in reactants missing from products: {:?}",
143                missing_in_products
144            ));
145        }
146
147        // All map numbers in products should be in reactants
148        let undefined_in_reactants: Vec<u16> = self
149            .product_maps
150            .iter()
151            .filter(|&m| !self.reactant_maps.contains(m))
152            .copied()
153            .collect();
154
155        if !undefined_in_reactants.is_empty() {
156            return Err(format!(
157                "map numbers in products not found in reactants: {:?}",
158                undefined_in_reactants
159            ));
160        }
161
162        Ok(())
163    }
164}
165
166/// Error type for reaction query operations.
167#[derive(Debug)]
168pub enum ReactionQueryError {
169    /// Failed to parse a SMARTS pattern.
170    SmartsParseError { smarts: String, source: String },
171    /// Missing arrow delimiter in reaction SMARTS.
172    MissingArrowDelimiter,
173    /// Invalid agents section (middle part between > and >).
174    InvalidAgentsSection,
175    /// Map number inconsistency (e.g., :1 in reactants but not products).
176    MapNumberMismatch { map_num: u16, message: String },
177}
178
179impl core::fmt::Display for ReactionQueryError {
180    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
181        match self {
182            Self::SmartsParseError { smarts, source } => {
183                write!(f, "failed to parse SMARTS '{smarts}': {source}")
184            }
185            Self::MissingArrowDelimiter => {
186                write!(f, "reaction SMARTS must contain '>' or '>>' delimiter")
187            }
188            Self::InvalidAgentsSection => {
189                write!(f, "invalid agents section in reaction SMARTS")
190            }
191            Self::MapNumberMismatch { map_num, message } => {
192                write!(f, "map number :{} error: {}", map_num, message)
193            }
194        }
195    }
196}
197
198impl std::error::Error for ReactionQueryError {}
199
200/// RDKit compatibility information and validation.
201pub mod rdkit_compat {
202    use super::*;
203
204    /// Result of RDKit compatibility checking.
205    #[derive(Clone, Debug)]
206    pub struct RDKitCompatReport {
207        /// Whether the reaction SMARTS is fully RDKit-compatible.
208        pub is_compatible: bool,
209        /// Warnings about potential differences in behavior.
210        pub warnings: Vec<String>,
211        /// RDKit format (legacy ">>" vs new ">").
212        pub rdkit_format: RDKitFormat,
213    }
214
215    /// RDKit reaction SMARTS format variants.
216    #[derive(Clone, Debug, PartialEq, Eq)]
217    pub enum RDKitFormat {
218        /// Legacy 2-part format: "reactants>>products"
219        Legacy,
220        /// New 3-part format: "reactants>agents>products"
221        WithAgents,
222    }
223
224    /// Check RDKit compatibility of a reaction SMARTS pattern.
225    ///
226    /// Returns a `RDKitCompatReport` with compatibility status and any warnings.
227    /// Both chematic and RDKit support the same reaction SMARTS format, but
228    /// there may be subtle differences in pattern matching behavior.
229    pub fn check_rdkit_compatibility(
230        smarts: &str,
231    ) -> Result<RDKitCompatReport, ReactionQueryError> {
232        let mut warnings = Vec::new();
233
234        // Detect format
235        let rdkit_format = if smarts.contains(">>") {
236            RDKitFormat::Legacy
237        } else if smarts.contains('>') {
238            RDKitFormat::WithAgents
239        } else {
240            return Err(ReactionQueryError::MissingArrowDelimiter);
241        };
242
243        // Try to parse to validate syntax
244        let _pattern = super::parse_reaction_smarts(smarts)?;
245
246        // Check for RDKit-specific concerns
247        if smarts.contains("[#") {
248            // Atomic number queries are fully supported
249        }
250
251        if smarts.contains("[*") {
252            // Wildcard atoms
253            warnings.push("wildcard atoms [*] may match behavior differently".to_string());
254        }
255
256        if smarts.contains("$") {
257            // Recursive SMARTS are complex
258            warnings.push("recursive SMARTS ($(...)) have complex matching behavior".to_string());
259        }
260
261        // Check for pipe-separated OR patterns
262        let section_count = smarts.matches('>').count();
263        let has_or_patterns = smarts.contains('|');
264        if has_or_patterns && section_count == 0 {
265            warnings
266                .push("pipe-separated patterns (|) require at least one > delimiter".to_string());
267        }
268
269        Ok(RDKitCompatReport {
270            is_compatible: warnings.is_empty(),
271            warnings,
272            rdkit_format,
273        })
274    }
275
276    /// Convert chematic ReactionSmartsMatch result to RDKit-compatible format.
277    ///
278    /// Returns a summary suitable for comparison with RDKit results.
279    pub fn match_to_rdkit_format(smarts_match: &ReactionSmartsMatch) -> RDKitMatchSummary {
280        RDKitMatchSummary {
281            matched: smarts_match.is_complete_match,
282            reactant_count: smarts_match.reactant_matches.pattern_matches.len(),
283            product_count: smarts_match.product_matches.pattern_matches.len(),
284            reactant_molecules_matched: smarts_match
285                .reactant_matches
286                .pattern_matches
287                .iter()
288                .filter(|m| !m.is_empty())
289                .count(),
290            product_molecules_matched: smarts_match
291                .product_matches
292                .pattern_matches
293                .iter()
294                .filter(|m| !m.is_empty())
295                .count(),
296        }
297    }
298
299    /// Summary of match result in RDKit-comparable format.
300    #[derive(Clone, Debug)]
301    pub struct RDKitMatchSummary {
302        /// Whether the reaction matched the pattern.
303        pub matched: bool,
304        /// Number of reactant patterns in the query.
305        pub reactant_count: usize,
306        /// Number of product patterns in the query.
307        pub product_count: usize,
308        /// How many reactant patterns matched.
309        pub reactant_molecules_matched: usize,
310        /// How many product patterns matched.
311        pub product_molecules_matched: usize,
312    }
313
314    /// Validate that a reaction SMARTS follows RDKit conventions.
315    pub fn validate_rdkit_conventions(smarts: &str) -> Result<(), String> {
316        // Check for balanced arrow delimiters
317        let arrow_count = smarts.matches('>').count();
318        if arrow_count == 0 {
319            return Err("must contain at least one '>' or '>>' delimiter".to_string());
320        }
321
322        // Check for empty sections (three > in a row would be >>>)
323        if smarts.contains(">>>") {
324            return Err("invalid delimiter sequence >>>".to_string());
325        }
326
327        // Map numbers must be between 1 and 999 in RDKit
328        // Extract all map numbers
329        let map_nums: Vec<u16> = smarts
330            .match_indices(':')
331            .filter_map(|(i, _)| {
332                let remainder = &smarts[i + 1..];
333                let digits: String = remainder
334                    .chars()
335                    .take_while(|c| c.is_ascii_digit())
336                    .collect();
337                if !digits.is_empty() {
338                    digits.parse().ok()
339                } else {
340                    None
341                }
342            })
343            .collect();
344
345        for map_num in map_nums {
346            if map_num == 0 || map_num > 999 {
347                return Err(format!(
348                    "map number :{} outside RDKit range [1-999]",
349                    map_num
350                ));
351            }
352        }
353
354        Ok(())
355    }
356}
357
358/// Parse a reaction SMARTS pattern with agents section and atom mapping support.
359///
360/// Format: `"reactants>[agents]>products"` (new) or `"reactants>>products"` (legacy)
361/// where each section is a pipe-separated (|) list of SMARTS patterns.
362///
363/// Validates atom map numbers for consistency between reactants and products.
364///
365/// # Example
366///
367/// ```ignore
368/// // With agents section
369/// let pattern = parse_reaction_smarts("[C:1][C:2]>[Pd]>[C:2][C:1]").unwrap();
370/// // Legacy format
371/// let pattern = parse_reaction_smarts("[C:1][C:2]>>[C:2][C:1]").unwrap();
372/// ```
373pub fn parse_reaction_smarts(
374    smarts_str: &str,
375) -> Result<ReactionSmartsPattern, ReactionQueryError> {
376    // Detect format by looking for ">>" or ">"
377    let has_double_arrow = smarts_str.contains(">>");
378
379    let (reactant_strs, agent_strs, product_strs) = if has_double_arrow {
380        // Legacy format: reactants >> products
381        let parts: Vec<&str> = smarts_str.splitn(2, ">>").collect();
382        if parts.len() != 2 {
383            return Err(ReactionQueryError::MissingArrowDelimiter);
384        }
385        (parts[0], "", parts[1])
386    } else {
387        // New format: reactants > agents > products
388        let parts: Vec<&str> = smarts_str.splitn(3, '>').collect();
389        if parts.len() < 2 {
390            return Err(ReactionQueryError::MissingArrowDelimiter);
391        }
392        if parts.len() == 2 {
393            // Only one '>' found, treat as reactants > products (no agents)
394            (parts[0], "", parts[1])
395        } else {
396            // Two '>' found: reactants > agents > products
397            (parts[0], parts[1], parts[2])
398        }
399    };
400
401    let reactant_patterns = parse_patterns(reactant_strs)?;
402    let product_patterns = parse_patterns(product_strs)?;
403    let agent_patterns = if agent_strs.is_empty() {
404        Vec::new()
405    } else {
406        parse_patterns(agent_strs)?
407    };
408
409    let map_number_info = extract_map_numbers(smarts_str)?;
410
411    Ok(ReactionSmartsPattern {
412        reactant_patterns,
413        agent_patterns,
414        product_patterns,
415        map_number_info,
416    })
417}
418
419/// Extracts and validates atom map numbers from a reaction SMARTS string.
420fn extract_map_numbers(smarts_str: &str) -> Result<MapNumberInfo, ReactionQueryError> {
421    let mut all_map_numbers = HashSet::new();
422    let mut reactant_maps = HashSet::new();
423    let mut agent_maps = HashSet::new();
424    let mut product_maps = HashSet::new();
425
426    // Find section boundaries
427    let has_double_arrow = smarts_str.contains(">>");
428
429    let (reactant_end, agent_start, agent_end, product_start) = if has_double_arrow {
430        let idx = smarts_str.find(">>").unwrap();
431        (idx, idx, idx, idx + 2)
432    } else {
433        let arrow_positions: Vec<_> = smarts_str.match_indices('>').collect();
434        match arrow_positions.len() {
435            0 => {
436                return Ok(MapNumberInfo {
437                    all_map_numbers,
438                    reactant_maps,
439                    agent_maps,
440                    product_maps,
441                });
442            }
443            1 => {
444                let idx = arrow_positions[0].0;
445                (idx, idx + 1, idx + 1, idx + 1)
446            }
447            _ => {
448                let first = arrow_positions[0].0;
449                let second = arrow_positions[1].0;
450                (first, first + 1, second, second + 1)
451            }
452        }
453    };
454
455    // Extract maps from reactants
456    let reactant_section = &smarts_str[..reactant_end];
457    for map_num in extract_map_numbers_from_section(reactant_section) {
458        reactant_maps.insert(map_num);
459        all_map_numbers.insert(map_num);
460    }
461
462    // Extract maps from agents (if present)
463    if agent_start < agent_end && agent_end <= smarts_str.len() {
464        let agent_section = &smarts_str[agent_start..agent_end];
465        if !agent_section.is_empty() {
466            for map_num in extract_map_numbers_from_section(agent_section) {
467                agent_maps.insert(map_num);
468                all_map_numbers.insert(map_num);
469            }
470        }
471    }
472
473    // Extract maps from products
474    if product_start < smarts_str.len() {
475        let product_section = &smarts_str[product_start..];
476        for map_num in extract_map_numbers_from_section(product_section) {
477            product_maps.insert(map_num);
478            all_map_numbers.insert(map_num);
479        }
480    }
481
482    let info = MapNumberInfo {
483        all_map_numbers,
484        reactant_maps,
485        agent_maps,
486        product_maps,
487    };
488
489    // Validate consistency (map numbers must exist in both reactants and products)
490    info.validate().map_err(|msg| {
491        let map_num = info.all_map_numbers.iter().next().copied().unwrap_or(0);
492        ReactionQueryError::MapNumberMismatch {
493            map_num,
494            message: msg,
495        }
496    })?;
497
498    Ok(info)
499}
500
501/// Extract map numbers (format `:123`) from a SMARTS section string.
502fn extract_map_numbers_from_section(smarts: &str) -> Vec<u16> {
503    let mut map_numbers = Vec::new();
504    let bytes = smarts.as_bytes();
505    let mut bracket_depth: u32 = 0;
506    let mut i = 0;
507
508    while i < bytes.len() {
509        match bytes[i] {
510            b'[' => {
511                bracket_depth += 1;
512                i += 1;
513            }
514            b']' => {
515                bracket_depth = bracket_depth.saturating_sub(1);
516                i += 1;
517            }
518            b':' if bracket_depth > 0 => {
519                // Atom map number: only valid inside a bracket atom `[…:N]`.
520                let mut j = i + 1;
521                let mut num_str = String::new();
522                while j < bytes.len() && bytes[j].is_ascii_digit() {
523                    num_str.push(bytes[j] as char);
524                    j += 1;
525                }
526                if !num_str.is_empty()
527                    && let Ok(num) = num_str.parse::<u16>()
528                {
529                    map_numbers.push(num);
530                }
531                i = j;
532            }
533            _ => {
534                i += 1;
535            }
536        }
537    }
538
539    map_numbers
540}
541
542/// Helper to parse pipe-separated SMARTS patterns.
543fn parse_patterns(side: &str) -> Result<Vec<QueryMolecule>, ReactionQueryError> {
544    if side.is_empty() {
545        return Ok(Vec::new());
546    }
547    side.split('|')
548        .filter(|p| !p.is_empty())
549        .map(|p| {
550            parse_smarts(p).map_err(|e| ReactionQueryError::SmartsParseError {
551                smarts: p.to_string(),
552                source: e.to_string(),
553            })
554        })
555        .collect()
556}
557
558/// Parse a reaction SMARTS query with reactant and product patterns.
559///
560/// Format: `"reactant_smarts>>product_smarts"`
561/// where each side is a pipe-separated (|) list of SMARTS patterns.
562///
563/// Example: `"[C:1]([#6])[C:2]>>[C:1][C:2]"` matches reactions that break and reform C-C bonds.
564pub fn parse_reaction_query(s: &str) -> Result<ReactionQuery, ReactionQueryError> {
565    let parts: Vec<&str> = s.splitn(2, ">>").collect();
566    if parts.len() != 2 {
567        return Err(ReactionQueryError::SmartsParseError {
568            smarts: s.to_string(),
569            source: "reaction query must contain '>>'".to_string(),
570        });
571    }
572
573    Ok(ReactionQuery {
574        reactant_patterns: parse_patterns(parts[0])?,
575        product_patterns: parse_patterns(parts[1])?,
576    })
577}
578
579/// Check if a reaction matches the given query pattern.
580///
581/// Returns `true` if:
582/// - All reactant patterns match at least one reactant molecule, AND
583/// - All product patterns match at least one product molecule
584///
585/// If the query has no patterns (empty reaction query), returns `true` (trivial match).
586pub fn has_reaction_substructure_match(rxn: &Reaction, query: &ReactionQuery) -> bool {
587    // Check if all reactant patterns are satisfied
588    for pattern in &query.reactant_patterns {
589        let mut matched = false;
590        for mol in &rxn.reactants {
591            if !find_matches(pattern, mol).is_empty() {
592                matched = true;
593                break;
594            }
595        }
596        if !matched {
597            return false;
598        }
599    }
600
601    // Check if all product patterns are satisfied
602    for pattern in &query.product_patterns {
603        let mut matched = false;
604        for mol in &rxn.products {
605            if !find_matches(pattern, mol).is_empty() {
606                matched = true;
607                break;
608            }
609        }
610        if !matched {
611            return false;
612        }
613    }
614
615    true
616}
617
618/// Get detailed match information for a reaction against a query pattern.
619///
620/// Returns a `ReactionSmartsMatch` containing:
621/// - All matched atoms for each pattern
622/// - Which molecules matched which patterns
623/// - Overall match status
624///
625/// This is useful for understanding *how* a reaction matches, not just *whether* it matches.
626pub fn get_reaction_smarts_matches(rxn: &Reaction, query: &ReactionQuery) -> ReactionSmartsMatch {
627    // Collect all reactant pattern matches
628    let mut reactant_pattern_matches = Vec::new();
629    for (pattern_idx, pattern) in query.reactant_patterns.iter().enumerate() {
630        let mut matches_for_pattern = Vec::new();
631        for (mol_idx, mol) in rxn.reactants.iter().enumerate() {
632            let atom_matches = find_matches(pattern, mol);
633            // Use first match if any exist (we only care that it matched)
634            if let Some(first_match) = atom_matches.first() {
635                let atom_indices: Vec<usize> = first_match
636                    .values()
637                    .map(|atom_idx| atom_idx.0 as usize)
638                    .collect();
639                matches_for_pattern.push(MoleculeMatch {
640                    molecule_index: mol_idx,
641                    pattern_index: pattern_idx,
642                    atom_indices,
643                });
644            }
645        }
646        reactant_pattern_matches.push(matches_for_pattern);
647    }
648
649    // Collect all product pattern matches
650    let mut product_pattern_matches = Vec::new();
651    for (pattern_idx, pattern) in query.product_patterns.iter().enumerate() {
652        let mut matches_for_pattern = Vec::new();
653        for (mol_idx, mol) in rxn.products.iter().enumerate() {
654            let atom_matches = find_matches(pattern, mol);
655            // Use first match if any exist (we only care that it matched)
656            if let Some(first_match) = atom_matches.first() {
657                let atom_indices: Vec<usize> = first_match
658                    .values()
659                    .map(|atom_idx| atom_idx.0 as usize)
660                    .collect();
661                matches_for_pattern.push(MoleculeMatch {
662                    molecule_index: mol_idx,
663                    pattern_index: pattern_idx,
664                    atom_indices,
665                });
666            }
667        }
668        product_pattern_matches.push(matches_for_pattern);
669    }
670
671    // Check if all patterns matched
672    let all_reactants_matched = reactant_pattern_matches.iter().all(|m| !m.is_empty());
673    let all_products_matched = product_pattern_matches.iter().all(|m| !m.is_empty());
674    let is_complete_match = all_reactants_matched && all_products_matched;
675
676    ReactionSmartsMatch {
677        reactant_matches: ReactantMatches {
678            pattern_matches: reactant_pattern_matches,
679        },
680        product_matches: ProductMatches {
681            pattern_matches: product_pattern_matches,
682        },
683        is_complete_match,
684    }
685}
686
687/// Batch query results with statistics and filtering.
688#[derive(Clone, Debug)]
689pub struct BatchQueryResults {
690    /// Total reactions queried
691    pub total_reactions: usize,
692    /// Reactions that matched the query
693    pub matching_reactions: usize,
694    /// Match percentage (0-100)
695    pub match_percentage: f64,
696    /// Individual match results
697    pub matches: Vec<(usize, bool)>, // (reaction_index, is_match)
698}
699
700impl BatchQueryResults {
701    /// Get indices of all matching reactions
702    pub fn matching_indices(&self) -> Vec<usize> {
703        self.matches
704            .iter()
705            .filter_map(|(idx, matched)| if *matched { Some(*idx) } else { None })
706            .collect()
707    }
708
709    /// Get indices of all non-matching reactions
710    pub fn non_matching_indices(&self) -> Vec<usize> {
711        self.matches
712            .iter()
713            .filter_map(|(idx, matched)| if !*matched { Some(*idx) } else { None })
714            .collect()
715    }
716}
717
718/// Pattern library for fast reaction querying against multiple patterns
719#[derive(Clone, Debug)]
720pub struct ReactionPatternLibrary {
721    /// Named reaction patterns
722    pub patterns: std::collections::HashMap<String, ReactionSmartsPattern>,
723}
724
725impl ReactionPatternLibrary {
726    /// Create a new empty pattern library
727    pub fn new() -> Self {
728        ReactionPatternLibrary {
729            patterns: std::collections::HashMap::new(),
730        }
731    }
732
733    /// Add a named pattern to the library
734    pub fn add_pattern(&mut self, name: String, pattern: ReactionSmartsPattern) {
735        self.patterns.insert(name, pattern);
736    }
737
738    /// Add a pattern from SMARTS string
739    pub fn add_pattern_from_smarts(
740        &mut self,
741        name: String,
742        smarts: &str,
743    ) -> Result<(), ReactionQueryError> {
744        let pattern = parse_reaction_smarts(smarts)?;
745        self.add_pattern(name, pattern);
746        Ok(())
747    }
748
749    /// Get number of patterns in library
750    pub fn len(&self) -> usize {
751        self.patterns.len()
752    }
753
754    /// Check if library is empty
755    pub fn is_empty(&self) -> bool {
756        self.patterns.is_empty()
757    }
758
759    /// Get pattern by name
760    pub fn get(&self, name: &str) -> Option<&ReactionSmartsPattern> {
761        self.patterns.get(name)
762    }
763
764    /// List all pattern names
765    pub fn pattern_names(&self) -> Vec<String> {
766        self.patterns.keys().cloned().collect()
767    }
768}
769
770impl Default for ReactionPatternLibrary {
771    fn default() -> Self {
772        Self::new()
773    }
774}
775
776/// Query a single reaction with a SMARTS pattern
777pub fn query_reaction(
778    rxn: &Reaction,
779    smarts: &str,
780) -> Result<ReactionSmartsMatch, ReactionQueryError> {
781    let pattern = parse_reaction_smarts(smarts)?;
782    // Convert ReactionSmartsPattern to ReactionQuery for matching
783    let query = ReactionQuery {
784        reactant_patterns: pattern.reactant_patterns,
785        product_patterns: pattern.product_patterns,
786    };
787    Ok(get_reaction_smarts_matches(rxn, &query))
788}
789
790/// Batch query reactions against a single SMARTS pattern
791pub fn batch_query_reactions(
792    reactions: &[Reaction],
793    smarts: &str,
794) -> Result<BatchQueryResults, ReactionQueryError> {
795    let pattern = parse_reaction_smarts(smarts)?;
796    let query = ReactionQuery {
797        reactant_patterns: pattern.reactant_patterns,
798        product_patterns: pattern.product_patterns,
799    };
800
801    let mut matches = Vec::new();
802    let mut matching_count = 0;
803
804    for (idx, rxn) in reactions.iter().enumerate() {
805        let result = get_reaction_smarts_matches(rxn, &query);
806        let is_match = result.is_complete_match;
807        if is_match {
808            matching_count += 1;
809        }
810        matches.push((idx, is_match));
811    }
812
813    let match_percentage = if reactions.is_empty() {
814        0.0
815    } else {
816        (matching_count as f64 / reactions.len() as f64) * 100.0
817    };
818
819    Ok(BatchQueryResults {
820        total_reactions: reactions.len(),
821        matching_reactions: matching_count,
822        match_percentage,
823        matches,
824    })
825}
826
827/// Batch query reactions against a pattern library
828pub fn batch_query_with_library(
829    reactions: &[Reaction],
830    library: &ReactionPatternLibrary,
831) -> std::collections::HashMap<String, BatchQueryResults> {
832    let mut results = std::collections::HashMap::new();
833
834    for (pattern_name, pattern) in &library.patterns {
835        let query = ReactionQuery {
836            reactant_patterns: pattern.reactant_patterns.clone(),
837            product_patterns: pattern.product_patterns.clone(),
838        };
839
840        let mut matches = Vec::new();
841        let mut matching_count = 0;
842
843        for (idx, rxn) in reactions.iter().enumerate() {
844            let result = get_reaction_smarts_matches(rxn, &query);
845            let is_match = result.is_complete_match;
846            if is_match {
847                matching_count += 1;
848            }
849            matches.push((idx, is_match));
850        }
851
852        let match_percentage = if reactions.is_empty() {
853            0.0
854        } else {
855            (matching_count as f64 / reactions.len() as f64) * 100.0
856        };
857
858        results.insert(
859            pattern_name.clone(),
860            BatchQueryResults {
861                total_reactions: reactions.len(),
862                matching_reactions: matching_count,
863                match_percentage,
864                matches,
865            },
866        );
867    }
868
869    results
870}
871
872#[cfg(test)]
873mod tests {
874    use super::*;
875    use crate::parse_reaction;
876
877    fn rxn(s: &str) -> Reaction {
878        crate::reaction::parse_reaction(s).unwrap()
879    }
880
881    #[test]
882    fn test_parse_reaction_query_basic() {
883        let query = parse_reaction_query("[#6]>>[#6]").unwrap();
884        assert_eq!(query.reactant_patterns.len(), 1);
885        assert_eq!(query.product_patterns.len(), 1);
886    }
887
888    #[test]
889    fn test_parse_reaction_query_multiple_patterns() {
890        let query = parse_reaction_query("[#6]|[#7]>>[#8]|[#9]").unwrap();
891        assert_eq!(query.reactant_patterns.len(), 2);
892        assert_eq!(query.product_patterns.len(), 2);
893    }
894
895    #[test]
896    fn test_has_reaction_substructure_match_simple() {
897        // Reaction: ethane to ethane (trivial)
898        let rxn = rxn("CC>>CC");
899        let query = parse_reaction_query("[#6]>>[#6]").unwrap();
900        assert!(has_reaction_substructure_match(&rxn, &query));
901    }
902
903    #[test]
904    fn test_has_reaction_substructure_match_no_match() {
905        // Reaction: ethane to ethane
906        let rxn = rxn("CC>>CC");
907        // Query: looking for nitrogen (not present)
908        let query = parse_reaction_query("[#7]>>[#7]").unwrap();
909        assert!(!has_reaction_substructure_match(&rxn, &query));
910    }
911
912    #[test]
913    fn test_has_reaction_substructure_match_product_mismatch() {
914        // Reaction: ethane to methane
915        let rxn = rxn("CC>>C");
916        // Query: looking for ethane in products (not present)
917        let query = parse_reaction_query("[#6]>>CC").unwrap();
918        assert!(!has_reaction_substructure_match(&rxn, &query));
919    }
920
921    #[test]
922    fn test_has_reaction_substructure_match_empty_query() {
923        // Empty query should match any reaction (trivial)
924        let rxn = rxn("CC>>C");
925        let query = parse_reaction_query(">>").unwrap();
926        assert!(has_reaction_substructure_match(&rxn, &query));
927    }
928
929    // ===== Phase 1: Agents Section Support =====
930
931    #[test]
932    fn test_parse_reaction_smarts_legacy_format() {
933        // Legacy 2-part format should still work
934        let pattern = parse_reaction_smarts("[C:1][C:2]>>[C:2][C:1]").unwrap();
935        assert_eq!(pattern.reactant_patterns.len(), 1);
936        assert_eq!(pattern.agent_patterns.len(), 0);
937        assert_eq!(pattern.product_patterns.len(), 1);
938    }
939
940    #[test]
941    fn test_parse_reaction_smarts_with_agents() {
942        // New 3-part format with agents
943        let pattern = parse_reaction_smarts("[C:1][C:2]>[Pd]>[C:2][C:1]").unwrap();
944        assert_eq!(pattern.reactant_patterns.len(), 1);
945        assert_eq!(pattern.agent_patterns.len(), 1);
946        assert_eq!(pattern.product_patterns.len(), 1);
947    }
948
949    #[test]
950    fn test_parse_reaction_smarts_empty_agents() {
951        // Format: reactants > (empty) > products
952        let pattern = parse_reaction_smarts("[C:1][C:2]>>[C:2][C:1]").unwrap();
953        assert_eq!(pattern.agent_patterns.len(), 0);
954    }
955
956    #[test]
957    fn test_parse_reaction_smarts_multiple_agent_patterns() {
958        // Multiple agent patterns with pipe-separated OR logic
959        let pattern = parse_reaction_smarts("[C:1]>[Pd]|[Ni]>[C:1]").unwrap();
960        assert_eq!(pattern.agent_patterns.len(), 2);
961    }
962
963    #[test]
964    fn test_extract_map_numbers_basic() {
965        let pattern = parse_reaction_smarts("[C:1][C:2]>>[C:2][C:1]").unwrap();
966        assert!(pattern.map_number_info.reactant_maps.contains(&1));
967        assert!(pattern.map_number_info.reactant_maps.contains(&2));
968        assert!(pattern.map_number_info.product_maps.contains(&1));
969        assert!(pattern.map_number_info.product_maps.contains(&2));
970    }
971
972    #[test]
973    fn test_extract_map_numbers_with_agents() {
974        let pattern = parse_reaction_smarts("[C:1]>[Pd:3]>[C:1]").unwrap();
975        assert!(pattern.map_number_info.reactant_maps.contains(&1));
976        assert!(pattern.map_number_info.agent_maps.contains(&3));
977        assert!(pattern.map_number_info.product_maps.contains(&1));
978    }
979
980    #[test]
981    fn test_map_number_validation_missing_in_products() {
982        // Map number :1 in reactants but not in products should fail
983        let result = parse_reaction_smarts("[C:1][C:2]>>[C:2]");
984        assert!(result.is_err());
985        if let Err(ReactionQueryError::MapNumberMismatch { message, .. }) = result {
986            assert!(message.contains("missing from products"));
987        } else {
988            panic!("expected MapNumberMismatch error");
989        }
990    }
991
992    #[test]
993    fn test_map_number_validation_undefined_in_reactants() {
994        // Map number :1 in products but not in reactants should fail
995        let result = parse_reaction_smarts("[C:2]>>[C:1][C:2]");
996        assert!(result.is_err());
997        if let Err(ReactionQueryError::MapNumberMismatch { message, .. }) = result {
998            assert!(message.contains("not found in reactants"));
999        } else {
1000            panic!("expected MapNumberMismatch error");
1001        }
1002    }
1003
1004    #[test]
1005    fn test_map_number_validation_consistent() {
1006        // Valid: all map numbers present in both reactants and products
1007        let pattern = parse_reaction_smarts("[C:1][C:2][C:3]>>[C:3][C:1][C:2]").unwrap();
1008        assert_eq!(pattern.map_number_info.reactant_maps.len(), 3);
1009        assert_eq!(pattern.map_number_info.product_maps.len(), 3);
1010    }
1011
1012    #[test]
1013    fn test_map_numbers_no_maps() {
1014        // Pattern with no map numbers is valid
1015        let pattern = parse_reaction_smarts("[#6]>>[#6]").unwrap();
1016        assert_eq!(pattern.map_number_info.all_map_numbers.len(), 0);
1017    }
1018
1019    #[test]
1020    fn test_map_numbers_large_numbers() {
1021        // Handle large map numbers (e.g., :100, :999)
1022        let pattern = parse_reaction_smarts("[C:100][N:999]>>[N:999][C:100]").unwrap();
1023        assert!(pattern.map_number_info.reactant_maps.contains(&100));
1024        assert!(pattern.map_number_info.reactant_maps.contains(&999));
1025    }
1026
1027    #[test]
1028    fn test_parse_reaction_smarts_missing_delimiter() {
1029        // No '>' or '>>' delimiter should error
1030        let result = parse_reaction_smarts("[C:1][C:2][C:1][C:2]");
1031        assert!(result.is_err());
1032    }
1033
1034    #[test]
1035    fn test_agents_section_in_map_numbers() {
1036        // Agents can have their own map numbers (independent of reactant/product validation)
1037        let pattern = parse_reaction_smarts("[C:1]>[Pd:99]>[C:1]").unwrap();
1038        assert!(pattern.map_number_info.agent_maps.contains(&99));
1039        // Agent map :99 is not required in reactants/products
1040        assert!(!pattern.map_number_info.reactant_maps.contains(&99));
1041    }
1042
1043    // ===== Phase 2: Detailed Match Information =====
1044
1045    #[test]
1046    fn test_get_reaction_smarts_matches_basic() {
1047        // Simple match: ethane to ethane with carbon pattern
1048        let rxn = rxn("CC>>CC");
1049        let query = parse_reaction_query("[#6]>>[#6]").unwrap();
1050        let matches = get_reaction_smarts_matches(&rxn, &query);
1051
1052        // Should have one reactant pattern and one product pattern
1053        assert_eq!(matches.reactant_matches.pattern_matches.len(), 1);
1054        assert_eq!(matches.product_matches.pattern_matches.len(), 1);
1055
1056        // Both patterns should match
1057        assert!(matches.all_reactants_matched());
1058        assert!(matches.all_products_matched());
1059        assert!(matches.is_complete_match);
1060    }
1061
1062    #[test]
1063    fn test_get_reaction_smarts_matches_multiple_reactants() {
1064        // Multiple reactants: C + C >> CC
1065        let rxn = rxn("C.C>>CC");
1066        let query = parse_reaction_query("[#6]>>[#6]").unwrap();
1067        let matches = get_reaction_smarts_matches(&rxn, &query);
1068
1069        // Reactant pattern should match both molecules
1070        assert_eq!(matches.reactant_matches.pattern_matches[0].len(), 2);
1071        // Product pattern should match the combined molecule
1072        assert_eq!(matches.product_matches.pattern_matches[0].len(), 1);
1073    }
1074
1075    #[test]
1076    fn test_get_reaction_smarts_matches_incomplete() {
1077        // Query for nitrogen (not present) should have incomplete match
1078        let rxn = rxn("CC>>CC");
1079        let query = parse_reaction_query("[#7]>>[#6]").unwrap();
1080        let matches = get_reaction_smarts_matches(&rxn, &query);
1081
1082        // Reactant pattern should not match
1083        assert!(!matches.all_reactants_matched());
1084        assert!(!matches.is_complete_match);
1085    }
1086
1087    #[test]
1088    fn test_get_reaction_smarts_matches_empty_patterns() {
1089        // Empty query should match anything
1090        let rxn = rxn("CC>>CC");
1091        let query = parse_reaction_query(">>").unwrap();
1092        let matches = get_reaction_smarts_matches(&rxn, &query);
1093
1094        // Empty patterns should be considered complete matches
1095        assert!(matches.is_complete_match);
1096    }
1097
1098    #[test]
1099    fn test_molecule_match_has_correct_indices() {
1100        // Verify that matched atoms have correct indices
1101        let rxn = rxn("CC>>CC");
1102        let query = parse_reaction_query("[#6]>>[#6]").unwrap();
1103        let matches = get_reaction_smarts_matches(&rxn, &query);
1104
1105        // Reactant carbon pattern matches (first match = first atom)
1106        let reactant_matches = &matches.reactant_matches.pattern_matches[0];
1107        assert_eq!(reactant_matches.len(), 1);
1108        assert_eq!(reactant_matches[0].molecule_index, 0);
1109        assert!(!reactant_matches[0].atom_indices.is_empty()); // At least one carbon matched
1110
1111        // Product carbon pattern matches
1112        let product_matches = &matches.product_matches.pattern_matches[0];
1113        assert_eq!(product_matches.len(), 1);
1114        assert_eq!(product_matches[0].molecule_index, 0);
1115        assert!(!product_matches[0].atom_indices.is_empty()); // At least one carbon matched
1116    }
1117
1118    #[test]
1119    fn test_reactant_matches_get_pattern_matches() {
1120        // Test the get_pattern_matches helper method
1121        let rxn = rxn("CC>>CC");
1122        let query = parse_reaction_query("[#6]>>[#6]").unwrap();
1123        let matches = get_reaction_smarts_matches(&rxn, &query);
1124
1125        // Should be able to retrieve matches for a specific pattern
1126        let reactant_pattern_0 = matches.reactant_matches.get_pattern_matches(0);
1127        assert!(reactant_pattern_0.is_some());
1128        assert_eq!(reactant_pattern_0.unwrap().len(), 1);
1129
1130        // Out of bounds pattern should return None
1131        let reactant_pattern_1 = matches.reactant_matches.get_pattern_matches(1);
1132        assert!(reactant_pattern_1.is_none());
1133    }
1134
1135    #[test]
1136    fn test_product_matches_get_pattern_matches() {
1137        // Test the get_pattern_matches helper method for products
1138        let rxn = rxn("CC>>CC");
1139        let query = parse_reaction_query("[#6]>>[#6]").unwrap();
1140        let matches = get_reaction_smarts_matches(&rxn, &query);
1141
1142        let product_pattern_0 = matches.product_matches.get_pattern_matches(0);
1143        assert!(product_pattern_0.is_some());
1144        assert_eq!(product_pattern_0.unwrap().len(), 1);
1145    }
1146
1147    #[test]
1148    fn test_pattern_matched_helper() {
1149        // Test the pattern_matched helper method
1150        let rxn = rxn("CC>>CC");
1151        let query = parse_reaction_query("[#6]>>[#7]").unwrap();
1152        let matches = get_reaction_smarts_matches(&rxn, &query);
1153
1154        // Reactant pattern should match
1155        assert!(matches.reactant_matches.pattern_matched(0));
1156
1157        // Product pattern should not match (looking for nitrogen)
1158        assert!(!matches.product_matches.pattern_matched(0));
1159    }
1160
1161    #[test]
1162    fn test_multiple_patterns_or_logic() {
1163        // Test with multiple patterns (OR logic)
1164        let rxn = rxn("CC>>CC");
1165        let query = parse_reaction_query("[#6]|[#7]>>[#6]|[#8]").unwrap();
1166        let matches = get_reaction_smarts_matches(&rxn, &query);
1167
1168        // Should have two patterns each (carbon|nitrogen and carbon|oxygen)
1169        assert_eq!(matches.reactant_matches.pattern_matches.len(), 2);
1170        assert_eq!(matches.product_matches.pattern_matches.len(), 2);
1171
1172        // Both reactant patterns should match (we have carbons)
1173        assert!(matches.reactant_matches.pattern_matched(0)); // [#6]
1174        assert!(!matches.reactant_matches.pattern_matched(1)); // [#7] - no nitrogen
1175
1176        // Both product patterns should match
1177        assert!(matches.product_matches.pattern_matched(0)); // [#6]
1178        assert!(!matches.product_matches.pattern_matched(1)); // [#8] - no oxygen
1179
1180        // Overall match should be false (nitrogen pattern in reactants didn't match)
1181        assert!(!matches.is_complete_match);
1182    }
1183
1184    #[test]
1185    fn test_complex_reaction_matches() {
1186        // Test with more complex reaction
1187        let rxn = rxn("CC(C)>>CC=C");
1188        let query = parse_reaction_query("[#6]>>[#6]").unwrap();
1189        let matches = get_reaction_smarts_matches(&rxn, &query);
1190
1191        // Both should match
1192        assert!(matches.all_reactants_matched());
1193        assert!(matches.all_products_matched());
1194        assert!(matches.is_complete_match);
1195
1196        // Reactant has one molecule matching the pattern
1197        assert_eq!(matches.reactant_matches.pattern_matches[0].len(), 1);
1198        assert!(
1199            !matches.reactant_matches.pattern_matches[0][0]
1200                .atom_indices
1201                .is_empty()
1202        );
1203
1204        // Product has one molecule matching the pattern
1205        assert_eq!(matches.product_matches.pattern_matches[0].len(), 1);
1206        assert!(
1207            !matches.product_matches.pattern_matches[0][0]
1208                .atom_indices
1209                .is_empty()
1210        );
1211    }
1212
1213    // ===== Phase 3: RDKit Compatibility Tooling =====
1214
1215    #[test]
1216    fn test_rdkit_compat_check_legacy_format() {
1217        use crate::query::rdkit_compat::*;
1218
1219        let result = check_rdkit_compatibility("[C:1][C:2]>>[C:2][C:1]").unwrap();
1220        assert!(result.is_compatible);
1221        assert_eq!(result.rdkit_format, RDKitFormat::Legacy);
1222        assert!(result.warnings.is_empty());
1223    }
1224
1225    #[test]
1226    fn test_rdkit_compat_check_agents_format() {
1227        use crate::query::rdkit_compat::*;
1228
1229        let result = check_rdkit_compatibility("[C:1][C:2]>[Pd]>[C:2][C:1]").unwrap();
1230        assert!(result.is_compatible);
1231        assert_eq!(result.rdkit_format, RDKitFormat::WithAgents);
1232        assert!(result.warnings.is_empty());
1233    }
1234
1235    #[test]
1236    fn test_rdkit_compat_wildcard_warning() {
1237        use crate::query::rdkit_compat::*;
1238
1239        let result = check_rdkit_compatibility("[*:1]>>[*:1]").unwrap();
1240        assert!(!result.is_compatible);
1241        assert!(result.warnings.iter().any(|w| w.contains("wildcard")));
1242    }
1243
1244    #[test]
1245    fn test_rdkit_compat_recursive_smarts_warning() {
1246        use crate::query::rdkit_compat::*;
1247
1248        // Use simpler recursive SMARTS pattern
1249        let result = check_rdkit_compatibility("[#6;$([#6]~[#8])]>>[#6]").unwrap();
1250        assert!(!result.is_compatible);
1251        assert!(result.warnings.iter().any(|w| w.contains("recursive")));
1252    }
1253
1254    #[test]
1255    fn test_rdkit_compat_missing_delimiter() {
1256        use crate::query::rdkit_compat::*;
1257
1258        let result = check_rdkit_compatibility("[C:1][C:2][C:1][C:2]");
1259        assert!(result.is_err());
1260    }
1261
1262    #[test]
1263    fn test_rdkit_format_detection_legacy() {
1264        use crate::query::rdkit_compat::*;
1265
1266        let result = check_rdkit_compatibility("C>>C").unwrap();
1267        assert_eq!(result.rdkit_format, RDKitFormat::Legacy);
1268    }
1269
1270    #[test]
1271    fn test_rdkit_format_detection_agents() {
1272        use crate::query::rdkit_compat::*;
1273
1274        let result = check_rdkit_compatibility("C>[Pd]>C").unwrap();
1275        assert_eq!(result.rdkit_format, RDKitFormat::WithAgents);
1276    }
1277
1278    #[test]
1279    fn test_rdkit_match_summary_complete() {
1280        use crate::query::rdkit_compat::*;
1281
1282        let rxn = rxn("CC>>CC");
1283        let query = parse_reaction_query("[#6]>>[#6]").unwrap();
1284        let matches = get_reaction_smarts_matches(&rxn, &query);
1285
1286        let summary = match_to_rdkit_format(&matches);
1287        assert!(summary.matched);
1288        assert_eq!(summary.reactant_count, 1);
1289        assert_eq!(summary.product_count, 1);
1290        assert_eq!(summary.reactant_molecules_matched, 1);
1291        assert_eq!(summary.product_molecules_matched, 1);
1292    }
1293
1294    #[test]
1295    fn test_rdkit_match_summary_incomplete() {
1296        use crate::query::rdkit_compat::*;
1297
1298        let rxn = rxn("CC>>CC");
1299        let query = parse_reaction_query("[#7]>>[#6]").unwrap();
1300        let matches = get_reaction_smarts_matches(&rxn, &query);
1301
1302        let summary = match_to_rdkit_format(&matches);
1303        assert!(!summary.matched);
1304        assert_eq!(summary.reactant_molecules_matched, 0);
1305    }
1306
1307    #[test]
1308    fn test_rdkit_validate_conventions_valid() {
1309        use crate::query::rdkit_compat::*;
1310
1311        let result = validate_rdkit_conventions("[C:1][C:2]>>[C:2][C:1]");
1312        assert!(result.is_ok());
1313    }
1314
1315    #[test]
1316    fn test_rdkit_validate_conventions_no_delimiter() {
1317        use crate::query::rdkit_compat::*;
1318
1319        let result = validate_rdkit_conventions("[C:1][C:2][C:1][C:2]");
1320        assert!(result.is_err());
1321    }
1322
1323    #[test]
1324    fn test_rdkit_validate_conventions_triple_arrow() {
1325        use crate::query::rdkit_compat::*;
1326
1327        let result = validate_rdkit_conventions("[C:1]>>>[C:1]");
1328        assert!(result.is_err());
1329    }
1330
1331    #[test]
1332    fn test_rdkit_validate_conventions_map_number_zero() {
1333        use crate::query::rdkit_compat::*;
1334
1335        let result = validate_rdkit_conventions("[C:0]>>[C:0]");
1336        assert!(result.is_err());
1337        if let Err(msg) = result {
1338            assert!(msg.contains("outside RDKit range"));
1339        }
1340    }
1341
1342    #[test]
1343    fn test_rdkit_validate_conventions_map_number_out_of_range() {
1344        use crate::query::rdkit_compat::*;
1345
1346        let result = validate_rdkit_conventions("[C:1000]>>[C:1000]");
1347        assert!(result.is_err());
1348        if let Err(msg) = result {
1349            assert!(msg.contains("outside RDKit range"));
1350        }
1351    }
1352
1353    #[test]
1354    fn test_rdkit_validate_conventions_map_number_valid() {
1355        use crate::query::rdkit_compat::*;
1356
1357        let result = validate_rdkit_conventions("[C:1][N:999]>>[N:999][C:1]");
1358        assert!(result.is_ok());
1359    }
1360
1361    #[test]
1362    fn test_rdkit_compat_agents_pipes() {
1363        use crate::query::rdkit_compat::*;
1364
1365        let result = check_rdkit_compatibility("[C:1]>[Pd]|[Ni]>[C:1]").unwrap();
1366        assert!(result.is_compatible);
1367        assert_eq!(result.rdkit_format, RDKitFormat::WithAgents);
1368    }
1369
1370    #[test]
1371    fn test_rdkit_compat_multiple_patterns() {
1372        use crate::query::rdkit_compat::*;
1373
1374        let result = check_rdkit_compatibility("[#6]|[#7]>>[#8]|[#9]").unwrap();
1375        assert!(result.is_compatible);
1376    }
1377
1378    // B7 Enhancement Tests: Batch querying and pattern libraries
1379
1380    #[test]
1381    fn test_batch_query_reactions_empty() {
1382        let reactions: Vec<Reaction> = vec![];
1383        let smarts = "[C:1]>>[C:1]";
1384
1385        let result = batch_query_reactions(&reactions, smarts);
1386        assert!(result.is_ok());
1387
1388        let batch = result.unwrap();
1389        assert_eq!(batch.total_reactions, 0);
1390        assert_eq!(batch.matching_reactions, 0);
1391        assert_eq!(batch.match_percentage, 0.0);
1392    }
1393
1394    #[test]
1395    fn test_batch_query_reactions_single_match() {
1396        let rxn = parse_reaction("C>>C").unwrap();
1397        let reactions = vec![rxn];
1398        let smarts = "[C:1]>>[C:1]";
1399
1400        let result = batch_query_reactions(&reactions, smarts);
1401        assert!(result.is_ok());
1402
1403        let batch = result.unwrap();
1404        assert_eq!(batch.total_reactions, 1);
1405        assert!(batch.matching_reactions > 0);
1406    }
1407
1408    #[test]
1409    fn test_batch_query_reactions_multiple() {
1410        let rxn1 = parse_reaction("C>>C").unwrap();
1411        let rxn2 = parse_reaction("CC>>C").unwrap();
1412        let rxn3 = parse_reaction("CCC>>CC").unwrap();
1413
1414        let reactions = vec![rxn1, rxn2, rxn3];
1415        let smarts = "[C:1]>>[C:1]";
1416
1417        let result = batch_query_reactions(&reactions, smarts);
1418        assert!(result.is_ok());
1419
1420        let batch = result.unwrap();
1421        assert_eq!(batch.total_reactions, 3);
1422        assert_eq!(batch.matches.len(), 3);
1423    }
1424
1425    #[test]
1426    fn test_batch_query_results_indices() {
1427        let rxn1 = parse_reaction("C>>C").unwrap();
1428        let rxn2 = parse_reaction("CC>>C").unwrap();
1429        let rxn3 = parse_reaction("CCC>>CC").unwrap();
1430
1431        let reactions = vec![rxn1, rxn2, rxn3];
1432        let smarts = "[C:1]>>[C:1]";
1433
1434        let batch = batch_query_reactions(&reactions, smarts).unwrap();
1435
1436        // Both matching and non-matching indices should return valid results
1437        let matching = batch.matching_indices();
1438        let non_matching = batch.non_matching_indices();
1439
1440        assert_eq!(matching.len() + non_matching.len(), batch.total_reactions);
1441    }
1442
1443    #[test]
1444    fn test_reaction_pattern_library_new() {
1445        let library = ReactionPatternLibrary::new();
1446        assert!(library.is_empty());
1447        assert_eq!(library.len(), 0);
1448    }
1449
1450    #[test]
1451    fn test_reaction_pattern_library_add_pattern() {
1452        let mut library = ReactionPatternLibrary::new();
1453
1454        let pattern = parse_reaction_smarts("[C:1]>>[C:1]").unwrap();
1455        library.add_pattern("simple_carbon".to_string(), pattern);
1456
1457        assert_eq!(library.len(), 1);
1458        assert!(!library.is_empty());
1459        assert!(library.get("simple_carbon").is_some());
1460    }
1461
1462    #[test]
1463    fn test_reaction_pattern_library_add_from_smarts() {
1464        let mut library = ReactionPatternLibrary::new();
1465
1466        let result = library.add_pattern_from_smarts(
1467            "acylation".to_string(),
1468            "[C:1](=[O:2])[N:3]>>[C:1](=[O:2])[N:3]",
1469        );
1470        assert!(result.is_ok());
1471        assert_eq!(library.len(), 1);
1472    }
1473
1474    #[test]
1475    fn test_reaction_pattern_library_multiple_patterns() {
1476        let mut library = ReactionPatternLibrary::new();
1477
1478        let _ = library.add_pattern_from_smarts("reaction1".to_string(), "[C:1]>>[C:1]");
1479        let _ = library.add_pattern_from_smarts("reaction2".to_string(), "[N:1]>>[N:1]");
1480        let _ = library.add_pattern_from_smarts("reaction3".to_string(), "[O:1]>>[O:1]");
1481
1482        assert_eq!(library.len(), 3);
1483
1484        let names = library.pattern_names();
1485        assert_eq!(names.len(), 3);
1486        assert!(names.contains(&"reaction1".to_string()));
1487    }
1488
1489    #[test]
1490    fn test_batch_query_with_library() {
1491        let mut library = ReactionPatternLibrary::new();
1492        let _ = library.add_pattern_from_smarts("carbon_only".to_string(), "[C:1]>>[C:1]");
1493
1494        let rxn1 = parse_reaction("C>>C").unwrap();
1495        let rxn2 = parse_reaction("CC>>C").unwrap();
1496        let reactions = vec![rxn1, rxn2];
1497
1498        let results = batch_query_with_library(&reactions, &library);
1499
1500        assert!(!results.is_empty());
1501        assert!(results.contains_key("carbon_only"));
1502
1503        let batch = &results["carbon_only"];
1504        assert_eq!(batch.total_reactions, 2);
1505    }
1506
1507    #[test]
1508    fn test_query_reaction_simple() {
1509        let rxn = parse_reaction("C>>C").unwrap();
1510        let smarts = "[C:1]>>[C:1]";
1511
1512        let result = query_reaction(&rxn, smarts);
1513        assert!(result.is_ok());
1514
1515        let match_result = result.unwrap();
1516        // Check that it produced a valid result structure
1517        assert!(!match_result.reactant_matches.pattern_matches.is_empty());
1518    }
1519
1520    #[test]
1521    fn test_batch_query_match_percentage() {
1522        let rxn1 = parse_reaction("C>>C").unwrap();
1523        let rxn2 = parse_reaction("CC>>C").unwrap();
1524        let rxn3 = parse_reaction("CCC>>CC").unwrap();
1525
1526        let reactions = vec![rxn1, rxn2, rxn3];
1527        let smarts = "[C:1]>>[C:1]";
1528
1529        let batch = batch_query_reactions(&reactions, smarts).unwrap();
1530
1531        // Match percentage should be between 0 and 100
1532        assert!(batch.match_percentage >= 0.0);
1533        assert!(batch.match_percentage <= 100.0);
1534
1535        // Verify calculation
1536        let expected_percentage =
1537            (batch.matching_reactions as f64 / batch.total_reactions as f64) * 100.0;
1538        assert!((batch.match_percentage - expected_percentage).abs() < 0.01);
1539    }
1540
1541    #[test]
1542    fn test_reaction_pattern_library_default() {
1543        let library = ReactionPatternLibrary::default();
1544        assert!(library.is_empty());
1545    }
1546}