use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use crate::config::Theme;
pub const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
pub enum UpdateReport<'a> {
Checking { spinner_frame: usize },
UpToDate { tag: &'a str },
Available { tag: &'a str, notes: &'a [String] },
Inconclusive { tag: &'a str },
Failed,
}
const VERSION_LABEL_WIDTH: usize = "Installed: ".len();
const VERSION_LABEL: &str = "edamame version:";
pub fn body_lines(theme: &Theme, report: UpdateReport<'_>, installed: &str) -> Vec<Line<'static>> {
match report {
UpdateReport::Checking { spinner_frame } => {
let frame = SPINNER_FRAMES[spinner_frame % SPINNER_FRAMES.len()];
vec![Line::from(Span::styled(
format!("Checking for updates… {frame}"),
theme.modal_description,
))]
}
UpdateReport::UpToDate { tag } => {
let mut out = vec![
Line::from(Span::styled("edamame is up to date.".to_owned(), theme.h1)),
Line::raw(""),
];
out.extend(version_rows(theme, installed, tag));
out
}
UpdateReport::Inconclusive { tag } => {
let mut out = vec![
Line::from(Span::styled(
"Couldn't compare versions.".to_owned(),
theme.h1,
)),
Line::raw(""),
];
out.extend(version_rows(theme, installed, tag));
out.push(Line::raw(""));
out.push(Line::from(Span::styled(
"The latest release isn't named like a plain version number, so \
edamame can't tell whether it is newer. Check the release page \
to be sure."
.to_owned(),
theme.modal_description,
)));
out
}
UpdateReport::Failed => vec![
Line::from(Span::styled(
"Couldn't check for updates.".to_owned(),
theme.h1,
)),
Line::raw(""),
Line::from(Span::styled(
"GitHub could not be reached. Your installed version is unchanged.".to_owned(),
theme.modal_description,
)),
],
UpdateReport::Available { tag, notes } => {
let mut out = vec![
Line::from(Span::styled(
"A new version of edamame is available.".to_owned(),
theme.h1,
)),
Line::raw(""),
];
out.extend(version_rows(theme, installed, tag));
if !notes.is_empty() {
out.push(Line::raw(""));
out.extend(note_lines(notes));
}
out
}
}
}
pub enum PostUpgradeReport<'a> {
Found { notes: &'a [String] },
NotFound,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PostUpgradeOccasion {
Upgrade,
OnDemand,
}
pub fn post_upgrade_body_lines(
theme: &Theme,
occasion: PostUpgradeOccasion,
report: PostUpgradeReport<'_>,
installed: &str,
) -> Vec<Line<'static>> {
let mut out = vec![match occasion {
PostUpgradeOccasion::Upgrade => {
Line::from(Span::styled(format!("Updated to v{installed}."), theme.h1))
}
PostUpgradeOccasion::OnDemand => version_row(
theme,
VERSION_LABEL,
format!("v{installed}"),
VERSION_LABEL.len() + 1,
),
}];
match report {
PostUpgradeReport::NotFound => {
out.push(Line::raw(""));
out.push(Line::from(Span::styled(
"No release notes are bundled for this version.".to_owned(),
theme.modal_description,
)));
}
PostUpgradeReport::Found { notes } if !notes.is_empty() => {
out.push(Line::raw(""));
out.extend(note_lines(notes));
}
PostUpgradeReport::Found { .. } => {}
}
out
}
const BULLET: &str = "•";
fn note_lines(notes: &[String]) -> Vec<Line<'static>> {
let mut out: Vec<Line<'static>> = Vec::with_capacity(notes.len());
let mut after_heading = false;
for raw in notes {
if let Some(text) = heading_text(raw) {
if after_heading {
out.push(Line::raw(""));
}
out.push(Line::from(Span::styled(
text.to_owned(),
Style::new().add_modifier(Modifier::BOLD),
)));
after_heading = true;
continue;
}
if raw.trim().is_empty() {
let previous_was_blank = out.last().is_some_and(|l| l.width() == 0);
if !after_heading && !previous_was_blank && !out.is_empty() {
out.push(Line::raw(""));
}
continue;
}
after_heading = false;
out.push(Line::raw(bulleted(raw)));
}
while out.last().is_some_and(|l| l.width() == 0) {
out.pop();
}
out
}
fn heading_text(line: &str) -> Option<&str> {
let hashes = line.len() - line.trim_start_matches('#').len();
(1..=6)
.contains(&hashes)
.then(|| line[hashes..].strip_prefix(' '))
.flatten()
.map(str::trim)
.filter(|text| !text.is_empty())
}
fn bulleted(line: &str) -> String {
let indent = line.len() - line.trim_start().len();
let rest = &line[indent..];
match rest
.strip_prefix("- ")
.or_else(|| rest.strip_prefix("* "))
.or_else(|| rest.strip_prefix("+ "))
{
Some(item) => format!("{}{BULLET} {item}", &line[..indent]),
None => line.to_owned(),
}
}
fn version_rows(theme: &Theme, installed: &str, latest: &str) -> [Line<'static>; 2] {
[
version_row(
theme,
"Installed:",
format!("v{installed}"),
VERSION_LABEL_WIDTH,
),
version_row(theme, "Latest:", latest.to_owned(), VERSION_LABEL_WIDTH),
]
}
fn version_row(theme: &Theme, label: &str, value: String, width: usize) -> Line<'static> {
Line::from(vec![
Span::styled(format!("{label:<width$}"), theme.text_muted()),
Span::raw(value),
])
}
#[cfg(test)]
mod tests {
use super::*;
fn theme() -> &'static Theme {
Box::leak(Box::new(Theme::default()))
}
fn text(lines: &[Line<'static>]) -> String {
lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn checking_shows_the_requested_spinner_frame() {
let body = text(&body_lines(
theme(),
UpdateReport::Checking { spinner_frame: 2 },
"0.1.0",
));
assert!(body.contains(SPINNER_FRAMES[2]), "{body}");
}
#[test]
fn the_spinner_frame_wraps_rather_than_panicking() {
let body = text(&body_lines(
theme(),
UpdateReport::Checking {
spinner_frame: SPINNER_FRAMES.len() + 1,
},
"0.1.0",
));
assert!(body.contains(SPINNER_FRAMES[1]), "{body}");
}
#[test]
fn up_to_date_states_the_verdict_then_both_versions() {
let body = text(&body_lines(
theme(),
UpdateReport::UpToDate { tag: "v0.1.0" },
"0.1.0",
));
assert!(body.starts_with("edamame is up to date."), "{body}");
assert!(body.contains("Installed: v0.1.0"), "{body}");
assert!(body.contains("Latest: v0.1.0"), "{body}");
}
#[test]
fn the_two_version_rows_align_their_values() {
let body = text(&body_lines(
theme(),
UpdateReport::UpToDate { tag: "v0.2.0" },
"0.1.0",
));
let value = |label: &str| {
body.lines()
.find(|l| l.starts_with(label))
.map(|l| l[VERSION_LABEL_WIDTH..].to_owned())
.expect("row present")
};
assert_eq!(value("Installed:"), "v0.1.0");
assert_eq!(value("Latest:"), "v0.2.0");
}
#[test]
fn inconclusive_never_claims_a_verdict_it_did_not_reach() {
let body = text(&body_lines(
theme(),
UpdateReport::Inconclusive { tag: "v0.2.0-rc1" },
"0.1.0",
));
assert!(body.starts_with("Couldn't compare versions."));
assert!(body.contains("Installed:"), "{body}");
assert!(body.contains("v0.1.0"), "{body}");
assert!(body.contains("v0.2.0-rc1"), "{body}");
assert!(!body.contains("up to date"));
assert!(!body.contains("is available"));
}
#[test]
fn failed_says_so_without_blaming_the_installed_version() {
let body = text(&body_lines(theme(), UpdateReport::Failed, "0.1.0"));
assert!(body.contains("Couldn't check for updates"), "{body}");
assert!(body.contains("unchanged"), "{body}");
}
#[test]
fn available_lists_the_notes_under_the_versions() {
let notes = vec![
"### Added".to_owned(),
"- **bold** stays literal".to_owned(),
];
let body = text(&body_lines(
theme(),
UpdateReport::Available {
tag: "v0.2.0",
notes: ¬es,
},
"0.1.0",
));
assert!(
body.starts_with("A new version of edamame is available."),
"{body}"
);
assert!(body.contains("Installed: v0.1.0"), "{body}");
assert!(body.contains("Latest: v0.2.0"), "{body}");
assert!(body.contains("\nAdded"), "{body}");
assert!(body.contains("• **bold** stays literal"), "{body}");
}
#[test]
fn a_heading_is_bolded_and_a_list_marker_becomes_a_bullet() {
let notes = vec!["## Fixed".to_owned(), "* a bug".to_owned()];
let lines = note_lines(¬es);
assert_eq!(text(&lines), "Fixed\n• a bug");
assert!(
lines[0].spans[0]
.style
.add_modifier
.contains(Modifier::BOLD),
"the heading carries the only styling this function applies"
);
assert_eq!(
lines[1].spans[0].style,
Style::default(),
"a list item is plain text with a swapped marker"
);
}
#[test]
fn the_blank_under_a_heading_goes_but_the_one_above_it_stays() {
let notes =
["### Added", "", "- one", "", "### Changed", "", "- two", ""].map(str::to_owned);
assert_eq!(text(¬e_lines(¬es)), "Added\n• one\n\nChanged\n• two");
}
#[test]
fn nesting_and_ordinary_prose_are_left_alone() {
let notes = [
"Some prose - with a dash.",
"- top",
" - nested",
"1. numbered",
"#not-a-heading",
]
.map(str::to_owned);
assert_eq!(
text(¬e_lines(¬es)),
"Some prose - with a dash.\n• top\n • nested\n1. numbered\n#not-a-heading"
);
}
#[test]
fn available_without_notes_omits_the_notes_block() {
let body = body_lines(
theme(),
UpdateReport::Available {
tag: "v0.2.0",
notes: &[],
},
"0.1.0",
);
assert_eq!(
body.len(),
4,
"headline, blank, and the two version rows — nothing else"
);
assert!(!text(&body).ends_with('\n'));
}
#[test]
fn the_upgrade_notice_announces_the_new_version_and_lists_the_notes() {
let notes = vec!["### Added".to_owned(), "- a thing".to_owned()];
let body = post_upgrade_body_lines(
theme(),
PostUpgradeOccasion::Upgrade,
PostUpgradeReport::Found { notes: ¬es },
"0.1.2",
);
let rendered = text(&body);
assert!(rendered.starts_with("Updated to v0.1.2."));
assert!(!rendered.contains(VERSION_LABEL));
assert!(!rendered.contains("Installed:"));
assert!(rendered.contains("Added"));
assert!(!rendered.contains("### Added"));
assert!(rendered.contains("\u{2022} a thing"));
}
#[test]
fn the_on_demand_opening_names_the_build_without_claiming_an_upgrade() {
let notes = vec!["- a thing".to_owned()];
let body = post_upgrade_body_lines(
theme(),
PostUpgradeOccasion::OnDemand,
PostUpgradeReport::Found { notes: ¬es },
"0.1.2",
);
let rendered = text(&body);
assert!(rendered.starts_with("edamame version: v0.1.2"));
assert!(!rendered.contains("Updated to"));
assert!(rendered.contains("\u{2022} a thing"));
}
#[test]
fn a_post_upgrade_report_shows_no_second_version_row() {
for occasion in [PostUpgradeOccasion::Upgrade, PostUpgradeOccasion::OnDemand] {
let body = post_upgrade_body_lines(
theme(),
occasion,
PostUpgradeReport::Found { notes: &[] },
"0.1.2",
);
assert!(!text(&body).contains("Latest:"));
assert_eq!(
body.len(),
1,
"an empty section leaves just the opening line — nothing else"
);
}
}
#[test]
fn a_missing_section_says_so_without_claiming_an_update() {
let body = post_upgrade_body_lines(
theme(),
PostUpgradeOccasion::OnDemand,
PostUpgradeReport::NotFound,
"0.1.2",
);
let rendered = text(&body);
assert!(rendered.contains("edamame version: v0.1.2"));
assert!(rendered.contains("No release notes are bundled"));
assert!(!rendered.contains("Updated to"));
}
}