1use 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 pub top_cut_offset: i32,
14 pub bottom_cut_offset: i32,
15}
16
17pub 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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
620pub struct CatalogueSource {
621 pub name: String,
622 pub version: String,
623 pub sha256: Option<String>,
625}
626
627#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
629pub struct UnsupportedEnzyme {
630 pub name: String,
631 pub reason: String,
632}
633
634#[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 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 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 #[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
752fn 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
769pub fn install_catalogue(catalogue: EnzymeCatalogue) -> bool {
772 ACTIVE.set(catalogue).is_ok()
773}
774
775pub fn active_catalogue() -> &'static EnzymeCatalogue {
777 ACTIVE.get_or_init(EnzymeCatalogue::builtin)
778}
779
780fn 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 pub strand: Strand,
860 pub top_cut: Option<usize>,
863 pub bottom_cut: Option<usize>,
864 pub cleavage_available: bool,
865 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
879pub 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 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 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 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 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 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 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 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 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}