use chrono::Utc;
use owo_colors::Style;
use terminal_size::{Width, terminal_size};
use crate::format::{relative_time, truncate_title};
use crate::github::{ItemKind, RepoResult, RepoStatus};
use crate::theme::Theme;
fn term_width() -> usize {
terminal_size().map_or(80, |(Width(w), _)| w as usize)
}
fn should_colorize() -> bool {
std::env::var("NO_COLOR").is_err()
}
fn paint(text: &str, color: bool, style: Style) -> String {
if color {
style.style(text).to_string()
} else {
text.to_owned()
}
}
pub fn render_digest(results: &[RepoResult], theme: &Theme, limit: Option<usize>) -> String {
render_inner_limited(results, theme, should_colorize(), term_width(), limit)
}
#[cfg(test)]
fn render_inner(results: &[RepoResult], theme: &Theme, color: bool, width: usize) -> String {
render_inner_limited(results, theme, color, width, None)
}
fn render_inner_limited(
results: &[RepoResult],
theme: &Theme,
color: bool,
width: usize,
limit: Option<usize>,
) -> String {
if results.is_empty() {
return "No repos tracked. Run `ghpending add` to get started.\n".into();
}
let item_allocations = allocate_items(results, limit);
let now = Utc::now();
let total = results.len();
let with_pending = results
.iter()
.filter(|r| matches!(&r.status, RepoStatus::Items(items) if !items.is_empty()))
.count();
let failures = results
.iter()
.filter(|r| matches!(&r.status, RepoStatus::Error(_)))
.count();
let mut body = String::new();
let mut shown = 0;
for (index, result) in results.iter().enumerate() {
if matches!(&result.status, RepoStatus::Items(_)) && item_allocations[index] == 0 {
continue;
}
if shown > 0 {
body.push('\n');
}
shown += 1;
let (header_name, fork_suffix): (&str, Option<String>) = match &result.upstream {
Some(upstream) => (
upstream.as_str(),
Some(format!(" (your items · via fork {})", result.repo)),
),
None => (result.repo.as_str(), None),
};
let repo_colored = paint(header_name, color, theme.repo);
body.push_str(&format!("━━ {repo_colored}"));
if let Some(suffix) = &fork_suffix {
body.push_str(&paint(suffix, color, theme.meta));
}
body.push('\n');
match &result.status {
RepoStatus::NotFound => {
let msg = paint("(not found or no access)", color, theme.meta);
body.push_str(&format!(" {msg}\n"));
}
RepoStatus::Error(e) => {
let msg = paint(&format!("(error: {e})"), color, theme.error);
body.push_str(&format!(" {msg}\n"));
}
RepoStatus::Items(items) => {
let title_max = if width > 20 { width - 20 } else { 10 };
let is_fork_view = result.upstream.is_some();
for item in items.iter().take(item_allocations[index]) {
let (kind_str, number_str, title_str) = match item.kind {
ItemKind::PullRequest => {
let ks = paint("PR ", color, theme.pr);
let ns = format!("#{}", item.number);
let title = truncate_title(&item.title, title_max);
(ks, ns, title)
}
ItemKind::Issue => {
let ks = paint("ISS", color, theme.issue);
let ns = format!("#{}", item.number);
let title = truncate_title(&item.title, title_max);
(ks, ns, title)
}
};
body.push_str(&format!(" {kind_str} {number_str} {title_str}\n"));
let rel = relative_time(&item.created_at, &now);
let mut meta = if is_fork_view {
let rel_updated = relative_time(&item.updated_at, &now);
format!(
"opened {rel} ago · updated {rel_updated} ago · {} comments",
item.comments.unwrap_or(0)
)
} else {
format!("opened {rel} ago by {}", item.author)
};
if is_fork_view && let Some(decision) = &item.review_decision {
meta.push_str(" · ");
meta.push_str(decision);
}
if let Some(state) = pr_state_label(item) {
meta.push_str(" · ");
meta.push_str(state);
}
let meta_colored = paint(&meta, color, theme.meta);
body.push_str(&format!(" {meta_colored}\n"));
}
}
}
}
let summary = if failures > 0 {
format!("{total} projects attempted, {with_pending} with pending tasks, {failures} failed")
} else {
format!("{total} projects checked, {with_pending} with pending tasks")
};
let summary_colored = paint(&summary, color, theme.meta);
if body.is_empty() {
format!("{summary_colored}\n")
} else {
format!("{summary_colored}\n\n{body}")
}
}
fn allocate_items(results: &[RepoResult], limit: Option<usize>) -> Vec<usize> {
let mut allocations = results
.iter()
.map(|result| match &result.status {
RepoStatus::Items(items) => items.len(),
_ => 0,
})
.collect::<Vec<_>>();
let total = allocations.iter().sum::<usize>();
let Some(limit) = limit else {
return allocations;
};
if total <= limit {
return allocations;
}
let mut remainders = Vec::new();
let mut allocated = 0;
for (index, allocation) in allocations.iter_mut().enumerate() {
if *allocation == 0 {
continue;
}
let numerator = *allocation as u128 * limit as u128;
*allocation = (numerator / total as u128) as usize;
allocated += *allocation;
remainders.push((index, numerator % total as u128));
}
remainders
.sort_unstable_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0)));
for (index, _) in remainders.into_iter().take(limit - allocated) {
allocations[index] += 1;
}
allocations
}
fn pr_state_label(item: &crate::github::RepoItem) -> Option<&'static str> {
match item.kind {
ItemKind::PullRequest => match item.pr_draft {
Some(true) => Some("draft"),
Some(false) => Some("ready"),
None => None,
},
ItemKind::Issue => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::github::{ItemKind, RepoError, RepoItem, RepoResult, RepoStatus};
use crate::theme::Theme;
fn make_item(kind: ItemKind, number: u64, title: &str, days_ago: i64) -> RepoItem {
let created_at = Utc::now() - chrono::Duration::days(days_ago);
RepoItem {
kind,
number,
title: title.into(),
created_at,
updated_at: created_at,
author: "testuser".into(),
pr_draft: None,
comments: None,
review_decision: None,
}
}
fn make_pr(number: u64, title: &str, draft: Option<bool>) -> RepoItem {
RepoItem {
kind: ItemKind::PullRequest,
number,
title: title.into(),
created_at: Utc::now(),
updated_at: Utc::now(),
author: "testuser".into(),
pr_draft: draft,
comments: None,
review_decision: None,
}
}
fn make_fork_item(
kind: ItemKind,
number: u64,
title: &str,
comments: u64,
review_decision: Option<&str>,
) -> RepoItem {
RepoItem {
kind,
number,
title: title.into(),
created_at: Utc::now() - chrono::Duration::days(3),
updated_at: Utc::now() - chrono::Duration::days(1),
author: "testuser".into(),
pr_draft: None,
comments: Some(comments),
review_decision: review_decision.map(str::to_owned),
}
}
#[test]
fn item_limit_is_proportional_without_distorting_summary() {
let results = vec![
RepoResult::new(
"a/large".into(),
RepoStatus::Items(
(1..=5)
.map(|number| make_item(ItemKind::Issue, number, "large", 1))
.collect(),
),
),
RepoResult::new(
"b/medium".into(),
RepoStatus::Items(
(1..=3)
.map(|number| make_item(ItemKind::Issue, number, "medium", 1))
.collect(),
),
),
RepoResult::new(
"c/small".into(),
RepoStatus::Items(
(1..=2)
.map(|number| make_item(ItemKind::Issue, number, "small", 1))
.collect(),
),
),
];
assert_eq!(allocate_items(&results, Some(6)), vec![3, 2, 1]);
let out = render_inner_limited(&results, &Theme::default_theme(), false, 80, Some(6));
assert_eq!(
out.lines().filter(|line| line.starts_with(" ISS")).count(),
6
);
assert!(out.contains("3 projects checked, 3 with pending tasks"));
}
#[test]
fn zero_item_limit_hides_items_but_preserves_summary() {
let results = vec![RepoResult::new(
"a/repo".into(),
RepoStatus::Items(vec![make_item(ItemKind::Issue, 1, "issue", 1)]),
)];
let out = render_inner_limited(&results, &Theme::default_theme(), false, 80, Some(0));
assert_eq!(out, "1 projects checked, 1 with pending tasks\n");
}
#[test]
fn no_repos_tracked() {
let out = render_inner(&[], &Theme::default_theme(), false, 80);
assert!(out.contains("ghpending add"));
}
#[test]
fn empty_repo_is_skipped_from_listing() {
let results = vec![RepoResult::new(
"owner/empty".into(),
RepoStatus::Items(vec![]),
)];
let out = render_inner(&results, &Theme::default_theme(), false, 80);
assert!(!out.contains("owner/empty"));
assert!(!out.contains("nothing pending"));
assert!(out.contains("1 projects checked, 0 with pending tasks"));
}
#[test]
fn repo_not_found() {
let results = vec![RepoResult::new(
"owner/missing".into(),
RepoStatus::NotFound,
)];
let out = render_inner(&results, &Theme::default_theme(), false, 80);
assert!(out.contains("owner/missing"));
assert!(out.contains("not found or no access"));
}
#[test]
fn repo_error_renders_message() {
let results = vec![RepoResult::new(
"owner/flaky".into(),
RepoStatus::Error(RepoError::Api("rate limited".into())),
)];
let out = render_inner(&results, &Theme::default_theme(), false, 80);
assert!(out.contains("owner/flaky"));
assert!(out.contains("error:"));
assert!(out.contains("rate limited"));
}
#[test]
fn normal_items_rendered() {
let results = vec![RepoResult::new(
"ratatui-org/ratatui".into(),
RepoStatus::Items(vec![
make_item(ItemKind::PullRequest, 1842, "Fix overflow in Table", 2),
make_item(ItemKind::Issue, 1840, "Crash on empty Paragraph", 0),
]),
)];
let out = render_inner(&results, &Theme::default_theme(), false, 80);
assert!(out.contains("ratatui-org/ratatui"));
assert!(out.contains("PR"));
assert!(out.contains("#1842"));
assert!(out.contains("Fix overflow in Table"));
assert!(out.contains("ISS"));
assert!(out.contains("#1840"));
assert!(out.contains("testuser"));
}
#[test]
fn pull_request_draft_status_is_rendered_subtly() {
let results = vec![RepoResult::new(
"owner/repo".into(),
RepoStatus::Items(vec![
make_pr(3, "Work in progress", Some(true)),
make_pr(2, "Ready for review", Some(false)),
make_pr(1, "Unknown state", None),
]),
)];
let out = render_inner(&results, &Theme::default_theme(), false, 80);
assert!(out.contains("opened just now ago by testuser · draft"));
assert!(out.contains("opened just now ago by testuser · ready"));
assert!(out.contains("opened just now ago by testuser\n"));
}
#[test]
fn header_is_just_prefix_and_name() {
let results = vec![RepoResult::new(
"a/b".into(),
RepoStatus::Items(vec![make_item(ItemKind::Issue, 1, "x", 0)]),
)];
let out = render_inner(&results, &Theme::default_theme(), false, 80);
let header_line = out.lines().find(|l| l.contains("━━")).unwrap();
assert_eq!(header_line, "━━ a/b");
}
#[test]
fn two_repos_separated_by_blank_line() {
let results = vec![
RepoResult::new(
"a/b".into(),
RepoStatus::Items(vec![make_item(ItemKind::Issue, 1, "x", 0)]),
),
RepoResult::new(
"c/d".into(),
RepoStatus::Items(vec![make_item(ItemKind::Issue, 2, "y", 0)]),
),
];
let out = render_inner(&results, &Theme::default_theme(), false, 80);
let body = out.split_once("\n\n").unwrap().1;
assert!(body.contains("\n\n"));
}
#[test]
fn summary_counts_only_repos_with_items() {
let results = vec![
RepoResult::new("a/empty".into(), RepoStatus::Items(vec![])),
RepoResult::new(
"a/withitems".into(),
RepoStatus::Items(vec![make_item(ItemKind::Issue, 1, "x", 0)]),
),
RepoResult::new("a/missing".into(), RepoStatus::NotFound),
RepoResult::new(
"a/flaky".into(),
RepoStatus::Error(RepoError::Api("rate limited".into())),
),
];
let out = render_inner(&results, &Theme::default_theme(), false, 80);
assert!(out.contains("4 projects attempted, 1 with pending tasks, 1 failed"));
assert!(!out.contains("a/empty"));
assert!(out.contains("a/withitems"));
assert!(out.contains("a/missing"));
assert!(out.contains("a/flaky"));
}
#[test]
fn summary_counts_failures_when_no_pending_tasks() {
let results = vec![
RepoResult::new("a/empty".into(), RepoStatus::Items(vec![])),
RepoResult::new("a/timeout".into(), RepoStatus::Error(RepoError::Timeout)),
];
let out = render_inner(&results, &Theme::default_theme(), false, 80);
assert!(out.contains("2 projects attempted, 0 with pending tasks, 1 failed"));
assert!(out.contains("timeout after 30s"));
}
#[test]
fn all_empty_shows_only_summary() {
let results = vec![
RepoResult::new("a/b".into(), RepoStatus::Items(vec![])),
RepoResult::new("c/d".into(), RepoStatus::Items(vec![])),
];
let out = render_inner(&results, &Theme::default_theme(), false, 80);
assert_eq!(out, "2 projects checked, 0 with pending tasks\n");
}
#[test]
fn by_name_returns_some_for_known_themes() {
assert!(Theme::by_name("default").is_some());
assert!(Theme::by_name("evangelion").is_some());
assert!(Theme::by_name("nerv").is_some());
}
#[test]
fn by_name_returns_none_for_unknown_theme() {
assert!(Theme::by_name("matrix").is_none());
assert!(Theme::by_name("").is_none());
}
#[test]
fn nerv_theme_with_color_produces_ansi_escapes() {
let results = vec![RepoResult::new(
"owner/repo".into(),
RepoStatus::Items(vec![make_item(ItemKind::Issue, 1, "x", 0)]),
)];
let out = render_inner(&results, &Theme::nerv(), true, 80);
assert!(out.contains("\x1b["));
}
#[test]
fn nerv_theme_without_color_has_no_ansi_escapes() {
let results = vec![RepoResult::new(
"owner/repo".into(),
RepoStatus::Items(vec![make_item(ItemKind::Issue, 1, "x", 0)]),
)];
let out = render_inner(&results, &Theme::nerv(), false, 80);
assert!(!out.contains("\x1b["));
}
#[test]
fn evangelion_theme_with_color_produces_ansi_escapes() {
let results = vec![RepoResult::new(
"owner/repo".into(),
RepoStatus::Items(vec![make_item(ItemKind::Issue, 1, "x", 0)]),
)];
let out = render_inner(&results, &Theme::evangelion(), true, 80);
assert!(out.contains("\x1b["));
}
#[test]
fn fork_header_shows_upstream_name_and_via_fork_suffix() {
let results = vec![
RepoResult::new(
"akitaonrails/omarchy".into(),
RepoStatus::Items(vec![make_fork_item(ItemKind::Issue, 1, "x", 0, None)]),
)
.with_upstream("omacom/omarchy".into()),
];
let out = render_inner(&results, &Theme::default_theme(), false, 80);
let header_line = out.lines().find(|l| l.contains("━━")).unwrap();
assert_eq!(
header_line,
"━━ omacom/omarchy (your items · via fork akitaonrails/omarchy)"
);
}
#[test]
fn fork_meta_line_shows_comments_and_omits_author() {
let results = vec![
RepoResult::new(
"akitaonrails/omarchy".into(),
RepoStatus::Items(vec![make_fork_item(
ItemKind::Issue,
1,
"an issue",
4,
None,
)]),
)
.with_upstream("omacom/omarchy".into()),
];
let out = render_inner(&results, &Theme::default_theme(), false, 80);
assert!(out.contains("opened 3d ago · updated 1d ago · 4 comments"));
assert!(!out.contains("by testuser"));
}
#[test]
fn fork_meta_line_appends_review_decision_when_present() {
let results = vec![
RepoResult::new(
"akitaonrails/omarchy".into(),
RepoStatus::Items(vec![make_fork_item(
ItemKind::PullRequest,
2,
"a pr",
1,
Some("changes requested"),
)]),
)
.with_upstream("omacom/omarchy".into()),
];
let out = render_inner(&results, &Theme::default_theme(), false, 80);
assert!(out.contains("1 comments · changes requested"));
}
#[test]
fn fork_meta_line_without_review_decision_has_no_trailing_dot() {
let results = vec![
RepoResult::new(
"akitaonrails/omarchy".into(),
RepoStatus::Items(vec![make_fork_item(
ItemKind::PullRequest,
2,
"a pr",
1,
None,
)]),
)
.with_upstream("omacom/omarchy".into()),
];
let out = render_inner(&results, &Theme::default_theme(), false, 80);
assert!(out.contains("1 comments\n"));
}
#[test]
fn non_fork_output_is_unaffected_by_upstream_field() {
let results = vec![RepoResult::new(
"ratatui-org/ratatui".into(),
RepoStatus::Items(vec![
make_item(ItemKind::PullRequest, 1842, "Fix overflow in Table", 2),
make_item(ItemKind::Issue, 1840, "Crash on empty Paragraph", 0),
]),
)];
let out = render_inner(&results, &Theme::default_theme(), false, 80);
assert!(out.contains("opened 2d ago by testuser"));
assert!(!out.contains("comments"));
assert!(!out.contains("via fork"));
}
}