#[derive(Debug, Clone, Default)]
pub struct TimelineDiagram {
pub title: Option<String>,
pub direction: String,
pub sections: Vec<String>,
pub tasks: Vec<TimelineTask>,
}
#[derive(Debug, Clone)]
pub struct TimelineTask {
pub id: usize,
pub section: String,
pub task: String,
pub events: Vec<String>,
}
pub fn parse(input: &str) -> crate::error::ParseResult<TimelineDiagram> {
let mut diag = TimelineDiagram {
direction: "LR".to_string(),
..Default::default()
};
let mut current_section = String::new();
let mut current_task_id: usize = 0;
let mut raw_tasks: Vec<TimelineTask> = Vec::new();
let mut section_seen: Vec<String> = Vec::new();
let mut header_seen = false;
for line in input.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with("%%") || trimmed.starts_with('#') {
continue;
}
if !header_seen {
let lower = trimmed.to_lowercase();
if lower == "timeline" {
header_seen = true;
continue;
} else if lower.starts_with("timeline") {
let rest = trimmed["timeline".len()..].trim();
if rest.eq_ignore_ascii_case("LR") {
diag.direction = "LR".to_string();
} else if rest.eq_ignore_ascii_case("TD") {
diag.direction = "TD".to_string();
}
header_seen = true;
continue;
}
continue;
}
if trimmed.len() > 5 && trimmed[..5].eq_ignore_ascii_case("title") {
let ch = trimmed.as_bytes()[5];
if ch == b' ' || ch == b'\t' {
let title_text = trimmed[5..].trim();
if !title_text.is_empty() {
diag.title = Some(title_text.to_string());
}
continue;
}
}
if trimmed.len() > 7 && trimmed[..7].eq_ignore_ascii_case("section") {
let ch = trimmed.as_bytes()[7];
if ch == b' ' || ch == b'\t' {
let section_text = trimmed[7..].trim().to_string();
if !section_seen.contains(§ion_text) {
section_seen.push(section_text.clone());
}
current_section = section_text;
continue;
}
}
if let Some(rest) = trimmed.strip_prefix(':') {
if rest.starts_with(' ') || rest.starts_with('\t') {
let event_text = rest.trim().to_string();
if !event_text.is_empty() {
if let Some(task) = raw_tasks
.iter_mut()
.rev()
.find(|t| t.id + 1 == current_task_id)
{
task.events.push(event_text);
}
}
continue;
}
}
if let Some(colon_pos) = trimmed.find(':') {
let period_part = trimmed[..colon_pos].trim();
let after_colon = &trimmed[colon_pos + 1..];
if !period_part.is_empty() && !period_part.contains('#') {
ensure_section_tracked(¤t_section, &mut section_seen);
raw_tasks.push(TimelineTask {
id: current_task_id,
section: current_section.clone(),
task: period_part.to_string(),
events: Vec::new(),
});
current_task_id += 1;
let event_text = after_colon.trim();
if !event_text.is_empty() {
if let Some(task) = raw_tasks
.iter_mut()
.rev()
.find(|t| t.id + 1 == current_task_id)
{
task.events.push(event_text.to_string());
}
}
}
} else {
let period_part = trimmed;
if !period_part.is_empty() && !period_part.contains('#') {
ensure_section_tracked(¤t_section, &mut section_seen);
raw_tasks.push(TimelineTask {
id: current_task_id,
section: current_section.clone(),
task: period_part.to_string(),
events: Vec::new(),
});
current_task_id += 1;
}
}
}
diag.sections = section_seen;
diag.tasks = raw_tasks;
crate::error::ParseResult::ok(diag)
}
fn ensure_section_tracked(current_section: &str, section_seen: &mut Vec<String>) {
let _ = current_section;
let _ = section_seen;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn basic_no_sections() {
let input = "timeline\n title History\n 2002 : LinkedIn\n 2004 : Facebook\n : Google";
let d = parse(input).diagram;
assert_eq!(d.title.as_deref(), Some("History"));
assert_eq!(d.tasks.len(), 2);
assert_eq!(d.tasks[0].task, "2002");
assert_eq!(d.tasks[0].events, vec!["LinkedIn"]);
assert_eq!(d.tasks[1].task, "2004");
assert_eq!(d.tasks[1].events, vec!["Facebook", "Google"]);
assert!(d.sections.is_empty());
}
#[test]
fn with_sections() {
let input = concat!(
"timeline\n",
" title Social Media\n",
" section Early\n",
" 2002 : LinkedIn\n",
" section Later\n",
" 2004 : Facebook\n",
);
let d = parse(input).diagram;
assert_eq!(d.sections, vec!["Early", "Later"]);
assert_eq!(d.tasks[0].section, "Early");
assert_eq!(d.tasks[1].section, "Later");
}
#[test]
fn multiple_events() {
let input = "timeline\n 2004 : Facebook\n : Google";
let d = parse(input).diagram;
assert_eq!(d.tasks[0].events.len(), 2);
assert_eq!(d.tasks[0].events[0], "Facebook");
assert_eq!(d.tasks[0].events[1], "Google");
}
#[test]
fn full_example() {
let input = concat!(
"timeline\n",
" title History of Social Media Platform\n",
" 2002 : LinkedIn\n",
" 2004 : Facebook\n",
" : Google\n",
" 2005 : YouTube\n",
" 2006 : Twitter\n",
" section ICT and Internet\n",
" 1978 : first commercial social network\n",
" 1994 : GeoCities\n",
);
let d = parse(input).diagram;
assert_eq!(d.title.as_deref(), Some("History of Social Media Platform"));
assert_eq!(d.tasks[0].task, "2002");
assert_eq!(d.tasks[1].task, "2004");
assert_eq!(d.tasks[2].task, "2005");
assert_eq!(d.tasks[3].task, "2006");
assert_eq!(d.tasks[4].task, "1978");
assert_eq!(d.tasks[4].section, "ICT and Internet");
assert_eq!(d.tasks[5].task, "1994");
assert_eq!(d.sections, vec!["ICT and Internet"]);
}
#[test]
fn no_title() {
let input = "timeline\n 2002 : LinkedIn\n";
let d = parse(input).diagram;
assert_eq!(d.title, None);
assert_eq!(d.tasks.len(), 1);
}
}