use crate::error::MermaidError;
use crate::types::RenderOptions;
#[derive(Debug, Clone)]
pub struct TimelineEvent {
pub text: String,
}
#[derive(Debug, Clone)]
pub struct TimelinePeriod {
pub label: String,
pub events: Vec<TimelineEvent>,
}
#[derive(Debug, Clone)]
pub struct Timeline {
pub title: Option<String>,
pub periods: Vec<TimelinePeriod>,
}
pub fn parse_timeline(input: &str) -> Result<Timeline, MermaidError> {
let lines: Vec<&str> = input
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty() && !l.starts_with("%%"))
.collect();
if lines.is_empty() {
return Err(MermaidError::EmptyInput);
}
let first_line = lines[0].to_lowercase();
if !first_line.starts_with("timeline") {
return Err(MermaidError::ParseError {
line: 1,
message: "Expected 'timeline'".to_string(),
suggestion: Some("Start with 'timeline'".to_string()),
});
}
let mut timeline = Timeline {
title: None,
periods: Vec::new(),
};
let mut current_period: Option<TimelinePeriod> = None;
for line in lines.iter().skip(1) {
if line.to_lowercase().starts_with("title") {
let title_text = line
.strip_prefix("title")
.or_else(|| line.strip_prefix("Title"))
.unwrap_or(line);
timeline.title = Some(title_text.trim().to_string());
continue;
}
if line.to_lowercase().starts_with("section") {
if let Some(period) = current_period.take() {
timeline.periods.push(period);
}
let section_name = line
.strip_prefix("section")
.or_else(|| line.strip_prefix("Section"))
.unwrap_or(line)
.trim()
.to_string();
current_period = Some(TimelinePeriod {
label: section_name,
events: Vec::new(),
});
continue;
}
if let Some(colon_idx) = line.find(':') {
let before_colon = line[..colon_idx].trim();
let after_colon = line[colon_idx + 1..].trim();
if before_colon.is_empty() {
if let Some(ref mut period) = current_period {
if !after_colon.is_empty() {
period.events.push(TimelineEvent {
text: after_colon.to_string(),
});
}
}
} else {
if let Some(period) = current_period.take() {
timeline.periods.push(period);
}
let mut events = Vec::new();
if !after_colon.is_empty() {
events.push(TimelineEvent {
text: after_colon.to_string(),
});
}
current_period = Some(TimelinePeriod {
label: before_colon.to_string(),
events,
});
}
}
}
if let Some(period) = current_period {
timeline.periods.push(period);
}
if timeline.periods.is_empty() {
return Err(MermaidError::ParseError {
line: 1,
message: "No timeline content found".to_string(),
suggestion: Some("Add periods like '2024 : Event description'".to_string()),
});
}
Ok(timeline)
}
pub fn render_timeline(timeline: &Timeline, options: &RenderOptions) -> String {
let mut output = String::new();
let (h_line, v_line, bullet) = if options.ascii {
('-', '|', '*')
} else {
('─', '│', '●')
};
if let Some(ref title) = timeline.title {
output.push_str(" ");
output.push_str(title);
output.push('\n');
output.push_str(" ");
output.push_str(&std::iter::repeat_n(h_line, title.len()).collect::<String>());
output.push_str("\n\n");
}
let max_period_width = timeline
.periods
.iter()
.map(|p| p.label.len())
.max()
.unwrap_or(4);
let timeline_width = 60.min(
timeline
.periods
.iter()
.flat_map(|p| p.events.iter())
.map(|e| e.text.len())
.max()
.unwrap_or(20)
+ max_period_width
+ 10,
);
output.push_str(&" ".repeat(max_period_width + 2));
output.push_str(&std::iter::repeat_n(h_line, timeline_width).collect::<String>());
output.push('\n');
for period in &timeline.periods {
output.push_str(&format!(
"{:>width$} ",
period.label,
width = max_period_width
));
output.push(bullet);
if let Some(first_event) = period.events.first() {
output.push_str(&format!("{}{} {}", h_line, h_line, first_event.text));
}
output.push('\n');
for event in period.events.iter().skip(1) {
output.push_str(&" ".repeat(max_period_width + 1));
output.push(v_line);
output.push_str(&format!(" {} {}", bullet, event.text));
output.push('\n');
}
if !period.events.is_empty() {
output.push_str(&" ".repeat(max_period_width + 1));
output.push(v_line);
output.push('\n');
}
}
output.push_str(&" ".repeat(max_period_width + 2));
output.push_str(&std::iter::repeat_n(h_line, timeline_width).collect::<String>());
output.push('\n');
output
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_timeline_simple() {
let input = r#"timeline
2024 : Event one
2025 : Event two
"#;
let timeline = parse_timeline(input).unwrap();
assert_eq!(timeline.periods.len(), 2);
assert_eq!(timeline.periods[0].label, "2024");
assert_eq!(timeline.periods[0].events[0].text, "Event one");
}
#[test]
fn test_parse_timeline_with_title() {
let input = r#"timeline
title My Timeline
2024 : Event one
"#;
let timeline = parse_timeline(input).unwrap();
assert_eq!(timeline.title, Some("My Timeline".to_string()));
}
#[test]
fn test_parse_timeline_multiple_events() {
let input = r#"timeline
2024 : First event
: Second event
: Third event
"#;
let timeline = parse_timeline(input).unwrap();
assert_eq!(timeline.periods.len(), 1);
assert_eq!(timeline.periods[0].events.len(), 3);
}
#[test]
fn test_parse_timeline_full_example() {
let input = r#"timeline
title Pied Piper Journey (2014-2019)
2014 : DEC-001 Peter Gregory Seed Funding
: DEC-002 Anti-Hooli Principle
: ADR-001 Middle-Out Algorithm
: INC-001 Hooli IP Lawsuit
2015 : DEC-004 Hanneman Bridge Funding
: ADR-002 Enterprise Platform
: ADR-003 The Box
2016 : INC-002 COPPA Violation
2018 : POL-001 Tethics Framework
: ADR-004 PiperNet Architecture
: INC-003 51% Attack
2019 : INC-004 AI Encryption Discovery
: DEC-005 Sabotage Launch
"#;
let timeline = parse_timeline(input).unwrap();
assert_eq!(timeline.title, Some("Pied Piper Journey (2014-2019)".to_string()));
assert_eq!(timeline.periods.len(), 5);
assert_eq!(timeline.periods[0].label, "2014");
assert_eq!(timeline.periods[0].events.len(), 4);
assert_eq!(timeline.periods[4].label, "2019");
assert_eq!(timeline.periods[4].events.len(), 2);
}
#[test]
fn test_render_timeline() {
let timeline = Timeline {
title: Some("Test".to_string()),
periods: vec![
TimelinePeriod {
label: "2024".to_string(),
events: vec![
TimelineEvent { text: "Event A".to_string() },
TimelineEvent { text: "Event B".to_string() },
],
},
TimelinePeriod {
label: "2025".to_string(),
events: vec![
TimelineEvent { text: "Event C".to_string() },
],
},
],
};
let output = render_timeline(&timeline, &RenderOptions::default());
assert!(output.contains("Test"));
assert!(output.contains("2024"));
assert!(output.contains("Event A"));
assert!(output.contains("2025"));
}
}