graphs-tui 0.2.0

Terminal renderer for Mermaid and D2 diagrams - flowcharts, state diagrams, pie charts in Unicode/ASCII
Documentation
//! Timeline diagram parser and renderer for Mermaid syntax
//!
//! Supports mermaid timeline diagram syntax

use crate::error::MermaidError;
use crate::types::RenderOptions;

/// An event in a timeline period
#[derive(Debug, Clone)]
pub struct TimelineEvent {
    pub text: String,
}

/// A period in the timeline (e.g., a year)
#[derive(Debug, Clone)]
pub struct TimelinePeriod {
    pub label: String,
    pub events: Vec<TimelineEvent>,
}

/// Timeline diagram data
#[derive(Debug, Clone)]
pub struct Timeline {
    pub title: Option<String>,
    pub periods: Vec<TimelinePeriod>,
}

/// Parse timeline diagram syntax
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);
    }

    // Validate header
    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) {
        // Parse title
        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;
        }

        // Parse section (alternative syntax)
        if line.to_lowercase().starts_with("section") {
            // Save current period if exists
            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;
        }

        // Parse event line: "PERIOD : Event" or ": Event" (continuation)
        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() {
                // Continuation event for current period
                if let Some(ref mut period) = current_period {
                    if !after_colon.is_empty() {
                        period.events.push(TimelineEvent {
                            text: after_colon.to_string(),
                        });
                    }
                }
            } else {
                // New period with first event
                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,
                });
            }
        }
    }

    // Push final period
    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)
}

/// Render timeline diagram to ASCII representation
pub fn render_timeline(timeline: &Timeline, options: &RenderOptions) -> String {
    let mut output = String::new();

    // Character set
    let (h_line, v_line, bullet) = if options.ascii {
        ('-', '|', '*')
    } else {
        ('', '', '')
    };

    // Title
    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");
    }

    // Find max period label width for alignment
    let max_period_width = timeline
        .periods
        .iter()
        .map(|p| p.label.len())
        .max()
        .unwrap_or(4);

    // Calculate total timeline width
    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,
    );

    // Draw horizontal timeline axis
    output.push_str(&" ".repeat(max_period_width + 2));
    output.push_str(&std::iter::repeat_n(h_line, timeline_width).collect::<String>());
    output.push('\n');

    // Draw each period
    for period in &timeline.periods {
        // Period label with marker
        output.push_str(&format!(
            "{:>width$} ",
            period.label,
            width = max_period_width
        ));
        output.push(bullet);

        // First event on same line (if exists)
        if let Some(first_event) = period.events.first() {
            output.push_str(&format!("{}{} {}", h_line, h_line, first_event.text));
        }
        output.push('\n');

        // Additional events
        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');
        }

        // Spacing between periods
        if !period.events.is_empty() {
            output.push_str(&" ".repeat(max_period_width + 1));
            output.push(v_line);
            output.push('\n');
        }
    }

    // Bottom of timeline
    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() {
        // Issue #3 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"));
    }
}