use crate::payload::{
BadgeData, Bar, BarsData, Body, EntriesData, Entry, ImageLinkedItem, ImageLinkedListData,
LinkedLine, LinkedTextBlockData, MarkdownTextBlockData, Status, TextBlockData, TextData,
TimelineData, TimelineEvent,
};
use crate::render::Shape;
#[derive(Debug, Clone, Default)]
pub struct ForgeRow {
pub label: String,
pub title: String,
pub url: Option<String>,
pub avatar_url: Option<String>,
pub avatar_path: Option<String>,
pub updated_at_unix: i64,
pub activity_count: u64,
}
pub fn render_forge_rows(rows: &[ForgeRow], shape: Shape) -> Body {
match shape {
Shape::Text => render_text(rows),
Shape::TextBlock => render_text_block(rows),
Shape::MarkdownTextBlock => render_markdown(rows),
Shape::LinkedTextBlock => render_linked(rows),
Shape::ImageLinkedList => render_image_linked(rows),
Shape::Entries => render_entries(rows),
Shape::Bars => render_bars(rows),
Shape::Timeline => render_timeline(rows),
_ => render_text_block(rows),
}
}
pub fn render_count_badge(count: u64, singular: &str, plural: &str) -> Body {
let (status, suffix) = if count == 0 {
(Status::Ok, " ✓")
} else {
(Status::Warn, "")
};
let noun = if count == 1 { singular } else { plural };
Body::Badge(BadgeData {
status,
label: format!("{count} {noun}{suffix}"),
})
}
fn render_text(rows: &[ForgeRow]) -> Body {
let value = rows
.first()
.map(|r| format!("{} {}", r.label, r.title))
.unwrap_or_default();
Body::Text(TextData { value })
}
fn render_text_block(rows: &[ForgeRow]) -> Body {
Body::TextBlock(TextBlockData {
lines: rows
.iter()
.map(|r| format!("{} {}", r.label, r.title))
.collect(),
})
}
fn render_markdown(rows: &[ForgeRow]) -> Body {
let value = rows
.iter()
.map(|r| {
let label = escape_markdown_inline(&r.label);
let title = escape_markdown_inline(&r.title);
match &r.url {
Some(url) => format!("- **{label}** [{title}]({})", escape_markdown_url(url)),
None => format!("- **{label}** {title}"),
}
})
.collect::<Vec<_>>()
.join("\n");
Body::MarkdownTextBlock(MarkdownTextBlockData { value })
}
fn escape_markdown_inline(value: &str) -> String {
value
.replace('\\', "\\\\")
.replace('`', "\\`")
.replace('*', "\\*")
.replace('_', "\\_")
.replace('[', "\\[")
.replace(']', "\\]")
}
fn escape_markdown_url(url: &str) -> String {
url.replace('\\', "%5C").replace(')', "%29")
}
fn render_linked(rows: &[ForgeRow]) -> Body {
Body::LinkedTextBlock(LinkedTextBlockData {
items: rows
.iter()
.map(|r| LinkedLine {
text: format!("{} {}", r.label, r.title),
url: r.url.clone(),
})
.collect(),
})
}
fn render_image_linked(rows: &[ForgeRow]) -> Body {
Body::ImageLinkedList(ImageLinkedListData {
items: rows
.iter()
.map(|r| ImageLinkedItem {
title: format!("{} {}", r.label, r.title),
url: r.url.clone(),
thumbnail_path: r.avatar_path.clone(),
subtitle: None,
})
.collect(),
})
}
fn render_entries(rows: &[ForgeRow]) -> Body {
Body::Entries(EntriesData {
items: rows
.iter()
.map(|r| Entry {
key: r.label.clone(),
value: Some(r.title.clone()),
status: None,
})
.collect(),
})
}
fn render_bars(rows: &[ForgeRow]) -> Body {
Body::Bars(BarsData {
bars: rows
.iter()
.map(|r| Bar {
label: r.label.clone(),
value: r.activity_count,
value_label: None,
})
.collect(),
})
}
fn render_timeline(rows: &[ForgeRow]) -> Body {
Body::Timeline(TimelineData {
events: rows
.iter()
.map(|r| TimelineEvent {
timestamp: r.updated_at_unix,
title: r.label.clone(),
detail: Some(r.title.clone()),
status: None,
})
.collect(),
})
}
pub const LIST_SHAPES: &[Shape] = &[
Shape::LinkedTextBlock,
Shape::TextBlock,
Shape::MarkdownTextBlock,
Shape::ImageLinkedList,
Shape::Entries,
Shape::Text,
Shape::Bars,
Shape::Badge,
Shape::Timeline,
];
pub async fn dispatch_rows_async(
mut rows: Vec<ForgeRow>,
shape: Shape,
badge_singular: &str,
badge_plural: &str,
) -> Body {
if shape == Shape::Badge {
return render_count_badge(rows.len() as u64, badge_singular, badge_plural);
}
if shape == Shape::ImageLinkedList {
let urls: Vec<Option<String>> = rows.iter().map(|r| r.avatar_url.clone()).collect();
let paths = crate::fetcher::thumbnails::download_many(&urls).await;
rows.iter_mut().zip(paths).for_each(|(row, path)| {
row.avatar_path = path.map(|p| p.to_string_lossy().into_owned());
});
}
render_forge_rows(&rows, shape)
}
pub fn dispatch_sample(
rows: &[ForgeRow],
shape: Shape,
badge_singular: &str,
badge_plural: &str,
) -> Option<Body> {
if shape == Shape::Badge {
return Some(render_count_badge(
rows.len() as u64,
badge_singular,
badge_plural,
));
}
if !LIST_SHAPES.contains(&shape) {
return None;
}
Some(render_forge_rows(rows, shape))
}
#[cfg(test)]
mod tests {
use super::*;
fn rows() -> Vec<ForgeRow> {
vec![
ForgeRow {
label: "splashboard#54".into(),
title: "feat(docs): widget catalogue".into(),
url: Some("https://github.com/u/splashboard/pull/54".into()),
avatar_url: Some("https://avatars.example/u54.png".into()),
avatar_path: Some("/tmp/u54.png".into()),
updated_at_unix: 1_774_000_000,
activity_count: 7,
},
ForgeRow {
label: "splashboard#51".into(),
title: "feat(fetcher): split clock options".into(),
url: None,
avatar_url: None,
avatar_path: None,
updated_at_unix: 1_773_800_000,
activity_count: 2,
},
]
}
#[test]
fn text_picks_the_first_row_headline() {
let Body::Text(t) = render_forge_rows(&rows(), Shape::Text) else {
panic!("expected text");
};
assert!(t.value.starts_with("splashboard#54"));
}
#[test]
fn text_block_emits_one_line_per_row() {
let Body::TextBlock(b) = render_forge_rows(&rows(), Shape::TextBlock) else {
panic!("expected text block");
};
assert_eq!(b.lines.len(), 2);
assert!(b.lines[1].contains("split clock options"));
}
#[test]
fn markdown_links_only_when_url_is_present() {
let Body::MarkdownTextBlock(b) = render_forge_rows(&rows(), Shape::MarkdownTextBlock)
else {
panic!("expected markdown");
};
assert!(
b.value
.contains("[feat(docs): widget catalogue](https://github.com")
);
assert!(b.value.contains("- **splashboard#51** feat(fetcher)"));
}
#[test]
fn markdown_escapes_inline_metacharacters_in_label_and_title() {
let rows = vec and *_wip_*".into(),
url: Some("https://example.com/path?x=1)y".into()),
avatar_url: None,
avatar_path: None,
updated_at_unix: 1_700_000_000,
activity_count: 0,
}];
let Body::MarkdownTextBlock(b) = render_forge_rows(&rows, Shape::MarkdownTextBlock) else {
panic!("expected markdown");
};
assert!(
b.value.contains("fix \\](oops) and \\*\\_wip\\_\\*"),
"title not escaped: {}",
b.value
);
assert!(
b.value.contains("1%29y"),
"url paren not escaped: {}",
b.value
);
}
#[test]
fn escape_markdown_inline_handles_each_documented_glyph() {
assert_eq!(
escape_markdown_inline("a\\b`c*d_e[f]g"),
"a\\\\b\\`c\\*d\\_e\\[f\\]g"
);
assert_eq!(escape_markdown_inline("plain text"), "plain text");
}
#[test]
fn linked_carries_optional_url_through() {
let Body::LinkedTextBlock(b) = render_forge_rows(&rows(), Shape::LinkedTextBlock) else {
panic!("expected linked text block");
};
assert_eq!(b.items.len(), 2);
assert!(b.items[0].url.is_some());
assert!(b.items[1].url.is_none());
}
#[test]
fn image_linked_passes_thumbnail_paths_through() {
let Body::ImageLinkedList(b) = render_forge_rows(&rows(), Shape::ImageLinkedList) else {
panic!("expected image linked list");
};
assert_eq!(b.items[0].thumbnail_path.as_deref(), Some("/tmp/u54.png"));
assert!(b.items[1].thumbnail_path.is_none());
}
#[test]
fn entries_maps_label_to_title() {
let Body::Entries(b) = render_forge_rows(&rows(), Shape::Entries) else {
panic!("expected entries");
};
assert_eq!(b.items[0].key, "splashboard#54");
assert_eq!(
b.items[1].value.as_deref(),
Some("feat(fetcher): split clock options")
);
}
#[test]
fn bars_carries_activity_count_as_value() {
let Body::Bars(b) = render_forge_rows(&rows(), Shape::Bars) else {
panic!("expected bars");
};
assert_eq!(b.bars[0].value, 7);
assert_eq!(b.bars[1].value, 2);
}
#[test]
fn timeline_uses_updated_at_unix() {
let Body::Timeline(t) = render_forge_rows(&rows(), Shape::Timeline) else {
panic!("expected timeline");
};
assert_eq!(t.events[0].timestamp, 1_774_000_000);
assert_eq!(
t.events[0].detail.as_deref(),
Some("feat(docs): widget catalogue")
);
}
#[test]
fn unsupported_shape_falls_back_to_text_block() {
let Body::TextBlock(_) = render_forge_rows(&rows(), Shape::Ratio) else {
panic!("expected fallback to text block");
};
}
#[test]
fn count_badge_flips_status_at_zero() {
let Body::Badge(b) = render_count_badge(0, "open MR", "open MRs") else {
panic!("expected badge");
};
assert_eq!(b.status, Status::Ok);
assert_eq!(b.label, "0 open MRs ✓");
let Body::Badge(b) = render_count_badge(1, "open MR", "open MRs") else {
panic!("expected badge");
};
assert_eq!(b.status, Status::Warn);
assert_eq!(b.label, "1 open MR");
let Body::Badge(b) = render_count_badge(5, "open MR", "open MRs") else {
panic!("expected badge");
};
assert_eq!(b.label, "5 open MRs");
}
#[test]
fn count_badge_keeps_verb_form_intact_across_counts() {
let Body::Badge(b) = render_count_badge(1, "to review", "to review") else {
panic!("expected badge");
};
assert_eq!(b.label, "1 to review");
let Body::Badge(b) = render_count_badge(3, "to review", "to review") else {
panic!("expected badge");
};
assert_eq!(b.label, "3 to review");
}
#[test]
fn list_shapes_covers_the_eight_documented_variants() {
assert_eq!(LIST_SHAPES.len(), 9);
assert_eq!(LIST_SHAPES[0], Shape::LinkedTextBlock);
assert!(LIST_SHAPES.contains(&Shape::ImageLinkedList));
assert!(LIST_SHAPES.contains(&Shape::Badge));
}
#[tokio::test]
async fn dispatch_rows_async_handles_badge_and_passes_through_others() {
let Body::Badge(b) = dispatch_rows_async(rows(), Shape::Badge, "open PR", "open PRs").await
else {
panic!("expected badge");
};
assert_eq!(b.label, "2 open PRs");
let Body::TextBlock(t) =
dispatch_rows_async(rows(), Shape::TextBlock, "open PR", "open PRs").await
else {
panic!("expected text block");
};
assert_eq!(t.lines.len(), 2);
}
#[test]
fn dispatch_sample_returns_none_for_unsupported_shapes() {
let rows = rows();
assert!(dispatch_sample(&rows, Shape::Ratio, "open PR", "open PRs").is_none());
let Some(Body::Badge(b)) = dispatch_sample(&rows, Shape::Badge, "open PR", "open PRs")
else {
panic!("expected badge");
};
assert_eq!(b.label, "2 open PRs");
let Some(Body::Entries(e)) = dispatch_sample(&rows, Shape::Entries, "open PR", "open PRs")
else {
panic!("expected entries");
};
assert_eq!(e.items.len(), 2);
}
#[tokio::test]
async fn dispatch_rows_async_image_linked_list_resolves_thumbnails_offline() {
let rows = vec![
ForgeRow {
label: "splashboard#1".into(),
title: "no avatar".into(),
url: Some("https://example.com/1".into()),
avatar_url: None,
avatar_path: None,
updated_at_unix: 0,
activity_count: 0,
},
ForgeRow {
label: "splashboard#2".into(),
title: "unsupported avatar scheme".into(),
url: None,
avatar_url: Some("ftp://example.com/avatar.png".into()),
avatar_path: None,
updated_at_unix: 0,
activity_count: 0,
},
];
let Body::ImageLinkedList(b) =
dispatch_rows_async(rows, Shape::ImageLinkedList, "open PR", "open PRs").await
else {
panic!("expected image linked list");
};
assert_eq!(b.items.len(), 2);
assert!(b.items.iter().all(|i| i.thumbnail_path.is_none()));
assert!(b.items[0].title.starts_with("splashboard#1"));
}
struct SplashHomeGuard(Option<String>);
impl SplashHomeGuard {
fn set(path: &std::path::Path) -> Self {
let previous = std::env::var("SPLASHBOARD_HOME").ok();
unsafe { std::env::set_var("SPLASHBOARD_HOME", path) };
Self(previous)
}
}
impl Drop for SplashHomeGuard {
fn drop(&mut self) {
unsafe {
match self.0.take() {
Some(value) => std::env::set_var("SPLASHBOARD_HOME", value),
None => std::env::remove_var("SPLASHBOARD_HOME"),
}
}
}
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn dispatch_rows_async_image_linked_list_fills_avatar_path_from_cache_hit() {
use sha2::{Digest, Sha256};
let _lock = crate::paths::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().unwrap();
let _home = SplashHomeGuard::set(tmp.path());
let avatar = "https://avatars.example/seeded.png";
let hash: String = Sha256::digest(avatar.as_bytes())
.iter()
.map(|b| format!("{b:02x}"))
.collect();
let dir = tmp.path().join("cache").join("thumbnails");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(format!("{hash}.png")), b"seeded-png").unwrap();
let rows = vec![ForgeRow {
label: "splashboard#9".into(),
title: "has a cached avatar".into(),
url: Some("https://example.com/9".into()),
avatar_url: Some(avatar.into()),
avatar_path: None,
updated_at_unix: 0,
activity_count: 0,
}];
let body = dispatch_rows_async(rows, Shape::ImageLinkedList, "open PR", "open PRs").await;
let Body::ImageLinkedList(b) = body else {
panic!("expected image linked list");
};
let resolved = b.items[0]
.thumbnail_path
.as_deref()
.expect("cache hit should resolve a local thumbnail path");
assert!(
resolved.ends_with(&format!("{hash}.png")),
"resolved path should point at the seeded cache file: {resolved}"
);
}
#[test]
fn empty_rows_collapse_to_empty_bodies() {
let Body::Text(t) = render_forge_rows(&[], Shape::Text) else {
panic!("expected text");
};
assert!(t.value.is_empty());
let Body::TextBlock(b) = render_forge_rows(&[], Shape::TextBlock) else {
panic!("expected text block");
};
assert!(b.lines.is_empty());
let Body::LinkedTextBlock(b) = render_forge_rows(&[], Shape::LinkedTextBlock) else {
panic!("expected linked text block");
};
assert!(b.items.is_empty());
}
}