fastqc_rust/utils/format.rs
1/// Java Double.toString() compatible formatting.
2///
3/// This module provides `java_format_double` which produces output matching
4/// Java's `Double.toString(double)` for the value ranges encountered in FastQC
5/// output. This is critical for byte-exact output matching.
6/// Format an f64 value to match Java's `Double.toString(double)` output.
7///
8/// JAVA COMPAT: Java.s Double.toString always includes at least one digit after
9/// the decimal point (e.g., "9.0" not "9"), uses "NaN" (capital N-a-N), and
10/// switches to scientific notation with uppercase "E" for very large/small values.
11///
12/// Key differences from Rust's default Display:
13/// - Rust prints `9` for 9.0f64; Java prints "9.0"
14/// - Rust prints `NaN`; Java prints "NaN" (same, fortunately)
15/// - Rust prints `inf`; Java prints "Infinity"
16/// - Rust prints `-inf`; Java prints "-Infinity"
17pub fn java_format_double(v: f64) -> String {
18 // Handle special cases
19 if v.is_nan() {
20 return "NaN".to_string();
21 }
22 if v.is_infinite() {
23 return if v.is_sign_positive() {
24 // JAVA COMPAT: Java uses "Infinity" not "inf"
25 "Infinity".to_string()
26 } else {
27 "-Infinity".to_string()
28 };
29 }
30
31 // JAVA COMPAT: Java.s Double.toString(-0.0) returns "-0.0"
32 if v == 0.0 && v.is_sign_negative() {
33 return "-0.0".to_string();
34 }
35
36 let abs = v.abs();
37
38 // JAVA COMPAT: Java uses scientific notation for |v| >= 1e7 or (|v| < 1e-3 and |v| > 0).
39 // For values in FastQC output, this is rarely needed, but we handle it for correctness.
40 if abs != 0.0 && !(1e-3..1e7).contains(&abs) {
41 return format_scientific(v);
42 }
43
44 // For normal range values, format with Rust and ensure decimal point is present.
45 let s = format!("{}", v);
46
47 // JAVA COMPAT: Rust.s Display for f64 omits ".0" for integer-valued doubles.
48 // Java always includes at least one decimal digit.
49 if s.contains('.') {
50 s
51 } else {
52 format!("{}.0", s)
53 }
54}
55
56/// Format a value in Java-style scientific notation.
57///
58/// JAVA COMPAT: Java uses uppercase "E" and formats the exponent without leading
59/// zeros (e.g., "1.5E-4" not "1.5e-04"). The mantissa uses the shortest
60/// representation that uniquely identifies the double, but always includes at
61/// least one digit after the decimal point (e.g., "1.0E7" not "1E7").
62fn format_scientific(v: f64) -> String {
63 // Use Rust's {:e} format then fixup to match Java conventions.
64 let s = format!("{:e}", v);
65
66 // Rust uses lowercase 'e'; Java uses uppercase 'E'.
67 let s = s.replace('e', "E");
68
69 // Parse mantissa and exponent to reformat both.
70 if let Some(e_pos) = s.find('E') {
71 let (mantissa, exp_part) = s.split_at(e_pos);
72 let exp_str = &exp_part[1..]; // skip 'E'
73
74 // JAVA COMPAT: Ensure mantissa has a decimal point (e.g., "1.0" not "1").
75 let mantissa = if mantissa.contains('.') {
76 mantissa.to_string()
77 } else {
78 format!("{}.0", mantissa)
79 };
80
81 // JAVA COMPAT: No leading zeros on exponent (e.g., "E7" not "E07").
82 if let Ok(exp_val) = exp_str.parse::<i32>() {
83 return format!("{}E{}", mantissa, exp_val);
84 }
85 }
86
87 s
88}
89
90#[cfg(test)]
91mod tests {
92 use super::*;
93
94 // Values directly observed in FastQC approved output files
95
96 #[test]
97 fn test_integer_values() {
98 assert_eq!(java_format_double(9.0), "9.0");
99 assert_eq!(java_format_double(100.0), "100.0");
100 assert_eq!(java_format_double(0.0), "0.0");
101 assert_eq!(java_format_double(1.0), "1.0");
102 assert_eq!(java_format_double(5.0), "5.0");
103 assert_eq!(java_format_double(20.0), "20.0");
104 }
105
106 #[test]
107 fn test_fractional_values() {
108 assert_eq!(java_format_double(0.5), "0.5");
109 assert_eq!(java_format_double(2.5), "2.5");
110 }
111
112 #[test]
113 fn test_nan() {
114 assert_eq!(java_format_double(f64::NAN), "NaN");
115 }
116
117 #[test]
118 fn test_infinity() {
119 // JAVA COMPAT: Java uses "Infinity" not "inf"
120 assert_eq!(java_format_double(f64::INFINITY), "Infinity");
121 assert_eq!(java_format_double(f64::NEG_INFINITY), "-Infinity");
122 }
123
124 #[test]
125 fn test_negative_zero() {
126 // JAVA COMPAT: Java preserves the sign of negative zero
127 assert_eq!(java_format_double(-0.0), "-0.0");
128 }
129
130 #[test]
131 fn test_negative_values() {
132 assert_eq!(java_format_double(-1.0), "-1.0");
133 assert_eq!(java_format_double(-0.5), "-0.5");
134 }
135
136 #[test]
137 fn test_scientific_large() {
138 // JAVA COMPAT: Java switches to scientific notation at >= 1e7
139 assert_eq!(java_format_double(1e7), "1.0E7");
140 assert_eq!(java_format_double(1.5e10), "1.5E10");
141 }
142
143 #[test]
144 fn test_scientific_small() {
145 // JAVA COMPAT: Java switches to scientific notation for |v| < 1e-3
146 assert_eq!(java_format_double(1e-4), "1.0E-4");
147 assert_eq!(java_format_double(1.5e-4), "1.5E-4");
148 }
149
150 #[test]
151 fn test_normal_range_boundary() {
152 // Values just inside the normal range
153 assert_eq!(java_format_double(0.001), "0.001");
154 assert_eq!(java_format_double(9999999.0), "9999999.0");
155 }
156
157 #[test]
158 fn test_typical_fastqc_doubles() {
159 // More values commonly seen in FastQC output
160 assert_eq!(java_format_double(25.0), "25.0");
161 assert_eq!(java_format_double(50.0), "50.0");
162 assert_eq!(java_format_double(75.0), "75.0");
163 assert_eq!(java_format_double(12.345), "12.345");
164 assert_eq!(java_format_double(99.99), "99.99");
165 }
166
167 #[test]
168 fn test_very_precise_decimal() {
169 // Verify that standard precision is maintained
170 let val = 33.333333333333336;
171 let formatted = java_format_double(val);
172 // Should contain the decimal point
173 assert!(formatted.contains('.'));
174 // Should round-trip parse to the same value
175 let parsed: f64 = formatted.parse().unwrap();
176 assert_eq!(parsed, val);
177 }
178}