bio_forge/model/
residue.rs

1//! Representation of a polymer residue and its atom inventory.
2//!
3//! Residues bridge raw template data, readers/writers, and structure-level operations by
4//! tracking naming metadata, chain position, and owned atoms. Higher-level components use
5//! this module to inspect or mutate residues while preserving biochemical context.
6
7use super::atom::Atom;
8use super::types::{ResidueCategory, ResiduePosition, StandardResidue};
9use std::fmt;
10
11/// A residue within a biomolecular structure.
12///
13/// Residues capture crystallographic identifiers (sequence number and insertion code), the
14/// original and normalized names, classification metadata, and the atoms that belong to the
15/// residue. They are the primary unit passed between IO routines, topology builders, and
16/// structure editing operations.
17#[derive(Debug, Clone, PartialEq)]
18pub struct Residue {
19    /// Author-provided sequence identifier (can be negative for certain files).
20    pub id: i32,
21    /// Optional insertion code differentiating records that share the same `id`.
22    pub insertion_code: Option<char>,
23    /// Original residue name as encountered during parsing.
24    pub name: String,
25    /// Canonical residue assignment if the name maps to a standard alphabet entry.
26    pub standard_name: Option<StandardResidue>,
27    /// Category describing whether the residue is standard, heterogen, or ion.
28    pub category: ResidueCategory,
29    /// Chain position annotation (terminus, internal, or nucleic end).
30    pub position: ResiduePosition,
31    /// Owned atoms that comprise the residue.
32    atoms: Vec<Atom>,
33}
34
35impl Residue {
36    /// Creates a residue with the provided identifiers and classification.
37    ///
38    /// The residue starts without atoms and defaults to `ResiduePosition::None` until the
39    /// caller updates it based on chain topology.
40    ///
41    /// # Arguments
42    ///
43    /// * `id` - Sequence number pulled from the original structure file.
44    /// * `insertion_code` - Optional insertion code for distinguishing alternate residues.
45    /// * `name` - Author-provided residue label (e.g., `"ALA"`, `"DN1"`).
46    /// * `standard_name` - Canonical assignment when the residue matches a standard entry.
47    /// * `category` - Classification describing standard, hetero, or ion behavior.
48    ///
49    /// # Returns
50    ///
51    /// A `Residue` ready for atoms to be added.
52    pub fn new(
53        id: i32,
54        insertion_code: Option<char>,
55        name: &str,
56        standard_name: Option<StandardResidue>,
57        category: ResidueCategory,
58    ) -> Self {
59        Self {
60            id,
61            insertion_code,
62            name: name.to_string(),
63            standard_name,
64            category,
65            position: ResiduePosition::None,
66            atoms: Vec::new(),
67        }
68    }
69
70    /// Reports whether the residue maps to a standard residue definition.
71    ///
72    /// # Returns
73    ///
74    /// `true` when `standard_name` is set.
75    pub fn is_standard(&self) -> bool {
76        self.standard_name.is_some()
77    }
78
79    /// Appends an atom to the residue.
80    ///
81    /// Duplicate atom names are guarded with a debug assertion to prevent inconsistent
82    /// topologies while still allowing release builds to proceed.
83    ///
84    /// # Arguments
85    ///
86    /// * `atom` - The atom to insert; ownership moves into the residue.
87    ///
88    /// # Panics
89    ///
90    /// Panics in debug builds if an atom with the same `name` already exists.
91    pub fn add_atom(&mut self, atom: Atom) {
92        debug_assert!(
93            self.atom(&atom.name).is_none(),
94            "Attempted to add a duplicate atom name '{}' to residue '{}'",
95            atom.name,
96            self.name
97        );
98        self.atoms.push(atom);
99    }
100
101    /// Removes an atom by name and returns ownership to the caller.
102    ///
103    /// # Arguments
104    ///
105    /// * `name` - Atom name to remove (case-sensitive).
106    ///
107    /// # Returns
108    ///
109    /// `Some(atom)` if the atom existed, otherwise `None`.
110    pub fn remove_atom(&mut self, name: &str) -> Option<Atom> {
111        if let Some(index) = self.atoms.iter().position(|a| a.name == name) {
112            Some(self.atoms.remove(index))
113        } else {
114            None
115        }
116    }
117
118    /// Retrieves an immutable reference to an atom by name.
119    ///
120    /// # Arguments
121    ///
122    /// * `name` - Atom name to search for.
123    ///
124    /// # Returns
125    ///
126    /// `Some(&Atom)` when the atom exists, otherwise `None`.
127    pub fn atom(&self, name: &str) -> Option<&Atom> {
128        self.atoms.iter().find(|a| a.name == name)
129    }
130
131    /// Retrieves a mutable reference to an atom by name.
132    ///
133    /// # Arguments
134    ///
135    /// * `name` - Atom name to search for.
136    ///
137    /// # Returns
138    ///
139    /// `Some(&mut Atom)` when the atom exists, otherwise `None`.
140    pub fn atom_mut(&mut self, name: &str) -> Option<&mut Atom> {
141        self.atoms.iter_mut().find(|a| a.name == name)
142    }
143
144    /// Checks whether an atom with the provided name exists.
145    ///
146    /// # Arguments
147    ///
148    /// * `name` - Atom name to test.
149    ///
150    /// # Returns
151    ///
152    /// `true` if the atom is present.
153    pub fn has_atom(&self, name: &str) -> bool {
154        self.atom(name).is_some()
155    }
156
157    /// Returns an immutable slice of all atoms contained in the residue.
158    ///
159    /// Enables zero-copy iteration when only read-only access is required.
160    ///
161    /// # Returns
162    ///
163    /// A slice view over the internal atom vector.
164    pub fn atoms(&self) -> &[Atom] {
165        &self.atoms
166    }
167
168    /// Counts the number of atoms owned by the residue.
169    ///
170    /// # Returns
171    ///
172    /// Number of atoms currently stored.
173    pub fn atom_count(&self) -> usize {
174        self.atoms.len()
175    }
176
177    /// Indicates whether the residue has no atoms.
178    ///
179    /// # Returns
180    ///
181    /// `true` if `atom_count()` equals zero.
182    pub fn is_empty(&self) -> bool {
183        self.atoms.is_empty()
184    }
185
186    /// Provides an iterator over immutable atom references.
187    ///
188    /// This mirrors `atoms()` but allows idiomatic `for` loops without exposing the backing
189    /// vector.
190    ///
191    /// # Returns
192    ///
193    /// An iterator yielding `&Atom` values.
194    pub fn iter_atoms(&self) -> std::slice::Iter<'_, Atom> {
195        self.atoms.iter()
196    }
197
198    /// Provides an iterator over mutable atom references.
199    ///
200    /// # Returns
201    ///
202    /// An iterator yielding `&mut Atom` values so callers can edit coordinates or metadata.
203    pub fn iter_atoms_mut(&mut self) -> std::slice::IterMut<'_, Atom> {
204        self.atoms.iter_mut()
205    }
206
207    /// Removes all hydrogen atoms from the residue.
208    ///
209    /// Used by cleaning operations when preparing structures for solvation or heavy-atom
210    /// analysis.
211    pub fn strip_hydrogens(&mut self) {
212        self.atoms
213            .retain(|a| a.element != crate::model::types::Element::H);
214    }
215}
216
217impl fmt::Display for Residue {
218    /// Formats the residue with identifier, canonical name, category, and atom count.
219    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
220        let insertion_code_str = self
221            .insertion_code
222            .map(|c| format!(" (ic: {})", c))
223            .unwrap_or_default();
224
225        if let Some(std_name) = self.standard_name {
226            write!(
227                f,
228                "Residue {{ id: {}{}, name: \"{}\" ({}), category: {}, atoms: {} }}",
229                self.id,
230                insertion_code_str,
231                self.name,
232                std_name,
233                self.category,
234                self.atom_count()
235            )
236        } else {
237            write!(
238                f,
239                "Residue {{ id: {}{}, name: \"{}\", category: {}, atoms: {} }}",
240                self.id,
241                insertion_code_str,
242                self.name,
243                self.category,
244                self.atom_count()
245            )
246        }
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use crate::model::types::{Element, Point};
254
255    #[test]
256    fn residue_new_creates_correct_residue() {
257        let residue = Residue::new(
258            1,
259            None,
260            "ALA",
261            Some(StandardResidue::ALA),
262            ResidueCategory::Standard,
263        );
264
265        assert_eq!(residue.id, 1);
266        assert_eq!(residue.insertion_code, None);
267        assert_eq!(residue.name, "ALA");
268        assert_eq!(residue.standard_name, Some(StandardResidue::ALA));
269        assert_eq!(residue.category, ResidueCategory::Standard);
270        assert_eq!(residue.position, ResiduePosition::None);
271        assert!(residue.atoms.is_empty());
272    }
273
274    #[test]
275    fn residue_new_with_insertion_code() {
276        let residue = Residue::new(
277            1,
278            Some('A'),
279            "ALA",
280            Some(StandardResidue::ALA),
281            ResidueCategory::Standard,
282        );
283
284        assert_eq!(residue.id, 1);
285        assert_eq!(residue.insertion_code, Some('A'));
286    }
287
288    #[test]
289    fn residue_new_with_none_standard_name() {
290        let residue = Residue::new(2, None, "UNK", None, ResidueCategory::Hetero);
291
292        assert_eq!(residue.id, 2);
293        assert_eq!(residue.name, "UNK");
294        assert_eq!(residue.standard_name, None);
295        assert_eq!(residue.category, ResidueCategory::Hetero);
296    }
297
298    #[test]
299    fn residue_is_standard_returns_true_for_standard_residue() {
300        let residue = Residue::new(
301            1,
302            None,
303            "ALA",
304            Some(StandardResidue::ALA),
305            ResidueCategory::Standard,
306        );
307        assert!(residue.is_standard());
308    }
309
310    #[test]
311    fn residue_is_standard_returns_false_for_non_standard_residue() {
312        let residue = Residue::new(2, None, "UNK", None, ResidueCategory::Hetero);
313        assert!(!residue.is_standard());
314    }
315
316    #[test]
317    fn residue_add_atom_adds_atom_correctly() {
318        let mut residue = Residue::new(
319            1,
320            None,
321            "ALA",
322            Some(StandardResidue::ALA),
323            ResidueCategory::Standard,
324        );
325        let atom = Atom::new("CA", Element::C, Point::new(0.0, 0.0, 0.0));
326
327        residue.add_atom(atom);
328
329        assert_eq!(residue.atom_count(), 1);
330        assert!(residue.has_atom("CA"));
331        assert_eq!(residue.atom("CA").unwrap().name, "CA");
332    }
333
334    #[test]
335    fn residue_remove_atom_removes_existing_atom() {
336        let mut residue = Residue::new(
337            1,
338            None,
339            "ALA",
340            Some(StandardResidue::ALA),
341            ResidueCategory::Standard,
342        );
343        let atom = Atom::new("CA", Element::C, Point::new(0.0, 0.0, 0.0));
344        residue.add_atom(atom);
345
346        let removed = residue.remove_atom("CA");
347
348        assert!(removed.is_some());
349        assert_eq!(removed.unwrap().name, "CA");
350        assert_eq!(residue.atom_count(), 0);
351        assert!(!residue.has_atom("CA"));
352    }
353
354    #[test]
355    fn residue_remove_atom_returns_none_for_nonexistent_atom() {
356        let mut residue = Residue::new(
357            1,
358            None,
359            "ALA",
360            Some(StandardResidue::ALA),
361            ResidueCategory::Standard,
362        );
363
364        let removed = residue.remove_atom("NONEXISTENT");
365
366        assert!(removed.is_none());
367    }
368
369    #[test]
370    fn residue_atom_returns_correct_atom() {
371        let mut residue = Residue::new(
372            1,
373            None,
374            "ALA",
375            Some(StandardResidue::ALA),
376            ResidueCategory::Standard,
377        );
378        let atom = Atom::new("CA", Element::C, Point::new(0.0, 0.0, 0.0));
379        residue.add_atom(atom);
380
381        let retrieved = residue.atom("CA");
382
383        assert!(retrieved.is_some());
384        assert_eq!(retrieved.unwrap().name, "CA");
385    }
386
387    #[test]
388    fn residue_atom_returns_none_for_nonexistent_atom() {
389        let residue = Residue::new(
390            1,
391            None,
392            "ALA",
393            Some(StandardResidue::ALA),
394            ResidueCategory::Standard,
395        );
396
397        let retrieved = residue.atom("NONEXISTENT");
398
399        assert!(retrieved.is_none());
400    }
401
402    #[test]
403    fn residue_atom_mut_returns_correct_mutable_atom() {
404        let mut residue = Residue::new(
405            1,
406            None,
407            "ALA",
408            Some(StandardResidue::ALA),
409            ResidueCategory::Standard,
410        );
411        let atom = Atom::new("CA", Element::C, Point::new(0.0, 0.0, 0.0));
412        residue.add_atom(atom);
413
414        let retrieved = residue.atom_mut("CA");
415
416        assert!(retrieved.is_some());
417        assert_eq!(retrieved.unwrap().name, "CA");
418    }
419
420    #[test]
421    fn residue_atom_mut_returns_none_for_nonexistent_atom() {
422        let mut residue = Residue::new(
423            1,
424            None,
425            "ALA",
426            Some(StandardResidue::ALA),
427            ResidueCategory::Standard,
428        );
429
430        let retrieved = residue.atom_mut("NONEXISTENT");
431
432        assert!(retrieved.is_none());
433    }
434
435    #[test]
436    fn residue_has_atom_returns_true_for_existing_atom() {
437        let mut residue = Residue::new(
438            1,
439            None,
440            "ALA",
441            Some(StandardResidue::ALA),
442            ResidueCategory::Standard,
443        );
444        let atom = Atom::new("CA", Element::C, Point::new(0.0, 0.0, 0.0));
445        residue.add_atom(atom);
446
447        assert!(residue.has_atom("CA"));
448    }
449
450    #[test]
451    fn residue_has_atom_returns_false_for_nonexistent_atom() {
452        let residue = Residue::new(
453            1,
454            None,
455            "ALA",
456            Some(StandardResidue::ALA),
457            ResidueCategory::Standard,
458        );
459
460        assert!(!residue.has_atom("NONEXISTENT"));
461    }
462
463    #[test]
464    fn residue_atoms_returns_correct_slice() {
465        let mut residue = Residue::new(
466            1,
467            None,
468            "ALA",
469            Some(StandardResidue::ALA),
470            ResidueCategory::Standard,
471        );
472        let atom1 = Atom::new("CA", Element::C, Point::new(0.0, 0.0, 0.0));
473        let atom2 = Atom::new("CB", Element::C, Point::new(1.0, 0.0, 0.0));
474        residue.add_atom(atom1);
475        residue.add_atom(atom2);
476
477        let atoms = residue.atoms();
478
479        assert_eq!(atoms.len(), 2);
480        assert_eq!(atoms[0].name, "CA");
481        assert_eq!(atoms[1].name, "CB");
482    }
483
484    #[test]
485    fn residue_atom_count_returns_correct_count() {
486        let mut residue = Residue::new(
487            1,
488            None,
489            "ALA",
490            Some(StandardResidue::ALA),
491            ResidueCategory::Standard,
492        );
493
494        assert_eq!(residue.atom_count(), 0);
495
496        let atom = Atom::new("CA", Element::C, Point::new(0.0, 0.0, 0.0));
497        residue.add_atom(atom);
498
499        assert_eq!(residue.atom_count(), 1);
500    }
501
502    #[test]
503    fn residue_is_empty_returns_true_for_empty_residue() {
504        let residue = Residue::new(
505            1,
506            None,
507            "ALA",
508            Some(StandardResidue::ALA),
509            ResidueCategory::Standard,
510        );
511
512        assert!(residue.is_empty());
513    }
514
515    #[test]
516    fn residue_is_empty_returns_false_for_non_empty_residue() {
517        let mut residue = Residue::new(
518            1,
519            None,
520            "ALA",
521            Some(StandardResidue::ALA),
522            ResidueCategory::Standard,
523        );
524        let atom = Atom::new("CA", Element::C, Point::new(0.0, 0.0, 0.0));
525        residue.add_atom(atom);
526
527        assert!(!residue.is_empty());
528    }
529
530    #[test]
531    fn residue_iter_atoms_iterates_correctly() {
532        let mut residue = Residue::new(
533            1,
534            None,
535            "ALA",
536            Some(StandardResidue::ALA),
537            ResidueCategory::Standard,
538        );
539        let atom1 = Atom::new("CA", Element::C, Point::new(0.0, 0.0, 0.0));
540        let atom2 = Atom::new("CB", Element::C, Point::new(1.0, 0.0, 0.0));
541        residue.add_atom(atom1);
542        residue.add_atom(atom2);
543
544        let mut names = Vec::new();
545        for atom in residue.iter_atoms() {
546            names.push(atom.name.clone());
547        }
548
549        assert_eq!(names, vec!["CA", "CB"]);
550    }
551
552    #[test]
553    fn residue_iter_atoms_mut_iterates_correctly() {
554        let mut residue = Residue::new(
555            1,
556            None,
557            "ALA",
558            Some(StandardResidue::ALA),
559            ResidueCategory::Standard,
560        );
561        let atom1 = Atom::new("CA", Element::C, Point::new(0.0, 0.0, 0.0));
562        residue.add_atom(atom1);
563
564        for atom in residue.iter_atoms_mut() {
565            atom.translate_by(&nalgebra::Vector3::new(1.0, 0.0, 0.0));
566        }
567
568        assert!((residue.atom("CA").unwrap().pos.x - 1.0).abs() < 1e-10);
569    }
570
571    #[test]
572    fn residue_strip_hydrogens_removes_hydrogen_atoms() {
573        let mut residue = Residue::new(
574            1,
575            None,
576            "ALA",
577            Some(StandardResidue::ALA),
578            ResidueCategory::Standard,
579        );
580        let carbon = Atom::new("CA", Element::C, Point::new(0.0, 0.0, 0.0));
581        let hydrogen1 = Atom::new("HA", Element::H, Point::new(1.0, 0.0, 0.0));
582        let hydrogen2 = Atom::new("HB", Element::H, Point::new(2.0, 0.0, 0.0));
583        residue.add_atom(carbon);
584        residue.add_atom(hydrogen1);
585        residue.add_atom(hydrogen2);
586
587        residue.strip_hydrogens();
588
589        assert_eq!(residue.atom_count(), 1);
590        assert!(residue.has_atom("CA"));
591        assert!(!residue.has_atom("HA"));
592        assert!(!residue.has_atom("HB"));
593    }
594
595    #[test]
596    fn residue_strip_hydrogens_preserves_non_hydrogen_atoms() {
597        let mut residue = Residue::new(
598            1,
599            None,
600            "ALA",
601            Some(StandardResidue::ALA),
602            ResidueCategory::Standard,
603        );
604        let carbon = Atom::new("CA", Element::C, Point::new(0.0, 0.0, 0.0));
605        let nitrogen = Atom::new("N", Element::N, Point::new(1.0, 0.0, 0.0));
606        let oxygen = Atom::new("O", Element::O, Point::new(2.0, 0.0, 0.0));
607        residue.add_atom(carbon);
608        residue.add_atom(nitrogen);
609        residue.add_atom(oxygen);
610
611        residue.strip_hydrogens();
612
613        assert_eq!(residue.atom_count(), 3);
614        assert!(residue.has_atom("CA"));
615        assert!(residue.has_atom("N"));
616        assert!(residue.has_atom("O"));
617    }
618
619    #[test]
620    fn residue_display_formats_standard_residue_correctly() {
621        let residue = Residue::new(
622            1,
623            None,
624            "ALA",
625            Some(StandardResidue::ALA),
626            ResidueCategory::Standard,
627        );
628
629        let display = format!("{}", residue);
630        let expected =
631            "Residue { id: 1, name: \"ALA\" (ALA), category: Standard Residue, atoms: 0 }";
632
633        assert_eq!(display, expected);
634    }
635
636    #[test]
637    fn residue_display_formats_residue_with_insertion_code_correctly() {
638        let residue = Residue::new(
639            1,
640            Some('A'),
641            "ALA",
642            Some(StandardResidue::ALA),
643            ResidueCategory::Standard,
644        );
645
646        let display = format!("{}", residue);
647        let expected =
648            "Residue { id: 1 (ic: A), name: \"ALA\" (ALA), category: Standard Residue, atoms: 0 }";
649
650        assert_eq!(display, expected);
651    }
652
653    #[test]
654    fn residue_display_formats_non_standard_residue_correctly() {
655        let residue = Residue::new(2, None, "UNK", None, ResidueCategory::Hetero);
656
657        let display = format!("{}", residue);
658        let expected = "Residue { id: 2, name: \"UNK\", category: Hetero Residue, atoms: 0 }";
659
660        assert_eq!(display, expected);
661    }
662
663    #[test]
664    fn residue_display_includes_atom_count() {
665        let mut residue = Residue::new(
666            1,
667            None,
668            "ALA",
669            Some(StandardResidue::ALA),
670            ResidueCategory::Standard,
671        );
672        let atom1 = Atom::new("CA", Element::C, Point::new(0.0, 0.0, 0.0));
673        let atom2 = Atom::new("CB", Element::C, Point::new(1.0, 0.0, 0.0));
674        residue.add_atom(atom1);
675        residue.add_atom(atom2);
676
677        let display = format!("{}", residue);
678        let expected =
679            "Residue { id: 1, name: \"ALA\" (ALA), category: Standard Residue, atoms: 2 }";
680
681        assert_eq!(display, expected);
682    }
683
684    #[test]
685    fn residue_clone_creates_identical_copy() {
686        let mut residue = Residue::new(
687            1,
688            Some('A'),
689            "ALA",
690            Some(StandardResidue::ALA),
691            ResidueCategory::Standard,
692        );
693        let atom = Atom::new("CA", Element::C, Point::new(0.0, 0.0, 0.0));
694        residue.add_atom(atom);
695        residue.position = ResiduePosition::Internal;
696
697        let cloned = residue.clone();
698
699        assert_eq!(residue, cloned);
700        assert_eq!(residue.id, cloned.id);
701        assert_eq!(residue.insertion_code, cloned.insertion_code);
702        assert_eq!(residue.name, cloned.name);
703        assert_eq!(residue.standard_name, cloned.standard_name);
704        assert_eq!(residue.category, cloned.category);
705        assert_eq!(residue.position, cloned.position);
706        assert_eq!(residue.atoms, cloned.atoms);
707    }
708
709    #[test]
710    fn residue_partial_eq_compares_correctly() {
711        let mut residue1 = Residue::new(
712            1,
713            None,
714            "ALA",
715            Some(StandardResidue::ALA),
716            ResidueCategory::Standard,
717        );
718        let mut residue2 = Residue::new(
719            1,
720            None,
721            "ALA",
722            Some(StandardResidue::ALA),
723            ResidueCategory::Standard,
724        );
725        let atom = Atom::new("CA", Element::C, Point::new(0.0, 0.0, 0.0));
726        residue1.add_atom(atom.clone());
727        residue2.add_atom(atom);
728
729        let residue3 = Residue::new(
730            2,
731            None,
732            "ALA",
733            Some(StandardResidue::ALA),
734            ResidueCategory::Standard,
735        );
736
737        assert_eq!(residue1, residue2);
738        assert_ne!(residue1, residue3);
739    }
740}