Files
Oxicloud/src/application/dtos/display_helpers.rs
T
Claude cd4c62042a perf: keyset/LATERAL SQL shapes, auth+blob-cache single-flight, spool buffers, DTO interning
Round 3 of benchmark-gated optimizations (benches/ROUND3.md; every change
gated by a before/after benchmark — an AFTER that did not beat its BEFORE
was to be rolled back; none needed it. Equivalence gates assert identical
row sequences / byte-identical output on every behavior-preserving rewrite):

DB hot paths (local PG16, EXPLAIN-verified):
- Web-UI listing (list_resources_paged): cursor pushed INSIDE the
  folders/files UNION-ALL branches as sargable row-value comparisons with
  per-branch ORDER/LIMIT + two partial expression indexes
  (folder_id, LOWER(name), id). 20k-entry folder: 26.6 -> 1.3 ms/page
  (19.5x); other sort modes at parity or better. New migration
  20260918000000. [benches/LISTING-KEYSET.md section in ROUND3]
- Photos timeline (list_media_files): per-drive CROSS JOIN LATERAL top-N
  on the timeline index, joins moved above the top-N. 50k-photo library:
  97.4 -> 1.6 ms/page (55.7x). The old "LIMIT stops the scan early"
  comment was refuted by EXPLAIN.
- PROPFIND sub-folders (both DAV surfaces): keyset list_folders_batch off
  idx_folders_unique_name replaces COUNT(*) OVER() + LIMIT/OFFSET
  (5k dirs: 79.7 -> 17.9 ms full walk, 4.5x).

Concurrency:
- Basic-auth cache single-flight (moka try_get_with): 8 concurrent DAV
  connections at TTL expiry paid 8 Argon2id runs (2.6 s CPU + 8x64 MiB);
  now 1 (300 ms). Failed verifications remain uncached.
- CachedBlobBackend per-hash single-flight + unique tmp names: 16
  concurrent cold readers = 16 full remote downloads racing truncating
  writes on ONE deterministic .tmp (corruptible cache); now 1 download
  (16x less egress, 2.8x wall on a shared link) and torn files can never
  be renamed into the cache.

I/O and allocations:
- Chunk-assembly reads 64K -> 512K buffers (2.3x, 8x fewer syscalls);
  chunk-spool writes via BufWriter 512K (5.6x, 32x fewer syscalls).
- S3/Azure put_blob_from_bytes_unsynced overrides: dedup settle no longer
  pays a HEAD probe per new chunk (2 RTT -> 1, 1.8x); Azure stops copying
  every chunk (Bytes -> Body, -0.44 ms - 4 MiB alloc per 4 MiB chunk).
- Entity->DTO mapping: Arc<str> interning of closed-set display fields +
  common MIMEs, 1-alloc etag/size formatting, FolderDto moves instead of
  clones. File row: 11 -> 4 allocs; folder row: 11.8 -> 1 (2.1x faster).
- CardDAV REPORT: deleted dead per-contact vCard pre-generation and the
  O(N^2) uid scan whose result was discarded (5k contacts: 55.7 -> 5.7 ms,
  9.8x); byte-identical XML asserted.
- Search-results cache: byte weigher + 32 MiB budget
  (OXICLOUD_SEARCH_CACHE_MAX_BYTES) replaces the 1000-ENTRY cap that let
  ~300 MiB of enriched rows sit in RSS; read latency parity.
- Dropped aws-config + aws-smithy-types (zero references; -82 dep-graph
  nodes, three SDK stacks gone from every build). tokio "process" is now
  an explicit feature (was enabled transitively by aws-config).

Frontend:
- Cached Intl.DateTimeFormat keyed by (locale, options) in formatDate and
  4 sibling callsites: 20k dates 2612 -> 51 ms (51.6x); vitest gate
  asserts output identity across locales and a 3x floor.

