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