tui-lipan 0.1.0

Opinionated, component-based TUI framework for Rust - declarative components, reconciliation, layout engine, focus, overlays, and rich widgets on top of ratatui.
Documentation
#![allow(missing_docs)]

use std::collections::{HashMap, HashSet};
use std::fmt;
use std::str::FromStr;
use std::sync::Arc;

use super::{GanttDate, GanttSpec, GanttTask, GanttTaskStart};

#[derive(Clone, Debug, PartialEq, Eq)]
pub enum GanttError {
    InvalidDate(String),
    MissingDependency {
        task: Arc<str>,
        dependency: Arc<str>,
    },
    DuplicateTaskId(Arc<str>),
    CyclicDependency(Vec<Arc<str>>),
    UnresolvedStart(Arc<str>),
}

impl fmt::Display for GanttError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::InvalidDate(date) => write!(f, "invalid Gantt date: {date}"),
            Self::MissingDependency { task, dependency } => {
                write!(f, "task '{task}' depends on missing task id '{dependency}'")
            }
            Self::DuplicateTaskId(id) => write!(f, "duplicate Gantt task id '{id}'"),
            Self::CyclicDependency(path) => {
                let path = path
                    .iter()
                    .map(|id| id.as_ref())
                    .collect::<Vec<_>>()
                    .join(" -> ");
                write!(f, "cyclic Gantt dependency: {path}")
            }
            Self::UnresolvedStart(task) => {
                write!(f, "task '{task}' must define start_date(...) or after(...)")
            }
        }
    }
}

impl std::error::Error for GanttError {}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResolvedGanttTask {
    pub task: GanttTask,
    pub start: GanttDate,
    pub end: GanttDate,
    pub start_day: i64,
    pub end_day_exclusive: i64,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResolvedGanttSection {
    pub title: Arc<str>,
    pub tasks: Vec<ResolvedGanttTask>,
}

impl GanttDate {
    pub fn parse_ymd(input: &str) -> Result<Self, GanttError> {
        input.parse()
    }

    pub fn add_days(self, days: i64) -> Self {
        Self::from_day_number(self.day_number() + days)
    }

    pub fn day_number(self) -> i64 {
        days_from_civil(self.year, self.month, self.day)
    }

    pub fn from_day_number(days: i64) -> Self {
        let (year, month, day) = civil_from_days(days);
        Self { year, month, day }
    }
}

impl FromStr for GanttDate {
    type Err = GanttError;

