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}