Skip to main content

coil_assets/
theme.rs

1use std::collections::BTreeMap;
2use std::fs;
3use std::fs::File;
4use std::io::{BufReader, Read};
5use std::path::{Path, PathBuf};
6
7use sha2::{Digest, Sha256};
8
9use crate::{
10    ActiveAssetManifest, AssetModelError, ContentFingerprint, DeploymentArtifact,
11    DeploymentRelease, FingerprintAlgorithm, ReleaseId,
12};
13use coil_storage::{StorageExecutor, StoragePlanner, StorageWriteReceipt};
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct ThemeAssetSource {
17    source_path: PathBuf,
18    artifact: DeploymentArtifact,
19}
20
21impl ThemeAssetSource {
22    pub fn source_path(&self) -> &Path {
23        &self.source_path
24    }
25
26    pub fn artifact(&self) -> &DeploymentArtifact {
27        &self.artifact
28    }
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct ThemeAssetPublicationPlan {
33    release: DeploymentRelease,
34    sources: BTreeMap<String, ThemeAssetSource>,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct ThemeAssetPublicationReceipt {
39    manifest: ActiveAssetManifest,
40    writes: Vec<StorageWriteReceipt>,
41}
42
43impl ThemeAssetPublicationReceipt {
44    pub fn manifest(&self) -> &ActiveAssetManifest {
45        &self.manifest
46    }
47
48    pub fn writes(&self) -> &[StorageWriteReceipt] {
49        &self.writes
50    }
51}
52
53impl ThemeAssetPublicationPlan {
54    pub fn from_roots<I, S, P>(
55        release_id: ReleaseId,
56        app_root: P,
57        roots: I,
58    ) -> Result<Self, AssetModelError>
59    where
60        I: IntoIterator<Item = S>,
61        S: AsRef<str>,
62        P: AsRef<Path>,
63    {
64        let app_root = app_root.as_ref();
65        let mut sources = Vec::new();
66
67        for root in roots {
68            collect_root_assets(app_root, root.as_ref(), &mut sources)?;
69        }
70
71        sources.sort_by(|left, right| {
72            left.artifact
73                .logical_path()
74                .cmp(right.artifact.logical_path())
75        });
76
77        let release = DeploymentRelease::new(
78            release_id,
79            sources
80                .iter()
81                .map(|source| source.artifact().clone())
82                .collect::<Vec<_>>(),
83        )?;
84        let sources = sources
85            .into_iter()
86            .map(|source| (source.artifact.logical_path().to_string(), source))
87            .collect::<BTreeMap<_, _>>();
88
89        Ok(Self { release, sources })
90    }
91
92    pub fn release(&self) -> &DeploymentRelease {
93        &self.release
94    }
95
96    pub fn publish(
97        &self,
98        planner: &StoragePlanner,
99        cdn_base_url: &str,
100    ) -> Result<ActiveAssetManifest, AssetModelError> {
101        self.release.publish(planner, cdn_base_url)
102    }
103
104    pub fn sync(
105        &self,
106        manifest: &ActiveAssetManifest,
107        executor: &StorageExecutor,
108    ) -> Result<Vec<StorageWriteReceipt>, AssetModelError> {
109        let mut writes = Vec::new();
110
111        for (logical_path, published) in manifest.entries() {
112            let source = self.sources.get(logical_path).ok_or_else(|| {
113                AssetModelError::MissingThemeAssetSource {
114                    logical_path: logical_path.to_string(),
115                }
116            })?;
117            let bytes = fs::read(source.source_path()).map_err(|error| {
118                AssetModelError::ThemeAssetReadFailed {
119                    path: source.source_path().display().to_string(),
120                    message: error.to_string(),
121                }
122            })?;
123            let write = executor.execute_write_with_content_type(
124                published.delivery().storage_plan(),
125                &bytes,
126                Some(source.artifact().content_type()),
127            )?;
128            writes.push(write);
129        }
130
131        Ok(writes)
132    }
133
134    pub fn publish_and_sync(
135        &self,
136        planner: &StoragePlanner,
137        cdn_base_url: &str,
138        executor: &StorageExecutor,
139    ) -> Result<ThemeAssetPublicationReceipt, AssetModelError> {
140        let manifest = self.publish(planner, cdn_base_url)?;
141        let writes = self.sync(&manifest, executor)?;
142        Ok(ThemeAssetPublicationReceipt { manifest, writes })
143    }
144}
145
146fn collect_root_assets(
147    app_root: &Path,
148    source_root: &str,
149    sources: &mut Vec<ThemeAssetSource>,
150) -> Result<(), AssetModelError> {
151    let source_root_path = app_root.join(source_root);
152    if !source_root_path.exists() {
153        return Err(AssetModelError::MissingThemeAssetRoot {
154            root: source_root.to_string(),
155        });
156    }
157
158    if !source_root_path.is_dir() {
159        return Err(AssetModelError::MissingThemeAssetRoot {
160            root: source_root.to_string(),
161        });
162    }
163
164    collect_directory_assets(app_root, source_root, &source_root_path, sources)
165}
166
167fn collect_directory_assets(
168    app_root: &Path,
169    source_root: &str,
170    current_dir: &Path,
171    sources: &mut Vec<ThemeAssetSource>,
172) -> Result<(), AssetModelError> {
173    let mut entries = fs::read_dir(current_dir)
174        .map_err(|error| AssetModelError::ThemeAssetReadFailed {
175            path: current_dir.display().to_string(),
176            message: error.to_string(),
177        })?
178        .collect::<Result<Vec<_>, _>>()
179        .map_err(|error| AssetModelError::ThemeAssetReadFailed {
180            path: current_dir.display().to_string(),
181            message: error.to_string(),
182        })?;
183    entries.sort_by_key(|entry| entry.path());
184
185    for entry in entries {
186        let path = entry.path();
187        let file_type =
188            entry
189                .file_type()
190                .map_err(|error| AssetModelError::ThemeAssetReadFailed {
191                    path: path.display().to_string(),
192                    message: error.to_string(),
193                })?;
194
195        if file_type.is_dir() {
196            collect_directory_assets(app_root, source_root, &path, sources)?;
197            continue;
198        }
199
200        if !file_type.is_file() {
201            continue;
202        }
203
204        let relative_path = path
205            .strip_prefix(app_root.join(source_root))
206            .expect("scanned asset path should always share the same source root");
207        let relative_path = relative_manifest_path(relative_path);
208        let artifact = load_theme_asset_artifact(source_root, &relative_path, &path)?;
209        sources.push(ThemeAssetSource {
210            source_path: path,
211            artifact,
212        });
213    }
214
215    Ok(())
216}
217
218fn load_theme_asset_artifact(
219    source_root: &str,
220    relative_path: &str,
221    path: &Path,
222) -> Result<DeploymentArtifact, AssetModelError> {
223    let file = File::open(path).map_err(|error| AssetModelError::ThemeAssetReadFailed {
224        path: path.display().to_string(),
225        message: error.to_string(),
226    })?;
227    let metadata = file
228        .metadata()
229        .map_err(|error| AssetModelError::ThemeAssetReadFailed {
230            path: path.display().to_string(),
231            message: error.to_string(),
232        })?;
233    let mut reader = BufReader::new(file);
234    let mut hasher = Sha256::new();
235    let mut buffer = [0u8; 8192];
236
237    loop {
238        let read =
239            reader
240                .read(&mut buffer)
241                .map_err(|error| AssetModelError::ThemeAssetReadFailed {
242                    path: path.display().to_string(),
243                    message: error.to_string(),
244                })?;
245        if read == 0 {
246            break;
247        }
248        hasher.update(&buffer[..read]);
249    }
250
251    let logical_path =
252        crate::normalize_manifest_path("logical_path", format!("{source_root}/{relative_path}"))?;
253    let fingerprint = ContentFingerprint::new(
254        FingerprintAlgorithm::Sha256,
255        format!("{:x}", hasher.finalize()),
256    )?;
257    let hashed_path = crate::normalize_manifest_path(
258        "hashed_path",
259        hashed_deployment_path(&logical_path, fingerprint.digest()),
260    )?;
261    let content_type = content_type_for_path(path);
262
263    DeploymentArtifact::new(
264        logical_path,
265        hashed_path,
266        fingerprint,
267        content_type,
268        metadata.len(),
269    )
270}
271
272fn relative_manifest_path(path: &Path) -> String {
273    path.components()
274        .map(|component| component.as_os_str().to_string_lossy().into_owned())
275        .collect::<Vec<_>>()
276        .join("/")
277}
278
279fn hashed_deployment_path(logical_path: &str, digest: &str) -> String {
280    let path = Path::new(logical_path);
281    let parent = path
282        .parent()
283        .map(|parent| parent.to_string_lossy().into_owned())
284        .filter(|parent| !parent.is_empty());
285    let file_name = path.file_name().unwrap().to_string_lossy();
286    let hashed_file_name = match (path.file_stem(), path.extension()) {
287        (Some(stem), Some(extension)) => format!(
288            "{}.{}.{}",
289            stem.to_string_lossy(),
290            digest,
291            extension.to_string_lossy()
292        ),
293        _ => format!("{file_name}.{digest}"),
294    };
295
296    match parent {
297        Some(parent) => format!("deploy/{parent}/{hashed_file_name}"),
298        None => format!("deploy/{hashed_file_name}"),
299    }
300}
301
302fn content_type_for_path(path: &Path) -> &'static str {
303    match path
304        .extension()
305        .and_then(|extension| extension.to_str())
306        .map(|extension| extension.to_ascii_lowercase())
307        .as_deref()
308    {
309        Some("css") => "text/css",
310        Some("js") | Some("mjs") => "application/javascript",
311        Some("json") | Some("map") => "application/json",
312        Some("html") | Some("htm") => "text/html",
313        Some("svg") => "image/svg+xml",
314        Some("png") => "image/png",
315        Some("jpg") | Some("jpeg") => "image/jpeg",
316        Some("gif") => "image/gif",
317        Some("webp") => "image/webp",
318        Some("ico") => "image/x-icon",
319        Some("woff") => "font/woff",
320        Some("woff2") => "font/woff2",
321        Some("txt") => "text/plain",
322        _ => "application/octet-stream",
323    }
324}