Skip to main content

typub_storage/
deferred.rs

1//! Deferred asset types for pipeline stages.
2//!
3//! Per [[RFC-0004:C-PIPELINE-INTEGRATION]].
4
5use std::collections::HashMap;
6use std::path::{Path, PathBuf};
7use typub_core::AssetStrategy;
8
9/// A pending asset reference for deferred upload.
10/// Per [[RFC-0004:C-PIPELINE-INTEGRATION]].
11#[derive(Debug, Clone)]
12pub struct PendingAsset {
13    /// Zero-based index for placeholder token
14    pub index: usize,
15    /// Absolute path to the local asset file
16    pub local_path: PathBuf,
17    /// Original relative path (as referenced in content)
18    pub original_ref: String,
19}
20
21impl PendingAsset {
22    /// Generate the placeholder token for this asset.
23    /// Format: `{{ASSET:<index>}}`
24    pub fn placeholder(&self) -> String {
25        format!("{{{{ASSET:{}}}}}", self.index)
26    }
27}
28
29/// Result of building pending assets for deferred upload.
30#[derive(Debug, Clone)]
31pub struct PendingAssetList {
32    /// List of pending assets
33    pub assets: Vec<PendingAsset>,
34}
35
36impl PendingAssetList {
37    /// Create a new empty pending asset list.
38    pub fn new() -> Self {
39        Self { assets: Vec::new() }
40    }
41
42    /// Get the placeholder token for an asset by its original reference.
43    pub fn placeholder_for(&self, original_ref: &str) -> Option<String> {
44        self.assets
45            .iter()
46            .find(|a| a.original_ref == original_ref)
47            .map(|a| a.placeholder())
48    }
49}
50
51impl Default for PendingAssetList {
52    fn default() -> Self {
53        Self::new()
54    }
55}
56
57/// Deferred asset context carried through pipeline stages.
58///
59/// This is the "asset layer" that wraps any platform-specific payload.
60/// Per [[RFC-0004:C-PIPELINE-INTEGRATION]] v0.2.0.
61///
62/// # Pipeline Flow
63///
64/// - **Stage 4 (Finalize)**: Created with `pending` assets and `strategy`
65/// - **Stage 6 (Materialize)**: `resolved` is filled with uploaded URLs
66/// - **Stage 7 (Publish)**: Content with placeholders replaced by resolved URLs
67#[derive(Debug, Clone)]
68pub struct DeferredAssets {
69    /// Assets pending upload during Materialize stage
70    pub pending: PendingAssetList,
71    /// Strategy for this batch
72    pub strategy: AssetStrategy,
73    /// Resolved URLs after Materialize (index → remote URL)
74    pub resolved: HashMap<usize, String>,
75}
76
77impl DeferredAssets {
78    /// Create a new DeferredAssets with pending assets and strategy.
79    pub fn new(pending: PendingAssetList, strategy: AssetStrategy) -> Self {
80        Self {
81            pending,
82            strategy,
83            resolved: HashMap::new(),
84        }
85    }
86
87    /// Create an empty DeferredAssets (for adapters that don't use deferred upload).
88    pub fn empty() -> Self {
89        Self::new(PendingAssetList::new(), AssetStrategy::Copy)
90    }
91
92    /// Returns true if this payload needs asset materialization.
93    pub fn needs_materialize(&self) -> bool {
94        self.strategy.requires_deferred_upload() && !self.pending.assets.is_empty()
95    }
96
97    /// Check if all pending assets have been resolved.
98    pub fn is_resolved(&self) -> bool {
99        self.pending.assets.len() == self.resolved.len()
100    }
101}
102
103impl Default for DeferredAssets {
104    fn default() -> Self {
105        Self::empty()
106    }
107}
108
109/// Build a list of pending assets for deferred upload.
110///
111/// This function collects all assets from the content and creates a `PendingAssetList`
112/// with placeholder tokens. The returned list maps each asset to a unique index,
113/// which can be used to generate `{{ASSET:N}}` placeholder tokens.
114///
115/// # Arguments
116///
117/// * `assets` - List of asset paths from content
118/// * `content_path` - Base path of the content directory (for resolving relative paths)
119///
120/// # Returns
121///
122/// A `PendingAssetList` containing all assets with their indices and resolved paths.
123pub fn build_pending_asset_list(assets: &[PathBuf], content_path: &Path) -> PendingAssetList {
124    let mut pending = Vec::with_capacity(assets.len());
125
126    for (index, asset) in assets.iter().enumerate() {
127        // Resolve absolute path
128        let local_path = if asset.is_absolute() {
129            asset.clone()
130        } else {
131            content_path.join(asset)
132        };
133
134        // Compute original reference (relative path as string)
135        let original_ref = if let Ok(rel) = asset.strip_prefix(content_path) {
136            rel.to_string_lossy().replace('\\', "/")
137        } else if asset.is_relative() {
138            asset.to_string_lossy().replace('\\', "/")
139        } else {
140            local_path
141                .file_name()
142                .map(|n| n.to_string_lossy().to_string())
143                .unwrap_or_else(|| asset.to_string_lossy().to_string())
144        };
145
146        pending.push(PendingAsset {
147            index,
148            local_path,
149            original_ref,
150        });
151    }
152
153    PendingAssetList { assets: pending }
154}
155
156#[cfg(test)]
157mod tests {
158    #![allow(clippy::expect_used)]
159    use super::*;
160
161    #[test]
162    fn test_pending_asset_placeholder() {
163        let asset = PendingAsset {
164            index: 0,
165            local_path: PathBuf::from("/tmp/image.png"),
166            original_ref: "assets/image.png".to_string(),
167        };
168        assert_eq!(asset.placeholder(), "{{ASSET:0}}");
169
170        let asset2 = PendingAsset {
171            index: 42,
172            local_path: PathBuf::from("/tmp/photo.jpg"),
173            original_ref: "photo.jpg".to_string(),
174        };
175        assert_eq!(asset2.placeholder(), "{{ASSET:42}}");
176    }
177
178    #[test]
179    fn test_pending_asset_list_placeholder_for() {
180        let list = PendingAssetList {
181            assets: vec![
182                PendingAsset {
183                    index: 0,
184                    local_path: PathBuf::from("/tmp/a.png"),
185                    original_ref: "a.png".to_string(),
186                },
187                PendingAsset {
188                    index: 1,
189                    local_path: PathBuf::from("/tmp/b.jpg"),
190                    original_ref: "b.jpg".to_string(),
191                },
192            ],
193        };
194        assert_eq!(
195            list.placeholder_for("a.png"),
196            Some("{{ASSET:0}}".to_string())
197        );
198        assert_eq!(
199            list.placeholder_for("b.jpg"),
200            Some("{{ASSET:1}}".to_string())
201        );
202        assert_eq!(list.placeholder_for("c.gif"), None);
203    }
204
205    #[test]
206    fn test_deferred_assets_empty() {
207        let da = DeferredAssets::empty();
208        assert!(da.pending.assets.is_empty());
209        assert_eq!(da.strategy, AssetStrategy::Copy);
210        assert!(da.resolved.is_empty());
211        assert!(!da.needs_materialize());
212        assert!(da.is_resolved()); // empty pending = resolved
213    }
214
215    #[test]
216    fn test_deferred_assets_needs_materialize() {
217        let pending = PendingAssetList {
218            assets: vec![PendingAsset {
219                index: 0,
220                local_path: PathBuf::from("/tmp/a.png"),
221                original_ref: "a.png".to_string(),
222            }],
223        };
224
225        // External strategy with pending assets -> needs materialize
226        let da_external = DeferredAssets::new(pending.clone(), AssetStrategy::External);
227        assert!(da_external.needs_materialize());
228
229        // Upload strategy with pending assets -> needs materialize
230        let da_upload = DeferredAssets::new(pending.clone(), AssetStrategy::Upload);
231        assert!(da_upload.needs_materialize());
232
233        // Copy strategy with pending assets -> no materialize
234        let da_copy = DeferredAssets::new(pending.clone(), AssetStrategy::Copy);
235        assert!(!da_copy.needs_materialize());
236
237        // Embed strategy with pending assets -> no materialize
238        let da_embed = DeferredAssets::new(pending, AssetStrategy::Embed);
239        assert!(!da_embed.needs_materialize());
240    }
241
242    #[test]
243    fn test_deferred_assets_is_resolved() {
244        let pending = PendingAssetList {
245            assets: vec![
246                PendingAsset {
247                    index: 0,
248                    local_path: PathBuf::from("/tmp/a.png"),
249                    original_ref: "a.png".to_string(),
250                },
251                PendingAsset {
252                    index: 1,
253                    local_path: PathBuf::from("/tmp/b.jpg"),
254                    original_ref: "b.jpg".to_string(),
255                },
256            ],
257        };
258
259        let mut da = DeferredAssets::new(pending, AssetStrategy::External);
260        assert!(!da.is_resolved());
261
262        da.resolved
263            .insert(0, "https://cdn.example.com/a.png".to_string());
264        assert!(!da.is_resolved());
265
266        da.resolved
267            .insert(1, "https://cdn.example.com/b.jpg".to_string());
268        assert!(da.is_resolved());
269    }
270
271    #[test]
272    fn test_build_pending_asset_list() {
273        let content_path = PathBuf::from("/project/content/my-post");
274        let assets = vec![
275            PathBuf::from("image.png"),
276            PathBuf::from("./assets/photo.jpg"),
277        ];
278
279        let list = build_pending_asset_list(&assets, &content_path);
280        assert_eq!(list.assets.len(), 2);
281
282        assert_eq!(list.assets[0].index, 0);
283        assert_eq!(
284            list.assets[0].local_path,
285            PathBuf::from("/project/content/my-post/image.png")
286        );
287        assert_eq!(list.assets[0].original_ref, "image.png");
288
289        assert_eq!(list.assets[1].index, 1);
290        assert_eq!(
291            list.assets[1].local_path,
292            PathBuf::from("/project/content/my-post/./assets/photo.jpg")
293        );
294        assert_eq!(list.assets[1].original_ref, "./assets/photo.jpg");
295    }
296}