Skip to main content

xisf_header/
value.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Value representation and the [`FromField`] / [`IntoValue`] conversion traits.
6
7use time::PrimitiveDateTime;
8
9/// A keyword value together with how it should be serialized.
10///
11/// XISF stores FITS keyword values with FITS formatting conventions: string
12/// values are single-quoted, everything else (numbers, logicals) is bare. The
13/// distinction is preserved so a value round-trips as the same kind it was.
14///
15/// ```
16/// use xisf_header::Value;
17///
18/// let string_value = Value::Str("Master Dark".to_owned());
19/// let literal_value = Value::Literal("300".to_owned());
20/// assert_eq!(string_value.text(), "Master Dark");
21/// assert_eq!(literal_value.text(), "300");
22/// ```
23#[derive(Debug, Clone, PartialEq, Eq)]
24#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
25pub enum Value {
26    /// A string value (serialized single-quoted, `''`-escaped).
27    Str(String),
28    /// A bare literal — number, logical, or any pre-formatted token.
29    Literal(String),
30}
31
32impl Value {
33    /// The underlying text, regardless of kind.
34    #[must_use]
35    pub fn text(&self) -> &str {
36        match self {
37            Value::Str(s) | Value::Literal(s) => s,
38        }
39    }
40}
41
42impl Default for Value {
43    fn default() -> Self {
44        Value::Str(String::new())
45    }
46}
47
48/// Interpret a keyword's value text as a Rust type.
49///
50/// This is the open extension point behind [`Header::get`](crate::Header::get):
51/// implement it for your own type to call `header.get::<MyType>(key)`.
52/// Returning `None` means "the value cannot be read as this type" (treated as
53/// absence, never an error).
54///
55/// ```
56/// use xisf_header::FromField;
57///
58/// assert_eq!(f64::from_field("300"), Some(300.0));
59/// assert_eq!(bool::from_field("T"), Some(true));
60/// assert_eq!(i64::from_field("not a number"), None);
61/// ```
62pub trait FromField: Sized {
63    /// Parse the value text into `Self`, or `None` if it does not apply.
64    fn from_field(text: &str) -> Option<Self>;
65}
66
67impl FromField for String {
68    fn from_field(text: &str) -> Option<Self> {
69        Some(text.to_owned())
70    }
71}
72
73impl FromField for f64 {
74    fn from_field(text: &str) -> Option<Self> {
75        text.trim().parse().ok()
76    }
77}
78
79impl FromField for i64 {
80    fn from_field(text: &str) -> Option<Self> {
81        lenient_int(text)
82    }
83}
84
85impl FromField for u32 {
86    fn from_field(text: &str) -> Option<Self> {
87        u32::try_from(lenient_int(text)?).ok()
88    }
89}
90
91impl FromField for bool {
92    fn from_field(text: &str) -> Option<Self> {
93        match text.trim() {
94            "T" | "t" | "1" => Some(true),
95            "F" | "f" | "0" => Some(false),
96            s if s.eq_ignore_ascii_case("true") => Some(true),
97            s if s.eq_ignore_ascii_case("false") => Some(false),
98            _ => None,
99        }
100    }
101}
102
103impl FromField for PrimitiveDateTime {
104    fn from_field(text: &str) -> Option<Self> {
105        parse_datetime(text)
106    }
107}
108
109/// Lenient integer parse: accepts `20` and the decimal form `20.0`.
110fn lenient_int(text: &str) -> Option<i64> {
111    let t = text.trim();
112    if let Ok(n) = t.parse::<i64>() {
113        return Some(n);
114    }
115    let f = t.parse::<f64>().ok()?;
116    if f.fract() == 0.0 && f.is_finite() && f.abs() < 9.007_199_254_740_992e15 {
117        // integral f64 within i64's exactly-representable range
118        Some(f as i64)
119    } else {
120        None
121    }
122}
123
124/// Parse a FITS/XISF ISO-8601 civil date/time, with or without fractional
125/// seconds, or a bare calendar date (interpreted at midnight). Delegates to
126/// `skymath::parse_date_obs` so this crate carries one implementation of
127/// FITS `DATE-OBS` parsing instead of a hand-rolled duplicate (see
128/// nightwatch-astro/xisf-header#5).
129fn parse_datetime(text: &str) -> Option<PrimitiveDateTime> {
130    skymath::parse_date_obs(text).ok()
131}
132
133/// Produce a [`Value`] for a write, with the on-disk kind chosen by the Rust
134/// type: strings become quoted string values, numbers and logicals become bare
135/// literals. Use [`Literal`], [`Fixed`], or [`Sci`] for controlled formatting.
136///
137/// ```
138/// use xisf_header::{IntoValue, Value};
139///
140/// assert_eq!("Master Dark".into_value(), Value::Str("Master Dark".to_owned()));
141/// assert_eq!(300.0.into_value().text(), "300.0");
142/// ```
143pub trait IntoValue {
144    /// Convert `self` into a serializable [`Value`].
145    fn into_value(self) -> Value;
146}
147
148impl IntoValue for Value {
149    fn into_value(self) -> Value {
150        self
151    }
152}
153
154impl IntoValue for &str {
155    fn into_value(self) -> Value {
156        Value::Str(self.to_owned())
157    }
158}
159
160impl IntoValue for String {
161    fn into_value(self) -> Value {
162        Value::Str(self)
163    }
164}
165
166impl IntoValue for f64 {
167    fn into_value(self) -> Value {
168        Value::Literal(format_f64(self))
169    }
170}
171
172impl IntoValue for i64 {
173    fn into_value(self) -> Value {
174        Value::Literal(self.to_string())
175    }
176}
177
178impl IntoValue for u32 {
179    fn into_value(self) -> Value {
180        Value::Literal(self.to_string())
181    }
182}
183
184impl IntoValue for bool {
185    fn into_value(self) -> Value {
186        Value::Literal(if self { "T" } else { "F" }.to_owned())
187    }
188}
189
190/// Write a value as a bare literal exactly as given (escape hatch for
191/// pre-formatted or vendor-specific tokens).
192///
193/// ```
194/// use xisf_header::{Header, Literal};
195///
196/// let mut header = Header::new();
197/// header.set("FLAGS", Literal("0x1F".to_owned()))?;
198/// assert_eq!(header.get_str("FLAGS")?, Some("0x1F"));
199/// # Ok::<(), xisf_header::Error>(())
200/// ```
201#[derive(Debug, Clone)]
202pub struct Literal(pub String);
203
204impl IntoValue for Literal {
205    fn into_value(self) -> Value {
206        Value::Literal(self.0)
207    }
208}
209
210/// Write a float in fixed-point notation with `decimals` fractional digits.
211///
212/// ```
213/// use xisf_header::{Fixed, Header};
214///
215/// let mut header = Header::new();
216/// header.set("EXPTIME", Fixed(300.0, 2))?;
217/// assert_eq!(header.get_str("EXPTIME")?, Some("300.00"));
218/// # Ok::<(), xisf_header::Error>(())
219/// ```
220#[derive(Debug, Clone, Copy)]
221pub struct Fixed(pub f64, pub usize);
222
223impl IntoValue for Fixed {
224    fn into_value(self) -> Value {
225        Value::Literal(format!("{:.*}", self.1, self.0))
226    }
227}
228
229/// Write a float in scientific notation with `sig_digits` significant digits,
230/// using the FITS `E` exponent marker.
231///
232/// ```
233/// use xisf_header::{Header, Sci};
234///
235/// let mut header = Header::new();
236/// header.set("FLUX", Sci(1234.5, 3))?;
237/// assert_eq!(header.get_str("FLUX")?, Some("1.23E3"));
238/// # Ok::<(), xisf_header::Error>(())
239/// ```
240#[derive(Debug, Clone, Copy)]
241pub struct Sci(pub f64, pub usize);
242
243impl IntoValue for Sci {
244    fn into_value(self) -> Value {
245        let digits = self.1.saturating_sub(1);
246        Value::Literal(format!("{:.*e}", digits, self.0).replace('e', "E"))
247    }
248}
249
250/// Format an `f64` as the shortest round-trippable decimal, normalized to read
251/// as a float (a `.` or exponent is always present).
252fn format_f64(v: f64) -> String {
253    let s = format!("{v}");
254    if s.contains(['.', 'e', 'E']) || !v.is_finite() {
255        // already floating-looking, or inf/NaN
256        s
257    } else {
258        format!("{s}.0")
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    #[test]
267    fn from_field_string_is_verbatim() {
268        assert_eq!(String::from_field(" M31 "), Some(" M31 ".to_owned()));
269    }
270
271    #[test]
272    fn from_field_f64() {
273        assert_eq!(f64::from_field("300"), Some(300.0));
274        assert_eq!(f64::from_field(" 3.5 "), Some(3.5));
275        assert_eq!(f64::from_field("1e3"), Some(1000.0));
276        assert_eq!(f64::from_field("abc"), None);
277    }
278
279    #[test]
280    fn from_field_i64_is_lenient() {
281        assert_eq!(i64::from_field("20"), Some(20));
282        assert_eq!(i64::from_field("20.0"), Some(20));
283        assert_eq!(i64::from_field(" -7 "), Some(-7));
284        assert_eq!(i64::from_field("1e10"), Some(10_000_000_000));
285        assert_eq!(i64::from_field("20.5"), None);
286        assert_eq!(i64::from_field("1e300"), None); // beyond exact-f64 range
287        assert_eq!(i64::from_field("inf"), None);
288        assert_eq!(i64::from_field("abc"), None);
289    }
290
291    #[test]
292    fn from_field_u32() {
293        assert_eq!(u32::from_field("2"), Some(2));
294        assert_eq!(u32::from_field("2.0"), Some(2));
295        assert_eq!(u32::from_field("-1"), None);
296        assert_eq!(u32::from_field("4294967296"), None); // u32::MAX + 1
297    }
298
299    #[test]
300    fn from_field_bool_spellings() {
301        for t in ["T", "t", "1", "true", "TRUE", " T "] {
302            assert_eq!(bool::from_field(t), Some(true), "{t:?}");
303        }
304        for f in ["F", "f", "0", "false", "FALSE"] {
305            assert_eq!(bool::from_field(f), Some(false), "{f:?}");
306        }
307        assert_eq!(bool::from_field("yes"), None);
308        assert_eq!(bool::from_field(""), None);
309    }
310
311    #[test]
312    fn from_field_datetime_forms() {
313        let dt = PrimitiveDateTime::from_field("2026-07-11T22:15:03").unwrap();
314        assert_eq!((dt.year(), dt.hour(), dt.second()), (2026, 22, 3));
315
316        let frac = PrimitiveDateTime::from_field("2026-07-11T22:15:03.25").unwrap();
317        assert_eq!(frac.millisecond(), 250);
318
319        // A trailing Z (UTC designator) is tolerated.
320        assert!(PrimitiveDateTime::from_field("2026-07-11T22:15:03Z").is_some());
321
322        // A bare calendar date reads as midnight.
323        let date = PrimitiveDateTime::from_field("2026-07-11").unwrap();
324        assert_eq!((date.hour(), date.minute()), (0, 0));
325
326        assert_eq!(PrimitiveDateTime::from_field("2026-13-40T00:00:00"), None);
327        assert_eq!(PrimitiveDateTime::from_field("not a date"), None);
328    }
329
330    #[test]
331    fn into_value_kind_selection() {
332        assert_eq!("s".into_value(), Value::Str("s".to_owned()));
333        assert_eq!(String::from("s").into_value(), Value::Str("s".to_owned()));
334        assert_eq!(100_i64.into_value(), Value::Literal("100".to_owned()));
335        assert_eq!(2_u32.into_value(), Value::Literal("2".to_owned()));
336        assert_eq!(true.into_value(), Value::Literal("T".to_owned()));
337        assert_eq!(false.into_value(), Value::Literal("F".to_owned()));
338        let v = Value::Literal("x".to_owned());
339        assert_eq!(v.clone().into_value(), v);
340    }
341
342    #[test]
343    fn f64_formatting_is_normalized() {
344        assert_eq!(300.0.into_value(), Value::Literal("300.0".to_owned()));
345        assert_eq!(0.5.into_value(), Value::Literal("0.5".to_owned()));
346        // Huge magnitudes render as full decimals; the text must read back
347        // as the identical float.
348        let huge = 1e300.into_value();
349        assert_eq!(f64::from_field(huge.text()), Some(1e300));
350        assert!(huge.text().ends_with(".0"));
351        assert_eq!(f64::INFINITY.into_value(), Value::Literal("inf".to_owned()));
352        assert_eq!(f64::NAN.into_value(), Value::Literal("NaN".to_owned()));
353    }
354
355    #[test]
356    fn controlled_formatting_wrappers() {
357        assert_eq!(
358            Fixed(300.0, 2).into_value(),
359            Value::Literal("300.00".to_owned())
360        );
361        assert_eq!(
362            Sci(1234.5, 3).into_value(),
363            Value::Literal("1.23E3".to_owned())
364        );
365        assert_eq!(
366            Sci(0.00012345, 2).into_value(),
367            Value::Literal("1.2E-4".to_owned())
368        );
369        assert_eq!(
370            Literal("0x1F".to_owned()).into_value(),
371            Value::Literal("0x1F".to_owned())
372        );
373    }
374
375    #[test]
376    fn value_text_and_default() {
377        assert_eq!(Value::Str("a".to_owned()).text(), "a");
378        assert_eq!(Value::Literal("1".to_owned()).text(), "1");
379        assert_eq!(Value::default(), Value::Str(String::new()));
380    }
381}