Skip to main content

dnagent_domain/
restriction.rs

1//! Small, explicit restriction-enzyme catalogue and sequence-only cleavage geometry.
2//! Constants verified against Biopython 1.85 Bio.Restriction; see docs/restriction.md.
3
4use crate::{DnaSeq, DomainError, Region, Strand, Topology};
5use serde::Serialize;
6use thiserror::Error;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
9pub struct Enzyme {
10    pub name: &'static str,
11    pub recognition_sequence: &'static str,
12    /// Boundary offsets from recognition start on the recognition-oriented strand.
13    pub top_cut_offset: i32,
14    pub bottom_cut_offset: i32,
15}
16
17/// Built-in catalogue: widely used commercial Type II enzymes, hand-listed from
18/// published recognition sequences and cut positions (scientific facts) and verified
19/// against Biopython 1.85 `Bio.Restriction` by `scripts/check_enzymes.py`. REBASE itself
20/// is not redistributed (see docs/restriction.md); it can be installed locally.
21pub const BUILTIN: &[Enzyme] = &[
22    Enzyme {
23        name: "AatII",
24        recognition_sequence: "GACGTC",
25        top_cut_offset: 5,
26        bottom_cut_offset: 1,
27    },
28    Enzyme {
29        name: "Acc65I",
30        recognition_sequence: "GGTACC",
31        top_cut_offset: 1,
32        bottom_cut_offset: 5,
33    },
34    Enzyme {
35        name: "AccI",
36        recognition_sequence: "GTMKAC",
37        top_cut_offset: 2,
38        bottom_cut_offset: 4,
39    },
40    Enzyme {
41        name: "AflII",
42        recognition_sequence: "CTTAAG",
43        top_cut_offset: 1,
44        bottom_cut_offset: 5,
45    },
46    Enzyme {
47        name: "AgeI",
48        recognition_sequence: "ACCGGT",
49        top_cut_offset: 1,
50        bottom_cut_offset: 5,
51    },
52    Enzyme {
53        name: "AluI",
54        recognition_sequence: "AGCT",
55        top_cut_offset: 2,
56        bottom_cut_offset: 2,
57    },
58    Enzyme {
59        name: "AlwNI",
60        recognition_sequence: "CAGNNNCTG",
61        top_cut_offset: 6,
62        bottom_cut_offset: 3,
63    },
64    Enzyme {
65        name: "ApaI",
66        recognition_sequence: "GGGCCC",
67        top_cut_offset: 5,
68        bottom_cut_offset: 1,
69    },
70    Enzyme {
71        name: "ApaLI",
72        recognition_sequence: "GTGCAC",
73        top_cut_offset: 1,
74        bottom_cut_offset: 5,
75    },
76    Enzyme {
77        name: "AscI",
78        recognition_sequence: "GGCGCGCC",
79        top_cut_offset: 2,
80        bottom_cut_offset: 6,
81    },
82    Enzyme {
83        name: "AseI",
84        recognition_sequence: "ATTAAT",
85        top_cut_offset: 2,
86        bottom_cut_offset: 4,
87    },
88    Enzyme {
89        name: "AsiSI",
90        recognition_sequence: "GCGATCGC",
91        top_cut_offset: 5,
92        bottom_cut_offset: 3,
93    },
94    Enzyme {
95        name: "AvaI",
96        recognition_sequence: "CYCGRG",
97        top_cut_offset: 1,
98        bottom_cut_offset: 5,
99    },
100    Enzyme {
101        name: "AvrII",
102        recognition_sequence: "CCTAGG",
103        top_cut_offset: 1,
104        bottom_cut_offset: 5,
105    },
106    Enzyme {
107        name: "BamHI",
108        recognition_sequence: "GGATCC",
109        top_cut_offset: 1,
110        bottom_cut_offset: 5,
111    },
112    Enzyme {
113        name: "BbsI",
114        recognition_sequence: "GAAGAC",
115        top_cut_offset: 8,
116        bottom_cut_offset: 12,
117    },
118    Enzyme {
119        name: "BclI",
120        recognition_sequence: "TGATCA",
121        top_cut_offset: 1,
122        bottom_cut_offset: 5,
123    },
124    Enzyme {
125        name: "BfaI",
126        recognition_sequence: "CTAG",
127        top_cut_offset: 1,
128        bottom_cut_offset: 3,
129    },
130    Enzyme {
131        name: "BglI",
132        recognition_sequence: "GCCNNNNNGGC",
133        top_cut_offset: 7,
134        bottom_cut_offset: 4,
135    },
136    Enzyme {
137        name: "BglII",
138        recognition_sequence: "AGATCT",
139        top_cut_offset: 1,
140        bottom_cut_offset: 5,
141    },
142    Enzyme {
143        name: "BmtI",
144        recognition_sequence: "GCTAGC",
145        top_cut_offset: 5,
146        bottom_cut_offset: 1,
147    },
148    Enzyme {
149        name: "BsaAI",
150        recognition_sequence: "YACGTR",
151        top_cut_offset: 3,
152        bottom_cut_offset: 3,
153    },
154    Enzyme {
155        name: "BsaI",
156        recognition_sequence: "GGTCTC",
157        top_cut_offset: 7,
158        bottom_cut_offset: 11,
159    },
160    Enzyme {
161        name: "BsaWI",
162        recognition_sequence: "WCCGGW",
163        top_cut_offset: 1,
164        bottom_cut_offset: 5,
165    },
166    Enzyme {
167        name: "BseRI",
168        recognition_sequence: "GAGGAG",
169        top_cut_offset: 16,
170        bottom_cut_offset: 14,
171    },
172    Enzyme {
173        name: "BsiWI",
174        recognition_sequence: "CGTACG",
175        top_cut_offset: 1,
176        bottom_cut_offset: 5,
177    },
178    Enzyme {
179        name: "BsmBI",
180        recognition_sequence: "CGTCTC",
181        top_cut_offset: 7,
182        bottom_cut_offset: 11,
183    },
184    Enzyme {
185        name: "BsmI",
186        recognition_sequence: "GAATGC",
187        top_cut_offset: 7,
188        bottom_cut_offset: 5,
189    },
190    Enzyme {
191        name: "BspEI",
192        recognition_sequence: "TCCGGA",
193        top_cut_offset: 1,
194        bottom_cut_offset: 5,
195    },
196    Enzyme {
197        name: "BspHI",
198        recognition_sequence: "TCATGA",
199        top_cut_offset: 1,
200        bottom_cut_offset: 5,
201    },
202    Enzyme {
203        name: "BsrGI",
204        recognition_sequence: "TGTACA",
205        top_cut_offset: 1,
206        bottom_cut_offset: 5,
207    },
208    Enzyme {
209        name: "BssHII",
210        recognition_sequence: "GCGCGC",
211        top_cut_offset: 1,
212        bottom_cut_offset: 5,
213    },
214    Enzyme {
215        name: "BstBI",
216        recognition_sequence: "TTCGAA",
217        top_cut_offset: 2,
218        bottom_cut_offset: 4,
219    },
220    Enzyme {
221        name: "BstEII",
222        recognition_sequence: "GGTNACC",
223        top_cut_offset: 1,
224        bottom_cut_offset: 6,
225    },
226    Enzyme {
227        name: "BstXI",
228        recognition_sequence: "CCANNNNNNTGG",
229        top_cut_offset: 8,
230        bottom_cut_offset: 4,
231    },
232    Enzyme {
233        name: "BtgZI",
234        recognition_sequence: "GCGATG",
235        top_cut_offset: 16,
236        bottom_cut_offset: 20,
237    },
238    Enzyme {
239        name: "ClaI",
240        recognition_sequence: "ATCGAT",
241        top_cut_offset: 2,
242        bottom_cut_offset: 4,
243    },
244    Enzyme {
245        name: "DdeI",
246        recognition_sequence: "CTNAG",
247        top_cut_offset: 1,
248        bottom_cut_offset: 4,
249    },
250    Enzyme {
251        name: "DraI",
252        recognition_sequence: "TTTAAA",
253        top_cut_offset: 3,
254        bottom_cut_offset: 3,
255    },
256    Enzyme {
257        name: "DraIII",
258        recognition_sequence: "CACNNNGTG",
259        top_cut_offset: 6,
260        bottom_cut_offset: 3,
261    },
262    Enzyme {
263        name: "EagI",
264        recognition_sequence: "CGGCCG",
265        top_cut_offset: 1,
266        bottom_cut_offset: 5,
267    },
268    Enzyme {
269        name: "EcoNI",
270        recognition_sequence: "CCTNNNNNAGG",
271        top_cut_offset: 5,
272        bottom_cut_offset: 6,
273    },
274    Enzyme {
275        name: "EcoRI",
276        recognition_sequence: "GAATTC",
277        top_cut_offset: 1,
278        bottom_cut_offset: 5,
279    },
280    Enzyme {
281        name: "EcoRV",
282        recognition_sequence: "GATATC",
283        top_cut_offset: 3,
284        bottom_cut_offset: 3,
285    },
286    Enzyme {
287        name: "Esp3I",
288        recognition_sequence: "CGTCTC",
289        top_cut_offset: 7,
290        bottom_cut_offset: 11,
291    },
292    Enzyme {
293        name: "FseI",
294        recognition_sequence: "GGCCGGCC",
295        top_cut_offset: 6,
296        bottom_cut_offset: 2,
297    },
298    Enzyme {
299        name: "FspI",
300        recognition_sequence: "TGCGCA",
301        top_cut_offset: 3,
302        bottom_cut_offset: 3,
303    },
304    Enzyme {
305        name: "HaeIII",
306        recognition_sequence: "GGCC",
307        top_cut_offset: 2,
308        bottom_cut_offset: 2,
309    },
310    Enzyme {
311        name: "HhaI",
312        recognition_sequence: "GCGC",
313        top_cut_offset: 3,
314        bottom_cut_offset: 1,
315    },
316    Enzyme {
317        name: "HincII",
318        recognition_sequence: "GTYRAC",
319        top_cut_offset: 3,
320        bottom_cut_offset: 3,
321    },
322    Enzyme {
323        name: "HindIII",
324        recognition_sequence: "AAGCTT",
325        top_cut_offset: 1,
326        bottom_cut_offset: 5,
327    },
328    Enzyme {
329        name: "HinfI",
330        recognition_sequence: "GANTC",
331        top_cut_offset: 1,
332        bottom_cut_offset: 4,
333    },
334    Enzyme {
335        name: "HpaI",
336        recognition_sequence: "GTTAAC",
337        top_cut_offset: 3,
338        bottom_cut_offset: 3,
339    },
340    Enzyme {
341        name: "KasI",
342        recognition_sequence: "GGCGCC",
343        top_cut_offset: 1,
344        bottom_cut_offset: 5,
345    },
346    Enzyme {
347        name: "KpnI",
348        recognition_sequence: "GGTACC",
349        top_cut_offset: 5,
350        bottom_cut_offset: 1,
351    },
352    Enzyme {
353        name: "MfeI",
354        recognition_sequence: "CAATTG",
355        top_cut_offset: 1,
356        bottom_cut_offset: 5,
357    },
358    Enzyme {
359        name: "MluI",
360        recognition_sequence: "ACGCGT",
361        top_cut_offset: 1,
362        bottom_cut_offset: 5,
363    },
364    Enzyme {
365        name: "MseI",
366        recognition_sequence: "TTAA",
367        top_cut_offset: 1,
368        bottom_cut_offset: 3,
369    },
370    Enzyme {
371        name: "MspI",
372        recognition_sequence: "CCGG",
373        top_cut_offset: 1,
374        bottom_cut_offset: 3,
375    },
376    Enzyme {
377        name: "NarI",
378        recognition_sequence: "GGCGCC",
379        top_cut_offset: 2,
380        bottom_cut_offset: 4,
381    },
382    Enzyme {
383        name: "NcoI",
384        recognition_sequence: "CCATGG",
385        top_cut_offset: 1,
386        bottom_cut_offset: 5,
387    },
388    Enzyme {
389        name: "NdeI",
390        recognition_sequence: "CATATG",
391        top_cut_offset: 2,
392        bottom_cut_offset: 4,
393    },
394    Enzyme {
395        name: "NheI",
396        recognition_sequence: "GCTAGC",
397        top_cut_offset: 1,
398        bottom_cut_offset: 5,
399    },
400    Enzyme {
401        name: "NlaIII",
402        recognition_sequence: "CATG",
403        top_cut_offset: 4,
404        bottom_cut_offset: 0,
405    },
406    Enzyme {
407        name: "NotI",
408        recognition_sequence: "GCGGCCGC",
409        top_cut_offset: 2,
410        bottom_cut_offset: 6,
411    },
412    Enzyme {
413        name: "NruI",
414        recognition_sequence: "TCGCGA",
415        top_cut_offset: 3,
416        bottom_cut_offset: 3,
417    },
418    Enzyme {
419        name: "NsiI",
420        recognition_sequence: "ATGCAT",
421        top_cut_offset: 5,
422        bottom_cut_offset: 1,
423    },
424    Enzyme {
425        name: "PacI",
426        recognition_sequence: "TTAATTAA",
427        top_cut_offset: 5,
428        bottom_cut_offset: 3,
429    },
430    Enzyme {
431        name: "PaqCI",
432        recognition_sequence: "CACCTGC",
433        top_cut_offset: 11,
434        bottom_cut_offset: 15,
435    },
436    Enzyme {
437        name: "PciI",
438        recognition_sequence: "ACATGT",
439        top_cut_offset: 1,
440        bottom_cut_offset: 5,
441    },
442    Enzyme {
443        name: "PmeI",
444        recognition_sequence: "GTTTAAAC",
445        top_cut_offset: 4,
446        bottom_cut_offset: 4,
447    },
448    Enzyme {
449        name: "PmlI",
450        recognition_sequence: "CACGTG",
451        top_cut_offset: 3,
452        bottom_cut_offset: 3,
453    },
454    Enzyme {
455        name: "PsiI",
456        recognition_sequence: "TTATAA",
457        top_cut_offset: 3,
458        bottom_cut_offset: 3,
459    },
460    Enzyme {
461        name: "PspOMI",
462        recognition_sequence: "GGGCCC",
463        top_cut_offset: 1,
464        bottom_cut_offset: 5,
465    },
466    Enzyme {
467        name: "PstI",
468        recognition_sequence: "CTGCAG",
469        top_cut_offset: 5,
470        bottom_cut_offset: 1,
471    },
472    Enzyme {
473        name: "PvuI",
474        recognition_sequence: "CGATCG",
475        top_cut_offset: 4,
476        bottom_cut_offset: 2,
477    },
478    Enzyme {
479        name: "PvuII",
480        recognition_sequence: "CAGCTG",
481        top_cut_offset: 3,
482        bottom_cut_offset: 3,
483    },
484    Enzyme {
485        name: "RsaI",
486        recognition_sequence: "GTAC",
487        top_cut_offset: 2,
488        bottom_cut_offset: 2,
489    },
490    Enzyme {
491        name: "SacI",
492        recognition_sequence: "GAGCTC",
493        top_cut_offset: 5,
494        bottom_cut_offset: 1,
495    },
496    Enzyme {
497        name: "SacII",
498        recognition_sequence: "CCGCGG",
499        top_cut_offset: 4,
500        bottom_cut_offset: 2,
501    },
502    Enzyme {
503        name: "SalI",
504        recognition_sequence: "GTCGAC",
505        top_cut_offset: 1,
506        bottom_cut_offset: 5,
507    },
508    Enzyme {
509        name: "SapI",
510        recognition_sequence: "GCTCTTC",
511        top_cut_offset: 8,
512        bottom_cut_offset: 11,
513    },
514    Enzyme {
515        name: "Sau3AI",
516        recognition_sequence: "GATC",
517        top_cut_offset: 0,
518        bottom_cut_offset: 4,
519    },
520    Enzyme {
521        name: "SbfI",
522        recognition_sequence: "CCTGCAGG",
523        top_cut_offset: 6,
524        bottom_cut_offset: 2,
525    },
526    Enzyme {
527        name: "ScaI",
528        recognition_sequence: "AGTACT",
529        top_cut_offset: 3,
530        bottom_cut_offset: 3,
531    },
532    Enzyme {
533        name: "SfiI",
534        recognition_sequence: "GGCCNNNNNGGCC",
535        top_cut_offset: 8,
536        bottom_cut_offset: 5,
537    },
538    Enzyme {
539        name: "SgrAI",
540        recognition_sequence: "CRCCGGYG",
541        top_cut_offset: 2,
542        bottom_cut_offset: 6,
543    },
544    Enzyme {
545        name: "SmaI",
546        recognition_sequence: "CCCGGG",
547        top_cut_offset: 3,
548        bottom_cut_offset: 3,
549    },
550    Enzyme {
551        name: "SnaBI",
552        recognition_sequence: "TACGTA",
553        top_cut_offset: 3,
554        bottom_cut_offset: 3,
555    },
556    Enzyme {
557        name: "SpeI",
558        recognition_sequence: "ACTAGT",
559        top_cut_offset: 1,
560        bottom_cut_offset: 5,
561    },
562    Enzyme {
563        name: "SphI",
564        recognition_sequence: "GCATGC",
565        top_cut_offset: 5,
566        bottom_cut_offset: 1,
567    },
568    Enzyme {
569        name: "SspI",
570        recognition_sequence: "AATATT",
571        top_cut_offset: 3,
572        bottom_cut_offset: 3,
573    },
574    Enzyme {
575        name: "StuI",
576        recognition_sequence: "AGGCCT",
577        top_cut_offset: 3,
578        bottom_cut_offset: 3,
579    },
580    Enzyme {
581        name: "SwaI",
582        recognition_sequence: "ATTTAAAT",
583        top_cut_offset: 4,
584        bottom_cut_offset: 4,
585    },
586    Enzyme {
587        name: "TaqI",
588        recognition_sequence: "TCGA",
589        top_cut_offset: 1,
590        bottom_cut_offset: 3,
591    },
592    Enzyme {
593        name: "XbaI",
594        recognition_sequence: "TCTAGA",
595        top_cut_offset: 1,
596        bottom_cut_offset: 5,
597    },
598    Enzyme {
599        name: "XhoI",
600        recognition_sequence: "CTCGAG",
601        top_cut_offset: 1,
602        bottom_cut_offset: 5,
603    },
604    Enzyme {
605        name: "XmaI",
606        recognition_sequence: "CCCGGG",
607        top_cut_offset: 1,
608        bottom_cut_offset: 5,
609    },
610    Enzyme {
611        name: "ZraI",
612        recognition_sequence: "GACGTC",
613        top_cut_offset: 3,
614        bottom_cut_offset: 3,
615    },
616];
617
618/// Where the active catalogue came from.
619#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
620pub struct CatalogueSource {
621    pub name: String,
622    pub version: String,
623    /// SHA-256 of the source files, when loaded from an installed database.
624    pub sha256: Option<String>,
625}
626
627/// An enzyme a source database lists but DNAgent cannot model yet.
628#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
629pub struct UnsupportedEnzyme {
630    pub name: String,
631    pub reason: String,
632}
633
634/// The enzymes available to scans and digests.
635#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
636pub struct EnzymeCatalogue {
637    pub source: CatalogueSource,
638    pub enzymes: Vec<Enzyme>,
639    pub unsupported: Vec<UnsupportedEnzyme>,
640}
641
642static ACTIVE: std::sync::OnceLock<EnzymeCatalogue> = std::sync::OnceLock::new();
643
644impl EnzymeCatalogue {
645    #[must_use]
646    pub fn builtin() -> Self {
647        Self {
648            source: CatalogueSource {
649                name: "DNAgent built-in".into(),
650                version: "1".into(),
651                sha256: None,
652            },
653            enzymes: BUILTIN.to_vec(),
654            unsupported: Vec::new(),
655        }
656    }
657
658    /// Parse REBASE EMBOSS files (`emboss_e.NNN`, `emboss_r.NNN`). Only commercially
659    /// available enzymes (at least one supplier) are included; two-sided cutters, unknown
660    /// cut positions and invalid patterns are listed as unsupported. Names and patterns
661    /// are leaked once so enzymes keep the `'static` API (load a catalogue once per process).
662    pub fn from_emboss(
663        e_text: &str,
664        r_text: &str,
665        version: &str,
666        sha256: Option<String>,
667    ) -> Result<Self, RestrictionError> {
668        let suppliers = emboss_suppliers(r_text);
669        let mut enzymes = Vec::new();
670        let mut unsupported = Vec::new();
671        let mut seen = std::collections::HashSet::new();
672        for line in e_text
673            .lines()
674            .filter(|l| !l.starts_with('#') && !l.trim().is_empty())
675        {
676            let fields: Vec<&str> = line.split_whitespace().collect();
677            let [name, pattern, length, ncuts, _blunt, c1, c2, c3, c4] = fields.as_slice() else {
678                return Err(RestrictionError::Catalogue(format!(
679                    "malformed EMBOSS line {line:?}"
680                )));
681            };
682            if !seen.insert((*name).to_owned()) || suppliers.get(*name).is_none_or(String::is_empty)
683            {
684                continue;
685            }
686            let number = |text: &str| {
687                text.parse::<i32>()
688                    .map_err(|_| RestrictionError::Catalogue(format!("bad number in {line:?}")))
689            };
690            let (length, ncuts, c1, c2, c3, c4) = (
691                number(length)?,
692                number(ncuts)?,
693                number(c1)?,
694                number(c2)?,
695                number(c3)?,
696                number(c4)?,
697            );
698            let pattern = pattern.to_ascii_uppercase();
699            let reason = if !pattern.bytes().all(|b| b"ACGTRYSWKMBDHVN".contains(&b))
700                || usize::try_from(length).ok() != Some(pattern.len())
701            {
702                Some("recognition sequence is not plain IUPAC DNA".to_owned())
703            } else if ncuts == 0 {
704                Some("cut positions unknown".to_owned())
705            } else if ncuts > 2 || c3 != 0 || c4 != 0 {
706                Some("cuts on both sides of the site (not modelled yet)".to_owned())
707            } else {
708                None
709            };
710            if let Some(reason) = reason {
711                unsupported.push(UnsupportedEnzyme {
712                    name: (*name).to_owned(),
713                    reason,
714                });
715                continue;
716            }
717            // EMBOSS numbers residues ...,-2,-1,1,2,... and cuts after the given residue.
718            let boundary = |c: i32| if c < 0 { c + 1 } else { c };
719            enzymes.push(Enzyme {
720                name: Box::leak((*name).to_owned().into_boxed_str()),
721                recognition_sequence: Box::leak(pattern.into_boxed_str()),
722                top_cut_offset: boundary(c1),
723                bottom_cut_offset: boundary(c2),
724            });
725        }
726        if enzymes.is_empty() {
727            return Err(RestrictionError::Catalogue(
728                "no commercially available enzymes found".into(),
729            ));
730        }
731        enzymes.sort_by(|a, b| a.name.cmp(b.name));
732        Ok(Self {
733            source: CatalogueSource {
734                name: "REBASE".into(),
735                version: version.to_owned(),
736                sha256,
737            },
738            enzymes,
739            unsupported,
740        })
741    }
742
743    /// Case-insensitive lookup.
744    #[must_use]
745    pub fn get(&self, name: &str) -> Option<&Enzyme> {
746        self.enzymes
747            .iter()
748            .find(|e| e.name.eq_ignore_ascii_case(name))
749    }
750}
751
752/// Supplier codes per enzyme from `emboss_r` (line 6 of each entry).
753fn emboss_suppliers(r_text: &str) -> std::collections::HashMap<String, String> {
754    let mut map = std::collections::HashMap::new();
755    let body: Vec<&str> = r_text.lines().skip_while(|l| l.starts_with('#')).collect();
756    for entry in body.split(|l| l.trim() == "//") {
757        let entry: Vec<&str> = entry
758            .iter()
759            .copied()
760            .skip_while(|l| l.trim().is_empty())
761            .collect();
762        if entry.len() >= 6 {
763            map.insert(entry[0].trim().to_owned(), entry[5].trim().to_owned());
764        }
765    }
766    map
767}
768
769/// Install the process-wide catalogue (once, before the first scan). Returns false if a
770/// catalogue was already active.
771pub fn install_catalogue(catalogue: EnzymeCatalogue) -> bool {
772    ACTIVE.set(catalogue).is_ok()
773}
774
775/// The active catalogue: an installed one, otherwise the built-in set.
776pub fn active_catalogue() -> &'static EnzymeCatalogue {
777    ACTIVE.get_or_init(EnzymeCatalogue::builtin)
778}
779
780/// IUPAC code → bases it stands for.
781fn iupac(base: u8) -> &'static [u8] {
782    match base {
783        b'A' => b"A",
784        b'C' => b"C",
785        b'G' => b"G",
786        b'T' => b"T",
787        b'R' => b"AG",
788        b'Y' => b"CT",
789        b'S' => b"CG",
790        b'W' => b"AT",
791        b'K' => b"GT",
792        b'M' => b"AC",
793        b'B' => b"CGT",
794        b'D' => b"AGT",
795        b'H' => b"ACT",
796        b'V' => b"ACG",
797        b'N' => b"ACGT",
798        _ => b"",
799    }
800}
801
802fn iupac_complement(base: u8) -> u8 {
803    match base {
804        b'A' => b'T',
805        b'T' => b'A',
806        b'C' => b'G',
807        b'G' => b'C',
808        b'R' => b'Y',
809        b'Y' => b'R',
810        b'S' => b'S',
811        b'W' => b'W',
812        b'K' => b'M',
813        b'M' => b'K',
814        b'B' => b'V',
815        b'V' => b'B',
816        b'D' => b'H',
817        b'H' => b'D',
818        _ => b'N',
819    }
820}
821
822#[derive(Debug, Clone, PartialEq, Eq, Error)]
823pub enum RestrictionError {
824    #[error("select at least one restriction enzyme")]
825    EmptySelection,
826    #[error("unsupported restriction enzyme {0:?}; list supported enzymes with `enzymes`")]
827    UnknownEnzyme(String),
828    #[error(
829        "restriction scanning requires unambiguous A/C/G/T; found {symbol} at zero-based position {position}"
830    )]
831    AmbiguousSequence { symbol: char, position: usize },
832    #[error("invalid recognition coordinates: {0}")]
833    Coordinates(#[from] DomainError),
834    #[error("enzyme catalogue: {0}")]
835    Catalogue(String),
836    #[error(
837        "circular molecule length {length} is too short for {enzyme} recognition/cleavage span {required}"
838    )]
839    ShortCircle {
840        enzyme: &'static str,
841        length: usize,
842        required: usize,
843    },
844}
845
846#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
847#[serde(rename_all = "snake_case")]
848pub enum OverhangPolarity {
849    Blunt,
850    FivePrime,
851    ThreePrime,
852}
853
854#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
855pub struct RestrictionSite {
856    pub enzyme: &'static str,
857    pub recognition: Region,
858    /// Relative to the stored sequence; palindromic sites use canonical Forward.
859    pub strand: Strand,
860    /// Boundaries along the stored forward sequence, even for reverse sites.
861    /// Null denotes a cut outside a linear molecule. Circular boundaries are modulo length.
862    pub top_cut: Option<usize>,
863    pub bottom_cut: Option<usize>,
864    pub cleavage_available: bool,
865    /// Nominal enzyme geometry, not proof of cleavage or a fragment-end sequence.
866    pub overhang_polarity: OverhangPolarity,
867    pub overhang_length: usize,
868}
869
870#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
871pub struct RestrictionScan {
872    pub length: usize,
873    pub topology: Topology,
874    pub enzymes: Vec<Enzyme>,
875    pub sites: Vec<RestrictionSite>,
876    pub assumptions: Vec<&'static str>,
877}
878
879/// Find exact sites and nominal cuts. Does not model methylation, reaction conditions,
880/// star activity, accessibility, minimum flanking DNA or digest fragments.
881/// Ambiguous input is rejected rather than reported as a misleading negative scan.
882pub fn find_sites(
883    sequence: &DnaSeq,
884    topology: Topology,
885    names: &[String],
886) -> Result<RestrictionScan, RestrictionError> {
887    if names.is_empty() {
888        return Err(RestrictionError::EmptySelection);
889    }
890    let catalogue = active_catalogue();
891    for name in names {
892        if catalogue.get(name).is_none() {
893            return Err(RestrictionError::UnknownEnzyme(name.clone()));
894        }
895    }
896    let enzymes: Vec<_> = catalogue
897        .enzymes
898        .iter()
899        .copied()
900        .filter(|e| names.iter().any(|n| e.name.eq_ignore_ascii_case(n)))
901        .collect();
902    let bases = sequence.as_str().as_bytes();
903    let n = bases.len();
904    for (position, base) in bases.iter().enumerate() {
905        if !b"ACGT".contains(base) {
906            return Err(RestrictionError::AmbiguousSequence {
907                symbol: char::from(*base),
908                position,
909            });
910        }
911    }
912    let mut sites = Vec::new();
913    for enzyme in &enzymes {
914        let motif = enzyme.recognition_sequence.as_bytes();
915        let m = motif.len();
916        // Recognition plus any cuts outside it, on either side.
917        let right = i64::from(enzyme.top_cut_offset.max(enzyme.bottom_cut_offset))
918            .max(i64::try_from(m).unwrap_or(i64::MAX));
919        let left = i64::from(enzyme.top_cut_offset.min(enzyme.bottom_cut_offset)).min(0);
920        let span = usize::try_from(right - left).unwrap_or(usize::MAX);
921        if topology == Topology::Circular && n < span {
922            return Err(RestrictionError::ShortCircle {
923                enzyme: enzyme.name,
924                length: n,
925                required: span,
926            });
927        }
928        if n < m {
929            continue;
930        }
931        let reverse: Vec<u8> = motif.iter().rev().map(|b| iupac_complement(*b)).collect();
932        let starts = if topology == Topology::Circular {
933            n
934        } else {
935            n - m + 1
936        };
937        for start in 0..starts {
938            for (pattern, strand) in [
939                (motif, Strand::Forward),
940                (reverse.as_slice(), Strand::Reverse),
941            ] {
942                if strand == Strand::Reverse && reverse == motif {
943                    continue;
944                }
945                if !pattern
946                    .iter()
947                    .enumerate()
948                    .all(|(i, b)| iupac(*b).contains(&bases[(start + i) % n]))
949                {
950                    continue;
951                }
952                sites.push(site_geometry(enzyme, start, strand, n, topology)?);
953            }
954        }
955    }
956    sites.sort_by_key(|s| {
957        (
958            s.recognition.start().get(),
959            s.enzyme,
960            s.strand == Strand::Reverse,
961        )
962    });
963    Ok(RestrictionScan {
964        length: n,
965        topology,
966        enzymes,
967        sites,
968        assumptions: vec![
969            "ACGT input only (ambiguous input rejected); recognition sequences may be degenerate or interrupted (IUPAC)",
970            "Nominal cleavage geometry only; methylation, star activity, reaction conditions and flanking-DNA requirements not modelled",
971            "Palindromic sites reported once in forward orientation",
972            "No digest fragments or fragment-end sequences are inferred",
973        ],
974    })
975}
976
977fn site_geometry(
978    enzyme: &Enzyme,
979    start: usize,
980    strand: Strand,
981    n: usize,
982    topology: Topology,
983) -> Result<RestrictionSite, DomainError> {
984    let m = enzyme.recognition_sequence.len();
985    // find_sites bounds start and motif length before calling us. Still propagate
986    // checked-region failures rather than panic if those invariants ever change.
987    let recognition = if topology == Topology::Linear || start + m <= n {
988        Region::linear(start, start + m, n)?
989    } else {
990        Region::circular_arc(start, m, n)?
991    };
992    let (top_offset, bottom_offset) = if strand == Strand::Forward {
993        (
994            i128::from(enzyme.top_cut_offset),
995            i128::from(enzyme.bottom_cut_offset),
996        )
997    } else {
998        (
999            m as i128 - i128::from(enzyme.bottom_cut_offset),
1000            m as i128 - i128::from(enzyme.top_cut_offset),
1001        )
1002    };
1003    let boundary = |offset: i128| {
1004        let position = start as i128 + offset;
1005        if topology == Topology::Circular {
1006            Some(position.rem_euclid(n as i128) as usize)
1007        } else {
1008            usize::try_from(position).ok().filter(|p| *p <= n)
1009        }
1010    };
1011    let top_cut = boundary(top_offset);
1012    let bottom_cut = boundary(bottom_offset);
1013    let delta = enzyme.bottom_cut_offset - enzyme.top_cut_offset;
1014    Ok(RestrictionSite {
1015        enzyme: enzyme.name,
1016        recognition,
1017        strand,
1018        top_cut,
1019        bottom_cut,
1020        cleavage_available: top_cut.is_some() && bottom_cut.is_some(),
1021        overhang_polarity: match delta.cmp(&0) {
1022            std::cmp::Ordering::Equal => OverhangPolarity::Blunt,
1023            std::cmp::Ordering::Greater => OverhangPolarity::FivePrime,
1024            std::cmp::Ordering::Less => OverhangPolarity::ThreePrime,
1025        },
1026        overhang_length: delta.unsigned_abs() as usize,
1027    })
1028}
1029
1030#[cfg(test)]
1031mod tests {
1032    use super::*;
1033
1034    #[test]
1035    fn invalid_site_geometry_returns_checked_coordinate_error() {
1036        for topology in [Topology::Linear, Topology::Circular] {
1037            assert!(site_geometry(&BUILTIN[42], 0, Strand::Forward, 3, topology).is_err());
1038            assert!(site_geometry(&BUILTIN[42], 0, Strand::Forward, 0, topology).is_err());
1039        }
1040    }
1041    fn scan(sequence: &str, topology: Topology, enzyme: &str) -> RestrictionScan {
1042        find_sites(
1043            &DnaSeq::new(sequence).unwrap(),
1044            topology,
1045            &[enzyme.to_owned()],
1046        )
1047        .unwrap()
1048    }
1049    #[test]
1050    fn catalogue_geometries_and_palindrome_deduplication() {
1051        for (name, motif, top, bottom, polarity) in [
1052            ("EcoRI", "GAATTC", 1, 5, OverhangPolarity::FivePrime),
1053            ("BamHI", "GGATCC", 1, 5, OverhangPolarity::FivePrime),
1054            ("EcoRV", "GATATC", 3, 3, OverhangPolarity::Blunt),
1055            ("KpnI", "GGTACC", 5, 1, OverhangPolarity::ThreePrime),
1056            ("BsaI", "GGTCTCAAAAAA", 7, 11, OverhangPolarity::FivePrime),
1057            ("BsmBI", "CGTCTCAAAAAA", 7, 11, OverhangPolarity::FivePrime),
1058        ] {
1059            let result = scan(motif, Topology::Linear, name);
1060            assert_eq!(result.sites.len(), 1);
1061            let site = &result.sites[0];
1062            assert_eq!((site.top_cut, site.bottom_cut), (Some(top), Some(bottom)));
1063            assert_eq!(site.overhang_polarity, polarity);
1064        }
1065    }
1066    #[test]
1067    fn reverse_type_iis_cuts_upstream_and_swaps_strands() {
1068        let result = scan("AAAAAGAGACCAAAAA", Topology::Linear, "BsaI");
1069        let site = &result.sites[0];
1070        assert_eq!(site.strand, Strand::Reverse);
1071        assert_eq!((site.top_cut, site.bottom_cut), (Some(0), Some(4)));
1072        assert_eq!(site.overhang_polarity, OverhangPolarity::FivePrime);
1073    }
1074    #[test]
1075    fn circular_origin_and_cut_wrapping() {
1076        let result = scan("TTCGAA", Topology::Circular, "EcoRI");
1077        assert_eq!(result.sites.len(), 1);
1078        let site = &result.sites[0];
1079        assert!(site.recognition.is_circular_arc());
1080        assert_eq!((site.top_cut, site.bottom_cut), (Some(4), Some(2)));
1081        let result = scan("GAGACCAAAAAA", Topology::Circular, "BsaI");
1082        assert_eq!(
1083            (result.sites[0].top_cut, result.sites[0].bottom_cut),
1084            (Some(7), Some(11))
1085        );
1086    }
1087    #[test]
1088    fn linear_end_sites_are_not_silently_dropped() {
1089        let result = scan("GGTCTC", Topology::Linear, "BsaI");
1090        assert_eq!(result.sites.len(), 1);
1091        assert!(!result.sites[0].cleavage_available);
1092        assert_eq!(
1093            (result.sites[0].top_cut, result.sites[0].bottom_cut),
1094            (None, None)
1095        );
1096        assert_eq!(scan("TTCGAA", Topology::Linear, "EcoRI").sites, []);
1097        assert_eq!(scan("ACG", Topology::Linear, "EcoRI").sites, []);
1098    }
1099    #[test]
1100    fn explicit_errors_and_deterministic_selection() {
1101        let seq = DnaSeq::new("GAATTC").unwrap();
1102        assert!(matches!(
1103            find_sites(&seq, Topology::Linear, &[]),
1104            Err(RestrictionError::EmptySelection)
1105        ));
1106        assert!(matches!(
1107            find_sites(&seq, Topology::Linear, &["unknown".into()]),
1108            Err(RestrictionError::UnknownEnzyme(_))
1109        ));
1110        assert!(matches!(
1111            find_sites(
1112                &DnaSeq::new("GAANTC").unwrap(),
1113                Topology::Linear,
1114                &["EcoRI".into()]
1115            ),
1116            Err(RestrictionError::AmbiguousSequence { .. })
1117        ));
1118        assert!(matches!(
1119            find_sites(&seq, Topology::Circular, &["BsaI".into()]),
1120            Err(RestrictionError::ShortCircle { .. })
1121        ));
1122        let result = find_sites(&seq, Topology::Linear, &["ecori".into(), "EcoRI".into()]).unwrap();
1123        assert_eq!(result.enzymes.len(), 1);
1124        assert_eq!(result.sites.len(), 1);
1125    }
1126
1127    #[test]
1128    fn degenerate_and_interrupted_sites_match_iupac() {
1129        // BsaWI WCCGGW is a degenerate palindrome: reported once per site.
1130        let result = scan("AACCGGTT", Topology::Linear, "BsaWI");
1131        assert_eq!(result.sites.len(), 1);
1132        assert_eq!(result.sites[0].top_cut, Some(2));
1133        // BglI GCCNNNNNGGC (cut GCCNNNN^NGGC) with arbitrary interruption.
1134        let result = scan("TTGCCATGCAGGCTT", Topology::Linear, "BglI");
1135        assert_eq!(result.sites.len(), 1);
1136        assert_eq!(
1137            (result.sites[0].top_cut, result.sites[0].bottom_cut),
1138            (Some(9), Some(6))
1139        );
1140        assert_eq!(
1141            result.sites[0].overhang_polarity,
1142            OverhangPolarity::ThreePrime
1143        );
1144        assert_eq!(scan("TTGCCATGCAGGGTT", Topology::Linear, "BglI").sites, []);
1145        // HinfI GANTC, non-palindromic only through N: reported once.
1146        assert_eq!(scan("AAGATTCAA", Topology::Linear, "HinfI").sites.len(), 1);
1147    }
1148
1149    #[test]
1150    fn non_palindromic_degenerate_sites_are_found_on_both_strands() {
1151        // BsmI GAATGC(1/-1): forward at 0, reverse (GCATTC) at 10.
1152        let result = scan("GAATGCAAAAGCATTCAAAA", Topology::Linear, "BsmI");
1153        let strands: Vec<Strand> = result.sites.iter().map(|s| s.strand).collect();
1154        assert_eq!(strands, vec![Strand::Forward, Strand::Reverse]);
1155        assert_eq!(result.sites[0].top_cut, Some(7));
1156        // Reverse site: top cut = start + len - bottom offset = 10 + 6 - 5.
1157        assert_eq!(result.sites[1].top_cut, Some(11));
1158    }
1159
1160    #[test]
1161    fn rebase_emboss_parsing_converts_cut_numbering_and_filters() {
1162        let e = "# comment\nEcoRI\tGAATTC\t6\t2\t0\t1\t5\t0\t0\nTspRI\tCASTG\t5\t2\t0\t7\t-3\t0\t0\n\
1163                 AloI\tGAACNNNNNNTCC\t13\t4\t0\t-8\t-13\t20\t15\nNoSupply\tGGGG\t4\t2\t0\t1\t3\t0\t0\nUnknownCut\tACGT\t4\t0\t0\t0\t0\t0\t0\n";
1164        let r = "# header\nEcoRI\nEscherichia coli\n\n\n\nBCFN\n0\n//\nTspRI\nThermus\n\n\n\nN\n0\n//\nAloI\nAcinetobacter\n\n\n\nB\n0\n//\nNoSupply\nX\n\n\n\n\n0\n//\nUnknownCut\nY\n\n\n\nN\n0\n//\n";
1165        let catalogue = EnzymeCatalogue::from_emboss(e, r, "999", None).unwrap();
1166        let names: Vec<&str> = catalogue.enzymes.iter().map(|x| x.name).collect();
1167        assert_eq!(names, vec!["EcoRI", "TspRI"]);
1168        let tspri = catalogue.get("tspri").unwrap();
1169        // "Cut after residue -3" (no residue 0) is the boundary two bases upstream.
1170        assert_eq!((tspri.top_cut_offset, tspri.bottom_cut_offset), (7, -2));
1171        let reasons: Vec<(&str, &str)> = catalogue
1172            .unsupported
1173            .iter()
1174            .map(|u| (u.name.as_str(), u.reason.as_str()))
1175            .collect();
1176        assert_eq!(
1177            reasons,
1178            vec![
1179                ("AloI", "cuts on both sides of the site (not modelled yet)"),
1180                ("UnknownCut", "cut positions unknown")
1181            ]
1182        );
1183        assert!(EnzymeCatalogue::from_emboss("Bad line", r, "1", None).is_err());
1184    }
1185}