use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use crate::ui::style;
use crate::ui::text_util::wrapped_line_count;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum RoadmapQueue {
ReadyNow,
QueuedNext,
Parked,
}
impl RoadmapQueue {
const ALL: [Self; 3] = [Self::ReadyNow, Self::QueuedNext, Self::Parked];
fn title(self) -> &'static str {
match self {
Self::ReadyNow => "Ready Now",
Self::QueuedNext => "Queued Next",
Self::Parked => "Parked",
}
}
fn color(self) -> ratatui::style::Color {
match self {
Self::ReadyNow => style::palette::warning(),
Self::QueuedNext => style::palette::accent(),
Self::Parked => style::palette::text_muted(),
}
}
}
struct RoadmapOverview {
ready_now: Vec<RoadmapCard>,
queued_next: Vec<RoadmapCard>,
parked: Vec<RoadmapCard>,
}
impl RoadmapOverview {
fn items(&self, queue: RoadmapQueue) -> &[RoadmapCard] {
match queue {
RoadmapQueue::ReadyNow => &self.ready_now,
RoadmapQueue::QueuedNext => &self.queued_next,
RoadmapQueue::Parked => &self.parked,
}
}
}
struct RoadmapCard {
assignee: Option<String>,
short_id: Option<String>,
detail: Option<String>,
outcome: Option<String>,
stream: String,
title: String,
}
struct RoadmapSubsection {
title: String,
body: String,
}
pub(crate) fn roadmap_task_lines(content: &str) -> Vec<Line<'static>> {
let overview = parse_roadmap_overview(content);
let mut lines = vec![
Line::from(Span::styled(
"Source: docs/plan/roadmap.md",
Style::default().fg(style::palette::text_muted()),
)),
Line::from(vec![
Span::styled(
"Ready Now ",
Style::default().fg(RoadmapQueue::ReadyNow.color()),
),
Span::styled(
overview.ready_now.len().to_string(),
Style::default()
.fg(style::palette::text())
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
"Queued Next ",
Style::default().fg(RoadmapQueue::QueuedNext.color()),
),
Span::styled(
overview.queued_next.len().to_string(),
Style::default()
.fg(style::palette::text())
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled("Parked ", Style::default().fg(RoadmapQueue::Parked.color())),
Span::styled(
overview.parked.len().to_string(),
Style::default()
.fg(style::palette::text())
.add_modifier(Modifier::BOLD),
),
]),
];
for queue in RoadmapQueue::ALL {
lines.push(Line::default());
lines.push(queue_header_line(queue, overview.items(queue).len()));
if overview.items(queue).is_empty() {
lines.push(indented_line(
"No roadmap items.",
Style::default().fg(style::palette::text_muted()),
));
continue;
}
for card in overview.items(queue) {
lines.extend(card_lines(queue, card));
}
}
lines
}
pub(crate) fn roadmap_task_max_scroll_offset(
lines: &[Line<'static>],
inner_width: u16,
viewport_height: u16,
) -> u16 {
if viewport_height == 0 {
return 0;
}
wrapped_line_count(lines, inner_width).saturating_sub(viewport_height)
}
fn parse_roadmap_overview(content: &str) -> RoadmapOverview {
RoadmapOverview {
ready_now: parse_queue_cards(
&queue_section_body(content, RoadmapQueue::ReadyNow),
RoadmapQueue::ReadyNow,
),
queued_next: parse_queue_cards(
&queue_section_body(content, RoadmapQueue::QueuedNext),
RoadmapQueue::QueuedNext,
),
parked: parse_queue_cards(
&queue_section_body(content, RoadmapQueue::Parked),
RoadmapQueue::Parked,
),
}
}
fn queue_section_body(content: &str, queue: RoadmapQueue) -> String {
let mut body_lines = Vec::new();
let mut is_in_queue = false;
for line in content.lines() {
if let Some(section_title) = line.strip_prefix("## ").map(str::trim) {
is_in_queue = section_title == queue.title();
continue;
}
if is_in_queue {
body_lines.push(line.to_string());
}
}
body_lines.join("\n")
}
fn parse_queue_cards(content: &str, queue: RoadmapQueue) -> Vec<RoadmapCard> {
let mut items = Vec::new();
let mut current_heading: Option<String> = None;
let mut current_body = Vec::new();
for line in content.lines() {
if let Some(heading) = line.strip_prefix("### ").map(str::trim) {
if let Some(previous_heading) = current_heading.replace(heading.to_string()) {
let card_body = current_body.join("\n");
items.push(parse_card(queue, &previous_heading, &card_body));
current_body.clear();
}
continue;
}
if current_heading.is_some() {
current_body.push(line.to_string());
}
}
if let Some(heading) = current_heading {
let card_body = current_body.join("\n");
items.push(parse_card(queue, &heading, &card_body));
}
items
}
fn parse_card(queue: RoadmapQueue, heading: &str, content: &str) -> RoadmapCard {
let (short_id, stream, title) = parse_card_heading(heading);
let subsections = parse_subsections(content);
let assignee = subsection_body(&subsections, "Assignee").map(clean_inline_text);
let outcome = match queue {
RoadmapQueue::ReadyNow => subsection_body(&subsections, "Usable outcome"),
RoadmapQueue::QueuedNext | RoadmapQueue::Parked => subsection_body(&subsections, "Outcome"),
}
.map(clean_inline_text);
let detail = match queue {
RoadmapQueue::ReadyNow => subsection_body(&subsections, "Substeps")
.map(checklist_summary)
.filter(|summary| !summary.is_empty())
.map(|summary| format!("Substeps: {summary}")),
RoadmapQueue::QueuedNext | RoadmapQueue::Parked => {
let promote_when = subsection_body(&subsections, "Promote when")
.map(clean_inline_text)
.filter(|text| !text.is_empty())
.map(|text| format!("Promote when: {text}"));
let depends_on = subsection_body(&subsections, "Depends on")
.map(clean_inline_text)
.filter(|text| !text.is_empty())
.map(|text| format!("Depends on: {text}"));
promote_when.or(depends_on)
}
};
RoadmapCard {
assignee,
short_id,
detail,
outcome,
stream,
title,
}
}
fn parse_card_heading(heading: &str) -> (Option<String>, String, String) {
let mut short_id = None;
let mut remainder = heading.trim();
if remainder.starts_with('[')
&& let Some(close_bracket_index) = remainder.find(']')
{
let id = &remainder[1..close_bracket_index];
short_id = Some(id.chars().take(8).collect());
remainder = remainder[(close_bracket_index + 1)..].trim();
}
if let Some(separator_index) = remainder.find(": ") {
return (
short_id,
remainder[..separator_index].trim().to_string(),
remainder[(separator_index + 2)..].trim().to_string(),
);
}
(short_id, String::new(), remainder.to_string())
}
fn parse_subsections(content: &str) -> Vec<RoadmapSubsection> {
let mut subsections = Vec::new();
let mut current_title: Option<String> = None;
let mut current_body = Vec::new();
for line in content.lines() {
if let Some(title) = line.strip_prefix("#### ").map(str::trim) {
if let Some(previous_title) = current_title.replace(title.to_string()) {
subsections.push(RoadmapSubsection {
title: previous_title,
body: current_body.join("\n").trim().to_string(),
});
current_body.clear();
}
continue;
}
if current_title.is_some() {
current_body.push(line.to_string());
}
}
if let Some(title) = current_title {
subsections.push(RoadmapSubsection {
title,
body: current_body.join("\n").trim().to_string(),
});
}
subsections
}
fn subsection_body<'a>(subsections: &'a [RoadmapSubsection], title: &str) -> Option<&'a str> {
subsections
.iter()
.find(|subsection| subsection.title == title)
.map(|subsection| subsection.body.as_str())
}
fn clean_inline_text(content: &str) -> String {
content
.replace("**", "")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
fn checklist_summary(content: &str) -> String {
content
.lines()
.filter_map(|line| {
line.trim()
.strip_prefix("- [ ] ")
.or_else(|| line.trim().strip_prefix("- [x] "))
})
.map(clean_inline_text)
.collect::<Vec<_>>()
.join("; ")
}
fn queue_header_line(queue: RoadmapQueue, item_count: usize) -> Line<'static> {
let noun = if item_count == 1 { "item" } else { "items" };
Line::from(vec![
Span::styled(
queue.title(),
Style::default()
.fg(queue.color())
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" ({item_count} {noun})"),
Style::default().fg(style::palette::text_muted()),
),
])
}
fn card_lines(queue: RoadmapQueue, card: &RoadmapCard) -> Vec<Line<'static>> {
let mut lines = vec![card_heading_line(card)];
let metadata_line = card_metadata_line(card);
if let Some(line) = metadata_line {
lines.push(line);
}
if let Some(outcome) = card.outcome.as_deref() {
lines.push(indented_label_line("Outcome", outcome));
}
if let Some(detail) = card.detail.as_deref() {
lines.push(indented_line(
detail,
Style::default().fg(style::palette::text_muted()),
));
}
if queue != RoadmapQueue::ReadyNow
&& card.detail.is_none()
&& let Some(card_id) = card.short_id.as_deref()
{
lines.push(indented_line(
&format!("Roadmap id: {card_id}"),
Style::default().fg(style::palette::text_muted()),
));
}
lines.push(Line::default());
lines
}
fn card_heading_line(card: &RoadmapCard) -> Line<'static> {
let mut spans = vec![
Span::raw("• "),
Span::styled(
card.stream.clone(),
Style::default()
.fg(style::palette::accent_soft())
.add_modifier(Modifier::BOLD),
),
];
if !card.stream.is_empty() {
spans.push(Span::raw(": "));
}
spans.push(Span::styled(
card.title.clone(),
Style::default()
.fg(style::palette::text())
.add_modifier(Modifier::BOLD),
));
Line::from(spans)
}
fn card_metadata_line(card: &RoadmapCard) -> Option<Line<'static>> {
let mut segments = Vec::new();
if let Some(short_id) = card.short_id.as_deref() {
segments.push(format!("[{short_id}]"));
}
if let Some(assignee) = card.assignee.as_deref() {
segments.push(assignee.to_string());
}
if segments.is_empty() {
return None;
}
Some(indented_line(
&segments.join(" "),
Style::default().fg(style::palette::text_muted()),
))
}
fn indented_label_line(label: &str, content: &str) -> Line<'static> {
Line::from(vec![
Span::raw(" "),
Span::styled(
format!("{label}: "),
Style::default().fg(style::palette::text_muted()),
),
Span::raw(content.to_string()),
])
}
fn indented_line(content: &str, style: Style) -> Line<'static> {
Line::from(vec![
Span::raw(" "),
Span::styled(content.to_string(), style),
])
}
#[cfg(test)]
mod tests {
use super::*;
fn line_text(lines: Vec<Line<'static>>) -> Vec<String> {
lines
.into_iter()
.map(|line| {
line.spans
.into_iter()
.map(|span| span.content.to_string())
.collect::<String>()
})
.collect()
}
#[test]
fn roadmap_task_lines_include_all_queue_summaries() {
let roadmap = r"
## Ready Now
### [ca014af3-5cd0-4567-bf11-3495765dcf6f] Forge: Add GitHub pull-request publish shortcut in session chat
#### Assignee
`@minev-dev`
#### Why now
Existing workflow pieces already exist.
#### Usable outcome
Pressing `p` creates or refreshes the pull request.
#### Substeps
- [ ] **Route review-request publishing through the `p` session-chat shortcut only.**
- [ ] **Reuse the existing review-request workflow for GitHub sessions.**
#### Tests
- [ ] Cover the new shortcut.
#### Docs
- [ ] Update workflow docs.
## Queued Next
### [17a9e2ba-0b7d-407d-9cd4-72807ef7bc1f] Delivery: Add project commit strategy selection
#### Outcome
Let each project stored in Agentty choose its expected landing path.
#### Promote when
Promote when maintainers want review and publish actions to respect project delivery flow.
#### Depends on
`[ca014af3] Forge`
## Parked
### [6bb0cae7-c07c-4fab-ae6b-e74444d3f0f0] Planning: Move roadmap tasks to a single canonical TOML plan
#### Outcome
Let Agentty manage roadmap tasks through one canonical `docs/plan/roadmap.toml` file.
#### Promote when
Promote when maintainers want direct task management in Agentty.
#### Depends on
`None`
";
let lines = line_text(roadmap_task_lines(roadmap));
assert!(lines.iter().any(|line| line.contains("Ready Now 1")));
assert!(lines.iter().any(|line| {
line.contains("Forge: Add GitHub pull-request publish shortcut in session chat")
}));
assert!(
lines
.iter()
.any(|line| line.contains("Substeps: Route review-request publishing"))
);
assert!(
lines
.iter()
.any(|line| { line.contains("Delivery: Add project commit strategy selection") })
);
assert!(
lines
.iter()
.any(|line| line.contains("Promote when: Promote when maintainers want"))
);
assert!(lines.iter().any(|line| {
line.contains("Planning: Move roadmap tasks to a single canonical TOML plan")
}));
}
#[test]
fn roadmap_task_lines_show_empty_queue_state() {
let roadmap = "## Ready Now\n\n## Queued Next\n\n## Parked\n";
let lines = line_text(roadmap_task_lines(roadmap));
assert_eq!(
lines
.iter()
.filter(|line| line.contains("No roadmap items."))
.count(),
3
);
}
#[test]
fn roadmap_task_max_scroll_offset_counts_wrapped_lines() {
let lines = vec![Line::from("alpha beta gamma"), Line::from("delta epsilon")];
let max_scroll_offset = roadmap_task_max_scroll_offset(&lines, 5, 2);
assert_eq!(max_scroll_offset, 3);
}
}