Skip to main content

fits_header/
record.rs

1//! Header records (cards) and their value payloads.
2
3use crate::error::FitsError;
4use crate::CARD_LEN;
5
6/// A value card's payload.
7///
8/// [`IntoValue`](crate::IntoValue) produces one of these from a Rust value; a card's
9/// [`RecordKind::Value`] holds one.
10///
11/// # Examples
12///
13/// ```
14/// # use fits_header::{Header, RecordKind, Value};
15/// let mut h = Header::new();
16/// h.set("OBJECT", "M31").unwrap(); // a quoted string
17/// h.set("EXPTIME", 120.0).unwrap(); // an unquoted literal
18///
19/// let values: Vec<&Value> = h
20///     .cards()
21///     .iter()
22///     .filter_map(|r| match &r.kind {
23///         RecordKind::Value { value, .. } => Some(value),
24///         _ => None,
25///     })
26///     .collect();
27/// assert_eq!(
28///     values,
29///     vec![&Value::Str("M31".to_string()), &Value::Literal("120.0".to_string())]
30/// );
31/// ```
32#[derive(Clone, Debug, PartialEq, Eq)]
33#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
34pub enum Value {
35    /// A single-quoted string; content is unescaped. `Str("")` is present-but-empty.
36    Str(String),
37    /// An unquoted literal token (number, `T`/`F`, …), kept verbatim.
38    Literal(String),
39}
40
41/// The semantic content of a [`Record`].
42///
43/// # Examples
44///
45/// ```
46/// # use fits_header::{Header, RecordKind};
47/// let mut h = Header::new();
48/// h.set("OBJECT", "M31").unwrap();
49/// h.append("HISTORY", "dark subtracted").unwrap();
50///
51/// assert!(matches!(h.cards()[0].kind, RecordKind::Value { .. }));
52/// assert!(matches!(h.cards()[1].kind, RecordKind::Commentary { .. }));
53/// ```
54#[derive(Clone, Debug, PartialEq, Eq)]
55#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
56pub enum RecordKind {
57    /// An addressable value card: `KEYWORD = value / comment`.
58    Value {
59        /// The trimmed 8-character keyword.
60        keyword: String,
61        /// The value payload.
62        value: Value,
63        /// The inline comment, without the leading ` / `.
64        comment: Option<String>,
65    },
66    /// A repeatable free-text card: `COMMENT`/`HISTORY`/blank keyword.
67    Commentary {
68        /// `COMMENT`, `HISTORY`, or the empty string (blank keyword).
69        keyword: String,
70        /// The free-text payload (columns 9–80).
71        text: String,
72    },
73    /// A preserved card that is not addressable as a keyword (`HIERARCH`, unrecognized, blank).
74    /// `text` holds the card content for display/serialization.
75    Opaque {
76        /// The card content (trailing spaces trimmed).
77        text: String,
78    },
79}
80
81/// One header card. Carries its parsed [`RecordKind`] plus, when parsed and unmodified, the
82/// original bytes of its physical card(s) so it can be serialized verbatim.
83///
84/// # Examples
85///
86/// ```
87/// # use fits_header::{Record, Value};
88/// let r = Record::value("OBJECT", Value::Str("M31".to_string()), Some("target".to_string()));
89/// assert_eq!(r.keyword(), Some("OBJECT"));
90/// assert_eq!(r.str_content(), Some("M31"));
91/// assert_eq!(r.comment(), Some("target"));
92/// ```
93#[derive(Clone, Debug)]
94#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
95pub struct Record {
96    /// The semantic content.
97    pub kind: RecordKind,
98    /// Original physical card bytes: `Some` when parsed and unmodified (a long-string run holds
99    /// more than one card); `None` once created or edited (formatted on write). Not part of
100    /// equality — two records are equal when their `kind`s are.
101    #[cfg_attr(feature = "serde", serde(skip))]
102    raw: Option<Vec<[u8; CARD_LEN]>>,
103}
104
105impl PartialEq for Record {
106    fn eq(&self, other: &Self) -> bool {
107        self.kind == other.kind
108    }
109}
110
111impl Record {
112    /// A new value card (not byte-backed; formatted on write).
113    ///
114    /// # Examples
115    ///
116    /// ```
117    /// # use fits_header::{Record, Value};
118    /// let r = Record::value("EXPTIME", Value::Literal("120.0".to_string()), None);
119    /// assert_eq!(r.keyword(), Some("EXPTIME"));
120    /// assert_eq!(r.value_text(), Some("120.0"));
121    /// ```
122    pub fn value(keyword: impl Into<String>, value: Value, comment: Option<String>) -> Self {
123        Record {
124            kind: RecordKind::Value {
125                keyword: keyword.into(),
126                value,
127                comment,
128            },
129            raw: None,
130        }
131    }
132
133    /// A new commentary card (not byte-backed; formatted on write).
134    ///
135    /// # Examples
136    ///
137    /// ```
138    /// # use fits_header::Record;
139    /// let r = Record::commentary("HISTORY", "dark subtracted");
140    /// assert_eq!(r.keyword(), Some("HISTORY"));
141    /// assert_eq!(r.value_text(), Some("dark subtracted"));
142    /// ```
143    pub fn commentary(keyword: impl Into<String>, text: impl Into<String>) -> Self {
144        Record {
145            kind: RecordKind::Commentary {
146                keyword: keyword.into(),
147                text: text.into(),
148            },
149            raw: None,
150        }
151    }
152
153    /// A record backed by original card bytes (parsed, unmodified).
154    pub(crate) fn from_raw(kind: RecordKind, raw: Vec<[u8; CARD_LEN]>) -> Self {
155        Record {
156            kind,
157            raw: Some(raw),
158        }
159    }
160
161    /// The addressable keyword, or `None` for opaque cards.
162    ///
163    /// # Examples
164    ///
165    /// ```
166    /// # use fits_header::Header;
167    /// let mut h = Header::new();
168    /// h.set("OBJECT", "M31").unwrap();
169    /// assert_eq!(h.cards()[0].keyword(), Some("OBJECT"));
170    /// ```
171    pub fn keyword(&self) -> Option<&str> {
172        match &self.kind {
173            RecordKind::Value { keyword, .. } | RecordKind::Commentary { keyword, .. } => {
174                Some(keyword)
175            }
176            RecordKind::Opaque { .. } => None,
177        }
178    }
179
180    /// The value as text for typed reads: `Str` content (non-empty), a `Literal` token, or
181    /// commentary text. `None` for empty strings and opaque cards.
182    ///
183    /// # Examples
184    ///
185    /// ```
186    /// # use fits_header::Header;
187    /// let mut h = Header::new();
188    /// h.set("EXPTIME", 120.0).unwrap();
189    /// assert_eq!(h.cards()[0].value_text(), Some("120.0"));
190    /// ```
191    pub fn value_text(&self) -> Option<&str> {
192        match &self.kind {
193            RecordKind::Value { value, .. } => match value {
194                Value::Str(s) => (!s.is_empty()).then_some(s.as_str()),
195                Value::Literal(l) => Some(l),
196            },
197            RecordKind::Commentary { text, .. } => Some(text),
198            RecordKind::Opaque { .. } => None,
199        }
200    }
201
202    /// The `Str` content of a value card (non-empty), for [`Header::get_str`](crate::Header::get_str).
203    ///
204    /// # Examples
205    ///
206    /// ```
207    /// # use fits_header::Header;
208    /// let mut h = Header::new();
209    /// h.set("OBJECT", "M31").unwrap();
210    /// h.set("EXPTIME", 120.0).unwrap(); // a Literal, not Str content
211    /// assert_eq!(h.cards()[0].str_content(), Some("M31"));
212    /// assert_eq!(h.cards()[1].str_content(), None);
213    /// ```
214    pub fn str_content(&self) -> Option<&str> {
215        match &self.kind {
216            RecordKind::Value {
217                value: Value::Str(s),
218                ..
219            } => (!s.is_empty()).then_some(s.as_str()),
220            _ => None,
221        }
222    }
223
224    /// The inline comment of a value card.
225    ///
226    /// # Examples
227    ///
228    /// ```
229    /// # use fits_header::Header;
230    /// let mut h = Header::new();
231    /// h.set("EXPTIME", 120.0).unwrap();
232    /// h.set_comment("EXPTIME", "seconds").unwrap();
233    /// assert_eq!(h.cards()[0].comment(), Some("seconds"));
234    /// ```
235    pub fn comment(&self) -> Option<&str> {
236        match &self.kind {
237            RecordKind::Value { comment, .. } => comment.as_deref(),
238            _ => None,
239        }
240    }
241
242    /// Original card bytes when byte-backed (unmodified).
243    pub(crate) fn raw_cards(&self) -> Option<&[[u8; CARD_LEN]]> {
244        self.raw.as_deref()
245    }
246
247    /// Replace a value card's value (or a commentary card's text) and mark the record dirty
248    /// so it is reformatted on write.
249    pub(crate) fn replace_value(&mut self, new: Value) {
250        match &mut self.kind {
251            RecordKind::Value { value, .. } => *value = new,
252            RecordKind::Commentary { text, .. } => {
253                *text = match new {
254                    Value::Str(s) | Value::Literal(s) => s,
255                };
256            }
257            RecordKind::Opaque { .. } => {}
258        }
259        self.raw = None;
260    }
261
262    /// Set or clear a value card's comment, marking it dirty.
263    pub(crate) fn set_comment(&mut self, c: Option<String>) {
264        if let RecordKind::Value { comment, .. } = &mut self.kind {
265            *comment = c;
266            self.raw = None;
267        }
268    }
269}
270
271/// True for keywords whose payload is free text (`COMMENT`, `HISTORY`, or blank).
272pub fn is_commentary_keyword(name: &str) -> bool {
273    name.is_empty() || name == "COMMENT" || name == "HISTORY"
274}
275
276/// Validate a standard FITS keyword: ≤8 characters, bytes in `A-Z 0-9 - _`.
277pub fn validate_keyword(name: &str) -> Result<(), FitsError> {
278    if name.len() > 8 {
279        return Err(FitsError::KeywordTooLong {
280            keyword: name.to_string(),
281        });
282    }
283    for &b in name.as_bytes() {
284        let ok = b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'-' || b == b'_';
285        if !ok {
286            return Err(FitsError::InvalidKeyword {
287                keyword: name.to_string(),
288            });
289        }
290    }
291    Ok(())
292}
293
294/// Validate a vendor keyword escape hatch: ≤8 characters, printable ASCII (any charset).
295pub fn validate_keyword_raw(name: &str) -> Result<(), FitsError> {
296    if name.len() > 8 {
297        return Err(FitsError::KeywordTooLong {
298            keyword: name.to_string(),
299        });
300    }
301    for &b in name.as_bytes() {
302        if !(0x20..=0x7e).contains(&b) {
303            return Err(FitsError::InvalidKeyword {
304                keyword: name.to_string(),
305            });
306        }
307    }
308    Ok(())
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    #[test]
316    fn keyword_validation_charset_and_length() {
317        for ok in ["", "A", "DATE-OBS", "NAXIS_1", "K2"] {
318            assert!(validate_keyword(ok).is_ok(), "{ok:?} should validate");
319        }
320        assert!(matches!(
321            validate_keyword("NINECHARS"),
322            Err(FitsError::KeywordTooLong { .. })
323        ));
324        for bad in ["obj", "KEY WORD", "É", "K.1"] {
325            assert!(
326                matches!(validate_keyword(bad), Err(FitsError::InvalidKeyword { .. })),
327                "{bad:?} should be rejected"
328            );
329        }
330    }
331
332    #[test]
333    fn raw_validation_allows_printable_ascii_only() {
334        for ok in ["obj", "K.1", "a b", "~"] {
335            assert!(validate_keyword_raw(ok).is_ok(), "{ok:?} should validate");
336        }
337        assert!(matches!(
338            validate_keyword_raw("NINECHARS"),
339            Err(FitsError::KeywordTooLong { .. })
340        ));
341        for bad in ["tab\there", "É"] {
342            assert!(
343                matches!(
344                    validate_keyword_raw(bad),
345                    Err(FitsError::InvalidKeyword { .. })
346                ),
347                "{bad:?} should be rejected"
348            );
349        }
350    }
351
352    #[test]
353    fn commentary_keywords() {
354        assert!(is_commentary_keyword(""));
355        assert!(is_commentary_keyword("COMMENT"));
356        assert!(is_commentary_keyword("HISTORY"));
357        assert!(!is_commentary_keyword("OBJECT"));
358    }
359
360    #[test]
361    fn accessors_by_kind() {
362        let v = Record::value("K", Value::Str("s".into()), Some("c".into()));
363        assert_eq!(v.keyword(), Some("K"));
364        assert_eq!(v.value_text(), Some("s"));
365        assert_eq!(v.str_content(), Some("s"));
366        assert_eq!(v.comment(), Some("c"));
367
368        let lit = Record::value("K", Value::Literal("42".into()), None);
369        assert_eq!(lit.value_text(), Some("42"));
370        assert_eq!(lit.str_content(), None, "literal is not Str content");
371
372        let c = Record::commentary("HISTORY", "note");
373        assert_eq!(c.keyword(), Some("HISTORY"));
374        assert_eq!(c.value_text(), Some("note"));
375        assert_eq!(c.str_content(), None);
376        assert_eq!(c.comment(), None);
377    }
378
379    #[test]
380    fn equality_ignores_retained_bytes() {
381        let kind = RecordKind::Value {
382            keyword: "K".into(),
383            value: Value::Str("s".into()),
384            comment: None,
385        };
386        let parsed = Record::from_raw(kind.clone(), vec![[b' '; CARD_LEN]]);
387        let created = Record::value("K", Value::Str("s".into()), None);
388        assert_eq!(parsed, created);
389    }
390
391    #[test]
392    fn mutation_drops_retained_bytes() {
393        let kind = RecordKind::Value {
394            keyword: "K".into(),
395            value: Value::Str("s".into()),
396            comment: None,
397        };
398        let mut r = Record::from_raw(kind, vec![[b' '; CARD_LEN]]);
399        assert!(r.raw_cards().is_some());
400        r.replace_value(Value::Str("t".into()));
401        assert!(r.raw_cards().is_none(), "edited record must reformat");
402
403        let mut r2 = Record::from_raw(
404            RecordKind::Value {
405                keyword: "K".into(),
406                value: Value::Str("s".into()),
407                comment: None,
408            },
409            vec![[b' '; CARD_LEN]],
410        );
411        r2.set_comment(Some("c".into()));
412        assert!(r2.raw_cards().is_none());
413        assert_eq!(r2.comment(), Some("c"));
414    }
415
416    #[test]
417    fn set_comment_ignores_non_value_records() {
418        let mut c = Record::from_raw(
419            RecordKind::Commentary {
420                keyword: "COMMENT".into(),
421                text: "x".into(),
422            },
423            vec![[b' '; CARD_LEN]],
424        );
425        c.set_comment(Some("ignored".into()));
426        assert_eq!(c.comment(), None);
427        assert!(c.raw_cards().is_some(), "no-op keeps original bytes");
428    }
429}