Skip to main content

rtb_assets/
assets.rs

1//! The [`Assets`] overlay container and its [`AssetsBuilder`].
2
3use std::collections::{BTreeSet, HashMap};
4use std::path::PathBuf;
5use std::sync::Arc;
6
7use rust_embed::RustEmbed;
8use serde::de::DeserializeOwned;
9
10use crate::error::AssetError;
11use crate::source::{AssetSource, DirectorySource, EmbeddedSource, MemorySource};
12
13/// Ordered stack of asset layers. Earlier-registered layers have lower
14/// priority; later-registered win on conflict.
15#[derive(Clone, Default)]
16pub struct Assets {
17    layers: Arc<[Arc<dyn AssetSource>]>,
18}
19
20impl Assets {
21    /// Start an empty builder.
22    pub fn builder() -> AssetsBuilder {
23        AssetsBuilder::default()
24    }
25
26    /// Return the highest-priority layer's bytes for `path`, or
27    /// `None` if no layer provides it.
28    #[must_use]
29    pub fn open(&self, path: &str) -> Option<Vec<u8>> {
30        for layer in self.layers.iter().rev() {
31            if let Some(bytes) = layer.read(path) {
32                return Some(bytes);
33            }
34        }
35        None
36    }
37
38    /// UTF-8 convenience read. `NotFound` if no layer has `path`;
39    /// `NotUtf8` if the bytes aren't valid UTF-8.
40    pub fn open_text(&self, path: &str) -> Result<String, AssetError> {
41        let bytes = self.open(path).ok_or_else(|| AssetError::NotFound(path.to_string()))?;
42        String::from_utf8(bytes).map_err(|_| AssetError::NotUtf8 { path: path.to_string() })
43    }
44
45    /// `true` iff any layer provides `path`.
46    #[must_use]
47    pub fn exists(&self, path: &str) -> bool {
48        self.layers.iter().any(|l| l.read(path).is_some())
49    }
50
51    /// Union of every layer's entries in `dir`, deduplicated and
52    /// alphabetically sorted.
53    #[must_use]
54    pub fn list_dir(&self, dir: &str) -> Vec<String> {
55        let mut all = BTreeSet::new();
56        for layer in self.layers.iter() {
57            for entry in layer.list(dir) {
58                all.insert(entry);
59            }
60        }
61        all.into_iter().collect()
62    }
63
64    /// Deep-merge YAML at `path` across every layer that provides it,
65    /// then deserialise into `T`.
66    ///
67    /// - If no layer has `path`, returns [`AssetError::NotFound`].
68    /// - If any contributing layer's YAML fails to parse, returns
69    ///   [`AssetError::Parse`] naming that layer.
70    /// - Later layers override earlier layers at matching keys;
71    ///   nested maps merge recursively; non-map values replace
72    ///   wholesale.
73    pub fn load_merged_yaml<T: DeserializeOwned>(&self, path: &str) -> Result<T, AssetError> {
74        self.load_merged(path, "YAML", |bytes, source_name| {
75            let s = std::str::from_utf8(bytes).map_err(|e| AssetError::Parse {
76                path: format!("{path} (layer `{source_name}`)"),
77                format: "YAML",
78                message: e.to_string(),
79            })?;
80            serde_saphyr::from_str_with_options(s, yaml_options()).map_err(|e| AssetError::Parse {
81                path: format!("{path} (layer `{source_name}`)"),
82                format: "YAML",
83                message: e.to_string(),
84            })
85        })
86    }
87
88    /// Same as [`Self::load_merged_yaml`] but for JSON input.
89    pub fn load_merged_json<T: DeserializeOwned>(&self, path: &str) -> Result<T, AssetError> {
90        self.load_merged(path, "JSON", |bytes, source_name| {
91            serde_json::from_slice::<serde_json::Value>(bytes).map_err(|e| AssetError::Parse {
92                path: format!("{path} (layer `{source_name}`)"),
93                format: "JSON",
94                message: e.to_string(),
95            })
96        })
97    }
98
99    fn load_merged<T, F>(&self, path: &str, format: &'static str, parse: F) -> Result<T, AssetError>
100    where
101        T: DeserializeOwned,
102        F: Fn(&[u8], &str) -> Result<serde_json::Value, AssetError>,
103    {
104        let mut merged: Option<serde_json::Value> = None;
105        for layer in self.layers.iter() {
106            let Some(bytes) = layer.read(path) else { continue };
107            let parsed = parse(&bytes, layer.name())?;
108            merged = Some(match merged {
109                None => parsed,
110                Some(mut acc) => {
111                    json_patch::merge(&mut acc, &parsed);
112                    acc
113                }
114            });
115        }
116        let merged = merged.ok_or_else(|| AssetError::NotFound(path.to_string()))?;
117        serde_json::from_value::<T>(merged).map_err(|e| AssetError::Parse {
118            path: path.to_string(),
119            format,
120            message: e.to_string(),
121        })
122    }
123}
124
125/// Fluent builder for [`Assets`]. Sources are appended in registration
126/// order; later registrations have higher precedence.
127#[derive(Default)]
128#[must_use]
129pub struct AssetsBuilder {
130    layers: Vec<Arc<dyn AssetSource>>,
131}
132
133impl std::fmt::Debug for AssetsBuilder {
134    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135        let names: Vec<&str> = self.layers.iter().map(|l| l.name()).collect();
136        f.debug_struct("AssetsBuilder").field("layers", &names).finish()
137    }
138}
139
140impl std::fmt::Debug for Assets {
141    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142        let names: Vec<&str> = self.layers.iter().map(|l| l.name()).collect();
143        f.debug_struct("Assets").field("layers", &names).finish()
144    }
145}
146
147impl AssetsBuilder {
148    /// Construct an empty builder. Equivalent to [`Assets::builder`].
149    pub fn new() -> Self {
150        Self::default()
151    }
152
153    /// Append a `rust-embed` layer. Use turbofish:
154    ///
155    /// ```no_run
156    /// #[derive(rust_embed::RustEmbed)]
157    /// #[folder = "docs/"] // in your own crate, point this at "assets/"
158    /// struct MyEmbed;
159    ///
160    /// let assets = rtb_assets::Assets::builder()
161    ///     .embedded::<MyEmbed>("default")
162    ///     .build();
163    /// ```
164    ///
165    /// `label` is used only in diagnostics.
166    pub fn embedded<E>(mut self, label: &'static str) -> Self
167    where
168        E: RustEmbed + Send + Sync + 'static,
169    {
170        self.layers.push(Arc::new(EmbeddedSource::<E>::new(label)));
171        self
172    }
173
174    /// Append a filesystem-directory layer.
175    pub fn directory(mut self, root: impl Into<PathBuf>, label: impl Into<String>) -> Self {
176        self.layers.push(Arc::new(DirectorySource::new(root, label)));
177        self
178    }
179
180    /// Append an in-memory layer. Primarily useful in tests.
181    pub fn memory(mut self, label: impl Into<String>, files: HashMap<String, Vec<u8>>) -> Self {
182        self.layers.push(Arc::new(MemorySource::new(label, files)));
183        self
184    }
185
186    /// Append an arbitrary [`AssetSource`] layer for exotic cases
187    /// (HTTP overlays, in-process archives, …).
188    pub fn source(mut self, source: Arc<dyn AssetSource>) -> Self {
189        self.layers.push(source);
190        self
191    }
192
193    /// Finalise the builder.
194    #[must_use]
195    pub fn build(self) -> Assets {
196        Assets { layers: self.layers.into() }
197    }
198}
199
200/// Parser options pinned to the semantics `serde_yaml` 0.9 gave this
201/// crate: YAML 1.2 booleans only, `<<` as an ordinary key, and
202/// duplicate keys rejected. `docs/reference/merge-semantics.md` is the
203/// contract these hold to.
204fn yaml_options() -> serde_saphyr::Options {
205    serde_saphyr::options! {
206        strict_booleans: true,
207        merge_keys: serde_saphyr::MergeKeyPolicy::AsOrdinary,
208        duplicate_keys: serde_saphyr::DuplicateKeyPolicy::Error,
209        with_snippet: false,
210    }
211}