1#[derive(Clone, Debug, PartialEq, Eq)]
29pub enum PlusHeader {
30 Length(u64),
32 PeriodTerminated,
34 UntilClose,
37 Error,
40}
41
42#[derive(Clone, Debug, PartialEq, Eq)]
44pub struct MalformedHeader(pub String);
45
46impl std::fmt::Display for MalformedHeader {
47 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48 write!(f, "malformed gopher+ header: {}", self.0)
49 }
50}
51
52impl std::error::Error for MalformedHeader {}
53
54pub fn parse_header(line: &str) -> Result<PlusHeader, MalformedHeader> {
66 let line = line.trim_end_matches(['\r', '\n']);
67 let (sign, token) = line
68 .split_at_checked(1)
69 .ok_or_else(|| MalformedHeader(line.to_string()))?;
70 let token = token.split_whitespace().next().unwrap_or("");
73 match (sign, token) {
74 ("-", _) => Ok(PlusHeader::Error),
75 ("+", "-1") => Ok(PlusHeader::PeriodTerminated),
76 ("+", "-2") => Ok(PlusHeader::UntilClose),
77 ("+", count) => count
78 .parse::<u64>()
79 .map(PlusHeader::Length)
80 .map_err(|_| MalformedHeader(line.to_string())),
81 _ => Err(MalformedHeader(line.to_string())),
82 }
83}
84
85#[derive(Clone, Debug, PartialEq, Eq)]
93pub struct AttributeBlock {
94 pub name: String,
96 pub head: Option<String>,
99 pub lines: Vec<String>,
101}
102
103impl AttributeBlock {
104 pub fn pairs(&self) -> Vec<(&str, &str)> {
107 self.lines
108 .iter()
109 .filter_map(|line| line.split_once(':'))
110 .map(|(k, v)| (k.trim(), v.trim()))
111 .collect()
112 }
113}
114
115pub fn parse_attributes(text: &str) -> Vec<AttributeBlock> {
120 let mut blocks: Vec<AttributeBlock> = Vec::new();
121 for line in text.lines() {
122 if line == "." {
123 break;
124 }
125 if let Some(rest) = line.strip_prefix('+') {
126 let (name, head) = match rest.split_once(':') {
128 Some((name, head)) => (name, head.trim()),
129 None => (rest, ""),
130 };
131 blocks.push(AttributeBlock {
132 name: name.trim().to_string(),
133 head: (!head.is_empty()).then(|| head.to_string()),
134 lines: Vec::new(),
135 });
136 } else if let Some(content) = line.strip_prefix(' ') {
137 if let Some(block) = blocks.last_mut() {
138 block.lines.push(content.to_string());
139 }
140 }
141 }
142 blocks
143}
144
145#[derive(Clone, Debug, PartialEq, Eq)]
147pub struct View {
148 pub mime: String,
150 pub language: Option<String>,
152 pub size: Option<String>,
155}
156
157pub fn parse_views(block: &AttributeBlock) -> Vec<View> {
171 block
172 .lines
173 .iter()
174 .filter_map(|line| {
175 let (label, size) = match line.split_once(':') {
176 Some((label, size)) => (label.trim(), size.trim()),
177 None => (line.trim(), ""),
178 };
179 if label.is_empty() {
180 return None;
181 }
182 let (mime, language) = match label.split_once(char::is_whitespace) {
184 Some((mime, lang)) => (mime.trim(), Some(lang.trim().to_string())),
185 None => (label, None),
186 };
187 Some(View {
188 mime: mime.to_string(),
189 language,
190 size: (!size.is_empty()).then(|| size.to_string()),
191 })
192 })
193 .collect()
194}
195
196#[derive(Clone, Debug, PartialEq, Eq)]
203pub enum AskDirective {
204 Ask {
206 prompt: String,
207 default: Option<String>,
208 },
209 AskPassword {
211 prompt: String,
212 default: Option<String>,
213 },
214 AskLong {
216 prompt: String,
217 default: Option<String>,
218 },
219 AskFile {
221 prompt: String,
222 default: Option<String>,
223 },
224 Select {
226 prompt: String,
227 options: Vec<String>,
228 },
229 Choose {
231 prompt: String,
232 options: Vec<String>,
233 },
234 ChooseFile { prompt: String },
236 Note(String),
238 Unknown { directive: String, rest: String },
241}
242
243pub fn parse_ask(block: &AttributeBlock) -> Vec<AskDirective> {
258 block.lines.iter().map(|line| parse_ask_line(line)).collect()
259}
260
261fn parse_ask_line(line: &str) -> AskDirective {
262 let (directive, rest) = match line.split_once(':') {
263 Some((directive, rest)) => (directive.trim(), rest.trim_start()),
264 None => (line.trim(), ""),
265 };
266
267 let mut fields = rest.split('\t');
270 let prompt = fields.next().unwrap_or("").trim().to_string();
271 let tail: Vec<String> = fields
272 .map(|f| f.trim().to_string())
273 .filter(|f| !f.is_empty())
274 .collect();
275 let default = tail.first().cloned();
276
277 match directive {
278 "Ask" => AskDirective::Ask { prompt, default },
279 "AskP" => AskDirective::AskPassword { prompt, default },
280 "AskL" => AskDirective::AskLong { prompt, default },
281 "AskF" => AskDirective::AskFile { prompt, default },
282 "Select" => AskDirective::Select {
283 prompt,
284 options: tail,
285 },
286 "Choose" => AskDirective::Choose {
287 prompt,
288 options: tail,
289 },
290 "ChooseF" => AskDirective::ChooseFile { prompt },
291 "Note" => AskDirective::Note(rest.trim().to_string()),
292 other => AskDirective::Unknown {
293 directive: other.to_string(),
294 rest: rest.to_string(),
295 },
296 }
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302
303 const SAMPLE: &str = "+INFO: 0Some file or other\tmoo selector\thost2\tport2\t+\n\
304 +ADMIN:\n\
305 \x20Admin: Frodo Gophermeister <fng@bogus.edu>\n\
306 \x20Mod-Date: Wed Jul 28 17:02:01 1993 <19930728170201>\n\
307 +VIEWS:\n\
308 \x20Text/plain: <10k>\n\
309 \x20application/postscript: <100k>\n";
310
311 #[test]
312 fn header_forms() {
313 assert_eq!(parse_header("+5340"), Ok(PlusHeader::Length(5340)));
314 assert_eq!(parse_header("+-1"), Ok(PlusHeader::PeriodTerminated));
315 assert_eq!(parse_header("+-2"), Ok(PlusHeader::UntilClose));
316 assert_eq!(parse_header("--1"), Ok(PlusHeader::Error));
317 }
318
319 #[test]
320 fn a_header_without_a_sign_is_malformed() {
321 assert!(parse_header("5340").is_err());
322 assert!(parse_header("").is_err());
323 assert!(parse_header("+banana").is_err());
324 }
325
326 #[test]
327 fn a_trailing_crlf_does_not_defeat_the_count() {
328 assert_eq!(parse_header("+5340\r\n"), Ok(PlusHeader::Length(5340)));
329 }
330
331 #[test]
332 fn blocks_split_on_the_leading_plus() {
333 let blocks = parse_attributes(SAMPLE);
334 assert_eq!(
335 blocks.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(),
336 vec!["INFO", "ADMIN", "VIEWS"]
337 );
338 }
339
340 #[test]
341 fn info_carries_its_descriptor_on_the_block_line() {
342 let blocks = parse_attributes(SAMPLE);
343 assert!(blocks[0].head.as_deref().unwrap().starts_with("0Some file"));
344 assert!(blocks[0].lines.is_empty(), "INFO is a one-line block");
345 }
346
347 #[test]
348 fn continuation_lines_lose_their_leading_space_and_pair_up() {
349 let blocks = parse_attributes(SAMPLE);
350 let admin = &blocks[1];
351 assert_eq!(admin.lines.len(), 2);
352 assert_eq!(
353 admin.pairs()[0],
354 ("Admin", "Frodo Gophermeister <fng@bogus.edu>")
355 );
356 }
357
358 #[test]
359 fn views_split_mime_language_and_size() {
360 let blocks = parse_attributes(SAMPLE);
361 let views = parse_views(&blocks[2]);
362 assert_eq!(views.len(), 2);
363 assert_eq!(views[0].mime, "Text/plain");
364 assert_eq!(views[0].size.as_deref(), Some("<10k>"));
365 assert_eq!(views[1].mime, "application/postscript");
366 }
367
368 #[test]
369 fn a_language_tag_is_split_off_the_mime_type() {
370 let blocks = parse_attributes("+VIEWS:\n Text/plain De_DE: <15k>\n");
371 let views = parse_views(&blocks[0]);
372 assert_eq!(views[0].mime, "Text/plain");
373 assert_eq!(views[0].language.as_deref(), Some("De_DE"));
374 }
375
376 #[test]
377 fn ask_directives_keep_their_order_and_kind() {
378 let blocks = parse_attributes(
379 "+ASK:\n\
380 \x20Ask: How many volts?\tdefault volts\n\
381 \x20AskP: Password?\n\
382 \x20Choose: Deliver shock?\tYes\tNo\n\
383 \x20Note: be careful\n",
384 );
385 let form = parse_ask(&blocks[0]);
386
387 assert_eq!(
388 form[0],
389 AskDirective::Ask {
390 prompt: "How many volts?".into(),
391 default: Some("default volts".into()),
392 }
393 );
394 assert_eq!(
395 form[1],
396 AskDirective::AskPassword {
397 prompt: "Password?".into(),
398 default: None,
399 }
400 );
401 assert_eq!(
402 form[2],
403 AskDirective::Choose {
404 prompt: "Deliver shock?".into(),
405 options: vec!["Yes".into(), "No".into()],
406 }
407 );
408 assert_eq!(form[3], AskDirective::Note("be careful".into()));
409 }
410
411 #[test]
412 fn an_unmodelled_directive_survives_rather_than_vanishing() {
413 let blocks = parse_attributes("+ASK:\n Wibble: something\n");
414 let form = parse_ask(&blocks[0]);
415 assert_eq!(
416 form[0],
417 AskDirective::Unknown {
418 directive: "Wibble".into(),
419 rest: "something".into(),
420 }
421 );
422 }
423
424 #[test]
425 fn a_period_terminator_ends_the_attribute_stream() {
426 let blocks = parse_attributes("+INFO: 1x\n.\n+ADMIN:\n Admin: nobody\n");
427 assert_eq!(blocks.len(), 1);
428 }
429}