energyml_storage/backend.rs
1//! Where the numbers come from, behind one trait.
2//!
3//! An EnergyML object points at its arrays; it does not hold them. The pointer
4//! says *which file* and *which path inside it*, and something has to turn that
5//! into bytes. That something is a backend, and it is a **trait** rather than a
6//! hard-wired HDF5 call, for a reason set out in ADR-0002:
7//!
8//! - **HDF5 must be optional.** An EPC has to be readable, editable and
9//! writable with no native library anywhere near the build - that is what
10//! keeps `energyml-core` and this crate compiling for `wasm32`. Without the
11//! feature, only *reading an array* fails, and it fails saying which
12//! capability is missing rather than pretending the data is absent.
13//! - **HDF5 does run in a browser**, through `h5wasm`, which is what makes
14//! H5Web work in JupyterLab and Galaxy. What does not exist is a *Rust* path
15//! to it. A WebAssembly application therefore supplies its **own**
16//! implementation of this trait, backed by h5wasm on the JavaScript side, and
17//! nothing in this crate changes. That is the whole point of the indirection.
18//! - **Other formats exist.** CSV and Parquet appear behind the same
19//! `MimeType`-driven choice; the trait does not know about HDF5 either.
20//!
21//! # Where a file is
22//!
23//! An array pointer names a file, usually as a bare name - `model.h5` - which
24//! the specification says sits beside the package. But a caller may know
25//! better: a cloud service has neither the right names nor a disk, and a
26//! package moved after export names files that are no longer there. So the
27//! resolution is a small policy, [`ExternalFiles`], that the caller can steer.
28//!
29//! # The question is not "where is the file"
30//!
31//! It is **"which file holds this dataset"**, and the difference is the whole
32//! design. A name is a *hint* - a good one, usually right, and worth trying
33//! first. But a person who renames `model.epc` to `model_final.epc`, or who
34//! receives the `.h5` from a colleague under another name, has not broken their
35//! data: every number is still there, in a file sitting right next to the
36//! package. Refusing them because a string no longer matches is refusing to
37//! read data that is present, over bookkeeping.
38//!
39//! So [`ExternalFiles::candidates`] returns an **ordered list of places to
40//! look**, and the caller walks it asking each one whether it actually holds
41//! the dataset. The first that does, wins. The name decides the *order*; the
42//! content decides the *answer*.
43//!
44//! The order, most authoritative first, is in [`Origin`].
45//!
46//! # Never quietly
47//!
48//! Reading a file the document did not name is exactly the mistake the uuid
49//! cross-check in `EPC §3.2.3` exists to catch, and being permissive here is a
50//! deliberate trade. It is paid for by saying so: every resolution that departs
51//! from what the document wrote becomes a diagnostic naming what was asked for,
52//! what was used, and why.
53
54use std::collections::BTreeMap;
55use std::path::{Path, PathBuf};
56
57use crate::array::{ArrayBuffer, ArrayData, ArrayLocation, ArrayMetadata, ByteOrder};
58use crate::error::{Error, ExternalDataReason, Result};
59
60/// Reads arrays out of one kind of external file.
61///
62/// Aligned on ETP protocol 9: metadata without the data, then the data, whole
63/// or sliced.
64pub trait ArrayBackend: Send + Sync {
65 /// What this backend is, for diagnostics.
66 fn name(&self) -> &'static str;
67
68 /// Whether this backend handles a file, judged on the MIME type the object
69 /// declared and on the file name.
70 fn handles(&self, mime: Option<&str>, file: &Path) -> bool;
71
72 /// Shape and element type, without reading the values -
73 /// `GetDataArrayMetadata`.
74 fn metadata(&self, file: &Path, path_in_file: &str) -> Result<ArrayMetadata>;
75
76 /// The values, or the slice `start`/`count` names - `GetDataArrays` and
77 /// `GetDataSubarrays`. Empty bounds mean the whole dataset.
78 ///
79 /// One copy: out of the file, into the returned block. Use
80 /// [`ArrayBackend::read_into`] when even that allocation is one too many.
81 fn read(&self, file: &Path, path_in_file: &str, start: &[i64], count: &[i64]) -> Result<ArrayData>;
82
83 /// Decode straight into a buffer the caller already owns.
84 ///
85 /// **The zero-allocation path**, and the one a 3D application wants: the
86 /// vertex buffer exists, the numbers go into it, and nothing else is
87 /// touched. Returns the shape that was read.
88 ///
89 /// The buffer must hold exactly the elements the selection asks for, in the
90 /// element type the dataset holds - [`ArrayBackend::metadata`] says both
91 /// without reading anything. A mismatch is an error, never a silent partial
92 /// read.
93 fn read_into(
94 &self,
95 file: &Path,
96 path_in_file: &str,
97 start: &[i64],
98 count: &[i64],
99 out: &mut ArrayBuffer<'_>,
100 ) -> Result<ArrayMetadata>;
101
102 /// Which end of a multi-byte number this dataset writes first, when the
103 /// backend can say and the width makes the question meaningful.
104 ///
105 /// `Ok(None)` for a backend that does not know, and for a one-byte element
106 /// where there is nothing to order. **Never a guess**: a caller that writes
107 /// this down - an EML 2.3 external array declares the storage type, byte
108 /// order included - would otherwise make every value read wrong while the
109 /// document stayed valid. A backend that cannot answer says so, and the
110 /// caller refuses or defers rather than assuming the usual one.
111 ///
112 /// Separate from [`ArrayBackend::metadata`] because it is a separate
113 /// question: the shape and the type are what a viewer needs before deciding
114 /// whether it can afford the data, and byte order tells it nothing.
115 fn byte_order(&self, _file: &Path, _path_in_file: &str) -> Result<Option<ByteOrder>> {
116 Ok(None)
117 }
118
119 /// The uuid the file claims for itself, when the format has a place for one.
120 ///
121 /// EPC §3.2.3 requires an HDF5 file to carry, at its root, a `uuid`
122 /// attribute equal to that of its `EpcExternalPartReference` - a
123 /// cross-validation that catches the one mistake nothing else does: reading
124 /// somebody else's file because it happened to have the right name.
125 fn file_uuid(&self, _file: &Path) -> Result<Option<String>> {
126 Ok(None)
127 }
128
129 /// Whether this backend can write, not only read.
130 ///
131 /// Asked before anything is attempted, so a caller can offer or refuse the
132 /// operation rather than discovering halfway through that half a file has
133 /// been created.
134 fn writes(&self) -> bool {
135 false
136 }
137
138 /// Create or replace a dataset, and the groups leading to it.
139 ///
140 /// The file is created when it does not exist. Replacing an existing
141 /// dataset is allowed - HDF5 cannot resize one in place, so the old one is
142 /// unlinked first and the space it held is **not** reclaimed. Rewriting the
143 /// same file many times grows it; that is HDF5's model, not a defect here,
144 /// and a caller that cares repacks.
145 ///
146 /// The default refuses by name. A backend that cannot write must say so
147 /// rather than appear to succeed.
148 fn write(&self, file: &Path, path_in_file: &str, _data: &ArrayData) -> Result<()> {
149 Err(Error::ExternalData {
150 location: format!("{}:{}", file.display(), path_in_file),
151 reason: ExternalDataReason::Backend(format!(
152 "the `{}` backend reads but does not write",
153 self.name()
154 )),
155 })
156 }
157
158 /// Write several datasets into **one** file - what a plan of many arrays
159 /// asks. One result per item, in their order: a refusal of one is not the
160 /// refusal of the others.
161 ///
162 /// The default writes each by [`ArrayBackend::write`]. A backend whose cost
163 /// is opening the file overrides it to open once: HDF5 spent 5.8 ms a
164 /// dataset reopening and flushing a growing file, 55.6 s of the 58 s it took
165 /// to write 9 576 small arrays for 1 300 SIGEOL boreholes (measured 28/09).
166 fn write_many(&self, file: &Path, items: &[(&str, &ArrayData)]) -> Vec<Result<()>> {
167 items
168 .iter()
169 .map(|(path, data)| self.write(file, path, data))
170 .collect()
171 }
172
173 /// Put the uuid of the owning `EpcExternalPartReference` at the file root.
174 ///
175 /// The other half of the cross-validation [`ArrayBackend::file_uuid`]
176 /// reads. Writing the numbers without writing this produces a file that
177 /// this very crate will later refuse - so it is part of writing, not a
178 /// nicety.
179 fn set_file_uuid(&self, file: &Path, _uuid: &str) -> Result<()> {
180 Err(Error::ExternalData {
181 location: file.display().to_string(),
182 reason: ExternalDataReason::Backend(format!(
183 "the `{}` backend cannot stamp a file uuid",
184 self.name()
185 )),
186 })
187 }
188}
189
190/// How a file name in an array pointer becomes a path on a disk.
191#[derive(Debug, Clone, Default)]
192pub struct ExternalFiles {
193 /// Where relative names are looked up - the folder holding the package,
194 /// which is what the specification recommends.
195 root: Option<PathBuf>,
196 /// Names the caller has pinned to a path, whatever the package says.
197 overrides: BTreeMap<String, PathBuf>,
198 /// Files the caller offered **without saying what they are called**, in the
199 /// order offered. See [`ExternalFiles::offer`].
200 offered: Vec<PathBuf>,
201 /// Names the caller says exist, with no path behind them at all. See
202 /// [`ExternalFiles::declare_present`].
203 present: Vec<String>,
204}
205
206/// Why a path is worth trying, and - through the order of the variants - how
207/// strongly.
208///
209/// The list is a chain of responsibility: each origin is asked in turn whether
210/// it can produce a file holding the dataset, and the first that can, answers.
211/// What makes it useful rather than decorative is that the acceptance test is
212/// the *content*, so a candidate that exists but does not hold the dataset
213/// steps aside for the next one instead of failing the read.
214#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
215pub enum Origin {
216 /// The caller pinned this path to the name the document uses.
217 ///
218 /// First, because it is the only case where someone has stated, about
219 /// *this* name, where the file is. Nothing the document says outranks it.
220 Pinned,
221 /// The caller offered this path without naming it, and this is its position
222 /// in the order they were offered.
223 ///
224 /// Before the document's own name, because offering a file is an
225 /// instruction - a person who passes `--h5 somewhere/data.h5` is saying
226 /// "use this", and they usually pass it precisely because the package's own
227 /// name is wrong.
228 Offered(usize),
229 /// The name the document wrote, resolved against the package's folder.
230 ///
231 /// What the specification means, and right almost always. It is third only
232 /// because the two above are explicit human instructions.
233 Declared,
234 /// The file beside the package carrying the package's own name:
235 /// `model.epc` → `model.h5`.
236 Companion,
237 /// Another file in the package's folder.
238 ///
239 /// The permissive tail, and the one that rescues a renamed pair. Tried in a
240 /// stable order so that two runs over the same folder agree.
241 Neighbour,
242}
243
244impl Origin {
245 /// Whether using this needs saying out loud.
246 ///
247 /// [`Origin::Declared`] is the document being right, which is not news.
248 /// Everything else means the read went somewhere the document did not
249 /// point, and a caller that saves the package afterwards needs to know.
250 pub fn is_a_departure(self) -> bool {
251 self != Origin::Declared
252 }
253
254 /// What to call it in a message.
255 pub fn describe(self) -> &'static str {
256 match self {
257 Origin::Pinned => "a file pinned to that name by the caller",
258 Origin::Offered(_) => "a file the caller offered",
259 Origin::Declared => "the file the document names",
260 Origin::Companion => "the file beside the package with the package's name",
261 Origin::Neighbour => "another file in the package's folder",
262 }
263 }
264}
265
266/// One place worth looking, and why.
267#[derive(Clone, PartialEq, Eq, Debug)]
268pub struct Candidate {
269 /// Why this path is being tried.
270 pub origin: Origin,
271 /// Where it is.
272 pub path: PathBuf,
273}
274
275impl ExternalFiles {
276 /// Resolve relative names against this folder.
277 pub fn with_root(root: impl Into<PathBuf>) -> Self {
278 Self {
279 root: Some(root.into()),
280 overrides: BTreeMap::new(),
281 offered: Vec::new(),
282 present: Vec::new(),
283 }
284 }
285
286 /// Pin one name to a path, overriding whatever the package says.
287 ///
288 /// For the packages whose HDF5 file was renamed, or moved, or never had the
289 /// name the object claims - all of which happen.
290 pub fn set(&mut self, name: impl Into<String>, path: impl Into<PathBuf>) -> &mut Self {
291 self.overrides.insert(name.into(), path.into());
292 self
293 }
294
295 /// Offer a file **without saying what it is called**.
296 ///
297 /// The difference from [`set`][Self::set] is the whole reason this exists.
298 /// Pinning requires knowing the name the document uses, which is exactly
299 /// what a person handed a renamed `.h5` does not know and should not have
300 /// to find out by opening the `.rels` in a text editor. Offering says only
301 /// "the numbers might be in here"; the search opens it and looks.
302 ///
303 /// Offers are tried in the order given, so a caller who knows which is
304 /// likeliest can say so by putting it first.
305 pub fn offer(&mut self, path: impl Into<PathBuf>) -> &mut Self {
306 self.offered.push(path.into());
307 self
308 }
309
310 /// The files offered without a name, in order.
311 pub fn offered(&self) -> &[PathBuf] {
312 &self.offered
313 }
314
315 /// State that a file of this name is available, without saying where.
316 ///
317 /// **For a store that has no filesystem**, which in practice means a
318 /// browser. Everything else here answers "which path", and every candidate
319 /// is filtered on the path existing - which is false for all of them when
320 /// there are no paths. Without this, the search yields nothing in a
321 /// browser and no array is ever read, *whatever backend is plugged in*: the
322 /// resolution would collapse silently one layer above the thing that was
323 /// supposed to do the work.
324 ///
325 /// So a declared name is a candidate whose path is the name itself. It goes
326 /// nowhere on disk, and it is not meant to: a backend that lives outside
327 /// this process - h5wasm behind [`ArrayBackend`], holding the bytes in its
328 /// own memory - receives that name and resolves it its own way.
329 ///
330 /// The bytes deliberately do **not** come through here. Whoever decodes the
331 /// format is who should hold them, and for HDF5 in a browser that is the
332 /// JavaScript side; copying nineteen megabytes into this process so that it
333 /// can hand them straight back out would buy nothing.
334 ///
335 /// Named rather than offered, because a caller who has the file also knows
336 /// what it is called - it is a file they picked or were given. A name that
337 /// matches nothing in the package still gets its turn, under
338 /// [`Origin::Offered`], in the order declared.
339 pub fn declare_present(&mut self, name: impl Into<String>) -> &mut Self {
340 let name = name.into();
341 if !self.present.contains(&name) {
342 self.present.push(name);
343 }
344 self
345 }
346
347 /// The names declared present, in order.
348 pub fn present(&self) -> &[String] {
349 &self.present
350 }
351
352 /// Whether any name was declared present - the shape a store takes when it
353 /// has no filesystem to resolve against.
354 pub fn is_virtual(&self) -> bool {
355 !self.present.is_empty()
356 }
357
358 /// Every place worth looking for a dataset that the document says is in
359 /// `declared`, most authoritative first.
360 ///
361 /// Existence is filtered here; **content is not**. Whether a candidate
362 /// actually holds the dataset is a question only a backend can answer, and
363 /// asking it is the caller's half of the job - see [`Origin`] for why the
364 /// division matters.
365 ///
366 /// `package` is the `.epc` itself when there is one on disk, which is what
367 /// [`Origin::Companion`] is derived from and what is excluded from
368 /// [`Origin::Neighbour`].
369 pub fn candidates(&self, declared: &str, package: Option<&Path>) -> Vec<Candidate> {
370 let mut out: Vec<Candidate> = Vec::new();
371 let push = |origin: Origin, path: PathBuf, out: &mut Vec<Candidate>| {
372 if path.exists() && !out.iter().any(|c| c.path == path) {
373 out.push(Candidate { origin, path });
374 }
375 };
376 // A name someone declared present is a candidate as it stands: there is
377 // no path to test, which is the whole point. Existence is asserted by
378 // the caller and the *content* is still checked by the backend, so the
379 // chain keeps the property that decides everything - the file that
380 // holds the dataset wins, whether it lives on a disk or in another
381 // process's memory.
382 let declare = |origin: Origin, name: &str, out: &mut Vec<Candidate>| {
383 let path = PathBuf::from(name);
384 if !out.iter().any(|c| c.path == path) {
385 out.push(Candidate { origin, path });
386 }
387 };
388 let bare_declared = declared.rsplit(['/', '\\']).next().unwrap_or(declared);
389 for (rank, name) in self.present.iter().enumerate() {
390 let bare = name.rsplit(['/', '\\']).next().unwrap_or(name);
391 // The one the document asks for comes first, then the rest in the
392 // order declared - the same shape as pinned before offered.
393 let origin = match name == declared || bare == bare_declared {
394 true => Origin::Pinned,
395 false => Origin::Offered(rank),
396 };
397 declare(origin, name, &mut out);
398 }
399 out.sort_by_key(|candidate| candidate.origin);
400
401 // 1. Pinned by name, spelled either way: a package may write the whole
402 // URI or the bare file name for the same file.
403 let bare = declared.rsplit(['/', '\\']).next().unwrap_or(declared);
404 for key in [declared, bare] {
405 if let Some(path) = self.overrides.get(key) {
406 push(Origin::Pinned, path.clone(), &mut out);
407 }
408 }
409
410 // 2. Offered, in the order offered.
411 for (rank, path) in self.offered.iter().enumerate() {
412 push(Origin::Offered(rank), path.clone(), &mut out);
413 }
414
415 // 3. What the document says, absolute or relative to the folder.
416 if !declared.is_empty() {
417 let written = Path::new(declared);
418 if written.is_absolute() {
419 push(Origin::Declared, written.to_path_buf(), &mut out);
420 }
421 if let Some(root) = &self.root {
422 for relative in [declared, bare] {
423 push(Origin::Declared, root.join(relative), &mut out);
424 }
425 }
426 }
427
428 // 4. The package's own name with the other extension.
429 if let Some(package) = package {
430 push(Origin::Companion, package.with_extension("h5"), &mut out);
431 }
432
433 // 5. Everything else in the folder, in a stable order. Not filtered by
434 // extension - choosing a backend is the caller's job and it does it
435 // better, since a file named without one may still declare a MIME
436 // type - except for the one kind that is never an array file: a
437 // **package**. A folder holding two `.epc` files is the ordinary
438 // case, and offering one as a place to look for the other's numbers
439 // can only waste an open and confuse a message.
440 if let Some(root) = &self.root
441 && let Ok(entries) = std::fs::read_dir(root)
442 {
443 let mut neighbours: Vec<PathBuf> = entries
444 .flatten()
445 .map(|entry| entry.path())
446 .filter(|path| path.is_file())
447 .filter(|path| Some(path.as_path()) != package)
448 .filter(|path| !path.extension().is_some_and(|e| e.eq_ignore_ascii_case("epc")))
449 .collect();
450 neighbours.sort();
451 for path in neighbours {
452 push(Origin::Neighbour, path, &mut out);
453 }
454 }
455
456 out
457 }
458
459 /// The folder relative names resolve against.
460 pub fn root(&self) -> Option<&Path> {
461 self.root.as_deref()
462 }
463
464 /// Set the folder relative names resolve against.
465 pub fn set_root(&mut self, root: impl Into<PathBuf>) -> &mut Self {
466 self.root = Some(root.into());
467 self
468 }
469
470 /// Turn the name an array pointer carries into a path that exists.
471 pub fn resolve(&self, name: &str) -> Result<PathBuf> {
472 let missing = || Error::ExternalData {
473 location: name.to_string(),
474 reason: ExternalDataReason::FileMissing,
475 };
476 if name.is_empty() {
477 return Err(missing());
478 }
479 // What the caller pinned wins, by whole name and by file name, since a
480 // package may spell the same file either way.
481 let bare = name.rsplit(['/', '\\']).next().unwrap_or(name);
482 for key in [name, bare] {
483 if let Some(path) = self.overrides.get(key) {
484 return Ok(path.clone());
485 }
486 }
487
488 let candidate = Path::new(name);
489 if candidate.is_absolute() && candidate.exists() {
490 return Ok(candidate.to_path_buf());
491 }
492 let root = self.root.as_deref().ok_or_else(missing)?;
493 // The name as written, then its last segment: the schema suggests a URI
494 // and fesapi writes a bare file name, and both occur.
495 for relative in [name, bare] {
496 let path = root.join(relative);
497 if path.exists() {
498 return Ok(path);
499 }
500 }
501 Err(missing())
502 }
503
504 /// Where a file of this name *would* go, whether or not it is there yet.
505 ///
506 /// [`ExternalFiles::resolve`] answers "where is it", and every branch of it
507 /// requires the file to exist - which is right for reading and useless for
508 /// writing. This answers "where should it be written", using the same
509 /// precedence so that a file created here is found by `resolve` afterwards.
510 ///
511 /// Errors when there is nowhere to put it: no override, not absolute, and
512 /// no root. Inventing the working directory would scatter data files next
513 /// to whatever the process happened to be run from.
514 pub fn destination_for(&self, name: &str) -> Result<PathBuf> {
515 let nowhere = || Error::ExternalData {
516 location: name.to_string(),
517 reason: ExternalDataReason::FileMissing,
518 };
519 if name.is_empty() {
520 return Err(nowhere());
521 }
522 let bare = name.rsplit(['/', '\\']).next().unwrap_or(name);
523 for key in [name, bare] {
524 if let Some(path) = self.overrides.get(key) {
525 return Ok(path.clone());
526 }
527 }
528 let candidate = Path::new(name);
529 if candidate.is_absolute() {
530 return Ok(candidate.to_path_buf());
531 }
532 // Beside the package, which is where every real EPC keeps its arrays.
533 self.root
534 .as_deref()
535 .map(|root| root.join(name))
536 .ok_or_else(nowhere)
537 }
538}
539
540/// The backends a store will try, in order.
541#[derive(Default)]
542pub struct Backends {
543 entries: Vec<Box<dyn ArrayBackend>>,
544}
545
546impl Backends {
547 /// No backend at all: every array read fails saying so.
548 pub fn none() -> Self {
549 Self::default()
550 }
551
552 /// Whatever this build was compiled with.
553 ///
554 /// HDF5 when the `hdf5` feature is on, nothing otherwise - and "nothing"
555 /// is a supported state, not a broken one.
556 pub fn compiled_in() -> Self {
557 #[allow(unused_mut)]
558 let mut backends = Self::default();
559 #[cfg(feature = "hdf5")]
560 backends.push(Box::new(crate::hdf5::Hdf5Backend));
561 backends
562 }
563
564 /// Add a backend, tried before the ones already there.
565 ///
566 /// This is how a WebAssembly application plugs h5wasm in, and how CSV or
567 /// Parquet will arrive.
568 pub fn push(&mut self, backend: Box<dyn ArrayBackend>) -> &mut Self {
569 self.entries.insert(0, backend);
570 self
571 }
572
573 /// Whether anything at all can read an array.
574 pub fn is_empty(&self) -> bool {
575 self.entries.is_empty()
576 }
577
578 /// The first backend that says it handles this file.
579 pub fn find(&self, location: &ArrayLocation, file: &Path) -> Result<&dyn ArrayBackend> {
580 self.entries
581 .iter()
582 .find(|backend| backend.handles(location.mime.as_deref(), file))
583 .map(|backend| backend.as_ref())
584 .ok_or_else(|| Error::ExternalData {
585 location: format!("{}:{}", file.display(), location.path_in_file),
586 reason: ExternalDataReason::FeatureNotEnabled {
587 feature: "hdf5",
588 mime: location.mime.clone(),
589 },
590 })
591 }
592}
593
594#[cfg(test)]
595mod tests {
596 use super::*;
597
598 #[test]
599 fn a_pinned_file_wins_over_the_package() {
600 let mut files = ExternalFiles::with_root("/nowhere");
601 files.set("model.h5", "/elsewhere/renamed.h5");
602 assert_eq!(
603 files.resolve("model.h5").unwrap(),
604 Path::new("/elsewhere/renamed.h5")
605 );
606 // Pinned by bare name, matched against a path-shaped reference.
607 assert_eq!(
608 files.resolve("sub/model.h5").unwrap(),
609 Path::new("/elsewhere/renamed.h5")
610 );
611 }
612
613 #[test]
614 fn an_unknown_file_says_it_is_missing() {
615 let files = ExternalFiles::with_root("/nowhere");
616 assert!(matches!(
617 files.resolve("absent.h5"),
618 Err(Error::ExternalData {
619 reason: ExternalDataReason::FileMissing,
620 ..
621 })
622 ));
623 assert!(files.resolve("").is_err(), "an empty name resolves to nothing");
624 }
625
626 #[test]
627 fn without_a_backend_the_error_names_the_capability() {
628 let backends = Backends::none();
629 let location = ArrayLocation {
630 file: "model.h5".to_string(),
631 path_in_file: "/a/b".to_string(),
632 mime: Some("application/x-hdf5".to_string()),
633 start: Vec::new(),
634 count: Vec::new(),
635 proxy_uuid: None,
636 field_path: "Points".to_string(),
637 resource_uri: None,
638 };
639 match backends.find(&location, Path::new("model.h5")) {
640 Err(Error::ExternalData {
641 reason: ExternalDataReason::FeatureNotEnabled { feature, .. },
642 ..
643 }) => assert_eq!(feature, "hdf5"),
644 Err(other) => panic!("wrong error: {other}"),
645 Ok(backend) => panic!("no backend was registered, yet `{}` answered", backend.name()),
646 }
647 }
648}