rustrails-storage 0.1.2

File storage (ActiveStorage equivalent)
Documentation
#![allow(clippy::module_name_repetitions)]

//! File storage primitives inspired by Rails ActiveStorage.

pub mod analyzer;
pub mod attachment;
pub mod blob;
pub mod direct_upload;
pub mod preview;
pub mod service;
pub mod transformations;
pub mod urls;
pub mod variant;

pub use analyzer::{
    Analysis, AnalyzerError, AnalyzerRegistry, BlobAnalyzer, ImageAnalyzer, MediaAnalyzer,
    TextAnalyzer,
};
pub use attachment::{
    Attachment, AttachmentError, HasManyAttached, HasOneAttached, ManyAttachments, OneAttachment,
    has_many_attached, has_one_attached,
};
pub use blob::{Blob, BlobError};
pub use direct_upload::{
    DirectUploadError, DirectUploadManager, DirectUploadRequest, DirectUploadTokenClaims,
};
pub use preview::{
    BlobPreviewer, PdfPreviewer, Preview, PreviewError, PreviewRegistry, VideoPreviewer,
};
pub use service::{DynStorageService, StorageError, StorageService};
pub use transformations::{CropTransform, ImageTransformations, ResizeTransform};
pub use urls::{SignedResource, SignedUrlError, SignedUrlGenerator};
pub use variant::{Variant, VariantError};

use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use sha2::{Digest, Sha256};

pub(crate) fn sha256_hex(data: impl AsRef<[u8]>) -> String {
    let digest = Sha256::digest(data.as_ref());
    hex_encode(&digest)
}

pub(crate) fn hex_encode(data: &[u8]) -> String {
    let mut output = String::with_capacity(data.len() * 2);
    for byte in data {
        use std::fmt::Write as _;
        let _ = write!(output, "{byte:02x}");
    }
    output
}

pub(crate) fn urlsafe_encode(data: impl AsRef<[u8]>) -> String {
    URL_SAFE_NO_PAD.encode(data.as_ref())
}

pub(crate) fn urlsafe_decode(data: &str) -> Result<Vec<u8>, base64::DecodeError> {
    URL_SAFE_NO_PAD.decode(data)
}

pub(crate) fn detect_content_type(filename: &str, provided: Option<&str>) -> Option<String> {
    let normalized = provided.and_then(|value| {
        let trimmed = value.trim();
        (!trimmed.is_empty()).then(|| trimmed.to_ascii_lowercase())
    });
    if let Some(content_type) = normalized.as_deref()
        && content_type != "application/octet-stream"
    {
        return Some(content_type.to_owned());
    }

    filename.rsplit('.').next().and_then(|extension| {
        let mime = match extension.to_ascii_lowercase().as_str() {
            "txt" => "text/plain",
            "md" => "text/markdown",
            "json" => "application/json",
            "csv" => "text/csv",
            "html" | "htm" => "text/html",
            "jpg" | "jpeg" => "image/jpeg",
            "png" => "image/png",
            "gif" => "image/gif",
            "webp" => "image/webp",
            "bmp" => "image/bmp",
            "tif" | "tiff" => "image/tiff",
            "ico" => "image/x-icon",
            "svg" => "image/svg+xml",
            "pdf" => "application/pdf",
            "mp4" => "video/mp4",
            "mov" => "video/quicktime",
            "mp3" => "audio/mpeg",
            "wav" => "audio/wav",
            "ogg" => "audio/ogg",
            _ => return normalized,
        };
        Some(mime.to_owned())
    })
}

pub(crate) fn file_extension(filename: &str) -> Option<&str> {
    filename.rsplit_once('.').map(|(_, extension)| extension)
}

pub(crate) fn replace_extension(filename: &str, extension: &str) -> String {
    match filename.rsplit_once('.') {
        Some((stem, _)) => format!("{stem}.{extension}"),
        None => format!("{filename}.{extension}"),
    }
}

#[cfg(test)]
pub(crate) mod test_support {
    pub(crate) use rustrails_support::testing::run_sync_test;
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_detect_content_type_prefers_specific_value() {
        assert_eq!(
            detect_content_type("file.txt", Some("image/jpeg")).as_deref(),
            Some("image/jpeg")
        );
    }

    #[test]
    fn test_detect_content_type_uses_filename_for_octet_stream() {
        assert_eq!(
            detect_content_type("file.txt", Some("application/octet-stream")).as_deref(),
            Some("text/plain")
        );
    }

    #[test]
    fn test_detect_content_type_returns_none_for_unknown_extension() {
        assert_eq!(detect_content_type("file.unknown", None), None);
    }

    #[test]
    fn test_replace_extension_rewrites_existing_extension() {
        assert_eq!(replace_extension("racecar.jpg", "png"), "racecar.png");
    }

    #[test]
    fn test_replace_extension_adds_extension_when_missing() {
        assert_eq!(replace_extension("README", "txt"), "README.txt");
    }
}