1use crate::level::LogLevel;
2
3use std::fmt;
4
5pub(crate) const UUID_LEN: usize = 36;
7
8pub(crate) const UUID_PREFIX_LEN: usize = UUID_LEN + 1;
11
12#[non_exhaustive]
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum LineKind {
21 Full,
23 System,
25 UuidContinuation,
27 BareContinuation,
29 Truncated,
31 Empty,
33}
34
35impl fmt::Display for LineKind {
36 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37 match self {
38 LineKind::Full => f.pad("full"),
39 LineKind::System => f.pad("system"),
40 LineKind::UuidContinuation => f.pad("uuid-cont"),
41 LineKind::BareContinuation => f.pad("bare-cont"),
42 LineKind::Truncated => f.pad("truncated"),
43 LineKind::Empty => f.pad("empty"),
44 }
45 }
46}
47
48#[derive(Debug, PartialEq, Eq)]
54pub struct RawLine<'a> {
55 pub uuid: Option<&'a str>,
57 pub timestamp: Option<&'a str>,
59 pub idle_pct: Option<&'a str>,
61 pub level: Option<LogLevel>,
63 pub source: Option<&'a str>,
65 pub message: &'a str,
67 pub kind: LineKind,
69}
70
71pub(crate) fn is_uuid_at(bytes: &[u8], offset: usize) -> bool {
72 if bytes.len() < offset + UUID_PREFIX_LEN {
73 return false;
74 }
75 if bytes[offset + UUID_LEN] != b' ' {
76 return false;
77 }
78 for (i, &b) in bytes[offset..offset + UUID_LEN].iter().enumerate() {
79 match i {
80 8 | 13 | 18 | 23 => {
81 if b != b'-' {
82 return false;
83 }
84 }
85 _ => {
86 if !b.is_ascii_hexdigit() {
87 return false;
88 }
89 }
90 }
91 }
92 true
93}
94
95fn find_uuid_in(bytes: &[u8]) -> Option<usize> {
96 if bytes.len() < UUID_PREFIX_LEN {
97 return None;
98 }
99 let max_start = (bytes.len() - UUID_PREFIX_LEN).min(50);
100 (1..=max_start).find(|&start| is_uuid_at(bytes, start))
101}
102
103pub(crate) fn is_date_at(bytes: &[u8], offset: usize) -> bool {
104 if bytes.len() < offset + 5 {
105 return false;
106 }
107 bytes[offset..offset + 4].iter().all(u8::is_ascii_digit) && bytes[offset + 4] == b'-'
108}
109
110pub(crate) fn is_log_header_at(bytes: &[u8], offset: usize) -> bool {
120 if bytes.len() < offset + 31 {
122 return false;
123 }
124 if !(bytes[offset..offset + 4].iter().all(u8::is_ascii_digit)
126 && bytes[offset + 4] == b'-'
127 && bytes[offset + 5..offset + 7].iter().all(u8::is_ascii_digit)
128 && bytes[offset + 7] == b'-'
129 && bytes[offset + 8..offset + 10]
130 .iter()
131 .all(u8::is_ascii_digit)
132 && bytes[offset + 10] == b' '
133 && bytes[offset + 11..offset + 13]
134 .iter()
135 .all(u8::is_ascii_digit)
136 && bytes[offset + 13] == b':'
137 && bytes[offset + 14..offset + 16]
138 .iter()
139 .all(u8::is_ascii_digit)
140 && bytes[offset + 16] == b':'
141 && bytes[offset + 17..offset + 19]
142 .iter()
143 .all(u8::is_ascii_digit)
144 && bytes[offset + 19] == b'.'
145 && bytes[offset + 20..offset + 26]
146 .iter()
147 .all(u8::is_ascii_digit)
148 && bytes[offset + 26] == b' ')
149 {
150 return false;
151 }
152 let rest = &bytes[offset + 27..];
155 if rest[0] == b'[' {
156 return true;
157 }
158 if !rest[0].is_ascii_digit() {
160 return false;
161 }
162 let Some(pct_pos) = rest[..rest.len().min(7)].iter().position(|&b| b == b'%') else {
163 return false;
164 };
165 rest.len() > pct_pos + 2 && rest[pct_pos + 1] == b' ' && rest[pct_pos + 2] == b'['
166}
167
168fn parse_idle_pct(rest: &str) -> (Option<&str>, &str) {
177 let bytes = rest.as_bytes();
178 if bytes.is_empty() || !bytes[0].is_ascii_digit() {
179 return (None, rest);
180 }
181 let search_len = rest.len().min(7);
182 let pct_pos = match bytes[..search_len].iter().position(|&b| b == b'%') {
183 Some(p) => p,
184 None => return (None, rest),
185 };
186 if !bytes[..pct_pos]
187 .iter()
188 .all(|&b| b.is_ascii_digit() || b == b'.')
189 {
190 return (None, rest);
191 }
192 let idle_pct = &rest[0..=pct_pos];
193 let after = if rest.len() > pct_pos + 2 {
194 &rest[pct_pos + 2..]
195 } else {
196 ""
197 };
198 (Some(idle_pct), after)
199}
200
201fn parse_timestamped_fields(
202 s: &str,
203) -> (
204 Option<&str>,
205 Option<&str>,
206 Option<LogLevel>,
207 Option<&str>,
208 &str,
209) {
210 if s.len() < 27 {
211 return (None, None, None, None, s);
212 }
213 let timestamp = &s[0..26];
214 let rest = &s[27..];
215
216 let (idle_pct, rest) = parse_idle_pct(rest);
217
218 let bracket_end = match rest.find(']') {
219 Some(p) => p,
220 None => return (Some(timestamp), idle_pct, None, None, rest),
221 };
222 let level = LogLevel::from_bracketed(&rest[0..=bracket_end]);
223
224 if rest.len() < bracket_end + 3 {
225 return (Some(timestamp), idle_pct, level, None, "");
226 }
227 let rest = &rest[bracket_end + 2..];
228
229 let source_end = rest.find(' ').unwrap_or(rest.len());
230 let source = &rest[0..source_end];
231 let message = if source_end < rest.len() {
232 &rest[source_end + 1..]
233 } else {
234 ""
235 };
236
237 (Some(timestamp), idle_pct, level, Some(source), message)
238}
239
240pub fn parse_line(line: &str) -> RawLine<'_> {
246 if line.trim().is_empty() {
247 return RawLine {
248 uuid: None,
249 timestamp: None,
250 idle_pct: None,
251 level: None,
252 source: None,
253 message: line,
254 kind: LineKind::Empty,
255 };
256 }
257
258 let bytes = line.as_bytes();
259
260 if is_uuid_at(bytes, 0) {
261 let uuid = &line[0..UUID_LEN];
262 let after_uuid = &line[UUID_PREFIX_LEN..];
263
264 if is_date_at(bytes, UUID_PREFIX_LEN) {
265 let (timestamp, idle_pct, level, source, message) =
266 parse_timestamped_fields(after_uuid);
267 return RawLine {
268 uuid: Some(uuid),
269 timestamp,
270 idle_pct,
271 level,
272 source,
273 message,
274 kind: LineKind::Full,
275 };
276 }
277
278 return RawLine {
279 uuid: Some(uuid),
280 timestamp: None,
281 idle_pct: None,
282 level: None,
283 source: None,
284 message: after_uuid,
285 kind: LineKind::UuidContinuation,
286 };
287 }
288
289 if is_date_at(bytes, 0) {
290 let (timestamp, idle_pct, level, source, message) = parse_timestamped_fields(line);
291 let (uuid, message) = if is_uuid_at(message.as_bytes(), 0) {
292 (Some(&message[0..UUID_LEN]), &message[UUID_PREFIX_LEN..])
293 } else {
294 (None, message)
295 };
296 return RawLine {
297 uuid,
298 timestamp,
299 idle_pct,
300 level,
301 source,
302 message,
303 kind: LineKind::System,
304 };
305 }
306
307 if let Some(uuid_start) = find_uuid_in(bytes) {
308 let uuid = &line[uuid_start..uuid_start + UUID_LEN];
309 let message = if line.len() > uuid_start + UUID_PREFIX_LEN {
310 &line[uuid_start + UUID_PREFIX_LEN..]
311 } else {
312 ""
313 };
314 return RawLine {
315 uuid: Some(uuid),
316 timestamp: None,
317 idle_pct: None,
318 level: None,
319 source: None,
320 message,
321 kind: LineKind::Truncated,
322 };
323 }
324
325 RawLine {
326 uuid: None,
327 timestamp: None,
328 idle_pct: None,
329 level: None,
330 source: None,
331 message: line,
332 kind: LineKind::BareContinuation,
333 }
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339
340 const UUID1: &str = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
341
342 #[test]
345 fn full_line_all_fields() {
346 let line = format!(
347 "{UUID1} 2025-01-15 10:30:45.123456 95.97% [DEBUG] sofia.c:100 Test message here"
348 );
349 let parsed = parse_line(&line);
350 assert_eq!(parsed.kind, LineKind::Full);
351 assert_eq!(parsed.uuid, Some(UUID1));
352 assert_eq!(parsed.timestamp, Some("2025-01-15 10:30:45.123456"));
353 assert_eq!(parsed.idle_pct, Some("95.97%"));
354 assert_eq!(parsed.level, Some(LogLevel::Debug));
355 assert_eq!(parsed.source, Some("sofia.c:100"));
356 assert_eq!(parsed.message, "Test message here");
357 }
358
359 #[test]
360 fn full_line_each_level() {
361 for (name, expected) in [
362 ("DEBUG", LogLevel::Debug),
363 ("INFO", LogLevel::Info),
364 ("NOTICE", LogLevel::Notice),
365 ("WARNING", LogLevel::Warning),
366 ("ERR", LogLevel::Err),
367 ("CRIT", LogLevel::Crit),
368 ("ALERT", LogLevel::Alert),
369 ("CONSOLE", LogLevel::Console),
370 ] {
371 let line =
372 format!("{UUID1} 2025-01-15 10:30:45.123456 95.97% [{name}] sofia.c:100 Test");
373 let parsed = parse_line(&line);
374 assert_eq!(parsed.kind, LineKind::Full);
375 assert_eq!(parsed.level, Some(expected), "failed for [{name}]");
376 }
377 }
378
379 #[test]
380 fn full_line_high_idle() {
381 let line =
382 format!("{UUID1} 2025-01-15 10:30:45.123456 99.99% [DEBUG] sofia.c:100 High idle");
383 let parsed = parse_line(&line);
384 assert_eq!(parsed.idle_pct, Some("99.99%"));
385 }
386
387 #[test]
388 fn full_line_low_idle() {
389 let line = format!("{UUID1} 2025-01-15 10:30:45.123456 0.00% [DEBUG] sofia.c:100 Low idle");
390 let parsed = parse_line(&line);
391 assert_eq!(parsed.idle_pct, Some("0.00%"));
392 }
393
394 #[test]
395 fn full_line_long_message() {
396 let line = format!(
397 "{UUID1} 2025-01-15 10:30:45.123456 95.97% [DEBUG] sofia.c:100 Channel [sofia/internal] key=val:123 (test) {{braces}}"
398 );
399 let parsed = parse_line(&line);
400 assert_eq!(
401 parsed.message,
402 "Channel [sofia/internal] key=val:123 (test) {braces}"
403 );
404 }
405
406 #[test]
409 fn system_line_no_uuid() {
410 let line =
411 "2025-01-15 10:30:45.123456 95.97% [INFO] mod_event_socket.c:1772 Event Socket command";
412 let parsed = parse_line(line);
413 assert_eq!(parsed.kind, LineKind::System);
414 assert_eq!(parsed.uuid, None);
415 assert_eq!(parsed.timestamp, Some("2025-01-15 10:30:45.123456"));
416 assert_eq!(parsed.idle_pct, Some("95.97%"));
417 assert_eq!(parsed.level, Some(LogLevel::Info));
418 assert_eq!(parsed.source, Some("mod_event_socket.c:1772"));
419 assert_eq!(parsed.message, "Event Socket command");
420 }
421
422 #[test]
423 fn system_line_with_embedded_uuid() {
424 let line = format!(
425 "2025-01-15 10:30:45.123456 95.97% [DEBUG] switch_cpp.cpp:1466 {UUID1} DAA-LOG WaveManager PSAP 911 originate"
426 );
427 let parsed = parse_line(&line);
428 assert_eq!(parsed.kind, LineKind::System);
429 assert_eq!(parsed.uuid, Some(UUID1));
430 assert_eq!(parsed.timestamp, Some("2025-01-15 10:30:45.123456"));
431 assert_eq!(parsed.level, Some(LogLevel::Debug));
432 assert_eq!(parsed.source, Some("switch_cpp.cpp:1466"));
433 assert_eq!(parsed.message, "DAA-LOG WaveManager PSAP 911 originate");
434 }
435
436 #[test]
437 fn system_line_with_embedded_uuid_empty_message() {
438 let line = format!("2025-01-15 10:30:45.123456 95.97% [INFO] switch_cpp.cpp:1466 {UUID1} ");
439 let parsed = parse_line(&line);
440 assert_eq!(parsed.kind, LineKind::System);
441 assert_eq!(parsed.uuid, Some(UUID1));
442 assert_eq!(parsed.message, "");
443 }
444
445 #[test]
446 fn system_line_without_embedded_uuid() {
447 let line =
448 "2025-01-15 10:30:45.123456 95.97% [INFO] mod_event_socket.c:1772 Event Socket command";
449 let parsed = parse_line(line);
450 assert_eq!(parsed.kind, LineKind::System);
451 assert_eq!(parsed.uuid, None);
452 assert_eq!(parsed.message, "Event Socket command");
453 }
454
455 #[test]
456 fn system_line_event_socket() {
457 let line = "2025-01-15 10:30:45.123456 95.97% [NOTICE] mod_logfile.c:217 New log started.";
458 let parsed = parse_line(line);
459 assert_eq!(parsed.kind, LineKind::System);
460 assert_eq!(parsed.level, Some(LogLevel::Notice));
461 assert_eq!(parsed.message, "New log started.");
462 }
463
464 #[test]
467 fn uuid_continuation_dialplan() {
468 let line =
469 format!("{UUID1} Dialplan: sofia/internal/+15550001234@192.0.2.1 parsing [public]");
470 let parsed = parse_line(&line);
471 assert_eq!(parsed.kind, LineKind::UuidContinuation);
472 assert_eq!(parsed.uuid, Some(UUID1));
473 assert_eq!(parsed.timestamp, None);
474 assert_eq!(parsed.level, None);
475 assert_eq!(
476 parsed.message,
477 "Dialplan: sofia/internal/+15550001234@192.0.2.1 parsing [public]"
478 );
479 }
480
481 #[test]
482 fn uuid_continuation_execute() {
483 let line =
484 format!("{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(foo=bar)");
485 let parsed = parse_line(&line);
486 assert_eq!(parsed.kind, LineKind::UuidContinuation);
487 assert_eq!(parsed.uuid, Some(UUID1));
488 assert_eq!(
489 parsed.message,
490 "EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(foo=bar)"
491 );
492 }
493
494 #[test]
495 fn uuid_continuation_channel_var() {
496 let line = format!("{UUID1} Channel-State: [CS_EXECUTE]");
497 let parsed = parse_line(&line);
498 assert_eq!(parsed.kind, LineKind::UuidContinuation);
499 assert_eq!(parsed.uuid, Some(UUID1));
500 assert_eq!(parsed.message, "Channel-State: [CS_EXECUTE]");
501 }
502
503 #[test]
504 fn uuid_continuation_variable() {
505 let line = format!("{UUID1} variable_sip_call_id: [test123@192.0.2.1]");
506 let parsed = parse_line(&line);
507 assert_eq!(parsed.kind, LineKind::UuidContinuation);
508 assert_eq!(parsed.uuid, Some(UUID1));
509 assert_eq!(parsed.message, "variable_sip_call_id: [test123@192.0.2.1]");
510 }
511
512 #[test]
513 fn uuid_continuation_blank() {
514 let line = format!("{UUID1} ");
515 let parsed = parse_line(&line);
516 assert_eq!(parsed.kind, LineKind::UuidContinuation);
517 assert_eq!(parsed.uuid, Some(UUID1));
518 assert_eq!(parsed.message, "");
519 }
520
521 #[test]
524 fn bare_variable() {
525 let line = "variable_foo: [bar]";
526 let parsed = parse_line(line);
527 assert_eq!(parsed.kind, LineKind::BareContinuation);
528 assert_eq!(parsed.uuid, None);
529 assert_eq!(parsed.message, "variable_foo: [bar]");
530 }
531
532 #[test]
533 fn bare_sdp_origin() {
534 let line = "o=- 1234 5678 IN IP4 192.0.2.1";
535 let parsed = parse_line(line);
536 assert_eq!(parsed.kind, LineKind::BareContinuation);
537 assert_eq!(parsed.message, line);
538 }
539
540 #[test]
541 fn bare_sdp_media() {
542 let line = "m=audio 47758 RTP/AVP 0 101";
543 let parsed = parse_line(line);
544 assert_eq!(parsed.kind, LineKind::BareContinuation);
545 assert_eq!(parsed.message, line);
546 }
547
548 #[test]
549 fn bare_sdp_attribute() {
550 let line = "a=rtpmap:0 PCMU/8000";
551 let parsed = parse_line(line);
552 assert_eq!(parsed.kind, LineKind::BareContinuation);
553 assert_eq!(parsed.message, line);
554 }
555
556 #[test]
557 fn bare_closing_bracket() {
558 let line = "]";
559 let parsed = parse_line(line);
560 assert_eq!(parsed.kind, LineKind::BareContinuation);
561 assert_eq!(parsed.message, "]");
562 }
563
564 #[test]
565 fn bare_empty_line() {
566 let parsed = parse_line("");
567 assert_eq!(parsed.kind, LineKind::Empty);
568 assert_eq!(parsed.message, "");
569 }
570
571 #[test]
574 fn truncated_varia_prefix() {
575 let line = format!(
576 "varia{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(x=y)"
577 );
578 let parsed = parse_line(&line);
579 assert_eq!(parsed.kind, LineKind::Truncated);
580 assert_eq!(parsed.uuid, Some(UUID1));
581 assert_eq!(
582 parsed.message,
583 "EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(x=y)"
584 );
585 }
586
587 #[test]
588 fn truncated_variab_prefix() {
589 let line = format!(
590 "variab{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(x=y)"
591 );
592 let parsed = parse_line(&line);
593 assert_eq!(parsed.kind, LineKind::Truncated);
594 assert_eq!(parsed.uuid, Some(UUID1));
595 }
596
597 #[test]
598 fn truncated_var_prefix() {
599 let line =
600 format!("var{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(x=y)");
601 let parsed = parse_line(&line);
602 assert_eq!(parsed.kind, LineKind::Truncated);
603 assert_eq!(parsed.uuid, Some(UUID1));
604 }
605
606 #[test]
607 fn truncated_variable_prefix() {
608 let line = format!(
609 "variable{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(x=y)"
610 );
611 let parsed = parse_line(&line);
612 assert_eq!(parsed.kind, LineKind::Truncated);
613 assert_eq!(parsed.uuid, Some(UUID1));
614 }
615
616 #[test]
619 fn log_header_with_idle_pct() {
620 let line = "2024-04-02 10:31:28.785679 98.03% [NOTICE] sofia.c:1114 Hangup";
621 assert!(is_log_header_at(line.as_bytes(), 0));
622 }
623
624 #[test]
625 fn log_header_no_idle_pct() {
626 let line = "2024-04-02 10:31:28.785679 [NOTICE] sofia.c:1114 Hangup";
628 assert!(is_log_header_at(line.as_bytes(), 0));
629 }
630
631 #[test]
632 fn log_header_no_idle_pct_at_offset() {
633 let line = "Session does not exist, aborting REFER.2024-04-02 10:31:28.785679 [WARNING] sofia_presence.c:4546 x";
634 let offset = line.find("2024").unwrap();
635 assert!(is_log_header_at(line.as_bytes(), offset));
636 }
637
638 #[test]
639 fn log_header_rejects_non_header() {
640 let line = "2024-04-02 not a real timestamp here";
641 assert!(!is_log_header_at(line.as_bytes(), 0));
642 }
643
644 #[test]
647 fn full_line_no_idle_pct() {
648 let line = format!(
649 "{UUID1} 2025-01-15 10:30:45.123456 [NOTICE] switch_core_session.c:1744 Session 3178948 ended"
650 );
651 let parsed = parse_line(&line);
652 assert_eq!(parsed.kind, LineKind::Full);
653 assert_eq!(parsed.uuid, Some(UUID1));
654 assert_eq!(parsed.timestamp, Some("2025-01-15 10:30:45.123456"));
655 assert_eq!(parsed.idle_pct, None);
656 assert_eq!(parsed.level, Some(LogLevel::Notice));
657 assert_eq!(parsed.source, Some("switch_core_session.c:1744"));
658 assert_eq!(parsed.message, "Session 3178948 ended");
659 }
660
661 #[test]
662 fn full_line_no_idle_pct_url_encoded_percent() {
663 let line = format!(
664 "{UUID1} 2025-01-15 10:30:45.123456 [NOTICE] switch_core_session.c:1744 Session 3178948 (sofia/psap/gw%2Bsg1vofswb-inbound@198.51.100.5:5060) Ended"
665 );
666 let parsed = parse_line(&line);
667 assert_eq!(parsed.kind, LineKind::Full);
668 assert_eq!(parsed.uuid, Some(UUID1));
669 assert_eq!(parsed.timestamp, Some("2025-01-15 10:30:45.123456"));
670 assert_eq!(parsed.idle_pct, None);
671 assert_eq!(parsed.level, Some(LogLevel::Notice));
672 assert_eq!(parsed.source, Some("switch_core_session.c:1744"));
673 assert_eq!(
674 parsed.message,
675 "Session 3178948 (sofia/psap/gw%2Bsg1vofswb-inbound@198.51.100.5:5060) Ended"
676 );
677 }
678
679 #[test]
680 fn system_line_no_idle_pct() {
681 let line = "2025-01-15 10:30:45.123456 [INFO] mod_event_socket.c:1772 Event Socket command";
682 let parsed = parse_line(line);
683 assert_eq!(parsed.kind, LineKind::System);
684 assert_eq!(parsed.uuid, None);
685 assert_eq!(parsed.timestamp, Some("2025-01-15 10:30:45.123456"));
686 assert_eq!(parsed.idle_pct, None);
687 assert_eq!(parsed.level, Some(LogLevel::Info));
688 assert_eq!(parsed.source, Some("mod_event_socket.c:1772"));
689 assert_eq!(parsed.message, "Event Socket command");
690 }
691
692 #[test]
693 fn full_line_no_idle_pct_hangup_url_encoded() {
694 let line = format!(
695 "{UUID1} 2025-01-15 10:30:45.123456 [NOTICE] sofia.c:1089 Hangup sofia/psap/gw%2Bgateway@198.51.100.5:5060 [CS_EXCHANGE_MEDIA] [CALL_AWARDED_DELIVERED]"
696 );
697 let parsed = parse_line(&line);
698 assert_eq!(parsed.kind, LineKind::Full);
699 assert_eq!(parsed.idle_pct, None);
700 assert_eq!(parsed.level, Some(LogLevel::Notice));
701 assert_eq!(parsed.source, Some("sofia.c:1089"));
702 assert_eq!(
703 parsed.message,
704 "Hangup sofia/psap/gw%2Bgateway@198.51.100.5:5060 [CS_EXCHANGE_MEDIA] [CALL_AWARDED_DELIVERED]"
705 );
706 }
707
708 #[test]
711 fn not_uuid_36_chars() {
712 let line = "this-is-not-a-valid-uuid-value-12345 rest of line";
713 let parsed = parse_line(line);
714 assert_eq!(parsed.kind, LineKind::BareContinuation);
715 assert_eq!(parsed.message, line);
716 }
717
718 #[test]
719 fn uuid_in_message_not_prefix() {
720 let line =
721 format!("This is some log message body with extra context then {UUID1} appears here");
722 let parsed = parse_line(&line);
723 assert_eq!(parsed.kind, LineKind::BareContinuation);
724 assert_eq!(parsed.message, line.as_str());
725 }
726
727 #[test]
728 fn whitespace_only_is_empty() {
729 let parsed = parse_line(" \t ");
730 assert_eq!(parsed.kind, LineKind::Empty);
731 }
732}