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}