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