phasesmith-io 0.4.1

Native file-format adapters for PhaseSmith
Documentation
//! Native, parser-independent CIF import contract tests.

use std::path::PathBuf;

use phasesmith_io::{
    CifIoError, CifReadLimits, DisplacementConvention, NATIVE_CIF_BACKEND, parse_cif_text,
    read_cif_file,
};

const P21_CIF: &str = r"
data_demo
_chemical_name_common 'Authored monoclinic test'
_chemical_formula_sum 'C Fe'
_cell_formula_units_Z 4
_chemical_formula_weight 123.45
_cell_length_a 4.200(5)
_cell_length_b 5.100(6)
_cell_length_c 6.300(7)
_cell_angle_alpha 90
_cell_angle_beta 101.00(2)
_cell_angle_gamma 90
_space_group_name_H-M_alt 'P 1 21/c 1'
_space_group_IT_number 14
loop_
_space_group_symop_operation_xyz
'x,y,z'
'-x,y+1/2,-z+1/2'
'-x,-y,-z'
'x,-y+1/2,z+1/2'
loop_
_atom_site_label
_atom_site_type_symbol
_atom_site_fract_x
_atom_site_fract_y
_atom_site_fract_z
_atom_site_occupancy
_atom_site_B_iso_or_equiv
C1 13C 0.100(2) 0.200(3) 0.300(4) 0.50(2) 0.7895683521
Fe1 Fe3+ 0.4 0.5 0.6 ? .
loop_
_atom_site_aniso_label
_atom_site_aniso_B_11
_atom_site_aniso_B_22
_atom_site_aniso_B_33
_atom_site_aniso_B_23
_atom_site_aniso_B_13
_atom_site_aniso_B_12
C1 0.7895683521(8) 1.5791367042 2.3687050563 0 0 0
";

const CELL_ONLY_CIF: &str = r"
data_cell_only
_cell_length_a 4
_cell_length_b 4
_cell_length_c 4
_cell_angle_alpha 90
_cell_angle_beta 90
_cell_angle_gamma 90
_space_group_name_H-M_alt 'I m -3 m'
";

#[test]
fn imports_typed_sites_uncertainties_and_exact_symmetry() {
    let result = parse(P21_CIF);
    let structure = &result.structure;
    assert_eq!(result.selected_block, "demo");
    assert_eq!(structure.name, "Authored monoclinic test");
    assert_eq!(structure.source.backend, NATIVE_CIF_BACKEND);
    assert_eq!(structure.metadata["chemical_formula_sum"], "C Fe");
    assert_eq!(structure.metadata["formula_units_per_cell"], "4");
    assert_eq!(structure.metadata["formula_mass_g_mol"], "123.45");
    assert_eq!(structure.space_group.operations().len(), 4);
    assert_close(structure.cell_standard_uncertainties[0].unwrap(), 0.005);
    assert_close(structure.cell_standard_uncertainties[4].unwrap(), 0.02);
    assert_eq!(structure.sites.len(), 2);

    let carbon = &structure.sites[0];
    assert_eq!(carbon.type_symbol, "13C");
    assert_eq!(carbon.element_symbol, "C");
    assert_eq!(carbon.isotope, Some(13));
    assert_close(carbon.occupancy, 0.5);
    assert_close(carbon.occupancy_standard_uncertainty.unwrap(), 0.02);
    assert_eq!(
        carbon.fractional_xyz_standard_uncertainty,
        [Some(0.002), Some(0.003), Some(0.004)]
    );
    assert_close(carbon.u_iso_angstrom2.unwrap(), 0.01);
    let anisotropic = carbon.anisotropic_displacement.as_ref().unwrap();
    assert_eq!(anisotropic.source_convention, DisplacementConvention::CifB);
    for (actual, expected) in anisotropic.u_cif_angstrom2[..3]
        .iter()
        .zip([0.01, 0.02, 0.03])
    {
        assert_close(*actual, expected);
    }

    let iron = &structure.sites[1];
    assert_eq!(iron.charge, Some(3));
    assert_close(iron.occupancy, 1.0);
    assert!(iron.u_iso_angstrom2.is_none());
    assert!(
        result
            .diagnostics
            .iter()
            .any(|item| item.code == "unknown_cif_value")
    );
}

