diff --git a/src/application/dtos/display_helpers.rs b/src/application/dtos/display_helpers.rs index 9062ee5f..9f562bfb 100644 --- a/src/application/dtos/display_helpers.rs +++ b/src/application/dtos/display_helpers.rs @@ -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")); + } +} diff --git a/src/application/dtos/favorites_dto.rs b/src/application/dtos/favorites_dto.rs index e7a73e5a..d665bbc0 100644 --- a/src/application/dtos/favorites_dto.rs +++ b/src/application/dtos/favorites_dto.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, // ── Enriched metadata (resolved via JOIN) ── - /// Display name of the file or folder #[serde(skip_serializing_if = "Option::is_none")] pub item_name: Option, @@ -45,7 +46,6 @@ pub struct FavoriteItemDto { pub modified_at: Option>, // ── 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(); diff --git a/src/application/dtos/file_dto.rs b/src/application/dtos/file_dto.rs index f1e3a202..ca955c26 100644 --- a/src/application/dtos/file_dto.rs +++ b/src/application/dtos/file_dto.rs @@ -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, diff --git a/src/application/dtos/folder_dto.rs b/src/application/dtos/folder_dto.rs index 7b67c0de..2c6466d8 100644 --- a/src/application/dtos/folder_dto.rs +++ b/src/application/dtos/folder_dto.rs @@ -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, diff --git a/src/application/dtos/recent_dto.rs b/src/application/dtos/recent_dto.rs index 3abf7ddf..4b6f00f4 100644 --- a/src/application/dtos/recent_dto.rs +++ b/src/application/dtos/recent_dto.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 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, // ── Enriched metadata (resolved via JOIN) ── - /// Display name of the file or folder #[serde(skip_serializing_if = "Option::is_none")] pub item_name: Option, @@ -41,7 +42,6 @@ pub struct RecentItemDto { pub parent_id: Option, // ── 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(); diff --git a/src/application/ports/favorites_ports.rs b/src/application/ports/favorites_ports.rs index d361834a..cb8d71e6 100644 --- a/src/application/ports/favorites_ports.rs +++ b/src/application/ports/favorites_ports.rs @@ -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; + async fn add_favorites_batch(&self, user_id: &str, items: &[(String, String)]) -> Result; } diff --git a/src/application/ports/inbound.rs b/src/application/ports/inbound.rs index 0e972258..a8bd53c5 100644 --- a/src/application/ports/inbound.rs +++ b/src/application/ports/inbound.rs @@ -47,17 +47,30 @@ pub trait FolderUseCase: Send + Sync + 'static { ) -> Result, DomainError>; /// Renames a folder (ownership verified against caller_id) - async fn rename_folder(&self, id: &str, dto: RenameFolderDto, caller_id: &str) - -> Result; + async fn rename_folder( + &self, + id: &str, + dto: RenameFolderDto, + caller_id: &str, + ) -> Result; /// Moves a folder to another parent (ownership verified against caller_id) - async fn move_folder(&self, id: &str, dto: MoveFolderDto, caller_id: &str) -> Result; + async fn move_folder( + &self, + id: &str, + dto: MoveFolderDto, + caller_id: &str, + ) -> Result; /// 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; + async fn create_home_folder( + &self, + user_id: &str, + name: String, + ) -> Result; } /** diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index d3908d2a..57b585bf 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -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)) diff --git a/src/application/services/batch_operations.rs b/src/application/services/batch_operations.rs index 95b55345..5631e284 100644 --- a/src/application/services/batch_operations.rs +++ b/src/application/services/batch_operations.rs @@ -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); } diff --git a/src/application/services/favorites_service.rs b/src/application/services/favorites_service.rs index 4bab5eb7..d498ee4e 100644 --- a/src/application/services/favorites_service.rs +++ b/src/application/services/favorites_service.rs @@ -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; diff --git a/src/application/services/folder_service.rs b/src/application/services/folder_service.rs index e4c61da8..3dc4b74e 100644 --- a/src/application/services/folder_service.rs +++ b/src/application/services/folder_service.rs @@ -112,7 +112,11 @@ impl FolderService { Ok(()) } - async fn create_home_folder(&self, _user_id: &str, _name: String) -> Result { + async fn create_home_folder( + &self, + _user_id: &str, + _name: String, + ) -> Result { 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 { + async fn create_home_folder( + &self, + user_id: &str, + name: String, + ) -> Result { 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 { + async fn move_folder( + &self, + id: &str, + dto: MoveFolderDto, + caller_id: &str, + ) -> Result { // 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)); } diff --git a/src/application/services/search_service.rs b/src/application/services/search_service.rs index d87c1f0b..37370c37 100644 --- a/src/application/services/search_service.rs +++ b/src/application/services/search_service.rs @@ -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, current_folder_id: Option, criteria: Arc, - ) -> std::pin::Pin, Vec)>> + Send>> { + ) -> std::pin::Pin< + Box, Vec)>> + 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 = 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 = 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 = folders + let filtered_files: Vec = 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 = 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 = 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)); diff --git a/src/common/stubs.rs b/src/common/stubs.rs index 4011150e..8c00846d 100644 --- a/src/common/stubs.rs +++ b/src/common/stubs.rs @@ -19,7 +19,9 @@ use crate::application::dtos::folder_dto::{ CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto, }; use crate::application::dtos::pagination::{PaginatedResponseDto, PaginationRequestDto}; -use crate::application::dtos::search_dto::{SearchCriteriaDto, SearchResultsDto, SearchSuggestionsDto}; +use crate::application::dtos::search_dto::{ + SearchCriteriaDto, SearchResultsDto, SearchSuggestionsDto, +}; use crate::application::ports::compression_ports::{CompressionLevel, CompressionPort}; use crate::application::ports::file_ports::{ FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase, FileUseCaseFactory, @@ -415,7 +417,12 @@ impl FolderUseCase for StubFolderUseCase { Ok(FolderDto::default()) } - async fn move_folder(&self, _id: &str, _dto: MoveFolderDto, _caller_id: &str) -> Result { + async fn move_folder( + &self, + _id: &str, + _dto: MoveFolderDto, + _caller_id: &str, + ) -> Result { Ok(FolderDto::default()) } @@ -423,7 +430,11 @@ impl FolderUseCase for StubFolderUseCase { Ok(()) } - async fn create_home_folder(&self, _user_id: &str, _name: String) -> Result { + async fn create_home_folder( + &self, + _user_id: &str, + _name: String, + ) -> Result { Ok(FolderDto::default()) } } diff --git a/src/domain/entities/folder.rs b/src/domain/entities/folder.rs index 5f70166b..c719fac8 100644 --- a/src/domain/entities/folder.rs +++ b/src/domain/entities/folder.rs @@ -102,7 +102,15 @@ impl Folder { created_at: u64, modified_at: u64, ) -> FolderResult { - Self::with_timestamps_and_owner(id, name, storage_path, parent_id, None, created_at, modified_at) + Self::with_timestamps_and_owner( + id, + name, + storage_path, + parent_id, + None, + created_at, + modified_at, + ) } /// Creates a folder with specific timestamps and owner (for DB reconstruction) diff --git a/src/domain/repositories/folder_repository.rs b/src/domain/repositories/folder_repository.rs index ff6e99e3..3f1d962a 100644 --- a/src/domain/repositories/folder_repository.rs +++ b/src/domain/repositories/folder_repository.rs @@ -102,9 +102,5 @@ pub trait FolderRepository: Send + Sync + 'static { /// Creates a root-level home folder for a user. /// This is used during user registration to create the user's personal folder. - async fn create_home_folder( - &self, - user_id: &str, - name: String, - ) -> Result; + async fn create_home_folder(&self, user_id: &str, name: String) -> Result; } diff --git a/src/infrastructure/repositories/pg/favorites_pg_repository.rs b/src/infrastructure/repositories/pg/favorites_pg_repository.rs index d758027c..4b88bf7f 100644 --- a/src/infrastructure/repositories/pg/favorites_pg_repository.rs +++ b/src/infrastructure/repositories/pg/favorites_pg_repository.rs @@ -60,23 +60,26 @@ impl FavoritesRepositoryPort for FavoritesPgRepository { let favorites = rows .iter() - .map(|row| FavoriteItemDto { - id: row.get("id"), - user_id: row.get("user_id"), - item_id: row.get("item_id"), - item_type: row.get("item_type"), - created_at: row.get("created_at"), - item_name: row.try_get("item_name").ok(), - item_size: row.try_get("item_size").ok(), - item_mime_type: row.try_get("item_mime_type").ok(), - parent_id: row.try_get("parent_id").ok(), - modified_at: row.try_get("modified_at").ok(), - // Temporary defaults; with_display_fields() computes the real values - icon_class: String::new(), - icon_special_class: String::new(), - category: String::new(), - size_formatted: String::new(), - }.with_display_fields()) + .map(|row| { + FavoriteItemDto { + id: row.get("id"), + user_id: row.get("user_id"), + item_id: row.get("item_id"), + item_type: row.get("item_type"), + created_at: row.get("created_at"), + item_name: row.try_get("item_name").ok(), + item_size: row.try_get("item_size").ok(), + item_mime_type: row.try_get("item_mime_type").ok(), + parent_id: row.try_get("parent_id").ok(), + modified_at: row.try_get("modified_at").ok(), + // Temporary defaults; with_display_fields() computes the real values + icon_class: String::new(), + icon_special_class: String::new(), + category: String::new(), + size_formatted: String::new(), + } + .with_display_fields() + }) .collect(); Ok(favorites) @@ -163,11 +166,7 @@ impl FavoritesRepositoryPort for FavoritesPgRepository { Ok(row.try_get("is_favorite").unwrap_or(false)) } - async fn add_favorites_batch( - &self, - user_id: &str, - items: &[(String, String)], - ) -> Result { + async fn add_favorites_batch(&self, user_id: &str, items: &[(String, String)]) -> Result { if items.is_empty() { return Ok(0); } diff --git a/src/infrastructure/repositories/pg/file_blob_read_repository.rs b/src/infrastructure/repositories/pg/file_blob_read_repository.rs index e973db62..00d956ec 100644 --- a/src/infrastructure/repositories/pg/file_blob_read_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_read_repository.rs @@ -140,10 +140,18 @@ impl FileReadPort for FileBlobReadRepository { } async fn list_files(&self, folder_id: Option<&str>) -> Result, DomainError> { - let rows: Vec<(String, String, Option, Option, i64, String, i64, i64)> = - if let Some(fid) = folder_id { - sqlx::query_as( - r#" + let rows: Vec<( + String, + String, + Option, + Option, + i64, + String, + i64, + i64, + )> = if let Some(fid) = folder_id { + sqlx::query_as( + r#" SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path, fi.size, fi.mime_type, EXTRACT(EPOCH FROM fi.created_at)::bigint, @@ -153,13 +161,13 @@ impl FileReadPort for FileBlobReadRepository { WHERE fi.folder_id = $1::uuid AND NOT fi.is_trashed ORDER BY fi.name "#, - ) - .bind(fid) - .fetch_all(self.pool.as_ref()) - .await - } else { - sqlx::query_as( - r#" + ) + .bind(fid) + .fetch_all(self.pool.as_ref()) + .await + } else { + sqlx::query_as( + r#" SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path, fi.size, fi.mime_type, EXTRACT(EPOCH FROM fi.created_at)::bigint, @@ -169,11 +177,11 @@ impl FileReadPort for FileBlobReadRepository { WHERE fi.folder_id IS NULL AND NOT fi.is_trashed ORDER BY fi.name "#, - ) - .fetch_all(self.pool.as_ref()) - .await - } - .map_err(|e| DomainError::internal_error("FileBlobRead", format!("list: {e}")))?; + ) + .fetch_all(self.pool.as_ref()) + .await + } + .map_err(|e| DomainError::internal_error("FileBlobRead", format!("list: {e}")))?; rows.into_iter() .map(|(id, name, fid, fpath, size, mime, ca, ma)| { @@ -289,7 +297,19 @@ impl FileReadPort for FileBlobReadRepository { let row = if folder_path.is_empty() { // File at root level (no parent folder) - sqlx::query_as::<_, (String, String, Option, Option, i64, String, i64, i64)>( + sqlx::query_as::< + _, + ( + String, + String, + Option, + Option, + i64, + String, + i64, + i64, + ), + >( r#" SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path, fi.size, fi.mime_type, @@ -305,7 +325,19 @@ impl FileReadPort for FileBlobReadRepository { .await } else { // File inside a folder — look up by folder path + filename - sqlx::query_as::<_, (String, String, Option, Option, i64, String, i64, i64)>( + sqlx::query_as::< + _, + ( + String, + String, + Option, + Option, + i64, + String, + i64, + i64, + ), + >( r#" SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path, fi.size, fi.mime_type, diff --git a/src/infrastructure/repositories/pg/file_blob_write_repository.rs b/src/infrastructure/repositories/pg/file_blob_write_repository.rs index b46ebb49..7eecb9d4 100644 --- a/src/infrastructure/repositories/pg/file_blob_write_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_write_repository.rs @@ -55,16 +55,18 @@ impl FileBlobWriteRepository { ) -> Result, DomainError> { match folder_id { Some(fid) => { - let path: String = sqlx::query_scalar( - "SELECT path FROM storage.folders WHERE id = $1::uuid", - ) - .bind(fid) - .fetch_optional(self.pool.as_ref()) - .await - .map_err(|e| { - DomainError::internal_error("FileBlobWrite", format!("folder path: {e}")) - })? - .ok_or_else(|| DomainError::not_found("Folder", fid))?; + let path: String = + sqlx::query_scalar("SELECT path FROM storage.folders WHERE id = $1::uuid") + .bind(fid) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| { + DomainError::internal_error( + "FileBlobWrite", + format!("folder path: {e}"), + ) + })? + .ok_or_else(|| DomainError::not_found("Folder", fid))?; Ok(Some(path)) } None => Ok(None), @@ -179,7 +181,16 @@ impl FileWritePort for FileBlobWriteRepository { ); let folder_path = self.lookup_folder_path(folder_id.as_deref()).await?; - Self::row_to_file(row.0, name, folder_id, folder_path, size, content_type, row.1, row.2) + Self::row_to_file( + row.0, + name, + folder_id, + folder_path, + size, + content_type, + row.1, + row.2, + ) } async fn save_file_from_temp( diff --git a/src/infrastructure/repositories/pg/folder_db_repository.rs b/src/infrastructure/repositories/pg/folder_db_repository.rs index b6855d04..676a8651 100644 --- a/src/infrastructure/repositories/pg/folder_db_repository.rs +++ b/src/infrastructure/repositories/pg/folder_db_repository.rs @@ -175,9 +175,10 @@ impl FolderRepository for FolderDbRepository { } async fn list_folders(&self, parent_id: Option<&str>) -> Result, DomainError> { - let rows: Vec<(String, String, String, Option, String, i64, i64)> = if let Some(pid) = parent_id { - sqlx::query_as( - r#" + let rows: Vec<(String, String, String, Option, String, i64, i64)> = + if let Some(pid) = parent_id { + sqlx::query_as( + r#" SELECT id::text, name, path, parent_id::text, user_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint @@ -185,13 +186,13 @@ impl FolderRepository for FolderDbRepository { WHERE parent_id = $1::uuid AND NOT is_trashed ORDER BY name "#, - ) - .bind(pid) - .fetch_all(self.pool()) - .await - } else { - sqlx::query_as( - r#" + ) + .bind(pid) + .fetch_all(self.pool()) + .await + } else { + sqlx::query_as( + r#" SELECT id::text, name, path, parent_id::text, user_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint @@ -199,11 +200,11 @@ impl FolderRepository for FolderDbRepository { WHERE parent_id IS NULL AND NOT is_trashed ORDER BY name "#, - ) - .fetch_all(self.pool()) - .await - } - .map_err(|e| DomainError::internal_error("FolderDb", format!("list: {e}")))?; + ) + .fetch_all(self.pool()) + .await + } + .map_err(|e| DomainError::internal_error("FolderDb", format!("list: {e}")))?; rows.into_iter() .map(|(id, name, path, pid, uid, ca, ma)| { @@ -217,9 +218,10 @@ impl FolderRepository for FolderDbRepository { parent_id: Option<&str>, owner_id: &str, ) -> Result, DomainError> { - let rows: Vec<(String, String, String, Option, String, i64, i64)> = if let Some(pid) = parent_id { - sqlx::query_as( - r#" + let rows: Vec<(String, String, String, Option, String, i64, i64)> = + if let Some(pid) = parent_id { + sqlx::query_as( + r#" SELECT id::text, name, path, parent_id::text, user_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint @@ -227,14 +229,14 @@ impl FolderRepository for FolderDbRepository { WHERE parent_id = $1::uuid AND user_id = $2 AND NOT is_trashed ORDER BY name "#, - ) - .bind(pid) - .bind(owner_id) - .fetch_all(self.pool()) - .await - } else { - sqlx::query_as( - r#" + ) + .bind(pid) + .bind(owner_id) + .fetch_all(self.pool()) + .await + } else { + sqlx::query_as( + r#" SELECT id::text, name, path, parent_id::text, user_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint @@ -242,12 +244,12 @@ impl FolderRepository for FolderDbRepository { WHERE parent_id IS NULL AND user_id = $1 AND NOT is_trashed ORDER BY name "#, - ) - .bind(owner_id) - .fetch_all(self.pool()) - .await - } - .map_err(|e| DomainError::internal_error("FolderDb", format!("list_by_owner: {e}")))?; + ) + .bind(owner_id) + .fetch_all(self.pool()) + .await + } + .map_err(|e| DomainError::internal_error("FolderDb", format!("list_by_owner: {e}")))?; rows.into_iter() .map(|(id, name, path, pid, uid, ca, ma)| { @@ -284,9 +286,10 @@ impl FolderRepository for FolderDbRepository { None }; - let rows: Vec<(String, String, String, Option, String, i64, i64)> = if let Some(pid) = parent_id { - sqlx::query_as( - r#" + let rows: Vec<(String, String, String, Option, String, i64, i64)> = + if let Some(pid) = parent_id { + sqlx::query_as( + r#" SELECT id::text, name, path, parent_id::text, user_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint @@ -295,15 +298,15 @@ impl FolderRepository for FolderDbRepository { ORDER BY name LIMIT $2 OFFSET $3 "#, - ) - .bind(pid) - .bind(limit as i64) - .bind(offset as i64) - .fetch_all(self.pool()) - .await - } else { - sqlx::query_as( - r#" + ) + .bind(pid) + .bind(limit as i64) + .bind(offset as i64) + .fetch_all(self.pool()) + .await + } else { + sqlx::query_as( + r#" SELECT id::text, name, path, parent_id::text, user_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint @@ -312,13 +315,13 @@ impl FolderRepository for FolderDbRepository { ORDER BY name LIMIT $1 OFFSET $2 "#, - ) - .bind(limit as i64) - .bind(offset as i64) - .fetch_all(self.pool()) - .await - } - .map_err(|e| DomainError::internal_error("FolderDb", format!("paginate: {e}")))?; + ) + .bind(limit as i64) + .bind(offset as i64) + .fetch_all(self.pool()) + .await + } + .map_err(|e| DomainError::internal_error("FolderDb", format!("paginate: {e}")))?; let folders: Result, DomainError> = rows .into_iter() @@ -360,9 +363,10 @@ impl FolderRepository for FolderDbRepository { None }; - let rows: Vec<(String, String, String, Option, String, i64, i64)> = if let Some(pid) = parent_id { - sqlx::query_as( - r#" + let rows: Vec<(String, String, String, Option, String, i64, i64)> = + if let Some(pid) = parent_id { + sqlx::query_as( + r#" SELECT id::text, name, path, parent_id::text, user_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint @@ -371,16 +375,16 @@ impl FolderRepository for FolderDbRepository { ORDER BY name LIMIT $3 OFFSET $4 "#, - ) - .bind(pid) - .bind(owner_id) - .bind(limit as i64) - .bind(offset as i64) - .fetch_all(self.pool()) - .await - } else { - sqlx::query_as( - r#" + ) + .bind(pid) + .bind(owner_id) + .bind(limit as i64) + .bind(offset as i64) + .fetch_all(self.pool()) + .await + } else { + sqlx::query_as( + r#" SELECT id::text, name, path, parent_id::text, user_id, EXTRACT(EPOCH FROM created_at)::bigint, EXTRACT(EPOCH FROM updated_at)::bigint @@ -389,14 +393,16 @@ impl FolderRepository for FolderDbRepository { ORDER BY name LIMIT $2 OFFSET $3 "#, - ) - .bind(owner_id) - .bind(limit as i64) - .bind(offset as i64) - .fetch_all(self.pool()) - .await - } - .map_err(|e| DomainError::internal_error("FolderDb", format!("paginate_by_owner: {e}")))?; + ) + .bind(owner_id) + .bind(limit as i64) + .bind(offset as i64) + .fetch_all(self.pool()) + .await + } + .map_err(|e| { + DomainError::internal_error("FolderDb", format!("paginate_by_owner: {e}")) + })?; let folders: Result, DomainError> = rows .into_iter() @@ -503,14 +509,13 @@ impl FolderRepository for FolderDbRepository { } async fn get_folder_path(&self, id: &str) -> Result { - let path: String = sqlx::query_scalar( - "SELECT path FROM storage.folders WHERE id = $1::uuid", - ) - .bind(id) - .fetch_optional(self.pool()) - .await - .map_err(|e| DomainError::internal_error("FolderDb", format!("get_path: {e}")))? - .ok_or_else(|| DomainError::not_found("Folder", id))?; + let path: String = + sqlx::query_scalar("SELECT path FROM storage.folders WHERE id = $1::uuid") + .bind(id) + .fetch_optional(self.pool()) + .await + .map_err(|e| DomainError::internal_error("FolderDb", format!("get_path: {e}")))? + .ok_or_else(|| DomainError::not_found("Folder", id))?; Ok(StoragePath::from_string(&path)) } @@ -615,11 +620,7 @@ impl FolderRepository for FolderDbRepository { Ok(()) } - async fn create_home_folder( - &self, - user_id: &str, - name: String, - ) -> Result { + async fn create_home_folder(&self, user_id: &str, name: String) -> Result { let row = sqlx::query_as::<_, (String, String, i64, i64)>( r#" INSERT INTO storage.folders (name, parent_id, user_id) @@ -638,7 +639,15 @@ impl FolderRepository for FolderDbRepository { .map_err(|e| DomainError::internal_error("FolderDb", format!("home folder: {e}")))?; match row { - Some((id, path, ca, ma)) => Self::row_to_folder(id, name.clone(), path, None, Some(user_id.to_string()), ca, ma), + Some((id, path, ca, ma)) => Self::row_to_folder( + id, + name.clone(), + path, + None, + Some(user_id.to_string()), + ca, + ma, + ), None => { // Already exists — fetch it let existing = sqlx::query_as::<_, (String, String, i64, i64)>( @@ -656,7 +665,15 @@ impl FolderRepository for FolderDbRepository { .fetch_one(self.pool()) .await .map_err(|e| DomainError::internal_error("FolderDb", format!("home fetch: {e}")))?; - Self::row_to_folder(existing.0, name, existing.1, None, Some(user_id.to_string()), existing.2, existing.3) + Self::row_to_folder( + existing.0, + name, + existing.1, + None, + Some(user_id.to_string()), + existing.2, + existing.3, + ) } } } diff --git a/src/infrastructure/repositories/pg/recent_items_pg_repository.rs b/src/infrastructure/repositories/pg/recent_items_pg_repository.rs index fabf4b92..c5249652 100644 --- a/src/infrastructure/repositories/pg/recent_items_pg_repository.rs +++ b/src/infrastructure/repositories/pg/recent_items_pg_repository.rs @@ -61,22 +61,25 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository { let items = rows .iter() - .map(|row| RecentItemDto { - id: row.get("id"), - user_id: row.get("user_id"), - item_id: row.get("item_id"), - item_type: row.get("item_type"), - accessed_at: row.get("accessed_at"), - item_name: row.try_get("item_name").ok(), - item_size: row.try_get("item_size").ok(), - item_mime_type: row.try_get("item_mime_type").ok(), - parent_id: row.try_get("parent_id").ok(), - // Temporary defaults; with_display_fields() computes the real values - icon_class: String::new(), - icon_special_class: String::new(), - category: String::new(), - size_formatted: String::new(), - }.with_display_fields()) + .map(|row| { + RecentItemDto { + id: row.get("id"), + user_id: row.get("user_id"), + item_id: row.get("item_id"), + item_type: row.get("item_type"), + accessed_at: row.get("accessed_at"), + item_name: row.try_get("item_name").ok(), + item_size: row.try_get("item_size").ok(), + item_mime_type: row.try_get("item_mime_type").ok(), + parent_id: row.try_get("parent_id").ok(), + // Temporary defaults; with_display_fields() computes the real values + icon_class: String::new(), + icon_special_class: String::new(), + category: String::new(), + size_formatted: String::new(), + } + .with_display_fields() + }) .collect(); Ok(items) diff --git a/src/interfaces/api/handlers/favorites_handler.rs b/src/interfaces/api/handlers/favorites_handler.rs index 564f99d6..948504b8 100644 --- a/src/interfaces/api/handlers/favorites_handler.rs +++ b/src/interfaces/api/handlers/favorites_handler.rs @@ -1,4 +1,9 @@ -use axum::{Json, extract::{Path, State}, http::StatusCode, response::IntoResponse}; +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, +}; use serde::Deserialize; use std::sync::Arc; use tracing::{error, info}; diff --git a/src/interfaces/api/handlers/folder_handler.rs b/src/interfaces/api/handlers/folder_handler.rs index f20de467..84cf59bc 100644 --- a/src/interfaces/api/handlers/folder_handler.rs +++ b/src/interfaces/api/handlers/folder_handler.rs @@ -90,9 +90,12 @@ impl FolderHandler { if owner != &auth_user.id { tracing::warn!( "get_folder: user '{}' attempted to access folder '{}' owned by '{}'", - auth_user.id, id, owner + auth_user.id, + id, + owner ); - return (StatusCode::NOT_FOUND, "Folder not found".to_string()).into_response(); + return (StatusCode::NOT_FOUND, "Folder not found".to_string()) + .into_response(); } } (StatusCode::OK, Json(folder)).into_response() @@ -144,7 +147,10 @@ impl FolderHandler { Path(id): Path, pagination: Query, ) -> axum::response::Response { - match service.list_folders_for_owner_paginated(Some(&id), &auth_user.id, &pagination).await { + match service + .list_folders_for_owner_paginated(Some(&id), &auth_user.id, &pagination) + .await + { Ok(paginated_result) => (StatusCode::OK, Json(paginated_result)).into_response(), Err(err) => { let status = match err.kind { @@ -168,7 +174,10 @@ impl FolderHandler { parent_id: Option<&str>, auth_user: &AuthUser, ) -> axum::response::Response { - match service.list_folders_for_owner(parent_id, &auth_user.id).await { + match service + .list_folders_for_owner(parent_id, &auth_user.id) + .await + { Ok(folders) => (StatusCode::OK, Json(folders)).into_response(), Err(err) => { let status = match err.kind { @@ -360,7 +369,9 @@ impl FolderHandler { if folder.owner_id.as_deref() != Some(&auth_user.id) { tracing::warn!( "download_folder_zip: user '{}' attempted to download folder '{}' owned by '{:?}'", - auth_user.id, id, folder.owner_id + auth_user.id, + id, + folder.owner_id ); return ( StatusCode::NOT_FOUND, diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index 20aac22c..a75b3e54 100644 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -765,7 +765,11 @@ async fn handle_move( }; folder_service - .move_folder(&folder.id, move_dto, folder.owner_id.as_deref().unwrap_or("webdav")) + .move_folder( + &folder.id, + move_dto, + folder.owner_id.as_deref().unwrap_or("webdav"), + ) .await .map_err(|e| AppError::internal_error(format!("Failed to move folder: {}", e)))?; @@ -775,7 +779,11 @@ async fn handle_move( }; folder_service - .rename_folder(&folder.id, rename_dto, folder.owner_id.as_deref().unwrap_or("webdav")) + .rename_folder( + &folder.id, + rename_dto, + folder.owner_id.as_deref().unwrap_or("webdav"), + ) .await .map_err(|e| AppError::internal_error(format!("Failed to rename folder: {}", e)))?; }