Skip to main content

cdx_core/asset/
index.rs

1//! Asset index types for managing collections of assets.
2
3use serde::{Deserialize, Serialize};
4
5use super::{FontAsset, ImageAsset};
6use crate::DocumentId;
7
8/// An asset index file structure.
9///
10/// This represents files like `assets/images/index.json` or `assets/fonts/index.json`.
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12#[serde(rename_all = "camelCase")]
13pub struct AssetIndex<T> {
14    /// Format version.
15    pub version: String,
16
17    /// Total count of assets.
18    pub count: u32,
19
20    /// Total size of all assets in bytes.
21    pub total_size: u64,
22
23    /// Array of asset entries.
24    pub assets: Vec<T>,
25}
26
27impl<T> AssetIndex<T> {
28    /// Create a new empty asset index.
29    #[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    /// Add an asset to the index.
40    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    /// Check if the index is empty.
47    #[must_use]
48    pub fn is_empty(&self) -> bool {
49        self.assets.is_empty()
50    }
51
52    /// Get the number of assets.
53    #[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
65/// Type alias for image asset index.
66pub type ImageIndex = AssetIndex<ImageAsset>;
67
68/// Type alias for font asset index.
69pub type FontIndex = AssetIndex<FontAsset>;
70
71/// Type alias for embed asset index.
72pub type EmbedIndex = AssetIndex<EmbedAsset>;
73
74/// An embedded file asset.
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(rename_all = "camelCase")]
77pub struct EmbedAsset {
78    /// Unique identifier for the embed.
79    pub id: String,
80
81    /// Path within the archive (e.g., "assets/embeds/data.csv").
82    pub path: String,
83
84    /// Content hash for verification.
85    pub hash: DocumentId,
86
87    /// File size in bytes.
88    pub size: u64,
89
90    /// MIME type of the embedded file.
91    pub mime_type: String,
92
93    /// Original filename (if known).
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub filename: Option<String>,
96
97    /// Description of the embedded file.
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub description: Option<String>,
100
101    /// Whether the embed should be displayed inline.
102    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
103    pub inline: bool,
104}
105
106impl EmbedAsset {
107    /// Create a new embed asset.
108    #[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    /// Set the content hash.
125    #[must_use]
126    pub fn with_hash(mut self, hash: DocumentId) -> Self {
127        self.hash = hash;
128        self
129    }
130
131    /// Set the file size.
132    #[must_use]
133    pub const fn with_size(mut self, size: u64) -> Self {
134        self.size = size;
135        self
136    }
137
138    /// Set the original filename.
139    #[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    /// Set the description.
146    #[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    /// Set whether the embed should be displayed inline.
153    #[must_use]
154    pub const fn with_inline(mut self, inline: bool) -> Self {
155        self.inline = inline;
156        self
157    }
158
159    /// Set a custom path.
160    #[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/// Generic asset entry that can represent any asset type.
190#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
191#[serde(tag = "type", rename_all = "camelCase")]
192pub enum AssetEntry {
193    /// Image asset.
194    Image(ImageAsset),
195    /// Font asset.
196    Font(FontAsset),
197    /// Embedded file asset.
198    Embed(EmbedAsset),
199    /// Alias to another asset (for deduplication).
200    ///
201    /// When two assets have identical content (same hash), one can be stored
202    /// as an alias pointing to the canonical asset. This saves storage space
203    /// while maintaining separate logical identities.
204    Alias(AssetAlias),
205}
206
207/// An alias entry that references another asset.
208///
209/// Used for deduplication: when the same content is referenced by multiple
210/// logical assets, only one copy is stored and others become aliases.
211#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
212#[serde(rename_all = "camelCase")]
213pub struct AssetAlias {
214    /// Unique identifier for this alias.
215    pub id: String,
216
217    /// ID of the canonical asset this aliases to.
218    pub alias_of: String,
219
220    /// The hash of the content (same as the canonical asset).
221    pub hash: DocumentId,
222}
223
224impl AssetEntry {
225    /// Get the asset ID.
226    #[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    /// Get the asset path.
237    ///
238    /// For aliases, this returns an empty string as aliases don't have their own path.
239    /// Use `resolve_path` with the asset index to get the canonical asset's path.
240    #[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    /// Get the asset hash.
251    #[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    /// Get the asset size.
262    ///
263    /// For aliases, this returns 0 as no additional storage is used.
264    #[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    /// Check if this entry is an alias.
275    #[must_use]
276    pub fn is_alias(&self) -> bool {
277        matches!(self, Self::Alias(_))
278    }
279
280    /// Get the canonical asset ID if this is an alias.
281    #[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    /// Create a new asset alias.
292    #[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); // Aliases don't add storage
397        assert_eq!(alias.path(), ""); // Aliases don't have their own path
398    }
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}