use unicode_width::UnicodeWidthStr;
use crate::journey::JourneyDiagram;
pub fn render(diag: &JourneyDiagram, _max_width: Option<usize>) -> String {
let mut out = String::new();
if let Some(title) = diag.title.as_deref() {
out.push_str("Journey: ");
out.push_str(title);
out.push('\n');
}
for section in &diag.sections {
out.push('\n');
if let Some(name) = section.name.as_deref() {
out.push_str(" ");
out.push_str(name);
out.push('\n');
}
let title_w = section
.tasks
.iter()
.map(|t| UnicodeWidthStr::width(t.title.as_str()))
.max()
.unwrap_or(0);
let last = section.tasks.len().saturating_sub(1);
for (i, task) in section.tasks.iter().enumerate() {
let connector = if i == last { "└─" } else { "├─" };
let tw = UnicodeWidthStr::width(task.title.as_str());
let pad = title_w.saturating_sub(tw);
let score_bar = star_bar(task.score);
let actors = task.actors.join(", ");
out.push_str(" ");
out.push_str(connector);
out.push(' ');
out.push_str(&task.title);
for _ in 0..pad + 4 {
out.push(' ');
}
out.push('[');
out.push_str(&score_bar);
out.push_str("] (");
out.push_str(&task.score.to_string());
out.push_str("/5) \u{2014} "); out.push_str(&actors);
out.push('\n');
}
}
if out.ends_with('\n') {
out.pop();
}
out
}
fn star_bar(score: u8) -> String {
let filled = (score as usize).min(5);
let mut s = String::with_capacity(5 * 3); for _ in 0..filled {
s.push('\u{2605}'); }
for _ in filled..5 {
s.push('\u{2606}'); }
s
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::journey::parse;
#[test]
fn renders_title_when_present() {
let diag = parse("journey\ntitle My Day\nsection Work\nStep: 3: Me").unwrap();
let out = render(&diag, None);
assert!(out.starts_with("Journey: My Day"), "got: {out:?}");
}
#[test]
fn renders_score_visually_distinguishable_at_5_vs_1() {
let diag = parse(
"journey\n\
section S\n\
Full: 5: Me\n\
Minimal: 1: Me",
)
.unwrap();
let out = render(&diag, None);
let five_bar = "\u{2605}\u{2605}\u{2605}\u{2605}\u{2605}";
let one_bar = "\u{2605}\u{2606}\u{2606}\u{2606}\u{2606}";
assert!(out.contains(five_bar), "5-star bar not found in:\n{out}");
assert!(out.contains(one_bar), "1-star bar not found in:\n{out}");
}
#[test]
fn renders_multi_actor_task() {
let diag = parse("journey\nGroup task: 4: Alice, Bob, Carol").unwrap();
let out = render(&diag, None);
assert!(out.contains("Alice, Bob, Carol"), "got: {out:?}");
}
#[test]
fn renders_unnamed_section_without_heading() {
let diag = parse("journey\nHidden: 2: Me").unwrap();
let out = render(&diag, None);
assert!(out.contains("Hidden"));
let section_name_row = out
.lines()
.any(|l| l.starts_with(" ") && !l.starts_with(" "));
assert!(!section_name_row, "unexpected section heading in: {out:?}");
}
#[test]
fn star_bar_bounds() {
for score in 1u8..=5 {
let bar = star_bar(score);
let filled = bar.chars().filter(|&c| c == '\u{2605}').count();
let empty = bar.chars().filter(|&c| c == '\u{2606}').count();
assert_eq!(filled, score as usize);
assert_eq!(empty, 5 - score as usize);
assert_eq!(filled + empty, 5);
}
}
}