1use crate::header::Header;
4use crate::record::{Record, RecordKind, Value};
5use crate::{BLOCK_LEN, CARD_LEN};
6
7pub 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
113fn 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
169fn 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 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 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 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 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 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}