//! Formatting utilities: prices, file sizes, initials, slugs, CSV cells. /// Group thousands with commas (US locale). Returns the input string unchanged /// for values ≤999. Operates on a digit-only string so callers stay in i64 /// arithmetic territory and don't need `f64` formatting tricks. fn group_thousands(n: u64) -> String { let s = n.to_string(); let bytes = s.as_bytes(); let mut out = String::with_capacity(bytes.len() + bytes.len() / 3); for (i, &b) in bytes.iter().enumerate() { if i > 0 && (bytes.len() - i).is_multiple_of(3) { out.push(','); } out.push(b as char); } out } /// Format a price in cents as a human-readable dollar string or "Free". pub fn format_price(cents: impl Into) -> String { let cents: i64 = cents.into(); if cents == 0 { return "Free".to_string(); } let neg = cents < 0; let abs = cents.unsigned_abs(); let dollars = group_thousands(abs / 100); let frac = (abs % 100) as u32; let sign = if neg { "-" } else { "" }; if frac == 0 { format!("{sign}${dollars}") } else { format!("{sign}${dollars}.{frac:02}") } } /// Format a revenue amount in cents as a dollar string (always shows decimals). /// /// Unlike [`format_price`], this never returns "Free" -- zero revenue is "$0.00". pub fn format_revenue(cents: i64) -> String { let neg = cents < 0; let abs = cents.unsigned_abs(); let dollars = group_thousands(abs / 100); let frac = (abs % 100) as u32; let sign = if neg { "-" } else { "" }; format!("{sign}${dollars}.{frac:02}") } /// Format a byte count as a human-readable file size string. /// Returns "N/A" for zero bytes (useful for optional file sizes). pub fn format_file_size(bytes: i64) -> String { if bytes == 0 { return "N/A".to_string(); } format_bytes(bytes) } /// Format a byte count as a compact human-readable string (e.g. "1.5 GB"). /// Returns "0 B" for zero bytes (useful for storage quota display). pub fn format_bytes(bytes: i64) -> String { let bytes = bytes.max(0) as u64; if bytes < 1024 { format!("{} B", bytes) } else if bytes < 1024 * 1024 { format!("{:.1} KB", bytes as f64 / 1024.0) } else if bytes < 1024 * 1024 * 1024 { format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) } else { format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) } } /// Extract up to two uppercase initials from a name for avatar display. pub fn get_initials(name: &str) -> String { name.split_whitespace() .filter_map(|word| word.chars().next()) .take(2) .collect::() .to_uppercase() } /// Generate a URL-safe slug from a title. /// /// Returns a `Slug` via `from_trusted` — the algorithm guarantees a valid slug. pub fn slugify(title: &str) -> crate::db::Slug { let slug: String = title .to_lowercase() .chars() .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) .collect(); let mut result = String::new(); let mut prev_hyphen = true; for c in slug.chars() { if c == '-' { if !prev_hyphen { result.push('-'); } prev_hyphen = true; } else { result.push(c); prev_hyphen = false; } } if result.ends_with('-') { result.pop(); } // Cap slug length to prevent unbounded output from long titles if result.len() > 128 { result.truncate(128); // Don't leave a trailing hyphen after truncation while result.ends_with('-') { result.pop(); } } if result.len() < 2 { result = "post".to_string(); } crate::db::Slug::from_trusted(result) } /// Sanitize a string for use as a CSV cell value. /// /// Prevents CSV injection by quoting cells and escaping values that start /// with formula-triggering characters (`=`, `+`, `-`, `@`, `\t`, `\r`). /// Also handles embedded commas, quotes, and newlines per RFC 4180. pub fn sanitize_csv_cell(value: &str) -> String { let needs_prefix = value .chars() .next() .map(|c| matches!(c, '=' | '+' | '-' | '@' | '\t' | '\r')) .unwrap_or(false); let escaped = value.replace('"', "\"\""); if needs_prefix { format!("\"'{}\"", escaped) } else if value.contains(',') || value.contains('"') || value.contains('\n') { format!("\"{}\"", escaped) } else { escaped } } #[cfg(test)] mod tests { use super::*; // ── format_price ── #[test] fn format_price_free() { assert_eq!(format_price(0), "Free"); } #[test] fn format_price_whole_dollars() { assert_eq!(format_price(500), "$5"); assert_eq!(format_price(100), "$1"); assert_eq!(format_price(10000), "$100"); } #[test] fn format_price_with_cents() { assert_eq!(format_price(999), "$9.99"); assert_eq!(format_price(150), "$1.50"); assert_eq!(format_price(1), "$0.01"); } #[test] fn format_price_negative_whole() { assert_eq!(format_price(-500i64), "-$5"); } #[test] fn format_price_negative_with_cents() { assert_eq!(format_price(-999i64), "-$9.99"); } #[test] fn format_price_one_cent() { assert_eq!(format_price(1), "$0.01"); } #[test] fn format_price_99_cents() { assert_eq!(format_price(99), "$0.99"); } // ── format_revenue ── #[test] fn format_revenue_zero() { assert_eq!(format_revenue(0), "$0.00"); } #[test] fn format_revenue_whole_dollars() { assert_eq!(format_revenue(500), "$5.00"); assert_eq!(format_revenue(10000), "$100.00"); } #[test] fn format_revenue_with_cents() { assert_eq!(format_revenue(999), "$9.99"); assert_eq!(format_revenue(150), "$1.50"); assert_eq!(format_revenue(1), "$0.01"); } #[test] fn format_revenue_large_amount() { assert_eq!(format_revenue(1_000_000), "$10,000.00"); } #[test] fn format_revenue_million_dollars() { assert_eq!(format_revenue(100_000_000), "$1,000,000.00"); } #[test] fn format_price_thousands() { assert_eq!(format_price(1_234_500), "$12,345"); assert_eq!(format_price(1_234_567), "$12,345.67"); } #[test] fn format_price_negative_thousands() { assert_eq!(format_price(-1_234_567i64), "-$12,345.67"); } #[test] fn format_revenue_negative() { assert_eq!(format_revenue(-500), "-$5.00"); } // ── format_file_size ── #[test] fn format_file_size_zero() { assert_eq!(format_file_size(0), "N/A"); } #[test] fn format_file_size_bytes() { assert_eq!(format_file_size(512), "512 B"); assert_eq!(format_file_size(1), "1 B"); } #[test] fn format_file_size_kilobytes() { assert_eq!(format_file_size(1024), "1.0 KB"); assert_eq!(format_file_size(1536), "1.5 KB"); } #[test] fn format_file_size_megabytes() { assert_eq!(format_file_size(1024 * 1024), "1.0 MB"); assert_eq!(format_file_size(5 * 1024 * 1024), "5.0 MB"); } #[test] fn format_file_size_gigabytes() { assert_eq!(format_file_size(1024 * 1024 * 1024), "1.0 GB"); assert_eq!(format_file_size(2 * 1024 * 1024 * 1024), "2.0 GB"); } // ── format_bytes ── #[test] fn format_bytes_zero() { assert_eq!(format_bytes(0), "0 B"); } #[test] fn format_bytes_small() { assert_eq!(format_bytes(512), "512 B"); } #[test] fn format_bytes_megabytes() { assert_eq!(format_bytes(5 * 1024 * 1024), "5.0 MB"); } #[test] fn format_bytes_gigabytes() { assert_eq!(format_bytes(10 * 1024 * 1024 * 1024), "10.0 GB"); } #[test] fn format_bytes_negative_clamped() { assert_eq!(format_bytes(-100), "0 B"); } #[test] fn format_bytes_exact_kb_boundary() { assert_eq!(format_bytes(1023), "1023 B"); assert_eq!(format_bytes(1024), "1.0 KB"); } #[test] fn format_bytes_exact_mb_boundary() { assert_eq!(format_bytes(1024 * 1024 - 1), "1024.0 KB"); assert_eq!(format_bytes(1024 * 1024), "1.0 MB"); } #[test] fn format_bytes_exact_gb_boundary() { assert_eq!(format_bytes(1024 * 1024 * 1024 - 1), "1024.0 MB"); assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB"); } // ── get_initials ── #[test] fn initials_two_words() { assert_eq!(get_initials("John Doe"), "JD"); } #[test] fn initials_single_word() { assert_eq!(get_initials("Alice"), "A"); } #[test] fn initials_three_words_takes_two() { assert_eq!(get_initials("John Michael Doe"), "JM"); } #[test] fn initials_empty() { assert_eq!(get_initials(""), ""); } #[test] fn initials_lowercase_uppercased() { assert_eq!(get_initials("bob smith"), "BS"); } #[test] fn initials_extra_whitespace() { assert_eq!(get_initials(" John Doe "), "JD"); } #[test] fn initials_unicode() { assert_eq!(get_initials("\u{00e9}mile Zola"), "\u{00c9}Z"); } // ── slugify ── #[test] fn slugify_basic() { assert_eq!(slugify("Hello World").as_str(), "hello-world"); } #[test] fn slugify_special_chars() { assert_eq!(slugify("My Song (feat. Artist)").as_str(), "my-song-feat-artist"); } #[test] fn slugify_multiple_spaces() { assert_eq!(slugify("too many spaces").as_str(), "too-many-spaces"); } #[test] fn slugify_leading_trailing_special() { assert_eq!(slugify("---hello---").as_str(), "hello"); } #[test] fn slugify_unicode() { let slug = slugify("café résumé"); assert!(slug.contains("caf")); assert!(!slug.contains(' ')); } #[test] fn slugify_too_short_falls_back() { assert_eq!(slugify("a").as_str(), "post"); assert_eq!(slugify("").as_str(), "post"); assert_eq!(slugify("---").as_str(), "post"); } #[test] fn slugify_numbers() { assert_eq!(slugify("Version 2.0").as_str(), "version-2-0"); } #[test] fn slugify_all_special_chars() { assert_eq!(slugify("!@#$%^&*()").as_str(), "post"); } #[test] fn slugify_single_valid_char() { assert_eq!(slugify("x").as_str(), "post"); } #[test] fn slugify_two_valid_chars() { assert_eq!(slugify("ab").as_str(), "ab"); } #[test] fn slugify_mixed_unicode_and_ascii() { let slug = slugify("café"); assert_eq!(slug.as_str(), "caf"); } // ── sanitize_csv_cell ── #[test] fn csv_cell_plain_text() { assert_eq!(sanitize_csv_cell("Hello World"), "Hello World"); } #[test] fn csv_cell_formula_prefix_equals() { assert_eq!(sanitize_csv_cell("=SUM(A1:A2)"), "\"'=SUM(A1:A2)\""); } #[test] fn csv_cell_formula_prefix_plus() { assert_eq!(sanitize_csv_cell("+cmd|' /C calc'!A0"), "\"'+cmd|' /C calc'!A0\""); } #[test] fn csv_cell_formula_prefix_minus() { assert_eq!(sanitize_csv_cell("-1+1"), "\"'-1+1\""); } #[test] fn csv_cell_formula_prefix_at() { assert_eq!(sanitize_csv_cell("@SUM(A1)"), "\"'@SUM(A1)\""); } #[test] fn csv_cell_with_comma() { assert_eq!(sanitize_csv_cell("one, two"), "\"one, two\""); } #[test] fn csv_cell_with_quotes() { assert_eq!(sanitize_csv_cell("say \"hi\""), "\"say \"\"hi\"\"\""); } #[test] fn csv_cell_empty() { assert_eq!(sanitize_csv_cell(""), ""); } #[test] fn csv_cell_with_newline() { assert_eq!(sanitize_csv_cell("line1\nline2"), "\"line1\nline2\""); } #[test] fn csv_cell_tab_prefix() { let result = sanitize_csv_cell("\tcmd"); assert!(result.starts_with("\"'"), "Tab prefix should be neutralized: {}", result); } #[test] fn csv_cell_cr_prefix() { let result = sanitize_csv_cell("\rcmd"); assert!(result.starts_with("\"'"), "CR prefix should be neutralized: {}", result); } // ── Edge cases (test-fuzz) ── #[test] fn format_price_negative_one_cent() { assert_eq!(format_price(-1i64), "-$0.01"); } #[test] fn format_price_negative_whole_dollar() { assert_eq!(format_price(-100i64), "-$1"); } #[test] fn format_price_large_value() { // $1 billion in cents assert_eq!(format_price(100_000_000_000i64), "$1,000,000,000"); } #[test] fn format_revenue_one_cent() { assert_eq!(format_revenue(1), "$0.01"); } #[test] fn format_revenue_negative_one_cent() { assert_eq!(format_revenue(-1), "-$0.01"); } #[test] fn format_file_size_negative() { // Negative byte count: only == 0 returns "N/A"; negatives go through // format_bytes which clamps to 0 via .max(0) → "0 B" assert_eq!(format_file_size(-100), "0 B"); } #[test] fn format_file_size_one_byte() { assert_eq!(format_file_size(1), "1 B"); } #[test] fn slugify_truncation_trailing_hyphen() { // 130 chars of "a-" pattern → after truncation at 128, trailing hyphen stripped let input = "a-".repeat(65); // 130 chars let slug = slugify(&input); assert!(slug.len() <= 128); assert!(!slug.ends_with('-'), "Slug should not end with hyphen after truncation"); } #[test] fn slugify_emoji_input() { // Emoji are non-ASCII → become hyphens → collapsed let slug = slugify("\u{1f600}\u{1f600}\u{1f600}"); // All chars become hyphens, collapsed to nothing, falls back to "post" assert_eq!(slug.as_str(), "post"); } #[test] fn slugify_mixed_valid_after_truncation() { // 127 a's + special char → truncation shouldn't break it let input = format!("{}-z", "a".repeat(127)); let slug = slugify(&input); assert!(slug.len() <= 128); assert!(slug.len() >= 2); } #[test] fn csv_cell_formula_with_embedded_quotes() { // Formula prefix + embedded quotes = both escapes apply let result = sanitize_csv_cell("=SUM(\"A1\")"); assert!(result.starts_with("\"'="), "Formula prefix not neutralized: {}", result); assert!(result.contains("\"\""), "Embedded quotes should be escaped: {}", result); } #[test] fn csv_cell_newline_with_formula_prefix() { // Newline AND formula prefix — both protections should apply let result = sanitize_csv_cell("=cmd\ninjection"); assert!(result.starts_with("\"'="), "Formula prefix not neutralized: {}", result); } #[test] fn csv_cell_very_long_value() { let long = "x".repeat(100_000); let result = sanitize_csv_cell(&long); // Plain text, no special chars → returned as-is assert_eq!(result.len(), 100_000); } #[test] fn initials_emoji_name() { // Emoji as first char of name — still takes 2 initials let result = get_initials("\u{1f600} Robot"); assert_eq!(result.chars().count(), 2); // emoji char + 'R' } #[test] fn initials_single_char_name() { assert_eq!(get_initials("A"), "A"); } // ── Adversarial ── #[test] fn adversarial_csv_injection_dde() { let result = sanitize_csv_cell("=cmd|'/C calc'!A0"); assert!(result.starts_with("\"'="), "DDE payload not neutralized: {}", result); } #[test] fn adversarial_csv_cell_null_bytes() { let result = sanitize_csv_cell("hello\0world"); assert!(!result.is_empty()); } #[test] fn adversarial_slugify_xss_attempt() { let slug = slugify(""); assert!(!slug.contains('<')); assert!(!slug.contains('>')); } #[test] fn adversarial_slugify_very_long_input() { let long = "a".repeat(10_000); let slug = slugify(&long); assert!(slug.len() <= 128, "slug should be capped at 128 chars, got {}", slug.len()); } #[test] fn adversarial_csv_rtl_override() { // Right-to-left override character — could disguise cell content let result = sanitize_csv_cell("normal\u{202e}evil"); assert!(!result.is_empty()); } #[test] fn adversarial_csv_zero_width_chars() { // Zero-width space and zero-width joiner let result = sanitize_csv_cell("=\u{200b}SUM(A1)"); // Starts with '=' so formula prefix should be applied assert!(result.starts_with("\"'="), "Formula prefix not applied with ZWS: {}", result); } #[test] fn adversarial_slugify_path_traversal() { let slug = slugify("../../../etc/passwd"); assert!(!slug.contains('.')); assert!(!slug.contains('/')); } #[test] fn adversarial_slugify_null_bytes() { let slug = slugify("hello\0world"); assert!(!slug.contains('\0')); assert!(slug.len() >= 2); } // ── Property-based tests ── proptest::proptest! { #[test] fn prop_format_price_never_panics(cents in proptest::num::i64::ANY) { let result = format_price(cents); proptest::prop_assert!(!result.is_empty()); if cents == 0 { proptest::prop_assert_eq!(result, "Free"); } else if cents < 0 { proptest::prop_assert!(result.starts_with("-$"), "Negative price should start with -$: {}", result); } else { proptest::prop_assert!(result.starts_with('$'), "Positive price should start with $: {}", result); } } #[test] fn prop_format_revenue_never_panics(cents in proptest::num::i64::ANY) { let result = format_revenue(cents); proptest::prop_assert!(result.starts_with('$') || result.starts_with("-$"), "Revenue should start with $ or -$: {}", result); } #[test] fn prop_format_bytes_never_panics(bytes in proptest::num::i64::ANY) { let result = format_bytes(bytes); proptest::prop_assert!(!result.is_empty()); } #[test] fn prop_slugify_never_panics(input in ".*") { let slug = slugify(&input); proptest::prop_assert!(slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'), "Slug should only contain ASCII alphanumeric + hyphens: {}", slug.as_str()); proptest::prop_assert!(slug.len() >= 2, "Slug should be at least 2 chars: {}", slug.as_str()); } } }