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}