Skip to main content

molframe_core/contract/policy/
vocabulary.rs

1//! The words a policy decision is spelled with.
2//!
3//! One vocabulary serves every surface that names a decision in text: a
4//! configuration file, a command-line flag and the Python keyword arguments all
5//! parse and print through these implementations, so a spelling cannot mean two
6//! things. Names are kebab-case; an underscore is accepted for a hyphen, because
7//! Python spells them that way, and a decision that carries a value is written
8//! `name:value` (`biological:1`, `crystal:12.5`, `index:3`, `label:A`).
9
10use super::{
11    AlignmentPolicy, AltlocPolicy, AssemblyChoice, ContactDefinition, EquivalencePolicy,
12    HydrogenPolicy, MissingPolicy, ModelChoice, Namespace, PeriodicPolicy, Precision, RadiiSet,
13    SymmetryPolicy,
14};
15use std::fmt;
16use std::str::FromStr;
17
18/// A named choice (a policy decision, a spatial backend, a file format) was
19/// spelled with a word outside its vocabulary.
20#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
21#[error("invalid policy value for {field}: {value} (expected {expected})")]
22pub struct PolicyParseError {
23    /// Which decision was being read.
24    pub field: &'static str,
25    /// What was written.
26    pub value: String,
27    /// What would have been accepted, for the message.
28    pub expected: String,
29}
30
31crate::diagnostic_from!(
32    PolicyParseError,
33    |_error| crate::Code::E6104,
34    |diagnostic, error| diagnostic
35        .about_field(error.field)
36        .with_context("value", error.value.clone())
37        .with_context("expected", error.expected.clone())
38);
39
40impl PolicyParseError {
41    /// A refusal of `value` for the choice named `field`, which would have
42    /// accepted `expected`.
43    #[must_use]
44    pub fn new(field: &'static str, value: &str, expected: &str) -> Self {
45        Self {
46            field,
47            value: value.to_owned(),
48            expected: expected.to_owned(),
49        }
50    }
51}
52
53fn refused(field: &'static str, value: &str, expected: &str) -> PolicyParseError {
54    PolicyParseError::new(field, value, expected)
55}
56
57/// `value` with underscores read as hyphens, the spelling every vocabulary
58/// compares against.
59#[must_use]
60pub fn canonical_spelling(value: &str) -> String {
61    value.replace('_', "-")
62}
63
64fn canonical(value: &str) -> String {
65    canonical_spelling(value)
66}
67
68/// The payload of a `prefix:payload` spelling, which must not be empty.
69fn payload<'a>(
70    field: &'static str,
71    value: &'a str,
72    prefix: &str,
73) -> Result<Option<&'a str>, PolicyParseError> {
74    match value.split_once(':') {
75        Some((head, rest)) if canonical(head) == prefix => {
76            if rest.is_empty() {
77                Err(refused(field, value, &format!("a value after '{prefix}:'")))
78            } else {
79                Ok(Some(rest))
80            }
81        }
82        _ => Ok(None),
83    }
84}
85
86fn finite_float(
87    field: &'static str,
88    value: &str,
89    text: &str,
90    allow_zero: bool,
91) -> Result<f32, PolicyParseError> {
92    let expected = if allow_zero {
93        "a finite non-negative number"
94    } else {
95        "a finite positive number"
96    };
97    let parsed: f32 = text.parse().map_err(|_| refused(field, value, expected))?;
98    if parsed.is_finite() && (parsed > 0.0 || (allow_zero && parsed == 0.0)) {
99        Ok(parsed)
100    } else {
101        Err(refused(field, value, expected))
102    }
103}
104
105/// Declares the spelling of a decision that is a plain closed set of words.
106///
107/// Other modules of this crate use it for their own closed sets, so every path
108/// it names is spelled in full.
109macro_rules! closed_vocabulary {
110    ($type:ty, $field:literal, [$(($variant:path, $name:literal)),+ $(,)?]) => {
111        impl $type {
112            /// The words this decision can be spelled with, in declaration order.
113            pub const NAMES: &'static [&'static str] = &[$($name),+];
114
115            /// The canonical spelling of this choice.
116            #[must_use]
117            pub const fn name(&self) -> &'static str {
118                match self {
119                    $($variant => $name,)+
120                }
121            }
122        }
123
124        impl ::std::fmt::Display for $type {
125            fn fmt(&self, formatter: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
126                formatter.write_str(self.name())
127            }
128        }
129
130        impl ::std::str::FromStr for $type {
131            type Err = $crate::contract::PolicyParseError;
132
133            fn from_str(value: &str) -> Result<Self, Self::Err> {
134                match $crate::contract::canonical_spelling(value).as_str() {
135                    $($name => Ok($variant),)+
136                    _ => Err($crate::contract::PolicyParseError::new(
137                        $field,
138                        value,
139                        &Self::NAMES.join(", "),
140                    )),
141                }
142            }
143        }
144    };
145}
146
147pub(crate) use closed_vocabulary;
148
149closed_vocabulary!(
150    Namespace,
151    "identifiers",
152    [
153        (Namespace::Label, "label"),
154        (Namespace::Auth, "auth"),
155        (Namespace::Explicit, "explicit"),
156    ]
157);
158closed_vocabulary!(
159    MissingPolicy,
160    "missing_atoms",
161    [
162        (MissingPolicy::Ignore, "ignore"),
163        (MissingPolicy::Report, "report"),
164        (MissingPolicy::Indeterminate, "indeterminate"),
165        (MissingPolicy::Fail, "fail"),
166    ]
167);
168closed_vocabulary!(
169    HydrogenPolicy,
170    "hydrogens",
171    [
172        (HydrogenPolicy::ExplicitOnly, "explicit-only"),
173        (HydrogenPolicy::IncludeInferred, "include-inferred"),
174        (HydrogenPolicy::Exclude, "exclude"),
175    ]
176);
177closed_vocabulary!(
178    EquivalencePolicy,
179    "atom_equivalence",
180    [
181        (EquivalencePolicy::None, "none"),
182        (EquivalencePolicy::Ccd, "ccd"),
183        (EquivalencePolicy::Explicit, "explicit"),
184    ]
185);
186closed_vocabulary!(
187    SymmetryPolicy,
188    "symmetry",
189    [
190        (SymmetryPolicy::None, "none"),
191        (SymmetryPolicy::Crystallographic, "crystallographic"),
192        (SymmetryPolicy::BiologicalAssembly, "biological-assembly"),
193    ]
194);
195closed_vocabulary!(
196    Precision,
197    "precision",
198    [(Precision::F32, "f32"), (Precision::F64, "f64"),]
199);
200closed_vocabulary!(
201    PeriodicPolicy,
202    "periodic",
203    [
204        (PeriodicPolicy::None, "none"),
205        (PeriodicPolicy::Pbc, "pbc"),
206        (PeriodicPolicy::MinimumImage, "minimum-image"),
207    ]
208);
209closed_vocabulary!(
210    RadiiSet,
211    "vdw_radii",
212    [
213        (RadiiSet::Bondi, "bondi"),
214        (RadiiSet::AmberUnited, "amber-united"),
215        (RadiiSet::Charmm, "charmm"),
216        (RadiiSet::Alvarez, "alvarez"),
217    ]
218);
219
220impl FromStr for AltlocPolicy {
221    type Err = PolicyParseError;
222
223    fn from_str(value: &str) -> Result<Self, Self::Err> {
224        if let Some(label) = payload("altloc", value, "label")? {
225            return Ok(Self::Label(label.into()));
226        }
227        match canonical(value).as_str() {
228            "keep-all" => Ok(Self::KeepAll),
229            "conformer-consistent" => Ok(Self::ConformerConsistent),
230            "first" => Ok(Self::First),
231            "highest-occupancy-per-residue" => Ok(Self::HighestOccupancyPerResidue),
232            "highest-occupancy-per-atom" => Ok(Self::HighestOccupancyPerAtom),
233            _ => Err(refused(
234                "altloc",
235                value,
236                "keep-all, conformer-consistent, first, highest-occupancy-per-residue, \
237                 highest-occupancy-per-atom or label:<id>",
238            )),
239        }
240    }
241}
242
243impl fmt::Display for AltlocPolicy {
244    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
245        match self {
246            Self::KeepAll => formatter.write_str("keep-all"),
247            Self::ConformerConsistent => formatter.write_str("conformer-consistent"),
248            Self::First => formatter.write_str("first"),
249            Self::HighestOccupancyPerResidue => {
250                formatter.write_str("highest-occupancy-per-residue")
251            }
252            Self::HighestOccupancyPerAtom => formatter.write_str("highest-occupancy-per-atom"),
253            Self::Label(label) => write!(formatter, "label:{label}"),
254        }
255    }
256}
257
258impl FromStr for AssemblyChoice {
259    type Err = PolicyParseError;
260
261    fn from_str(value: &str) -> Result<Self, Self::Err> {
262        if let Some(id) = payload("assembly", value, "biological")? {
263            return Ok(Self::Biological(id.into()));
264        }
265        if let Some(radius) = payload("assembly", value, "crystal")? {
266            return Ok(Self::Crystal {
267                radius: finite_float("assembly", value, radius, false)?,
268            });
269        }
270        match canonical(value).as_str() {
271            "asymmetric-unit" => Ok(Self::AsymmetricUnit),
272            _ => Err(refused(
273                "assembly",
274                value,
275                "asymmetric-unit, biological:<id> or crystal:<radius>",
276            )),
277        }
278    }
279}
280
281impl fmt::Display for AssemblyChoice {
282    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
283        match self {
284            Self::AsymmetricUnit => formatter.write_str("asymmetric-unit"),
285            Self::Biological(id) => write!(formatter, "biological:{id}"),
286            Self::Crystal { radius } => write!(formatter, "crystal:{radius}"),
287        }
288    }
289}
290
291impl FromStr for ModelChoice {
292    type Err = PolicyParseError;
293
294    fn from_str(value: &str) -> Result<Self, Self::Err> {
295        if let Some(index) = payload("model", value, "index")? {
296            return index
297                .parse()
298                .map(Self::Index)
299                .map_err(|_| refused("model", value, "index:<non-negative integer>"));
300        }
301        match canonical(value).as_str() {
302            "first" => Ok(Self::First),
303            "all" => Ok(Self::All),
304            "ensemble" => Ok(Self::Ensemble),
305            _ => Err(refused("model", value, "first, all, ensemble or index:<n>")),
306        }
307    }
308}
309
310impl fmt::Display for ModelChoice {
311    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
312        match self {
313            Self::First => formatter.write_str("first"),
314            Self::Index(index) => write!(formatter, "index:{index}"),
315            Self::All => formatter.write_str("all"),
316            Self::Ensemble => formatter.write_str("ensemble"),
317        }
318    }
319}
320
321impl FromStr for AlignmentPolicy {
322    type Err = PolicyParseError;
323
324    fn from_str(value: &str) -> Result<Self, Self::Err> {
325        if let Some(selection) = payload("alignment", value, "explicit")? {
326            return Ok(Self::Explicit(selection.into()));
327        }
328        match canonical(value).as_str() {
329            "none" => Ok(Self::None),
330            "global" => Ok(Self::Global),
331            "local" => Ok(Self::Local),
332            _ => Err(refused(
333                "alignment",
334                value,
335                "none, global, local or explicit:<selection>",
336            )),
337        }
338    }
339}
340
341impl fmt::Display for AlignmentPolicy {
342    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
343        match self {
344            Self::None => formatter.write_str("none"),
345            Self::Explicit(selection) => write!(formatter, "explicit:{selection}"),
346            Self::Global => formatter.write_str("global"),
347            Self::Local => formatter.write_str("local"),
348        }
349    }
350}
351
352impl FromStr for ContactDefinition {
353    type Err = PolicyParseError;
354
355    fn from_str(value: &str) -> Result<Self, Self::Err> {
356        if let Some(tolerance) = payload("contact_def", value, "distance")? {
357            return Ok(Self::DistanceCutoff {
358                tolerance: finite_float("contact_def", value, tolerance, true)?,
359            });
360        }
361        if let Some(probe) = payload("contact_def", value, "surface")? {
362            return Ok(Self::SurfaceBased {
363                probe: finite_float("contact_def", value, probe, false)?,
364            });
365        }
366        Err(refused(
367            "contact_def",
368            value,
369            "distance:<tolerance> or surface:<probe>",
370        ))
371    }
372}
373
374impl fmt::Display for ContactDefinition {
375    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
376        match self {
377            Self::DistanceCutoff { tolerance } => write!(formatter, "distance:{tolerance}"),
378            Self::SurfaceBased { probe } => write!(formatter, "surface:{probe}"),
379        }
380    }
381}
382
383#[cfg(test)]
384#[path = "vocabulary_tests.rs"]
385mod tests;