pdbrust 0.7.1

A comprehensive Rust library for parsing and analyzing Protein Data Bank (PDB) files
Documentation
//! Robustness of the PDB parser on real-world input: records it must accept
//! without panicking or aborting the whole file, and fields it must read from
//! the right columns.

use pdbrust::{parse_pdb_reader, parse_pdb_string};
use proptest::prelude::*;

/// A complete, well-formed coordinate record used to check that the rest of a
/// file is still parsed.
const ATOM: &str =
    "ATOM      1  N   MET A   1      27.340  24.430   2.614  1.00  9.67           N  ";

// ============================================================================
// Header and free-text records
// ============================================================================

#[test]
fn short_header_and_title_lines_do_not_abort_parsing() {
    let structure = parse_pdb_string(&format!("HEADER\nTITLE\n{ATOM}\n")).unwrap();

    assert_eq!(structure.atoms.len(), 1);
}

#[test]
fn title_continuation_lines_are_joined() {
    let input = format!(
        "TITLE     CRYSTAL STRUCTURE OF HUMAN UBIQUITIN\n\
         TITLE    2 AT 1.8 ANGSTROM RESOLUTION\n{ATOM}\n"
    );

    let structure = parse_pdb_string(&input).unwrap();

    assert_eq!(
        structure.title.as_deref(),
        Some("CRYSTAL STRUCTURE OF HUMAN UBIQUITIN AT 1.8 ANGSTROM RESOLUTION")
    );
}

#[test]
fn unnumbered_remarks_are_kept_as_free_text() {
    // Written by e.g. GROMACS: no remark number in columns 8-10.
    let input = format!("REMARK    GENERATED BY TRJCONV\nREMARK This is free text\n{ATOM}\n");

    let structure = parse_pdb_string(&input).unwrap();

    let remarks: Vec<(i32, &str)> = structure
        .remarks
        .iter()
        .map(|r| (r.number, r.content.as_str()))
        .collect();
    assert_eq!(
        remarks,
        vec![(0, "GENERATED BY TRJCONV"), (0, "This is free text")]
    );
    assert_eq!(structure.atoms.len(), 1);
}

#[test]
fn numbered_remarks_keep_number_and_text() {
    let structure = parse_pdb_string("REMARK   2 RESOLUTION.    2.00 ANGSTROMS.\n").unwrap();

    assert_eq!(structure.remarks[0].number, 2);
    assert_eq!(
        structure.remarks[0].content,
        "RESOLUTION.    2.00 ANGSTROMS."
    );
}

// ============================================================================
// Non-ASCII and non-UTF-8 input
// ============================================================================

#[test]
fn multibyte_characters_do_not_panic_or_shift_columns() {
    // "Å" occupies column 10; columns count characters, not bytes.
    let structure = parse_pdb_string("HEADER   ÅBC\n").unwrap();
    assert_eq!(structure.header.as_deref(), Some("BC"));

    let atom_line =
        "ATOM      1  Cα  GLY A   1      27.340  24.430   2.614  1.00  9.67           C  ";
    let atom = &parse_pdb_string(atom_line).unwrap().atoms[0];
    assert_eq!(
        (
            atom.name.as_str(),
            atom.residue_name.as_str(),
            atom.residue_seq
        ),
        ("", "GLY", 1)
    );
    assert_eq!((atom.x, atom.y, atom.z), (27.340, 24.430, 2.614));
}

#[test]
fn invalid_utf8_bytes_do_not_fail_the_parse() {
    // A Latin-1 encoded "Å" (0xC5), as found in some older files.
    let mut bytes = b"REMARK   3 B-FACTORS IN \xC5**2\n".to_vec();
    bytes.extend_from_slice(ATOM.as_bytes());

    let structure = parse_pdb_reader(&bytes[..]).unwrap();

    assert_eq!(structure.remarks[0].number, 3);
    assert_eq!(structure.atoms.len(), 1);
}

// ============================================================================
// Coordinate records
// ============================================================================

#[test]
fn blank_occupancy_and_b_factor_use_defaults() {
    let line = "ATOM      1  N   MET A   1      27.340  24.430   2.614                       N  ";

    let atom = &parse_pdb_string(line).unwrap().atoms[0];

    assert_eq!((atom.occupancy, atom.temp_factor), (1.0, 0.0));
    assert_eq!(atom.element, "N");
}