Validation: cargo fmt + clippy --all-features --all-targets -D warnings
clean; 518 unit + 548 integration-cfg tests green; new-shape endpoints
smoke-tested end-to-end over HTTP (all 5 listing sort modes with cursor
walks, WebDAV PROPFIND Depth-1, photos timeline, Basic-auth DAV login);
frontend npm run check clean, new vitest gates green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBsU2qEzny3A8WQUEuMNCr
2026-07-17 11:10:27 +00:00

741 lines
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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).
use std::collections::HashMap;
use std::fmt::Write as _;
use std::sync::{Arc, LazyLock};
// ─── Arc<str> interning for closed-set display values ────────────────
//
// `FileDto` / `FolderDto` store their display fields as `Arc<str>` so DTO
// clones are O(1). But `Arc::<str>::from(&str)` always allocates + copies,
// so building the DTO paid 3-4 heap allocations per row even though the
// value space is a small closed set. Interning turns each conversion into
// a HashMap lookup + refcount bump.
/// Every `&'static str` that [`icon_class_for`], [`icon_special_class_for`]
/// and [`category_for`] can return, plus the folder-DTO constants.
///
/// Keep this table in sync when adding a value to those functions — a
/// missing entry is not a bug (callers fall back to `Arc::from`, same
/// bytes, one extra allocation), just a lost optimization.
static DISPLAY_INTERN: LazyLock<HashMap<&'static str, Arc<str>>> = LazyLock::new(|| {
const CLOSED_SET: &[&str] = &[
// icon_class_for
"fas fa-file-pdf",
"fas fa-file-word",
"fas fa-file-excel",
"fas fa-file-powerpoint",
"fas fa-file-archive",
"fas fa-file-code",
"fas fa-hdd",
"fas fa-file-image",
"fas fa-file-video",
"fas fa-file-audio",
"fas fa-file-alt",
"fas fa-terminal",
"fas fa-file",
// icon_special_class_for
"pdf-icon",
"doc-icon",
"spreadsheet-icon",
"presentation-icon",
"archive-icon",
"code-icon json-icon",
"code-icon js-icon",
"code-icon ts-icon",
"code-icon html-icon",
"code-icon sql-icon",
"code-icon config-icon",
"code-icon php-icon",
"script-icon",
"installer-icon",
"image-icon",
"video-icon",
"audio-icon",
"code-icon py-icon",
"code-icon rust-icon",
"code-icon",
"code-icon go-icon",
"code-icon ruby-icon",
"code-icon md-icon",
"code-icon css-icon",
"code-icon java-icon",
"code-icon c-icon",
"code-icon cs-icon",
"code-icon swift-icon",
"",
// category_for
"PDF",
"Document",
"Spreadsheet",
"Presentation",
"Archive",
"Code",
"Installer",
"Image",
"Video",
"Audio",
"Markdown",
"Text",
// FolderDto constants
"fas fa-folder",
"folder-icon",
"Folder",
];
CLOSED_SET.iter().map(|s| (*s, Arc::from(*s))).collect()
});
/// Returns a shared `Arc<str>` for a display value from the closed sets
/// above (icon class, icon special class, category). Lookup + refcount
/// bump instead of alloc + copy; unknown values (future additions not
/// yet in the table) fall back to `Arc::from` with identical bytes.
pub fn intern_display(s: &'static str) -> Arc<str> {
DISPLAY_INTERN
.get(s)
.cloned()
.unwrap_or_else(|| Arc::from(s))
}
/// The MIME types that dominate real storage rows. Exotic types fall back
/// to a per-row `Arc::from` — correctness is unaffected, only the alloc is.
static MIME_INTERN: LazyLock<HashMap<&'static str, Arc<str>>> = LazyLock::new(|| {
const COMMON_MIMES: &[&str] = &[
"",
"directory",
"application/octet-stream",
// Images
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/svg+xml",
"image/heic",
"image/heif",
"image/avif",
"image/bmp",
"image/tiff",
"image/x-icon",
// Video
"video/mp4",
"video/quicktime",
"video/webm",
"video/x-matroska",
"video/x-msvideo",
// Audio
"audio/mpeg",
"audio/mp4",
"audio/ogg",
"audio/flac",
"audio/wav",
"audio/x-wav",
"audio/aac",
// Documents
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.oasis.opendocument.text",
"application/vnd.oasis.opendocument.spreadsheet",
// Text / code
"text/plain",
"text/csv",
"text/html",
"text/css",
"text/markdown",
"text/xml",
"application/json",
"application/javascript",
"application/xml",
"application/x-yaml",
// Archives
"application/zip",
"application/gzip",
"application/x-tar",
"application/x-7z-compressed",
"application/x-rar-compressed",
];
COMMON_MIMES.iter().map(|s| (*s, Arc::from(*s))).collect()
});
/// Returns a shared `Arc<str>` for the given MIME type. Common types hit
/// the intern table (refcount bump); exotic ones allocate as before.
pub fn intern_mime(mime: &str) -> Arc<str> {
MIME_INTERN
.get(mime)
.cloned()
.unwrap_or_else(|| Arc::from(mime))
}
// ─── 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/") {
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"
}
/// Returns the sort order for a file category, stored as `category_order` in `storage.files`.
///
/// Values are **sparse multiples of 100** so a future category can be slotted between two
/// existing ones (e.g. "RichText" = 550, between Document=500 and Spreadsheet=600) without
/// renumbering any rows. Folders are not handled here — the SQL query hard-codes 0 for them.
///
/// This function delegates to [`category_for`] so the two are always in sync.
pub fn category_order_for(name: &str, mime: &str) -> i16 {
match category_for(name, mime) {
"Image" => 100,
"Video" => 200,
"Audio" => 300,
"PDF" => 400,
"Document" => 500,
"Spreadsheet" => 600,
"Presentation" => 700,
"Archive" => 800,
"Code" => 900,
"Markdown" => 1000,
"Text" => 1100,
"Installer" => 1200,
_ => 9999, // Other / unknown
}
}
/// 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);
// Single buffer: write the 2-decimal value, strip trailing zeros in
// place (matches JS parseFloat behaviour), then append the unit.
// 16 chars covers the worst case ("16777216 TB" for u64::MAX,
// "1023.99 Bytes" for the longest unit), so no realloc occurs.
let mut out = String::with_capacity(16);
let _ = write!(out, "{:.2}", value);
while out.ends_with('0') {
out.pop();
}
if out.ends_with('.') {
out.pop();
}
out.push(' ');
out.push_str(SIZES[i]);
out
}
#[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"
);
}
/// Every value the closed-set display functions can return must hit
/// the intern table (same bytes, shared allocation) — a miss is only
/// a lost optimization, but this test keeps the table in sync.
#[test]
fn test_intern_display_covers_closed_sets_and_shares_storage() {
for s in [
"fas fa-file-pdf",
"fas fa-file",
"fas fa-terminal",
"fas fa-folder",
"code-icon rust-icon",
"folder-icon",
"",
"PDF",
"Folder",
"Document",
"Markdown",
] {
let a = intern_display(s);
let b = intern_display(s);
assert_eq!(&*a, s, "interned bytes must be identical");
assert!(
Arc::ptr_eq(&a, &b),
"closed-set value {s:?} must come from the intern table"
);
}
}
#[test]
fn test_intern_mime_common_hits_table_exotic_falls_back() {
let a = intern_mime("image/jpeg");
let b = intern_mime("image/jpeg");
assert_eq!(&*a, "image/jpeg");
assert!(Arc::ptr_eq(&a, &b), "common MIME must be interned");
let exotic = intern_mime("chemical/x-pdb");
assert_eq!(&*exotic, "chemical/x-pdb");
let exotic2 = intern_mime("chemical/x-pdb");
assert!(
!Arc::ptr_eq(&exotic, &exotic2),
"exotic MIME falls back to a fresh Arc"
);
}
#[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"));
}
}