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}