1use std::path::PathBuf;
18
19use blockworx_doc::{block_model::Asset, hash::AssetHash};
20use serde::{Deserialize, Serialize};
21
22#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
26pub enum AssetFormat {
27 #[serde(rename = "svg")]
28 Svg,
29 #[serde(rename = "png")]
30 Png,
31}
32
33impl AssetFormat {
34 pub const ALL: [Self; 2] = [Self::Svg, Self::Png];
38
39 pub fn of(asset: &Asset) -> Self {
40 match asset {
41 Asset::Svg(_) => Self::Svg,
42 Asset::Png(_) => Self::Png,
43 }
44 }
45
46 pub fn extension(self) -> &'static str {
47 match self {
48 Self::Svg => "svg",
49 Self::Png => "png",
50 }
51 }
52
53 fn asset(self, bytes: Vec<u8>) -> Asset {
54 match self {
55 Self::Svg => Asset::Svg(bytes.into()),
56 Self::Png => Asset::Png(bytes.into()),
57 }
58 }
59
60 pub fn file_name(self, hash: AssetHash) -> String {
62 format!("{hash}.{}", self.extension())
63 }
64}
65
66pub trait AssetSource {
70 fn names(&self, hash: AssetHash) -> PathBuf;
73
74 fn find(&self, hash: AssetHash) -> std::io::Result<(AssetFormat, Vec<u8>)>;
81}
82
83pub trait AssetSink {
86 fn put(&mut self, hash: AssetHash, asset: &Asset) -> std::io::Result<()>;
91}
92
93#[derive(Debug)]
95pub enum Miss {
96 Unreadable(std::io::Error),
97 Corrupt {
101 found: AssetHash,
102 },
103}
104
105#[derive(Debug, thiserror::Error)]
107pub struct AssetFault {
108 pub hash: AssetHash,
109 pub path: PathBuf,
110 pub why: Miss,
111}
112
113impl std::fmt::Display for AssetFault {
114 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115 let path = self.path.display();
116 match &self.why {
117 Miss::Unreadable(error) => write!(
118 f,
119 "the document places artwork the container no longer holds: {path} ({error})",
120 ),
121 Miss::Corrupt { found } => write!(
122 f,
123 "the document places artwork addressed as {named}, but {path} holds bytes that \
124 hash to {found} — the payload has been replaced",
125 named = self.hash,
126 ),
127 }
128 }
129}
130
131pub fn hydrate(source: &dyn AssetSource, hash: AssetHash) -> Result<Asset, AssetFault> {
136 let fault = |why| AssetFault {
137 hash,
138 path: source.names(hash),
139 why,
140 };
141 let (format, bytes) = source.find(hash).map_err(|e| fault(Miss::Unreadable(e)))?;
142 let found = AssetHash::of(&bytes);
143 if found != hash {
144 return Err(fault(Miss::Corrupt { found }));
145 }
146 Ok(format.asset(bytes))
147}
148
149#[derive(Default, Debug)]
152pub struct Held(blockworx_doc::hash::HashedMap<blockworx_doc::hash::AssetKind, Asset>);
153
154impl AssetSource for Held {
155 fn names(&self, hash: AssetHash) -> PathBuf {
156 PathBuf::from(format!("{hash} in this session"))
157 }
158
159 fn find(&self, hash: AssetHash) -> std::io::Result<(AssetFormat, Vec<u8>)> {
160 self.0
161 .get(&hash)
162 .map(|asset| (AssetFormat::of(asset), asset.bytes().to_vec()))
163 .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::NotFound))
164 }
165}
166
167impl AssetSink for Held {
168 fn put(&mut self, hash: AssetHash, asset: &Asset) -> std::io::Result<()> {
169 self.0.entry(hash).or_insert_with(|| asset.clone());
170 Ok(())
171 }
172}
173
174#[cfg(not(target_arch = "wasm32"))]
175pub use native::Dir;
176
177#[cfg(not(target_arch = "wasm32"))]
178mod native {
179 use super::{Asset, AssetFormat, AssetHash, AssetSink, AssetSource, PathBuf};
180 use crate::store::container::ASSETS;
181 use std::fs::{File, OpenOptions};
182 use std::io::Write as _;
183 use std::path::Path;
184
185 pub struct Dir(PathBuf);
187
188 impl Dir {
189 pub fn at(root: &Path) -> Self {
190 Self(root.join(ASSETS))
191 }
192
193 fn path(&self, hash: AssetHash, format: AssetFormat) -> PathBuf {
194 self.0.join(format.file_name(hash))
195 }
196 }
197
198 impl AssetSource for Dir {
199 fn names(&self, hash: AssetHash) -> PathBuf {
200 self.0.join(hash.to_string())
201 }
202
203 fn find(&self, hash: AssetHash) -> std::io::Result<(AssetFormat, Vec<u8>)> {
204 let mut missing = std::io::Error::from(std::io::ErrorKind::NotFound);
205 for format in AssetFormat::ALL {
206 match std::fs::read(self.path(hash, format)) {
207 Ok(bytes) => return Ok((format, bytes)),
208 Err(error) => missing = error,
209 }
210 }
211 Err(missing)
212 }
213 }
214
215 impl AssetSink for Dir {
216 fn put(&mut self, hash: AssetHash, asset: &Asset) -> std::io::Result<()> {
221 std::fs::create_dir_all(&self.0)?;
222 let path = self.path(hash, AssetFormat::of(asset));
223 let mut file = match OpenOptions::new().write(true).create_new(true).open(path) {
224 Ok(file) => file,
225 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => return Ok(()),
226 Err(error) => return Err(error),
227 };
228 file.write_all(asset.bytes())?;
229 file.sync_all()?;
230 sync_dir(&self.0)
231 }
232 }
233
234 #[cfg(unix)]
235 fn sync_dir(dir: &Path) -> std::io::Result<()> {
236 File::open(dir)?.sync_all()
237 }
238
239 #[cfg(not(unix))]
240 fn sync_dir(_dir: &Path) -> std::io::Result<()> {
241 Ok(())
242 }
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248 use blockworx_doc::block_model::Asset;
249
250 #[test]
253 fn a_payload_is_filed_under_its_hash_and_its_format() {
254 let asset = Asset::Svg(b"<svg/>".as_slice().into());
255 assert_eq!(AssetFormat::of(&asset), AssetFormat::Svg);
256 assert_eq!(
257 AssetFormat::Svg.file_name(asset.hash()),
258 format!("{}.svg", asset.hash()),
259 );
260 assert_eq!(
261 serde_json::to_string(&AssetFormat::Svg).expect("it serializes"),
262 "\"svg\"",
263 );
264 assert_eq!(
265 AssetFormat::of(&Asset::Png(b"".as_slice().into())).extension(),
266 "png",
267 );
268 }
269
270 #[test]
273 fn a_payload_comes_back_by_hash_and_is_checked_against_it() {
274 let asset = Asset::Png(b"artwork".as_slice().into());
275 let mut store = Held::default();
276 store.put(asset.hash(), &asset).expect("it is held");
277
278 assert_eq!(hydrate(&store, asset.hash()).expect("it comes back"), asset);
279 assert!(matches!(
280 hydrate(&store, Asset::Svg(b"<svg/>".as_slice().into()).hash()),
281 Err(AssetFault {
282 why: Miss::Unreadable(_),
283 ..
284 }),
285 ));
286 }
287}