Skip to main content

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}