Skip to main content

xisf_header/
header.rs

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