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                    #[allow(unreachable_patterns)]
121                    _ => "custom",
122                }
123            }
124        }
125
126        impl ::std::fmt::Display for $type {
127            fn fmt(&self, formatter: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
128                formatter.write_str(self.name())
129            }
130        }
131
132        impl ::std::str::FromStr for $type {
133            type Err = $crate::contract::PolicyParseError;
134
135            fn from_str(value: &str) -> Result<Self, Self::Err> {
136                match $crate::contract::canonical_spelling(value).as_str() {
137                    $($name => Ok($variant),)+
138                    _ => Err($crate::contract::PolicyParseError::new(
139                        $field,
140                        value,
141                        &Self::NAMES.join(", "),
142                    )),
143                }
144            }
145        }
146    };
147}
148
149pub(crate) use closed_vocabulary;
150
151closed_vocabulary!(
152    Namespace,
153    "identifiers",
154    [
155        (Namespace::Label, "label"),
156        (Namespace::Auth, "auth"),
157        (Namespace::Explicit, "explicit"),
158    ]
159);
160closed_vocabulary!(
161    MissingPolicy,
162    "missing_atoms",
163    [
164        (MissingPolicy::Ignore, "ignore"),
165        (MissingPolicy::Report, "report"),
166        (MissingPolicy::Indeterminate, "indeterminate"),
167        (MissingPolicy::Fail, "fail"),
168    ]
169);
170closed_vocabulary!(
171    HydrogenPolicy,
172    "hydrogens",
173    [
174        (HydrogenPolicy::ExplicitOnly, "explicit-only"),
175        (HydrogenPolicy::IncludeInferred, "include-inferred"),
176        (HydrogenPolicy::Exclude, "exclude"),
177    ]
178);
179closed_vocabulary!(
180    EquivalencePolicy,
181    "atom_equivalence",
182    [
183        (EquivalencePolicy::None, "none"),
184        (EquivalencePolicy::Ccd, "ccd"),
185        (EquivalencePolicy::Explicit, "explicit"),
186    ]
187);
188closed_vocabulary!(
189    SymmetryPolicy,
190    "symmetry",
191    [
192        (SymmetryPolicy::None, "none"),
193        (SymmetryPolicy::Crystallographic, "crystallographic"),
194        (SymmetryPolicy::BiologicalAssembly, "biological-assembly"),
195    ]
196);
197closed_vocabulary!(
198    Precision,
199    "precision",
200    [(Precision::F32, "f32"), (Precision::F64, "f64"),]
201);
202closed_vocabulary!(
203    PeriodicPolicy,
204    "periodic",
205    [
206        (PeriodicPolicy::None, "none"),
207        (PeriodicPolicy::Pbc, "pbc"),
208        (PeriodicPolicy::MinimumImage, "minimum-image"),
209    ]
210);
211closed_vocabulary!(
212    RadiiSet,
213    "vdw_radii",
214    [
215        (RadiiSet::Bondi, "bondi"),
216        (RadiiSet::AmberUnited, "amber-united"),
217        (RadiiSet::Charmm, "charmm"),
218        (RadiiSet::Alvarez, "alvarez"),
219    ]
220);
221
222impl FromStr for AltlocPolicy {
223    type Err = PolicyParseError;
224
225    fn from_str(value: &str) -> Result<Self, Self::Err> {
226        if let Some(label) = payload("altloc", value, "label")? {
227            return Ok(Self::Label(label.into()));
228        }
229        match canonical(value).as_str() {
230            "keep-all" => Ok(Self::KeepAll),
231            "conformer-consistent" => Ok(Self::ConformerConsistent),
232            "first" => Ok(Self::First),
233            "highest-occupancy-per-residue" => Ok(Self::HighestOccupancyPerResidue),
234            "highest-occupancy-per-atom" => Ok(Self::HighestOccupancyPerAtom),
235            _ => Err(refused(
236                "altloc",
237                value,
238                "keep-all, conformer-consistent, first, highest-occupancy-per-residue, \
239                 highest-occupancy-per-atom or label:<id>",
240            )),
241        }
242    }
243}
244
245impl fmt::Display for AltlocPolicy {
246    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
247        match self {
248            Self::KeepAll => formatter.write_str("keep-all"),
249            Self::ConformerConsistent => formatter.write_str("conformer-consistent"),
250            Self::First => formatter.write_str("first"),
251            Self::HighestOccupancyPerResidue => {
252                formatter.write_str("highest-occupancy-per-residue")
253            }
254            Self::HighestOccupancyPerAtom => formatter.write_str("highest-occupancy-per-atom"),
255            Self::Label(label) => write!(formatter, "label:{label}"),
256        }
257    }
258}
259
260impl FromStr for AssemblyChoice {
261    type Err = PolicyParseError;
262
263    fn from_str(value: &str) -> Result<Self, Self::Err> {
264        if let Some(id) = payload("assembly", value, "biological")? {
265            return Ok(Self::Biological(id.into()));
266        }
267        if let Some(radius) = payload("assembly", value, "crystal")? {
268            return Ok(Self::Crystal {
269                radius: finite_float("assembly", value, radius, false)?,
270            });
271        }
272        match canonical(value).as_str() {
273            "asymmetric-unit" => Ok(Self::AsymmetricUnit),
274            _ => Err(refused(
275                "assembly",
276                value,
277                "asymmetric-unit, biological:<id> or crystal:<radius>",
278            )),
279        }
280    }
281}
282
283impl fmt::Display for AssemblyChoice {
284    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
285        match self {
286            Self::AsymmetricUnit => formatter.write_str("asymmetric-unit"),
287            Self::Biological(id) => write!(formatter, "biological:{id}"),
288            Self::Crystal { radius } => write!(formatter, "crystal:{radius}"),
289        }
290    }
291}
292
293impl FromStr for ModelChoice {
294    type Err = PolicyParseError;
295
296    fn from_str(value: &str) -> Result<Self, Self::Err> {
297        if let Some(index) = payload("model", value, "index")? {
298            return index
299                .parse()
300                .map(Self::Index)
301                .map_err(|_| refused("model", value, "index:<non-negative integer>"));
302        }
303        match canonical(value).as_str() {
304            "first" => Ok(Self::First),
305            "all" => Ok(Self::All),
306            "ensemble" => Ok(Self::Ensemble),
307            _ => Err(refused("model", value, "first, all, ensemble or index:<n>")),
308        }
309    }
310}
311
312impl fmt::Display for ModelChoice {
313    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
314        match self {
315            Self::First => formatter.write_str("first"),
316            Self::Index(index) => write!(formatter, "index:{index}"),
317            Self::All => formatter.write_str("all"),
318            Self::Ensemble => formatter.write_str("ensemble"),
319        }
320    }
321}
322
323impl FromStr for AlignmentPolicy {
324    type Err = PolicyParseError;
325
326    fn from_str(value: &str) -> Result<Self, Self::Err> {
327        if let Some(selection) = payload("alignment", value, "explicit")? {
328            return Ok(Self::Explicit(selection.into()));
329        }
330        match canonical(value).as_str() {
331            "none" => Ok(Self::None),
332            "global" => Ok(Self::Global),
333            "local" => Ok(Self::Local),
334            _ => Err(refused(
335                "alignment",
336                value,
337                "none, global, local or explicit:<selection>",
338            )),
339        }
340    }
341}
342
343impl fmt::Display for AlignmentPolicy {
344    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
345        match self {
346            Self::None => formatter.write_str("none"),
347            Self::Explicit(selection) => write!(formatter, "explicit:{selection}"),
348            Self::Global => formatter.write_str("global"),
349            Self::Local => formatter.write_str("local"),
350        }
351    }
352}
353
354impl FromStr for ContactDefinition {
355    type Err = PolicyParseError;
356
357    fn from_str(value: &str) -> Result<Self, Self::Err> {
358        if let Some(tolerance) = payload("contact_def", value, "distance")? {
359            return Ok(Self::DistanceCutoff {
360                tolerance: finite_float("contact_def", value, tolerance, true)?,
361            });
362        }
363        if let Some(probe) = payload("contact_def", value, "surface")? {
364            return Ok(Self::SurfaceBased {
365                probe: finite_float("contact_def", value, probe, false)?,
366            });
367        }
368        Err(refused(
369            "contact_def",
370            value,
371            "distance:<tolerance> or surface:<probe>",
372        ))
373    }
374}
375
376impl fmt::Display for ContactDefinition {
377    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
378        match self {
379            Self::DistanceCutoff { tolerance } => write!(formatter, "distance:{tolerance}"),
380            Self::SurfaceBased { probe } => write!(formatter, "surface:{probe}"),
381        }
382    }
383}
384
385#[cfg(test)]
386#[path = "vocabulary_tests.rs"]
387mod tests;