Skip to main content

phasesmith_io/cif/
mod.rs

1//! Bounded native CIF import into parser-independent crystallographic records.
2
3mod import;
4mod syntax;
5
6use std::collections::BTreeMap;
7use std::error::Error;
8use std::fmt::{Display, Formatter};
9use std::path::PathBuf;
10
11use phasesmith_crystallography::{CellError, SpaceGroup, SymmetryError, UnitCell};
12
13use crate::SpaceGroupLookupError;
14
15pub use import::{parse_cif_text, read_cif_file};
16
17/// Native CIF parser implementation identifier.
18pub const NATIVE_CIF_BACKEND: &str = "phasesmith-native";
19/// Version of the native CIF adapter contract.
20pub const NATIVE_CIF_BACKEND_VERSION: &str = env!("CARGO_PKG_VERSION");
21
22/// Resource limits checked before and during CIF parsing.
23///
24/// The defaults allow 16 MiB of UTF-8 text, 100 data blocks, 1,000,000 rows in
25/// any one loop, and 100,000 atom or anisotropic-site rows. A file's byte size
26/// is checked before its contents are read. Loop and site bounds are checked
27/// before constructing domain records.
28///
29/// Tighten these limits when accepting untrusted uploads. All limits must be
30/// positive; zero does not mean unlimited.
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub struct CifReadLimits {
33    /// Maximum UTF-8 byte count.
34    pub max_bytes: usize,
35    /// Maximum number of data blocks.
36    pub max_blocks: usize,
37    /// Maximum row count of any loop.
38    pub max_loop_rows: usize,
39    /// Maximum atom or anisotropic-site rows.
40    pub max_atom_sites: usize,
41}
42
43impl Default for CifReadLimits {
44    fn default() -> Self {
45        Self {
46            max_bytes: 16 * 1024 * 1024,
47            max_blocks: 100,
48            max_loop_rows: 1_000_000,
49            max_atom_sites: 100_000,
50        }
51    }
52}
53
54impl CifReadLimits {
55    /// Validate that every limit is positive.
56    ///
57    /// # Errors
58    ///
59    /// Returns [`CifIoError::InvalidLimits`] when any limit is zero.
60    pub fn validate(self) -> Result<(), CifIoError> {
61        if self.max_bytes == 0
62            || self.max_blocks == 0
63            || self.max_loop_rows == 0
64            || self.max_atom_sites == 0
65        {
66            return Err(CifIoError::InvalidLimits);
67        }
68        Ok(())
69    }
70}
71
72/// Stable diagnostic severity for a successfully returned import.
73///
74/// Diagnostics describe visible recovery or interpretation decisions. They
75/// are distinct from [`CifIoError`], which prevents a valid structure from
76/// being returned.
77#[derive(Clone, Copy, Debug, PartialEq, Eq)]
78pub enum CifDiagnosticSeverity {
79    /// Recoverable condition visible to the caller.
80    Warning,
81    /// Non-recoverable condition retained in a partial record.
82    Error,
83}
84
85/// One stable CIF import diagnostic.
86///
87/// Branch on [`Self::code`] and [`Self::severity`], not on the human-readable
88/// message. `row` is zero-based within the related loop. `tag` is normalized
89/// to lower case where it originated from parsed CIF syntax.
90#[derive(Clone, Debug, PartialEq, Eq)]
91pub struct CifDiagnostic {
92    /// Warning or error severity.
93    pub severity: CifDiagnosticSeverity,
94    /// Stable machine-readable code.
95    pub code: String,
96    /// Human-readable explanation.
97    pub message: String,
98    /// Related CIF tag when applicable.
99    pub tag: Option<String>,
100    /// Zero-based loop row when applicable.
101    pub row: Option<usize>,
102}
103
104impl CifDiagnostic {
105    fn warning(code: impl Into<String>, message: impl Into<String>) -> Self {
106        Self {
107            severity: CifDiagnosticSeverity::Warning,
108            code: code.into(),
109            message: message.into(),
110            tag: None,
111            row: None,
112        }
113    }
114
115    fn with_tag(mut self, tag: impl Into<String>) -> Self {
116        self.tag = Some(tag.into());
117        self
118    }
119
120    fn with_row(mut self, row: usize) -> Self {
121        self.row = Some(row);
122        self
123    }
124}
125
126/// Source provenance retained after CIF parsing.
127///
128/// The source path is present only for [`read_cif_file`]. Parsed text retains
129/// the selected block and backend/version but has no invented path.
130#[derive(Clone, Debug, PartialEq, Eq)]
131pub struct CifStructureSource {
132    /// Source format, currently `CIF`.
133    pub format: String,
134    /// Selected data-block name.
135    pub block_name: String,
136    /// Parser backend identifier.
137    pub backend: String,
138    /// Parser backend version.
139    pub backend_version: String,
140    /// Source path when read from a file.
141    pub source_path: Option<PathBuf>,
142}
143
144/// Original CIF displacement convention.
145///
146/// Imported numerical tensors are always stored as U in square ångströms;
147/// this enum records whether the source supplied U or B.
148#[derive(Clone, Copy, Debug, PartialEq, Eq)]
149pub enum DisplacementConvention {
150    /// CIF `U_ij` components.
151    CifU,
152    /// CIF `B_ij` components converted to `U_ij`.
153    CifB,
154}
155
156/// Fixed anisotropic displacement attached to one site.
157///
158/// Components follow CIF order `11,22,33,23,13,12`. B components and their
159/// uncertainties are converted using `U = B/(8*pi^2)` before storage.
160#[derive(Clone, Debug, PartialEq)]
161pub struct CifAnisotropicDisplacement {
162    /// CIF U tensor in component order `11,22,33,23,13,12`.
163    pub u_cif_angstrom2: [f64; 6],
164    /// Convention present in the source file.
165    pub source_convention: DisplacementConvention,
166    /// Optional standard uncertainties in the same component order.
167    pub standard_uncertainty: [Option<f64>; 6],
168}
169
170/// One independent atom site imported from CIF.
171///
172/// Sites remain in source order and are not expanded by symmetry. `site_id` is
173/// unique within the returned structure. In permissive mode duplicate source
174/// labels become IDs such as `C1#2` while [`Self::source_label`] preserves the
175/// original text.
176#[derive(Clone, Debug, PartialEq)]
177pub struct CifAtomSite {
178    /// Unique stable site identifier within the structure.
179    pub site_id: String,
180    /// Original CIF label before permissive duplicate renaming.
181    pub source_label: String,
182    /// Original atom type symbol, including isotope or charge decorations.
183    pub type_symbol: String,
184    /// Parsed element symbol without isotope or charge decorations.
185    pub element_symbol: String,
186    /// Fractional coordinates in the selected cell.
187    pub fractional_xyz: [f64; 3],
188    /// Site occupancy, defaulting to `1.0` when absent or unknown.
189    pub occupancy: f64,
190    /// Optional isotropic U in square ångströms.
191    pub u_iso_angstrom2: Option<f64>,
192    /// Optional fixed anisotropic CIF U tensor.
193    pub anisotropic_displacement: Option<CifAnisotropicDisplacement>,
194    /// Parsed formal charge.
195    pub charge: Option<i32>,
196    /// Parsed isotope mass number.
197    pub isotope: Option<u32>,
198    /// Optional disorder group.
199    pub disorder_group: Option<String>,
200    /// Optional fractional-coordinate standard uncertainties.
201    pub fractional_xyz_standard_uncertainty: [Option<f64>; 3],
202    /// Optional occupancy standard uncertainty.
203    pub occupancy_standard_uncertainty: Option<f64>,
204    /// Optional isotropic-U standard uncertainty.
205    pub u_iso_standard_uncertainty: Option<f64>,
206}
207
208/// Parser-independent native crystallographic structure.
209///
210/// This record contains only imported structural facts and provenance. It does
211/// not choose radiation, scattering, reflection range, peak profile,
212/// corrections, sample physics, or refinable parameters.
213#[derive(Clone, Debug, PartialEq)]
214pub struct CifStructure {
215    /// Stable ID derived from the selected block name.
216    pub structure_id: String,
217    /// Human-readable chemical/phase name.
218    pub name: String,
219    /// Validated direct unit cell.
220    pub cell: UnitCell,
221    /// Exact validated conventional symmetry operations.
222    pub space_group: SpaceGroup,
223    /// Independent atom sites in source order.
224    pub sites: Vec<CifAtomSite>,
225    /// Source provenance.
226    pub source: CifStructureSource,
227    /// Optional standard uncertainties for `a,b,c,alpha,beta,gamma`, in the
228    /// same ångström/degree units as [`Self::cell`].
229    pub cell_standard_uncertainties: [Option<f64>; 6],
230    /// Import diagnostics also returned at the top level.
231    pub diagnostics: Vec<CifDiagnostic>,
232    /// Small textual metadata extracted from CIF.
233    ///
234    /// Known keys include `symmetry_source`, supplied symmetry identifiers,
235    /// `chemical_formula_sum`, `chemical_formula_structural`,
236    /// `formula_units_per_cell`, `formula_mass_g_mol`, and
237    /// `radiation_wavelength` when those values exist.
238    pub metadata: BTreeMap<String, String>,
239}
240
241/// One selected CIF structure plus block-selection context.
242///
243/// `selected_block` and `available_blocks` omit the source `data_` prefix.
244/// Diagnostics are duplicated inside [`Self::structure`] so the standalone
245/// parser-independent structure retains its import history.
246#[derive(Clone, Debug, PartialEq)]
247pub struct CifReadResult {
248    /// Imported native structure.
249    pub structure: CifStructure,
250    /// Visible import diagnostics.
251    pub diagnostics: Vec<CifDiagnostic>,
252    /// Selected display block name.
253    pub selected_block: String,
254    /// All display block names in source order.
255    pub available_blocks: Vec<String>,
256}
257
258/// Native CIF syntax, limit, lookup, or domain failure.
259///
260/// A returned error means no scientifically valid structure was produced.
261/// Recoverable decisions on a successful permissive import are represented by
262/// [`CifDiagnostic`] instead.
263#[derive(Debug)]
264pub enum CifIoError {
265    /// One or more configured limits are zero.
266    InvalidLimits,
267    /// UTF-8 input exceeds the configured byte limit.
268    ByteLimitExceeded {
269        /// Observed byte count.
270        actual: u64,
271        /// Configured maximum.
272        maximum: usize,
273    },
274    /// Filesystem or UTF-8 reading failed.
275    Io(std::io::Error),
276    /// CIF tokenization or document structure is invalid.
277    Syntax {
278        /// Stable explanation.
279        message: String,
280        /// One-based source line when known.
281        line: Option<usize>,
282    },
283    /// A parser or import resource limit was exceeded.
284    Limit {
285        /// Stable explanation including the limit name.
286        message: String,
287    },
288    /// A syntactically valid CIF cannot form the requested structure.
289    Import {
290        /// Stable explanation.
291        message: String,
292    },
293    /// A deliberately unsupported CIF feature was encountered in strict mode.
294    Unsupported {
295        /// Unsupported feature family.
296        feature: String,
297        /// Human-readable explanation.
298        message: String,
299    },
300    /// Native space-group lookup failed.
301    SpaceGroup(SpaceGroupLookupError),
302    /// Unit-cell validation failed.
303    Cell(CellError),
304    /// Exact operation validation failed.
305    Symmetry(SymmetryError),
306}
307
308impl Display for CifIoError {
309    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
310        match self {
311            Self::InvalidLimits => formatter.write_str("all CIF read limits must be positive"),
312            Self::ByteLimitExceeded { actual, maximum } => {
313                write!(
314                    formatter,
315                    "CIF input exceeds max_bytes: {actual} > {maximum}"
316                )
317            }
318            Self::Io(error) => Display::fmt(error, formatter),
319            Self::Syntax {
320                message,
321                line: Some(line),
322            } => write!(formatter, "invalid CIF syntax at line {line}: {message}"),
323            Self::Syntax {
324                message,
325                line: None,
326            } => write!(formatter, "invalid CIF syntax: {message}"),
327            Self::Limit { message }
328            | Self::Import { message }
329            | Self::Unsupported { message, .. } => formatter.write_str(message),
330            Self::SpaceGroup(error) => Display::fmt(error, formatter),
331            Self::Cell(error) => Display::fmt(error, formatter),
332            Self::Symmetry(error) => Display::fmt(error, formatter),
333        }
334    }
335}
336
337impl Error for CifIoError {
338    fn source(&self) -> Option<&(dyn Error + 'static)> {
339        match self {
340            Self::Io(error) => Some(error),
341            Self::SpaceGroup(error) => Some(error),
342            Self::Cell(error) => Some(error),
343            Self::Symmetry(error) => Some(error),
344            _ => None,
345        }
346    }
347}
348
349fn import_error(message: impl Into<String>) -> CifIoError {
350    CifIoError::Import {
351        message: message.into(),
352    }
353}