style: apply cargo fmt formatting to existing codebase
Run `cargo fmt` across all Rust source files to enforce consistent formatting (import ordering, line wrapping, match arm braces). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,457 +1,495 @@
|
||||
/// Shared display helpers for DTOs.
|
||||
///
|
||||
/// These functions centralise the mime→icon / mime→category / size→human-string
|
||||
/// logic so that every API response carries pre-computed display fields and the
|
||||
/// frontend does **not** need to duplicate these mappings.
|
||||
///
|
||||
/// The approach is: try MIME first (specific matches beat prefix matches),
|
||||
/// then fall back to the file extension when the MIME is generic
|
||||
/// (`application/octet-stream` or empty).
|
||||
|
||||
// ─── Private: extract lowercase extension from a filename ────────────
|
||||
|
||||
fn ext_of(name: &str) -> Option<&str> {
|
||||
let name = name.rsplit('/').next().unwrap_or(name); // strip path
|
||||
let after_dot = name.rsplit('.').next()?;
|
||||
// Reject the whole name (no dot) or empty after dot
|
||||
if after_dot.len() == name.len() || after_dot.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(after_dot)
|
||||
}
|
||||
|
||||
// ─── Icon class (FontAwesome) ────────────────────────────────────────
|
||||
|
||||
/// Returns the FontAwesome icon class for a file, considering both MIME
|
||||
/// and filename extension as fallback.
|
||||
///
|
||||
/// Use this instead of the old `mime_to_icon_class` whenever the filename
|
||||
/// is available.
|
||||
pub fn icon_class_for(name: &str, mime: &str) -> &'static str {
|
||||
// 1. Try specific MIME matches first
|
||||
match mime {
|
||||
"application/pdf" => return "fas fa-file-pdf",
|
||||
// MS Office & OpenDocument – Word
|
||||
"application/msword"
|
||||
| "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
| "application/vnd.oasis.opendocument.text" => return "fas fa-file-word",
|
||||
// Excel
|
||||
"application/vnd.ms-excel"
|
||||
| "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
| "application/vnd.oasis.opendocument.spreadsheet"
|
||||
| "text/csv" => return "fas fa-file-excel",
|
||||
// PowerPoint
|
||||
"application/vnd.ms-powerpoint"
|
||||
| "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||
| "application/vnd.oasis.opendocument.presentation" => return "fas fa-file-powerpoint",
|
||||
// Archives
|
||||
"application/zip"
|
||||
| "application/x-rar-compressed"
|
||||
| "application/vnd.rar"
|
||||
| "application/x-7z-compressed"
|
||||
| "application/gzip"
|
||||
| "application/x-gzip"
|
||||
| "application/x-tar"
|
||||
| "application/x-bzip2"
|
||||
| "application/x-xz"
|
||||
| "application/x-compress" => return "fas fa-file-archive",
|
||||
// JSON / JavaScript / code transported as application/*
|
||||
"application/json"
|
||||
| "application/ld+json"
|
||||
| "application/javascript"
|
||||
| "application/typescript"
|
||||
| "application/x-httpd-php"
|
||||
| "application/xml"
|
||||
| "application/xhtml+xml"
|
||||
| "application/sql"
|
||||
| "application/x-yaml"
|
||||
| "application/toml"
|
||||
| "application/x-sh"
|
||||
| "application/x-shellscript"
|
||||
| "application/x-csh" => return "fas fa-file-code",
|
||||
// Installers / disk images
|
||||
"application/x-apple-diskimage"
|
||||
| "application/x-ms-dos-executable"
|
||||
| "application/x-msdownload"
|
||||
| "application/x-msi"
|
||||
| "application/vnd.debian.binary-package"
|
||||
| "application/x-rpm"
|
||||
| "application/vnd.appimage" => return "fas fa-hdd",
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// 2. MIME prefix matches
|
||||
if mime.starts_with("image/") {
|
||||
return "fas fa-file-image";
|
||||
} else if mime.starts_with("video/") {
|
||||
return "fas fa-file-video";
|
||||
} else if mime.starts_with("audio/") {
|
||||
return "fas fa-file-audio";
|
||||
} else if mime.starts_with("text/x-script")
|
||||
|| mime.starts_with("text/x-python")
|
||||
|| mime.starts_with("text/x-java")
|
||||
|| mime.starts_with("text/x-c")
|
||||
|| mime.starts_with("text/x-rust")
|
||||
|| mime.starts_with("text/x-go")
|
||||
|| mime.starts_with("text/x-ruby")
|
||||
|| mime.starts_with("text/x-shellscript")
|
||||
|| mime.starts_with("text/x-php")
|
||||
|| mime.contains("javascript")
|
||||
|| mime.contains("typescript")
|
||||
{
|
||||
return "fas fa-file-code";
|
||||
} else if mime.starts_with("text/markdown") {
|
||||
return "fas fa-file-alt";
|
||||
} else if mime.starts_with("text/") {
|
||||
return "fas fa-file-alt";
|
||||
}
|
||||
|
||||
// 3. Extension-based fallback (for application/octet-stream, empty, etc.)
|
||||
if let Some(ext) = ext_of(name) {
|
||||
return match ext.to_ascii_lowercase().as_str() {
|
||||
"pdf" => "fas fa-file-pdf",
|
||||
"doc" | "docx" | "odt" | "rtf" => "fas fa-file-word",
|
||||
"xls" | "xlsx" | "ods" | "csv" => "fas fa-file-excel",
|
||||
"ppt" | "pptx" | "odp" | "key" => "fas fa-file-powerpoint",
|
||||
"jpg" | "jpeg" | "png" | "gif" | "bmp" | "svg" | "webp" | "ico" | "tiff" | "tif" | "heic" | "heif" | "avif" => "fas fa-file-image",
|
||||
"mp4" | "avi" | "mkv" | "mov" | "wmv" | "flv" | "webm" | "m4v" => "fas fa-file-video",
|
||||
"mp3" | "wav" | "ogg" | "flac" | "aac" | "wma" | "m4a" | "opus" => "fas fa-file-audio",
|
||||
"zip" | "rar" | "7z" | "tar" | "gz" | "bz2" | "xz" | "zst" | "lz4" => "fas fa-file-archive",
|
||||
"exe" | "msi" | "dmg" | "deb" | "rpm" | "appimage" | "pkg" | "snap" | "flatpak" => "fas fa-hdd",
|
||||
"js" | "jsx" | "mjs" | "cjs" | "ts" | "tsx"
|
||||
| "py" | "pyw" | "rs" | "go" | "java" | "kt" | "kts" | "scala"
|
||||
| "c" | "h" | "cpp" | "hpp" | "cc" | "cxx" | "cs"
|
||||
| "rb" | "php" | "swift" | "r" | "lua" | "pl" | "pm"
|
||||
| "html" | "htm" | "css" | "scss" | "sass" | "less"
|
||||
| "json" | "xml" | "yaml" | "yml" | "toml" | "ini" | "cfg" | "conf"
|
||||
| "sql" | "graphql" | "proto" | "vue" | "svelte" => "fas fa-file-code",
|
||||
"sh" | "bash" | "zsh" | "fish" | "ps1" | "bat" | "cmd" => "fas fa-terminal",
|
||||
"md" | "markdown" | "rst" | "txt" => "fas fa-file-alt",
|
||||
_ => "fas fa-file",
|
||||
};
|
||||
}
|
||||
|
||||
"fas fa-file"
|
||||
}
|
||||
|
||||
// ─── Icon special class (CSS styling) ────────────────────────────────
|
||||
|
||||
/// Returns the CSS class for styling the icon container, considering both
|
||||
/// MIME and filename extension.
|
||||
///
|
||||
/// The returned class maps to CSS rules in `style.css` that set colours,
|
||||
/// backgrounds and decorative pseudo-elements per file type.
|
||||
pub fn icon_special_class_for(name: &str, mime: &str) -> &'static str {
|
||||
// 1. Specific MIME matches
|
||||
match mime {
|
||||
"application/pdf" => return "pdf-icon",
|
||||
"application/msword"
|
||||
| "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
| "application/vnd.oasis.opendocument.text" => return "doc-icon",
|
||||
"application/vnd.ms-excel"
|
||||
| "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
| "application/vnd.oasis.opendocument.spreadsheet"
|
||||
| "text/csv" => return "spreadsheet-icon",
|
||||
"application/vnd.ms-powerpoint"
|
||||
| "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||
| "application/vnd.oasis.opendocument.presentation" => return "presentation-icon",
|
||||
"application/zip"
|
||||
| "application/x-rar-compressed"
|
||||
| "application/vnd.rar"
|
||||
| "application/x-7z-compressed"
|
||||
| "application/gzip"
|
||||
| "application/x-gzip"
|
||||
| "application/x-tar"
|
||||
| "application/x-bzip2"
|
||||
| "application/x-xz"
|
||||
| "application/x-compress" => return "archive-icon",
|
||||
"application/json" | "application/ld+json" => return "code-icon json-icon",
|
||||
"application/javascript" => return "code-icon js-icon",
|
||||
"application/typescript" => return "code-icon ts-icon",
|
||||
"application/xml" | "application/xhtml+xml" => return "code-icon html-icon",
|
||||
"application/sql" => return "code-icon sql-icon",
|
||||
"application/x-yaml" | "application/toml" => return "code-icon config-icon",
|
||||
"application/x-httpd-php" => return "code-icon php-icon",
|
||||
"application/x-sh" | "application/x-shellscript" | "application/x-csh" => {
|
||||
return "script-icon"
|
||||
}
|
||||
"application/x-apple-diskimage"
|
||||
| "application/x-ms-dos-executable"
|
||||
| "application/x-msdownload"
|
||||
| "application/x-msi"
|
||||
| "application/vnd.debian.binary-package"
|
||||
| "application/x-rpm"
|
||||
| "application/vnd.appimage" => return "installer-icon",
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// 2. MIME prefix matches
|
||||
if mime.starts_with("image/") {
|
||||
return "image-icon";
|
||||
} else if mime.starts_with("video/") {
|
||||
return "video-icon";
|
||||
} else if mime.starts_with("audio/") {
|
||||
return "audio-icon";
|
||||
} else if mime.starts_with("text/x-python") {
|
||||
return "code-icon py-icon";
|
||||
} else if mime.starts_with("text/x-rust") {
|
||||
return "code-icon rust-icon";
|
||||
} else if mime.starts_with("text/x-java") || mime.starts_with("text/x-c") {
|
||||
return "code-icon";
|
||||
} else if mime.starts_with("text/x-go") {
|
||||
return "code-icon go-icon";
|
||||
} else if mime.starts_with("text/x-ruby") {
|
||||
return "code-icon ruby-icon";
|
||||
} else if mime.starts_with("text/x-shellscript") {
|
||||
return "script-icon";
|
||||
} else if mime.starts_with("text/x-script") || mime.starts_with("text/x-php") {
|
||||
return "code-icon";
|
||||
} else if mime.starts_with("text/markdown") {
|
||||
return "code-icon md-icon";
|
||||
} else if mime.starts_with("text/html") {
|
||||
return "code-icon html-icon";
|
||||
} else if mime.starts_with("text/css") {
|
||||
return "code-icon css-icon";
|
||||
} else if mime.contains("javascript") {
|
||||
return "code-icon js-icon";
|
||||
} else if mime.contains("typescript") {
|
||||
return "code-icon ts-icon";
|
||||
} else if mime.starts_with("text/") {
|
||||
return "doc-icon";
|
||||
}
|
||||
|
||||
// 3. Extension-based fallback
|
||||
if let Some(ext) = ext_of(name) {
|
||||
return match ext.to_ascii_lowercase().as_str() {
|
||||
"pdf" => "pdf-icon",
|
||||
"doc" | "docx" | "odt" | "rtf" => "doc-icon",
|
||||
"xls" | "xlsx" | "ods" | "csv" => "spreadsheet-icon",
|
||||
"ppt" | "pptx" | "odp" | "key" => "presentation-icon",
|
||||
"jpg" | "jpeg" | "png" | "gif" | "bmp" | "svg" | "webp" | "ico" | "tiff" | "tif" | "heic" | "heif" | "avif" => "image-icon",
|
||||
"mp4" | "avi" | "mkv" | "mov" | "wmv" | "flv" | "webm" | "m4v" => "video-icon",
|
||||
"mp3" | "wav" | "ogg" | "flac" | "aac" | "wma" | "m4a" | "opus" => "audio-icon",
|
||||
"zip" | "rar" | "7z" | "tar" | "gz" | "bz2" | "xz" | "zst" | "lz4" => "archive-icon",
|
||||
"exe" | "msi" | "dmg" | "deb" | "rpm" | "appimage" | "pkg" | "snap" | "flatpak" => "installer-icon",
|
||||
"py" | "pyw" => "code-icon py-icon",
|
||||
"rs" => "code-icon rust-icon",
|
||||
"go" => "code-icon go-icon",
|
||||
"java" | "kt" | "kts" | "scala" => "code-icon java-icon",
|
||||
"js" | "jsx" | "mjs" | "cjs" => "code-icon js-icon",
|
||||
"ts" | "tsx" => "code-icon ts-icon",
|
||||
"c" | "h" | "cpp" | "hpp" | "cc" | "cxx" => "code-icon c-icon",
|
||||
"cs" => "code-icon cs-icon",
|
||||
"rb" => "code-icon ruby-icon",
|
||||
"php" => "code-icon php-icon",
|
||||
"swift" => "code-icon swift-icon",
|
||||
"r" | "lua" | "pl" | "pm" => "code-icon",
|
||||
"html" | "htm" => "code-icon html-icon",
|
||||
"css" | "scss" | "sass" | "less" => "code-icon css-icon",
|
||||
"json" => "code-icon json-icon",
|
||||
"xml" => "code-icon html-icon",
|
||||
"yaml" | "yml" | "toml" | "ini" | "cfg" | "conf" => "code-icon config-icon",
|
||||
"sql" | "graphql" | "proto" => "code-icon sql-icon",
|
||||
"vue" | "svelte" => "code-icon js-icon",
|
||||
"sh" | "bash" | "zsh" | "fish" | "ps1" | "bat" | "cmd" => "script-icon",
|
||||
"md" | "markdown" | "rst" => "code-icon md-icon",
|
||||
"txt" => "doc-icon",
|
||||
_ => "",
|
||||
};
|
||||
}
|
||||
|
||||
""
|
||||
}
|
||||
|
||||
// ─── Category label ──────────────────────────────────────────────────
|
||||
|
||||
/// Returns a human-readable category label, considering MIME + extension.
|
||||
pub fn category_for(name: &str, mime: &str) -> &'static str {
|
||||
// 1. Specific MIME matches
|
||||
match mime {
|
||||
"application/pdf" => return "PDF",
|
||||
"application/msword"
|
||||
| "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
| "application/vnd.oasis.opendocument.text" => return "Document",
|
||||
"application/vnd.ms-excel"
|
||||
| "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
| "application/vnd.oasis.opendocument.spreadsheet"
|
||||
| "text/csv" => return "Spreadsheet",
|
||||
"application/vnd.ms-powerpoint"
|
||||
| "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||
| "application/vnd.oasis.opendocument.presentation" => return "Presentation",
|
||||
"application/zip"
|
||||
| "application/x-rar-compressed"
|
||||
| "application/vnd.rar"
|
||||
| "application/x-7z-compressed"
|
||||
| "application/gzip"
|
||||
| "application/x-tar" => return "Archive",
|
||||
"application/json"
|
||||
| "application/javascript"
|
||||
| "application/typescript"
|
||||
| "application/xml"
|
||||
| "application/sql"
|
||||
| "application/x-sh"
|
||||
| "application/x-shellscript" => return "Code",
|
||||
"application/x-apple-diskimage"
|
||||
| "application/x-ms-dos-executable"
|
||||
| "application/x-msdownload"
|
||||
| "application/x-msi" => return "Installer",
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// 2. MIME prefix
|
||||
if mime.starts_with("image/") {
|
||||
return "Image";
|
||||
} else if mime.starts_with("video/") {
|
||||
return "Video";
|
||||
} else if mime.starts_with("audio/") {
|
||||
return "Audio";
|
||||
} else if mime.starts_with("text/x-") || mime.contains("script") || mime.contains("javascript") {
|
||||
return "Code";
|
||||
} else if mime.starts_with("text/markdown") {
|
||||
return "Markdown";
|
||||
} else if mime.starts_with("text/html") || mime.starts_with("text/css") {
|
||||
return "Code";
|
||||
} else if mime.starts_with("text/") {
|
||||
return "Text";
|
||||
}
|
||||
|
||||
// 3. Extension fallback
|
||||
if let Some(ext) = ext_of(name) {
|
||||
return match ext.to_ascii_lowercase().as_str() {
|
||||
"pdf" => "PDF",
|
||||
"doc" | "docx" | "odt" | "rtf" | "txt" => "Document",
|
||||
"xls" | "xlsx" | "ods" | "csv" => "Spreadsheet",
|
||||
"ppt" | "pptx" | "odp" | "key" => "Presentation",
|
||||
"jpg" | "jpeg" | "png" | "gif" | "bmp" | "svg" | "webp" | "ico" | "tiff" | "heic" | "avif" => "Image",
|
||||
"mp4" | "avi" | "mkv" | "mov" | "wmv" | "flv" | "webm" | "m4v" => "Video",
|
||||
"mp3" | "wav" | "ogg" | "flac" | "aac" | "wma" | "m4a" | "opus" => "Audio",
|
||||
"zip" | "rar" | "7z" | "tar" | "gz" | "bz2" | "xz" => "Archive",
|
||||
"exe" | "msi" | "dmg" | "deb" | "rpm" | "appimage" => "Installer",
|
||||
"js" | "jsx" | "ts" | "tsx" | "py" | "rs" | "go" | "java" | "c" | "cpp" | "cs"
|
||||
| "rb" | "php" | "swift" | "kt" | "scala" | "r" | "lua" | "pl"
|
||||
| "html" | "htm" | "css" | "scss" | "json" | "xml" | "yaml" | "yml"
|
||||
| "toml" | "sql" | "sh" | "bash" | "bat" | "ps1" | "vue" | "svelte" => "Code",
|
||||
"md" | "markdown" | "rst" => "Markdown",
|
||||
_ => "Document",
|
||||
};
|
||||
}
|
||||
|
||||
"Document"
|
||||
}
|
||||
|
||||
/// Formats a byte count into a human-readable string (1024-based).
|
||||
///
|
||||
/// Matches the JavaScript `formatFileSize()` output exactly so the frontend
|
||||
/// does not need its own per-file formatting.
|
||||
///
|
||||
/// Examples: `"0 Bytes"`, `"1.5 KB"`, `"3.27 MB"`.
|
||||
pub fn format_file_size(bytes: u64) -> String {
|
||||
if bytes == 0 {
|
||||
return "0 Bytes".to_string();
|
||||
}
|
||||
|
||||
const K: f64 = 1024.0;
|
||||
const SIZES: [&str; 5] = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||
|
||||
let i = ((bytes as f64).ln() / K.ln()).floor() as usize;
|
||||
let i = i.min(SIZES.len() - 1);
|
||||
|
||||
let value = bytes as f64 / K.powi(i as i32);
|
||||
|
||||
// Two decimal places, then strip trailing zeros (matches JS parseFloat behaviour)
|
||||
let formatted = format!("{:.2}", value);
|
||||
let formatted = formatted
|
||||
.trim_end_matches('0')
|
||||
.trim_end_matches('.');
|
||||
|
||||
format!("{} {}", formatted, SIZES[i])
|
||||
}
|
||||
|
||||
/// Formats a byte count for quota display. When bytes is 0, returns "∞" (unlimited).
|
||||
///
|
||||
/// Matches the JavaScript `formatQuotaSize()` output.
|
||||
pub fn format_quota_size(bytes: u64) -> String {
|
||||
if bytes == 0 {
|
||||
return "∞".to_string();
|
||||
}
|
||||
format_file_size(bytes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_format_file_size() {
|
||||
assert_eq!(format_file_size(0), "0 Bytes");
|
||||
assert_eq!(format_file_size(500), "500 Bytes");
|
||||
assert_eq!(format_file_size(1024), "1 KB");
|
||||
assert_eq!(format_file_size(1536), "1.5 KB");
|
||||
assert_eq!(format_file_size(1_048_576), "1 MB");
|
||||
assert_eq!(format_file_size(3_423_744), "3.27 MB");
|
||||
assert_eq!(format_file_size(1_073_741_824), "1 GB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_quota_size() {
|
||||
// Unlimited quota (0) should show infinity symbol
|
||||
assert_eq!(format_quota_size(0), "∞");
|
||||
// Non-zero values should format normally
|
||||
assert_eq!(format_quota_size(500), "500 Bytes");
|
||||
assert_eq!(format_quota_size(1_073_741_824), "1 GB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_icon_class_for_with_extension_fallback() {
|
||||
// Specific MIME types
|
||||
assert_eq!(icon_class_for("doc.pdf", "application/pdf"), "fas fa-file-pdf");
|
||||
assert_eq!(icon_class_for("file.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"), "fas fa-file-word");
|
||||
|
||||
// Extension fallback when MIME is generic
|
||||
assert_eq!(icon_class_for("script.py", "application/octet-stream"), "fas fa-file-code");
|
||||
assert_eq!(icon_class_for("app.dmg", "application/octet-stream"), "fas fa-hdd");
|
||||
assert_eq!(icon_class_for("archive.zip", "application/octet-stream"), "fas fa-file-archive");
|
||||
assert_eq!(icon_class_for("data.xlsx", "application/octet-stream"), "fas fa-file-excel");
|
||||
assert_eq!(icon_class_for("run.sh", "application/octet-stream"), "fas fa-terminal");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_icon_special_class_for() {
|
||||
// MIME-based
|
||||
assert_eq!(icon_special_class_for("", "image/png"), "image-icon");
|
||||
assert_eq!(icon_special_class_for("", "application/pdf"), "pdf-icon");
|
||||
assert_eq!(icon_special_class_for("", "application/json"), "code-icon json-icon");
|
||||
|
||||
// Extension-based fallback
|
||||
assert_eq!(icon_special_class_for("main.py", "application/octet-stream"), "code-icon py-icon");
|
||||
assert_eq!(icon_special_class_for("lib.rs", "application/octet-stream"), "code-icon rust-icon");
|
||||
assert_eq!(icon_special_class_for("style.css", "application/octet-stream"), "code-icon css-icon");
|
||||
assert_eq!(icon_special_class_for("data.xlsx", "application/octet-stream"), "spreadsheet-icon");
|
||||
assert_eq!(icon_special_class_for("backup.tar", "application/octet-stream"), "archive-icon");
|
||||
assert_eq!(icon_special_class_for("setup.dmg", "application/octet-stream"), "installer-icon");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_category_for() {
|
||||
// MIME-based
|
||||
assert_eq!(category_for("", "image/jpeg"), "Image");
|
||||
assert_eq!(category_for("", "video/webm"), "Video");
|
||||
assert_eq!(category_for("", "audio/ogg"), "Audio");
|
||||
assert_eq!(category_for("", "application/pdf"), "PDF");
|
||||
assert_eq!(category_for("", "application/zip"), "Archive");
|
||||
|
||||
// Extension-based fallback
|
||||
assert_eq!(category_for("main.rs", "application/octet-stream"), "Code");
|
||||
assert_eq!(category_for("photo.jpg", "application/octet-stream"), "Image");
|
||||
assert_eq!(category_for("notes.md", "application/octet-stream"), "Markdown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ext_of() {
|
||||
assert_eq!(ext_of("file.txt"), Some("txt"));
|
||||
assert_eq!(ext_of("archive.tar.gz"), Some("gz"));
|
||||
assert_eq!(ext_of("no_extension"), None);
|
||||
assert_eq!(ext_of(".gitignore"), Some("gitignore")); // dot file treated as having extension
|
||||
assert_eq!(ext_of("path/to/file.rs"), Some("rs"));
|
||||
}
|
||||
}
|
||||
/// Shared display helpers for DTOs.
|
||||
///
|
||||
/// These functions centralise the mime→icon / mime→category / size→human-string
|
||||
/// logic so that every API response carries pre-computed display fields and the
|
||||
/// frontend does **not** need to duplicate these mappings.
|
||||
///
|
||||
/// The approach is: try MIME first (specific matches beat prefix matches),
|
||||
/// then fall back to the file extension when the MIME is generic
|
||||
/// (`application/octet-stream` or empty).
|
||||
|
||||
// ─── Private: extract lowercase extension from a filename ────────────
|
||||
|
||||
fn ext_of(name: &str) -> Option<&str> {
|
||||
let name = name.rsplit('/').next().unwrap_or(name); // strip path
|
||||
let after_dot = name.rsplit('.').next()?;
|
||||
// Reject the whole name (no dot) or empty after dot
|
||||
if after_dot.len() == name.len() || after_dot.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(after_dot)
|
||||
}
|
||||
|
||||
// ─── Icon class (FontAwesome) ────────────────────────────────────────
|
||||
|
||||
/// Returns the FontAwesome icon class for a file, considering both MIME
|
||||
/// and filename extension as fallback.
|
||||
///
|
||||
/// Use this instead of the old `mime_to_icon_class` whenever the filename
|
||||
/// is available.
|
||||
pub fn icon_class_for(name: &str, mime: &str) -> &'static str {
|
||||
// 1. Try specific MIME matches first
|
||||
match mime {
|
||||
"application/pdf" => return "fas fa-file-pdf",
|
||||
// MS Office & OpenDocument – Word
|
||||
"application/msword"
|
||||
| "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
| "application/vnd.oasis.opendocument.text" => return "fas fa-file-word",
|
||||
// Excel
|
||||
"application/vnd.ms-excel"
|
||||
| "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
| "application/vnd.oasis.opendocument.spreadsheet"
|
||||
| "text/csv" => return "fas fa-file-excel",
|
||||
// PowerPoint
|
||||
"application/vnd.ms-powerpoint"
|
||||
| "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||
| "application/vnd.oasis.opendocument.presentation" => return "fas fa-file-powerpoint",
|
||||
// Archives
|
||||
"application/zip"
|
||||
| "application/x-rar-compressed"
|
||||
| "application/vnd.rar"
|
||||
| "application/x-7z-compressed"
|
||||
| "application/gzip"
|
||||
| "application/x-gzip"
|
||||
| "application/x-tar"
|
||||
| "application/x-bzip2"
|
||||
| "application/x-xz"
|
||||
| "application/x-compress" => return "fas fa-file-archive",
|
||||
// JSON / JavaScript / code transported as application/*
|
||||
"application/json"
|
||||
| "application/ld+json"
|
||||
| "application/javascript"
|
||||
| "application/typescript"
|
||||
| "application/x-httpd-php"
|
||||
| "application/xml"
|
||||
| "application/xhtml+xml"
|
||||
| "application/sql"
|
||||
| "application/x-yaml"
|
||||
| "application/toml"
|
||||
| "application/x-sh"
|
||||
| "application/x-shellscript"
|
||||
| "application/x-csh" => return "fas fa-file-code",
|
||||
// Installers / disk images
|
||||
"application/x-apple-diskimage"
|
||||
| "application/x-ms-dos-executable"
|
||||
| "application/x-msdownload"
|
||||
| "application/x-msi"
|
||||
| "application/vnd.debian.binary-package"
|
||||
| "application/x-rpm"
|
||||
| "application/vnd.appimage" => return "fas fa-hdd",
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// 2. MIME prefix matches
|
||||
if mime.starts_with("image/") {
|
||||
return "fas fa-file-image";
|
||||
} else if mime.starts_with("video/") {
|
||||
return "fas fa-file-video";
|
||||
} else if mime.starts_with("audio/") {
|
||||
return "fas fa-file-audio";
|
||||
} else if mime.starts_with("text/x-script")
|
||||
|| mime.starts_with("text/x-python")
|
||||
|| mime.starts_with("text/x-java")
|
||||
|| mime.starts_with("text/x-c")
|
||||
|| mime.starts_with("text/x-rust")
|
||||
|| mime.starts_with("text/x-go")
|
||||
|| mime.starts_with("text/x-ruby")
|
||||
|| mime.starts_with("text/x-shellscript")
|
||||
|| mime.starts_with("text/x-php")
|
||||
|| mime.contains("javascript")
|
||||
|| mime.contains("typescript")
|
||||
{
|
||||
return "fas fa-file-code";
|
||||
} else if mime.starts_with("text/markdown") {
|
||||
return "fas fa-file-alt";
|
||||
} else if mime.starts_with("text/") {
|
||||
return "fas fa-file-alt";
|
||||
}
|
||||
|
||||
// 3. Extension-based fallback (for application/octet-stream, empty, etc.)
|
||||
if let Some(ext) = ext_of(name) {
|
||||
return match ext.to_ascii_lowercase().as_str() {
|
||||
"pdf" => "fas fa-file-pdf",
|
||||
"doc" | "docx" | "odt" | "rtf" => "fas fa-file-word",
|
||||
"xls" | "xlsx" | "ods" | "csv" => "fas fa-file-excel",
|
||||
"ppt" | "pptx" | "odp" | "key" => "fas fa-file-powerpoint",
|
||||
"jpg" | "jpeg" | "png" | "gif" | "bmp" | "svg" | "webp" | "ico" | "tiff" | "tif"
|
||||
| "heic" | "heif" | "avif" => "fas fa-file-image",
|
||||
"mp4" | "avi" | "mkv" | "mov" | "wmv" | "flv" | "webm" | "m4v" => "fas fa-file-video",
|
||||
"mp3" | "wav" | "ogg" | "flac" | "aac" | "wma" | "m4a" | "opus" => "fas fa-file-audio",
|
||||
"zip" | "rar" | "7z" | "tar" | "gz" | "bz2" | "xz" | "zst" | "lz4" => {
|
||||
"fas fa-file-archive"
|
||||
}
|
||||
"exe" | "msi" | "dmg" | "deb" | "rpm" | "appimage" | "pkg" | "snap" | "flatpak" => {
|
||||
"fas fa-hdd"
|
||||
}
|
||||
"js" | "jsx" | "mjs" | "cjs" | "ts" | "tsx" | "py" | "pyw" | "rs" | "go" | "java"
|
||||
| "kt" | "kts" | "scala" | "c" | "h" | "cpp" | "hpp" | "cc" | "cxx" | "cs" | "rb"
|
||||
| "php" | "swift" | "r" | "lua" | "pl" | "pm" | "html" | "htm" | "css" | "scss"
|
||||
| "sass" | "less" | "json" | "xml" | "yaml" | "yml" | "toml" | "ini" | "cfg"
|
||||
| "conf" | "sql" | "graphql" | "proto" | "vue" | "svelte" => "fas fa-file-code",
|
||||
"sh" | "bash" | "zsh" | "fish" | "ps1" | "bat" | "cmd" => "fas fa-terminal",
|
||||
"md" | "markdown" | "rst" | "txt" => "fas fa-file-alt",
|
||||
_ => "fas fa-file",
|
||||
};
|
||||
}
|
||||
|
||||
"fas fa-file"
|
||||
}
|
||||
|
||||
// ─── Icon special class (CSS styling) ────────────────────────────────
|
||||
|
||||
/// Returns the CSS class for styling the icon container, considering both
|
||||
/// MIME and filename extension.
|
||||
///
|
||||
/// The returned class maps to CSS rules in `style.css` that set colours,
|
||||
/// backgrounds and decorative pseudo-elements per file type.
|
||||
pub fn icon_special_class_for(name: &str, mime: &str) -> &'static str {
|
||||
// 1. Specific MIME matches
|
||||
match mime {
|
||||
"application/pdf" => return "pdf-icon",
|
||||
"application/msword"
|
||||
| "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
| "application/vnd.oasis.opendocument.text" => return "doc-icon",
|
||||
"application/vnd.ms-excel"
|
||||
| "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
| "application/vnd.oasis.opendocument.spreadsheet"
|
||||
| "text/csv" => return "spreadsheet-icon",
|
||||
"application/vnd.ms-powerpoint"
|
||||
| "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||
| "application/vnd.oasis.opendocument.presentation" => return "presentation-icon",
|
||||
"application/zip"
|
||||
| "application/x-rar-compressed"
|
||||
| "application/vnd.rar"
|
||||
| "application/x-7z-compressed"
|
||||
| "application/gzip"
|
||||
| "application/x-gzip"
|
||||
| "application/x-tar"
|
||||
| "application/x-bzip2"
|
||||
| "application/x-xz"
|
||||
| "application/x-compress" => return "archive-icon",
|
||||
"application/json" | "application/ld+json" => return "code-icon json-icon",
|
||||
"application/javascript" => return "code-icon js-icon",
|
||||
"application/typescript" => return "code-icon ts-icon",
|
||||
"application/xml" | "application/xhtml+xml" => return "code-icon html-icon",
|
||||
"application/sql" => return "code-icon sql-icon",
|
||||
"application/x-yaml" | "application/toml" => return "code-icon config-icon",
|
||||
"application/x-httpd-php" => return "code-icon php-icon",
|
||||
"application/x-sh" | "application/x-shellscript" | "application/x-csh" => {
|
||||
return "script-icon";
|
||||
}
|
||||
"application/x-apple-diskimage"
|
||||
| "application/x-ms-dos-executable"
|
||||
| "application/x-msdownload"
|
||||
| "application/x-msi"
|
||||
| "application/vnd.debian.binary-package"
|
||||
| "application/x-rpm"
|
||||
| "application/vnd.appimage" => return "installer-icon",
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// 2. MIME prefix matches
|
||||
if mime.starts_with("image/") {
|
||||
return "image-icon";
|
||||
} else if mime.starts_with("video/") {
|
||||
return "video-icon";
|
||||
} else if mime.starts_with("audio/") {
|
||||
return "audio-icon";
|
||||
} else if mime.starts_with("text/x-python") {
|
||||
return "code-icon py-icon";
|
||||
} else if mime.starts_with("text/x-rust") {
|
||||
return "code-icon rust-icon";
|
||||
} else if mime.starts_with("text/x-java") || mime.starts_with("text/x-c") {
|
||||
return "code-icon";
|
||||
} else if mime.starts_with("text/x-go") {
|
||||
return "code-icon go-icon";
|
||||
} else if mime.starts_with("text/x-ruby") {
|
||||
return "code-icon ruby-icon";
|
||||
} else if mime.starts_with("text/x-shellscript") {
|
||||
return "script-icon";
|
||||
} else if mime.starts_with("text/x-script") || mime.starts_with("text/x-php") {
|
||||
return "code-icon";
|
||||
} else if mime.starts_with("text/markdown") {
|
||||
return "code-icon md-icon";
|
||||
} else if mime.starts_with("text/html") {
|
||||
return "code-icon html-icon";
|
||||
} else if mime.starts_with("text/css") {
|
||||
return "code-icon css-icon";
|
||||
} else if mime.contains("javascript") {
|
||||
return "code-icon js-icon";
|
||||
} else if mime.contains("typescript") {
|
||||
return "code-icon ts-icon";
|
||||
} else if mime.starts_with("text/") {
|
||||
return "doc-icon";
|
||||
}
|
||||
|
||||
// 3. Extension-based fallback
|
||||
if let Some(ext) = ext_of(name) {
|
||||
return match ext.to_ascii_lowercase().as_str() {
|
||||
"pdf" => "pdf-icon",
|
||||
"doc" | "docx" | "odt" | "rtf" => "doc-icon",
|
||||
"xls" | "xlsx" | "ods" | "csv" => "spreadsheet-icon",
|
||||
"ppt" | "pptx" | "odp" | "key" => "presentation-icon",
|
||||
"jpg" | "jpeg" | "png" | "gif" | "bmp" | "svg" | "webp" | "ico" | "tiff" | "tif"
|
||||
| "heic" | "heif" | "avif" => "image-icon",
|
||||
"mp4" | "avi" | "mkv" | "mov" | "wmv" | "flv" | "webm" | "m4v" => "video-icon",
|
||||
"mp3" | "wav" | "ogg" | "flac" | "aac" | "wma" | "m4a" | "opus" => "audio-icon",
|
||||
"zip" | "rar" | "7z" | "tar" | "gz" | "bz2" | "xz" | "zst" | "lz4" => "archive-icon",
|
||||
"exe" | "msi" | "dmg" | "deb" | "rpm" | "appimage" | "pkg" | "snap" | "flatpak" => {
|
||||
"installer-icon"
|
||||
}
|
||||
"py" | "pyw" => "code-icon py-icon",
|
||||
"rs" => "code-icon rust-icon",
|
||||
"go" => "code-icon go-icon",
|
||||
"java" | "kt" | "kts" | "scala" => "code-icon java-icon",
|
||||
"js" | "jsx" | "mjs" | "cjs" => "code-icon js-icon",
|
||||
"ts" | "tsx" => "code-icon ts-icon",
|
||||
"c" | "h" | "cpp" | "hpp" | "cc" | "cxx" => "code-icon c-icon",
|
||||
"cs" => "code-icon cs-icon",
|
||||
"rb" => "code-icon ruby-icon",
|
||||
"php" => "code-icon php-icon",
|
||||
"swift" => "code-icon swift-icon",
|
||||
"r" | "lua" | "pl" | "pm" => "code-icon",
|
||||
"html" | "htm" => "code-icon html-icon",
|
||||
"css" | "scss" | "sass" | "less" => "code-icon css-icon",
|
||||
"json" => "code-icon json-icon",
|
||||
"xml" => "code-icon html-icon",
|
||||
"yaml" | "yml" | "toml" | "ini" | "cfg" | "conf" => "code-icon config-icon",
|
||||
"sql" | "graphql" | "proto" => "code-icon sql-icon",
|
||||
"vue" | "svelte" => "code-icon js-icon",
|
||||
"sh" | "bash" | "zsh" | "fish" | "ps1" | "bat" | "cmd" => "script-icon",
|
||||
"md" | "markdown" | "rst" => "code-icon md-icon",
|
||||
"txt" => "doc-icon",
|
||||
_ => "",
|
||||
};
|
||||
}
|
||||
|
||||
""
|
||||
}
|
||||
|
||||
// ─── Category label ──────────────────────────────────────────────────
|
||||
|
||||
/// Returns a human-readable category label, considering MIME + extension.
|
||||
pub fn category_for(name: &str, mime: &str) -> &'static str {
|
||||
// 1. Specific MIME matches
|
||||
match mime {
|
||||
"application/pdf" => return "PDF",
|
||||
"application/msword"
|
||||
| "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
| "application/vnd.oasis.opendocument.text" => return "Document",
|
||||
"application/vnd.ms-excel"
|
||||
| "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
| "application/vnd.oasis.opendocument.spreadsheet"
|
||||
| "text/csv" => return "Spreadsheet",
|
||||
"application/vnd.ms-powerpoint"
|
||||
| "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||
| "application/vnd.oasis.opendocument.presentation" => return "Presentation",
|
||||
"application/zip"
|
||||
| "application/x-rar-compressed"
|
||||
| "application/vnd.rar"
|
||||
| "application/x-7z-compressed"
|
||||
| "application/gzip"
|
||||
| "application/x-tar" => return "Archive",
|
||||
"application/json"
|
||||
| "application/javascript"
|
||||
| "application/typescript"
|
||||
| "application/xml"
|
||||
| "application/sql"
|
||||
| "application/x-sh"
|
||||
| "application/x-shellscript" => return "Code",
|
||||
"application/x-apple-diskimage"
|
||||
| "application/x-ms-dos-executable"
|
||||
| "application/x-msdownload"
|
||||
| "application/x-msi" => return "Installer",
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// 2. MIME prefix
|
||||
if mime.starts_with("image/") {
|
||||
return "Image";
|
||||
} else if mime.starts_with("video/") {
|
||||
return "Video";
|
||||
} else if mime.starts_with("audio/") {
|
||||
return "Audio";
|
||||
} else if mime.starts_with("text/x-") || mime.contains("script") || mime.contains("javascript")
|
||||
{
|
||||
return "Code";
|
||||
} else if mime.starts_with("text/markdown") {
|
||||
return "Markdown";
|
||||
} else if mime.starts_with("text/html") || mime.starts_with("text/css") {
|
||||
return "Code";
|
||||
} else if mime.starts_with("text/") {
|
||||
return "Text";
|
||||
}
|
||||
|
||||
// 3. Extension fallback
|
||||
if let Some(ext) = ext_of(name) {
|
||||
return match ext.to_ascii_lowercase().as_str() {
|
||||
"pdf" => "PDF",
|
||||
"doc" | "docx" | "odt" | "rtf" | "txt" => "Document",
|
||||
"xls" | "xlsx" | "ods" | "csv" => "Spreadsheet",
|
||||
"ppt" | "pptx" | "odp" | "key" => "Presentation",
|
||||
"jpg" | "jpeg" | "png" | "gif" | "bmp" | "svg" | "webp" | "ico" | "tiff" | "heic"
|
||||
| "avif" => "Image",
|
||||
"mp4" | "avi" | "mkv" | "mov" | "wmv" | "flv" | "webm" | "m4v" => "Video",
|
||||
"mp3" | "wav" | "ogg" | "flac" | "aac" | "wma" | "m4a" | "opus" => "Audio",
|
||||
"zip" | "rar" | "7z" | "tar" | "gz" | "bz2" | "xz" => "Archive",
|
||||
"exe" | "msi" | "dmg" | "deb" | "rpm" | "appimage" => "Installer",
|
||||
"js" | "jsx" | "ts" | "tsx" | "py" | "rs" | "go" | "java" | "c" | "cpp" | "cs"
|
||||
| "rb" | "php" | "swift" | "kt" | "scala" | "r" | "lua" | "pl" | "html" | "htm"
|
||||
| "css" | "scss" | "json" | "xml" | "yaml" | "yml" | "toml" | "sql" | "sh" | "bash"
|
||||
| "bat" | "ps1" | "vue" | "svelte" => "Code",
|
||||
"md" | "markdown" | "rst" => "Markdown",
|
||||
_ => "Document",
|
||||
};
|
||||
}
|
||||
|
||||
"Document"
|
||||
}
|
||||
|
||||
/// Formats a byte count into a human-readable string (1024-based).
|
||||
///
|
||||
/// Matches the JavaScript `formatFileSize()` output exactly so the frontend
|
||||
/// does not need its own per-file formatting.
|
||||
///
|
||||
/// Examples: `"0 Bytes"`, `"1.5 KB"`, `"3.27 MB"`.
|
||||
pub fn format_file_size(bytes: u64) -> String {
|
||||
if bytes == 0 {
|
||||
return "0 Bytes".to_string();
|
||||
}
|
||||
|
||||
const K: f64 = 1024.0;
|
||||
const SIZES: [&str; 5] = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||
|
||||
let i = ((bytes as f64).ln() / K.ln()).floor() as usize;
|
||||
let i = i.min(SIZES.len() - 1);
|
||||
|
||||
let value = bytes as f64 / K.powi(i as i32);
|
||||
|
||||
// Two decimal places, then strip trailing zeros (matches JS parseFloat behaviour)
|
||||
let formatted = format!("{:.2}", value);
|
||||
let formatted = formatted.trim_end_matches('0').trim_end_matches('.');
|
||||
|
||||
format!("{} {}", formatted, SIZES[i])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_format_file_size() {
|
||||
assert_eq!(format_file_size(0), "0 Bytes");
|
||||
assert_eq!(format_file_size(500), "500 Bytes");
|
||||
assert_eq!(format_file_size(1024), "1 KB");
|
||||
assert_eq!(format_file_size(1536), "1.5 KB");
|
||||
assert_eq!(format_file_size(1_048_576), "1 MB");
|
||||
assert_eq!(format_file_size(3_423_744), "3.27 MB");
|
||||
assert_eq!(format_file_size(1_073_741_824), "1 GB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_icon_class_for_with_extension_fallback() {
|
||||
// Specific MIME types
|
||||
assert_eq!(
|
||||
icon_class_for("doc.pdf", "application/pdf"),
|
||||
"fas fa-file-pdf"
|
||||
);
|
||||
assert_eq!(
|
||||
icon_class_for(
|
||||
"file.docx",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
),
|
||||
"fas fa-file-word"
|
||||
);
|
||||
|
||||
// Extension fallback when MIME is generic
|
||||
assert_eq!(
|
||||
icon_class_for("script.py", "application/octet-stream"),
|
||||
"fas fa-file-code"
|
||||
);
|
||||
assert_eq!(
|
||||
icon_class_for("app.dmg", "application/octet-stream"),
|
||||
"fas fa-hdd"
|
||||
);
|
||||
assert_eq!(
|
||||
icon_class_for("archive.zip", "application/octet-stream"),
|
||||
"fas fa-file-archive"
|
||||
);
|
||||
assert_eq!(
|
||||
icon_class_for("data.xlsx", "application/octet-stream"),
|
||||
"fas fa-file-excel"
|
||||
);
|
||||
assert_eq!(
|
||||
icon_class_for("run.sh", "application/octet-stream"),
|
||||
"fas fa-terminal"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_icon_special_class_for() {
|
||||
// MIME-based
|
||||
assert_eq!(icon_special_class_for("", "image/png"), "image-icon");
|
||||
assert_eq!(icon_special_class_for("", "application/pdf"), "pdf-icon");
|
||||
assert_eq!(
|
||||
icon_special_class_for("", "application/json"),
|
||||
"code-icon json-icon"
|
||||
);
|
||||
|
||||
// Extension-based fallback
|
||||
assert_eq!(
|
||||
icon_special_class_for("main.py", "application/octet-stream"),
|
||||
"code-icon py-icon"
|
||||
);
|
||||
assert_eq!(
|
||||
icon_special_class_for("lib.rs", "application/octet-stream"),
|
||||
"code-icon rust-icon"
|
||||
);
|
||||
assert_eq!(
|
||||
icon_special_class_for("style.css", "application/octet-stream"),
|
||||
"code-icon css-icon"
|
||||
);
|
||||
assert_eq!(
|
||||
icon_special_class_for("data.xlsx", "application/octet-stream"),
|
||||
"spreadsheet-icon"
|
||||
);
|
||||
assert_eq!(
|
||||
icon_special_class_for("backup.tar", "application/octet-stream"),
|
||||
"archive-icon"
|
||||
);
|
||||
assert_eq!(
|
||||
icon_special_class_for("setup.dmg", "application/octet-stream"),
|
||||
"installer-icon"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_category_for() {
|
||||
// MIME-based
|
||||
assert_eq!(category_for("", "image/jpeg"), "Image");
|
||||
assert_eq!(category_for("", "video/webm"), "Video");
|
||||
assert_eq!(category_for("", "audio/ogg"), "Audio");
|
||||
assert_eq!(category_for("", "application/pdf"), "PDF");
|
||||
assert_eq!(category_for("", "application/zip"), "Archive");
|
||||
|
||||
// Extension-based fallback
|
||||
assert_eq!(category_for("main.rs", "application/octet-stream"), "Code");
|
||||
assert_eq!(
|
||||
category_for("photo.jpg", "application/octet-stream"),
|
||||
"Image"
|
||||
);
|
||||
assert_eq!(
|
||||
category_for("notes.md", "application/octet-stream"),
|
||||
"Markdown"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ext_of() {
|
||||
assert_eq!(ext_of("file.txt"), Some("txt"));
|
||||
assert_eq!(ext_of("archive.tar.gz"), Some("gz"));
|
||||
assert_eq!(ext_of("no_extension"), None);
|
||||
assert_eq!(ext_of(".gitignore"), Some("gitignore")); // dot file treated as having extension
|
||||
assert_eq!(ext_of("path/to/file.rs"), Some("rs"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::display_helpers::{format_file_size, icon_class_for, icon_special_class_for, category_for};
|
||||
use super::display_helpers::{
|
||||
category_for, format_file_size, icon_class_for, icon_special_class_for,
|
||||
};
|
||||
|
||||
/// DTO for favorites item, enriched with item metadata via SQL JOIN
|
||||
/// so the frontend does not need N+1 requests to resolve names/sizes.
|
||||
@@ -23,7 +25,6 @@ pub struct FavoriteItemDto {
|
||||
pub created_at: DateTime<Utc>,
|
||||
|
||||
// ── Enriched metadata (resolved via JOIN) ──
|
||||
|
||||
/// Display name of the file or folder
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub item_name: Option<String>,
|
||||
@@ -45,7 +46,6 @@ pub struct FavoriteItemDto {
|
||||
pub modified_at: Option<DateTime<Utc>>,
|
||||
|
||||
// ── Pre-computed display fields ──
|
||||
|
||||
/// FontAwesome icon CSS class (e.g. "fas fa-file-image", "fas fa-folder")
|
||||
pub icon_class: String,
|
||||
|
||||
@@ -70,7 +70,10 @@ impl FavoriteItemDto {
|
||||
self.size_formatted = "--".to_string();
|
||||
} else {
|
||||
let name = self.item_name.as_deref().unwrap_or("");
|
||||
let mime = self.item_mime_type.as_deref().unwrap_or("application/octet-stream");
|
||||
let mime = self
|
||||
.item_mime_type
|
||||
.as_deref()
|
||||
.unwrap_or("application/octet-stream");
|
||||
self.icon_class = icon_class_for(name, mime).to_string();
|
||||
self.icon_special_class = icon_special_class_for(name, mime).to_string();
|
||||
self.category = category_for(name, mime).to_string();
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use crate::domain::entities::file::File;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::display_helpers::{format_file_size, icon_class_for, icon_special_class_for, category_for};
|
||||
use super::display_helpers::{
|
||||
category_for, format_file_size, icon_class_for, icon_special_class_for,
|
||||
};
|
||||
|
||||
/// DTO for file responses
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -31,7 +33,6 @@ pub struct FileDto {
|
||||
pub modified_at: u64,
|
||||
|
||||
// ── Pre-computed display fields ──
|
||||
|
||||
/// FontAwesome icon CSS class (e.g. "fas fa-file-image")
|
||||
pub icon_class: String,
|
||||
|
||||
|
||||
@@ -54,7 +54,6 @@ pub struct FolderDto {
|
||||
pub is_root: bool,
|
||||
|
||||
// ── Pre-computed display fields ──
|
||||
|
||||
/// FontAwesome icon CSS class (always "fas fa-folder")
|
||||
pub icon_class: String,
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::display_helpers::{format_file_size, icon_class_for, icon_special_class_for, category_for};
|
||||
use super::display_helpers::{
|
||||
category_for, format_file_size, icon_class_for, icon_special_class_for,
|
||||
};
|
||||
|
||||
/// DTO for recent items, enriched with item metadata via SQL JOIN
|
||||
/// so the frontend does not need N+1 requests to resolve names/sizes.
|
||||
@@ -23,7 +25,6 @@ pub struct RecentItemDto {
|
||||
pub accessed_at: DateTime<Utc>,
|
||||
|
||||
// ── Enriched metadata (resolved via JOIN) ──
|
||||
|
||||
/// Display name of the file or folder
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub item_name: Option<String>,
|
||||
@@ -41,7 +42,6 @@ pub struct RecentItemDto {
|
||||
pub parent_id: Option<String>,
|
||||
|
||||
// ── Pre-computed display fields ──
|
||||
|
||||
/// FontAwesome icon CSS class (e.g. "fas fa-file-image", "fas fa-folder")
|
||||
pub icon_class: String,
|
||||
|
||||
@@ -66,7 +66,10 @@ impl RecentItemDto {
|
||||
self.size_formatted = "--".to_string();
|
||||
} else {
|
||||
let name = self.item_name.as_deref().unwrap_or("");
|
||||
let mime = self.item_mime_type.as_deref().unwrap_or("application/octet-stream");
|
||||
let mime = self
|
||||
.item_mime_type
|
||||
.as_deref()
|
||||
.unwrap_or("application/octet-stream");
|
||||
self.icon_class = icon_class_for(name, mime).to_string();
|
||||
self.icon_special_class = icon_special_class_for(name, mime).to_string();
|
||||
self.category = category_for(name, mime).to_string();
|
||||
|
||||
@@ -56,9 +56,5 @@ pub trait FavoritesRepositoryPort: Send + Sync + 'static {
|
||||
|
||||
/// Insert multiple items in a single transaction.
|
||||
/// Returns the number of rows actually inserted (ignoring duplicates).
|
||||
async fn add_favorites_batch(
|
||||
&self,
|
||||
user_id: &str,
|
||||
items: &[(String, String)],
|
||||
) -> Result<u64>;
|
||||
async fn add_favorites_batch(&self, user_id: &str, items: &[(String, String)]) -> Result<u64>;
|
||||
}
|
||||
|
||||
@@ -47,17 +47,30 @@ pub trait FolderUseCase: Send + Sync + 'static {
|
||||
) -> Result<crate::application::dtos::pagination::PaginatedResponseDto<FolderDto>, DomainError>;
|
||||
|
||||
/// Renames a folder (ownership verified against caller_id)
|
||||
async fn rename_folder(&self, id: &str, dto: RenameFolderDto, caller_id: &str)
|
||||
-> Result<FolderDto, DomainError>;
|
||||
async fn rename_folder(
|
||||
&self,
|
||||
id: &str,
|
||||
dto: RenameFolderDto,
|
||||
caller_id: &str,
|
||||
) -> Result<FolderDto, DomainError>;
|
||||
|
||||
/// Moves a folder to another parent (ownership verified against caller_id)
|
||||
async fn move_folder(&self, id: &str, dto: MoveFolderDto, caller_id: &str) -> Result<FolderDto, DomainError>;
|
||||
async fn move_folder(
|
||||
&self,
|
||||
id: &str,
|
||||
dto: MoveFolderDto,
|
||||
caller_id: &str,
|
||||
) -> Result<FolderDto, DomainError>;
|
||||
|
||||
/// Deletes a folder (ownership verified against caller_id)
|
||||
async fn delete_folder(&self, id: &str, caller_id: &str) -> Result<(), DomainError>;
|
||||
|
||||
/// Creates a root-level home folder for a user during registration.
|
||||
async fn create_home_folder(&self, user_id: &str, name: String) -> Result<FolderDto, DomainError>;
|
||||
async fn create_home_folder(
|
||||
&self,
|
||||
user_id: &str,
|
||||
name: String,
|
||||
) -> Result<FolderDto, DomainError>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -317,7 +317,8 @@ impl AuthApplicationService {
|
||||
let created_user = self.user_storage.create_user(user).await?;
|
||||
|
||||
// Create personal folder for the user
|
||||
self.create_personal_folder(&dto.username, created_user.id()).await;
|
||||
self.create_personal_folder(&dto.username, created_user.id())
|
||||
.await;
|
||||
|
||||
tracing::info!("User registered: {}", created_user.id());
|
||||
Ok(UserDto::from(created_user))
|
||||
@@ -659,7 +660,8 @@ impl AuthApplicationService {
|
||||
let created_user = self.user_storage.create_user(user).await?;
|
||||
|
||||
// 5. Create personal folder for the new admin
|
||||
self.create_personal_folder(&dto.username, created_user.id()).await;
|
||||
self.create_personal_folder(&dto.username, created_user.id())
|
||||
.await;
|
||||
|
||||
tracing::info!("Custom admin created: {}", created_user.id());
|
||||
Ok(UserDto::from(created_user))
|
||||
@@ -765,7 +767,8 @@ impl AuthApplicationService {
|
||||
}
|
||||
|
||||
// Create personal folder
|
||||
self.create_personal_folder(&dto.username, created.id()).await;
|
||||
self.create_personal_folder(&dto.username, created.id())
|
||||
.await;
|
||||
|
||||
tracing::info!("Admin created user: {} ({})", dto.username, created.id());
|
||||
Ok(UserDto::from(created))
|
||||
|
||||
@@ -702,22 +702,20 @@ impl BatchOperationService {
|
||||
// Add individual files at the root of the ZIP
|
||||
for file_id in &file_ids {
|
||||
match self.file_retrieval.get_file(file_id).await {
|
||||
Ok(file_dto) => {
|
||||
match self.file_retrieval.get_file_content(file_id).await {
|
||||
Ok(content) => {
|
||||
if let Err(e) = zip.start_file(&file_dto.name, options) {
|
||||
info!("Could not start zip entry for {}: {}", file_dto.name, e);
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = zip.write_all(&content) {
|
||||
info!("Could not write zip entry for {}: {}", file_dto.name, e);
|
||||
}
|
||||
Ok(file_dto) => match self.file_retrieval.get_file_content(file_id).await {
|
||||
Ok(content) => {
|
||||
if let Err(e) = zip.start_file(&file_dto.name, options) {
|
||||
info!("Could not start zip entry for {}: {}", file_dto.name, e);
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
info!("Could not read file content {}: {}", file_id, e);
|
||||
if let Err(e) = zip.write_all(&content) {
|
||||
info!("Could not write zip entry for {}: {}", file_dto.name, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
info!("Could not read file content {}: {}", file_id, e);
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
info!("Could not get file metadata {}: {}", file_id, e);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use crate::application::dtos::favorites_dto::{BatchFavoritesResult, BatchFavoritesStats, FavoriteItemDto};
|
||||
use crate::application::dtos::favorites_dto::{
|
||||
BatchFavoritesResult, BatchFavoritesStats, FavoriteItemDto,
|
||||
};
|
||||
use crate::application::ports::favorites_ports::{FavoritesRepositoryPort, FavoritesUseCase};
|
||||
use crate::common::errors::{DomainError, ErrorKind, Result};
|
||||
use async_trait::async_trait;
|
||||
|
||||
@@ -112,7 +112,11 @@ impl FolderService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_home_folder(&self, _user_id: &str, _name: String) -> Result<FolderDto, DomainError> {
|
||||
async fn create_home_folder(
|
||||
&self,
|
||||
_user_id: &str,
|
||||
_name: String,
|
||||
) -> Result<FolderDto, DomainError> {
|
||||
Ok(FolderDto::empty())
|
||||
}
|
||||
}
|
||||
@@ -159,7 +163,11 @@ impl FolderUseCase for FolderService {
|
||||
}
|
||||
|
||||
/// Creates a root-level home folder for a user during registration.
|
||||
async fn create_home_folder(&self, user_id: &str, name: String) -> Result<FolderDto, DomainError> {
|
||||
async fn create_home_folder(
|
||||
&self,
|
||||
user_id: &str,
|
||||
name: String,
|
||||
) -> Result<FolderDto, DomainError> {
|
||||
let folder = self
|
||||
.folder_storage
|
||||
.create_home_folder(user_id, name)
|
||||
@@ -256,12 +264,7 @@ impl FolderUseCase for FolderService {
|
||||
|
||||
let (folders, total_items) = self
|
||||
.folder_storage
|
||||
.list_folders_paginated(
|
||||
parent_id,
|
||||
pagination.offset(),
|
||||
pagination.limit(),
|
||||
true,
|
||||
)
|
||||
.list_folders_paginated(parent_id, pagination.offset(), pagination.limit(), true)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
@@ -354,7 +357,9 @@ impl FolderUseCase for FolderService {
|
||||
if existing_folder.owner_id() != Some(caller_id) {
|
||||
tracing::warn!(
|
||||
"rename_folder: user '{}' attempted to rename folder '{}' owned by '{:?}'",
|
||||
caller_id, id, existing_folder.owner_id()
|
||||
caller_id,
|
||||
id,
|
||||
existing_folder.owner_id()
|
||||
);
|
||||
return Err(DomainError::not_found("Folder", id));
|
||||
}
|
||||
@@ -412,7 +417,12 @@ impl FolderUseCase for FolderService {
|
||||
}
|
||||
|
||||
/// Moves a folder to a new parent after verifying ownership.
|
||||
async fn move_folder(&self, id: &str, dto: MoveFolderDto, caller_id: &str) -> Result<FolderDto, DomainError> {
|
||||
async fn move_folder(
|
||||
&self,
|
||||
id: &str,
|
||||
dto: MoveFolderDto,
|
||||
caller_id: &str,
|
||||
) -> Result<FolderDto, DomainError> {
|
||||
// Verify the source folder exists and belongs to the caller
|
||||
let source_folder = self.folder_storage.get_folder(id).await.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
@@ -424,7 +434,9 @@ impl FolderUseCase for FolderService {
|
||||
if source_folder.owner_id() != Some(caller_id) {
|
||||
tracing::warn!(
|
||||
"move_folder: user '{}' attempted to move folder '{}' owned by '{:?}'",
|
||||
caller_id, id, source_folder.owner_id()
|
||||
caller_id,
|
||||
id,
|
||||
source_folder.owner_id()
|
||||
);
|
||||
return Err(DomainError::not_found("Folder", id));
|
||||
}
|
||||
@@ -517,7 +529,9 @@ impl FolderUseCase for FolderService {
|
||||
if folder.owner_id() != Some(caller_id) {
|
||||
tracing::warn!(
|
||||
"delete_folder: user '{}' attempted to delete folder '{}' owned by '{:?}'",
|
||||
caller_id, id, folder.owner_id()
|
||||
caller_id,
|
||||
id,
|
||||
folder.owner_id()
|
||||
);
|
||||
return Err(DomainError::not_found("Folder", id));
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::time;
|
||||
|
||||
use crate::application::dtos::display_helpers::{
|
||||
category_for, icon_class_for, icon_special_class_for,
|
||||
};
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::dtos::folder_dto::FolderDto;
|
||||
use crate::application::dtos::display_helpers::{icon_class_for, icon_special_class_for, category_for};
|
||||
use crate::application::dtos::search_dto::{
|
||||
SearchCriteriaDto, SearchFileResultDto, SearchFolderResultDto, SearchResultsDto,
|
||||
SearchSuggestionItem, SearchSuggestionsDto,
|
||||
@@ -287,67 +289,67 @@ impl SearchService {
|
||||
folder_repo: Arc<dyn FolderStoragePort>,
|
||||
current_folder_id: Option<String>,
|
||||
criteria: Arc<SearchCriteriaDto>,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(Vec<FileDto>, Vec<FolderDto>)>> + Send>> {
|
||||
) -> std::pin::Pin<
|
||||
Box<dyn std::future::Future<Output = Result<(Vec<FileDto>, Vec<FolderDto>)>> + Send>,
|
||||
> {
|
||||
Box::pin(async move {
|
||||
// List files in the current folder
|
||||
let files = file_repo
|
||||
.list_files(current_folder_id.as_deref())
|
||||
.await?;
|
||||
// List files in the current folder
|
||||
let files = file_repo.list_files(current_folder_id.as_deref()).await?;
|
||||
|
||||
let filtered_files: Vec<FileDto> = files
|
||||
.into_iter()
|
||||
.map(FileDto::from)
|
||||
.filter(|file| passes_file_filter(file, &criteria))
|
||||
.collect();
|
||||
|
||||
let mut all_files = filtered_files;
|
||||
let mut all_folders: Vec<FolderDto> = Vec::new();
|
||||
|
||||
// If recursive, process subfolders in parallel
|
||||
if criteria.recursive {
|
||||
let folders = folder_repo
|
||||
.list_folders(current_folder_id.as_deref())
|
||||
.await?;
|
||||
|
||||
let folder_dtos: Vec<FolderDto> = folders
|
||||
let filtered_files: Vec<FileDto> = files
|
||||
.into_iter()
|
||||
.map(FolderDto::from)
|
||||
.filter(|f| passes_folder_filter(f, &criteria))
|
||||
.map(FileDto::from)
|
||||
.filter(|file| passes_file_filter(file, &criteria))
|
||||
.collect();
|
||||
|
||||
all_folders.extend(folder_dtos.iter().cloned());
|
||||
let mut all_files = filtered_files;
|
||||
let mut all_folders: Vec<FolderDto> = Vec::new();
|
||||
|
||||
// Spawn parallel tasks for each subfolder
|
||||
let mut handles = Vec::with_capacity(folder_dtos.len());
|
||||
for subfolder in &folder_dtos {
|
||||
let fr = file_repo.clone();
|
||||
let fdr = folder_repo.clone();
|
||||
let crit = criteria.clone();
|
||||
let folder_id = subfolder.id.clone();
|
||||
// If recursive, process subfolders in parallel
|
||||
if criteria.recursive {
|
||||
let folders = folder_repo
|
||||
.list_folders(current_folder_id.as_deref())
|
||||
.await?;
|
||||
|
||||
handles.push(tokio::spawn(async move {
|
||||
Self::search_parallel(fr, fdr, Some(folder_id), crit).await
|
||||
}));
|
||||
}
|
||||
let folder_dtos: Vec<FolderDto> = folders
|
||||
.into_iter()
|
||||
.map(FolderDto::from)
|
||||
.filter(|f| passes_folder_filter(f, &criteria))
|
||||
.collect();
|
||||
|
||||
// Collect results from all parallel tasks
|
||||
for handle in handles {
|
||||
match handle.await {
|
||||
Ok(Ok((sub_files, sub_folders))) => {
|
||||
all_files.extend(sub_files);
|
||||
all_folders.extend(sub_folders);
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!("Parallel search subtask error: {}", e);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Parallel search task join error: {}", e);
|
||||
all_folders.extend(folder_dtos.iter().cloned());
|
||||
|
||||
// Spawn parallel tasks for each subfolder
|
||||
let mut handles = Vec::with_capacity(folder_dtos.len());
|
||||
for subfolder in &folder_dtos {
|
||||
let fr = file_repo.clone();
|
||||
let fdr = folder_repo.clone();
|
||||
let crit = criteria.clone();
|
||||
let folder_id = subfolder.id.clone();
|
||||
|
||||
handles.push(tokio::spawn(async move {
|
||||
Self::search_parallel(fr, fdr, Some(folder_id), crit).await
|
||||
}));
|
||||
}
|
||||
|
||||
// Collect results from all parallel tasks
|
||||
for handle in handles {
|
||||
match handle.await {
|
||||
Ok(Ok((sub_files, sub_folders))) => {
|
||||
all_files.extend(sub_files);
|
||||
all_folders.extend(sub_folders);
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!("Parallel search subtask error: {}", e);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Parallel search task join error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((all_files, all_folders))
|
||||
Ok((all_files, all_folders))
|
||||
}) // end Box::pin
|
||||
}
|
||||
|
||||
@@ -560,13 +562,11 @@ impl SearchUseCase for SearchService {
|
||||
match criteria.sort_by.as_str() {
|
||||
"name" => {
|
||||
enriched_files.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
enriched_folders
|
||||
.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
enriched_folders.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
}
|
||||
"name_desc" => {
|
||||
enriched_files.sort_by(|a, b| b.name.to_lowercase().cmp(&a.name.to_lowercase()));
|
||||
enriched_folders
|
||||
.sort_by(|a, b| b.name.to_lowercase().cmp(&a.name.to_lowercase()));
|
||||
enriched_folders.sort_by(|a, b| b.name.to_lowercase().cmp(&a.name.to_lowercase()));
|
||||
}
|
||||
"date" => {
|
||||
enriched_files.sort_by(|a, b| a.modified_at.cmp(&b.modified_at));
|
||||
|
||||
Reference in New Issue
Block a user