#[test]
fn converts_cartesian_coordinates_and_preserves_visible_warning() {
    let text = r"
data_cart
_cell_length_a 4
_cell_length_b 5
_cell_length_c 6
_cell_angle_alpha 90
_cell_angle_beta 90
_cell_angle_gamma 90
_symmetry_space_group_name_H-M 'P 1'
loop_
_atom_site_label
_atom_site_type_symbol
_atom_site_Cartn_x
_atom_site_Cartn_y
_atom_site_Cartn_z
X1 Si4+ 2.00(1) 1 3
";
    let result = parse(text);
    let site = &result.structure.sites[0];
    for (actual, expected) in site.fractional_xyz.iter().zip([0.5, 0.2, 0.5]) {
        assert_close(*actual, expected);
    }
    assert_eq!(site.element_symbol, "Si");
    assert_eq!(site.charge, Some(4));
    assert!(
        result
            .diagnostics
            .iter()
            .any(|item| { item.code == "cartesian_coordinate_uncertainty_not_transformed" })
    );
}

#[test]
fn selection_conflicts_and_limits_follow_strict_policy() {
    let second = CELL_ONLY_CIF
        .replace("cell_only", "second")
        .replace("_cell_length_a 4", "_cell_length_a 5")
        .replace("_cell_length_b 4", "_cell_length_b 5")
        .replace("_cell_length_c 4", "_cell_length_c 5");
    let document = format!("{CELL_ONLY_CIF}{second}");
    assert!(parse_cif_text(&document, None, true, CifReadLimits::default()).is_err());
    let selected =
        parse_cif_text(&document, Some("second"), true, CifReadLimits::default()).unwrap();
    assert_close(selected.structure.cell.a_angstrom, 5.0);
    let permissive = parse_cif_text(&document, None, false, CifReadLimits::default()).unwrap();
    assert_eq!(permissive.selected_block, "cell_only");
    assert_eq!(
        permissive.diagnostics[0].code,
        "multiple_blocks_first_selected"
    );

    let conflict = CELL_ONLY_CIF.replace(
        "_space_group_name_H-M_alt 'I m -3 m'",
        "_space_group_name_H-M_alt 'P 1'\nloop_\n_space_group_symop_operation_xyz\n'x,y,z'\n'x+1/2,y+1/2,z+1/2'",
    );
    let strict_error = parse_cif_text(&conflict, None, true, CifReadLimits::default())
        .expect_err("strict conflicting symmetry must fail");
    assert!(strict_error.to_string().contains("disagree"));
    let permissive = parse_cif_text(&conflict, None, false, CifReadLimits::default()).unwrap();
    assert_eq!(permissive.structure.space_group.operations().len(), 2);
    assert!(
        permissive
            .diagnostics
            .iter()
            .any(|item| { item.code == "conflicting_space_group_definition" })
    );

    let limits = CifReadLimits {
        max_atom_sites: 1,
        ..CifReadLimits::default()
    };
    assert!(matches!(
        parse_cif_text(P21_CIF, None, true, limits),
        Err(CifIoError::Limit { .. })
    ));
}

#[test]
fn accepts_a_single_explicit_operation_stored_as_a_tag_value() {
    let text = CELL_ONLY_CIF.replace(
        "_space_group_name_H-M_alt 'I m -3 m'",
        "_space_group_symop_operation_xyz 'x,y,z'",
    );
    let imported = parse(&text);
    assert_eq!(imported.structure.space_group.operations().len(), 1);
    assert_eq!(
        imported.structure.metadata["symmetry_source"],
        "explicit_operations"
    );
}

#[test]
fn imports_general_hall_expressions_outside_canonical_database_spellings() {
    let text = CELL_ONLY_CIF.replace(
        "_space_group_name_H-M_alt 'I m -3 m'",
        "_symmetry_space_group_name_Hall 'A 2 -2ac'",
    );
    let imported = parse(&text);
    assert_eq!(imported.structure.space_group.operations().len(), 8);
    assert_eq!(imported.structure.metadata["symmetry_source"], "hall");
    assert_eq!(imported.structure.metadata["space_group_hall"], "A 2 -2ac");
}

