1pub mod amplification;
4pub mod compatibility;
5pub mod digest;
6pub mod existing_overlaps;
7pub mod feature_match;
8pub mod fragment_annotations;
9mod genetic_codes;
10pub mod gibson;
11pub mod ligation;
12pub mod primer_optimisation;
13pub mod restriction;
14pub mod translation;
15
16use serde::{Deserialize, Serialize};
17use std::{collections::HashSet, fmt};
18use thiserror::Error;
19
20const IUPAC_DNA: &str = "ACGTRYSWKMBDHVN";
21
22#[derive(Debug, Clone, PartialEq, Eq, Error)]
24pub enum DomainError {
25 #[error("DNA sequence must not be empty")]
27 EmptySequence,
28 #[error("invalid IUPAC DNA symbol {symbol:?} at byte {index}")]
30 InvalidSymbol { symbol: char, index: usize },
31 #[error("{kind} identifier must not be empty")]
33 EmptyId { kind: &'static str },
34 #[error("a feature location must contain at least one region")]
36 EmptyLocation,
37 #[error("location operator {operator:?} is incompatible with {part_count} regions")]
39 InvalidLocationOperator {
40 operator: LocationOperator,
41 part_count: usize,
42 },
43 #[error("invalid region for molecule length {molecule_length}: {reason}")]
45 InvalidRegion {
46 molecule_length: usize,
47 reason: String,
48 },
49 #[error("duplicate feature identifier {id:?}")]
51 DuplicateFeatureId { id: String },
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
56#[serde(transparent)]
57pub struct DnaSeq(String);
58
59impl DnaSeq {
60 pub fn new(sequence: impl AsRef<str>) -> Result<Self, DomainError> {
62 let sequence = sequence.as_ref();
63 if sequence.is_empty() {
64 return Err(DomainError::EmptySequence);
65 }
66
67 let mut canonical = String::with_capacity(sequence.len());
68 for (index, symbol) in sequence.char_indices() {
69 let symbol = symbol.to_ascii_uppercase();
70 if !symbol.is_ascii() || !IUPAC_DNA.contains(symbol) {
71 return Err(DomainError::InvalidSymbol { symbol, index });
72 }
73 canonical.push(symbol);
74 }
75 Ok(Self(canonical))
76 }
77
78 #[must_use]
80 pub fn as_str(&self) -> &str {
81 &self.0
82 }
83
84 #[must_use]
87 pub fn aligned_complement_3to5(&self) -> String {
88 self.0
89 .bytes()
90 .map(|base| match base {
91 b'A' => 'T',
92 b'T' => 'A',
93 b'C' => 'G',
94 b'G' => 'C',
95 b'R' => 'Y',
96 b'Y' => 'R',
97 b'S' => 'S',
98 b'W' => 'W',
99 b'K' => 'M',
100 b'M' => 'K',
101 b'B' => 'V',
102 b'V' => 'B',
103 b'D' => 'H',
104 b'H' => 'D',
105 b'N' => 'N',
106 _ => unreachable!("DnaSeq validates IUPAC DNA"),
107 })
108 .collect()
109 }
110
111 #[must_use]
113 pub fn len(&self) -> usize {
114 self.0.len()
115 }
116
117 #[must_use]
119 pub const fn is_empty(&self) -> bool {
120 false
121 }
122
123 #[must_use]
125 pub fn slice(&self, start: usize, end: usize) -> Option<&str> {
126 if start <= end {
127 self.0.get(start..end)
128 } else {
129 None
130 }
131 }
132}
133
134impl fmt::Display for DnaSeq {
135 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
136 formatter.write_str(&self.0)
137 }
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(rename_all = "snake_case")]
143pub enum Topology {
144 Linear,
146 Circular,
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
152#[serde(transparent)]
153pub struct Position(usize);
154
155impl Position {
156 #[must_use]
158 pub const fn new(value: usize) -> Self {
159 Self(value)
160 }
161
162 #[must_use]
164 pub const fn get(self) -> usize {
165 self.0
166 }
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
171#[serde(transparent)]
172pub struct Length(usize);
173
174impl Length {
175 #[must_use]
177 pub const fn new(value: usize) -> Option<Self> {
178 if value == 0 { None } else { Some(Self(value)) }
179 }
180
181 #[must_use]
183 pub const fn get(self) -> usize {
184 self.0
185 }
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
190#[serde(rename_all = "snake_case")]
191pub enum Strand {
192 Forward,
194 Reverse,
196 Unknown,
198}
199
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
202#[serde(rename_all = "snake_case")]
203pub enum LocationOperator {
204 Contiguous,
206 Join,
208 Order,
210}
211
212#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
220#[serde(transparent)]
221pub struct Region(RegionValue);
222
223#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
224#[serde(tag = "kind", rename_all = "snake_case")]
225enum RegionValue {
226 Linear { start: Position, end: Position },
228 CircularArc { start: Position, length: Length },
230}
231
232impl Region {
233 pub fn linear(start: usize, end: usize, molecule_length: usize) -> Result<Self, DomainError> {
235 if start >= end || end > molecule_length {
236 return Err(invalid_region(
237 molecule_length,
238 format!("linear interval [{start}, {end})"),
239 ));
240 }
241 Ok(Self(RegionValue::Linear {
242 start: Position::new(start),
243 end: Position::new(end),
244 }))
245 }
246
247 pub fn circular_arc(
249 start: usize,
250 length: usize,
251 molecule_length: usize,
252 ) -> Result<Self, DomainError> {
253 if start >= molecule_length || length == 0 || length > molecule_length {
254 return Err(invalid_region(
255 molecule_length,
256 format!("circular arc starting at {start} with length {length}"),
257 ));
258 }
259 Ok(Self(RegionValue::CircularArc {
260 start: Position::new(start),
261 length: Length::new(length).expect("length was checked as non-zero"),
262 }))
263 }
264
265 #[must_use]
267 pub const fn start(&self) -> Position {
268 match &self.0 {
269 RegionValue::Linear { start, .. } | RegionValue::CircularArc { start, .. } => *start,
270 }
271 }
272
273 #[must_use]
275 pub const fn length(&self) -> Length {
276 match &self.0 {
277 RegionValue::Linear { start, end } => Length(end.0 - start.0),
278 RegionValue::CircularArc { length, .. } => *length,
279 }
280 }
281
282 #[must_use]
284 pub const fn is_circular_arc(&self) -> bool {
285 matches!(self.0, RegionValue::CircularArc { .. })
286 }
287}
288
289fn invalid_region(molecule_length: usize, reason: String) -> DomainError {
290 DomainError::InvalidRegion {
291 molecule_length,
292 reason,
293 }
294}
295
296#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
298pub struct Location {
299 parts: Vec<Region>,
300 strand: Strand,
301 operator: LocationOperator,
302}
303
304impl Location {
305 pub fn new(
307 parts: Vec<Region>,
308 strand: Strand,
309 operator: LocationOperator,
310 ) -> Result<Self, DomainError> {
311 if parts.is_empty() {
312 return Err(DomainError::EmptyLocation);
313 }
314 if (parts.len() == 1) != (operator == LocationOperator::Contiguous) {
315 return Err(DomainError::InvalidLocationOperator {
316 operator,
317 part_count: parts.len(),
318 });
319 }
320 Ok(Self {
321 parts,
322 strand,
323 operator,
324 })
325 }
326
327 #[must_use]
329 pub fn parts(&self) -> &[Region] {
330 &self.parts
331 }
332
333 #[must_use]
335 pub const fn strand(&self) -> Strand {
336 self.strand
337 }
338
339 #[must_use]
341 pub const fn operator(&self) -> LocationOperator {
342 self.operator
343 }
344}
345
346#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
348#[serde(transparent)]
349pub struct FeatureId(String);
350
351impl FeatureId {
352 pub fn new(value: impl Into<String>) -> Result<Self, DomainError> {
354 let value = value.into();
355 if value.is_empty() {
356 return Err(DomainError::EmptyId { kind: "feature" });
357 }
358 Ok(Self(value))
359 }
360
361 #[must_use]
363 pub fn as_str(&self) -> &str {
364 &self.0
365 }
366}
367
368#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
370pub struct Qualifier {
371 pub key: String,
372 pub value: Option<String>,
373}
374
375#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
377pub struct DisplayHints {
378 pub color: Option<String>,
379}
380
381#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
383pub struct Feature {
384 id: FeatureId,
385 kind: String,
386 label: String,
387 location: Location,
388 qualifiers: Vec<Qualifier>,
389 display: DisplayHints,
390}
391
392impl Feature {
393 #[must_use]
395 pub fn new(
396 id: FeatureId,
397 kind: impl Into<String>,
398 label: impl Into<String>,
399 location: Location,
400 qualifiers: Vec<Qualifier>,
401 display: DisplayHints,
402 ) -> Self {
403 Self {
404 id,
405 kind: kind.into(),
406 label: label.into(),
407 location,
408 qualifiers,
409 display,
410 }
411 }
412
413 #[must_use]
414 pub const fn id(&self) -> &FeatureId {
415 &self.id
416 }
417
418 #[must_use]
419 pub fn kind(&self) -> &str {
420 &self.kind
421 }
422
423 #[must_use]
424 pub fn label(&self) -> &str {
425 &self.label
426 }
427
428 #[must_use]
429 pub const fn location(&self) -> &Location {
430 &self.location
431 }
432
433 #[must_use]
434 pub fn qualifiers(&self) -> &[Qualifier] {
435 &self.qualifiers
436 }
437
438 #[must_use]
439 pub const fn display(&self) -> &DisplayHints {
440 &self.display
441 }
442}
443
444#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
446pub struct ImportedPrimer {
447 pub name: String,
448 pub sequence: DnaSeq,
449 pub description: Option<String>,
450}
451
452#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
454pub struct SequenceRecord {
455 name: String,
456 sequence: DnaSeq,
457 topology: Topology,
458 features: Vec<Feature>,
459 primers: Vec<ImportedPrimer>,
460}
461
462impl SequenceRecord {
463 pub fn new(
465 name: impl Into<String>,
466 sequence: DnaSeq,
467 topology: Topology,
468 features: Vec<Feature>,
469 primers: Vec<ImportedPrimer>,
470 ) -> Result<Self, DomainError> {
471 let molecule_length = sequence.len();
472 let mut feature_ids = HashSet::with_capacity(features.len());
473 for feature in &features {
474 if !feature_ids.insert(feature.id().as_str()) {
475 return Err(DomainError::DuplicateFeatureId {
476 id: feature.id().as_str().to_owned(),
477 });
478 }
479 for region in feature.location().parts() {
480 match ®ion.0 {
481 RegionValue::Linear { end, .. } if end.get() > molecule_length => {
482 return Err(invalid_region(
483 molecule_length,
484 format!("feature {} extends to {}", feature.id().as_str(), end.get()),
485 ));
486 }
487 RegionValue::CircularArc { start, length }
488 if topology != Topology::Circular
489 || start.get() >= molecule_length
490 || length.get() > molecule_length =>
491 {
492 return Err(invalid_region(
493 molecule_length,
494 format!(
495 "feature {} has an incompatible circular arc",
496 feature.id().as_str()
497 ),
498 ));
499 }
500 _ => {}
501 }
502 }
503 }
504 Ok(Self {
505 name: name.into(),
506 sequence,
507 topology,
508 features,
509 primers,
510 })
511 }
512
513 #[must_use]
514 pub fn name(&self) -> &str {
515 &self.name
516 }
517
518 #[must_use]
519 pub const fn sequence(&self) -> &DnaSeq {
520 &self.sequence
521 }
522
523 #[must_use]
524 pub const fn topology(&self) -> Topology {
525 self.topology
526 }
527
528 #[must_use]
529 pub fn features(&self) -> &[Feature] {
530 &self.features
531 }
532
533 #[must_use]
534 pub fn primers(&self) -> &[ImportedPrimer] {
535 &self.primers
536 }
537}
538
539#[cfg(test)]
540mod tests {
541 use super::*;
542
543 #[test]
544 fn aligned_complement_preserves_positions_and_all_iupac_symbols() {
545 let sequence = DnaSeq::new("ACGTRYSWKMBDHVN").unwrap();
546 assert_eq!(sequence.aligned_complement_3to5(), "TGCAYRSWMKVHDBN");
547 let complement = DnaSeq::new(sequence.aligned_complement_3to5()).unwrap();
548 assert_eq!(complement.aligned_complement_3to5(), sequence.as_str());
549 }
550
551 #[test]
552 fn sequence_is_validated_and_canonicalized() {
553 let sequence = DnaSeq::new("acgtryn").unwrap();
554 assert_eq!(sequence.as_str(), "ACGTRYN");
555 assert!(matches!(
556 DnaSeq::new("ACGU"),
557 Err(DomainError::InvalidSymbol {
558 symbol: 'U',
559 index: 3
560 })
561 ));
562 }
563
564 #[test]
565 fn circular_arc_can_cross_the_origin() {
566 let region = Region::circular_arc(8, 4, 10).unwrap();
567 assert_eq!(region.start().get(), 8);
568 assert_eq!(region.length().get(), 4);
569 }
570
571 #[test]
572 fn region_boundaries_are_checked_exhaustively_on_small_molecules() {
573 for size in 0..8 {
574 for start in 0..10 {
575 for end in 0..10 {
576 let region = Region::linear(start, end, size);
577 assert_eq!(region.is_ok(), start < end && end <= size);
578 if let Ok(region) = region {
579 assert_eq!(region.length().get(), end - start);
580 }
581 let arc = Region::circular_arc(start, end, size);
582 assert_eq!(arc.is_ok(), start < size && end > 0 && end <= size);
583 }
584 }
585 }
586 assert_eq!(
587 Region::linear(0, usize::MAX, usize::MAX)
588 .unwrap()
589 .length()
590 .get(),
591 usize::MAX
592 );
593 assert!(Region::linear(usize::MAX, 0, usize::MAX).is_err());
594 }
595
596 #[test]
597 fn location_operator_must_match_part_count() {
598 let part = Region::linear(0, 2, 10).unwrap();
599 for operator in [
600 LocationOperator::Contiguous,
601 LocationOperator::Join,
602 LocationOperator::Order,
603 ] {
604 assert!(Location::new(vec![], Strand::Unknown, operator).is_err());
605 assert_eq!(
606 Location::new(vec![part.clone()], Strand::Forward, operator).is_ok(),
607 operator == LocationOperator::Contiguous
608 );
609 assert_eq!(
610 Location::new(
611 vec![part.clone(), Region::linear(4, 6, 10).unwrap()],
612 Strand::Reverse,
613 operator
614 )
615 .is_ok(),
616 operator != LocationOperator::Contiguous
617 );
618 }
619 }
620
621 #[test]
622 fn record_rechecks_regions_against_actual_length_and_topology() {
623 let make_record = |region, topology| {
624 let feature = Feature::new(
625 FeatureId::new("feature").unwrap(),
626 "misc_feature",
627 "test",
628 Location::new(vec![region], Strand::Forward, LocationOperator::Contiguous).unwrap(),
629 vec![],
630 DisplayHints::default(),
631 );
632 SequenceRecord::new(
633 "test",
634 DnaSeq::new("ACGT").unwrap(),
635 topology,
636 vec![feature],
637 vec![],
638 )
639 };
640 assert!(make_record(Region::linear(3, 5, 10).unwrap(), Topology::Linear).is_err());
641 assert!(make_record(Region::circular_arc(0, 5, 10).unwrap(), Topology::Circular).is_err());
642 assert!(make_record(Region::circular_arc(4, 1, 10).unwrap(), Topology::Circular).is_err());
643 assert!(make_record(Region::circular_arc(3, 2, 4).unwrap(), Topology::Linear).is_err());
644 assert!(make_record(Region::circular_arc(3, 2, 4).unwrap(), Topology::Circular).is_ok());
645 assert!(make_record(Region::linear(0, 4, 4).unwrap(), Topology::Linear).is_ok());
646 }
647
648 #[test]
649 fn invalid_regions_are_rejected() {
650 assert!(Region::linear(5, 5, 10).is_err());
651 assert!(Region::circular_arc(10, 1, 10).is_err());
652 assert!(Region::circular_arc(0, 11, 10).is_err());
653 }
654}