use unicode_width::UnicodeWidthStr;
use crate::gantt::{GanttDiagram, GanttTask};
const FULL_BLOCK: char = '\u{2588}'; const LIGHT_SHADE: char = '\u{2591}';
const NAME_COL_MIN: usize = 18;
const BAR_ZONE_MIN: usize = 20;
const BAR_ZONE_UNCONSTRAINED_CAP: usize = 60;
pub fn render(diag: &GanttDiagram, max_width: Option<usize>) -> String {
let (min_date, max_date) = match (diag.min_date(), diag.max_date()) {
(Some(lo), Some(hi)) => (lo, hi),
_ => return render_empty(diag),
};
let span_days = diag.span_days().max(1);
let name_col = diag
.sections
.iter()
.flat_map(|s| s.tasks.iter())
.map(|t| t.name.len() + 2) .max()
.unwrap_or(NAME_COL_MIN)
.max(NAME_COL_MIN);
let annot_col = 24usize;
let bar_zone = compute_bar_zone(max_width, name_col, annot_col, span_days);
let mut out = String::new();
let lo_str = min_date.format("%Y-%m-%d").to_string();
let hi_str = max_date.format("%Y-%m-%d").to_string();
if let Some(title) = &diag.title {
out.push_str(&format!(
"Gantt: {title} ({lo_str} \u{2192} {hi_str}, {span_days} days)\n"
));
} else {
out.push_str(&format!(
"Gantt ({lo_str} \u{2192} {hi_str}, {span_days} days)\n"
));
}
out.push('\n');
let axis_line = build_axis_line(
&diag.axis_format,
min_date,
span_days,
bar_zone,
name_col,
annot_col,
);
out.push_str(&axis_line);
out.push('\n');
for section in &diag.sections {
out.push('\n');
if let Some(name) = §ion.name {
out.push_str(name);
out.push('\n');
}
for task in §ion.tasks {
let row = render_task_row(
task,
min_date,
span_days,
bar_zone,
name_col,
&diag.axis_format,
);
out.push_str(&row);
out.push('\n');
}
}
if out.ends_with('\n') {
out.pop();
}
out
}
fn compute_bar_zone(
max_width: Option<usize>,
name_col: usize,
annot_col: usize,
span_days: i64,
) -> usize {
match max_width {
Some(budget) => {
let overhead = name_col + 2 + annot_col;
budget.saturating_sub(overhead).max(BAR_ZONE_MIN)
}
None => (span_days as usize).min(BAR_ZONE_UNCONSTRAINED_CAP),
}
}
fn render_task_row(
task: &GanttTask,
min_date: chrono::NaiveDate,
span_days: i64,
bar_zone: usize,
name_col: usize,
axis_format: &str,
) -> String {
let display_name = format!(" {}", task.name);
let col_out = pad_or_truncate(&display_name, name_col);
let bar = build_bar(task, min_date, span_days, bar_zone);
let start_str = format_date_axis(&task.start, axis_format);
let end_str = format_date_axis(&task.end, axis_format);
let dur = task.duration_days();
let annot = format!(" [{start_str} \u{2192} {end_str}, {dur}d]");
format!("{col_out}{bar}{annot}")
}
fn build_bar(
task: &GanttTask,
min_date: chrono::NaiveDate,
span_days: i64,
bar_zone: usize,
) -> String {
let task_start_offset = (task.start - min_date).num_days();
let task_end_offset = (task.end - min_date).num_days();
let mut bar = String::with_capacity(bar_zone * 3); for cell in 0..bar_zone {
let day_lo = (cell as f64 * span_days as f64) / bar_zone as f64;
let day_hi = ((cell + 1) as f64 * span_days as f64) / bar_zone as f64 - 1.0;
let active = (task_end_offset as f64) >= day_lo && (task_start_offset as f64) <= day_hi;
bar.push(if active { FULL_BLOCK } else { LIGHT_SHADE });
}
bar
}
fn build_axis_line(
axis_format: &str,
min_date: chrono::NaiveDate,
span_days: i64,
bar_zone: usize,
name_col: usize,
_annot_col: usize,
) -> String {
let prefix = " ".repeat(name_col);
let min_tick_cells = 8usize;
let cells_per_day = bar_zone as f64 / span_days as f64;
let min_days_between_ticks = (min_tick_cells as f64 / cells_per_day).ceil() as i64;
let tick_interval_days = nice_interval(min_days_between_ticks.max(1));
let mut row: Vec<char> = vec![' '; bar_zone];
let mut tick_day: i64 = 0;
while tick_day < span_days {
let cell = ((tick_day as f64 * bar_zone as f64) / span_days as f64) as usize;
let tick_date = min_date + chrono::Duration::days(tick_day);
let label = format_date_axis(&tick_date, axis_format);
for (j, ch) in label.chars().enumerate() {
if cell + j < bar_zone {
row[cell + j] = ch;
}
}
tick_day += tick_interval_days;
}
let axis_str: String = row.into_iter().collect();
format!("{prefix}{axis_str}")
}
fn nice_interval(n: i64) -> i64 {
const NICE: &[i64] = &[1, 2, 5, 7, 10, 14, 21, 30, 60, 90, 180, 365];
NICE.iter().copied().find(|&v| v >= n).unwrap_or(365)
}
fn format_date_axis(date: &chrono::NaiveDate, axis_format: &str) -> String {
match axis_format {
"%b %d" => {
date.format("%b %d").to_string()
}
"%Y-%m-%d" => date.format("%Y-%m-%d").to_string(),
"%m/%d" => date.format("%m/%d").to_string(),
"%d" => date.format("%d").to_string(),
_ => date.format("%m-%d").to_string(),
}
}
fn pad_or_truncate(s: &str, width: usize) -> String {
let w = UnicodeWidthStr::width(s as &str);
if w >= width {
let mut out = String::new();
let mut used = 0;
for ch in s.chars() {
let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1);
if used + cw + 1 > width {
break;
}
out.push(ch);
used += cw;
}
out.push('\u{2026}'); let final_w = UnicodeWidthStr::width(out.as_str());
for _ in final_w..width {
out.push(' ');
}
out
} else {
let mut out = s.to_string();
for _ in w..width {
out.push(' ');
}
out
}
}
fn render_empty(diag: &GanttDiagram) -> String {
if let Some(title) = &diag.title {
format!("Gantt: {title} (no tasks)")
} else {
"Gantt (no tasks)".to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::gantt::parse;
fn date(y: i32, m: u32, d: u32) -> chrono::NaiveDate {
chrono::NaiveDate::from_ymd_opt(y, m, d).unwrap()
}
#[test]
fn title_appears_in_output() {
let src =
"gantt\n title My Project\n dateFormat YYYY-MM-DD\n section S\n T :2024-01-01, 5d";
let diag = parse(src).unwrap();
let out = render(&diag, None);
assert!(
out.contains("My Project"),
"title not found in output:\n{out}"
);
}
#[test]
fn date_labels_appear_in_output() {
let src = "gantt\n\
dateFormat YYYY-MM-DD\n\
axisFormat %Y-%m-%d\n\
section S\n\
Task :2024-03-01, 30d";
let diag = parse(src).unwrap();
let out = render(&diag, Some(100));
assert!(
out.contains("2024-"),
"date label not found in output:\n{out}"
);
}
#[test]
fn bar_widths_proportional_to_durations() {
let src = "gantt\n\
dateFormat YYYY-MM-DD\n\
section S\n\
Short :2024-01-01, 10d\n\
Long :2024-01-11, 30d";
let diag = parse(src).unwrap();
let out = render(&diag, Some(120));
let mut lines = out
.lines()
.filter(|l| l.contains("Short") || l.contains("Long"));
let short_line = lines.next().unwrap_or("");
let long_line = lines.next().unwrap_or("");
let short_blocks = short_line.chars().filter(|&c| c == FULL_BLOCK).count();
let long_blocks = long_line.chars().filter(|&c| c == FULL_BLOCK).count();
assert!(
long_blocks > short_blocks,
"long task bar ({long_blocks}) not wider than short ({short_blocks})"
);
}
#[test]
fn ascii_fallback_substitutes_block_chars() {
let src = "gantt\n dateFormat YYYY-MM-DD\n section S\n T :2024-01-01, 5d";
let diag = parse(src).unwrap();
let unicode_out = render(&diag, None);
assert!(unicode_out.contains(FULL_BLOCK) || unicode_out.contains(LIGHT_SHADE));
let ascii_out = crate::to_ascii(&unicode_out);
assert!(
ascii_out.is_ascii(),
"non-ASCII chars remain after to_ascii:\n{ascii_out}"
);
assert!(ascii_out.contains('#') || ascii_out.contains('.'));
}
#[test]
fn render_empty_diagram_returns_placeholder() {
let diag = GanttDiagram {
title: Some("Empty".to_string()),
..Default::default()
};
let out = render(&diag, None);
assert!(out.contains("Empty"));
assert!(out.contains("no tasks"));
}
#[test]
fn bar_starts_and_ends_at_correct_positions() {
let src = "gantt\n dateFormat YYYY-MM-DD\n section S\n Only :2024-01-01, 20d";
let diag = parse(src).unwrap();
let task = &diag.sections[0].tasks[0];
let span = diag.span_days(); let bar_zone = span as usize; let bar = build_bar(task, date(2024, 1, 1), span, bar_zone);
assert_eq!(
bar.chars().filter(|&c| c == FULL_BLOCK).count(),
bar_zone,
"all cells should be active when task spans full diagram (1 cell per day)"
);
}
}