#[test]
fn permissive_import_uses_valid_operations_without_guessing_malformed_hm() {
    let text = CELL_ONLY_CIF.replace(
        "_space_group_name_H-M_alt 'I m -3 m'",
        "_space_group_name_H-M_alt 'F m 3 m'\n\
         _space_group_symop_operation_xyz 'x,y,z'",
    );
    assert!(parse_cif_text(&text, None, true, CifReadLimits::default()).is_err());

    let imported = parse_cif_text(&text, None, false, CifReadLimits::default()).unwrap();
    assert_eq!(imported.structure.space_group.operations().len(), 1);
    assert_eq!(
        imported.structure.metadata["symmetry_source"],
        "explicit_operations"
    );
    let diagnostic = imported
        .diagnostics
        .iter()
        .find(|item| item.code == "invalid_space_group_definition_ignored")
        .expect("malformed secondary HM tag must remain visible");
    assert_eq!(diagnostic.tag.as_deref(), Some("_space_group_name_h-m_alt"));
    assert!(diagnostic.message.contains("F m 3 m"));
}

#[test]
fn unsupported_features_and_duplicate_labels_are_explicit() {
    let magnetic = format!("{CELL_ONLY_CIF}\n_atom_site_moment.label M1\n");
    assert!(matches!(
        parse_cif_text(&magnetic, None, true, CifReadLimits::default()),
        Err(CifIoError::Unsupported { .. })
    ));
    let permissive = parse_cif_text(&magnetic, None, false, CifReadLimits::default()).unwrap();
    assert_eq!(
        permissive.diagnostics[0].code,
        "unsupported_magnetic_features"
    );

    let duplicate = r"
data_duplicate
_cell_length_a 3
_cell_length_b 3
_cell_length_c 3
_cell_angle_alpha 90
_cell_angle_beta 90
_cell_angle_gamma 90
loop_
_atom_site_label
_atom_site_type_symbol
_atom_site_fract_x
_atom_site_fract_y
_atom_site_fract_z
C1 C 0 0 0
C1 D 0.5 0.5 0.5
";
    assert!(parse_cif_text(duplicate, None, true, CifReadLimits::default()).is_err());
    let imported = parse_cif_text(duplicate, None, false, CifReadLimits::default()).unwrap();
    assert_eq!(imported.structure.sites[0].site_id, "C1");
    assert_eq!(imported.structure.sites[1].site_id, "C1#2");
    assert_eq!(imported.structure.sites[1].element_symbol, "H");
    assert_eq!(imported.structure.sites[1].isotope, Some(2));
}

#[test]
#[ignore = "requires checksum-pinned external validation data"]
fn imports_pinned_real_small_structure_cifs_without_python() {
    let fixtures = [
        ("iucr-qarr-1g/Al2O3.cif", 36_usize),
        ("iucr-qarr-1g/CaF2.cif", 192),
        ("iucr-qarr-1g/ZnO.cif", 12),
        ("gsasii-pbso4-cw/PbSO4-Wyckoff.cif", 8),
    ];
    for (relative, operation_count) in fixtures {
        let path = validation_data().join(relative);
        let result = read_cif_file(&path, None, true, CifReadLimits::default())
            .unwrap_or_else(|error| panic!("{} failed native import: {error}", path.display()));
        assert!(
            !result.structure.sites.is_empty(),
            "{} has no sites",
            path.display()
        );
        assert_eq!(
            result.structure.space_group.operations().len(),
            operation_count,
            "unexpected operation count for {}",
            path.display()
        );
    }
}

fn parse(text: &str) -> phasesmith_io::CifReadResult {
    parse_cif_text(text, None, true, CifReadLimits::default()).unwrap()
}

fn assert_close(actual: f64, expected: f64) {
    assert!(
        (actual - expected).abs() < 1.0e-10,
        "{actual} != {expected}"
    );
}

fn validation_data() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../validation/data")
}