Skip to main content

fits_header/
write.rs

1//! Serialize a header back to bytes.
2
3use crate::header::Header;
4use crate::record::{Record, RecordKind, Value};
5use crate::{BLOCK_LEN, CARD_LEN};
6
7/// Serialize the header block only (cards + `END`, padded to a 2880 multiple).
8pub fn to_header_bytes(header: &Header) -> Vec<u8> {
9    let mut out = Vec::new();
10    emit_records(&mut out, header.cards(), has_longstrn(header));
11    finish_header(&mut out);
12    out
13}
14
15fn finish_header(out: &mut Vec<u8>) {
16    push(out, pad80("END"));
17    pad_to_block(out, b' ');
18}
19
20fn emit_records(out: &mut Vec<u8>, records: &[Record], longstrn_present: bool) {
21    let mut longstrn_done = longstrn_present;
22    for record in records {
23        let (cards, used_continue) = render_record(record);
24        if used_continue && !longstrn_done {
25            push(out, longstrn_card());
26            longstrn_done = true;
27        }
28        for card in cards {
29            out.extend_from_slice(&card);
30        }
31    }
32}
33
34fn render_record(record: &Record) -> (Vec<[u8; CARD_LEN]>, bool) {
35    if let Some(raw) = record.raw_cards() {
36        return (raw.to_vec(), false);
37    }
38    match &record.kind {
39        RecordKind::Value {
40            keyword,
41            value: Value::Str(s),
42            comment,
43        } => string_cards(keyword, s, comment.as_deref()),
44        RecordKind::Value {
45            keyword,
46            value: Value::Literal(l),
47            comment,
48        } => (vec![literal_card(keyword, l, comment.as_deref())], false),
49        RecordKind::Commentary { keyword, text } => (commentary_cards(keyword, text), false),
50        RecordKind::Opaque { text } => (vec![pad80(text)], false),
51    }
52}
53
54fn literal_card(keyword: &str, token: &str, comment: Option<&str>) -> [u8; CARD_LEN] {
55    let mut s = format!("{keyword:<8}= ");
56    if token.len() <= 20 {
57        s.push_str(&format!("{token:>20}"));
58    } else {
59        s.push_str(token);
60    }
61    if let Some(c) = comment {
62        s.push_str(&format!(" / {c}"));
63    }
64    pad80(&s)
65}
66
67fn string_cards(
68    keyword: &str,
69    content: &str,
70    comment: Option<&str>,
71) -> (Vec<[u8; CARD_LEN]>, bool) {
72    if let Some(card) = string_single(keyword, content, comment) {
73        return (vec![card], false);
74    }
75    let pieces = chunk_for_continue(content);
76    let mut cards = Vec::new();
77    let last_idx = pieces.len() - 1;
78    for (idx, piece) in pieces.iter().enumerate() {
79        let esc = piece.replace('\'', "''");
80        let body = if idx == last_idx {
81            format!("'{esc}'")
82        } else {
83            format!("'{esc}&'")
84        };
85        let mut s = if idx == 0 {
86            format!("{keyword:<8}= {body}")
87        } else {
88            format!("{:<8}  {body}", "CONTINUE")
89        };
90        if idx == last_idx {
91            if let Some(c) = comment {
92                let with = format!("{s} / {c}");
93                if with.len() <= CARD_LEN {
94                    s = with;
95                }
96            }
97        }
98        cards.push(pad80(&s));
99    }
100    (cards, true)
101}
102
103fn string_single(keyword: &str, content: &str, comment: Option<&str>) -> Option<[u8; CARD_LEN]> {
104    let esc = content.replace('\'', "''");
105    let inner = format!("{esc:<8}");
106    let mut s = format!("{keyword:<8}= '{inner}'");
107    if let Some(c) = comment {
108        s.push_str(&format!(" / {c}"));
109    }
110    (s.len() <= CARD_LEN).then(|| pad80(&s))
111}
112
113/// Split content so each escaped piece fits a continuation card's value field.
114fn chunk_for_continue(content: &str) -> Vec<String> {
115    let mut pieces = Vec::new();
116    let mut cur = String::new();
117    let mut esc_len = 0usize;
118    for ch in content.chars() {
119        let add = if ch == '\'' { 2 } else { 1 };
120        if esc_len + add > 66 {
121            pieces.push(std::mem::take(&mut cur));
122            esc_len = 0;
123        }
124        cur.push(ch);
125        esc_len += add;
126    }
127    pieces.push(cur);
128    pieces
129}
130
131fn commentary_cards(keyword: &str, text: &str) -> Vec<[u8; CARD_LEN]> {
132    if text.is_empty() {
133        return vec![pad80(&format!("{keyword:<8}"))];
134    }
135    let chars: Vec<char> = text.chars().collect();
136    chars
137        .chunks(72)
138        .map(|chunk| {
139            let t: String = chunk.iter().collect();
140            pad80(&format!("{keyword:<8}{t}"))
141        })
142        .collect()
143}
144
145fn longstrn_card() -> [u8; CARD_LEN] {
146    pad80(&format!(
147        "{:<8}= '{:<8}' / {}",
148        "LONGSTRN", "OGIP 1.0", "The OGIP long string convention may be used."
149    ))
150}
151
152fn has_longstrn(header: &Header) -> bool {
153    header
154        .cards()
155        .iter()
156        .any(|r| r.keyword() == Some("LONGSTRN"))
157}
158
159fn push(out: &mut Vec<u8>, card: [u8; CARD_LEN]) {
160    out.extend_from_slice(&card);
161}
162
163fn pad_to_block(out: &mut Vec<u8>, fill: u8) {
164    while out.len() % BLOCK_LEN != 0 {
165        out.push(fill);
166    }
167}
168
169/// Left-justify `s` into an 80-byte card, space-padded (truncated at 80 bytes).
170fn pad80(s: &str) -> [u8; CARD_LEN] {
171    let mut card = [b' '; CARD_LEN];
172    let bytes = s.as_bytes();
173    let n = bytes.len().min(CARD_LEN);
174    card[..n].copy_from_slice(&bytes[..n]);
175    card
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    fn text(card: &[u8; CARD_LEN]) -> String {
183        String::from_utf8_lossy(card).into_owned()
184    }
185
186    #[test]
187    fn pad80_pads_and_truncates() {
188        assert_eq!(&pad80("END")[..3], b"END");
189        assert!(pad80("END")[3..].iter().all(|&b| b == b' '));
190        let long = "x".repeat(100);
191        assert_eq!(pad80(&long), [b'x'; CARD_LEN]);
192    }
193
194    #[test]
195    fn literal_card_right_justifies_short_tokens() {
196        let c = text(&literal_card("BITPIX", "8", Some("bits")));
197        assert!(c.starts_with("BITPIX  =                    8 / bits"));
198        // A token wider than the fixed 20-char field is emitted unpadded.
199        let wide = "1234567890123456789012345";
200        let c = text(&literal_card("K", wide, None));
201        assert!(c.starts_with(&format!("K       = {wide}")));
202    }
203
204    #[test]
205    fn string_single_pads_content_to_eight() {
206        let c = text(&string_single("OBJECT", "M31", None).unwrap());
207        assert!(c.starts_with("OBJECT  = 'M31     '"));
208    }
209
210    #[test]
211    fn string_single_rejects_overflow() {
212        // 69 escaped chars + quotes + "KEY     = " no longer fit in 80.
213        assert!(string_single("KEY", &"x".repeat(68), None).is_some());
214        assert!(string_single("KEY", &"x".repeat(69), None).is_none());
215    }
216
217    #[test]
218    fn string_single_drops_to_continue_when_comment_overflows() {
219        let content = "x".repeat(60);
220        assert!(string_single("KEY", &content, Some(&"c".repeat(20))).is_none());
221    }
222
223    #[test]
224    fn chunks_fit_continuation_cards_and_split_escapes() {
225        // Doubled quotes count as two; every escaped piece must fit 66 columns.
226        let content = "'".repeat(50) + &"a".repeat(50);
227        for piece in chunk_for_continue(&content) {
228            let esc = piece.replace('\'', "''");
229            assert!(esc.len() <= 66, "escaped piece too long: {}", esc.len());
230        }
231        assert_eq!(chunk_for_continue(&content).concat(), content);
232    }
233
234    #[test]
235    fn commentary_chunks_at_72() {
236        let cards = commentary_cards("COMMENT", &"y".repeat(100));
237        assert_eq!(cards.len(), 2);
238        assert!(text(&cards[0]).starts_with(&format!("COMMENT {}", "y".repeat(72))));
239        assert!(text(&cards[1]).starts_with(&format!("COMMENT {}", "y".repeat(28))));
240        // Empty commentary still emits its bare keyword card.
241        assert_eq!(commentary_cards("HISTORY", "").len(), 1);
242    }
243
244    #[test]
245    fn longstrn_emitted_once_before_first_continue() {
246        let mut h = Header::new();
247        h.set("A", "x".repeat(100).as_str()).unwrap();
248        h.set("B", "y".repeat(100).as_str()).unwrap();
249        let out = to_header_bytes(&h);
250        let s = String::from_utf8_lossy(&out);
251        assert_eq!(s.matches("LONGSTRN").count(), 1);
252        // LONGSTRN precedes the first long-string card.
253        assert!(s.find("LONGSTRN").unwrap() < s.find('A').unwrap_or(usize::MAX));
254    }
255
256    #[test]
257    fn existing_longstrn_not_duplicated() {
258        let mut h = Header::new();
259        h.set("LONGSTRN", "OGIP 1.0").unwrap();
260        h.set("A", "x".repeat(100).as_str()).unwrap();
261        let out = to_header_bytes(&h);
262        let s = String::from_utf8_lossy(&out);
263        assert_eq!(s.matches("LONGSTRN").count(), 1);
264    }
265}