1use serde::{Deserialize, Serialize};
4
5use super::{FontAsset, ImageAsset};
6use crate::DocumentId;
7
8#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12#[serde(rename_all = "camelCase")]
13pub struct AssetIndex<T> {
14 pub version: String,
16
17 pub count: u32,
19
20 pub total_size: u64,
22
23 pub assets: Vec<T>,
25}
26
27impl<T> AssetIndex<T> {
28 #[must_use]
30 pub fn new() -> Self {
31 Self {
32 version: crate::SPEC_VERSION.to_string(),
33 count: 0,
34 total_size: 0,
35 assets: Vec::new(),
36 }
37 }
38
39 pub fn add(&mut self, asset: T, size: u64) {
41 self.assets.push(asset);
42 self.count += 1;
43 self.total_size += size;
44 }
45
46 #[must_use]
48 pub fn is_empty(&self) -> bool {
49 self.assets.is_empty()
50 }
51
52 #[must_use]
54 pub fn len(&self) -> usize {
55 self.assets.len()
56 }
57}
58
59impl<T> Default for AssetIndex<T> {
60 fn default() -> Self {
61 Self::new()
62 }
63}
64
65pub type ImageIndex = AssetIndex<ImageAsset>;
67
68pub type FontIndex = AssetIndex<FontAsset>;
70
71pub type EmbedIndex = AssetIndex<EmbedAsset>;
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(rename_all = "camelCase")]
77pub struct EmbedAsset {
78 pub id: String,
80
81 pub path: String,
83
84 pub hash: DocumentId,
86
87 pub size: u64,
89
90 pub mime_type: String,
92
93 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub filename: Option<String>,
96
97 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub description: Option<String>,
100
101 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
103 pub inline: bool,
104}
105
106impl EmbedAsset {
107 #[must_use]
109 pub fn new(id: impl Into<String>, mime_type: impl Into<String>) -> Self {
110 let id = id.into();
111 let path = format!("assets/embeds/{id}");
112 Self {
113 id,
114 path,
115 hash: DocumentId::pending(),
116 size: 0,
117 mime_type: mime_type.into(),
118 filename: None,
119 description: None,
120 inline: false,
121 }
122 }
123
124 #[must_use]
126 pub fn with_hash(mut self, hash: DocumentId) -> Self {
127 self.hash = hash;
128 self
129 }
130
131 #[must_use]
133 pub const fn with_size(mut self, size: u64) -> Self {
134 self.size = size;
135 self
136 }
137
138 #[must_use]
140 pub fn with_filename(mut self, filename: impl Into<String>) -> Self {
141 self.filename = Some(filename.into());
142 self
143 }
144
145 #[must_use]
147 pub fn with_description(mut self, description: impl Into<String>) -> Self {
148 self.description = Some(description.into());
149 self
150 }
151
152 #[must_use]
154 pub const fn with_inline(mut self, inline: bool) -> Self {
155 self.inline = inline;
156 self
157 }
158
159 #[must_use]
161 pub fn with_path(mut self, path: impl Into<String>) -> Self {
162 self.path = path.into();
163 self
164 }
165}
166
167impl super::Asset for EmbedAsset {
168 fn id(&self) -> &str {
169 &self.id
170 }
171
172 fn path(&self) -> &str {
173 &self.path
174 }
175
176 fn hash(&self) -> &DocumentId {
177 &self.hash
178 }
179
180 fn size(&self) -> u64 {
181 self.size
182 }
183
184 fn mime_type(&self) -> &str {
185 &self.mime_type
186 }
187}
188
189#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
191#[serde(tag = "type", rename_all = "camelCase")]
192pub enum AssetEntry {
193 Image(ImageAsset),
195 Font(FontAsset),
197 Embed(EmbedAsset),
199 Alias(AssetAlias),
205}
206
207#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
212#[serde(rename_all = "camelCase")]
213pub struct AssetAlias {
214 pub id: String,
216
217 pub alias_of: String,
219
220 pub hash: DocumentId,
222}
223
224impl AssetEntry {
225 #[must_use]
227 pub fn id(&self) -> &str {
228 match self {
229 Self::Image(a) => &a.id,
230 Self::Font(a) => &a.id,
231 Self::Embed(a) => &a.id,
232 Self::Alias(a) => &a.id,
233 }
234 }
235
236 #[must_use]
241 pub fn path(&self) -> &str {
242 match self {
243 Self::Image(a) => &a.path,
244 Self::Font(a) => &a.path,
245 Self::Embed(a) => &a.path,
246 Self::Alias(_) => "",
247 }
248 }
249
250 #[must_use]
252 pub fn hash(&self) -> &DocumentId {
253 match self {
254 Self::Image(a) => &a.hash,
255 Self::Font(a) => &a.hash,
256 Self::Embed(a) => &a.hash,
257 Self::Alias(a) => &a.hash,
258 }
259 }
260
261 #[must_use]
265 pub fn size(&self) -> u64 {
266 match self {
267 Self::Image(a) => a.size,
268 Self::Font(a) => a.size,
269 Self::Embed(a) => a.size,
270 Self::Alias(_) => 0,
271 }
272 }
273
274 #[must_use]
276 pub fn is_alias(&self) -> bool {
277 matches!(self, Self::Alias(_))
278 }
279
280 #[must_use]
282 pub fn alias_of(&self) -> Option<&str> {
283 match self {
284 Self::Alias(a) => Some(&a.alias_of),
285 _ => None,
286 }
287 }
288}
289
290impl AssetAlias {
291 #[must_use]
293 pub fn new(id: impl Into<String>, alias_of: impl Into<String>, hash: DocumentId) -> Self {
294 Self {
295 id: id.into(),
296 alias_of: alias_of.into(),
297 hash,
298 }
299 }
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305 use crate::asset::ImageFormat;
306
307 #[test]
308 fn test_asset_index_new() {
309 let index: ImageIndex = AssetIndex::new();
310 assert!(index.is_empty());
311 assert_eq!(index.count, 0);
312 assert_eq!(index.total_size, 0);
313 }
314
315 #[test]
316 fn test_asset_index_add() {
317 let mut index: ImageIndex = AssetIndex::new();
318 let image = ImageAsset::new("test", ImageFormat::Png).with_size(1024);
319 index.add(image, 1024);
320
321 assert_eq!(index.len(), 1);
322 assert_eq!(index.count, 1);
323 assert_eq!(index.total_size, 1024);
324 }
325
326 #[test]
327 fn test_embed_asset_new() {
328 let embed = EmbedAsset::new("data", "text/csv");
329 assert_eq!(embed.id, "data");
330 assert_eq!(embed.mime_type, "text/csv");
331 assert_eq!(embed.path, "assets/embeds/data");
332 }
333
334 #[test]
335 fn test_embed_asset_builder() {
336 let embed = EmbedAsset::new("spreadsheet", "application/vnd.ms-excel")
337 .with_filename("sales.xlsx")
338 .with_description("Quarterly sales data")
339 .with_size(65536)
340 .with_inline(false);
341
342 assert_eq!(embed.filename, Some("sales.xlsx".to_string()));
343 assert_eq!(embed.description, Some("Quarterly sales data".to_string()));
344 assert_eq!(embed.size, 65536);
345 assert!(!embed.inline);
346 }
347
348 #[test]
349 fn test_asset_entry_variants() {
350 let image = AssetEntry::Image(ImageAsset::new("img", ImageFormat::Png));
351 assert_eq!(image.id(), "img");
352
353 let embed = AssetEntry::Embed(EmbedAsset::new("file", "text/plain"));
354 assert_eq!(embed.id(), "file");
355 }
356
357 #[test]
358 fn test_asset_index_serialization() {
359 let mut index: ImageIndex = AssetIndex::new();
360 let image = ImageAsset::new("test", ImageFormat::Png).with_size(1024);
361 index.add(image, 1024);
362
363 let json = serde_json::to_string_pretty(&index).unwrap();
364 assert!(json.contains(r#""count": 1"#));
365 assert!(json.contains(r#""totalSize": 1024"#));
366
367 let deserialized: ImageIndex = serde_json::from_str(&json).unwrap();
368 assert_eq!(deserialized.count, 1);
369 assert_eq!(deserialized.total_size, 1024);
370 }
371
372 #[test]
373 fn test_asset_alias_creation() {
374 let hash: DocumentId =
375 "sha256:abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"
376 .parse()
377 .unwrap();
378 let alias = AssetAlias::new("duplicate-logo", "original-logo", hash.clone());
379
380 assert_eq!(alias.id, "duplicate-logo");
381 assert_eq!(alias.alias_of, "original-logo");
382 assert_eq!(alias.hash, hash);
383 }
384
385 #[test]
386 fn test_asset_entry_alias() {
387 let hash: DocumentId =
388 "sha256:abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"
389 .parse()
390 .unwrap();
391 let alias = AssetEntry::Alias(AssetAlias::new("dup", "orig", hash));
392
393 assert!(alias.is_alias());
394 assert_eq!(alias.alias_of(), Some("orig"));
395 assert_eq!(alias.id(), "dup");
396 assert_eq!(alias.size(), 0); assert_eq!(alias.path(), ""); }
399
400 #[test]
401 fn test_asset_entry_not_alias() {
402 let image = AssetEntry::Image(ImageAsset::new("img", ImageFormat::Png));
403
404 assert!(!image.is_alias());
405 assert_eq!(image.alias_of(), None);
406 }
407
408 #[test]
409 fn test_asset_alias_serialization() {
410 let hash: DocumentId =
411 "sha256:abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"
412 .parse()
413 .unwrap();
414 let alias = AssetEntry::Alias(AssetAlias::new("dup", "orig", hash));
415
416 let json = serde_json::to_string_pretty(&alias).unwrap();
417 assert!(json.contains(r#""type": "alias""#));
418 assert!(json.contains(r#""aliasOf": "orig""#));
419
420 let deserialized: AssetEntry = serde_json::from_str(&json).unwrap();
421 assert!(deserialized.is_alias());
422 assert_eq!(deserialized.alias_of(), Some("orig"));
423 }
424}