hydra_common/identity.rs
1//! Engine identity: descriptors and the registry (spec §2).
2
3use serde::Serialize;
4
5/// Whether a registered engine is implemented in this distribution
6/// (spec §2.3).
7///
8/// A `Planned` engine is registered so applications can present it and so
9/// its key is reserved — it carries no implementation. Applications must
10/// refuse to create projects, import models, or run simulations for one.
11/// Resolving a planned key is **not** an [`UnknownEngineError`]: the
12/// descriptor exists and its identity fields are valid.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
14#[serde(rename_all = "camelCase")]
15pub enum EngineStatus {
16 /// Implemented and usable.
17 Available,
18 /// Registered and reserved; no implementation yet.
19 Planned,
20}
21
22/// How strongly an engine claims a candidate model as its own (spec §2.5).
23///
24/// This is the whole vocabulary of the recognition contract. The foundation
25/// layer holds no section names and no format grammar — the judgement is
26/// authored entirely by the engine; this type only gives every engine the
27/// same three words to express it in.
28///
29/// Recognition answers "whose is this?", never "can this run?". A
30/// [`Definite`](Self::Definite) verdict is not a promise that the model is
31/// well-formed: the owning engine's parse may still reject it.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
33#[serde(rename_all = "camelCase")]
34pub enum Recognition {
35 /// The bytes carry a marker belonging to this engine's format and to no
36 /// other.
37 Definite,
38 /// The bytes are shaped like this engine's format but carry nothing
39 /// distinguishing them from another engine claiming the same shape.
40 Plausible,
41 /// Not this engine's — the format is unrecognised, or the bytes carry
42 /// another format's marker.
43 No {
44 /// Optional engine-authored text saying what the engine believes the
45 /// file is instead, e.g. "this looks like a SWMM model". Advisory:
46 /// applications must behave identically without it.
47 reason: Option<String>,
48 },
49}
50
51impl Recognition {
52 /// Whether this verdict is a claim at all (spec §2.5.1 consults
53 /// `definite` before `plausible`, and ignores `no` entirely).
54 pub fn claims(&self) -> bool {
55 !matches!(self, Recognition::No { .. })
56 }
57
58 /// A plain refusal carrying no explanation.
59 pub fn no() -> Self {
60 Recognition::No { reason: None }
61 }
62}
63
64/// One source-model file format an engine imports (spec §2.2).
65///
66/// This names a format for a file picker's filter. It is **not** a
67/// validity test: `wds` and `uds` both claim the `inp` extension with
68/// wholly incompatible contents, so deciding whether a file really is a
69/// model of this format is the owning engine's job.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
71#[serde(rename_all = "camelCase")]
72pub struct ImportFormat {
73 /// Human-facing format name, e.g. "EPANET input file".
74 pub label: &'static str,
75 /// Filename extensions, lowercase ASCII with no leading dot.
76 pub extensions: &'static [&'static str],
77}
78
79/// Immutable identity of one Hydra engine (spec §2.1).
80///
81/// `key` and the `label`/`pill` pair are two deliberately separate naming
82/// systems: the key carries the accurate domain umbrella and never changes
83/// once released (it is persisted in project metadata and report
84/// templates); the label carries the familiar practitioner term and may be
85/// revised between releases.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
87#[serde(rename_all = "camelCase")]
88pub struct EngineDescriptor {
89 /// Stable machine identifier: lowercase ASCII domain-umbrella
90 /// abbreviation (`wds`, `uds`, `och`).
91 pub key: &'static str,
92 /// Human-facing product name (e.g. "Water Distribution").
93 pub label: &'static str,
94 /// Two-character uppercase badge (e.g. "WD").
95 pub pill: &'static str,
96 /// Brand color for this engine, `#rrggbb`.
97 pub accent: &'static str,
98 /// One-sentence description of the engine's domain. Plain text.
99 pub summary: &'static str,
100 /// Whether this distribution can actually run the engine (spec §2.3).
101 pub status: EngineStatus,
102 /// Source-model formats this engine imports (spec §2.2). May be empty.
103 pub import: &'static [ImportFormat],
104}
105
106impl EngineDescriptor {
107 /// Whether this engine is implemented in this distribution.
108 pub fn is_available(&self) -> bool {
109 matches!(self.status, EngineStatus::Available)
110 }
111}
112
113/// Every engine compiled into this distribution, in presentation order
114/// (spec §2.4) — planned engines included, so applications can present
115/// the full modelling scope rather than only what ships today.
116///
117/// Accents are chosen to be distinct from one another **and** from any hue
118/// that carries state meaning in a consuming application — green reads as
119/// success, amber as caution, red as failure, so an engine wearing one
120/// would say something it does not mean. (The APWA Uniform Color Code
121/// would put drainage on green; that standard governs excavation markings
122/// on pavement, not software identity, and green is spoken for here.)
123/// These are identity, not data presentation — the contract keeps colour
124/// out of the element and result catalogs precisely so applications own
125/// *those* palettes (spec §6).
126pub const ENGINES: &[EngineDescriptor] = &[
127 EngineDescriptor {
128 key: "wds",
129 label: "Water Distribution",
130 pill: "WD",
131 accent: "#4a90d9",
132 summary: "Pressurized water distribution network simulation — hydraulics, \
133 water quality, and energy on the EPANET data model.",
134 status: EngineStatus::Available,
135 import: &[ImportFormat {
136 label: "EPANET input file",
137 extensions: &["inp"],
138 }],
139 },
140 EngineDescriptor {
141 key: "uds",
142 label: "Urban Drainage",
143 pill: "UD",
144 accent: "#7a6ff0",
145 summary: "Stormwater and wastewater collection network simulation — \
146 runoff, routing, and water quality on the SWMM data model.",
147 status: EngineStatus::Available,
148 import: &[ImportFormat {
149 label: "SWMM input file",
150 extensions: &["inp"],
151 }],
152 },
153 EngineDescriptor {
154 key: "och",
155 label: "Open Channel",
156 pill: "OC",
157 accent: "#2f9e9e",
158 summary: "River and open-channel hydraulics — steady and unsteady flow \
159 on the HEC-RAS data model.",
160 status: EngineStatus::Planned,
161 import: &[ImportFormat {
162 label: "HEC-RAS project archive",
163 extensions: &["zip", "7z", "tar", "gz", "tgz"],
164 }],
165 },
166];
167
168/// Lookup failure for [`engine_by_key`].
169///
170/// Applications must treat this as an explicit unsupported state (e.g. a
171/// project created by a newer Hydra carrying an engine this build lacks) —
172/// never as a fallback to a default engine (spec §2.2).
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct UnknownEngineError {
175 /// The key that failed to resolve.
176 pub key: String,
177}
178
179impl std::fmt::Display for UnknownEngineError {
180 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
181 write!(f, "unknown engine key: {:?}", self.key)
182 }
183}
184
185impl std::error::Error for UnknownEngineError {}
186
187/// Resolve an engine key to its descriptor (spec §2.2).
188pub fn engine_by_key(key: &str) -> Result<&'static EngineDescriptor, UnknownEngineError> {
189 ENGINES
190 .iter()
191 .find(|e| e.key == key)
192 .ok_or_else(|| UnknownEngineError { key: key.into() })
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198
199 #[test]
200 fn registry_contains_wds_first() {
201 assert_eq!(ENGINES[0].key, "wds");
202 assert_eq!(ENGINES[0].label, "Water Distribution");
203 assert_eq!(ENGINES[0].pill, "WD");
204 }
205
206 #[test]
207 fn registry_lists_the_three_domain_engines_in_order() {
208 let keys: Vec<_> = ENGINES.iter().map(|e| e.key).collect();
209 assert_eq!(keys, ["wds", "uds", "och"]);
210 }
211
212 #[test]
213 fn wds_and_uds_are_the_available_engines() {
214 // Guards the availability contract in both directions: adding an
215 // engine implementation without flipping its status leaves it
216 // unusable, and flipping a status without an implementation lets
217 // applications create projects that can never run.
218 let available: Vec<_> = ENGINES
219 .iter()
220 .filter(|e| e.is_available())
221 .map(|e| e.key)
222 .collect();
223 assert_eq!(available, ["wds", "uds"]);
224 assert_eq!(engine_by_key("och").unwrap().status, EngineStatus::Planned);
225 }
226
227 #[test]
228 fn every_engine_declares_a_usable_import_filter() {
229 for e in ENGINES {
230 assert!(
231 !e.import.is_empty(),
232 "engine {:?} declares no import format",
233 e.key
234 );
235 for fmt in e.import {
236 assert!(!fmt.label.is_empty());
237 assert!(
238 !fmt.extensions.is_empty(),
239 "format {:?} lists no extensions",
240 fmt.label
241 );
242 for ext in fmt.extensions {
243 // A leading dot or any uppercase would silently break
244 // extension matching in every consumer.
245 assert!(
246 !ext.is_empty()
247 && ext
248 .chars()
249 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()),
250 "extension {ext:?} must be lowercase ASCII with no leading dot",
251 );
252 }
253 }
254 }
255 }
256
257 #[test]
258 fn the_inp_extension_is_shared_and_therefore_never_a_validity_test() {
259 // wds and uds both claim `inp`. This is the concrete reason spec
260 // §2.2 forbids treating an extension as a format check — if this
261 // assertion ever fails, that rationale needs revisiting, not the
262 // consumers that rely on it.
263 let claimants: Vec<_> = ENGINES
264 .iter()
265 .filter(|e| e.import.iter().any(|f| f.extensions.contains(&"inp")))
266 .map(|e| e.key)
267 .collect();
268 assert_eq!(claimants, ["wds", "uds"]);
269 }
270
271 #[test]
272 fn descriptor_field_invariants_hold_for_every_engine() {
273 for e in ENGINES {
274 assert!(
275 e.key.chars().all(|c| c.is_ascii_lowercase()),
276 "key {:?} must be lowercase ASCII",
277 e.key
278 );
279 assert_eq!(
280 e.pill.chars().count(),
281 2,
282 "pill {:?} must be 2 chars",
283 e.pill
284 );
285 assert!(e.pill.chars().all(|c| c.is_ascii_uppercase()));
286 assert!(
287 e.accent.len() == 7 && e.accent.starts_with('#'),
288 "accent {:?} must be #rrggbb",
289 e.accent
290 );
291 assert!(!e.summary.is_empty());
292 }
293 }
294
295 #[test]
296 fn keys_are_unique() {
297 let mut keys: Vec<_> = ENGINES.iter().map(|e| e.key).collect();
298 keys.sort_unstable();
299 keys.dedup();
300 assert_eq!(keys.len(), ENGINES.len());
301 }
302
303 #[test]
304 fn lookup_resolves_and_rejects() {
305 assert_eq!(engine_by_key("wds").unwrap().pill, "WD");
306 let err = engine_by_key("nope").unwrap_err();
307 assert_eq!(err.key, "nope");
308 assert!(err.to_string().contains("nope"));
309 }
310
311 #[test]
312 fn a_planned_engine_resolves_rather_than_erroring() {
313 // Spec §2.3: "planned" and "unknown" are distinct states. Conflating
314 // them would make a planned engine indistinguishable from one this
315 // build has never heard of.
316 assert!(engine_by_key("och").is_ok());
317 assert!(!engine_by_key("och").unwrap().is_available());
318 }
319}