rjango 0.1.1

A full-stack Rust backend framework inspired by Django
Documentation
use std::path::{Component, Path, PathBuf};

use super::storage::StorageError;

#[must_use]
pub fn guess_content_type(name: &str) -> Option<&'static str> {
    let extension = Path::new(name)
        .extension()
        .and_then(|value| value.to_str())?
        .to_ascii_lowercase();

    match extension.as_str() {
        "txt" | "text" => Some("text/plain"),
        "html" | "htm" => Some("text/html"),
        "css" => Some("text/css"),
        "csv" => Some("text/csv"),
        "js" | "mjs" => Some("application/javascript"),
        "json" => Some("application/json"),
        "xml" => Some("application/xml"),
        "yaml" | "yml" => Some("application/yaml"),
        "pdf" => Some("application/pdf"),
        "png" => Some("image/png"),
        "jpg" | "jpeg" => Some("image/jpeg"),
        "gif" => Some("image/gif"),
        "svg" => Some("image/svg+xml"),
        "webp" => Some("image/webp"),
        _ => None,
    }
}

pub fn normalize_relative_path(name: &str) -> Result<PathBuf, StorageError> {
    let candidate = Path::new(name);
    if candidate.as_os_str().is_empty() {
        return Err(StorageError::SuspiciousFilename(name.to_string()));
    }

    let mut normalized = PathBuf::new();
    for component in candidate.components() {
        match component {
            Component::CurDir => {}
            Component::Normal(part) => normalized.push(part),
            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
                return Err(StorageError::SuspiciousFilename(name.to_string()));
            }
        }
    }

    if normalized.as_os_str().is_empty() {
        return Err(StorageError::SuspiciousFilename(name.to_string()));
    }

    Ok(normalized)
}

pub fn normalize_storage_name(name: &str) -> Result<String, StorageError> {
    let normalized = normalize_relative_path(name)?;
    Ok(path_to_storage_name(&normalized))
}

#[must_use]
pub fn path_to_storage_name(path: &Path) -> String {
    path.components()
        .filter_map(|component| match component {
            Component::Normal(part) => Some(part.to_string_lossy().into_owned()),
            _ => None,
        })
        .collect::<Vec<_>>()
        .join("/")
}

#[must_use]
pub fn build_url(base_url: &str, name: &str) -> String {
    let trimmed_name = name.trim_start_matches('/').replace('\\', "/");
    if base_url.is_empty() {
        return trimmed_name;
    }

    let base = if base_url.ends_with('/') {
        base_url.to_string()
    } else {
        format!("{base_url}/")
    };
    format!("{base}{trimmed_name}")
}

#[cfg(test)]
mod tests {
    use super::{build_url, guess_content_type, normalize_relative_path, normalize_storage_name};

    #[test]
    fn guess_content_type_matches_common_extensions() {
        assert_eq!(guess_content_type("notes.txt"), Some("text/plain"));
        assert_eq!(guess_content_type("logo.PNG"), Some("image/png"));
        assert_eq!(guess_content_type("data.json"), Some("application/json"));
    }

    #[test]
    fn guess_content_type_returns_none_for_unknown_extension() {
        assert_eq!(guess_content_type("archive.unknown"), None);
        assert_eq!(guess_content_type("README"), None);
    }

    #[test]
    fn normalize_relative_path_rejects_parent_components() {
        let error = normalize_relative_path("../secret.txt").expect_err("expected rejection");
        assert_eq!(error.to_string(), "suspicious filename: ../secret.txt");
    }

    #[test]
    fn normalize_storage_name_uses_forward_slashes() {
        let normalized = normalize_storage_name("nested/path/file.txt").expect("normalize name");
        assert_eq!(normalized, "nested/path/file.txt");
    }

    #[test]
    fn build_url_adds_missing_separator_once() {
        assert_eq!(
            build_url("/media", "avatars/me.png"),
            "/media/avatars/me.png"
        );
        assert_eq!(
            build_url("/media/", "/avatars/me.png"),
            "/media/avatars/me.png"
        );
    }
}