1use crate::digest::{FragmentStrand, reverse_complement};
3use crate::fragment_annotations::{FeatureMapping, map_feature};
4use crate::ligation::Orientation;
5use crate::{DomainError, SequenceRecord, Topology};
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9pub const MAX_BASES: usize = 1_000_000;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12#[serde(deny_unknown_fields)]
13pub struct CoreSelection {
14 pub input: usize,
15 pub start: usize,
16 pub length: usize,
17 pub orientation: Orientation,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum Preparation {
26 Pcr,
27 Provided,
28}
29
30#[derive(Debug, Error)]
31pub enum GibsonError {
32 #[error("invalid Gibson design: {0}")]
33 Invalid(&'static str),
34 #[error("component {component}: {reason}")]
35 Component {
36 component: usize,
37 reason: &'static str,
38 },
39 #[error(transparent)]
40 Coordinates(#[from] DomainError),
41}
42
43#[derive(Debug, Clone, Serialize)]
44pub struct PrimerCandidate {
45 pub sequence_5to3: String,
46 pub annealing_sequence_5to3: String,
47 pub tail_sequence_5to3: String,
48 pub annealing_gc_bases: usize,
49}
50
51#[derive(Debug, Clone, Serialize)]
52pub struct GibsonComponent {
53 pub selection: CoreSelection,
54 pub preparation: Preparation,
55 pub product_start: usize,
56 pub core_sequence_5to3: String,
57 pub fragment_sequence_5to3: String,
60 pub forward_primer: Option<PrimerCandidate>,
62 pub reverse_primer: Option<PrimerCandidate>,
63 pub annotations: Vec<FeatureMapping>,
65}
66
67#[derive(Debug, Clone, Serialize)]
68pub struct GibsonJunction {
69 pub after_component: usize,
70 pub before_component: usize,
71 pub closure: bool,
72 pub product_start: usize,
73 pub overlap_sequence_5to3: String,
74 pub added_by: Option<TailCarrier>,
79}
80
81#[derive(Debug, Clone, Copy, Serialize)]
83#[serde(tag = "primer", content = "component", rename_all = "snake_case")]
84pub enum TailCarrier {
85 ReverseOf(usize),
86 ForwardOf(usize),
87}
88
89#[derive(Debug, Clone, Serialize)]
90pub struct GibsonReport {
91 pub inputs: Vec<SequenceRecord>,
92 pub topology: Topology,
93 pub overlap_length: usize,
94 pub annealing_length: Option<usize>,
95 pub product_sequence_5to3: String,
96 pub components: Vec<GibsonComponent>,
97 pub junctions: Vec<GibsonJunction>,
98 pub assumptions: Vec<&'static str>,
99}
100
101pub fn validate_request(
102 input_count: usize,
103 selected: &[CoreSelection],
104 overlap: usize,
105 annealing: usize,
106) -> Result<(), GibsonError> {
107 if !(1..=16).contains(&input_count) || selected.is_empty() || selected.len() > 128 {
108 return Err(GibsonError::Invalid(
109 "expected 1..=16 templates and 1..=128 cores",
110 ));
111 }
112 if !(20..=60).contains(&overlap) || !(18..=40).contains(&annealing) {
113 return Err(GibsonError::Invalid(
114 "overlap must be 20..=60 and annealing length 18..=40 bases",
115 ));
116 }
117 let mut total = 0usize;
118 for selection in selected {
119 if selection.input == 0
120 || selection.input > input_count
121 || selection.length < overlap.max(2 * annealing)
122 {
123 return Err(GibsonError::Invalid(
124 "invalid template number or core shorter than overlap/two annealing regions",
125 ));
126 }
127 total = total
128 .checked_add(selection.length)
129 .filter(|n| *n <= MAX_BASES)
130 .ok_or(GibsonError::Invalid("product exceeds 1,000,000 bases"))?;
131 }
132 Ok(())
133}
134
135pub fn design(
136 records: &[SequenceRecord],
137 selected: &[CoreSelection],
138 preparation: &[Preparation],
139 topology: Topology,
140 overlap: usize,
141 annealing: usize,
142) -> Result<GibsonReport, GibsonError> {
143 validate_request(records.len(), selected, overlap, annealing)?;
144 let mut report = design_with_lengths(
145 records,
146 selected,
147 preparation,
148 topology,
149 overlap,
150 &vec![(annealing, annealing); selected.len()],
151 )?;
152 report.assumptions.extend([
153 "Primer candidates use explicit fixed annealing lengths; GC counts are descriptive, not optimisation or thermodynamic validation",
154 "Exact primer-site uniqueness is checked on each full template in both orientations; mismatched/off-template binding, dimers, hairpins and melting temperatures are not evaluated",
155 ]);
156 Ok(report)
157}
158
159pub(crate) fn validate_templates(records: &[SequenceRecord]) -> Result<(), GibsonError> {
160 if records.iter().any(|r| {
161 r.sequence().len() > MAX_BASES
162 || !r.sequence().as_str().bytes().all(|b| b"ACGT".contains(&b))
163 }) {
164 return Err(GibsonError::Invalid(
165 "templates must be unambiguous ACGT and at most 1,000,000 bases",
166 ));
167 }
168 Ok(())
169}
170
171fn plan_junctions(
175 cores: &[String],
176 preparation: &[Preparation],
177 starts: &[usize],
178 product_sequence_5to3: &str,
179 topology: Topology,
180 overlap: usize,
181) -> Result<Vec<GibsonJunction>, GibsonError> {
182 let mut junctions: Vec<GibsonJunction> = Vec::new();
183 for i in 0..cores.len() {
184 let closure = i + 1 == cores.len();
185 if closure && topology != Topology::Circular {
186 continue;
187 }
188 let next = (i + 1) % cores.len();
189 let (overlap_sequence_5to3, added_by, product_start) = match (
190 preparation[i],
191 preparation[next],
192 ) {
193 (Preparation::Provided, Preparation::Pcr) => (
196 cores[i][cores[i].len() - overlap..].to_owned(),
197 TailCarrier::ForwardOf(next + 1),
198 starts[i] + cores[i].len() - overlap,
199 ),
200 (Preparation::Provided, Preparation::Provided) => {
201 return Err(GibsonError::Component {
202 component: i + 1,
203 reason: "both fragments at this junction are provided, so no primer can add the overlap; give them existing overlaps instead",
204 });
205 }
206 (Preparation::Pcr, _) => (
208 cores[next][..overlap].to_owned(),
209 TailCarrier::ReverseOf(i + 1),
210 if closure { 0 } else { starts[next] },
211 ),
212 };
213 if !unique_duplex_site(product_sequence_5to3, &overlap_sequence_5to3, topology) {
214 return Err(GibsonError::Component {
215 component: i + 1,
216 reason: "overlap is repeated or reverse-complement ambiguous in the intended product",
217 });
218 }
219 junctions.push(GibsonJunction {
220 after_component: i + 1,
221 before_component: next + 1,
222 closure,
223 product_start,
224 overlap_sequence_5to3,
225 added_by: Some(added_by),
226 });
227 }
228 Ok(junctions)
229}
230
231pub(crate) fn design_with_lengths(
232 records: &[SequenceRecord],
233 selected: &[CoreSelection],
234 preparation: &[Preparation],
235 topology: Topology,
236 overlap: usize,
237 lengths: &[(usize, usize)],
238) -> Result<GibsonReport, GibsonError> {
239 validate_preparation(selected, preparation, overlap)?;
240 validate_lengths(records.len(), selected, overlap, lengths, preparation)?;
241 validate_templates(records)?;
242 let cores = selected
243 .iter()
244 .map(|s| core(&records[s.input - 1], s))
245 .collect::<Result<Vec<_>, _>>()?;
246 let product_sequence_5to3 = cores.concat();
247 let mut starts = Vec::with_capacity(cores.len());
248 let mut at = 0usize;
249 for sequence in &cores {
250 starts.push(at);
251 at += sequence.len();
252 }
253
254 for (i, (selection, sequence)) in selected.iter().zip(&cores).enumerate() {
255 if preparation[i] == Preparation::Provided {
256 continue;
257 }
258 let template = &records[selection.input - 1];
259 let reverse_anneal = reverse_complement(&sequence[sequence.len() - lengths[i].1..]);
260 for annealing in [&sequence[..lengths[i].0], reverse_anneal.as_str()] {
261 if !unique_duplex_site(template.sequence().as_str(), annealing, template.topology()) {
262 return Err(GibsonError::Component {
263 component: i + 1,
264 reason: "primer annealing sequence lacks a unique exact site on its full template",
265 });
266 }
267 }
268 }
269
270 let junctions = plan_junctions(
271 &cores,
272 preparation,
273 &starts,
274 &product_sequence_5to3,
275 topology,
276 overlap,
277 )?;
278
279 let mut components = Vec::new();
280 for (i, (selection, sequence)) in selected.iter().zip(&cores).enumerate() {
281 let template = &records[selection.input - 1];
282 let tail_of = |want: TailCarrier| -> &str {
283 junctions
284 .iter()
285 .find(|j| match (j.added_by, Some(want)) {
286 (Some(TailCarrier::ReverseOf(a)), Some(TailCarrier::ReverseOf(b)))
287 | (Some(TailCarrier::ForwardOf(a)), Some(TailCarrier::ForwardOf(b))) => a == b,
288 _ => false,
289 })
290 .map_or("", |j| j.overlap_sequence_5to3.as_str())
291 };
292 let forward_tail = tail_of(TailCarrier::ForwardOf(i + 1));
293 let reverse_tail = tail_of(TailCarrier::ReverseOf(i + 1));
294 let annotations = core_annotations(template, selection, sequence)?;
295 let (fragment_sequence_5to3, forward_primer, reverse_primer) = match preparation[i] {
296 Preparation::Provided => (sequence.clone(), None, None),
297 Preparation::Pcr => {
298 let forward_anneal = &sequence[..lengths[i].0];
299 let reverse_anneal = reverse_complement(&sequence[sequence.len() - lengths[i].1..]);
300 (
301 format!("{forward_tail}{sequence}{reverse_tail}"),
302 Some(primer(forward_anneal, forward_tail)),
303 Some(primer(&reverse_anneal, &reverse_complement(reverse_tail))),
304 )
305 }
306 };
307 components.push(GibsonComponent {
308 selection: selection.clone(),
309 preparation: preparation[i],
310 product_start: starts[i],
311 core_sequence_5to3: sequence.clone(),
312 fragment_sequence_5to3,
313 forward_primer,
314 reverse_primer,
315 annotations,
316 });
317 }
318 verify_assembly(&components, &junctions, &product_sequence_5to3)?;
319 Ok(GibsonReport {
320 inputs: records.to_vec(),
321 topology,
322 overlap_length: overlap,
323 annealing_length: lengths
324 .iter()
325 .all(|&(f, r)| f == lengths[0].0 && r == f)
326 .then_some(lengths[0].0),
327 product_sequence_5to3,
328 components,
329 junctions,
330 assumptions: vec![
331 "PCR-tail design mode: requested cores concatenate without deduplicating endogenous sequence; this is not intake of pre-existing overlapping fragments",
332 "Each junction's overlap is written by one primer: the upstream fragment's reverse primer, or the downstream fragment's forward primer where the upstream fragment is provided rather than amplified",
333 "Provided fragments (restriction digest, synthesis, stock linear DNA) receive no primers; their ends are used exactly as given and are not verified against any digest here",
334 "Overlaps must occur exactly once in the intended product across both orientations, including circular-origin matches",
335 "Assumes clean sequence-faithful PCR and ideal overlap-directed assembly; reaction yield, enzyme conditions and experimental validity are not predicted",
336 "Core-local annotations map through product_start; feature reunion/fusion, translations and biological function are not inferred",
337 "Synthetic overlap copies are removed exactly once at each planned junction; final core bases remain unchanged",
338 "Source primers are provenance only, not reused binding-site predictions; opaque SnapGene metadata is not exported",
339 "Linear single-core designs are PCR-only, without an assembly junction; circular single-core designs model PCR-assisted reclosure",
340 ],
341 })
342}
343
344fn validate_preparation(
347 selected: &[CoreSelection],
348 preparation: &[Preparation],
349 overlap: usize,
350) -> Result<(), GibsonError> {
351 if preparation.len() != selected.len() {
352 return Err(GibsonError::Invalid(
353 "every core needs an explicit preparation",
354 ));
355 }
356 for (i, selection) in selected.iter().enumerate() {
357 if preparation[i] == Preparation::Provided && selection.length < 2 * overlap {
358 return Err(GibsonError::Component {
359 component: i + 1,
360 reason: "a provided fragment must be at least twice the overlap length",
361 });
362 }
363 }
364 Ok(())
365}
366
367fn validate_lengths(
368 input_count: usize,
369 selected: &[CoreSelection],
370 overlap: usize,
371 lengths: &[(usize, usize)],
372 preparation: &[Preparation],
373) -> Result<(), GibsonError> {
374 validate_request(input_count, selected, overlap, 18)?;
375 if lengths.len() != selected.len()
376 || lengths
377 .iter()
378 .zip(selected)
379 .zip(preparation)
380 .filter(|(_, p)| **p == Preparation::Pcr)
381 .any(|((&(f, r), s), _)| {
382 !(18..=40).contains(&f) || !(18..=40).contains(&r) || f + r > s.length
383 })
384 {
385 return Err(GibsonError::Invalid(
386 "invalid or overlapping primer annealing lengths",
387 ));
388 }
389 Ok(())
390}
391
392pub(crate) fn core_annotations(
393 template: &SequenceRecord,
394 selection: &CoreSelection,
395 sequence: &str,
396) -> Result<Vec<FeatureMapping>, DomainError> {
397 let interval = FragmentStrand {
398 source_start: selection.start,
399 length: selection.length,
400 sequence_5to3: sequence.into(),
401 };
402 template
403 .features()
404 .iter()
405 .map(|f| {
406 map_feature(
407 f,
408 &interval,
409 Topology::Linear,
410 template.sequence().len(),
411 selection.orientation == Orientation::Reverse,
412 )
413 })
414 .collect::<Result<Vec<_>, _>>()
415 .map(|m| m.into_iter().filter(|m| !m.parts.is_empty()).collect())
416}
417
418pub fn core(record: &SequenceRecord, selection: &CoreSelection) -> Result<String, GibsonError> {
419 let n = record.sequence().len();
420 let end = selection
421 .start
422 .checked_add(selection.length)
423 .ok_or(GibsonError::Invalid("source interval overflow"))?;
424 if selection.start >= n
425 || selection.length > n
426 || (record.topology() == Topology::Linear && end > n)
427 {
428 return Err(GibsonError::Invalid(
429 "source interval outside template bounds/topology",
430 ));
431 }
432 let sequence = record.sequence().as_str();
433 let core = if end <= n {
434 sequence[selection.start..end].to_owned()
435 } else {
436 format!("{}{}", &sequence[selection.start..], &sequence[..end - n])
437 };
438 Ok(if selection.orientation == Orientation::Reverse {
439 reverse_complement(&core)
440 } else {
441 core
442 })
443}
444
445pub(crate) fn primer(annealing: &str, tail: &str) -> PrimerCandidate {
446 PrimerCandidate {
447 sequence_5to3: format!("{tail}{annealing}"),
448 annealing_sequence_5to3: annealing.into(),
449 tail_sequence_5to3: tail.into(),
450 annealing_gc_bases: annealing
451 .bytes()
452 .filter(|b| matches!(b, b'G' | b'C'))
453 .count(),
454 }
455}
456
457#[must_use]
458pub fn unique_duplex_site(sequence: &str, motif: &str, topology: Topology) -> bool {
459 let rc = reverse_complement(motif);
460 if rc == motif || motif.len() > sequence.len() {
461 return false;
462 }
463 let extended = if topology == Topology::Circular {
464 format!("{sequence}{}", &sequence[..motif.len() - 1])
465 } else {
466 sequence.into()
467 };
468 extended
469 .as_bytes()
470 .windows(motif.len())
471 .filter(|w| *w == motif.as_bytes() || *w == rc.as_bytes())
472 .take(2)
473 .count()
474 == 1
475}
476
477fn verify_assembly(
478 components: &[GibsonComponent],
479 junctions: &[GibsonJunction],
480 expected: &str,
481) -> Result<(), GibsonError> {
482 let mut assembled = components[0].fragment_sequence_5to3.clone();
483 for junction in junctions {
484 let overlap = &junction.overlap_sequence_5to3;
485 let next = &components[junction.before_component - 1].fragment_sequence_5to3;
486 if !assembled.ends_with(overlap) || !next.starts_with(overlap) {
487 return Err(GibsonError::Invalid("fragment junction mismatch"));
488 }
489 if junction.closure {
490 assembled.truncate(assembled.len() - overlap.len());
491 } else {
492 assembled.push_str(&next[overlap.len()..]);
493 }
494 }
495 if assembled != expected {
496 return Err(GibsonError::Invalid(
497 "fragment assembly does not conserve requested cores",
498 ));
499 }
500 Ok(())
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506 use crate::DnaSeq;
507 fn record() -> SequenceRecord {
508 let mut state = 1_234_567_u64;
510 let sequence: String = (0..300)
511 .map(|_| {
512 state ^= state << 13;
513 state ^= state >> 7;
514 state ^= state << 17;
515 char::from(b"ACGT"[(state % 4) as usize])
516 })
517 .collect();
518 SequenceRecord::new(
519 "synthetic",
520 DnaSeq::new(sequence).unwrap(),
521 Topology::Circular,
522 vec![],
523 vec![],
524 )
525 .unwrap()
526 }
527 fn selections() -> Vec<CoreSelection> {
528 vec![
529 CoreSelection {
530 input: 1,
531 start: 270,
532 length: 90,
533 orientation: Orientation::Forward,
534 },
535 CoreSelection {
536 input: 1,
537 start: 90,
538 length: 100,
539 orientation: Orientation::Reverse,
540 },
541 ]
542 }
543 #[test]
544 fn circular_origin_reverse_core_and_pcr_tails_reconstruct_product() {
545 let report = design(
546 &[record()],
547 &selections(),
548 &[Preparation::Pcr; 2],
549 Topology::Circular,
550 25,
551 22,
552 )
553 .unwrap();
554 assert_eq!(report.product_sequence_5to3.len(), 190);
555 assert_eq!(report.junctions.len(), 2);
556 assert!(report.junctions[1].closure);
557 assert_eq!(
558 report.components[0]
559 .forward_primer
560 .as_ref()
561 .unwrap()
562 .tail_sequence_5to3,
563 ""
564 );
565 assert_eq!(
566 report.components[0]
567 .reverse_primer
568 .as_ref()
569 .unwrap()
570 .tail_sequence_5to3,
571 reverse_complement(&report.components[1].core_sequence_5to3[..25])
572 );
573 }
574 #[test]
575 fn linear_last_primer_has_no_tail_and_source_errors_fail() {
576 let r = record();
577 let report = design(
578 std::slice::from_ref(&r),
579 &selections(),
580 &[Preparation::Pcr; 2],
581 Topology::Linear,
582 25,
583 22,
584 )
585 .unwrap();
586 assert_eq!(
587 report.components[1]
588 .reverse_primer
589 .as_ref()
590 .unwrap()
591 .tail_sequence_5to3,
592 ""
593 );
594 assert_eq!(report.junctions.len(), 1);
595 let mut bad = selections();
596 bad[0].start = usize::MAX;
597 assert!(
598 design(
599 std::slice::from_ref(&r),
600 &bad,
601 &[Preparation::Pcr; 2],
602 Topology::Linear,
603 25,
604 22
605 )
606 .is_err()
607 );
608 assert!(
609 design(
610 &[r],
611 &selections(),
612 &[Preparation::Pcr; 2],
613 Topology::Linear,
614 19,
615 22
616 )
617 .is_err()
618 );
619 }
620 #[test]
621 fn resource_and_template_bounds_are_explicit() {
622 let mut selected = selections();
623 assert!(validate_request(17, &selected, 25, 22).is_err());
624 assert!(validate_request(0, &selected, 25, 22).is_err());
625 assert!(validate_request(1, &vec![selected[0].clone(); 129], 25, 22).is_err());
626 selected.truncate(1);
627 selected[0].length = MAX_BASES;
628 assert!(validate_request(1, &selected, 60, 40).is_ok());
629 selected[0].length += 1;
630 assert!(validate_request(1, &selected, 60, 40).is_err());
631 selected[0].length = usize::MAX;
632 assert!(validate_request(1, &selected, 60, 40).is_err());
633 let mut ambiguous = record().sequence().as_str().to_owned();
634 ambiguous.replace_range(0..1, "N");
635 let r = SequenceRecord::new(
636 "synthetic",
637 DnaSeq::new(ambiguous).unwrap(),
638 Topology::Circular,
639 vec![],
640 vec![],
641 )
642 .unwrap();
643 assert!(
644 design(
645 &[r],
646 &selections(),
647 &[Preparation::Pcr; 2],
648 Topology::Circular,
649 25,
650 22
651 )
652 .is_err()
653 );
654 }
655
656 #[test]
659 fn a_provided_vector_puts_both_tails_on_the_amplified_insert() {
660 let report = design(
661 &[record()],
662 &selections(),
663 &[Preparation::Provided, Preparation::Pcr],
664 Topology::Circular,
665 25,
666 22,
667 )
668 .unwrap();
669 let (vector, insert) = (&report.components[0], &report.components[1]);
670 assert!(vector.forward_primer.is_none() && vector.reverse_primer.is_none());
671 assert_eq!(vector.fragment_sequence_5to3, vector.core_sequence_5to3);
672
673 let forward = insert.forward_primer.as_ref().unwrap();
674 let reverse = insert.reverse_primer.as_ref().unwrap();
675 assert_eq!(forward.tail_sequence_5to3.len(), 25);
676 assert_eq!(reverse.tail_sequence_5to3.len(), 25);
677 assert_eq!(
680 forward.tail_sequence_5to3,
681 vector.core_sequence_5to3[vector.core_sequence_5to3.len() - 25..]
682 );
683 assert_eq!(
684 reverse.tail_sequence_5to3,
685 reverse_complement(&vector.core_sequence_5to3[..25])
686 );
687 assert!(matches!(
688 report.junctions[0].added_by,
689 Some(TailCarrier::ForwardOf(2))
690 ));
691 assert!(matches!(
692 report.junctions[1].added_by,
693 Some(TailCarrier::ReverseOf(2))
694 ));
695 }
696
697 #[test]
698 fn two_provided_fragments_cannot_form_a_junction() {
699 let error = design(
700 &[record()],
701 &selections(),
702 &[Preparation::Provided; 2],
703 Topology::Circular,
704 25,
705 22,
706 )
707 .unwrap_err();
708 assert!(format!("{error}").contains("both fragments at this junction are provided"));
709 }
710
711 #[test]
712 fn preparation_must_cover_every_core() {
713 assert!(
714 design(
715 &[record()],
716 &selections(),
717 &[Preparation::Pcr],
718 Topology::Circular,
719 25,
720 22
721 )
722 .is_err()
723 );
724 }
725
726 #[test]
727 fn exact_uniqueness_includes_opposite_strand_and_origin_crossings() {
728 assert!(!unique_duplex_site(
729 "AACGTGGACGTT",
730 "AACGT",
731 Topology::Linear
732 ));
733 assert!(unique_duplex_site("ACGTTCGGA", "GGAAC", Topology::Circular));
734 assert!(!unique_duplex_site("ACGTTCGGA", "GGAAC", Topology::Linear));
735 }
736
737 #[test]
738 fn repeated_primers_overlaps_and_palindromes_are_rejected() {
739 assert!(!unique_duplex_site("AAAAAA", "AAA", Topology::Linear));
740 assert!(!unique_duplex_site("AAGCTT", "AAGCTT", Topology::Linear));
741 assert!(unique_duplex_site("GACCTAGT", "TGA", Topology::Circular));
742 let s = selections()[0].clone();
743 assert!(
744 design(
745 &[record()],
746 &[s.clone(), s],
747 &[Preparation::Pcr; 2],
748 Topology::Circular,
749 25,
750 22
751 )
752 .is_err()
753 );
754 }
755}