Skip to main content

fits_header/
header.rs

1//! The ordered header of records and its keyword access.
2
3use crate::error::FitsError;
4use crate::key::Key;
5use crate::record::{is_commentary_keyword, validate_keyword, validate_keyword_raw, Record, Value};
6use crate::value::{FromCard, IntoValue};
7use crate::write::{self, StructuralHints};
8
9/// An ordered FITS header unit: records in appearance order, with strict keyword access and CRUD.
10///
11/// Equality is semantic (records compare by content, not by retained bytes).
12#[derive(Clone, Debug, Default, PartialEq)]
13#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
14pub struct Header {
15    records: Vec<Record>,
16}
17
18impl Header {
19    /// An empty header.
20    pub fn new() -> Self {
21        Header::default()
22    }
23
24    /// Construct from records (used by the parser).
25    pub(crate) fn from_records(records: Vec<Record>) -> Self {
26        Header { records }
27    }
28
29    /// The records in order (read-only escape hatch).
30    pub fn cards(&self) -> &[Record] {
31        &self.records
32    }
33
34    /// Iterate the records in order.
35    pub fn iter(&self) -> impl Iterator<Item = &Record> {
36        self.records.iter()
37    }
38
39    /// How many records carry this keyword.
40    pub fn count(&self, name: &str) -> usize {
41        self.records
42            .iter()
43            .filter(|r| r.keyword() == Some(name))
44            .count()
45    }
46
47    /// Resolve a key to a record index. A bare name is strict.
48    fn resolve(&self, key: &Key) -> Result<Option<usize>, FitsError> {
49        let name = key.name();
50        let indices: Vec<usize> = self
51            .records
52            .iter()
53            .enumerate()
54            .filter(|(_, r)| r.keyword() == Some(name))
55            .map(|(i, _)| i)
56            .collect();
57        match key.occurrence() {
58            Some(n) => Ok(indices.get(n).copied()),
59            None => match indices.len() {
60                0 => Ok(None),
61                1 => Ok(Some(indices[0])),
62                count => Err(FitsError::AmbiguousKeyword {
63                    keyword: name.to_string(),
64                    count,
65                }),
66            },
67        }
68    }
69
70    /// Read a keyword as `T`. `Err` only on an ambiguous bare name; `Ok(None)` when absent or the
71    /// value does not convert; never panics.
72    ///
73    /// # Examples
74    ///
75    /// ```
76    /// # use fits_header::Header;
77    /// let mut h = Header::new();
78    /// h.set("EXPTIME", 120.0).unwrap();
79    /// assert_eq!(h.get::<f64>("EXPTIME").unwrap(), Some(120.0));
80    /// assert_eq!(h.get::<i64>("MISSING").unwrap(), None);
81    /// ```
82    pub fn get<T: FromCard>(&self, key: impl Into<Key>) -> Result<Option<T>, FitsError> {
83        Ok(self
84            .resolve(&key.into())?
85            .and_then(|i| T::from_card(&self.records[i])))
86    }
87
88    /// Borrow a keyword's string value (`Str` content, non-empty); `None` for empty or a literal.
89    pub fn get_str(&self, key: impl Into<Key>) -> Result<Option<&str>, FitsError> {
90        Ok(self
91            .resolve(&key.into())?
92            .and_then(|i| self.records[i].str_content()))
93    }
94
95    /// Every value for a keyword, in order.
96    pub fn get_all<T: FromCard>(&self, name: &str) -> Vec<T> {
97        self.records
98            .iter()
99            .filter(|r| r.keyword() == Some(name))
100            .filter_map(T::from_card)
101            .collect()
102    }
103
104    fn make_record(name: &str, value: Value) -> Record {
105        if is_commentary_keyword(name) {
106            let text = match value {
107                Value::Str(s) | Value::Literal(s) => s,
108            };
109            Record::commentary(name, text)
110        } else {
111            Record::value(name, value, None)
112        }
113    }
114
115    fn set_inner(&mut self, key: Key, value: Value, raw: bool) -> Result<(), FitsError> {
116        let name = key.name().to_string();
117        if raw {
118            validate_keyword_raw(&name)?;
119        } else {
120            validate_keyword(&name)?;
121        }
122        match self.resolve(&key)? {
123            Some(i) => {
124                self.records[i].replace_value(value);
125                Ok(())
126            }
127            None => match key.occurrence() {
128                Some(n) => Err(FitsError::OccurrenceOutOfRange {
129                    keyword: name.clone(),
130                    occurrence: n,
131                    count: self.count(&name),
132                }),
133                None => {
134                    self.records.push(Self::make_record(&name, value));
135                    Ok(())
136                }
137            },
138        }
139    }
140
141    /// Update the addressed record in place, or append when the (unique) name is absent.
142    /// The keyword must be FITS-standard (`≤8`, `A-Z 0-9 - _`); use [`set_raw`](Self::set_raw)
143    /// for vendor keys.
144    ///
145    /// # Examples
146    ///
147    /// ```
148    /// # use fits_header::{FitsError, Header};
149    /// let mut h = Header::new();
150    /// h.set("OBJECT", "M31").unwrap(); // appends
151    /// h.set("OBJECT", "NGC 7000").unwrap(); // updates in place
152    /// assert_eq!(h.count("OBJECT"), 1);
153    ///
154    /// let err = h.set("object", 1); // lowercase is not FITS-standard
155    /// assert!(matches!(err, Err(FitsError::InvalidKeyword { .. })));
156    /// ```
157    pub fn set(&mut self, key: impl Into<Key>, value: impl IntoValue) -> Result<(), FitsError> {
158        self.set_inner(key.into(), value.into_value(), false)
159    }
160
161    /// Like [`set`](Self::set) but accepts any ≤8-char printable-ASCII keyword (vendor escape hatch).
162    pub fn set_raw(&mut self, keyword: &str, value: impl IntoValue) -> Result<(), FitsError> {
163        self.set_inner(Key::Name(keyword.to_string()), value.into_value(), true)
164    }
165
166    /// Always add a record (a value card, or a commentary card for `COMMENT`/`HISTORY`/blank).
167    ///
168    /// # Examples
169    ///
170    /// ```
171    /// # use fits_header::Header;
172    /// let mut h = Header::new();
173    /// h.append("HISTORY", "dark subtracted").unwrap();
174    /// h.append("HISTORY", "flat fielded").unwrap();
175    /// assert_eq!(h.get_all::<String>("HISTORY").len(), 2);
176    /// ```
177    pub fn append(&mut self, name: &str, value: impl IntoValue) -> Result<(), FitsError> {
178        validate_keyword(name)?;
179        self.records
180            .push(Self::make_record(name, value.into_value()));
181        Ok(())
182    }
183
184    /// Set or replace the addressed value card's inline comment. No-op if the keyword is absent
185    /// or not a value card.
186    pub fn set_comment(
187        &mut self,
188        key: impl Into<Key>,
189        comment: impl Into<String>,
190    ) -> Result<(), FitsError> {
191        if let Some(i) = self.resolve(&key.into())? {
192            self.records[i].set_comment(Some(comment.into()));
193        }
194        Ok(())
195    }
196
197    /// Remove the addressed record. Returns whether anything was removed.
198    ///
199    /// # Examples
200    ///
201    /// ```
202    /// # use fits_header::Header;
203    /// let mut h = Header::new();
204    /// h.set("AIRMASS", 1.2).unwrap();
205    /// assert!(h.remove("AIRMASS").unwrap());
206    /// assert!(!h.remove("AIRMASS").unwrap());
207    /// ```
208    pub fn remove(&mut self, key: impl Into<Key>) -> Result<bool, FitsError> {
209        match self.resolve(&key.into())? {
210            Some(i) => {
211                self.records.remove(i);
212                Ok(true)
213            }
214            None => Ok(false),
215        }
216    }
217
218    /// Apply several mutations atomically: validate every entry first, then apply all or none.
219    ///
220    /// # Examples
221    ///
222    /// ```
223    /// # use fits_header::Header;
224    /// let mut h = Header::new();
225    /// h.set_many([("FILTER", "Ha"), ("TELESCOP", "EdgeHD 8")]).unwrap();
226    ///
227    /// // A rejected batch leaves the header untouched.
228    /// assert!(h.set_many([("GAIN", "1"), ("TOOLONGKEY", "2")]).is_err());
229    /// assert_eq!(h.count("GAIN"), 0);
230    /// ```
231    pub fn set_many<K, V>(
232        &mut self,
233        entries: impl IntoIterator<Item = (K, V)>,
234    ) -> Result<(), FitsError>
235    where
236        K: Into<Key>,
237        V: IntoValue,
238    {
239        let items: Vec<(Key, Value)> = entries
240            .into_iter()
241            .map(|(k, v)| (k.into(), v.into_value()))
242            .collect();
243        // Validate keywords and resolvability against the current state before mutating.
244        for (k, _) in &items {
245            validate_keyword(k.name())?;
246            if let Some(n) = k.occurrence() {
247                if self.resolve(k)?.is_none() {
248                    return Err(FitsError::OccurrenceOutOfRange {
249                        keyword: k.name().to_string(),
250                        occurrence: n,
251                        count: self.count(k.name()),
252                    });
253                }
254            } else {
255                // Surfaces AmbiguousKeyword before any change.
256                self.resolve(k)?;
257            }
258        }
259        for (k, v) in items {
260            self.set_inner(k, v, false)?;
261        }
262        Ok(())
263    }
264
265    /// Remove several keys atomically (validation only guards ambiguity). Returns the count removed.
266    pub fn remove_many<K: Into<Key>>(
267        &mut self,
268        keys: impl IntoIterator<Item = K>,
269    ) -> Result<usize, FitsError> {
270        let keys: Vec<Key> = keys.into_iter().map(Into::into).collect();
271        for k in &keys {
272            self.resolve(k)?;
273        }
274        let mut removed = 0;
275        for k in keys {
276            if self.remove(k)? {
277                removed += 1;
278            }
279        }
280        Ok(removed)
281    }
282
283    /// Serialize the header block only (cards, `END`, padded to a 2880 multiple) for splicing onto
284    /// an existing file's data.
285    ///
286    /// # Examples
287    ///
288    /// ```
289    /// # use fits_header::Header;
290    /// let mut h = Header::new();
291    /// h.set("OBJECT", "M31").unwrap();
292    /// let bytes = h.to_header_bytes();
293    /// assert_eq!(bytes.len() % fits_header::BLOCK_LEN, 0);
294    /// ```
295    pub fn to_header_bytes(&self) -> Vec<u8> {
296        write::to_header_bytes(self)
297    }
298
299    /// Serialize a standalone FITS object (header + a minimal zero data block). Mandatory
300    /// structural cards are synthesized only when absent; `structural` is a fallback.
301    ///
302    /// Errors with [`FitsError::DataTooLarge`] when the declared data segment exceeds
303    /// [`MAX_ZERO_FILL`](crate::MAX_ZERO_FILL) — for real-file edits, serialize with
304    /// [`to_header_bytes`](Self::to_header_bytes) and splice the original data.
305    ///
306    /// # Examples
307    ///
308    /// ```
309    /// # use fits_header::{Header, StructuralHints};
310    /// let mut h = Header::new();
311    /// h.set("OBJECT", "M31").unwrap();
312    /// let file = h.to_bytes(&StructuralHints::default()).unwrap();
313    /// assert!(file.starts_with(b"SIMPLE"));
314    /// ```
315    pub fn to_bytes(&self, structural: &StructuralHints) -> Result<Vec<u8>, FitsError> {
316        write::to_bytes(self, structural)
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    #[test]
325    fn set_routes_commentary_keywords_to_commentary_records() {
326        let mut h = Header::new();
327        h.set("COMMENT", "a note").unwrap();
328        h.set("HISTORY", "step 1").unwrap();
329        assert!(matches!(
330            h.cards()[0].kind,
331            crate::record::RecordKind::Commentary { .. }
332        ));
333        assert_eq!(h.get_all::<String>("HISTORY"), vec!["step 1".to_string()]);
334    }
335
336    #[test]
337    fn get_all_skips_unconvertible_values() {
338        let mut h = Header::new();
339        h.append("GAIN", 100).unwrap();
340        h.append("GAIN", "not a number").unwrap();
341        h.append("GAIN", 200).unwrap();
342        assert_eq!(h.get_all::<i64>("GAIN"), vec![100, 200]);
343        assert_eq!(h.count("GAIN"), 3);
344    }
345
346    #[test]
347    fn set_comment_on_absent_key_is_noop() {
348        let mut h = Header::new();
349        h.set_comment("NOPE", "x").unwrap();
350        assert!(h.cards().is_empty());
351    }
352
353    #[test]
354    fn remove_returns_false_when_absent() {
355        let mut h = Header::new();
356        assert!(!h.remove("NOPE").unwrap());
357    }
358
359    #[test]
360    fn remove_many_aborts_on_ambiguity_before_removing() {
361        let mut h = Header::new();
362        h.set("A", 1).unwrap();
363        h.append("DUP", 1).unwrap();
364        h.append("DUP", 2).unwrap();
365        let before = h.clone();
366        assert!(matches!(
367            h.remove_many(["A", "DUP"]),
368            Err(FitsError::AmbiguousKeyword { .. })
369        ));
370        assert_eq!(h, before, "nothing may be removed on a rejected batch");
371
372        assert_eq!(h.remove_many(["A", "MISSING"]).unwrap(), 1);
373    }
374
375    #[test]
376    fn set_many_accepts_occurrence_keys() {
377        let mut h = Header::new();
378        h.append("GAIN", 1).unwrap();
379        h.append("GAIN", 2).unwrap();
380        h.set_many([(("GAIN", 0), 10), (("GAIN", 1), 20)]).unwrap();
381        assert_eq!(h.get_all::<i64>("GAIN"), vec![10, 20]);
382    }
383
384    #[test]
385    fn iter_matches_cards() {
386        let mut h = Header::new();
387        h.set("A", 1).unwrap();
388        h.set("B", 2).unwrap();
389        assert_eq!(h.iter().count(), 2);
390        let names: Vec<_> = h.iter().filter_map(|r| r.keyword()).collect();
391        assert_eq!(names, vec!["A", "B"]);
392    }
393
394    #[test]
395    fn get_on_missing_key_is_ok_none() {
396        let h = Header::new();
397        assert_eq!(h.get::<i64>("NOPE").unwrap(), None);
398        assert_eq!(h.get_str("NOPE").unwrap(), None);
399        assert_eq!(h.get::<i64>(("NOPE", 3)).unwrap(), None);
400    }
401}