coil-assets 0.1.0

Asset publishing and delivery primitives for the Coil framework.
Documentation
use std::collections::BTreeMap;
use std::fs;
use std::fs::File;
use std::io::{BufReader, Read};
use std::path::{Path, PathBuf};

use sha2::{Digest, Sha256};

use crate::{
    ActiveAssetManifest, AssetModelError, ContentFingerprint, DeploymentArtifact,
    DeploymentRelease, FingerprintAlgorithm, ReleaseId,
};
use coil_storage::{StorageExecutor, StoragePlanner, StorageWriteReceipt};

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ThemeAssetSource {
    source_path: PathBuf,
    artifact: DeploymentArtifact,
}

impl ThemeAssetSource {
    pub fn source_path(&self) -> &Path {
        &self.source_path
    }

    pub fn artifact(&self) -> &DeploymentArtifact {
        &self.artifact
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ThemeAssetPublicationPlan {
    release: DeploymentRelease,
    sources: BTreeMap<String, ThemeAssetSource>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ThemeAssetPublicationReceipt {
    manifest: ActiveAssetManifest,
    writes: Vec<StorageWriteReceipt>,
}

impl ThemeAssetPublicationReceipt {
    pub fn manifest(&self) -> &ActiveAssetManifest {
        &self.manifest
    }

    pub fn writes(&self) -> &[StorageWriteReceipt] {
        &self.writes
    }
}

impl ThemeAssetPublicationPlan {
    pub fn from_roots<I, S, P>(
        release_id: ReleaseId,
        app_root: P,
        roots: I,
    ) -> Result<Self, AssetModelError>
    where
        I: IntoIterator<Item = S>,
        S: AsRef<str>,
        P: AsRef<Path>,
    {
        let app_root = app_root.as_ref();
        let mut sources = Vec::new();

        for root in roots {
            collect_root_assets(app_root, root.as_ref(), &mut sources)?;
        }

        sources.sort_by(|left, right| {
            left.artifact
                .logical_path()
                .cmp(right.artifact.logical_path())
        });

        let release = DeploymentRelease::new(
            release_id,
            sources
                .iter()
                .map(|source| source.artifact().clone())
                .collect::<Vec<_>>(),
        )?;
        let sources = sources
            .into_iter()
            .map(|source| (source.artifact.logical_path().to_string(), source))
            .collect::<BTreeMap<_, _>>();

        Ok(Self { release, sources })
    }

    pub fn release(&self) -> &DeploymentRelease {
        &self.release
    }

    pub fn publish(
        &self,
        planner: &StoragePlanner,
        cdn_base_url: &str,
    ) -> Result<ActiveAssetManifest, AssetModelError> {
        self.release.publish(planner, cdn_base_url)
    }

    pub fn sync(
        &self,
        manifest: &ActiveAssetManifest,
        executor: &StorageExecutor,
    ) -> Result<Vec<StorageWriteReceipt>, AssetModelError> {
        let mut writes = Vec::new();

        for (logical_path, published) in manifest.entries() {
            let source = self.sources.get(logical_path).ok_or_else(|| {
                AssetModelError::MissingThemeAssetSource {
                    logical_path: logical_path.to_string(),
                }
            })?;
            let bytes = fs::read(source.source_path()).map_err(|error| {
                AssetModelError::ThemeAssetReadFailed {
                    path: source.source_path().display().to_string(),
                    message: error.to_string(),
                }
            })?;
            let write = executor.execute_write_with_content_type(
                published.delivery().storage_plan(),
                &bytes,
                Some(source.artifact().content_type()),
            )?;
            writes.push(write);
        }

        Ok(writes)
    }

    pub fn publish_and_sync(
        &self,
        planner: &StoragePlanner,
        cdn_base_url: &str,
        executor: &StorageExecutor,
    ) -> Result<ThemeAssetPublicationReceipt, AssetModelError> {
        let manifest = self.publish(planner, cdn_base_url)?;
        let writes = self.sync(&manifest, executor)?;
        Ok(ThemeAssetPublicationReceipt { manifest, writes })
    }
}

fn collect_root_assets(
    app_root: &Path,
    source_root: &str,
    sources: &mut Vec<ThemeAssetSource>,
) -> Result<(), AssetModelError> {
    let source_root_path = app_root.join(source_root);
    if !source_root_path.exists() {
        return Err(AssetModelError::MissingThemeAssetRoot {
            root: source_root.to_string(),
        });
    }

    if !source_root_path.is_dir() {
        return Err(AssetModelError::MissingThemeAssetRoot {
            root: source_root.to_string(),
        });
    }

    collect_directory_assets(app_root, source_root, &source_root_path, sources)
}

fn collect_directory_assets(
    app_root: &Path,
    source_root: &str,
    current_dir: &Path,
    sources: &mut Vec<ThemeAssetSource>,
) -> Result<(), AssetModelError> {
    let mut entries = fs::read_dir(current_dir)
        .map_err(|error| AssetModelError::ThemeAssetReadFailed {
            path: current_dir.display().to_string(),
            message: error.to_string(),
        })?
        .collect::<Result<Vec<_>, _>>()
        .map_err(|error| AssetModelError::ThemeAssetReadFailed {
            path: current_dir.display().to_string(),
            message: error.to_string(),
        })?;
    entries.sort_by_key(|entry| entry.path());

    for entry in entries {
        let path = entry.path();
        let file_type =
            entry
                .file_type()
                .map_err(|error| AssetModelError::ThemeAssetReadFailed {
                    path: path.display().to_string(),
                    message: error.to_string(),
                })?;

        if file_type.is_dir() {
            collect_directory_assets(app_root, source_root, &path, sources)?;
            continue;
        }

        if !file_type.is_file() {
            continue;
        }

        let relative_path = path
            .strip_prefix(app_root.join(source_root))
            .expect("scanned asset path should always share the same source root");
        let relative_path = relative_manifest_path(relative_path);
        let artifact = load_theme_asset_artifact(source_root, &relative_path, &path)?;
        sources.push(ThemeAssetSource {
            source_path: path,
            artifact,
        });
    }

    Ok(())
}

fn load_theme_asset_artifact(
    source_root: &str,
    relative_path: &str,
    path: &Path,
) -> Result<DeploymentArtifact, AssetModelError> {
    let file = File::open(path).map_err(|error| AssetModelError::ThemeAssetReadFailed {
        path: path.display().to_string(),
        message: error.to_string(),
    })?;
    let metadata = file
        .metadata()
        .map_err(|error| AssetModelError::ThemeAssetReadFailed {
            path: path.display().to_string(),
            message: error.to_string(),
        })?;
    let mut reader = BufReader::new(file);
    let mut hasher = Sha256::new();
    let mut buffer = [0u8; 8192];

    loop {
        let read =
            reader
                .read(&mut buffer)
                .map_err(|error| AssetModelError::ThemeAssetReadFailed {
                    path: path.display().to_string(),
                    message: error.to_string(),
                })?;
        if read == 0 {
            break;
        }
        hasher.update(&buffer[..read]);
    }

    let logical_path =
        crate::normalize_manifest_path("logical_path", format!("{source_root}/{relative_path}"))?;
    let fingerprint = ContentFingerprint::new(
        FingerprintAlgorithm::Sha256,
        format!("{:x}", hasher.finalize()),
    )?;
    let hashed_path = crate::normalize_manifest_path(
        "hashed_path",
        hashed_deployment_path(&logical_path, fingerprint.digest()),
    )?;
    let content_type = content_type_for_path(path);

    DeploymentArtifact::new(
        logical_path,
        hashed_path,
        fingerprint,
        content_type,
        metadata.len(),
    )
}

fn relative_manifest_path(path: &Path) -> String {
    path.components()
        .map(|component| component.as_os_str().to_string_lossy().into_owned())
        .collect::<Vec<_>>()
        .join("/")
}

fn hashed_deployment_path(logical_path: &str, digest: &str) -> String {
    let path = Path::new(logical_path);
    let parent = path
        .parent()
        .map(|parent| parent.to_string_lossy().into_owned())
        .filter(|parent| !parent.is_empty());
    let file_name = path.file_name().unwrap().to_string_lossy();
    let hashed_file_name = match (path.file_stem(), path.extension()) {
        (Some(stem), Some(extension)) => format!(
            "{}.{}.{}",
            stem.to_string_lossy(),
            digest,
            extension.to_string_lossy()
        ),
        _ => format!("{file_name}.{digest}"),
    };

    match parent {
        Some(parent) => format!("deploy/{parent}/{hashed_file_name}"),
        None => format!("deploy/{hashed_file_name}"),
    }
}

fn content_type_for_path(path: &Path) -> &'static str {
    match path
        .extension()
        .and_then(|extension| extension.to_str())
        .map(|extension| extension.to_ascii_lowercase())
        .as_deref()
    {
        Some("css") => "text/css",
        Some("js") | Some("mjs") => "application/javascript",
        Some("json") | Some("map") => "application/json",
        Some("html") | Some("htm") => "text/html",
        Some("svg") => "image/svg+xml",
        Some("png") => "image/png",
        Some("jpg") | Some("jpeg") => "image/jpeg",
        Some("gif") => "image/gif",
        Some("webp") => "image/webp",
        Some("ico") => "image/x-icon",
        Some("woff") => "font/woff",
        Some("woff2") => "font/woff2",
        Some("txt") => "text/plain",
        _ => "application/octet-stream",
    }
}