Skip to main content

fits_header/
value.rs

1//! Typed reads (`FromCard`) and writes (`IntoValue`), plus number parsing/formatting.
2
3use crate::record::{Record, Value};
4
5/// Convert a record's value into a Rust type. The extension point behind
6/// [`Header::get`](crate::Header::get).
7pub trait FromCard: Sized {
8    /// Extract `Self` from a record, or `None` if absent/unparseable.
9    fn from_card(record: &Record) -> Option<Self>;
10}
11
12/// Convert a Rust value into a [`Value`]. The extension point behind
13/// [`Header::set`](crate::Header::set) / [`append`](crate::Header::append).
14pub trait IntoValue {
15    /// The value payload to store.
16    fn into_value(self) -> Value;
17}
18
19// --- FromCard -------------------------------------------------------------
20
21impl FromCard for String {
22    fn from_card(record: &Record) -> Option<Self> {
23        record.value_text().map(str::to_string)
24    }
25}
26
27impl FromCard for bool {
28    fn from_card(record: &Record) -> Option<Self> {
29        match record.value_text()?.trim() {
30            "T" | "1" => Some(true),
31            "F" | "0" => Some(false),
32            _ => None,
33        }
34    }
35}
36
37impl FromCard for f64 {
38    fn from_card(record: &Record) -> Option<Self> {
39        record.value_text().and_then(parse_f64)
40    }
41}
42
43impl FromCard for f32 {
44    fn from_card(record: &Record) -> Option<Self> {
45        record.value_text().and_then(parse_f64).map(|v| v as f32)
46    }
47}
48
49macro_rules! from_card_int {
50    ($($t:ty),* $(,)?) => {$(
51        impl FromCard for $t {
52            fn from_card(record: &Record) -> Option<Self> {
53                record.value_text().and_then(lenient_i128).and_then(|v| <$t>::try_from(v).ok())
54            }
55        }
56    )*};
57}
58from_card_int!(i64, i32, u64, u32, i16, u16, i8, u8);
59
60impl FromCard for time::PrimitiveDateTime {
61    fn from_card(record: &Record) -> Option<Self> {
62        record.value_text().and_then(crate::dates::parse_datetime)
63    }
64}
65
66// --- IntoValue ------------------------------------------------------------
67
68impl IntoValue for &str {
69    fn into_value(self) -> Value {
70        Value::Str(self.to_string())
71    }
72}
73
74impl IntoValue for String {
75    fn into_value(self) -> Value {
76        Value::Str(self)
77    }
78}
79
80impl IntoValue for &String {
81    fn into_value(self) -> Value {
82        Value::Str(self.clone())
83    }
84}
85
86impl IntoValue for bool {
87    fn into_value(self) -> Value {
88        Value::Literal(if self { "T" } else { "F" }.to_string())
89    }
90}
91
92macro_rules! into_value_int {
93    ($($t:ty),* $(,)?) => {$(
94        impl IntoValue for $t {
95            fn into_value(self) -> Value { Value::Literal(self.to_string()) }
96        }
97    )*};
98}
99into_value_int!(i64, i32, u64, u32, i16, u16, i8, u8, usize, isize);
100
101impl IntoValue for f64 {
102    fn into_value(self) -> Value {
103        Value::Literal(format_f64(self))
104    }
105}
106
107impl IntoValue for f32 {
108    fn into_value(self) -> Value {
109        Value::Literal(format_f64(self as f64))
110    }
111}
112
113/// Write a literal token verbatim (numeric text a vendor supplied, or a value you don't want
114/// reformatted).
115///
116/// # Examples
117///
118/// ```
119/// # use fits_header::{Header, Literal};
120/// let mut h = Header::new();
121/// h.set("BSCALE", Literal("1.000")).unwrap(); // kept as-is, not reformatted
122/// assert_eq!(h.get::<String>("BSCALE").unwrap().as_deref(), Some("1.000"));
123/// ```
124pub struct Literal<S: Into<String>>(pub S);
125
126impl<S: Into<String>> IntoValue for Literal<S> {
127    fn into_value(self) -> Value {
128        Value::Literal(self.0.into())
129    }
130}
131
132/// Write a float with a fixed number of decimal places.
133///
134/// # Examples
135///
136/// ```
137/// # use fits_header::{Fixed, Header};
138/// let mut h = Header::new();
139/// h.set("EXPTIME", Fixed(120.0, 2)).unwrap();
140/// assert_eq!(h.get::<String>("EXPTIME").unwrap().as_deref(), Some("120.00"));
141/// ```
142pub struct Fixed(pub f64, pub u8);
143
144impl IntoValue for Fixed {
145    fn into_value(self) -> Value {
146        Value::Literal(format!("{:.*}", self.1 as usize, self.0))
147    }
148}
149
150/// Write a float in scientific notation with `N` significant digits (uppercase `E`).
151///
152/// # Examples
153///
154/// ```
155/// # use fits_header::{Header, Sci};
156/// let mut h = Header::new();
157/// h.set("BZERO", Sci(0.000123, 3)).unwrap();
158/// assert_eq!(h.get::<String>("BZERO").unwrap().as_deref(), Some("1.23E-4"));
159/// ```
160pub struct Sci(pub f64, pub u8);
161
162impl IntoValue for Sci {
163    fn into_value(self) -> Value {
164        let prec = self.1.saturating_sub(1) as usize;
165        Value::Literal(format!("{:.*E}", prec, self.0))
166    }
167}
168
169// --- number parsing/formatting -------------------------------------------
170
171/// Parse a float, accepting the Fortran `D` exponent (`1.5D3`).
172///
173/// # Examples
174///
175/// ```
176/// assert_eq!(fits_header::parse_f64("1.5D3"), Some(1500.0));
177/// assert_eq!(fits_header::parse_f64("120.0"), Some(120.0));
178/// ```
179pub fn parse_f64(s: &str) -> Option<f64> {
180    let t = s.trim();
181    if t.contains(['D', 'd']) {
182        t.replace(['D', 'd'], "E").parse().ok()
183    } else {
184        t.parse().ok()
185    }
186}
187
188/// Parse an integer, accepting decimal-form integers (`"20.0"` → `20`).
189///
190/// # Examples
191///
192/// ```
193/// assert_eq!(fits_header::parse_i64("20.0"), Some(20));
194/// assert_eq!(fits_header::parse_i64("20.5"), None);
195/// ```
196pub fn parse_i64(s: &str) -> Option<i64> {
197    lenient_i128(s).and_then(|v| i64::try_from(v).ok())
198}
199
200fn lenient_i128(s: &str) -> Option<i128> {
201    let t = s.trim();
202    if let Ok(i) = t.parse::<i128>() {
203        return Some(i);
204    }
205    let f = parse_f64(t)?;
206    (f.fract() == 0.0 && f.is_finite()).then_some(f as i128)
207}
208
209/// Shortest round-trip `f64` text, normalized to read as a float (decimal point or `E`).
210pub(crate) fn format_f64(x: f64) -> String {
211    if !x.is_finite() {
212        return x.to_string();
213    }
214    let mut s = format!("{x}");
215    if s.len() > 20 {
216        // Positional display expands extreme magnitudes into hundreds of digits, which no
217        // 80-byte card can hold; exponent form is equally exact and always fits.
218        s = format!("{x:E}");
219    } else if !s.contains('.') {
220        s.push_str(".0");
221    }
222    s
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use crate::record::Record;
229
230    fn literal(token: &str) -> Record {
231        Record::value("K", Value::Literal(token.to_string()), None)
232    }
233
234    #[test]
235    fn format_f64_normalizes() {
236        assert_eq!(format_f64(120.0), "120.0");
237        assert_eq!(format_f64(0.5), "0.5");
238        assert_eq!(format_f64(-0.0), "-0.0");
239        assert_eq!(format_f64(f64::NAN), "NaN");
240        // Extreme magnitudes switch to exponent form instead of hundreds of digits.
241        assert_eq!(format_f64(1e300), "1E300");
242        assert_eq!(format_f64(1e-300), "1E-300");
243        assert_eq!(format_f64(f64::MIN_POSITIVE), "2.2250738585072014E-308");
244        // Every emitted token fits a fixed-format value field or close to it, and
245        // round-trips exactly.
246        for x in [0.1, 1.0 / 3.0, 6.02214076e23, 1e300, -1e-300, f64::MAX] {
247            let s = format_f64(x);
248            assert!(s.len() <= 24, "token too long: {s}");
249            assert_eq!(s.parse::<f64>().unwrap(), x);
250        }
251    }
252
253    #[test]
254    fn parse_f64_accepts_fortran_exponent() {
255        assert_eq!(parse_f64("1.5D3"), Some(1500.0));
256        assert_eq!(parse_f64("1.5d-2"), Some(0.015));
257        assert_eq!(parse_f64(" 2.0 "), Some(2.0));
258        assert_eq!(parse_f64("abc"), None);
259    }
260
261    #[test]
262    fn parse_i64_is_lenient_but_rejects_fractions() {
263        assert_eq!(parse_i64("20.0"), Some(20));
264        assert_eq!(parse_i64("20.5"), None);
265        assert_eq!(parse_i64("1e3"), Some(1000));
266        // Larger than i64 → None, not a wrap.
267        assert_eq!(parse_i64("170141183460469231731687303715884105727"), None);
268        assert_eq!(parse_i64("inf"), None);
269    }
270
271    #[test]
272    fn int_narrowing_fails_closed() {
273        assert_eq!(u8::from_card(&literal("300")), None);
274        assert_eq!(u8::from_card(&literal("255")), Some(255));
275        assert_eq!(u32::from_card(&literal("-1")), None);
276        assert_eq!(i16::from_card(&literal("-32768")), Some(-32768));
277    }
278
279    #[test]
280    fn bool_from_card_variants() {
281        assert_eq!(bool::from_card(&literal("T")), Some(true));
282        assert_eq!(bool::from_card(&literal("1")), Some(true));
283        assert_eq!(bool::from_card(&literal("F")), Some(false));
284        assert_eq!(bool::from_card(&literal("0")), Some(false));
285        assert_eq!(bool::from_card(&literal("yes")), None);
286    }
287
288    #[test]
289    fn string_from_card_reads_literal_token() {
290        assert_eq!(
291            String::from_card(&literal("120.0")).as_deref(),
292            Some("120.0")
293        );
294        // An empty Str value reads as absent.
295        let empty = Record::value("K", Value::Str(String::new()), None);
296        assert_eq!(String::from_card(&empty), None);
297    }
298
299    #[test]
300    fn into_value_wrappers() {
301        assert_eq!(true.into_value(), Value::Literal("T".to_string()));
302        assert_eq!(false.into_value(), Value::Literal("F".to_string()));
303        assert_eq!(
304            Literal("007").into_value(),
305            Value::Literal("007".to_string())
306        );
307        assert_eq!(
308            Fixed(1.5, 3).into_value(),
309            Value::Literal("1.500".to_string())
310        );
311        assert_eq!(
312            Sci(0.000123, 3).into_value(),
313            Value::Literal("1.23E-4".to_string())
314        );
315        assert_eq!(
316            Sci(1234.0, 1).into_value(),
317            Value::Literal("1E3".to_string())
318        );
319        assert_eq!("s".into_value(), Value::Str("s".to_string()));
320        assert_eq!((&"s".to_string()).into_value(), Value::Str("s".to_string()));
321        assert_eq!(42u8.into_value(), Value::Literal("42".to_string()));
322        assert_eq!((-3isize).into_value(), Value::Literal("-3".to_string()));
323        assert_eq!(2.5f32.into_value(), Value::Literal("2.5".to_string()));
324    }
325
326    #[test]
327    fn datetime_from_card() {
328        let r = Record::value("K", Value::Str("2026-07-11T22:15:03".to_string()), None);
329        let dt = time::PrimitiveDateTime::from_card(&r).unwrap();
330        assert_eq!(crate::dates::format_datetime(&dt), "2026-07-11T22:15:03");
331        assert_eq!(time::PrimitiveDateTime::from_card(&literal("nope")), None);
332    }
333}