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}