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            let yaml_value: serde_yaml::Value =
81                serde_yaml::from_str(s).map_err(|e| AssetError::Parse {
82                    path: format!("{path} (layer `{source_name}`)"),
83                    format: "YAML",
84                    message: e.to_string(),
85                })?;
86            // Round-trip YAML → JSON value for deep-merge. Every YAML
87            // scalar that figment/serde_yaml produces has a JSON
88            // equivalent for our use cases (maps, sequences, strings,
89            // numbers, bools, null).
90            serde_json::to_value(yaml_value).map_err(|e| AssetError::Parse {
91                path: format!("{path} (layer `{source_name}`)"),
92                format: "YAML",
93                message: e.to_string(),
94            })
95        })
96    }
97
98    /// Same as [`Self::load_merged_yaml`] but for JSON input.
99    pub fn load_merged_json<T: DeserializeOwned>(&self, path: &str) -> Result<T, AssetError> {
100        self.load_merged(path, "JSON", |bytes, source_name| {
101            serde_json::from_slice::<serde_json::Value>(bytes).map_err(|e| AssetError::Parse {
102                path: format!("{path} (layer `{source_name}`)"),
103                format: "JSON",
104                message: e.to_string(),
105            })
106        })
107    }
108
109    fn load_merged<T, F>(&self, path: &str, format: &'static str, parse: F) -> Result<T, AssetError>
110    where
111        T: DeserializeOwned,
112        F: Fn(&[u8], &str) -> Result<serde_json::Value, AssetError>,
113    {
114        let mut merged: Option<serde_json::Value> = None;
115        for layer in self.layers.iter() {
116            let Some(bytes) = layer.read(path) else { continue };
117            let parsed = parse(&bytes, layer.name())?;
118            merged = Some(match merged {
119                None => parsed,
120                Some(mut acc) => {
121                    json_patch::merge(&mut acc, &parsed);
122                    acc
123                }
124            });
125        }
126        let merged = merged.ok_or_else(|| AssetError::NotFound(path.to_string()))?;
127        serde_json::from_value::<T>(merged).map_err(|e| AssetError::Parse {
128            path: path.to_string(),
129            format,
130            message: e.to_string(),
131        })
132    }
133}
134
135/// Fluent builder for [`Assets`]. Sources are appended in registration
136/// order; later registrations have higher precedence.
137#[derive(Default)]
138#[must_use]
139pub struct AssetsBuilder {
140    layers: Vec<Arc<dyn AssetSource>>,
141}
142
143impl std::fmt::Debug for AssetsBuilder {
144    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145        let names: Vec<&str> = self.layers.iter().map(|l| l.name()).collect();
146        f.debug_struct("AssetsBuilder").field("layers", &names).finish()
147    }
148}
149
150impl std::fmt::Debug for Assets {
151    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152        let names: Vec<&str> = self.layers.iter().map(|l| l.name()).collect();
153        f.debug_struct("Assets").field("layers", &names).finish()
154    }
155}
156
157impl AssetsBuilder {
158    /// Construct an empty builder. Equivalent to [`Assets::builder`].
159    pub fn new() -> Self {
160        Self::default()
161    }
162
163    /// Append a `rust-embed` layer. Use turbofish:
164    ///
165    /// ```ignore
166    /// #[derive(rust_embed::RustEmbed)]
167    /// #[folder = "assets/"]
168    /// struct MyEmbed;
169    ///
170    /// let assets = rtb_assets::Assets::builder()
171    ///     .embedded::<MyEmbed>("default")
172    ///     .build();
173    /// ```
174    ///
175    /// `label` is used only in diagnostics.
176    pub fn embedded<E>(mut self, label: &'static str) -> Self
177    where
178        E: RustEmbed + Send + Sync + 'static,
179    {
180        self.layers.push(Arc::new(EmbeddedSource::<E>::new(label)));
181        self
182    }
183
184    /// Append a filesystem-directory layer.
185    pub fn directory(mut self, root: impl Into<PathBuf>, label: impl Into<String>) -> Self {
186        self.layers.push(Arc::new(DirectorySource::new(root, label)));
187        self
188    }
189
190    /// Append an in-memory layer. Primarily useful in tests.
191    pub fn memory(mut self, label: impl Into<String>, files: HashMap<String, Vec<u8>>) -> Self {
192        self.layers.push(Arc::new(MemorySource::new(label, files)));
193        self
194    }
195
196    /// Append an arbitrary [`AssetSource`] layer for exotic cases
197    /// (HTTP overlays, in-process archives, …).
198    pub fn source(mut self, source: Arc<dyn AssetSource>) -> Self {
199        self.layers.push(source);
200        self
201    }
202
203    /// Finalise the builder.
204    #[must_use]
205    pub fn build(self) -> Assets {
206        Assets { layers: self.layers.into() }
207    }
208}