    fn from_str(input: &str) -> Result<Self, Self::Err> {
        let mut parts = input.split('-');
        let year = parts
            .next()
            .and_then(|p| p.parse::<i32>().ok())
            .ok_or_else(|| GanttError::InvalidDate(input.to_owned()))?;
        let month = parts
            .next()
            .and_then(|p| p.parse::<u8>().ok())
            .ok_or_else(|| GanttError::InvalidDate(input.to_owned()))?;
        let day = parts
            .next()
            .and_then(|p| p.parse::<u8>().ok())
            .ok_or_else(|| GanttError::InvalidDate(input.to_owned()))?;
        if parts.next().is_some() || !is_valid_date(year, month, day) {
            return Err(GanttError::InvalidDate(input.to_owned()));
        }
        Ok(Self { year, month, day })
    }
}

impl From<&str> for GanttDate {
    fn from(value: &str) -> Self {
        value
            .parse()
            .unwrap_or_else(|_| panic!("invalid Gantt date literal '{value}'"))
    }
}

pub fn resolve_gantt_spec(spec: &GanttSpec) -> Result<Vec<ResolvedGanttSection>, GanttError> {
    let mut id_to_task = HashMap::<Arc<str>, &GanttTask>::new();
    for section in &spec.sections {
        for task in &section.tasks {
            if let Some(id) = &task.id
                && id_to_task.insert(id.clone(), task).is_some()
            {
                return Err(GanttError::DuplicateTaskId(id.clone()));
            }
        }
    }

    for section in &spec.sections {
        for task in &section.tasks {
            if let Some(GanttTaskStart::After(dependency)) = &task.start
                && !id_to_task.contains_key(dependency)
            {
                return Err(GanttError::MissingDependency {
                    task: task_name(task),
                    dependency: dependency.clone(),
                });
            }
        }
    }

    let mut resolved = HashMap::<Arc<str>, (GanttDate, GanttDate, i64, i64)>::new();
    let mut visiting = HashSet::<Arc<str>>::new();
    let mut stack = Vec::<Arc<str>>::new();
    for id in id_to_task.keys() {
        resolve_task(id, &id_to_task, &mut resolved, &mut visiting, &mut stack)?;
    }

    spec.sections
        .iter()
        .map(|section| {
            let tasks = section
                .tasks
                .iter()
                .map(|task| {
                    let range = match &task.id {
                        Some(id) => resolved[id],
                        None => resolve_anonymous_task(task, &resolved)?,
                    };
                    Ok(ResolvedGanttTask {
                        task: task.clone(),
                        start: range.0,
                        end: range.1,
                        start_day: range.2,
                        end_day_exclusive: range.3,
                    })
                })
                .collect::<Result<Vec<_>, GanttError>>()?;
            Ok(ResolvedGanttSection {
                title: section.title.clone(),
                tasks,
            })
        })
        .collect()
}

fn resolve_task(
    id: &Arc<str>,
    id_to_task: &HashMap<Arc<str>, &GanttTask>,
    resolved: &mut HashMap<Arc<str>, (GanttDate, GanttDate, i64, i64)>,
    visiting: &mut HashSet<Arc<str>>,
    stack: &mut Vec<Arc<str>>,
) -> Result<(GanttDate, GanttDate, i64, i64), GanttError> {
    if let Some(range) = resolved.get(id) {
        return Ok(*range);
    }
    if !visiting.insert(id.clone()) {
        let cycle_start = stack.iter().position(|seen| seen == id).unwrap_or(0);
        let mut path = stack[cycle_start..].to_vec();
        path.push(id.clone());
        return Err(GanttError::CyclicDependency(path));
    }
    stack.push(id.clone());

    let task = id_to_task[id];
    let start = match &task.start {
        Some(GanttTaskStart::Date(date)) => *date,
        Some(GanttTaskStart::After(dependency)) => {
            let (_, _, _, predecessor_end) =
                resolve_task(dependency, id_to_task, resolved, visiting, stack)?;
            GanttDate::from_day_number(predecessor_end)
        }
        None => return Err(GanttError::UnresolvedStart(id.clone())),
    };
    let range = task_range(task, start);
    resolved.insert(id.clone(), range);
    visiting.remove(id);
    stack.pop();
    Ok(range)
}

fn resolve_anonymous_task(
    task: &GanttTask,
    resolved: &HashMap<Arc<str>, (GanttDate, GanttDate, i64, i64)>,
) -> Result<(GanttDate, GanttDate, i64, i64), GanttError> {
    match task.start {
        Some(GanttTaskStart::Date(date)) => Ok(task_range(task, date)),
        Some(GanttTaskStart::After(ref dependency)) => {
            let (_, _, _, predecessor_end) =
                resolved
                    .get(dependency)
                    .ok_or_else(|| GanttError::MissingDependency {
                        task: task_name(task),
                        dependency: dependency.clone(),
                    })?;
            Ok(task_range(
                task,
                GanttDate::from_day_number(*predecessor_end),
            ))
        }
        None => Err(GanttError::UnresolvedStart(task_name(task))),
    }
}

fn task_range(task: &GanttTask, start: GanttDate) -> (GanttDate, GanttDate, i64, i64) {
    let start_day = start.day_number();
    let span = if task.milestone {
        1
    } else {
        i64::from(task.duration.days.max(1))
    };
    let end_day_exclusive = start_day + span;
    let end = GanttDate::from_day_number(end_day_exclusive.saturating_sub(1));
    (start, end, start_day, end_day_exclusive)
}

fn task_name(task: &GanttTask) -> Arc<str> {
    task.id.clone().unwrap_or_else(|| task.label.clone())
}

fn is_valid_date(year: i32, month: u8, day: u8) -> bool {
    (1..=12).contains(&month) && (1..=days_in_month(year, month)).contains(&day)
}

fn days_in_month(year: i32, month: u8) -> u8 {
    match month {
        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
        4 | 6 | 9 | 11 => 30,
        2 if is_leap_year(year) => 29,
        2 => 28,
        _ => 0,
    }
}

fn is_leap_year(year: i32) -> bool {
    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}

fn days_from_civil(year: i32, month: u8, day: u8) -> i64 {
    let year = i64::from(year) - i64::from(month <= 2);
    let era = if year >= 0 { year } else { year - 399 } / 400;
    let yoe = year - era * 400;
    let month = i64::from(month);
    let doy = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + i64::from(day) - 1;
    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
    era * 146_097 + doe - 719_468
}

fn civil_from_days(days: i64) -> (i32, u8, u8) {
    let days = days + 719_468;
    let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
    let doe = days - era * 146_097;
    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
    let mut year = yoe + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let day = doy - (153 * mp + 2) / 5 + 1;
    let month = mp + if mp < 10 { 3 } else { -9 };
    year += i64::from(month <= 2);
    (year as i32, month as u8, day as u8)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::widgets::gantt_diagram::{GanttSection, GanttTask};

    #[test]
    fn date_roundtrip_formats_ymd() {
        let date = GanttDate::parse_ymd("2026-05-20").unwrap();
        assert_eq!(date.to_string(), "2026-05-20");
        assert_eq!(GanttDate::from_day_number(date.day_number()), date);
    }

    #[test]
    fn add_days_crosses_leap_and_month_boundaries() {
        assert_eq!(
            GanttDate::parse_ymd("2024-02-28").unwrap().add_days(1),
            GanttDate::parse_ymd("2024-02-29").unwrap()
        );
        assert_eq!(
            GanttDate::parse_ymd("2024-02-29").unwrap().add_days(1),
            GanttDate::parse_ymd("2024-03-01").unwrap()
        );
    }

    #[test]
    fn resolves_after_chain_sample() {
        let spec = GanttSpec::new().section(
            GanttSection::new("Build")
                .task(
                    GanttTask::new("Design")
                        .id("design")
                        .start_date("2026-01-01")
                        .duration_days(3),
                )
                .task(
                    GanttTask::new("Implement")
                        .id("impl")
                        .after("design")
                        .duration_days(2),
                ),
        );
        let resolved = resolve_gantt_spec(&spec).unwrap();
        assert_eq!(resolved[0].tasks[1].start.to_string(), "2026-01-04");
        assert_eq!(resolved[0].tasks[1].end.to_string(), "2026-01-05");
    }

    #[test]
    fn reports_missing_dependency() {
        let spec = GanttSpec::new()
            .section(GanttSection::new("Build").task(GanttTask::new("Implement").after("missing")));
        assert!(matches!(
            resolve_gantt_spec(&spec),
            Err(GanttError::MissingDependency { .. })
        ));
    }

    #[test]
    fn reports_cycle() {
        let spec = GanttSpec::new().section(
            GanttSection::new("Build")
                .task(GanttTask::new("A").id("a").after("b"))
                .task(GanttTask::new("B").id("b").after("a")),
        );
        assert!(matches!(
            resolve_gantt_spec(&spec),
            Err(GanttError::CyclicDependency(_))
        ));
    }
}