use crate::Error;
use crate::parser::common::strip_inline_comment;
use crate::timeline::{Timeline, TimelineEntry, TimelineSection};
pub fn parse(src: &str) -> Result<Timeline, Error> {
let mut diag = Timeline::default();
let mut header_seen = false;
let mut current_section: Option<usize> = None;
for raw in src.lines() {
let line = strip_inline_comment(raw).trim();
if line.is_empty() {
continue;
}
if !header_seen {
if !line.eq_ignore_ascii_case("timeline") {
return Err(Error::ParseError(format!(
"expected `timeline` header, got {line:?}"
)));
}
header_seen = true;
continue;
}
if line.starts_with("accTitle") || line.starts_with("accDescr") {
continue;
}
if let Some(rest) = strip_keyword_prefix(line, "title") {
diag.title = Some(rest.to_string());
continue;
}
if let Some(rest) = strip_keyword_prefix(line, "section") {
diag.sections.push(TimelineSection {
name: Some(rest.to_string()),
entries: Vec::new(),
});
current_section = Some(diag.sections.len() - 1);
continue;
}
let Some(colon_pos) = line.find(':') else {
continue;
};
let period = line[..colon_pos].trim().to_string();
if period.is_empty() {
continue;
}
let events: Vec<String> = line[colon_pos + 1..]
.split(':')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect();
let entry = TimelineEntry { period, events };
let idx = match current_section {
Some(i) => i,
None => {
diag.sections.push(TimelineSection {
name: None,
entries: Vec::new(),
});
let i = diag.sections.len() - 1;
current_section = Some(i);
i
}
};
diag.sections[idx].entries.push(entry);
}
if !header_seen {
return Err(Error::ParseError(
"missing `timeline` header line".to_string(),
));
}
Ok(diag)
}
fn strip_keyword_prefix<'a>(line: &'a str, keyword: &str) -> Option<&'a str> {
let klen = keyword.len();
if line.len() > klen
&& line[..klen].eq_ignore_ascii_case(keyword)
&& line.as_bytes()[klen].is_ascii_whitespace()
{
Some(line[klen..].trim())
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_minimal_timeline() {
let diag = parse("timeline\n2002 : LinkedIn").unwrap();
assert_eq!(diag.title, None);
assert_eq!(diag.sections.len(), 1);
assert_eq!(diag.sections[0].name, None); assert_eq!(diag.sections[0].entries.len(), 1);
let entry = &diag.sections[0].entries[0];
assert_eq!(entry.period, "2002");
assert_eq!(entry.events, vec!["LinkedIn"]);
}
#[test]
fn parses_title() {
let diag = parse(
"timeline\n\
title History of Social Media\n\
2002 : LinkedIn",
)
.unwrap();
assert_eq!(diag.title.as_deref(), Some("History of Social Media"));
assert_eq!(diag.sections[0].entries[0].period, "2002");
}
#[test]
fn parses_section_grouping() {
let diag = parse(
"timeline\n\
section 2002-2004\n\
2002 : LinkedIn\n\
2003 : MySpace launched\n\
section 2005-2008\n\
2005 : YouTube",
)
.unwrap();
assert_eq!(diag.sections.len(), 2);
assert_eq!(diag.sections[0].name.as_deref(), Some("2002-2004"));
assert_eq!(diag.sections[0].entries.len(), 2);
assert_eq!(diag.sections[1].name.as_deref(), Some("2005-2008"));
assert_eq!(diag.sections[1].entries.len(), 1);
}
#[test]
fn parses_multiple_events_per_period() {
let diag = parse("timeline\n2004 : Facebook : Google goes public").unwrap();
let entry = &diag.sections[0].entries[0];
assert_eq!(entry.period, "2004");
assert_eq!(entry.events, vec!["Facebook", "Google goes public"]);
}
#[test]
fn events_before_first_section_land_in_implicit_unnamed_section() {
let diag = parse(
"timeline\n\
2001 : Wikipedia\n\
section 2002-2004\n\
2002 : LinkedIn",
)
.unwrap();
assert_eq!(diag.sections.len(), 2);
assert_eq!(diag.sections[0].name, None);
assert_eq!(diag.sections[0].entries[0].period, "2001");
assert_eq!(diag.sections[1].name.as_deref(), Some("2002-2004"));
assert_eq!(diag.sections[1].entries[0].period, "2002");
}
#[test]
fn comment_lines_are_stripped() {
let diag = parse(
"%% leading comment\n\
timeline\n\
%% mid comment\n\
2002 : LinkedIn %% inline comment",
)
.unwrap();
assert_eq!(diag.sections[0].entries[0].events[0], "LinkedIn");
}
#[test]
fn blank_lines_are_ignored() {
let diag = parse(
"timeline\n\
\n\
2002 : LinkedIn\n\
\n\
2003 : MySpace",
)
.unwrap();
assert_eq!(diag.sections[0].entries.len(), 2);
}
#[test]
fn missing_timeline_header_returns_error() {
let err = parse("section 2002\n2002 : LinkedIn").unwrap_err();
assert!(
err.to_string().contains("timeline"),
"unexpected error: {err}"
);
}
#[test]
fn accessibility_metadata_is_silently_ignored() {
let diag = parse(
"timeline\n\
accTitle: My accessible title\n\
accDescr: Long description\n\
2002 : LinkedIn",
)
.unwrap();
assert_eq!(diag.title, None);
assert_eq!(diag.sections[0].entries.len(), 1);
}
}