Skip to main content

max / makenotwork

31.0 KB · 960 lines History Blame Raw
1 //! Formatting utilities: prices, file sizes, initials, slugs, CSV cells.
2 use std::fmt::Write as _;
3
4 use crate::currency::SettlementCurrency;
5
6 /// Group thousands with commas (US locale). Returns the input string unchanged
7 /// for values ≤999. Operates on a digit-only string so callers stay in i64
8 /// arithmetic territory and don't need `f64` formatting tricks.
9 fn group_thousands(n: u64) -> String {
10 let s = n.to_string();
11 let bytes = s.as_bytes();
12 let mut out = String::with_capacity(bytes.len() + bytes.len() / 3);
13 for (i, &b) in bytes.iter().enumerate() {
14 if i > 0 && (bytes.len() - i).is_multiple_of(3) {
15 out.push(',');
16 }
17 out.push(b as char);
18 }
19 out
20 }
21
22 /// Format a price in cents as a human-readable amount or "Free".
23 ///
24 /// Takes the currency the amount is denominated in, because there is no such
25 /// thing as a bare price: the same integer means different money to a US and a
26 /// British creator. Pass the seller's `settlement_currency`, or
27 /// `SettlementCurrency::Usd` explicitly for MNW's own billing (membership tiers,
28 /// Fan+, SyncKit), which is USD regardless of who is looking.
29 pub fn format_price(cents: impl Into<i64>, currency: SettlementCurrency) -> String {
30 let cents: i64 = cents.into();
31 if cents == 0 {
32 return "Free".to_string();
33 }
34 let neg = cents < 0;
35 let abs = cents.unsigned_abs();
36 let dollars = group_thousands(abs / 100);
37 let frac = (abs % 100) as u32;
38 let sign = if neg { "-" } else { "" };
39 let symbol = currency.symbol();
40 if frac == 0 {
41 format!("{sign}{symbol}{dollars}")
42 } else {
43 format!("{sign}{symbol}{dollars}.{frac:02}")
44 }
45 }
46
47 /// Format a revenue amount in cents (always shows decimals).
48 ///
49 /// Unlike [`format_price`], this never returns "Free": zero revenue is "$0.00".
50 /// See [`format_price`] on why the currency is not optional.
51 pub fn format_revenue(cents: i64, currency: SettlementCurrency) -> String {
52 let neg = cents < 0;
53 let abs = cents.unsigned_abs();
54 let dollars = group_thousands(abs / 100);
55 let frac = (abs % 100) as u32;
56 let sign = if neg { "-" } else { "" };
57 let symbol = currency.symbol();
58 format!("{sign}{symbol}{dollars}.{frac:02}")
59 }
60
61 /// Format a price in cents as a plain decimal string: no currency symbol, no
62 /// thousands separators, always two decimal places (e.g. "9.99", "1234.50").
63 ///
64 /// The one formatter here that takes no currency, and deliberately so: it emits
65 /// no symbol, and all six settlement currencies are two-decimal, so there is
66 /// nothing for a currency to change. Where the currency matters to the reader
67 /// (a CSV a creator will open in a spreadsheet), name it in its own column
68 /// rather than gluing a symbol onto a machine-readable value.
69 ///
70 /// For CSV cells and form `value=` attributes where a bare numeric is required
71 /// and the surrounding context (spreadsheet column, template `$` prefix)
72 /// supplies its own framing. For human-facing display use [`format_price`]
73 /// (shows "Free" / drops trailing `.00`) or [`format_revenue`] (always `$X.XX`).
74 ///
75 /// This is the canonical cents→decimal conversion: bypassing it with a raw
76 /// `as f64 / 100.0` is what the pricing-format drift chronic keeps re-finding,
77 /// so a grep-enforcing test (below) bans that idiom outside this module.
78 pub fn format_dollars_plain(cents: impl Into<i64>) -> String {
79 let cents: i64 = cents.into();
80 let neg = cents < 0;
81 let abs = cents.unsigned_abs();
82 let sign = if neg { "-" } else { "" };
83 format!("{sign}{}.{:02}", abs / 100, (abs % 100) as u32)
84 }
85
86 /// Format a byte count as a human-readable file size string.
87 /// Returns "N/A" for zero bytes (useful for optional file sizes).
88 pub fn format_file_size(bytes: i64) -> String {
89 if bytes == 0 {
90 return "N/A".to_string();
91 }
92 format_bytes(bytes)
93 }
94
95 /// Format a byte count as a compact human-readable string (e.g. "1.5 GB").
96 /// Returns "0 B" for zero bytes (useful for storage quota display).
97 pub fn format_bytes(bytes: i64) -> String {
98 let bytes = bytes.max(0) as u64;
99 if bytes < 1024 {
100 format!("{bytes} B")
101 } else if bytes < 1024 * 1024 {
102 format!("{:.1} KB", bytes as f64 / 1024.0)
103 } else if bytes < 1024 * 1024 * 1024 {
104 format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
105 } else {
106 format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
107 }
108 }
109
110 /// Extract up to two uppercase initials from a name for avatar display.
111 pub fn get_initials(name: &str) -> String {
112 name.split_whitespace()
113 .filter_map(|word| word.chars().next())
114 .take(2)
115 .collect::<String>()
116 .to_uppercase()
117 }
118
119 /// Generate a URL-safe slug from a title.
120 ///
121 /// Returns a `Slug` via `from_trusted`, the algorithm guarantees a valid slug.
122 pub fn slugify(title: &str) -> crate::db::Slug {
123 let slug: String = title
124 .to_lowercase()
125 .chars()
126 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
127 .collect();
128 let mut result = String::new();
129 let mut prev_hyphen = true;
130 for c in slug.chars() {
131 if c == '-' {
132 if !prev_hyphen {
133 result.push('-');
134 }
135 prev_hyphen = true;
136 } else {
137 result.push(c);
138 prev_hyphen = false;
139 }
140 }
141 if result.ends_with('-') {
142 result.pop();
143 }
144 // Cap slug length to prevent unbounded output from long titles
145 if result.len() > 128 {
146 result.truncate(128);
147 // Don't leave a trailing hyphen after truncation
148 while result.ends_with('-') {
149 result.pop();
150 }
151 }
152 if result.len() < 2 {
153 // The ASCII-only pass kept nothing usable, typically an all-non-Latin
154 // title (CJK/Cyrillic/Arabic/emoji). A constant "post" fallback made
155 // every such title collide on one slug, so a creator working in a
156 // non-Latin script hit a unique-violation on their second section and
157 // every blog post URL became `/post`. Derive a stable suffix from the
158 // title content so distinct titles get distinct slugs (hash-fallback;
159 // no transliteration dependency).
160 result = format!("post-{}", title_hash_suffix(title));
161 }
162 crate::db::Slug::from_trusted(result)
163 }
164
165 /// Short, stable, URL-safe suffix derived from a title's content. Used as the
166 /// slug fallback when ASCII slugification yields nothing usable, so two
167 /// different non-Latin titles produce two different slugs instead of colliding
168 /// on a constant. Deterministic: the same title always yields the same suffix.
169 fn title_hash_suffix(title: &str) -> String {
170 use sha2::{Digest, Sha256};
171 let digest = Sha256::digest(title.trim().as_bytes());
172 // 4 bytes -> 8 lowercase hex chars; ample to separate a creator's titles,
173 // and the section/blog dedup loop handles the astronomically rare clash.
174 let mut suffix = String::with_capacity(8);
175 for b in &digest[..4] {
176 write!(suffix, "{b:02x}").unwrap();
177 }
178 suffix
179 }
180
181 /// Sanitize a string for use as a CSV cell value.
182 ///
183 /// Prevents CSV injection by quoting cells and escaping values that start
184 /// with formula-triggering characters (`=`, `+`, `-`, `@`, `\t`, `\r`).
185 /// Also handles embedded commas, quotes, and newlines per RFC 4180.
186 pub fn sanitize_csv_cell(value: &str) -> String {
187 let needs_prefix = value
188 .chars()
189 .next()
190 .is_some_and(|c| matches!(c, '=' | '+' | '-' | '@' | '\t' | '\r'));
191
192 let escaped = value.replace('"', "\"\"");
193
194 if needs_prefix {
195 format!("\"'{escaped}\"")
196 } else if value.contains(',') || value.contains('"') || value.contains('\n') {
197 format!("\"{escaped}\"")
198 } else {
199 escaped
200 }
201 }
202
203 #[cfg(test)]
204 mod tests {
205 use super::*;
206
207 // --- format_dollars_plain ---
208
209 #[test]
210 fn dollars_plain_basic() {
211 assert_eq!(format_dollars_plain(0), "0.00");
212 assert_eq!(format_dollars_plain(999), "9.99");
213 assert_eq!(format_dollars_plain(100), "1.00");
214 assert_eq!(format_dollars_plain(1), "0.01");
215 }
216
217 #[test]
218 fn dollars_plain_no_thousands_separator() {
219 // CSV cells and form values must stay machine-parseable, no commas.
220 assert_eq!(format_dollars_plain(123_456), "1234.56");
221 assert_eq!(format_dollars_plain(1_000_000), "10000.00");
222 }
223
224 #[test]
225 fn dollars_plain_negative() {
226 assert_eq!(format_dollars_plain(-999), "-9.99");
227 assert_eq!(format_dollars_plain(-5), "-0.05");
228 }
229
230 // --- format_price ---
231
232 #[test]
233 fn format_price_free() {
234 assert_eq!(format_price(0, SettlementCurrency::Usd), "Free");
235 }
236
237 #[test]
238 fn format_price_whole_dollars() {
239 assert_eq!(format_price(500, SettlementCurrency::Usd), "$5");
240 assert_eq!(format_price(100, SettlementCurrency::Usd), "$1");
241 assert_eq!(format_price(10000, SettlementCurrency::Usd), "$100");
242 }
243
244 #[test]
245 fn format_price_with_cents() {
246 assert_eq!(format_price(999, SettlementCurrency::Usd), "$9.99");
247 assert_eq!(format_price(150, SettlementCurrency::Usd), "$1.50");
248 assert_eq!(format_price(1, SettlementCurrency::Usd), "$0.01");
249 }
250
251 #[test]
252 fn format_price_negative_whole() {
253 assert_eq!(format_price(-500i64, SettlementCurrency::Usd), "-$5");
254 }
255
256 #[test]
257 fn format_price_negative_with_cents() {
258 assert_eq!(format_price(-999i64, SettlementCurrency::Usd), "-$9.99");
259 }
260
261 #[test]
262 fn format_price_one_cent() {
263 assert_eq!(format_price(1, SettlementCurrency::Usd), "$0.01");
264 }
265
266 #[test]
267 fn format_price_99_cents() {
268 assert_eq!(format_price(99, SettlementCurrency::Usd), "$0.99");
269 }
270
271 // --- format_revenue ---
272
273 #[test]
274 fn format_revenue_zero() {
275 assert_eq!(format_revenue(0, SettlementCurrency::Usd), "$0.00");
276 }
277
278 #[test]
279 fn format_revenue_whole_dollars() {
280 assert_eq!(format_revenue(500, SettlementCurrency::Usd), "$5.00");
281 assert_eq!(format_revenue(10000, SettlementCurrency::Usd), "$100.00");
282 }
283
284 #[test]
285 fn format_revenue_with_cents() {
286 assert_eq!(format_revenue(999, SettlementCurrency::Usd), "$9.99");
287 assert_eq!(format_revenue(150, SettlementCurrency::Usd), "$1.50");
288 assert_eq!(format_revenue(1, SettlementCurrency::Usd), "$0.01");
289 }
290
291 #[test]
292 fn format_revenue_large_amount() {
293 assert_eq!(
294 format_revenue(1_000_000, SettlementCurrency::Usd),
295 "$10,000.00"
296 );
297 }
298
299 #[test]
300 fn format_revenue_million_dollars() {
301 assert_eq!(
302 format_revenue(100_000_000, SettlementCurrency::Usd),
303 "$1,000,000.00"
304 );
305 }
306
307 #[test]
308 fn format_price_thousands() {
309 assert_eq!(format_price(1_234_500, SettlementCurrency::Usd), "$12,345");
310 assert_eq!(
311 format_price(1_234_567, SettlementCurrency::Usd),
312 "$12,345.67"
313 );
314 }
315
316 #[test]
317 fn format_price_negative_thousands() {
318 assert_eq!(
319 format_price(-1_234_567i64, SettlementCurrency::Usd),
320 "-$12,345.67"
321 );
322 }
323
324 #[test]
325 fn format_revenue_negative() {
326 assert_eq!(format_revenue(-500, SettlementCurrency::Usd), "-$5.00");
327 }
328
329 // --- currency-aware formatting ---
330
331 #[test]
332 fn price_carries_the_currency_symbol() {
333 assert_eq!(format_price(999, SettlementCurrency::Gbp), "\u{a3}9.99");
334 assert_eq!(format_price(999, SettlementCurrency::Eur), "\u{20ac}9.99");
335 assert_eq!(format_price(999, SettlementCurrency::Cad), "CA$9.99");
336 assert_eq!(format_price(999, SettlementCurrency::Aud), "A$9.99");
337 assert_eq!(format_price(999, SettlementCurrency::Nzd), "NZ$9.99");
338 }
339
340 #[test]
341 fn revenue_carries_the_currency_symbol() {
342 assert_eq!(
343 format_revenue(1_234_567, SettlementCurrency::Gbp),
344 "\u{a3}12,345.67"
345 );
346 assert_eq!(format_revenue(0, SettlementCurrency::Eur), "\u{20ac}0.00");
347 assert_eq!(format_revenue(-500, SettlementCurrency::Cad), "-CA$5.00");
348 }
349
350 #[test]
351 fn free_is_free_in_every_currency() {
352 // Zero is the one amount with no denomination worth printing.
353 for c in SettlementCurrency::ALL {
354 assert_eq!(format_price(0, c), "Free", "{c}");
355 }
356 }
357
358 #[test]
359 fn two_currencies_never_render_alike() {
360 // The failure this guards: a Canadian creator's price reading as USD.
361 let rendered: std::collections::HashSet<_> = SettlementCurrency::ALL
362 .iter()
363 .map(|c| format_price(999, *c))
364 .collect();
365 assert_eq!(rendered.len(), SettlementCurrency::ALL.len());
366 }
367
368 // --- format_file_size ---
369
370 #[test]
371 fn format_file_size_zero() {
372 assert_eq!(format_file_size(0), "N/A");
373 }
374
375 #[test]
376 fn format_file_size_bytes() {
377 assert_eq!(format_file_size(512), "512 B");
378 assert_eq!(format_file_size(1), "1 B");
379 }
380
381 #[test]
382 fn format_file_size_kilobytes() {
383 assert_eq!(format_file_size(1024), "1.0 KB");
384 assert_eq!(format_file_size(1536), "1.5 KB");
385 }
386
387 #[test]
388 fn format_file_size_megabytes() {
389 assert_eq!(format_file_size(1024 * 1024), "1.0 MB");
390 assert_eq!(format_file_size(5 * 1024 * 1024), "5.0 MB");
391 }
392
393 #[test]
394 fn format_file_size_gigabytes() {
395 assert_eq!(format_file_size(1024 * 1024 * 1024), "1.0 GB");
396 assert_eq!(format_file_size(2 * 1024 * 1024 * 1024), "2.0 GB");
397 }
398
399 // --- format_bytes ---
400
401 #[test]
402 fn format_bytes_zero() {
403 assert_eq!(format_bytes(0), "0 B");
404 }
405
406 #[test]
407 fn format_bytes_small() {
408 assert_eq!(format_bytes(512), "512 B");
409 }
410
411 #[test]
412 fn format_bytes_megabytes() {
413 assert_eq!(format_bytes(5 * 1024 * 1024), "5.0 MB");
414 }
415
416 #[test]
417 fn format_bytes_gigabytes() {
418 assert_eq!(format_bytes(10 * 1024 * 1024 * 1024), "10.0 GB");
419 }
420
421 #[test]
422 fn format_bytes_negative_clamped() {
423 assert_eq!(format_bytes(-100), "0 B");
424 }
425
426 #[test]
427 fn format_bytes_exact_kb_boundary() {
428 assert_eq!(format_bytes(1023), "1023 B");
429 assert_eq!(format_bytes(1024), "1.0 KB");
430 }
431
432 #[test]
433 fn format_bytes_exact_mb_boundary() {
434 assert_eq!(format_bytes(1024 * 1024 - 1), "1024.0 KB");
435 assert_eq!(format_bytes(1024 * 1024), "1.0 MB");
436 }
437
438 #[test]
439 fn format_bytes_exact_gb_boundary() {
440 assert_eq!(format_bytes(1024 * 1024 * 1024 - 1), "1024.0 MB");
441 assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB");
442 }
443
444 // --- get_initials ---
445
446 #[test]
447 fn initials_two_words() {
448 assert_eq!(get_initials("John Doe"), "JD");
449 }
450
451 #[test]
452 fn initials_single_word() {
453 assert_eq!(get_initials("Alice"), "A");
454 }
455
456 #[test]
457 fn initials_three_words_takes_two() {
458 assert_eq!(get_initials("John Michael Doe"), "JM");
459 }
460
461 #[test]
462 fn initials_empty() {
463 assert_eq!(get_initials(""), "");
464 }
465
466 #[test]
467 fn initials_lowercase_uppercased() {
468 assert_eq!(get_initials("bob smith"), "BS");
469 }
470
471 #[test]
472 fn initials_extra_whitespace() {
473 assert_eq!(get_initials(" John Doe "), "JD");
474 }
475
476 #[test]
477 fn initials_unicode() {
478 assert_eq!(get_initials("\u{00e9}mile Zola"), "\u{00c9}Z");
479 }
480
481 // --- slugify ---
482
483 #[test]
484 fn slugify_basic() {
485 assert_eq!(slugify("Hello World").as_str(), "hello-world");
486 }
487
488 #[test]
489 fn slugify_special_chars() {
490 assert_eq!(
491 slugify("My Song (feat. Artist)").as_str(),
492 "my-song-feat-artist"
493 );
494 }
495
496 #[test]
497 fn slugify_multiple_spaces() {
498 assert_eq!(slugify("too many spaces").as_str(), "too-many-spaces");
499 }
500
501 #[test]
502 fn slugify_leading_trailing_special() {
503 assert_eq!(slugify("---hello---").as_str(), "hello");
504 }
505
506 #[test]
507 fn slugify_unicode() {
508 let slug = slugify("café résumé");
509 assert!(slug.contains("caf"));
510 assert!(!slug.contains(' '));
511 }
512
513 #[test]
514 fn slugify_too_short_falls_back() {
515 // Fallback is now `post-<hash>` (was a constant "post"): still a valid
516 // slug, deterministic per input, and distinct across inputs.
517 for input in ["a", "", "---"] {
518 let s = slugify(input);
519 assert!(s.as_str().starts_with("post-"), "got {}", s.as_str());
520 assert!(crate::validation::validate_slug(s.as_str()).is_ok());
521 // Deterministic.
522 assert_eq!(slugify(input).as_str(), s.as_str());
523 }
524 // Distinct inputs -> distinct fallback slugs (the bug this fixes).
525 assert_ne!(slugify("a").as_str(), slugify("---").as_str());
526 }
527
528 #[test]
529 fn slugify_non_latin_titles_are_distinct() {
530 // The whole point of the hash-fallback: two different non-Latin titles
531 // must not collide on one slug (which caused a unique-violation 500 for
532 // the second section a non-Latin creator added).
533 let a = slugify("日本語の記事");
534 let b = slugify("Статья на русском");
535 assert!(a.as_str().starts_with("post-"));
536 assert!(b.as_str().starts_with("post-"));
537 assert_ne!(a.as_str(), b.as_str());
538 assert!(crate::validation::validate_slug(a.as_str()).is_ok());
539 assert!(crate::validation::validate_slug(b.as_str()).is_ok());
540 }
541
542 #[test]
543 fn slugify_numbers() {
544 assert_eq!(slugify("Version 2.0").as_str(), "version-2-0");
545 }
546
547 #[test]
548 fn slugify_all_special_chars() {
549 let s = slugify("!@#$%^&*()");
550 assert!(s.as_str().starts_with("post-"));
551 assert!(crate::validation::validate_slug(s.as_str()).is_ok());
552 }
553
554 #[test]
555 fn slugify_single_valid_char() {
556 let s = slugify("x");
557 assert!(s.as_str().starts_with("post-"));
558 assert!(crate::validation::validate_slug(s.as_str()).is_ok());
559 }
560
561 #[test]
562 fn slugify_two_valid_chars() {
563 assert_eq!(slugify("ab").as_str(), "ab");
564 }
565
566 #[test]
567 fn slugify_mixed_unicode_and_ascii() {
568 let slug = slugify("café");
569 assert_eq!(slug.as_str(), "caf");
570 }
571
572 // --- sanitize_csv_cell ---
573
574 #[test]
575 fn csv_cell_plain_text() {
576 assert_eq!(sanitize_csv_cell("Hello World"), "Hello World");
577 }
578
579 #[test]
580 fn csv_cell_formula_prefix_equals() {
581 assert_eq!(sanitize_csv_cell("=SUM(A1:A2)"), "\"'=SUM(A1:A2)\"");
582 }
583
584 #[test]
585 fn csv_cell_formula_prefix_plus() {
586 assert_eq!(
587 sanitize_csv_cell("+cmd|' /C calc'!A0"),
588 "\"'+cmd|' /C calc'!A0\""
589 );
590 }
591
592 #[test]
593 fn csv_cell_formula_prefix_minus() {
594 assert_eq!(sanitize_csv_cell("-1+1"), "\"'-1+1\"");
595 }
596
597 #[test]
598 fn csv_cell_formula_prefix_at() {
599 assert_eq!(sanitize_csv_cell("@SUM(A1)"), "\"'@SUM(A1)\"");
600 }
601
602 #[test]
603 fn csv_cell_with_comma() {
604 assert_eq!(sanitize_csv_cell("one, two"), "\"one, two\"");
605 }
606
607 #[test]
608 fn csv_cell_with_quotes() {
609 assert_eq!(sanitize_csv_cell("say \"hi\""), "\"say \"\"hi\"\"\"");
610 }
611
612 #[test]
613 fn csv_cell_empty() {
614 assert_eq!(sanitize_csv_cell(""), "");
615 }
616
617 #[test]
618 fn csv_cell_with_newline() {
619 assert_eq!(sanitize_csv_cell("line1\nline2"), "\"line1\nline2\"");
620 }
621
622 #[test]
623 fn csv_cell_tab_prefix() {
624 let result = sanitize_csv_cell("\tcmd");
625 assert!(
626 result.starts_with("\"'"),
627 "Tab prefix should be neutralized: {result}"
628 );
629 }
630
631 #[test]
632 fn csv_cell_cr_prefix() {
633 let result = sanitize_csv_cell("\rcmd");
634 assert!(
635 result.starts_with("\"'"),
636 "CR prefix should be neutralized: {result}"
637 );
638 }
639
640 // --- Edge cases (test-fuzz) ---
641
642 #[test]
643 fn format_price_negative_one_cent() {
644 assert_eq!(format_price(-1i64, SettlementCurrency::Usd), "-$0.01");
645 }
646
647 #[test]
648 fn format_price_negative_whole_dollar() {
649 assert_eq!(format_price(-100i64, SettlementCurrency::Usd), "-$1");
650 }
651
652 #[test]
653 fn format_price_large_value() {
654 // $1 billion in cents
655 assert_eq!(
656 format_price(100_000_000_000i64, SettlementCurrency::Usd),
657 "$1,000,000,000"
658 );
659 }
660
661 #[test]
662 fn format_revenue_one_cent() {
663 assert_eq!(format_revenue(1, SettlementCurrency::Usd), "$0.01");
664 }
665
666 #[test]
667 fn format_revenue_negative_one_cent() {
668 assert_eq!(format_revenue(-1, SettlementCurrency::Usd), "-$0.01");
669 }
670
671 #[test]
672 fn format_file_size_negative() {
673 // Negative byte count: only == 0 returns "N/A"; negatives go through
674 // format_bytes which clamps to 0 via .max(0) → "0 B"
675 assert_eq!(format_file_size(-100), "0 B");
676 }
677
678 #[test]
679 fn format_file_size_one_byte() {
680 assert_eq!(format_file_size(1), "1 B");
681 }
682
683 #[test]
684 fn slugify_truncation_trailing_hyphen() {
685 // 130 chars of "a-" pattern → after truncation at 128, trailing hyphen stripped
686 let input = "a-".repeat(65); // 130 chars
687 let slug = slugify(&input);
688 assert!(slug.len() <= 128);
689 assert!(
690 !slug.ends_with('-'),
691 "Slug should not end with hyphen after truncation"
692 );
693 }
694
695 #[test]
696 fn slugify_emoji_input() {
697 // Emoji are non-ASCII → become hyphens → collapsed to nothing, so the
698 // hash-fallback kicks in (was a constant "post").
699 let slug = slugify("\u{1f600}\u{1f600}\u{1f600}");
700 assert!(slug.as_str().starts_with("post-"));
701 assert!(crate::validation::validate_slug(slug.as_str()).is_ok());
702 }
703
704 #[test]
705 fn slugify_mixed_valid_after_truncation() {
706 // 127 a's + special char → truncation shouldn't break it
707 let input = format!("{}-z", "a".repeat(127));
708 let slug = slugify(&input);
709 assert!(slug.len() <= 128);
710 assert!(slug.len() >= 2);
711 }
712
713 #[test]
714 fn csv_cell_formula_with_embedded_quotes() {
715 // Formula prefix + embedded quotes = both escapes apply
716 let result = sanitize_csv_cell("=SUM(\"A1\")");
717 assert!(
718 result.starts_with("\"'="),
719 "Formula prefix not neutralized: {result}"
720 );
721 assert!(
722 result.contains("\"\""),
723 "Embedded quotes should be escaped: {result}"
724 );
725 }
726
727 #[test]
728 fn csv_cell_newline_with_formula_prefix() {
729 // Newline AND formula prefix, both protections should apply
730 let result = sanitize_csv_cell("=cmd\ninjection");
731 assert!(
732 result.starts_with("\"'="),
733 "Formula prefix not neutralized: {result}"
734 );
735 }
736
737 #[test]
738 fn csv_cell_very_long_value() {
739 let long = "x".repeat(100_000);
740 let result = sanitize_csv_cell(&long);
741 // Plain text, no special chars → returned as-is
742 assert_eq!(result.len(), 100_000);
743 }
744
745 #[test]
746 fn initials_emoji_name() {
747 // Emoji as first char of name, still takes 2 initials
748 let result = get_initials("\u{1f600} Robot");
749 assert_eq!(result.chars().count(), 2); // emoji char + 'R'
750 }
751
752 #[test]
753 fn initials_single_char_name() {
754 assert_eq!(get_initials("A"), "A");
755 }
756
757 // --- Adversarial ---
758
759 #[test]
760 fn adversarial_csv_injection_dde() {
761 let result = sanitize_csv_cell("=cmd|'/C calc'!A0");
762 assert!(
763 result.starts_with("\"'="),
764 "DDE payload not neutralized: {result}"
765 );
766 }
767
768 #[test]
769 fn adversarial_csv_cell_null_bytes() {
770 let result = sanitize_csv_cell("hello\0world");
771 assert!(!result.is_empty());
772 }
773
774 #[test]
775 fn adversarial_slugify_xss_attempt() {
776 let slug = slugify("<script>alert('xss')</script>");
777 assert!(!slug.contains('<'));
778 assert!(!slug.contains('>'));
779 }
780
781 #[test]
782 fn adversarial_slugify_very_long_input() {
783 let long = "a".repeat(10_000);
784 let slug = slugify(&long);
785 assert!(
786 slug.len() <= 128,
787 "slug should be capped at 128 chars, got {}",
788 slug.len()
789 );
790 }
791
792 #[test]
793 fn adversarial_csv_rtl_override() {
794 // Right-to-left override character, could disguise cell content
795 let result = sanitize_csv_cell("normal\u{202e}evil");
796 assert!(!result.is_empty());
797 }
798
799 #[test]
800 fn adversarial_csv_zero_width_chars() {
801 // Zero-width space and zero-width joiner
802 let result = sanitize_csv_cell("=\u{200b}SUM(A1)");
803 // Starts with '=' so formula prefix should be applied
804 assert!(
805 result.starts_with("\"'="),
806 "Formula prefix not applied with ZWS: {result}"
807 );
808 }
809
810 #[test]
811 fn adversarial_slugify_path_traversal() {
812 let slug = slugify("../../../etc/passwd");
813 assert!(!slug.contains('.'));
814 assert!(!slug.contains('/'));
815 }
816
817 #[test]
818 fn adversarial_slugify_null_bytes() {
819 let slug = slugify("hello\0world");
820 assert!(!slug.contains('\0'));
821 assert!(slug.len() >= 2);
822 }
823
824 // --- Property-based tests ---
825
826 proptest::proptest! {
827 #[test]
828 fn prop_format_price_never_panics(cents in proptest::num::i64::ANY) {
829 let result = format_price(cents, SettlementCurrency::Usd);
830 proptest::prop_assert!(!result.is_empty());
831 match cents.cmp(&0) {
832 std::cmp::Ordering::Equal => {
833 proptest::prop_assert_eq!(result, "Free");
834 }
835 std::cmp::Ordering::Less => {
836 proptest::prop_assert!(result.starts_with("-$"),
837 "Negative price should start with -$: {}", result);
838 }
839 std::cmp::Ordering::Greater => {
840 proptest::prop_assert!(result.starts_with('$'),
841 "Positive price should start with $: {}", result);
842 }
843 }
844 }
845
846 #[test]
847 fn prop_format_revenue_never_panics(cents in proptest::num::i64::ANY) {
848 let result = format_revenue(cents, SettlementCurrency::Usd);
849 proptest::prop_assert!(result.starts_with('$') || result.starts_with("-$"),
850 "Revenue should start with $ or -$: {}", result);
851 }
852
853 #[test]
854 fn prop_format_bytes_never_panics(bytes in proptest::num::i64::ANY) {
855 let result = format_bytes(bytes);
856 proptest::prop_assert!(!result.is_empty());
857 }
858
859 #[test]
860 fn prop_slugify_never_panics(input in ".*") {
861 let slug = slugify(&input);
862 proptest::prop_assert!(slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'),
863 "Slug should only contain ASCII alphanumeric + hyphens: {}", slug.as_str());
864 proptest::prop_assert!(slug.len() >= 2, "Slug should be at least 2 chars: {}", slug.as_str());
865 }
866 }
867 }
868
869 /// Seal for the pricing-format drift chronic.
870 ///
871 /// An ad-hoc `cents as f64 / 100.0` conversion anywhere in routes, exports or
872 /// admin tooling is a place a rounding or display inconsistency creeps back in.
873 /// This test fails the build if that idiom appears anywhere under `src/` outside
874 /// this module, forcing every cents→dollars conversion through [`format_price`],
875 /// [`format_revenue`], or [`format_dollars_plain`].
876 #[cfg(test)]
877 mod pricing_format_seal_guard {
878 use std::path::Path;
879
880 #[test]
881 fn no_ad_hoc_cents_to_dollars_conversion() {
882 let root = Path::new(env!("CARGO_MANIFEST_DIR"));
883 let this_file = root.join("src/formatting.rs");
884 let mut offenders = Vec::new();
885
886 // The float cents→dollars idiom (rounding-risk) in ANY form, in Rust,
887 // the raw cast `as f64 / 100.0` AND the newtype accessor `.as_f64() / 100`
888 // that slipped past the earlier `as f64` pattern (the seal's own blind
889 // spot; `src/bin/` and the exports used it). Integer `cents / 100` manual
890 // formatting is a separate style item, not this float-drift seal.
891 let mut rust_check = |path: &Path, contents: &str| {
892 if path == this_file {
893 return;
894 }
895 for (i, line) in contents.lines().enumerate() {
896 if line.trim_start().starts_with("//") {
897 continue;
898 }
899 let squished: String = line.chars().filter(|c| !c.is_whitespace()).collect();
900 // The float idiom, in both the raw-cast and newtype-accessor forms.
901 let float_drift =
902 squished.contains("asf64/100") || squished.contains("as_f64()/100");
903 // The integer idiom, which the original seal did not cover and
904 // which twelve call sites had quietly grown: `format!("${}.{:02}",
905 // cents / 100, cents % 100)`. It hardcodes a dollar sign, so every
906 // one of them rendered a British creator's revenue as USD. The
907 // literal `$` is what makes it wrong, not the arithmetic, so that
908 // is what this matches.
909 let hardcoded_dollar_format = squished.contains(r#""${}.{:02}"#);
910 if float_drift || hardcoded_dollar_format {
911 offenders.push(format!("{}:{}: {}", path.display(), i + 1, line.trim()));
912 }
913 }
914 };
915 walk(&root.join("src"), "rs", &mut rust_check);
916
917 // Templates must never do cents math, prices arrive pre-formatted from
918 // Rust. A `{{ price_cents / 100 }}` bypasses the centralized formatters in
919 // the one surface the original seal never scanned.
920 let mut template_check = |path: &Path, contents: &str| {
921 for (i, line) in contents.lines().enumerate() {
922 if line.trim_start().starts_with("{#") {
923 continue;
924 }
925 let squished: String = line.chars().filter(|c| !c.is_whitespace()).collect();
926 if squished.contains("cents/100") || squished.contains("asf64/100") {
927 offenders.push(format!("{}:{}: {}", path.display(), i + 1, line.trim()));
928 }
929 }
930 };
931 walk(&root.join("templates"), "html", &mut template_check);
932
933 assert!(
934 offenders.is_empty(),
935 "pricing-format seal violated, convert cents to an amount via \
936 formatting::format_price / format_revenue / format_dollars_plain (never a raw \
937 `as f64 / 100.0` or `.as_f64() / 100`, never a hardcoded `format!(\"${{}}.{{:02}}\", \
938 ...)` which cannot render a non-USD creator's currency, never cents math in a \
939 template). Offending lines:\n{}",
940 offenders.join("\n")
941 );
942 }
943
944 fn walk(dir: &Path, ext: &str, f: &mut impl FnMut(&Path, &str)) {
945 let Ok(entries) = std::fs::read_dir(dir) else {
946 return;
947 };
948 for entry in entries.flatten() {
949 let path = entry.path();
950 if path.is_dir() {
951 walk(&path, ext, f);
952 } else if path.extension().is_some_and(|e| e == ext)
953 && let Ok(contents) = std::fs::read_to_string(&path)
954 {
955 f(&path, &contents);
956 }
957 }
958 }
959 }
960