Skip to main content

fits_well/header/
mod.rs

1pub(crate) mod card;
2pub(crate) mod value;
3
4use std::collections::HashMap;
5
6use crate::bitpix::Bitpix;
7use crate::block::CARD_SIZE;
8use crate::data::Scaling;
9use crate::error::FitsError;
10use crate::error::Result;
11use crate::header::card::Card;
12use crate::header::card::CardKind;
13use crate::header::card::validate_keyword;
14use crate::header::value::Value;
15use crate::keyword::key;
16use crate::time::{EpochTime, FitsTime, PhaseAxis, TimeBounds};
17use crate::wcs::Wcs;
18
19/// A parsed header unit: an *ordered* list of content cards plus a side index
20/// for O(1) keyword lookup.
21///
22/// Order and duplicates are preserved exactly (commentary cards repeat, and
23/// record order is significant), so the model is a vector — never a map. The
24/// terminating `END` record is implicit and not stored as a card. Long-string
25/// values split across `CONTINUE` records are reassembled into a single value
26/// card on read and re-emitted as a canonical `CONTINUE` chain on write, so the
27/// round-trip preserves the logical model (not necessarily the original byte
28/// split).
29#[derive(Debug, Clone, Default)]
30pub struct Header {
31    pub(crate) cards: Vec<Card>,
32    /// First occurrence of each valued keyword → index into `cards`.
33    ///
34    /// Invariant: every entry points at a card that carries a value — a
35    /// [`CardKind::Value`] or [`CardKind::Hierarch`] — in `cards`.
36    /// `cards` is only ever appended/extended in place during `parse`, never
37    /// reordered, so the index stays valid. Any future card-mutation API must
38    /// rebuild this (or it must be made a method that maintains it) — do not
39    /// expose raw mutation that can desynchronize the two.
40    index: HashMap<String, usize>,
41}
42
43/// A read-only view of one stored header record, yielded by [`Header::iter`].
44///
45/// `value` is `None` for commentary (`COMMENT`/`HISTORY`/blank-keyword) cards and
46/// `Some` for valued ones — that distinction is all a caller needs, so the internal
47/// `CardKind` (which also tags transient parse states) stays private. `comment`
48/// carries the inline `/`-comment of a valued card, or the whole free text of a
49/// commentary card.
50#[derive(Debug, Clone, Copy, PartialEq)]
51pub struct HeaderEntry<'a> {
52    pub keyword: &'a str,
53    pub value: Option<&'a Value>,
54    pub comment: Option<&'a str>,
55}
56
57impl Header {
58    /// Parse a header unit from its raw bytes (a whole number of 80-byte cards;
59    /// the reader supplies block-aligned input). Stops at the `END` record.
60    pub fn parse(bytes: &[u8]) -> Result<Header> {
61        // One record per card is the upper bound (CONTINUE folding only merges,
62        // never adds; commentary cards skip the index), so reserve both once and
63        // let parsing fill them without the grow-reallocations a small header would
64        // otherwise pay on every push.
65        let ncards = bytes.len() / CARD_SIZE;
66        let mut cards: Vec<Card> = Vec::with_capacity(ncards);
67        let mut index = HashMap::with_capacity(ncards);
68        for chunk in bytes.chunks_exact(CARD_SIZE) {
69            let card = Card::parse(
70                chunk
71                    .try_into()
72                    .expect("chunks_exact yields CARD_SIZE slices"),
73            )?;
74            match card.kind {
75                CardKind::End => return Ok(Header { cards, index }),
76                CardKind::Continue if fold_continuation(&mut cards, &card) => {}
77                _ => {
78                    let mut card = card;
79                    // A CONTINUE with no value card to extend is malformed; keep it
80                    // readable by demoting it to a commentary card.
81                    if card.kind == CardKind::Continue {
82                        card.kind = CardKind::Commentary;
83                        card.value = None;
84                    }
85                    if matches!(card.kind, CardKind::Value | CardKind::Hierarch) {
86                        index.entry(card.keyword.clone()).or_insert(cards.len());
87                    }
88                    cards.push(card);
89                }
90            }
91        }
92        Err(FitsError::MissingEnd)
93    }
94
95    /// The value of the first card with this keyword, if it is a valued card.
96    pub fn get(&self, keyword: &str) -> Option<&Value> {
97        self.index
98            .get(keyword)
99            .and_then(|&i| self.cards[i].value.as_ref())
100    }
101
102    pub fn get_logical(&self, keyword: &str) -> Option<bool> {
103        self.get(keyword)?.as_logical()
104    }
105
106    pub fn get_integer(&self, keyword: &str) -> Option<i64> {
107        self.get(keyword)?.as_integer()
108    }
109
110    pub fn get_real(&self, keyword: &str) -> Option<f64> {
111        self.get(keyword)?.as_real()
112    }
113
114    pub fn get_text(&self, keyword: &str) -> Option<&str> {
115        self.get(keyword)?.as_text()
116    }
117
118    /// Every stored record in file order, as [`HeaderEntry`] views — duplicates and
119    /// order preserved (the whole point of the ordered model), so `COMMENT`/`HISTORY`
120    /// runs and repeated keywords come through intact. The implicit `END` is not a
121    /// record and is never yielded. For valued keywords only, filter on
122    /// `e.value`: `header.iter().filter_map(|e| Some((e.keyword, e.value?)))`.
123    pub fn iter(&self) -> impl Iterator<Item = HeaderEntry<'_>> {
124        self.cards.iter().map(|c| HeaderEntry {
125            keyword: c.keyword.as_str(),
126            value: c.value.as_ref(),
127            comment: c.comment.as_deref(),
128        })
129    }
130
131    /// `BITPIX`, mapped to the typed element kind.
132    pub fn bitpix(&self) -> Result<Bitpix> {
133        let code = self
134            .get_integer("BITPIX")
135            .ok_or(FitsError::MissingKeyword { name: "BITPIX" })?;
136        Bitpix::from_code(code)
137    }
138
139    /// `NAXIS` — the number of axes (0 means no data array).
140    pub fn naxis(&self) -> Result<usize> {
141        let n = self
142            .get_integer("NAXIS")
143            .ok_or(FitsError::MissingKeyword { name: "NAXIS" })?;
144        // §4.4.1: `0 ≤ NAXIS ≤ 999`. Rejecting an out-of-range value is both
145        // conformance and a guard — `axes()` reserves `Vec::with_capacity(NAXIS)`,
146        // so an absurd `NAXIS` from an untrusted header would otherwise abort.
147        match usize::try_from(n) {
148            Ok(n) if n <= 999 => Ok(n),
149            _ => Err(FitsError::KeywordOutOfRange { name: "NAXIS" }),
150        }
151    }
152
153    /// The axis lengths `NAXIS1..NAXIS{NAXIS}`, in order.
154    pub fn axes(&self) -> Result<Vec<usize>> {
155        let naxis = self.naxis()?;
156        let mut axes = Vec::with_capacity(naxis);
157        for n in 1..=naxis {
158            let len = self
159                .get_integer(key!("NAXIS{n}").as_str())
160                .ok_or(FitsError::MissingKeyword { name: "NAXISn" })?;
161            axes.push(
162                usize::try_from(len)
163                    .map_err(|_| FitsError::KeywordOutOfRange { name: "NAXISn" })?,
164            );
165        }
166        Ok(axes)
167    }
168
169    /// The physical-value scaling (`BSCALE`/`BZERO`/`BLANK`) declared by this header.
170    pub fn scaling(&self) -> Scaling {
171        Scaling::from_header(self)
172    }
173
174    /// Parse the World Coordinate System (FITS §8) described by this header: the
175    /// primary description (`alt = None`) or an alternate (`alt = Some('A'..='Z')`).
176    pub fn wcs(&self, alt: Option<char>) -> Result<Wcs> {
177        Wcs::from_header(self, alt)
178    }
179
180    /// WCS for a *pixel-list* table (§8.4.2), where the given `columns` hold the
181    /// coordinate axes; `alt` selects the primary (`None`) or an alternate system.
182    pub fn wcs_pixel_list(&self, columns: &[usize], alt: Option<char>) -> Result<Wcs> {
183        Wcs::from_pixel_list(self, columns, alt)
184    }
185
186    /// WCS attached to a single array-valued table `column` (§8.4.1).
187    pub fn wcs_array_column(&self, column: usize, alt: Option<char>) -> Result<Wcs> {
188        Wcs::from_array_column(self, column, alt)
189    }
190
191    /// The time-coordinate frame (FITS §9) parsed from this header — reference
192    /// epoch/scale, units, and any time WCS axis.
193    pub fn time(&self) -> FitsTime {
194        FitsTime::from_header(self)
195    }
196
197    /// The observation Modified Julian Date — `MJD-OBS`, else `DATE-OBS`, else the
198    /// `JEPOCH`/`BEPOCH` epoch, else `None`.
199    pub fn obs_mjd(&self) -> Option<f64> {
200        FitsTime::obs_mjd(self)
201    }
202
203    /// The Julian (`JEPOCH`) or Besselian (`BEPOCH`) epoch keyword, if present.
204    pub fn epoch(&self) -> Option<EpochTime> {
205        FitsTime::epoch(self)
206    }
207
208    /// The observation time bounds (start/end/duration, §9.2.3) from this header.
209    pub fn time_bounds(&self) -> TimeBounds {
210        FitsTime::bounds(self)
211    }
212
213    /// The §9.6 `'PHASE'` axis parameters for WCS `axis` (1-based), if it is one.
214    pub fn phase_axis(&self, axis: usize) -> Option<PhaseAxis> {
215        FitsTime::phase_axis(self, axis)
216    }
217
218    /// Create an empty header. Build it up with [`Header::set`] and friends.
219    pub fn new() -> Header {
220        Header::default()
221    }
222
223    /// Insert a valued keyword, or replace the value of an existing one, keeping
224    /// the keyword index in sync. Returns `&mut self` for chaining. The keyword
225    /// must be a valid FITS keyword name (≤ 8 chars of `A–Z`, `0–9`, `-`, `_`).
226    pub fn set(&mut self, keyword: &str, value: impl Into<Value>) -> &mut Self {
227        assert!(
228            validate_keyword(keyword).is_ok(),
229            "Header::set: invalid FITS keyword {keyword:?}"
230        );
231        let value = value.into();
232        if let Some(&i) = self.index.get(keyword) {
233            self.cards[i].value = Some(value);
234        } else {
235            self.index.insert(keyword.to_string(), self.cards.len());
236            self.cards.push(Card::value(keyword, value));
237        }
238        self
239    }
240
241    /// Remove every card with this keyword and rebuild the index. A no-op if the
242    /// keyword is absent. Used when transforming headers (e.g. stripping the `Z*`
243    /// keywords when uncompressing a tiled table).
244    #[cfg(feature = "compression")]
245    pub(crate) fn remove(&mut self, keyword: &str) -> &mut Self {
246        if self.index.contains_key(keyword) {
247            self.cards.retain(|c| c.keyword != keyword);
248            self.reindex();
249        }
250        self
251    }
252
253    /// Rebuild the keyword → first-card index after a structural edit.
254    #[cfg(feature = "compression")]
255    fn reindex(&mut self) {
256        self.index.clear();
257        for (i, card) in self.cards.iter().enumerate() {
258            if matches!(card.kind, CardKind::Value | CardKind::Hierarch) {
259                self.index.entry(card.keyword.clone()).or_insert(i);
260            }
261        }
262    }
263
264    /// Attach (or replace) the inline comment of an existing valued keyword;
265    /// a no-op if the keyword is absent.
266    pub fn comment(&mut self, keyword: &str, text: &str) -> &mut Self {
267        if let Some(&i) = self.index.get(keyword) {
268            self.cards[i].comment = Some(text.to_string());
269        }
270        self
271    }
272
273    /// Append a `COMMENT` card.
274    pub fn push_comment(&mut self, text: &str) -> &mut Self {
275        self.cards.push(Card::commentary("COMMENT", text));
276        self
277    }
278
279    /// Append a `HISTORY` card.
280    pub fn push_history(&mut self, text: &str) -> &mut Self {
281        self.cards.push(Card::commentary("HISTORY", text));
282        self
283    }
284}
285
286/// Fold a `CONTINUE` substring into the preceding long-string value card,
287/// returning `false` when the previous card is not a value awaiting continuation
288/// (i.e. a [`Value::Text`] whose text ends with the `&` continuation flag).
289fn fold_continuation(cards: &mut [Card], cont: &Card) -> bool {
290    let Some(prev) = cards.last_mut() else {
291        return false;
292    };
293    let Some(Value::Text(acc)) = prev.value.as_mut() else {
294        return false;
295    };
296    if !acc.ends_with('&') {
297        return false;
298    }
299    acc.pop(); // drop the continuation flag
300    if let Some(Value::Text(sub)) = &cont.value {
301        acc.push_str(sub);
302    }
303    // The convention carries any comment on the final CONTINUE record.
304    if cont.comment.is_some() {
305        prev.comment = cont.comment.clone();
306    }
307    true
308}
309
310/// Build a header from left-justified 80-column card text lines, appending the
311/// `END` record. Shared test helper for modules that exercise parsed headers.
312#[cfg(test)]
313pub(crate) fn from_card_lines(lines: &[&str]) -> Header {
314    let mut buf = Vec::with_capacity((lines.len() + 1) * CARD_SIZE);
315    for line in lines {
316        let mut card = [b' '; CARD_SIZE];
317        card[..line.len()].copy_from_slice(line.as_bytes());
318        buf.extend_from_slice(&card);
319    }
320    let mut end = [b' '; CARD_SIZE];
321    end[..3].copy_from_slice(b"END");
322    buf.extend_from_slice(&end);
323    Header::parse(&buf).unwrap()
324}
325
326#[cfg(test)]
327mod tests;