Skip to main content

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.
116pub const ENGINES: &[EngineDescriptor] = &[
117    EngineDescriptor {
118        key: "wds",
119        label: "Water Distribution",
120        pill: "WD",
121        accent: "#4a90d9",
122        summary: "Pressurized water distribution network simulation — hydraulics, \
123                  water quality, and energy on the EPANET data model.",
124        status: EngineStatus::Available,
125        import: &[ImportFormat {
126            label: "EPANET input file",
127            extensions: &["inp"],
128        }],
129    },
130    EngineDescriptor {
131        key: "uds",
132        label: "Urban Drainage",
133        pill: "UD",
134        accent: "#7a6ff0",
135        summary: "Stormwater and wastewater collection network simulation — \
136                  runoff, routing, and water quality on the SWMM data model.",
137        status: EngineStatus::Planned,
138        import: &[ImportFormat {
139            label: "SWMM input file",
140            extensions: &["inp"],
141        }],
142    },
143    EngineDescriptor {
144        key: "och",
145        label: "Open Channel",
146        pill: "OC",
147        accent: "#3daf75",
148        summary: "River and open-channel hydraulics — steady and unsteady flow \
149                  on the HEC-RAS data model.",
150        status: EngineStatus::Planned,
151        import: &[ImportFormat {
152            label: "HEC-RAS project archive",
153            extensions: &["zip", "7z", "tar", "gz", "tgz"],
154        }],
155    },
156];
157
158/// Lookup failure for [`engine_by_key`].
159///
160/// Applications must treat this as an explicit unsupported state (e.g. a
161/// project created by a newer Hydra carrying an engine this build lacks) —
162/// never as a fallback to a default engine (spec §2.2).
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct UnknownEngineError {
165    /// The key that failed to resolve.
166    pub key: String,
167}
168
169impl std::fmt::Display for UnknownEngineError {
170    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171        write!(f, "unknown engine key: {:?}", self.key)
172    }
173}
174
175impl std::error::Error for UnknownEngineError {}
176
177/// Resolve an engine key to its descriptor (spec §2.2).
178pub fn engine_by_key(key: &str) -> Result<&'static EngineDescriptor, UnknownEngineError> {
179    ENGINES
180        .iter()
181        .find(|e| e.key == key)
182        .ok_or_else(|| UnknownEngineError { key: key.into() })
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn registry_contains_wds_first() {
191        assert_eq!(ENGINES[0].key, "wds");
192        assert_eq!(ENGINES[0].label, "Water Distribution");
193        assert_eq!(ENGINES[0].pill, "WD");
194    }
195
196    #[test]
197    fn registry_lists_the_three_domain_engines_in_order() {
198        let keys: Vec<_> = ENGINES.iter().map(|e| e.key).collect();
199        assert_eq!(keys, ["wds", "uds", "och"]);
200    }
201
202    #[test]
203    fn wds_is_the_only_available_engine() {
204        // Guards the availability contract in both directions: adding an
205        // engine implementation without flipping its status leaves it
206        // unusable, and flipping a status without an implementation lets
207        // applications create projects that can never run.
208        let available: Vec<_> = ENGINES
209            .iter()
210            .filter(|e| e.is_available())
211            .map(|e| e.key)
212            .collect();
213        assert_eq!(available, ["wds"]);
214        assert_eq!(engine_by_key("uds").unwrap().status, EngineStatus::Planned);
215        assert_eq!(engine_by_key("och").unwrap().status, EngineStatus::Planned);
216    }
217
218    #[test]
219    fn every_engine_declares_a_usable_import_filter() {
220        for e in ENGINES {
221            assert!(
222                !e.import.is_empty(),
223                "engine {:?} declares no import format",
224                e.key
225            );
226            for fmt in e.import {
227                assert!(!fmt.label.is_empty());
228                assert!(
229                    !fmt.extensions.is_empty(),
230                    "format {:?} lists no extensions",
231                    fmt.label
232                );
233                for ext in fmt.extensions {
234                    // A leading dot or any uppercase would silently break
235                    // extension matching in every consumer.
236                    assert!(
237                        !ext.is_empty()
238                            && ext
239                                .chars()
240                                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()),
241                        "extension {ext:?} must be lowercase ASCII with no leading dot",
242                    );
243                }
244            }
245        }
246    }
247
248    #[test]
249    fn the_inp_extension_is_shared_and_therefore_never_a_validity_test() {
250        // wds and uds both claim `inp`. This is the concrete reason spec
251        // §2.2 forbids treating an extension as a format check — if this
252        // assertion ever fails, that rationale needs revisiting, not the
253        // consumers that rely on it.
254        let claimants: Vec<_> = ENGINES
255            .iter()
256            .filter(|e| e.import.iter().any(|f| f.extensions.contains(&"inp")))
257            .map(|e| e.key)
258            .collect();
259        assert_eq!(claimants, ["wds", "uds"]);
260    }
261
262    #[test]
263    fn descriptor_field_invariants_hold_for_every_engine() {
264        for e in ENGINES {
265            assert!(
266                e.key.chars().all(|c| c.is_ascii_lowercase()),
267                "key {:?} must be lowercase ASCII",
268                e.key
269            );
270            assert_eq!(
271                e.pill.chars().count(),
272                2,
273                "pill {:?} must be 2 chars",
274                e.pill
275            );
276            assert!(e.pill.chars().all(|c| c.is_ascii_uppercase()));
277            assert!(
278                e.accent.len() == 7 && e.accent.starts_with('#'),
279                "accent {:?} must be #rrggbb",
280                e.accent
281            );
282            assert!(!e.summary.is_empty());
283        }
284    }
285
286    #[test]
287    fn keys_are_unique() {
288        let mut keys: Vec<_> = ENGINES.iter().map(|e| e.key).collect();
289        keys.sort_unstable();
290        keys.dedup();
291        assert_eq!(keys.len(), ENGINES.len());
292    }
293
294    #[test]
295    fn lookup_resolves_and_rejects() {
296        assert_eq!(engine_by_key("wds").unwrap().pill, "WD");
297        let err = engine_by_key("nope").unwrap_err();
298        assert_eq!(err.key, "nope");
299        assert!(err.to_string().contains("nope"));
300    }
301
302    #[test]
303    fn a_planned_engine_resolves_rather_than_erroring() {
304        // Spec §2.3: "planned" and "unknown" are distinct states. Conflating
305        // them would make a planned engine indistinguishable from one this
306        // build has never heard of.
307        assert!(engine_by_key("uds").is_ok());
308        assert!(!engine_by_key("uds").unwrap().is_available());
309    }
310}