Skip to main content

xisf_header/
header.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! The [`Header`] value, [`StructuralHints`], and the keyword/property API.
6
7use std::collections::BTreeMap;
8
9use crate::error::{Error, Result};
10use crate::key::Key;
11use crate::keyword::FitsKeyword;
12use crate::property::Property;
13use crate::value::{FromField, IntoValue};
14
15/// Geometry hints used when serializing a standalone container. A [`Header`]
16/// stores only keywords and properties — never image structure — so these
17/// hints always supply the `<Image>` element's `geometry`, `sampleFormat`, and
18/// `colorSpace`. Defaults to a minimal 1×1 8-bit grayscale image.
19///
20/// ```
21/// use xisf_header::StructuralHints;
22///
23/// let hints = StructuralHints {
24///     geometry: "6248:4176:1".to_owned(),
25///     sample_format: "UInt16".to_owned(),
26///     color_space: "Gray".to_owned(),
27/// };
28/// assert_eq!(hints.sample_format, "UInt16");
29/// assert_eq!(StructuralHints::default().geometry, "1:1:1");
30/// ```
31#[derive(Debug, Clone, PartialEq, Eq)]
32#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
33pub struct StructuralHints {
34    /// XISF `geometry` attribute, e.g. `"1:1:1"` (width:height:channels).
35    pub geometry: String,
36    /// XISF `sampleFormat`, e.g. `"UInt8"`.
37    pub sample_format: String,
38    /// XISF `colorSpace`, e.g. `"Gray"`.
39    pub color_space: String,
40}
41
42impl Default for StructuralHints {
43    fn default() -> Self {
44        Self {
45            geometry: "1:1:1".to_owned(),
46            sample_format: "UInt8".to_owned(),
47            color_space: "Gray".to_owned(),
48        }
49    }
50}
51
52/// A parsed XISF header: an ordered list of [`FitsKeyword`]s plus a map of
53/// XISF `<Property>` elements.
54///
55/// Keyword access is **strict**: a bare name must be unique, or the accessor
56/// returns [`Error::Ambiguous`]. Repeated keywords are reached with an
57/// `(name, n)` key or the `get_all`/`count` helpers. Keyword order is
58/// preserved; property iteration is ordered by id, not document order.
59///
60/// This struct is an **in-memory model only**: mutating it (`set`, `append`,
61/// `remove`, `set_property`, …) changes nothing on disk. Persist the result
62/// with [`write_to_file`](Self::write_to_file) to create a new file, or
63/// [`update_file`](Self::update_file) to splice the change into an existing
64/// one.
65///
66/// ```
67/// use xisf_header::Header;
68///
69/// let mut header = Header::new();
70/// header.set("IMAGETYP", "Master Dark")?;
71/// header.set("EXPTIME", 300.0)?;
72/// assert_eq!(header.get_str("IMAGETYP")?, Some("Master Dark"));
73/// # Ok::<(), xisf_header::Error>(())
74/// ```
75#[derive(Debug, Clone, PartialEq, Eq, Default)]
76#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
77pub struct Header {
78    pub(crate) keywords: Vec<FitsKeyword>,
79    pub(crate) properties: BTreeMap<String, Property>,
80}
81
82impl Header {
83    /// Create an empty header.
84    ///
85    /// ```
86    /// use xisf_header::Header;
87    ///
88    /// let header = Header::new();
89    /// assert_eq!(header.keywords().len(), 0);
90    /// ```
91    #[must_use]
92    pub fn new() -> Self {
93        Self::default()
94    }
95
96    // ----- keyword reads -------------------------------------------------
97
98    /// Interpret the addressed keyword's value as `T`.
99    ///
100    /// Returns `Ok(None)` when the keyword is absent or its value cannot be read
101    /// as `T`, and [`Error::Ambiguous`] when a bare name matches more than one
102    /// keyword.
103    ///
104    /// # Errors
105    ///
106    /// [`Error::Ambiguous`] on a duplicated bare name; [`Error::IndexOutOfRange`]
107    /// for an `(name, n)` index past the last occurrence.
108    ///
109    /// ```
110    /// use xisf_header::Header;
111    ///
112    /// let mut header = Header::new();
113    /// header.set("OBJECT", "NGC 7000")?;
114    /// assert_eq!(header.get::<String>("OBJECT")?, Some("NGC 7000".to_owned()));
115    /// # Ok::<(), xisf_header::Error>(())
116    /// ```
117    pub fn get<'a, T: FromField>(&self, key: impl Into<Key<'a>>) -> Result<Option<T>> {
118        Ok(self
119            .resolve(key.into())?
120            .and_then(|i| self.keywords[i].get::<T>()))
121    }
122
123    /// The addressed keyword's raw value text.
124    ///
125    /// # Errors
126    ///
127    /// See [`Header::get`].
128    ///
129    /// ```
130    /// use xisf_header::Header;
131    ///
132    /// let mut header = Header::new();
133    /// header.set("IMAGETYP", "Master Dark")?;
134    /// assert_eq!(header.get_str("IMAGETYP")?, Some("Master Dark"));
135    /// # Ok::<(), xisf_header::Error>(())
136    /// ```
137    pub fn get_str<'a>(&self, key: impl Into<Key<'a>>) -> Result<Option<&str>> {
138        Ok(self
139            .resolve(key.into())?
140            .map(|i| self.keywords[i].value_str()))
141    }
142
143    /// The addressed keyword's value as an `f64`.
144    ///
145    /// # Errors
146    ///
147    /// See [`Header::get`].
148    ///
149    /// ```
150    /// use xisf_header::Header;
151    ///
152    /// let mut header = Header::new();
153    /// header.set("EXPTIME", 300.0)?;
154    /// assert_eq!(header.get_f64("EXPTIME")?, Some(300.0));
155    /// # Ok::<(), xisf_header::Error>(())
156    /// ```
157    pub fn get_f64<'a>(&self, key: impl Into<Key<'a>>) -> Result<Option<f64>> {
158        self.get(key)
159    }
160
161    /// The addressed keyword's value as an `i64` (accepts `20` and `20.0`).
162    ///
163    /// # Errors
164    ///
165    /// See [`Header::get`].
166    ///
167    /// ```
168    /// use xisf_header::Header;
169    ///
170    /// let mut header = Header::new();
171    /// header.set("GAIN", 100_i64)?;
172    /// assert_eq!(header.get_i64("GAIN")?, Some(100));
173    /// # Ok::<(), xisf_header::Error>(())
174    /// ```
175    pub fn get_i64<'a>(&self, key: impl Into<Key<'a>>) -> Result<Option<i64>> {
176        self.get(key)
177    }
178
179    /// The addressed keyword's value as a `u32`.
180    ///
181    /// # Errors
182    ///
183    /// See [`Header::get`].
184    ///
185    /// ```
186    /// use xisf_header::Header;
187    ///
188    /// let mut header = Header::new();
189    /// header.set("XBINNING", 2_u32)?;
190    /// assert_eq!(header.get_u32("XBINNING")?, Some(2));
191    /// # Ok::<(), xisf_header::Error>(())
192    /// ```
193    pub fn get_u32<'a>(&self, key: impl Into<Key<'a>>) -> Result<Option<u32>> {
194        self.get(key)
195    }
196
197    /// The addressed keyword's value as a `bool` (FITS `T`/`F`).
198    ///
199    /// # Errors
200    ///
201    /// See [`Header::get`].
202    ///
203    /// ```
204    /// use xisf_header::Header;
205    ///
206    /// let mut header = Header::new();
207    /// header.set("SIMPLE", true)?;
208    /// assert_eq!(header.get_bool("SIMPLE")?, Some(true));
209    /// # Ok::<(), xisf_header::Error>(())
210    /// ```
211    pub fn get_bool<'a>(&self, key: impl Into<Key<'a>>) -> Result<Option<bool>> {
212        self.get(key)
213    }
214
215    /// The addressed keyword's value as a civil date/time.
216    ///
217    /// # Errors
218    ///
219    /// See [`Header::get`].
220    ///
221    /// ```
222    /// use xisf_header::Header;
223    ///
224    /// let mut header = Header::new();
225    /// header.set("DATE-OBS", "2026-07-11T22:15:03")?;
226    /// let observed = header.get_datetime("DATE-OBS")?.unwrap();
227    /// assert_eq!(observed.year(), 2026);
228    /// # Ok::<(), xisf_header::Error>(())
229    /// ```
230    pub fn get_datetime<'a>(
231        &self,
232        key: impl Into<Key<'a>>,
233    ) -> Result<Option<time::PrimitiveDateTime>> {
234        self.get(key)
235    }
236
237    /// Every value for `name`, in order, that reads as `T`.
238    ///
239    /// ```
240    /// use xisf_header::Header;
241    ///
242    /// let mut header = Header::new();
243    /// header.append("HISTORY", "reduced with siril").unwrap();
244    /// header.append("HISTORY", "stacked 20x300s").unwrap();
245    /// assert_eq!(
246    ///     header.get_all::<String>("HISTORY"),
247    ///     vec!["reduced with siril", "stacked 20x300s"]
248    /// );
249    /// ```
250    pub fn get_all<T: FromField>(&self, name: &str) -> Vec<T> {
251        self.indices(name)
252            .filter_map(|i| self.keywords[i].get::<T>())
253            .collect()
254    }
255
256    /// How many keywords carry `name` (case-insensitive).
257    ///
258    /// ```
259    /// use xisf_header::Header;
260    ///
261    /// let mut header = Header::new();
262    /// header.append("HISTORY", "reduced with siril").unwrap();
263    /// header.append("HISTORY", "stacked 20x300s").unwrap();
264    /// assert_eq!(header.count("HISTORY"), 2);
265    /// ```
266    #[must_use]
267    pub fn count(&self, name: &str) -> usize {
268        self.indices(name).count()
269    }
270
271    /// All keywords in document order.
272    ///
273    /// ```
274    /// use xisf_header::Header;
275    ///
276    /// let mut header = Header::new();
277    /// header.set("GAIN", 100_i64).unwrap();
278    /// assert_eq!(header.keywords().len(), 1);
279    /// assert_eq!(header.keywords()[0].name, "GAIN");
280    /// ```
281    #[must_use]
282    pub fn keywords(&self) -> &[FitsKeyword] {
283        &self.keywords
284    }
285
286    /// Iterate the keywords in document order.
287    ///
288    /// ```
289    /// use xisf_header::Header;
290    ///
291    /// let mut header = Header::new();
292    /// header.set("IMAGETYP", "Master Dark").unwrap();
293    /// header.set("EXPTIME", 300.0).unwrap();
294    /// let names: Vec<&str> = header.iter().map(|k| k.name.as_str()).collect();
295    /// assert_eq!(names, ["IMAGETYP", "EXPTIME"]);
296    /// ```
297    pub fn iter(&self) -> std::slice::Iter<'_, FitsKeyword> {
298        self.keywords.iter()
299    }
300
301    // ----- keyword writes ------------------------------------------------
302
303    /// Set a keyword's value: update in place when the name is unique, append
304    /// when absent. The existing comment is preserved.
305    ///
306    /// This changes the in-memory header only — nothing is written to disk
307    /// until you call [`write_to_file`](Self::write_to_file) (new file) or
308    /// [`update_file`](Self::update_file) (existing file).
309    ///
310    /// For XISF-native structured metadata, see
311    /// [`set_property`](Self::set_property).
312    ///
313    /// # Errors
314    ///
315    /// [`Error::Ambiguous`] when a bare name is duplicated (use `(name, n)` or
316    /// `set_at`-style selection), [`Error::IndexOutOfRange`] for a bad occurrence
317    /// index, or [`Error::InvalidName`] when creating an invalid keyword.
318    ///
319    /// ```
320    /// use xisf_header::Header;
321    ///
322    /// let mut header = Header::new();
323    /// header.set("IMAGETYP", "Master Dark")?; // absent: appended
324    /// header.set("IMAGETYP", "Master Flat")?; // unique: updated in place
325    /// assert_eq!(header.get_str("IMAGETYP")?, Some("Master Flat"));
326    /// assert_eq!(header.keywords().len(), 1);
327    /// # Ok::<(), xisf_header::Error>(())
328    /// ```
329    pub fn set<'a>(&mut self, key: impl Into<Key<'a>>, value: impl IntoValue) -> Result<()> {
330        let key = key.into();
331        let value = value.into_value();
332        match key {
333            Key::Name(name) => match self.resolve(Key::Name(name))? {
334                Some(i) => self.keywords[i].value = value,
335                None => {
336                    Self::validate_name(name)?;
337                    self.keywords.push(FitsKeyword {
338                        name: name.to_owned(),
339                        value,
340                        comment: String::new(),
341                    });
342                }
343            },
344            Key::Nth(name, n) => {
345                let i = self.require_nth(name, n)?;
346                self.keywords[i].value = value;
347            }
348        }
349        Ok(())
350    }
351
352    /// Append a keyword unconditionally (allowing duplicate names). This is how
353    /// commentary keywords such as `HISTORY` are built up.
354    ///
355    /// This changes the in-memory header only — nothing is written to disk
356    /// until you call [`write_to_file`](Self::write_to_file) (new file) or
357    /// [`update_file`](Self::update_file) (existing file).
358    ///
359    /// # Errors
360    ///
361    /// [`Error::InvalidName`] if `name` is not a valid keyword.
362    ///
363    /// ```
364    /// use xisf_header::Header;
365    ///
366    /// let mut header = Header::new();
367    /// header.append("HISTORY", "reduced with siril")?;
368    /// header.append("HISTORY", "stacked 20x300s")?;
369    /// assert_eq!(header.count("HISTORY"), 2);
370    /// # Ok::<(), xisf_header::Error>(())
371    /// ```
372    pub fn append(&mut self, name: &str, value: impl IntoValue) -> Result<()> {
373        Self::validate_name(name)?;
374        self.keywords.push(FitsKeyword {
375            name: name.to_owned(),
376            value: value.into_value(),
377            comment: String::new(),
378        });
379        Ok(())
380    }
381
382    /// Set (or clear, with `""`) the comment on the addressed keyword.
383    /// Returns `true` if a keyword was found.
384    ///
385    /// # Errors
386    ///
387    /// See [`Header::get`]: [`Error::Ambiguous`] on a duplicated bare name,
388    /// [`Error::IndexOutOfRange`] for an out-of-range `(name, n)` index.
389    ///
390    /// ```
391    /// use xisf_header::Header;
392    ///
393    /// let mut header = Header::new();
394    /// header.set("IMAGETYP", "Master Dark")?;
395    /// assert!(header.set_comment("IMAGETYP", "Type of image")?);
396    /// assert_eq!(header.keywords()[0].comment, "Type of image");
397    /// # Ok::<(), xisf_header::Error>(())
398    /// ```
399    pub fn set_comment<'a>(
400        &mut self,
401        key: impl Into<Key<'a>>,
402        comment: impl Into<String>,
403    ) -> Result<bool> {
404        match self.resolve(key.into())? {
405            Some(i) => {
406                self.keywords[i].comment = comment.into();
407                Ok(true)
408            }
409            None => Ok(false),
410        }
411    }
412
413    /// Set a keyword's value and comment together.
414    ///
415    /// # Errors
416    ///
417    /// See [`Header::set`].
418    ///
419    /// ```
420    /// use xisf_header::Header;
421    ///
422    /// let mut header = Header::new();
423    /// header.set_with_comment("GAIN", 100_i64, "Sensor gain")?;
424    /// assert_eq!(header.get_i64("GAIN")?, Some(100));
425    /// assert_eq!(header.keywords()[0].comment, "Sensor gain");
426    /// # Ok::<(), xisf_header::Error>(())
427    /// ```
428    pub fn set_with_comment<'a>(
429        &mut self,
430        key: impl Into<Key<'a>>,
431        value: impl IntoValue,
432        comment: impl Into<String>,
433    ) -> Result<()> {
434        let key = key.into();
435        self.set(key, value)?;
436        if let Some(i) = self.resolve(key)? {
437            self.keywords[i].comment = comment.into();
438        }
439        Ok(())
440    }
441
442    /// Remove the addressed keyword. Returns `true` if one was removed.
443    ///
444    /// This changes the in-memory header only — nothing is written to disk
445    /// until you call [`write_to_file`](Self::write_to_file) (new file) or
446    /// [`update_file`](Self::update_file) (existing file).
447    ///
448    /// # Errors
449    ///
450    /// See [`Header::get`]: [`Error::Ambiguous`] on a duplicated bare name,
451    /// [`Error::IndexOutOfRange`] for an out-of-range `(name, n)` index.
452    ///
453    /// ```
454    /// use xisf_header::Header;
455    ///
456    /// let mut header = Header::new();
457    /// header.set("GAIN", 100_i64)?;
458    /// assert!(header.remove("GAIN")?);
459    /// assert_eq!(header.get_i64("GAIN")?, None);
460    /// # Ok::<(), xisf_header::Error>(())
461    /// ```
462    pub fn remove<'a>(&mut self, key: impl Into<Key<'a>>) -> Result<bool> {
463        match self.resolve(key.into())? {
464            Some(i) => {
465                self.keywords.remove(i);
466                Ok(true)
467            }
468            None => Ok(false),
469        }
470    }
471
472    /// Remove every keyword named `name`. Returns how many were removed.
473    ///
474    /// ```
475    /// use xisf_header::Header;
476    ///
477    /// let mut header = Header::new();
478    /// header.append("HISTORY", "reduced with siril").unwrap();
479    /// header.append("HISTORY", "stacked 20x300s").unwrap();
480    /// assert_eq!(header.remove_all("HISTORY"), 2);
481    /// assert_eq!(header.count("HISTORY"), 0);
482    /// ```
483    pub fn remove_all(&mut self, name: &str) -> usize {
484        let before = self.keywords.len();
485        self.keywords.retain(|k| !k.name.eq_ignore_ascii_case(name));
486        before - self.keywords.len()
487    }
488
489    /// Apply several single-keyword upserts atomically: validate every entry
490    /// first, then apply all — or, on any rejection, apply none.
491    ///
492    /// # Errors
493    ///
494    /// [`Error::InvalidName`] or [`Error::Ambiguous`] for any entry; on error the
495    /// header is unchanged.
496    ///
497    /// ```
498    /// use xisf_header::Header;
499    ///
500    /// let mut header = Header::new();
501    /// header.set_many([("IMAGETYP", "Master Dark"), ("OBJECT", "NGC 7000")])?;
502    /// assert_eq!(header.get_str("IMAGETYP")?, Some("Master Dark"));
503    /// assert_eq!(header.get_str("OBJECT")?, Some("NGC 7000"));
504    /// # Ok::<(), xisf_header::Error>(())
505    /// ```
506    pub fn set_many<'a, V, I>(&mut self, entries: I) -> Result<()>
507    where
508        V: IntoValue,
509        I: IntoIterator<Item = (&'a str, V)>,
510    {
511        let entries: Vec<(&str, V)> = entries.into_iter().collect();
512        for (name, _) in &entries {
513            Self::validate_name(name)?;
514            let count = self.count(name);
515            if count > 1 {
516                return Err(Error::Ambiguous {
517                    name: (*name).to_owned(),
518                    count,
519                });
520            }
521        }
522        for (name, value) in entries {
523            match self.first_index(name) {
524                Some(i) => self.keywords[i].value = value.into_value(),
525                None => self.keywords.push(FitsKeyword {
526                    name: name.to_owned(),
527                    value: value.into_value(),
528                    comment: String::new(),
529                }),
530            }
531        }
532        Ok(())
533    }
534
535    /// Remove several keywords atomically. Returns how many were removed.
536    ///
537    /// # Errors
538    ///
539    /// [`Error::Ambiguous`] if any name is duplicated; on error the header is
540    /// unchanged.
541    ///
542    /// ```
543    /// use xisf_header::Header;
544    ///
545    /// let mut header = Header::new();
546    /// header.set_many([("IMAGETYP", "Master Dark"), ("OBJECT", "NGC 7000")])?;
547    /// assert_eq!(header.remove_many(["IMAGETYP", "OBJECT"])?, 2);
548    /// # Ok::<(), xisf_header::Error>(())
549    /// ```
550    pub fn remove_many<'a, I: IntoIterator<Item = &'a str>>(&mut self, names: I) -> Result<usize> {
551        let names: Vec<&str> = names.into_iter().collect();
552        for name in &names {
553            let count = self.count(name);
554            if count > 1 {
555                return Err(Error::Ambiguous {
556                    name: (*name).to_owned(),
557                    count,
558                });
559            }
560        }
561        let mut removed = 0;
562        for name in names {
563            if let Some(i) = self.first_index(name) {
564                self.keywords.remove(i);
565                removed += 1;
566            }
567        }
568        Ok(removed)
569    }
570
571    // ----- property CRUD -------------------------------------------------
572
573    /// All `<Property>` entries, keyed by `id`. Iteration is ordered by id,
574    /// not by document order.
575    ///
576    /// ```
577    /// use xisf_header::Header;
578    ///
579    /// let mut header = Header::new();
580    /// header.set_property("Observation:Object:Name", "NGC 7000").unwrap();
581    /// assert_eq!(header.properties().len(), 1);
582    /// ```
583    #[must_use]
584    pub fn properties(&self) -> &BTreeMap<String, Property> {
585        &self.properties
586    }
587
588    /// A property's raw value text by `id`.
589    ///
590    /// ```
591    /// use xisf_header::Header;
592    ///
593    /// let mut header = Header::new();
594    /// header.set_property("Observation:Object:Name", "NGC 7000").unwrap();
595    /// assert_eq!(header.property("Observation:Object:Name"), Some("NGC 7000"));
596    /// ```
597    #[must_use]
598    pub fn property(&self, id: &str) -> Option<&str> {
599        self.properties.get(id).map(|p| p.value.as_str())
600    }
601
602    /// A property value interpreted as `T`.
603    ///
604    /// ```
605    /// use xisf_header::Header;
606    ///
607    /// let mut header = Header::new();
608    /// header
609    ///     .set_property_with_type("Instrument:Telescope:FocalLength", "0.53", "Float32")
610    ///     .unwrap();
611    /// assert_eq!(
612    ///     header.property_get::<f64>("Instrument:Telescope:FocalLength"),
613    ///     Some(0.53)
614    /// );
615    /// ```
616    #[must_use]
617    pub fn property_get<T: FromField>(&self, id: &str) -> Option<T> {
618        self.properties
619            .get(id)
620            .and_then(|p| T::from_field(&p.value))
621    }
622
623    /// Insert or update a property's value. An existing property keeps its
624    /// `type`, `comment`, and `format`; a new one is created with type
625    /// `String`.
626    ///
627    /// For embedded FITS header keywords, see [`set`](Self::set).
628    ///
629    /// # Errors
630    ///
631    /// [`Error::InvalidName`] if `id` is not a valid XISF property id.
632    ///
633    /// ```
634    /// use xisf_header::Header;
635    ///
636    /// let mut header = Header::new();
637    /// header.set_property("Observation:Object:Name", "NGC 7000")?;
638    /// assert_eq!(header.properties()["Observation:Object:Name"].type_, "String");
639    /// # Ok::<(), xisf_header::Error>(())
640    /// ```
641    pub fn set_property(&mut self, id: impl Into<String>, value: impl Into<String>) -> Result<()> {
642        let id = id.into();
643        Self::validate_property_id(&id)?;
644        self.properties.entry(id).or_default().value = value.into();
645        Ok(())
646    }
647
648    /// Insert or update a property with an explicit XISF `type` (e.g.
649    /// `Float32`, `TimePoint`). An existing property keeps its `comment` and
650    /// `format`.
651    ///
652    /// # Errors
653    ///
654    /// [`Error::InvalidName`] if `id` is not a valid XISF property id.
655    ///
656    /// ```
657    /// use xisf_header::Header;
658    ///
659    /// let mut header = Header::new();
660    /// header.set_property_with_type("Instrument:Telescope:FocalLength", "0.53", "Float32")?;
661    /// assert_eq!(
662    ///     header.properties()["Instrument:Telescope:FocalLength"].type_,
663    ///     "Float32"
664    /// );
665    /// # Ok::<(), xisf_header::Error>(())
666    /// ```
667    pub fn set_property_with_type(
668        &mut self,
669        id: impl Into<String>,
670        value: impl Into<String>,
671        type_: impl Into<String>,
672    ) -> Result<()> {
673        let id = id.into();
674        Self::validate_property_id(&id)?;
675        let p = self.properties.entry(id).or_default();
676        p.value = value.into();
677        p.type_ = type_.into();
678        Ok(())
679    }
680
681    /// Remove a property by `id`. Returns `true` if it existed.
682    ///
683    /// ```
684    /// use xisf_header::Header;
685    ///
686    /// let mut header = Header::new();
687    /// header.set_property("Observation:Object:Name", "NGC 7000").unwrap();
688    /// assert!(header.remove_property("Observation:Object:Name"));
689    /// assert!(header.property("Observation:Object:Name").is_none());
690    /// ```
691    pub fn remove_property(&mut self, id: &str) -> bool {
692        self.properties.remove(id).is_some()
693    }
694
695    // ----- internals -----------------------------------------------------
696
697    fn indices<'s>(&'s self, name: &'s str) -> impl Iterator<Item = usize> + 's {
698        self.keywords
699            .iter()
700            .enumerate()
701            .filter(move |(_, k)| k.name.eq_ignore_ascii_case(name))
702            .map(|(i, _)| i)
703    }
704
705    fn first_index(&self, name: &str) -> Option<usize> {
706        self.indices(name).next()
707    }
708
709    /// Resolve a key to a keyword index, enforcing the strict rules.
710    fn resolve(&self, key: Key) -> Result<Option<usize>> {
711        match key {
712            Key::Name(name) => {
713                let mut it = self.indices(name);
714                let first = it.next();
715                if first.is_some() && it.next().is_some() {
716                    return Err(Error::Ambiguous {
717                        name: name.to_owned(),
718                        count: self.count(name),
719                    });
720                }
721                Ok(first)
722            }
723            Key::Nth(name, n) => {
724                let indices: Vec<usize> = self.indices(name).collect();
725                match indices.get(n) {
726                    Some(&i) => Ok(Some(i)),
727                    None if indices.is_empty() => Ok(None),
728                    None => Err(Error::IndexOutOfRange {
729                        name: name.to_owned(),
730                        index: n,
731                        count: indices.len(),
732                    }),
733                }
734            }
735        }
736    }
737
738    fn require_nth(&self, name: &str, n: usize) -> Result<usize> {
739        self.resolve(Key::Nth(name, n))?
740            .ok_or_else(|| Error::IndexOutOfRange {
741                name: name.to_owned(),
742                index: n,
743                count: 0,
744            })
745    }
746
747    fn validate_name(name: &str) -> Result<()> {
748        if name.is_empty() {
749            return Err(Error::InvalidName {
750                name: name.to_owned(),
751                reason: "empty",
752            });
753        }
754        if name.len() > 8 {
755            return Err(Error::InvalidName {
756                name: name.to_owned(),
757                reason: "exceeds 8 characters",
758            });
759        }
760        if !name
761            .bytes()
762            .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
763        {
764            return Err(Error::InvalidName {
765                name: name.to_owned(),
766                reason: "must be ASCII letters, digits, `-`, or `_`",
767            });
768        }
769        Ok(())
770    }
771
772    fn validate_property_id(id: &str) -> Result<()> {
773        if id.is_empty() {
774            return Err(Error::InvalidName {
775                name: id.to_owned(),
776                reason: "empty",
777            });
778        }
779        if !id
780            .bytes()
781            .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b':')
782        {
783            return Err(Error::InvalidName {
784                name: id.to_owned(),
785                reason: "property id must be ASCII alphanumeric, `_`, or `:`",
786            });
787        }
788        Ok(())
789    }
790}
791
792#[cfg(test)]
793mod tests {
794    use super::*;
795
796    #[test]
797    fn validate_name_rules() {
798        assert!(Header::validate_name("GAIN").is_ok());
799        assert!(Header::validate_name("DATE-OBS").is_ok());
800        assert!(Header::validate_name("lower_k").is_ok());
801        assert!(Header::validate_name("EIGHTCHR").is_ok());
802        assert!(Header::validate_name("").is_err());
803        assert!(Header::validate_name("NINECHARS").is_err());
804        assert!(Header::validate_name("BAD KEY").is_err());
805        assert!(Header::validate_name("NAME!").is_err());
806    }
807
808    #[test]
809    fn validate_property_id_rules() {
810        assert!(Header::validate_property_id("Instrument:Telescope:FocalLength").is_ok());
811        assert!(Header::validate_property_id("A_b:9").is_ok());
812        assert!(Header::validate_property_id("").is_err());
813        assert!(Header::validate_property_id("bad id!").is_err());
814        assert!(Header::validate_property_id("hy-phen").is_err());
815    }
816
817    #[test]
818    fn nth_write_on_absent_name_errors() {
819        let mut h = Header::new();
820        assert!(matches!(
821            h.set(("MISSING", 0), 1_i64),
822            Err(Error::IndexOutOfRange { count: 0, .. })
823        ));
824    }
825
826    #[test]
827    fn set_with_comment_creates_and_updates() {
828        let mut h = Header::new();
829        h.set_with_comment("GAIN", 100_i64, "sensor gain").unwrap();
830        assert_eq!(h.get_i64("GAIN").unwrap(), Some(100));
831        assert_eq!(h.keywords()[0].comment, "sensor gain");
832
833        h.set_with_comment("GAIN", 200_i64, "updated").unwrap();
834        assert_eq!(h.get_i64("GAIN").unwrap(), Some(200));
835        assert_eq!(h.keywords()[0].comment, "updated");
836
837        h.append("HISTORY", "a").unwrap();
838        h.append("HISTORY", "b").unwrap();
839        assert!(matches!(
840            h.set_with_comment("HISTORY", "x", "c"),
841            Err(Error::Ambiguous { .. })
842        ));
843    }
844
845    #[test]
846    fn set_comment_on_absent_keyword_reports_not_found() {
847        let mut h = Header::new();
848        assert!(!h.set_comment("MISSING", "c").unwrap());
849    }
850
851    #[test]
852    fn remove_all_clears_every_occurrence() {
853        let mut h = Header::new();
854        h.append("HISTORY", "a").unwrap();
855        h.append("HISTORY", "b").unwrap();
856        h.set("GAIN", 1_i64).unwrap();
857        assert_eq!(h.remove_all("history"), 2); // case-insensitive
858        assert_eq!(h.count("HISTORY"), 0);
859        assert_eq!(h.remove_all("HISTORY"), 0);
860        assert_eq!(h.get_i64("GAIN").unwrap(), Some(1));
861    }
862
863    #[test]
864    fn iter_preserves_document_order() {
865        let mut h = Header::new();
866        h.set("A", 1_i64).unwrap();
867        h.set("B", 2_i64).unwrap();
868        h.set("C", 3_i64).unwrap();
869        let names: Vec<&str> = h.iter().map(|k| k.name.as_str()).collect();
870        assert_eq!(names, ["A", "B", "C"]);
871    }
872
873    #[test]
874    fn string_keys_are_accepted() {
875        let mut h = Header::new();
876        let key = String::from("GAIN");
877        h.set(&key, 100_i64).unwrap();
878        assert_eq!(h.get_i64(&key).unwrap(), Some(100));
879    }
880
881    #[test]
882    fn generic_get_reads_string() {
883        let mut h = Header::new();
884        h.set("OBJECT", "M31").unwrap();
885        assert_eq!(h.get::<String>("OBJECT").unwrap(), Some("M31".to_owned()));
886    }
887}