#[test]
fn hybrid36_serial_and_residue_numbers_are_decoded() {
    let line = "ATOM  A0000  CA  GLY AA000       1.000   2.000   3.000  1.00  0.00           C  ";

    let atom = &parse_pdb_string(line).unwrap().atoms[0];

    assert_eq!((atom.serial, atom.residue_seq), (100_000, 10_000));
    assert_eq!(atom.chain_id, "A");
}

// ============================================================================
// MODEL, SEQRES, CONECT, SSBOND
// ============================================================================

#[test]
fn model_numbers_are_read_from_short_model_lines() {
    let input = format!("MODEL 1\n{ATOM}\nENDMDL\nMODEL 2\n{ATOM}\nENDMDL\n");

    let structure = parse_pdb_string(&input).unwrap();

    let serials: Vec<i32> = structure.models.iter().map(|m| m.serial).collect();
    assert_eq!(serials, vec![1, 2]);
}

#[test]
fn blank_model_numbers_are_numbered_sequentially() {
    let input = format!("MODEL     \n{ATOM}\nENDMDL\nMODEL     \n{ATOM}\nENDMDL\n");

    let structure = parse_pdb_string(&input).unwrap();

    let serials: Vec<i32> = structure.models.iter().map(|m| m.serial).collect();
    assert_eq!(serials, vec![1, 2]);
}

#[test]
fn seqres_serial_numbers_above_99_are_read() {
    let structure = parse_pdb_string("SEQRES 100 A 1300  ALA GLY SER\n").unwrap();

    let seqres = &structure.seqres[0];
    assert_eq!((seqres.serial, seqres.chain_id.as_str()), (100, "A"));
    assert_eq!(seqres.residues, vec!["ALA", "GLY", "SER"]);
}

#[test]
fn conect_serials_in_hybrid36_are_decoded() {
    let structure = parse_pdb_string("CONECTA0000A0001\n").unwrap();

    let conect = &structure.connects[0];
    assert_eq!((conect.atom1, conect.atom2), (100_000, 100_001));
}

#[test]
fn conect_record_without_bonded_atoms_is_skipped() {
    let structure = parse_pdb_string(&format!("{ATOM}\nCONECT    1\n")).unwrap();

    assert!(structure.connects.is_empty());
    assert_eq!(structure.atoms.len(), 1);
}

#[test]
fn ssbond_with_blank_symmetry_columns_uses_identity() {
    let line = format!("SSBOND   1 CYS A    6    CYS A   11{}", " ".repeat(45));

    let structure = parse_pdb_string(&line).unwrap();

    let bond = &structure.ssbonds[0];
    assert_eq!((bond.sym1, bond.sym2), (1555, 1555));
    assert_eq!((bond.residue1_seq, bond.residue2_seq), (6, 11));
}

#[test]
fn short_ssbond_lines_keep_default_symmetry_and_length() {
    // Seen in the wild: the bond length written inside the symmetry columns of
    // a line shorter than the standard 78 columns. Such text is not read.
    let line = "SSBOND   1 CYS A   85    CYS A  101                          2.03 ";

    let structure = parse_pdb_string(line).unwrap();

    let bond = &structure.ssbonds[0];
    assert_eq!((bond.sym1, bond.sym2), (1555, 1555));
    assert_eq!((bond.residue1_seq, bond.residue2_seq), (85, 101));
}

// ============================================================================
// Property tests: the parser must never panic
// ============================================================================

const RECORD_NAMES: &[&str] = &[
    "HEADER", "TITLE ", "REMARK", "ATOM  ", "HETATM", "MODEL ", "ENDMDL", "SEQRES", "CONECT",
    "SSBOND", "TER   ", "END   ", "HEAD", "",
];

proptest! {
    #![proptest_config(ProptestConfig::with_cases(2000))]

    #[test]
    fn parsing_pdb_like_text_never_panics(
        lines in prop::collection::vec(
            (prop::sample::select(RECORD_NAMES), "[ -~Åα\u{FFFD}]{0,90}"),
            0..8,
        )
    ) {
        let text: String = lines
            .iter()
            .map(|(record, tail)| format!("{record}{tail}\n"))
            .collect();
        let _ = parse_pdb_string(&text);
    }

    #[test]
    fn parsing_arbitrary_bytes_never_panics(bytes in prop::collection::vec(any::<u8>(), 0..400)) {
        let _ = parse_pdb_reader(&bytes[..]);
    }
}