use crate::error::FitsError;
use crate::CARD_LEN;
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum Value {
Str(String),
Literal(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum RecordKind {
Value {
keyword: String,
value: Value,
comment: Option<String>,
},
Commentary {
keyword: String,
text: String,
},
Opaque {
text: String,
},
}
#[derive(Clone, Debug)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Record {
pub kind: RecordKind,
#[cfg_attr(feature = "serde", serde(skip))]
raw: Option<Vec<[u8; CARD_LEN]>>,
}
impl PartialEq for Record {
fn eq(&self, other: &Self) -> bool {
self.kind == other.kind
}
}
impl Record {
pub fn value(keyword: impl Into<String>, value: Value, comment: Option<String>) -> Self {
Record {
kind: RecordKind::Value {
keyword: keyword.into(),
value,
comment,
},
raw: None,
}
}
pub fn commentary(keyword: impl Into<String>, text: impl Into<String>) -> Self {
Record {
kind: RecordKind::Commentary {
keyword: keyword.into(),
text: text.into(),
},
raw: None,
}
}
pub(crate) fn from_raw(kind: RecordKind, raw: Vec<[u8; CARD_LEN]>) -> Self {
Record {
kind,
raw: Some(raw),
}
}
pub fn keyword(&self) -> Option<&str> {
match &self.kind {
RecordKind::Value { keyword, .. } | RecordKind::Commentary { keyword, .. } => {
Some(keyword)
}
RecordKind::Opaque { .. } => None,
}
}
pub fn value_text(&self) -> Option<&str> {
match &self.kind {
RecordKind::Value { value, .. } => match value {
Value::Str(s) => (!s.is_empty()).then_some(s.as_str()),
Value::Literal(l) => Some(l),
},
RecordKind::Commentary { text, .. } => Some(text),
RecordKind::Opaque { .. } => None,
}
}
pub fn str_content(&self) -> Option<&str> {
match &self.kind {
RecordKind::Value {
value: Value::Str(s),
..
} => (!s.is_empty()).then_some(s.as_str()),
_ => None,
}
}
pub fn comment(&self) -> Option<&str> {
match &self.kind {
RecordKind::Value { comment, .. } => comment.as_deref(),
_ => None,
}
}
pub(crate) fn raw_cards(&self) -> Option<&[[u8; CARD_LEN]]> {
self.raw.as_deref()
}
pub(crate) fn replace_value(&mut self, new: Value) {
match &mut self.kind {
RecordKind::Value { value, .. } => *value = new,
RecordKind::Commentary { text, .. } => {
*text = match new {
Value::Str(s) | Value::Literal(s) => s,
};
}
RecordKind::Opaque { .. } => {}
}
self.raw = None;
}
pub(crate) fn set_comment(&mut self, c: Option<String>) {
if let RecordKind::Value { comment, .. } = &mut self.kind {
*comment = c;
self.raw = None;
}
}
}
pub fn is_commentary_keyword(name: &str) -> bool {
name.is_empty() || name == "COMMENT" || name == "HISTORY"
}
pub fn validate_keyword(name: &str) -> Result<(), FitsError> {
if name.len() > 8 {
return Err(FitsError::KeywordTooLong {
keyword: name.to_string(),
});
}
for &b in name.as_bytes() {
let ok = b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'-' || b == b'_';
if !ok {
return Err(FitsError::InvalidKeyword {
keyword: name.to_string(),
});
}
}
Ok(())
}
pub fn validate_keyword_raw(name: &str) -> Result<(), FitsError> {
if name.len() > 8 {
return Err(FitsError::KeywordTooLong {
keyword: name.to_string(),
});
}
for &b in name.as_bytes() {
if !(0x20..=0x7e).contains(&b) {
return Err(FitsError::InvalidKeyword {
keyword: name.to_string(),
});
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keyword_validation_charset_and_length() {
for ok in ["", "A", "DATE-OBS", "NAXIS_1", "K2"] {
assert!(validate_keyword(ok).is_ok(), "{ok:?} should validate");
}
assert!(matches!(
validate_keyword("NINECHARS"),
Err(FitsError::KeywordTooLong { .. })
));
for bad in ["obj", "KEY WORD", "É", "K.1"] {
assert!(
matches!(validate_keyword(bad), Err(FitsError::InvalidKeyword { .. })),
"{bad:?} should be rejected"
);
}
}
#[test]
fn raw_validation_allows_printable_ascii_only() {
for ok in ["obj", "K.1", "a b", "~"] {
assert!(validate_keyword_raw(ok).is_ok(), "{ok:?} should validate");
}
assert!(matches!(
validate_keyword_raw("NINECHARS"),
Err(FitsError::KeywordTooLong { .. })
));
for bad in ["tab\there", "É"] {
assert!(
matches!(
validate_keyword_raw(bad),
Err(FitsError::InvalidKeyword { .. })
),
"{bad:?} should be rejected"
);
}
}
#[test]
fn commentary_keywords() {
assert!(is_commentary_keyword(""));
assert!(is_commentary_keyword("COMMENT"));
assert!(is_commentary_keyword("HISTORY"));
assert!(!is_commentary_keyword("OBJECT"));
}
#[test]
fn accessors_by_kind() {
let v = Record::value("K", Value::Str("s".into()), Some("c".into()));
assert_eq!(v.keyword(), Some("K"));
assert_eq!(v.value_text(), Some("s"));
assert_eq!(v.str_content(), Some("s"));
assert_eq!(v.comment(), Some("c"));
let lit = Record::value("K", Value::Literal("42".into()), None);
assert_eq!(lit.value_text(), Some("42"));
assert_eq!(lit.str_content(), None, "literal is not Str content");
let c = Record::commentary("HISTORY", "note");
assert_eq!(c.keyword(), Some("HISTORY"));
assert_eq!(c.value_text(), Some("note"));
assert_eq!(c.str_content(), None);
assert_eq!(c.comment(), None);
}
#[test]
fn equality_ignores_retained_bytes() {
let kind = RecordKind::Value {
keyword: "K".into(),
value: Value::Str("s".into()),
comment: None,
};
let parsed = Record::from_raw(kind.clone(), vec![[b' '; CARD_LEN]]);
let created = Record::value("K", Value::Str("s".into()), None);
assert_eq!(parsed, created);
}
#[test]
fn mutation_drops_retained_bytes() {
let kind = RecordKind::Value {
keyword: "K".into(),
value: Value::Str("s".into()),
comment: None,
};
let mut r = Record::from_raw(kind, vec![[b' '; CARD_LEN]]);
assert!(r.raw_cards().is_some());
r.replace_value(Value::Str("t".into()));
assert!(r.raw_cards().is_none(), "edited record must reformat");
let mut r2 = Record::from_raw(
RecordKind::Value {
keyword: "K".into(),
value: Value::Str("s".into()),
comment: None,
},
vec![[b' '; CARD_LEN]],
);
r2.set_comment(Some("c".into()));
assert!(r2.raw_cards().is_none());
assert_eq!(r2.comment(), Some("c"));
}
#[test]
fn set_comment_ignores_non_value_records() {
let mut c = Record::from_raw(
RecordKind::Commentary {
keyword: "COMMENT".into(),
text: "x".into(),
},
vec![[b' '; CARD_LEN]],
);
c.set_comment(Some("ignored".into()));
assert_eq!(c.comment(), None);
assert!(c.raw_cards().is_some(), "no-op keeps original bytes");
}
}