1use crate::error::FitsError;
4use crate::header::Header;
5use crate::record::{RecordKind, Value};
6use crate::CARD_LEN;
7
8pub fn parse(bytes: &[u8]) -> Result<Header, FitsError> {
29 let cards: Vec<[u8; CARD_LEN]> = bytes
30 .chunks_exact(CARD_LEN)
31 .map(|c| {
32 let mut a = [b' '; CARD_LEN];
33 a.copy_from_slice(c);
34 a
35 })
36 .collect();
37
38 let mut records = Vec::new();
39 let mut i = 0;
40 while i < cards.len() {
41 let card = cards[i];
42 let card_str = String::from_utf8_lossy(&card).into_owned();
43 let keyword = card_str[..8].trim().to_string();
44
45 if keyword == "END" {
46 break;
47 }
48
49 let is_value = card.get(8) == Some(&b'=') && card.get(9) == Some(&b' ');
50
51 if is_value {
52 let field = card_str[10..].trim_start();
53 if field.starts_with('\'') {
54 let (mut content, mut comment, mut cont) = parse_string_field(field);
56 let mut raw = vec![card];
57 while cont && i + 1 < cards.len() {
58 let next = cards[i + 1];
59 let next_kw = String::from_utf8_lossy(&next[..8]).trim().to_string();
60 if next_kw != "CONTINUE" {
61 break;
62 }
63 content.pop();
65 let next_str = String::from_utf8_lossy(&next).into_owned();
66 let nf = next_str[10..].trim_start();
67 let (piece, c2, cont2) = parse_string_field(nf);
68 content.push_str(&piece);
69 if c2.is_some() {
70 comment = c2;
71 }
72 cont = cont2;
73 raw.push(next);
74 i += 1;
75 }
76 let content = content.trim_end().to_string();
77 records.push(crate::record::Record::from_raw(
78 RecordKind::Value {
79 keyword,
80 value: Value::Str(content),
81 comment,
82 },
83 raw,
84 ));
85 } else {
86 let (token, comment) = split_comment(field);
87 records.push(crate::record::Record::from_raw(
88 RecordKind::Value {
89 keyword,
90 value: Value::Literal(token.trim().to_string()),
91 comment,
92 },
93 vec![card],
94 ));
95 }
96 } else if keyword == "COMMENT" || keyword == "HISTORY" {
97 let text = card_str[8..].trim_end().to_string();
98 records.push(crate::record::Record::from_raw(
99 RecordKind::Commentary { keyword, text },
100 vec![card],
101 ));
102 } else if keyword.is_empty() {
103 let rest = &card_str[8..];
104 if rest.trim().is_empty() {
105 records.push(crate::record::Record::from_raw(
106 RecordKind::Opaque {
107 text: String::new(),
108 },
109 vec![card],
110 ));
111 } else {
112 records.push(crate::record::Record::from_raw(
113 RecordKind::Commentary {
114 keyword,
115 text: rest.trim_end().to_string(),
116 },
117 vec![card],
118 ));
119 }
120 } else {
121 records.push(crate::record::Record::from_raw(
123 RecordKind::Opaque {
124 text: card_str.trim_end().to_string(),
125 },
126 vec![card],
127 ));
128 }
129
130 i += 1;
131 }
132
133 Ok(Header::from_records(records))
134}
135
136fn parse_string_field(field: &str) -> (String, Option<String>, bool) {
139 let rest = match field.strip_prefix('\'') {
140 Some(r) => r,
141 None => return (String::new(), None, false),
142 };
143 let chars: Vec<char> = rest.chars().collect();
144 let mut content = String::new();
145 let mut k = 0;
146 while k < chars.len() {
147 if chars[k] == '\'' {
148 if chars.get(k + 1) == Some(&'\'') {
149 content.push('\'');
150 k += 2;
151 continue;
152 }
153 k += 1;
154 break;
155 }
156 content.push(chars[k]);
157 k += 1;
158 }
159 let remainder: String = chars[k..].iter().collect();
160 let comment = extract_comment(&remainder);
161 let cont = content.ends_with('&');
163 (content, comment, cont)
164}
165
166fn extract_comment(s: &str) -> Option<String> {
168 let idx = s.find('/')?;
169 let c = s[idx + 1..].trim().to_string();
170 (!c.is_empty()).then_some(c)
171}
172
173fn split_comment(s: &str) -> (&str, Option<String>) {
175 match s.find('/') {
176 Some(idx) => (&s[..idx], extract_comment(&s[idx..])),
177 None => (s, None),
178 }
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184 use crate::record::RecordKind;
185
186 fn block(cards: &[&str]) -> Vec<u8> {
187 let mut out = Vec::new();
188 for c in cards {
189 let mut b = c.as_bytes().to_vec();
190 b.resize(CARD_LEN, b' ');
191 out.extend(b);
192 }
193 let mut end = b"END".to_vec();
194 end.resize(CARD_LEN, b' ');
195 out.extend(end);
196 out
197 }
198
199 #[test]
200 fn string_field_plain_with_comment() {
201 let (content, comment, cont) = parse_string_field("'abc ' / a note");
202 assert_eq!(content, "abc ");
203 assert_eq!(comment.as_deref(), Some("a note"));
204 assert!(!cont);
205 }
206
207 #[test]
208 fn string_field_unescapes_doubled_quotes() {
209 let (content, _, _) = parse_string_field("'ab''c'");
210 assert_eq!(content, "ab'c");
211 }
212
213 #[test]
214 fn string_field_continuation_marker() {
215 let (content, comment, cont) = parse_string_field("'abc&'");
216 assert_eq!(content, "abc&");
218 assert_eq!(comment, None);
219 assert!(cont);
220 }
221
222 #[test]
223 fn string_field_without_quote_is_empty() {
224 assert_eq!(parse_string_field("T"), (String::new(), None, false));
225 }
226
227 #[test]
228 fn comment_extraction() {
229 assert_eq!(extract_comment(" / hi"), Some("hi".to_string()));
230 assert_eq!(extract_comment(" / "), None, "empty comment is None");
231 assert_eq!(extract_comment("no slash"), None);
232 let (token, comment) = split_comment("T / yes");
233 assert_eq!(token.trim(), "T");
234 assert_eq!(comment.as_deref(), Some("yes"));
235 let (token, comment) = split_comment("42");
236 assert_eq!(token, "42");
237 assert_eq!(comment, None);
238 }
239
240 #[test]
241 fn hierarch_and_unrecognized_are_opaque() {
242 let h = parse(&block(&["HIERARCH ESO DET DIT = 10.0", "JUNK CARD"])).unwrap();
243 assert_eq!(h.cards().len(), 2);
244 for r in h.cards() {
245 assert!(matches!(r.kind, RecordKind::Opaque { .. }));
246 assert_eq!(r.keyword(), None);
247 }
248 }
249
250 #[test]
251 fn fully_blank_card_is_opaque_blank_with_text_is_commentary() {
252 let h = parse(&block(&["", " some annotation"])).unwrap();
253 assert!(matches!(
254 h.cards()[0].kind,
255 RecordKind::Opaque { ref text } if text.is_empty()
256 ));
257 assert!(matches!(
258 h.cards()[1].kind,
259 RecordKind::Commentary { ref keyword, ref text }
260 if keyword.is_empty() && text == "some annotation"
261 ));
262 }
263
264 #[test]
265 fn stray_continue_is_opaque() {
266 let h = parse(&block(&["OBJECT = 'X'", "CONTINUE 'orphan'"])).unwrap();
268 assert_eq!(h.get_str("OBJECT").unwrap(), Some("X"));
269 assert!(matches!(h.cards()[1].kind, RecordKind::Opaque { .. }));
270 }
271
272 #[test]
273 fn continue_comment_comes_from_last_card() {
274 let h = parse(&block(&["LONG = 'aaa&'", "CONTINUE 'bbb' / tail note"])).unwrap();
275 assert_eq!(h.get_str("LONG").unwrap(), Some("aaabbb"));
276 assert_eq!(h.cards()[0].comment(), Some("tail note"));
277 }
278
279 #[test]
280 fn continue_run_at_end_of_input_keeps_marker() {
281 let h = parse(&block(&["LONG = 'aaa&'"])).unwrap();
283 assert_eq!(h.get_str("LONG").unwrap(), Some("aaa&"));
284 }
285
286 #[test]
287 fn trailing_partial_card_is_dropped() {
288 let mut bytes = block(&["OBJECT = 'X'"]);
289 bytes.extend_from_slice(b"GAIN = 1"); let h = parse(&bytes).unwrap();
291 assert_eq!(h.get_str("OBJECT").unwrap(), Some("X"));
292 assert_eq!(h.count("GAIN"), 0);
293 }
294
295 #[test]
296 fn non_utf8_bytes_parse_lossily() {
297 let mut cards = block(&["OBJECT = 'X'"]);
298 cards[15] = 0xff; let h = parse(&cards).unwrap();
300 assert_eq!(h.cards().len(), 1, "card is retained, lossily decoded");
301 }
302
303 #[test]
304 fn value_needs_equals_space() {
305 let h = parse(&block(&["WEIRD =X"])).unwrap();
307 assert!(matches!(h.cards()[0].kind, RecordKind::Opaque { .. }));
308 }
309}