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}