Skip to main content

xisf_header/
value.rs

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