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/// One source-model file format an engine imports (spec §2.2).
23///
24/// This names a format for a file picker's filter. It is **not** a
25/// validity test: `wds` and `uds` both claim the `inp` extension with
26/// wholly incompatible contents, so deciding whether a file really is a
27/// model of this format is the owning engine's job.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
29#[serde(rename_all = "camelCase")]
30pub struct ImportFormat {
31    /// Human-facing format name, e.g. "EPANET input file".
32    pub label: &'static str,
33    /// Filename extensions, lowercase ASCII with no leading dot.
34    pub extensions: &'static [&'static str],
35}
36
37/// Immutable identity of one Hydra engine (spec §2.1).
38///
39/// `key` and the `label`/`pill` pair are two deliberately separate naming
40/// systems: the key carries the accurate domain umbrella and never changes
41/// once released (it is persisted in project metadata and report
42/// templates); the label carries the familiar practitioner term and may be
43/// revised between releases.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
45#[serde(rename_all = "camelCase")]
46pub struct EngineDescriptor {
47    /// Stable machine identifier: lowercase ASCII domain-umbrella
48    /// abbreviation (`wds`, `uds`, `och`).
49    pub key: &'static str,
50    /// Human-facing product name (e.g. "Water Distribution").
51    pub label: &'static str,
52    /// Two-character uppercase badge (e.g. "WD").
53    pub pill: &'static str,
54    /// Brand color for this engine, `#rrggbb`.
55    pub accent: &'static str,
56    /// One-sentence description of the engine's domain. Plain text.
57    pub summary: &'static str,
58    /// Whether this distribution can actually run the engine (spec §2.3).
59    pub status: EngineStatus,
60    /// Source-model formats this engine imports (spec §2.2). May be empty.
61    pub import: &'static [ImportFormat],
62}
63
64impl EngineDescriptor {
65    /// Whether this engine is implemented in this distribution.
66    pub fn is_available(&self) -> bool {
67        matches!(self.status, EngineStatus::Available)
68    }
69}
70
71/// Every engine compiled into this distribution, in presentation order
72/// (spec §2.4) — planned engines included, so applications can present
73/// the full modelling scope rather than only what ships today.
74pub const ENGINES: &[EngineDescriptor] = &[
75    EngineDescriptor {
76        key: "wds",
77        label: "Water Distribution",
78        pill: "WD",
79        accent: "#4a90d9",
80        summary: "Pressurized water distribution network simulation — hydraulics, \
81                  water quality, and energy on the EPANET data model.",
82        status: EngineStatus::Available,
83        import: &[ImportFormat {
84            label: "EPANET input file",
85            extensions: &["inp"],
86        }],
87    },
88    EngineDescriptor {
89        key: "uds",
90        label: "Urban Drainage",
91        pill: "UD",
92        accent: "#7a6ff0",
93        summary: "Stormwater and wastewater collection network simulation — \
94                  runoff, routing, and water quality on the SWMM data model.",
95        status: EngineStatus::Planned,
96        import: &[ImportFormat {
97            label: "SWMM input file",
98            extensions: &["inp"],
99        }],
100    },
101    EngineDescriptor {
102        key: "och",
103        label: "Open Channel",
104        pill: "OC",
105        accent: "#3daf75",
106        summary: "River and open-channel hydraulics — steady and unsteady flow \
107                  on the HEC-RAS data model.",
108        status: EngineStatus::Planned,
109        import: &[ImportFormat {
110            label: "HEC-RAS project archive",
111            extensions: &["zip", "7z", "tar", "gz", "tgz"],
112        }],
113    },
114];
115
116/// Lookup failure for [`engine_by_key`].
117///
118/// Applications must treat this as an explicit unsupported state (e.g. a
119/// project created by a newer Hydra carrying an engine this build lacks) —
120/// never as a fallback to a default engine (spec §2.2).
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct UnknownEngineError {
123    /// The key that failed to resolve.
124    pub key: String,
125}
126
127impl std::fmt::Display for UnknownEngineError {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        write!(f, "unknown engine key: {:?}", self.key)
130    }
131}
132
133impl std::error::Error for UnknownEngineError {}
134
135/// Resolve an engine key to its descriptor (spec §2.2).
136pub fn engine_by_key(key: &str) -> Result<&'static EngineDescriptor, UnknownEngineError> {
137    ENGINES
138        .iter()
139        .find(|e| e.key == key)
140        .ok_or_else(|| UnknownEngineError { key: key.into() })
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn registry_contains_wds_first() {
149        assert_eq!(ENGINES[0].key, "wds");
150        assert_eq!(ENGINES[0].label, "Water Distribution");
151        assert_eq!(ENGINES[0].pill, "WD");
152    }
153
154    #[test]
155    fn registry_lists_the_three_domain_engines_in_order() {
156        let keys: Vec<_> = ENGINES.iter().map(|e| e.key).collect();
157        assert_eq!(keys, ["wds", "uds", "och"]);
158    }
159
160    #[test]
161    fn wds_is_the_only_available_engine() {
162        // Guards the availability contract in both directions: adding an
163        // engine implementation without flipping its status leaves it
164        // unusable, and flipping a status without an implementation lets
165        // applications create projects that can never run.
166        let available: Vec<_> = ENGINES
167            .iter()
168            .filter(|e| e.is_available())
169            .map(|e| e.key)
170            .collect();
171        assert_eq!(available, ["wds"]);
172        assert_eq!(engine_by_key("uds").unwrap().status, EngineStatus::Planned);
173        assert_eq!(engine_by_key("och").unwrap().status, EngineStatus::Planned);
174    }
175
176    #[test]
177    fn every_engine_declares_a_usable_import_filter() {
178        for e in ENGINES {
179            assert!(
180                !e.import.is_empty(),
181                "engine {:?} declares no import format",
182                e.key
183            );
184            for fmt in e.import {
185                assert!(!fmt.label.is_empty());
186                assert!(
187                    !fmt.extensions.is_empty(),
188                    "format {:?} lists no extensions",
189                    fmt.label
190                );
191                for ext in fmt.extensions {
192                    // A leading dot or any uppercase would silently break
193                    // extension matching in every consumer.
194                    assert!(
195                        !ext.is_empty()
196                            && ext
197                                .chars()
198                                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()),
199                        "extension {ext:?} must be lowercase ASCII with no leading dot",
200                    );
201                }
202            }
203        }
204    }
205
206    #[test]
207    fn the_inp_extension_is_shared_and_therefore_never_a_validity_test() {
208        // wds and uds both claim `inp`. This is the concrete reason spec
209        // §2.2 forbids treating an extension as a format check — if this
210        // assertion ever fails, that rationale needs revisiting, not the
211        // consumers that rely on it.
212        let claimants: Vec<_> = ENGINES
213            .iter()
214            .filter(|e| e.import.iter().any(|f| f.extensions.contains(&"inp")))
215            .map(|e| e.key)
216            .collect();
217        assert_eq!(claimants, ["wds", "uds"]);
218    }
219
220    #[test]
221    fn descriptor_field_invariants_hold_for_every_engine() {
222        for e in ENGINES {
223            assert!(
224                e.key.chars().all(|c| c.is_ascii_lowercase()),
225                "key {:?} must be lowercase ASCII",
226                e.key
227            );
228            assert_eq!(
229                e.pill.chars().count(),
230                2,
231                "pill {:?} must be 2 chars",
232                e.pill
233            );
234            assert!(e.pill.chars().all(|c| c.is_ascii_uppercase()));
235            assert!(
236                e.accent.len() == 7 && e.accent.starts_with('#'),
237                "accent {:?} must be #rrggbb",
238                e.accent
239            );
240            assert!(!e.summary.is_empty());
241        }
242    }
243
244    #[test]
245    fn keys_are_unique() {
246        let mut keys: Vec<_> = ENGINES.iter().map(|e| e.key).collect();
247        keys.sort_unstable();
248        keys.dedup();
249        assert_eq!(keys.len(), ENGINES.len());
250    }
251
252    #[test]
253    fn lookup_resolves_and_rejects() {
254        assert_eq!(engine_by_key("wds").unwrap().pill, "WD");
255        let err = engine_by_key("nope").unwrap_err();
256        assert_eq!(err.key, "nope");
257        assert!(err.to_string().contains("nope"));
258    }
259
260    #[test]
261    fn a_planned_engine_resolves_rather_than_erroring() {
262        // Spec §2.3: "planned" and "unknown" are distinct states. Conflating
263        // them would make a planned engine indistinguishable from one this
264        // build has never heard of.
265        assert!(engine_by_key("uds").is_ok());
266        assert!(!engine_by_key("uds").unwrap().is_available());
267    }
268}