Skip to main content

energyml_export/
shapes.rs

1//! Choosing, per type, what form an export should take.
2//!
3//! # The problem this solves
4//!
5//! [`energyml_geometry::shape::Shape`] lets a caller ask for *one* object in a
6//! particular form. But an export is usually a whole package - thirty-seven
7//! representations of six different kinds, out of one command - and the answer
8//! is not the same for all of them. Someone loading a package into QGIS wants
9//! the horizons as polygons and the fault sticks as lines; someone building a
10//! point-cloud index wants the nodes of everything.
11//!
12//! So the caller writes **rules**, and each rule is a pattern and a shape:
13//!
14//! ```text
15//! --as Grid2d=surface --as TriangulatedSet=points --as '*=auto'
16//! ```
17//!
18//! # Why substrings, and not a full glob or regex
19//!
20//! A rule matches when its pattern appears in the object's **qualified type** -
21//! `resqml20.obj_Grid2dRepresentation`, `resqml22.TriangulatedSetRepresentation` -
22//! case-insensitively. That one rule covers every level of precision anyone
23//! actually needs:
24//!
25//! | Pattern | Matches |
26//! |---|---|
27//! | `Grid2d` | both releases' 2D grids |
28//! | `resqml20.` | everything from RESQML 2.0.1 |
29//! | `IjkGridRepresentation` | exactly that type |
30//! | `*` | everything, as a final fallback |
31//!
32//! A glob or a regex would add escaping, anchoring and a class of typo that
33//! fails **silently** - a pattern that matches nothing looks exactly like a
34//! pattern whose objects are absent. Substrings have none of that, and
35//! [`Selector`] is an enum precisely so a `Selector::Regex` can be added later
36//! without changing a single caller or a single command line.
37//!
38//! # First match wins, and order is the caller's
39//!
40//! Rules are tried in the order they were given. That is what makes a general
41//! rule and an exception composable in one line - `--as Grid2d=surface --as
42//! '*=points'` means *grids as polygons, everything else as nodes* - and it is
43//! the only ordering a person can predict without reading this file.
44//!
45//! # A rule that cannot be honoured is an error, not a shrug
46//!
47//! `--as PolylineSet=surface` does not quietly fall back to lines. The
48//! reshaping refuses by name, the export of that object fails, and the caller is
49//! told which of their rules met which object. A rule that silently does nothing
50//! is worse than one that fails: it teaches the caller that it worked.
51
52use energyml_geometry::shape::Shape;
53
54/// Which objects a rule applies to.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum Selector {
57    /// Every object. Written `*`.
58    Any,
59    /// Objects whose qualified type contains this, case-insensitively.
60    Contains(String),
61}
62
63impl Selector {
64    /// Parse a pattern. `*` is [`Selector::Any`]; anything else is a substring.
65    pub fn parse(pattern: &str) -> Selector {
66        match pattern.trim() {
67            "*" | "" => Selector::Any,
68            other => Selector::Contains(other.to_ascii_lowercase()),
69        }
70    }
71
72    /// Whether this selector picks that qualified type.
73    pub fn matches(&self, qualified_type: &str) -> bool {
74        match self {
75            Selector::Any => true,
76            Selector::Contains(needle) => qualified_type.to_ascii_lowercase().contains(needle.as_str()),
77        }
78    }
79}
80
81impl std::fmt::Display for Selector {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        match self {
84            Selector::Any => f.write_str("*"),
85            Selector::Contains(needle) => f.write_str(needle),
86        }
87    }
88}
89
90/// The rules, in the order the caller gave them.
91///
92/// Empty is the ordinary state and means [`Shape::Auto`] for everything, which
93/// is what every export did before there was anything to ask.
94#[derive(Debug, Clone, Default, PartialEq, Eq)]
95pub struct Shapes {
96    rules: Vec<(Selector, Shape)>,
97}
98
99impl Shapes {
100    /// No rules: every object in its natural shape.
101    pub fn new() -> Self {
102        Self::default()
103    }
104
105    /// Add a rule at the end, where it will be tried last.
106    pub fn with(mut self, selector: Selector, shape: Shape) -> Self {
107        self.rules.push((selector, shape));
108        self
109    }
110
111    /// Parse one `PATTERN=SHAPE` rule and add it.
112    ///
113    /// The error says which half was not understood and what the alternatives
114    /// are, because this is typed on a command line by someone who will not go
115    /// looking for documentation to find out that `polygon` was spelled
116    /// `polygons`.
117    pub fn parse_rule(self, rule: &str) -> std::result::Result<Self, String> {
118        let Some((pattern, shape)) = rule.split_once('=') else {
119            return Err(format!(
120                "`{rule}` is not a rule: write it as PATTERN=SHAPE, e.g. `Grid2d=surface`"
121            ));
122        };
123        let Some(shape) = Shape::parse(shape) else {
124            let names: Vec<&str> = Shape::all().iter().map(|s| s.name()).collect();
125            return Err(format!(
126                "`{shape}` is not a shape - try one of: {}",
127                names.join(", ")
128            ));
129        };
130        Ok(self.with(Selector::parse(pattern), shape))
131    }
132
133    /// The shape asked for this qualified type: the first rule that matches, or
134    /// [`Shape::Auto`].
135    pub fn shape_for(&self, qualified_type: &str) -> Shape {
136        self.rules
137            .iter()
138            .find(|(selector, _)| selector.matches(qualified_type))
139            .map(|(_, shape)| *shape)
140            .unwrap_or_default()
141    }
142
143    /// Whether any rule was given at all.
144    pub fn is_empty(&self) -> bool {
145        self.rules.is_empty()
146    }
147
148    /// The rules, for a caller echoing them back.
149    pub fn rules(&self) -> &[(Selector, Shape)] {
150        &self.rules
151    }
152}