1use crate::error::FitsError;
4use crate::CARD_LEN;
5
6#[derive(Clone, Debug, PartialEq, Eq)]
8#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
9pub enum Value {
10 Str(String),
12 Literal(String),
14}
15
16#[derive(Clone, Debug, PartialEq, Eq)]
18#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
19pub enum RecordKind {
20 Value {
22 keyword: String,
24 value: Value,
26 comment: Option<String>,
28 },
29 Commentary {
31 keyword: String,
33 text: String,
35 },
36 Opaque {
39 text: String,
41 },
42}
43
44#[derive(Clone, Debug)]
47#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
48pub struct Record {
49 pub kind: RecordKind,
51 #[cfg_attr(feature = "serde", serde(skip))]
55 raw: Option<Vec<[u8; CARD_LEN]>>,
56}
57
58impl PartialEq for Record {
59 fn eq(&self, other: &Self) -> bool {
60 self.kind == other.kind
61 }
62}
63
64impl Record {
65 pub fn value(keyword: impl Into<String>, value: Value, comment: Option<String>) -> Self {
67 Record {
68 kind: RecordKind::Value {
69 keyword: keyword.into(),
70 value,
71 comment,
72 },
73 raw: None,
74 }
75 }
76
77 pub fn commentary(keyword: impl Into<String>, text: impl Into<String>) -> Self {
79 Record {
80 kind: RecordKind::Commentary {
81 keyword: keyword.into(),
82 text: text.into(),
83 },
84 raw: None,
85 }
86 }
87
88 pub(crate) fn from_raw(kind: RecordKind, raw: Vec<[u8; CARD_LEN]>) -> Self {
90 Record {
91 kind,
92 raw: Some(raw),
93 }
94 }
95
96 pub fn keyword(&self) -> Option<&str> {
98 match &self.kind {
99 RecordKind::Value { keyword, .. } | RecordKind::Commentary { keyword, .. } => {
100 Some(keyword)
101 }
102 RecordKind::Opaque { .. } => None,
103 }
104 }
105
106 pub fn value_text(&self) -> Option<&str> {
109 match &self.kind {
110 RecordKind::Value { value, .. } => match value {
111 Value::Str(s) => (!s.is_empty()).then_some(s.as_str()),
112 Value::Literal(l) => Some(l),
113 },
114 RecordKind::Commentary { text, .. } => Some(text),
115 RecordKind::Opaque { .. } => None,
116 }
117 }
118
119 pub fn str_content(&self) -> Option<&str> {
121 match &self.kind {
122 RecordKind::Value {
123 value: Value::Str(s),
124 ..
125 } => (!s.is_empty()).then_some(s.as_str()),
126 _ => None,
127 }
128 }
129
130 pub fn comment(&self) -> Option<&str> {
132 match &self.kind {
133 RecordKind::Value { comment, .. } => comment.as_deref(),
134 _ => None,
135 }
136 }
137
138 pub(crate) fn raw_cards(&self) -> Option<&[[u8; CARD_LEN]]> {
140 self.raw.as_deref()
141 }
142
143 pub(crate) fn replace_value(&mut self, new: Value) {
146 match &mut self.kind {
147 RecordKind::Value { value, .. } => *value = new,
148 RecordKind::Commentary { text, .. } => {
149 *text = match new {
150 Value::Str(s) | Value::Literal(s) => s,
151 };
152 }
153 RecordKind::Opaque { .. } => {}
154 }
155 self.raw = None;
156 }
157
158 pub(crate) fn set_comment(&mut self, c: Option<String>) {
160 if let RecordKind::Value { comment, .. } = &mut self.kind {
161 *comment = c;
162 self.raw = None;
163 }
164 }
165}
166
167pub fn is_commentary_keyword(name: &str) -> bool {
169 name.is_empty() || name == "COMMENT" || name == "HISTORY"
170}
171
172pub fn validate_keyword(name: &str) -> Result<(), FitsError> {
174 if name.len() > 8 {
175 return Err(FitsError::KeywordTooLong {
176 keyword: name.to_string(),
177 });
178 }
179 for &b in name.as_bytes() {
180 let ok = b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'-' || b == b'_';
181 if !ok {
182 return Err(FitsError::InvalidKeyword {
183 keyword: name.to_string(),
184 });
185 }
186 }
187 Ok(())
188}
189
190pub fn validate_keyword_raw(name: &str) -> Result<(), FitsError> {
192 if name.len() > 8 {
193 return Err(FitsError::KeywordTooLong {
194 keyword: name.to_string(),
195 });
196 }
197 for &b in name.as_bytes() {
198 if !(0x20..=0x7e).contains(&b) {
199 return Err(FitsError::InvalidKeyword {
200 keyword: name.to_string(),
201 });
202 }
203 }
204 Ok(())
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[test]
212 fn keyword_validation_charset_and_length() {
213 for ok in ["", "A", "DATE-OBS", "NAXIS_1", "K2"] {
214 assert!(validate_keyword(ok).is_ok(), "{ok:?} should validate");
215 }
216 assert!(matches!(
217 validate_keyword("NINECHARS"),
218 Err(FitsError::KeywordTooLong { .. })
219 ));
220 for bad in ["obj", "KEY WORD", "É", "K.1"] {
221 assert!(
222 matches!(validate_keyword(bad), Err(FitsError::InvalidKeyword { .. })),
223 "{bad:?} should be rejected"
224 );
225 }
226 }
227
228 #[test]
229 fn raw_validation_allows_printable_ascii_only() {
230 for ok in ["obj", "K.1", "a b", "~"] {
231 assert!(validate_keyword_raw(ok).is_ok(), "{ok:?} should validate");
232 }
233 assert!(matches!(
234 validate_keyword_raw("NINECHARS"),
235 Err(FitsError::KeywordTooLong { .. })
236 ));
237 for bad in ["tab\there", "É"] {
238 assert!(
239 matches!(
240 validate_keyword_raw(bad),
241 Err(FitsError::InvalidKeyword { .. })
242 ),
243 "{bad:?} should be rejected"
244 );
245 }
246 }
247
248 #[test]
249 fn commentary_keywords() {
250 assert!(is_commentary_keyword(""));
251 assert!(is_commentary_keyword("COMMENT"));
252 assert!(is_commentary_keyword("HISTORY"));
253 assert!(!is_commentary_keyword("OBJECT"));
254 }
255
256 #[test]
257 fn accessors_by_kind() {
258 let v = Record::value("K", Value::Str("s".into()), Some("c".into()));
259 assert_eq!(v.keyword(), Some("K"));
260 assert_eq!(v.value_text(), Some("s"));
261 assert_eq!(v.str_content(), Some("s"));
262 assert_eq!(v.comment(), Some("c"));
263
264 let lit = Record::value("K", Value::Literal("42".into()), None);
265 assert_eq!(lit.value_text(), Some("42"));
266 assert_eq!(lit.str_content(), None, "literal is not Str content");
267
268 let c = Record::commentary("HISTORY", "note");
269 assert_eq!(c.keyword(), Some("HISTORY"));
270 assert_eq!(c.value_text(), Some("note"));
271 assert_eq!(c.str_content(), None);
272 assert_eq!(c.comment(), None);
273 }
274
275 #[test]
276 fn equality_ignores_retained_bytes() {
277 let kind = RecordKind::Value {
278 keyword: "K".into(),
279 value: Value::Str("s".into()),
280 comment: None,
281 };
282 let parsed = Record::from_raw(kind.clone(), vec![[b' '; CARD_LEN]]);
283 let created = Record::value("K", Value::Str("s".into()), None);
284 assert_eq!(parsed, created);
285 }
286
287 #[test]
288 fn mutation_drops_retained_bytes() {
289 let kind = RecordKind::Value {
290 keyword: "K".into(),
291 value: Value::Str("s".into()),
292 comment: None,
293 };
294 let mut r = Record::from_raw(kind, vec![[b' '; CARD_LEN]]);
295 assert!(r.raw_cards().is_some());
296 r.replace_value(Value::Str("t".into()));
297 assert!(r.raw_cards().is_none(), "edited record must reformat");
298
299 let mut r2 = Record::from_raw(
300 RecordKind::Value {
301 keyword: "K".into(),
302 value: Value::Str("s".into()),
303 comment: None,
304 },
305 vec![[b' '; CARD_LEN]],
306 );
307 r2.set_comment(Some("c".into()));
308 assert!(r2.raw_cards().is_none());
309 assert_eq!(r2.comment(), Some("c"));
310 }
311
312 #[test]
313 fn set_comment_ignores_non_value_records() {
314 let mut c = Record::from_raw(
315 RecordKind::Commentary {
316 keyword: "COMMENT".into(),
317 text: "x".into(),
318 },
319 vec![[b' '; CARD_LEN]],
320 );
321 c.set_comment(Some("ignored".into()));
322 assert_eq!(c.comment(), None);
323 assert!(c.raw_cards().is_some(), "no-op keeps original bytes");
324 }
325}