Skip to main content

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}