Skip to main content

dnagent_domain/
lib.rs

1//! Biological domain types shared by every DNAgent adapter.
2
3pub 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/// Errors raised while constructing checked domain values.
23#[derive(Debug, Clone, PartialEq, Eq, Error)]
24pub enum DomainError {
25    /// A sequence was empty.
26    #[error("DNA sequence must not be empty")]
27    EmptySequence,
28    /// A sequence contained a non-IUPAC DNA symbol.
29    #[error("invalid IUPAC DNA symbol {symbol:?} at byte {index}")]
30    InvalidSymbol { symbol: char, index: usize },
31    /// A stable identifier was empty.
32    #[error("{kind} identifier must not be empty")]
33    EmptyId { kind: &'static str },
34    /// A location had no regions.
35    #[error("a feature location must contain at least one region")]
36    EmptyLocation,
37    /// The operator did not match the number of regions.
38    #[error("location operator {operator:?} is incompatible with {part_count} regions")]
39    InvalidLocationOperator {
40        operator: LocationOperator,
41        part_count: usize,
42    },
43    /// A region did not fit the molecule.
44    #[error("invalid region for molecule length {molecule_length}: {reason}")]
45    InvalidRegion {
46        molecule_length: usize,
47        reason: String,
48    },
49    /// Two features used the same record-local identifier.
50    #[error("duplicate feature identifier {id:?}")]
51    DuplicateFeatureId { id: String },
52}
53
54/// Validated, canonical uppercase IUPAC DNA.
55#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
56#[serde(transparent)]
57pub struct DnaSeq(String);
58
59impl DnaSeq {
60    /// Validate and canonicalize an IUPAC DNA sequence.
61    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    /// Return the canonical sequence.
79    #[must_use]
80    pub fn as_str(&self) -> &str {
81        &self.0
82    }
83
84    /// Complement aligned to the forward reference, read left-to-right 3′→5′.
85    /// This does not reverse the coordinate frame or return a 5′→3′ reverse complement.
86    #[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    /// Return the number of bases.
112    #[must_use]
113    pub fn len(&self) -> usize {
114        self.0.len()
115    }
116
117    /// DNAgent sequences are never empty after construction.
118    #[must_use]
119    pub const fn is_empty(&self) -> bool {
120        false
121    }
122
123    /// Return a checked zero-based, half-open sequence slice.
124    #[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/// Physical topology of an imported DNA record.
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(rename_all = "snake_case")]
143pub enum Topology {
144    /// A molecule with two ends.
145    Linear,
146    /// A covalently closed or conceptually circular record.
147    Circular,
148}
149
150/// Zero-based base-boundary position.
151#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
152#[serde(transparent)]
153pub struct Position(usize);
154
155impl Position {
156    /// Create a position. Bounds are checked by the containing region.
157    #[must_use]
158    pub const fn new(value: usize) -> Self {
159        Self(value)
160    }
161
162    /// Return the numeric boundary position.
163    #[must_use]
164    pub const fn get(self) -> usize {
165        self.0
166    }
167}
168
169/// Number of bases.
170#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
171#[serde(transparent)]
172pub struct Length(usize);
173
174impl Length {
175    /// Create a non-zero length.
176    #[must_use]
177    pub const fn new(value: usize) -> Option<Self> {
178        if value == 0 { None } else { Some(Self(value)) }
179    }
180
181    /// Return the number of bases.
182    #[must_use]
183    pub const fn get(self) -> usize {
184        self.0
185    }
186}
187
188/// Strand relative to the record's forward sequence.
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
190#[serde(rename_all = "snake_case")]
191pub enum Strand {
192    /// Forward/reference strand.
193    Forward,
194    /// Reverse/complementary strand.
195    Reverse,
196    /// Strand is absent or unknown.
197    Unknown,
198}
199
200/// How multiple regions compose a biological location.
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
202#[serde(rename_all = "snake_case")]
203pub enum LocationOperator {
204    /// One contiguous region.
205    Contiguous,
206    /// Ordered joined regions.
207    Join,
208    /// Ordered regions whose precise relationship is unknown.
209    Order,
210}
211
212/// A checked zero-based, half-open region. Construction is only through checked methods.
213/// JSON retains the tagged `linear` / `circular_arc` representation.
214///
215/// ```compile_fail
216/// use dnagent_domain::{Position, Region};
217/// let invalid = Region::Linear { start: Position::new(9), end: Position::new(2) };
218/// ```
219#[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    /// A conventional non-wrapping interval.
227    Linear { start: Position, end: Position },
228    /// A contiguous interval on a circle, represented by start and length.
229    CircularArc { start: Position, length: Length },
230}
231
232impl Region {
233    /// Construct a checked linear interval.
234    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    /// Construct a checked circular arc.
248    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    /// Return the first boundary on the stored forward record.
266    #[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    /// Return the number of bases represented by this region.
274    #[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    /// Whether this region requires circular topology.
283    #[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/// A feature location preserving biological identity across one or more regions.
297#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
298pub struct Location {
299    parts: Vec<Region>,
300    strand: Strand,
301    operator: LocationOperator,
302}
303
304impl Location {
305    /// Construct a non-empty location.
306    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    /// Return the ordered regions.
328    #[must_use]
329    pub fn parts(&self) -> &[Region] {
330        &self.parts
331    }
332
333    /// Return the strand.
334    #[must_use]
335    pub const fn strand(&self) -> Strand {
336        self.strand
337    }
338
339    /// Return the composition operator.
340    #[must_use]
341    pub const fn operator(&self) -> LocationOperator {
342        self.operator
343    }
344}
345
346/// Stable feature identifier within an artifact.
347#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
348#[serde(transparent)]
349pub struct FeatureId(String);
350
351impl FeatureId {
352    /// Construct a non-empty feature identifier.
353    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    /// Return the identifier text.
362    #[must_use]
363    pub fn as_str(&self) -> &str {
364        &self.0
365    }
366}
367
368/// Ordered, repeatable feature qualifier. A `None` value represents a valueless qualifier.
369#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
370pub struct Qualifier {
371    pub key: String,
372    pub value: Option<String>,
373}
374
375/// Non-biological presentation hints imported from a format.
376#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
377pub struct DisplayHints {
378    pub color: Option<String>,
379}
380
381/// An annotated biological feature.
382#[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    /// Construct a feature.
394    #[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/// Primer content retained during migration import.
445#[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/// Imported annotated DNA artifact.
453#[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    /// Construct a normalized record and verify record-level invariants.
464    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 &region.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}