Skip to main content

fits_header/
header.rs

1//! The ordered header of records and its keyword access.
2
3use crate::error::{FitsError, Result};
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;
8use crate::{BLOCK_LEN, CARD_LEN};
9use std::fs;
10use std::io::Write as _;
11use std::path::Path;
12
13/// An ordered FITS header unit: [`Record`]s in appearance order, with strict keyword
14/// access (via [`Key`]) and CRUD.
15///
16/// A `Header` is an in-memory value. `set`, `append`, `remove`, `set_many`, and
17/// `set_comment` change it in memory only — nothing is written to disk. Persist it with
18/// [`update_file`](Self::update_file) to edit an existing file's header in place (the
19/// common case), or [`write_to_file`](Self::write_to_file) to create a new file from a
20/// header you built plus pixel data you already have.
21/// [`to_header_bytes`](Self::to_header_bytes) is the lower-level building block behind
22/// both, for callers who assemble the file bytes themselves.
23///
24/// Equality is semantic (records compare by content, not by retained bytes).
25#[derive(Clone, Debug, Default, PartialEq)]
26#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
27pub struct Header {
28    records: Vec<Record>,
29}
30
31impl Header {
32    /// An empty header.
33    ///
34    /// # Examples
35    ///
36    /// ```
37    /// # use fits_header::Header;
38    /// let h = Header::new();
39    /// assert_eq!(h.count("OBJECT"), 0);
40    /// ```
41    pub fn new() -> Self {
42        Header::default()
43    }
44
45    /// Construct from records (used by the parser).
46    pub(crate) fn from_records(records: Vec<Record>) -> Self {
47        Header { records }
48    }
49
50    /// Parse one FITS header unit from raw bytes.
51    ///
52    /// Reads 80-byte cards in order, stops at `END`, and retains every card (including
53    /// commentary, `HIERARCH`, and unrecognized cards) so untouched cards serialize verbatim.
54    /// `CONTINUE` runs are reassembled into a single logical value. Bytes after `END` (the data
55    /// unit, later HDUs) are ignored — this crate is header-only and never inspects them.
56    ///
57    /// # Examples
58    ///
59    /// ```
60    /// use fits_header::Header;
61    ///
62    /// let mut bytes = Vec::new();
63    /// for card in ["OBJECT  = 'M31     '", "EXPTIME =                120.0", "END"] {
64    ///     let mut c = card.as_bytes().to_vec();
65    ///     c.resize(80, b' ');
66    ///     bytes.extend(c);
67    /// }
68    ///
69    /// let header = Header::parse(&bytes).unwrap();
70    /// assert_eq!(header.get_str("OBJECT").unwrap(), Some("M31"));
71    /// assert_eq!(header.get::<f64>("EXPTIME").unwrap(), Some(120.0));
72    /// ```
73    pub fn parse(bytes: &[u8]) -> Result<Header> {
74        crate::parse::parse_header(bytes)
75    }
76
77    /// Read a FITS header from a file on disk.
78    ///
79    /// Reads the whole file and [`parse`](Self::parse)s it; parsing already stops at `END`, so
80    /// the data unit and any later HDUs are read but never interpreted.
81    ///
82    /// # Examples
83    ///
84    /// ```
85    /// use fits_header::Header;
86    ///
87    /// let mut bytes = Vec::new();
88    /// for card in ["OBJECT  = 'M31     '", "END"] {
89    ///     let mut c = card.as_bytes().to_vec();
90    ///     c.resize(80, b' ');
91    ///     bytes.extend(c);
92    /// }
93    /// while bytes.len() % fits_header::BLOCK_LEN != 0 {
94    ///     bytes.push(b' ');
95    /// }
96    /// bytes.extend_from_slice(&[0u8; 4]); // stand-in pixel data
97    ///
98    /// let path = std::env::temp_dir().join("fits-header-doctest-read_from_file.fits");
99    /// std::fs::write(&path, &bytes).unwrap();
100    ///
101    /// let header = Header::read_from_file(&path).unwrap();
102    /// assert_eq!(header.get_str("OBJECT").unwrap(), Some("M31"));
103    ///
104    /// std::fs::remove_file(&path).ok();
105    /// ```
106    pub fn read_from_file<P: AsRef<Path>>(path: P) -> Result<Header> {
107        let bytes = fs::read(path)?;
108        Header::parse(&bytes)
109    }
110
111    /// Edit a FITS file's header in place, preserving its data unit (and any later HDUs)
112    /// byte-for-byte.
113    ///
114    /// Reads the whole file, locates the header region by scanning for the `END` card, parses
115    /// only that region, runs `edit` on it, then writes the re-serialized header back followed
116    /// by every byte that came after the original header — untouched, regardless of what `edit`
117    /// did.
118    ///
119    /// The write is atomic and edits the real file in place: it writes a temp file in the
120    /// target's directory and renames it over the target. It follows symlinks (a symlinked
121    /// `path` stays a symlink; its target is edited) and, on Unix, preserves the target's file
122    /// mode. A crash or interruption cannot leave a truncated file.
123    ///
124    /// Errors with [`FitsError::MissingEnd`] if the file has no `END` card, or
125    /// [`FitsError::TruncatedHeader`] if it has one but ends before the header's 2880-byte
126    /// block is complete.
127    ///
128    /// # Examples
129    ///
130    /// ```
131    /// use fits_header::Header;
132    ///
133    /// let mut bytes = Vec::new();
134    /// for card in ["OBJECT  = 'M31     '", "END"] {
135    ///     let mut c = card.as_bytes().to_vec();
136    ///     c.resize(80, b' ');
137    ///     bytes.extend(c);
138    /// }
139    /// while bytes.len() % fits_header::BLOCK_LEN != 0 {
140    ///     bytes.push(b' ');
141    /// }
142    /// let data = [1u8, 2, 3, 4]; // stand-in pixel data
143    /// bytes.extend_from_slice(&data);
144    ///
145    /// let path = std::env::temp_dir().join("fits-header-doctest-update_file.fits");
146    /// std::fs::write(&path, &bytes).unwrap();
147    ///
148    /// Header::update_file(&path, |h| {
149    ///     h.set("OBJECT", "NGC 7000")?;
150    ///     Ok(())
151    /// })
152    /// .unwrap();
153    ///
154    /// let after = std::fs::read(&path).unwrap();
155    /// let header = Header::parse(&after).unwrap();
156    /// assert_eq!(header.get_str("OBJECT").unwrap(), Some("NGC 7000"));
157    /// assert_eq!(&after[after.len() - data.len()..], &data, "data unit preserved");
158    ///
159    /// std::fs::remove_file(&path).ok();
160    /// ```
161    pub fn update_file<P: AsRef<Path>>(
162        path: P,
163        edit: impl FnOnce(&mut Header) -> Result<()>,
164    ) -> Result<()> {
165        let path = path.as_ref();
166        let bytes = fs::read(path)?;
167        let header_len = header_region_len(&bytes)?;
168        if header_len > bytes.len() {
169            // END found, but the file ends before the header block is padded out.
170            return Err(FitsError::TruncatedHeader);
171        }
172        let tail = &bytes[header_len..];
173
174        let mut header = Header::parse(&bytes[..header_len])?;
175        edit(&mut header)?;
176
177        let mut out = header.to_header_bytes();
178        out.extend_from_slice(tail);
179        write_atomic(path, &out)?;
180        Ok(())
181    }
182
183    /// Create a new FITS file: the serialized header block followed by `data`.
184    ///
185    /// This is the convenience for the rarer case where you already have pixel data and
186    /// are writing it for the first time. It creates `path` and errors if it already
187    /// exists — via [`OpenOptions::create_new`](std::fs::OpenOptions::create_new), which is
188    /// race-free — so this method can never overwrite or corrupt an existing file's
189    /// contents. To edit a file that already exists, use [`update_file`](Self::update_file)
190    /// instead.
191    ///
192    /// `data` is the caller's own pixel bytes, written immediately after the header block;
193    /// pass `&[]` for a header-only file. This crate is header-only and never fabricates
194    /// pixel data itself.
195    ///
196    /// Errors with [`FitsError::Io`] if the write fails, including when `path` already
197    /// exists ([`io::ErrorKind::AlreadyExists`](std::io::ErrorKind::AlreadyExists)).
198    ///
199    /// # Examples
200    ///
201    /// ```
202    /// use fits_header::Header;
203    ///
204    /// let mut header = Header::new();
205    /// header.set("OBJECT", "M31").unwrap();
206    ///
207    /// let path = std::env::temp_dir().join("fits-header-doctest-write_to_file.fits");
208    /// # std::fs::remove_file(&path).ok();
209    /// header.write_to_file(&path, &[0u8; 4]).unwrap();
210    ///
211    /// let bytes = std::fs::read(&path).unwrap();
212    /// assert_eq!(&bytes[bytes.len() - 4..], &[0u8; 4], "pixel data survived");
213    /// let back = Header::parse(&bytes).unwrap();
214    /// assert_eq!(back.get_str("OBJECT").unwrap(), Some("M31"));
215    ///
216    /// // Writing to the same path again errors instead of overwriting it.
217    /// assert!(header.write_to_file(&path, &[1u8; 4]).is_err());
218    ///
219    /// std::fs::remove_file(&path).ok();
220    /// ```
221    pub fn write_to_file<P: AsRef<Path>>(&self, path: P, data: &[u8]) -> Result<()> {
222        let mut file = fs::OpenOptions::new()
223            .write(true)
224            .create_new(true)
225            .open(path)?;
226        file.write_all(&self.to_header_bytes())?;
227        file.write_all(data)?;
228        Ok(())
229    }
230
231    /// The records in order (read-only escape hatch).
232    ///
233    /// # Examples
234    ///
235    /// ```
236    /// # use fits_header::Header;
237    /// let mut h = Header::new();
238    /// h.set("OBJECT", "M31").unwrap();
239    /// assert_eq!(h.cards()[0].keyword(), Some("OBJECT"));
240    /// ```
241    pub fn cards(&self) -> &[Record] {
242        &self.records
243    }
244
245    /// Iterate the records in order.
246    ///
247    /// # Examples
248    ///
249    /// ```
250    /// # use fits_header::Header;
251    /// let mut h = Header::new();
252    /// h.set("OBJECT", "M31").unwrap();
253    /// h.set("EXPTIME", 120.0).unwrap();
254    /// let names: Vec<&str> = h.iter().filter_map(|r| r.keyword()).collect();
255    /// assert_eq!(names, vec!["OBJECT", "EXPTIME"]);
256    /// ```
257    pub fn iter(&self) -> impl Iterator<Item = &Record> {
258        self.records.iter()
259    }
260
261    /// How many records carry this keyword.
262    ///
263    /// # Examples
264    ///
265    /// ```
266    /// # use fits_header::Header;
267    /// let mut h = Header::new();
268    /// h.append("HISTORY", "dark subtracted").unwrap();
269    /// h.append("HISTORY", "flat fielded").unwrap();
270    /// assert_eq!(h.count("HISTORY"), 2);
271    /// assert_eq!(h.count("OBJECT"), 0);
272    /// ```
273    pub fn count(&self, name: &str) -> usize {
274        self.records
275            .iter()
276            .filter(|r| r.keyword() == Some(name))
277            .count()
278    }
279
280    /// Resolve a key to a record index. A bare name is strict.
281    fn resolve(&self, key: &Key) -> Result<Option<usize>> {
282        let name = key.name();
283        let indices: Vec<usize> = self
284            .records
285            .iter()
286            .enumerate()
287            .filter(|(_, r)| r.keyword() == Some(name))
288            .map(|(i, _)| i)
289            .collect();
290        match key.occurrence() {
291            Some(n) => Ok(indices.get(n).copied()),
292            None => match indices.len() {
293                0 => Ok(None),
294                1 => Ok(Some(indices[0])),
295                count => Err(FitsError::AmbiguousKeyword {
296                    keyword: name.to_string(),
297                    count,
298                }),
299            },
300        }
301    }
302
303    /// Read a keyword as `T`. `Err` only on an ambiguous bare name; `Ok(None)` when absent or the
304    /// value does not convert; never panics.
305    ///
306    /// # Examples
307    ///
308    /// ```
309    /// # use fits_header::Header;
310    /// let mut h = Header::new();
311    /// h.set("EXPTIME", 120.0).unwrap();
312    /// assert_eq!(h.get::<f64>("EXPTIME").unwrap(), Some(120.0));
313    /// assert_eq!(h.get::<i64>("MISSING").unwrap(), None);
314    /// ```
315    pub fn get<T: FromCard>(&self, key: impl Into<Key>) -> Result<Option<T>> {
316        Ok(self
317            .resolve(&key.into())?
318            .and_then(|i| T::from_card(&self.records[i])))
319    }
320
321    /// Borrow a keyword's string value (`Str` content, non-empty); `None` for empty or a literal.
322    ///
323    /// # Examples
324    ///
325    /// ```
326    /// # use fits_header::Header;
327    /// let mut h = Header::new();
328    /// h.set("OBJECT", "M31").unwrap();
329    /// h.set("EXPTIME", 120.0).unwrap(); // a Literal, not Str content
330    /// assert_eq!(h.get_str("OBJECT").unwrap(), Some("M31"));
331    /// assert_eq!(h.get_str("EXPTIME").unwrap(), None);
332    /// ```
333    pub fn get_str(&self, key: impl Into<Key>) -> Result<Option<&str>> {
334        Ok(self
335            .resolve(&key.into())?
336            .and_then(|i| self.records[i].str_content()))
337    }
338
339    /// Every value for a keyword, in order. Unlike [`get`](Self::get), never errors on a
340    /// duplicated keyword — that is the point of calling it.
341    ///
342    /// # Examples
343    ///
344    /// ```
345    /// # use fits_header::Header;
346    /// let mut h = Header::new();
347    /// h.append("HISTORY", "dark subtracted").unwrap();
348    /// h.append("HISTORY", "flat fielded").unwrap();
349    /// assert_eq!(
350    ///     h.get_all::<String>("HISTORY"),
351    ///     vec!["dark subtracted".to_string(), "flat fielded".to_string()]
352    /// );
353    /// ```
354    pub fn get_all<T: FromCard>(&self, name: &str) -> Vec<T> {
355        self.records
356            .iter()
357            .filter(|r| r.keyword() == Some(name))
358            .filter_map(T::from_card)
359            .collect()
360    }
361
362    fn make_record(name: &str, value: Value) -> Record {
363        if is_commentary_keyword(name) {
364            let text = match value {
365                Value::Str(s) | Value::Literal(s) => s,
366            };
367            Record::commentary(name, text)
368        } else {
369            Record::value(name, value, None)
370        }
371    }
372
373    fn set_inner(&mut self, key: Key, value: Value, raw: bool) -> Result<()> {
374        let name = key.name().to_string();
375        if raw {
376            validate_keyword_raw(&name)?;
377        } else {
378            validate_keyword(&name)?;
379        }
380        match self.resolve(&key)? {
381            Some(i) => {
382                self.records[i].replace_value(value);
383                Ok(())
384            }
385            None => match key.occurrence() {
386                Some(n) => Err(FitsError::OccurrenceOutOfRange {
387                    keyword: name.clone(),
388                    occurrence: n,
389                    count: self.count(&name),
390                }),
391                None => {
392                    self.records.push(Self::make_record(&name, value));
393                    Ok(())
394                }
395            },
396        }
397    }
398
399    /// Updates the addressed record (or appends when the unique name is absent) in the
400    /// in-memory header; nothing is written to disk — see [`Header`] to persist. The
401    /// keyword must be FITS-standard (`≤8`, `A-Z 0-9 - _`); use [`set_raw`](Self::set_raw)
402    /// for vendor keys.
403    ///
404    /// # Examples
405    ///
406    /// ```
407    /// # use fits_header::{FitsError, Header};
408    /// let mut h = Header::new();
409    /// h.set("OBJECT", "M31").unwrap(); // appends
410    /// h.set("OBJECT", "NGC 7000").unwrap(); // updates the in-memory record
411    /// assert_eq!(h.count("OBJECT"), 1);
412    ///
413    /// let err = h.set("object", 1); // lowercase is not FITS-standard
414    /// assert!(matches!(err, Err(FitsError::InvalidKeyword { .. })));
415    /// ```
416    pub fn set(&mut self, key: impl Into<Key>, value: impl IntoValue) -> Result<()> {
417        self.set_inner(key.into(), value.into_value(), false)
418    }
419
420    /// Like [`set`](Self::set) but accepts any ≤8-char printable-ASCII keyword (vendor escape hatch).
421    ///
422    /// # Examples
423    ///
424    /// ```
425    /// # use fits_header::Header;
426    /// let mut h = Header::new();
427    /// h.set_raw("pi.name", "Jane Doe").unwrap(); // lowercase, not FITS-standard
428    /// assert_eq!(h.get_str("pi.name").unwrap(), Some("Jane Doe"));
429    /// ```
430    pub fn set_raw(&mut self, keyword: &str, value: impl IntoValue) -> Result<()> {
431        self.set_inner(Key::Name(keyword.to_string()), value.into_value(), true)
432    }
433
434    /// Always add a record to the in-memory header (a value card, or a commentary card
435    /// for `COMMENT`/`HISTORY`/blank).
436    ///
437    /// # Examples
438    ///
439    /// ```
440    /// # use fits_header::Header;
441    /// let mut h = Header::new();
442    /// h.append("HISTORY", "dark subtracted").unwrap();
443    /// h.append("HISTORY", "flat fielded").unwrap();
444    /// assert_eq!(h.get_all::<String>("HISTORY").len(), 2);
445    /// ```
446    pub fn append(&mut self, name: &str, value: impl IntoValue) -> Result<()> {
447        validate_keyword(name)?;
448        self.records
449            .push(Self::make_record(name, value.into_value()));
450        Ok(())
451    }
452
453    /// Set or replace the addressed value card's inline comment. No-op if the keyword is absent
454    /// or not a value card.
455    ///
456    /// # Examples
457    ///
458    /// ```
459    /// # use fits_header::Header;
460    /// let mut h = Header::new();
461    /// h.set("EXPTIME", 120.0).unwrap();
462    /// h.set_comment("EXPTIME", "seconds").unwrap();
463    /// assert_eq!(h.cards()[0].comment(), Some("seconds"));
464    /// ```
465    pub fn set_comment(&mut self, key: impl Into<Key>, comment: impl Into<String>) -> Result<()> {
466        if let Some(i) = self.resolve(&key.into())? {
467            self.records[i].set_comment(Some(comment.into()));
468        }
469        Ok(())
470    }
471
472    /// Remove the addressed record from the in-memory header. Returns whether anything
473    /// was removed.
474    ///
475    /// # Examples
476    ///
477    /// ```
478    /// # use fits_header::Header;
479    /// let mut h = Header::new();
480    /// h.set("AIRMASS", 1.2).unwrap();
481    /// assert!(h.remove("AIRMASS").unwrap());
482    /// assert!(!h.remove("AIRMASS").unwrap());
483    /// ```
484    pub fn remove(&mut self, key: impl Into<Key>) -> Result<bool> {
485        match self.resolve(&key.into())? {
486            Some(i) => {
487                self.records.remove(i);
488                Ok(true)
489            }
490            None => Ok(false),
491        }
492    }
493
494    /// Apply several mutations to the in-memory header atomically: validate every entry
495    /// first, then apply all or none.
496    ///
497    /// # Examples
498    ///
499    /// ```
500    /// # use fits_header::Header;
501    /// let mut h = Header::new();
502    /// h.set_many([("FILTER", "Ha"), ("TELESCOP", "EdgeHD 8")]).unwrap();
503    ///
504    /// // A rejected batch leaves the header untouched.
505    /// assert!(h.set_many([("GAIN", "1"), ("TOOLONGKEY", "2")]).is_err());
506    /// assert_eq!(h.count("GAIN"), 0);
507    /// ```
508    pub fn set_many<K, V>(&mut self, entries: impl IntoIterator<Item = (K, V)>) -> Result<()>
509    where
510        K: Into<Key>,
511        V: IntoValue,
512    {
513        let items: Vec<(Key, Value)> = entries
514            .into_iter()
515            .map(|(k, v)| (k.into(), v.into_value()))
516            .collect();
517        // Validate keywords and resolvability against the current state before mutating.
518        for (k, _) in &items {
519            validate_keyword(k.name())?;
520            if let Some(n) = k.occurrence() {
521                if self.resolve(k)?.is_none() {
522                    return Err(FitsError::OccurrenceOutOfRange {
523                        keyword: k.name().to_string(),
524                        occurrence: n,
525                        count: self.count(k.name()),
526                    });
527                }
528            } else {
529                // Surfaces AmbiguousKeyword before any change.
530                self.resolve(k)?;
531            }
532        }
533        for (k, v) in items {
534            self.set_inner(k, v, false)?;
535        }
536        Ok(())
537    }
538
539    /// Remove several keys atomically (validation only guards ambiguity). Returns the count removed.
540    ///
541    /// # Examples
542    ///
543    /// ```
544    /// # use fits_header::Header;
545    /// let mut h = Header::new();
546    /// h.set("OBJECT", "M31").unwrap();
547    /// h.set("EXPTIME", 120.0).unwrap();
548    /// assert_eq!(h.remove_many(["OBJECT", "MISSING"]).unwrap(), 1);
549    /// assert_eq!(h.count("OBJECT"), 0);
550    /// ```
551    pub fn remove_many<K: Into<Key>>(
552        &mut self,
553        keys: impl IntoIterator<Item = K>,
554    ) -> Result<usize> {
555        let keys: Vec<Key> = keys.into_iter().map(Into::into).collect();
556        for k in &keys {
557            self.resolve(k)?;
558        }
559        let mut removed = 0;
560        for k in keys {
561            if self.remove(k)? {
562                removed += 1;
563            }
564        }
565        Ok(removed)
566    }
567
568    /// Serialize the header block only (cards, `END`, padded to a 2880 multiple).
569    ///
570    /// This is the lower-level building block: the bytes alone, for callers who assemble
571    /// the rest of the file themselves. To write a header plus pixel data straight to a
572    /// new file, use [`write_to_file`](Self::write_to_file) instead — it wraps this and
573    /// errors rather than overwriting an existing path.
574    /// [`update_file`](Self::update_file) edits an existing file's header while preserving
575    /// its data unit.
576    ///
577    /// # Examples
578    ///
579    /// ```
580    /// # use fits_header::Header;
581    /// let mut h = Header::new();
582    /// h.set("OBJECT", "M31").unwrap();
583    /// let bytes = h.to_header_bytes();
584    /// assert_eq!(bytes.len() % fits_header::BLOCK_LEN, 0);
585    /// ```
586    pub fn to_header_bytes(&self) -> Vec<u8> {
587        write::to_header_bytes(self)
588    }
589}
590
591/// The byte length of the header region at the start of `bytes`: cards up to and including
592/// `END`, rounded up to a [`BLOCK_LEN`] multiple. Scans the same way [`Header::parse`] does, so
593/// this always agrees with what parsing would consume.
594fn header_region_len(bytes: &[u8]) -> Result<usize> {
595    for (i, card) in bytes.chunks_exact(CARD_LEN).enumerate() {
596        let keyword = String::from_utf8_lossy(&card[..8]).trim().to_string();
597        if keyword == "END" {
598            let raw_len = (i + 1) * CARD_LEN;
599            return Ok(raw_len.div_ceil(BLOCK_LEN) * BLOCK_LEN);
600        }
601    }
602    Err(FitsError::MissingEnd)
603}
604
605/// Write `bytes` to `path` atomically, editing the real file in place.
606///
607/// Follows symlinks: if `path` is a symlink, its canonical target is resolved first and both
608/// the temp file and the rename land in the target's directory, so the link is preserved and
609/// the file it points at is the one edited. The temp file is renamed over the target (an atomic
610/// replace), so a crash mid-write leaves the original file intact, never a truncated one. On
611/// Unix the target's file mode is copied onto the temp file before the rename, so a 0600 file
612/// stays 0600 instead of dropping to the umask default. Any failure after the temp file is
613/// created removes it.
614fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
615    // Resolve symlinks so we edit the real file in place; fall back to `path` if it does not
616    // yet exist (update_file always reads first, so it does).
617    let target = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
618    let dir = target
619        .parent()
620        .filter(|p| !p.as_os_str().is_empty())
621        .unwrap_or_else(|| Path::new("."));
622    let file_name = target.file_name().map(|n| n.to_string_lossy().into_owned());
623    let tmp_name = match file_name {
624        Some(name) => format!(".{name}.tmp-{}", std::process::id()),
625        None => format!(".fits-header.tmp-{}", std::process::id()),
626    };
627    let tmp_path = dir.join(tmp_name);
628
629    let result = (|| {
630        fs::write(&tmp_path, bytes)?;
631        copy_mode(&target, &tmp_path)?;
632        fs::rename(&tmp_path, &target)?;
633        Ok(())
634    })();
635    if result.is_err() {
636        let _ = fs::remove_file(&tmp_path);
637    }
638    result
639}
640
641/// Copy `src`'s file permissions onto `dst`. No-op off Unix, and silently ignores a missing
642/// `src` (a freshly created file has no prior mode to preserve).
643#[cfg(unix)]
644fn copy_mode(src: &Path, dst: &Path) -> Result<()> {
645    match fs::metadata(src) {
646        Ok(meta) => fs::set_permissions(dst, meta.permissions())?,
647        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
648        Err(e) => return Err(e.into()),
649    }
650    Ok(())
651}
652
653#[cfg(not(unix))]
654fn copy_mode(_src: &Path, _dst: &Path) -> Result<()> {
655    Ok(())
656}
657
658#[cfg(test)]
659mod tests {
660    use super::*;
661
662    #[test]
663    fn set_routes_commentary_keywords_to_commentary_records() {
664        let mut h = Header::new();
665        h.set("COMMENT", "a note").unwrap();
666        h.set("HISTORY", "step 1").unwrap();
667        assert!(matches!(
668            h.cards()[0].kind,
669            crate::record::RecordKind::Commentary { .. }
670        ));
671        assert_eq!(h.get_all::<String>("HISTORY"), vec!["step 1".to_string()]);
672    }
673
674    #[test]
675    fn get_all_skips_unconvertible_values() {
676        let mut h = Header::new();
677        h.append("GAIN", 100).unwrap();
678        h.append("GAIN", "not a number").unwrap();
679        h.append("GAIN", 200).unwrap();
680        assert_eq!(h.get_all::<i64>("GAIN"), vec![100, 200]);
681        assert_eq!(h.count("GAIN"), 3);
682    }
683
684    #[test]
685    fn set_comment_on_absent_key_is_noop() {
686        let mut h = Header::new();
687        h.set_comment("NOPE", "x").unwrap();
688        assert!(h.cards().is_empty());
689    }
690
691    #[test]
692    fn remove_returns_false_when_absent() {
693        let mut h = Header::new();
694        assert!(!h.remove("NOPE").unwrap());
695    }
696
697    #[test]
698    fn remove_many_aborts_on_ambiguity_before_removing() {
699        let mut h = Header::new();
700        h.set("A", 1).unwrap();
701        h.append("DUP", 1).unwrap();
702        h.append("DUP", 2).unwrap();
703        let before = h.clone();
704        assert!(matches!(
705            h.remove_many(["A", "DUP"]),
706            Err(FitsError::AmbiguousKeyword { .. })
707        ));
708        assert_eq!(h, before, "nothing may be removed on a rejected batch");
709
710        assert_eq!(h.remove_many(["A", "MISSING"]).unwrap(), 1);
711    }
712
713    #[test]
714    fn set_many_accepts_occurrence_keys() {
715        let mut h = Header::new();
716        h.append("GAIN", 1).unwrap();
717        h.append("GAIN", 2).unwrap();
718        h.set_many([(("GAIN", 0), 10), (("GAIN", 1), 20)]).unwrap();
719        assert_eq!(h.get_all::<i64>("GAIN"), vec![10, 20]);
720    }
721
722    #[test]
723    fn iter_matches_cards() {
724        let mut h = Header::new();
725        h.set("A", 1).unwrap();
726        h.set("B", 2).unwrap();
727        assert_eq!(h.iter().count(), 2);
728        let names: Vec<_> = h.iter().filter_map(|r| r.keyword()).collect();
729        assert_eq!(names, vec!["A", "B"]);
730    }
731
732    #[test]
733    fn get_on_missing_key_is_ok_none() {
734        let h = Header::new();
735        assert_eq!(h.get::<i64>("NOPE").unwrap(), None);
736        assert_eq!(h.get_str("NOPE").unwrap(), None);
737        assert_eq!(h.get::<i64>(("NOPE", 3)).unwrap(), None);
738    }
739}