use ratatui::{Frame, layout::Rect, text::Line};
use crate::{
config,
glyphs::{BorderScratch, GlyphSet},
keys::{self, BindingTable},
theme::{self, Theme},
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Warning {
Theme(theme::ThemeWarning),
Config(config::document::Warning),
OnRefreshFailed {
action: String,
entities: usize,
},
FetchFailed(usize),
DiscoveryAbandoned(String),
Vanished(usize),
}
impl Warning {
fn rank(&self) -> u8 {
match self {
Warning::Theme(_) => 1,
Warning::Config(_) => 2,
Warning::OnRefreshFailed {
action: _,
entities: _,
} => 3,
Warning::FetchFailed(_) => 4,
Warning::DiscoveryAbandoned(_) => 5,
Warning::Vanished(_) => 6,
}
}
}
impl std::fmt::Display for Warning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Warning::Theme(warning) => write!(f, "{warning}"),
Warning::Config(warning) => write!(f, "{warning}"),
Warning::OnRefreshFailed { action, entities } => {
write!(f, "on_refresh `{action}` failed a step on {entities} rows")
}
Warning::FetchFailed(count) => {
write!(f, "periodic fetch failed on {count} repositories")
}
Warning::DiscoveryAbandoned(message) => write!(f, "{message}"),
Warning::Vanished(count) => write!(f, "{count} vanished, d to dismiss"),
}
}
}
pub(crate) struct WarningSources {
pub(crate) theme: Vec<theme::ThemeWarning>,
pub(crate) config: Vec<config::document::Warning>,
pub(crate) on_refresh_failed: Option<(String, usize)>,
pub(crate) fetch_failed: usize,
pub(crate) discovery_abandoned: Option<String>,
pub(crate) vanished: usize,
}
impl WarningSources {
pub(crate) fn into_warnings(self) -> Vec<Warning> {
let WarningSources {
theme,
config,
on_refresh_failed,
fetch_failed,
discovery_abandoned,
vanished,
} = self;
let mut warnings: Vec<Warning> = theme.into_iter().map(Warning::Theme).collect();
warnings.extend(config.into_iter().map(Warning::Config));
if let Some((action, entities)) = on_refresh_failed.filter(|(_, entities)| *entities > 0) {
warnings.push(Warning::OnRefreshFailed { action, entities });
}
if fetch_failed > 0 {
warnings.push(Warning::FetchFailed(fetch_failed));
}
warnings.extend(
discovery_abandoned
.into_iter()
.map(Warning::DiscoveryAbandoned),
);
if vanished > 0 {
warnings.push(Warning::Vanished(vanished));
}
warnings
}
}
pub(crate) fn most_severe(warnings: &[Warning]) -> Option<&Warning> {
warnings.iter().max_by_key(|warning| warning.rank())
}
fn sorted_by_severity(warnings: &[Warning]) -> Vec<&Warning> {
let mut sorted: Vec<&Warning> = warnings.iter().collect();
sorted.sort_by_key(|warning| std::cmp::Reverse(warning.rank()));
sorted
}
pub(crate) fn slot_line(warnings: &[Warning], bindings: &BindingTable) -> Option<String> {
let most_severe = most_severe(warnings)?;
if warnings.len() == 1 {
return Some(most_severe.to_string());
}
let (code, modifiers) = bindings
.primary_chord(keys::Context::Global, keys::Action::ExpandWarning)
.unwrap_or_else(|| {
panic!("ExpandWarning is not bound in Global, but the warning slot names it")
});
let key = keys::chord_label(code, modifiers);
Some(format!(
"{most_severe} (+{} more, {key} to expand)",
warnings.len() - 1
))
}
pub(crate) const BORDER_TITLE: &str = " warnings ";
pub(crate) const CLOSE_HINT: &str = " esc closes ";
pub(crate) fn draw_overlay(
frame: &mut Frame,
area: Rect,
warnings: &[Warning],
theme: &Theme,
glyphs: &'static GlyphSet,
) {
let style = theme.style_for(theme::Role::Warn);
let mut scratch = BorderScratch::new();
let block = glyphs
.bordered_block(&mut scratch)
.border_style(style)
.title(BORDER_TITLE)
.title_bottom(Line::from(CLOSE_HINT).right_aligned());
let interior = block.inner(area);
frame.render_widget(block, area);
let buf = frame.buffer_mut();
for (row, warning) in sorted_by_severity(warnings)
.iter()
.take(interior.height as usize)
.enumerate()
{
buf.set_string(
interior.x,
interior.y + row as u16,
warning.to_string(),
style,
);
}
}
pub(crate) fn log_discovery_warning_once(
discovery_warning: Option<&String>,
already_logged: &mut bool,
) {
if *already_logged {
return;
}
if let Some(message) = discovery_warning {
tracing::warn!("{message}");
*already_logged = true;
}
}
#[cfg(test)]
mod tests {
use ratatui::{Terminal, backend::TestBackend};
use super::*;
use crate::{config::document, test_support::capture_tracing};
fn theme_unknown_key(key: &str) -> Warning {
Warning::Theme(theme::ThemeWarning::UnknownKey {
key: key.to_string(),
})
}
fn config_set_named_all() -> Warning {
Warning::Config(document::Warning::SetNamedAll)
}
fn discovery_abandoned(directories: usize) -> Warning {
Warning::DiscoveryAbandoned(format!("discovery: stopped at {directories} directories"))
}
fn vanished(count: usize) -> Warning {
Warning::Vanished(count)
}
fn on_refresh_failed(entities: usize) -> Warning {
Warning::OnRefreshFailed {
action: "sync".to_string(),
entities,
}
}
fn fetch_failed(count: usize) -> Warning {
Warning::FetchFailed(count)
}
#[test]
fn into_warnings_folds_every_source_into_one_flat_list_in_field_order() {
let sources = WarningSources {
theme: vec![theme::ThemeWarning::UnknownKey {
key: "x".to_string(),
}],
config: vec![document::Warning::SetNamedAll],
on_refresh_failed: Some(("sync".to_string(), 3)),
fetch_failed: 4,
discovery_abandoned: Some("discovery: stopped at 5 directories".to_string()),
vanished: 2,
};
let warnings = sources.into_warnings();
assert_eq!(
warnings,
vec![
theme_unknown_key("x"),
config_set_named_all(),
on_refresh_failed(3),
fetch_failed(4),
discovery_abandoned(5),
vanished(2),
]
);
}
#[test]
fn a_missing_discovery_warning_and_a_zero_vanished_count_contribute_nothing_to_the_flat_list() {
let sources = WarningSources {
theme: Vec::new(),
config: Vec::new(),
on_refresh_failed: Some(("sync".to_string(), 0)),
fetch_failed: 0,
discovery_abandoned: None,
vanished: 0,
};
assert!(sources.into_warnings().is_empty());
}
#[test]
fn most_severe_picks_vanished_over_discovery_config_and_theme_even_arriving_last_and_outnumbered()
{
let warnings = vec![
theme_unknown_key("a"),
theme_unknown_key("b"),
config_set_named_all(),
discovery_abandoned(412_000),
vanished(7),
];
let winner = most_severe(&warnings).expect("expected a most-severe warning");
assert_eq!(winner, &vanished(7));
}
#[test]
fn most_severe_picks_discovery_over_config_and_theme_even_arriving_last_and_outnumbered() {
let warnings = vec![
theme_unknown_key("a"),
theme_unknown_key("b"),
config_set_named_all(),
discovery_abandoned(412_000),
];
let winner = most_severe(&warnings).expect("expected a most-severe warning");
assert_eq!(winner, &discovery_abandoned(412_000));
}
#[test]
fn a_single_outstanding_warning_is_trivially_its_own_most_severe() {
let warnings = vec![theme_unknown_key("solo")];
assert_eq!(most_severe(&warnings), Some(&theme_unknown_key("solo")));
}
#[test]
fn no_outstanding_warnings_means_no_most_severe() {
assert_eq!(most_severe(&[]), None);
}
#[test]
fn a_single_warning_renders_as_just_its_own_message() {
let warnings = vec![config_set_named_all()];
let bindings = BindingTable::compiled_default();
let line = slot_line(&warnings, &bindings).expect("expected a slot line");
assert_eq!(line, config_set_named_all().to_string());
}
#[test]
fn several_warnings_name_the_most_severe_and_the_live_expand_key() {
let warnings = vec![
theme_unknown_key("a"),
config_set_named_all(),
discovery_abandoned(5),
];
let bindings = BindingTable::compiled_default();
let line = slot_line(&warnings, &bindings).expect("expected a slot line");
assert!(
line.contains(&discovery_abandoned(5).to_string()),
"expected the most severe condition named, got: {line:?}"
);
assert!(
line.contains("+2 more"),
"expected the other two outstanding conditions counted, got: {line:?}"
);
assert!(
line.contains("w to expand"),
"expected the compiled default's `w` binding named, got: {line:?}"
);
}
#[test]
fn slot_line_names_a_rebound_expand_key_rather_than_a_hardcoded_one() {
let warnings = vec![theme_unknown_key("a"), config_set_named_all()];
let mut context_table = toml::Table::new();
context_table.insert(
"expand_warning".to_string(),
toml::Value::String("x".to_string()),
);
let mut document_keys = toml::Table::new();
document_keys.insert("global".to_string(), toml::Value::Table(context_table));
let (bindings, _) =
keys::merge(&document_keys).expect("expected the rebind to merge cleanly");
let line = slot_line(&warnings, &bindings).expect("expected a slot line");
assert!(
line.contains("x to expand"),
"expected the rebound key named, got: {line:?}"
);
assert!(
!line.contains("w to expand"),
"the old default key must not still appear once it has been rebound, got: {line:?}"
);
}
#[test]
fn no_warnings_means_no_slot_line() {
assert_eq!(slot_line(&[], &BindingTable::compiled_default()), None);
}
#[test]
fn slot_line_answers_identically_on_a_later_call_with_the_same_warnings_still_outstanding() {
let warnings = vec![config_set_named_all()];
let bindings = BindingTable::compiled_default();
let first = slot_line(&warnings, &bindings);
let second = slot_line(&warnings, &bindings);
assert_eq!(
first, second,
"the same outstanding warnings must compute the same slot text on a later call"
);
}
const RENDER_WIDTH: u16 = 100;
fn render_overlay(warnings: &[Warning], height: u16) -> Vec<String> {
let backend = TestBackend::new(RENDER_WIDTH, height);
let mut terminal = Terminal::new(backend).expect("create test terminal");
terminal
.draw(|frame| {
let area = frame.area();
draw_overlay(frame, area, warnings, &theme::DEFAULT, &crate::glyphs::FULL);
})
.expect("draw the overlay");
let buffer = terminal.backend().buffer().clone();
(0..height)
.map(|row| {
(0..buffer.area.width)
.map(|column| buffer[(column, row)].symbol())
.collect::<String>()
})
.collect()
}
#[test]
fn the_expansion_lists_every_outstanding_condition_most_severe_first() {
let warnings = vec![
theme_unknown_key("a"),
discovery_abandoned(5),
config_set_named_all(),
];
let lines = render_overlay(&warnings, 5);
assert!(
lines[1].contains(&discovery_abandoned(5).to_string()),
"expected the most severe condition on the first interior row, got: {lines:?}"
);
assert!(
lines
.iter()
.any(|line| line.contains("shadowing the implicit Set")),
"expected the config warning listed, got: {lines:?}"
);
assert!(
lines
.iter()
.any(|line| line.contains("unknown theme key `a`")),
"expected the theme warning listed, got: {lines:?}"
);
}
#[test]
fn the_expansion_truncates_to_the_interior_height_not_the_whole_frame_height() {
let warnings = vec![
theme_unknown_key("a"),
theme_unknown_key("b"),
theme_unknown_key("c"),
theme_unknown_key("d"),
];
let lines = render_overlay(&warnings, 5);
let interior = &lines[1..4];
assert!(
!lines
.iter()
.any(|line| line.contains(&theme_unknown_key("d").to_string())),
"expected the fourth warning dropped entirely once the border leaves only three \
interior rows, got: {lines:?}"
);
for (warning, row) in ["a", "b", "c"].iter().zip(interior) {
assert!(
row.contains(&theme_unknown_key(warning).to_string()),
"expected warning {warning:?} still drawn in the available interior rows, got: \
{lines:?}"
);
}
}
#[test]
fn draw_overlay_frames_itself_in_the_active_glyph_tables_own_border_with_both_titles() {
for glyphs in [&crate::glyphs::FULL, &crate::glyphs::ASCII] {
let warnings = vec![theme_unknown_key("a")];
let area = ratatui::layout::Rect::new(0, 0, RENDER_WIDTH, 5);
let backend = TestBackend::new(area.width, area.height);
let mut terminal = Terminal::new(backend).expect("create test terminal");
terminal
.draw(|frame| draw_overlay(frame, frame.area(), &warnings, &theme::DEFAULT, glyphs))
.expect("draw the overlay");
let buf = terminal.backend().buffer().clone();
let border = glyphs.border;
assert_eq!(
buf[(0, 0)].symbol(),
border.top_left.to_string(),
"expected the top-left corner from the active glyph table"
);
assert_eq!(
buf[(area.width - 1, 0)].symbol(),
border.top_right.to_string(),
"expected the top-right corner from the active glyph table"
);
assert_eq!(
buf[(0, area.height - 1)].symbol(),
border.bottom_left.to_string(),
"expected the bottom-left corner from the active glyph table"
);
assert_eq!(
buf[(area.width - 1, area.height - 1)].symbol(),
border.bottom_right.to_string(),
"expected the bottom-right corner from the active glyph table"
);
let top_row: String = (0..area.width).map(|x| buf[(x, 0)].symbol()).collect();
let expected_top_head = format!("{}{BORDER_TITLE}", border.top_left);
assert!(
top_row.starts_with(&expected_top_head),
"expected the top title right after the top-left corner, got {top_row:?}"
);
let bottom_row: String = (0..area.width)
.map(|x| buf[(x, area.height - 1)].symbol())
.collect();
let expected_bottom_tail = format!("{CLOSE_HINT}{}", border.bottom_right);
assert!(
bottom_row.ends_with(&expected_bottom_tail),
"expected the close hint right-aligned against the bottom-right corner, got \
{bottom_row:?}"
);
}
}
#[test]
fn draw_overlay_paints_the_border_in_the_themes_warn_colour() {
let theme = Theme {
warn: ratatui::style::Color::Rgb(1, 2, 3),
..Theme::default()
};
let warnings = vec![theme_unknown_key("a")];
let backend = TestBackend::new(RENDER_WIDTH, 5);
let mut terminal = Terminal::new(backend).expect("create test terminal");
terminal
.draw(|frame| {
draw_overlay(frame, frame.area(), &warnings, &theme, &crate::glyphs::FULL);
})
.expect("draw the overlay");
let buf = terminal.backend().buffer();
assert_eq!(buf[(0, 0)].fg, theme.warn);
}
#[test]
fn a_discovery_abandoned_warning_is_logged_to_the_file_writer() {
let mut already_logged = false;
let message = "discovery: stopped at 5 directories".to_string();
let logs = capture_tracing(|| {
log_discovery_warning_once(Some(&message), &mut already_logged);
});
assert!(
logs.contains(&message),
"expected the discovery warning's own message logged, got: {logs:?}"
);
}
#[test]
fn a_discovery_abandoned_warning_is_logged_exactly_once_even_when_checked_every_tick() {
let mut already_logged = false;
let message = "discovery: stopped at 5 directories".to_string();
let logs = capture_tracing(|| {
for _ in 0..5 {
log_discovery_warning_once(Some(&message), &mut already_logged);
}
});
assert_eq!(
logs.matches(&message).count(),
1,
"expected exactly one log line despite five checks against the same still-set \
warning, got: {logs:?}"
);
}
#[test]
fn no_discovery_warning_logs_nothing() {
let mut already_logged = false;
let logs = capture_tracing(|| {
log_discovery_warning_once(None, &mut already_logged);
});
assert!(logs.is_empty(), "expected no log line, got: {logs:?}");
assert!(!already_logged);
}
#[test]
fn rank_matches_theming_mds_own_severity_order() {
let theming = spec("theming.md");
let sentence = theming
.split("The warning slot carries **standing conditions of the session only**: ")
.nth(1)
.expect("theming.md still introduces the warning slot's population in that sentence");
let (clauses_text, _) = sentence
.split_once('.')
.expect("the standing-conditions sentence ends with a full stop");
let clauses: Vec<&str> = clauses_text.split(", ").collect();
assert_eq!(
clauses.len(),
6,
"expected theming.md to name exactly the six standing conditions `Warning` has \
variants for, got: {clauses:?}"
);
let ranks: Vec<u8> = clauses
.iter()
.map(|clause| {
if clause.contains("theme") {
theme_unknown_key("x").rank()
} else if clause.contains("on_refresh") {
on_refresh_failed(1).rank()
} else if clause.contains("periodic fetch") {
fetch_failed(1).rank()
} else if clause.contains("config") {
config_set_named_all().rank()
} else if clause.contains("discovery") {
discovery_abandoned(5).rank()
} else if clause.contains("vanished") {
vanished(1).rank()
} else {
panic!(
"theming.md names a standing condition this test cannot classify \
against a `Warning` variant: {clause:?}"
)
}
})
.collect();
assert!(
ranks.windows(2).all(|pair| pair[0] < pair[1]),
"`Warning::rank` must increase in the same order theming.md lists the standing \
conditions, least severe first; got ranks {ranks:?} for clauses {clauses:?}"
);
}
fn spec(name: &str) -> String {
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
std::fs::read_to_string(manifest_dir.join("../../docs/spec").join(name))
.unwrap_or_else(|_| panic!("read docs/spec/{name}"))
}
#[test]
fn the_status_row_contract_lives_in_one_document_and_the_others_redirect() {
let layout = spec("layout-and-provenance.md");
assert!(
layout.contains("## The status row"),
"layout-and-provenance.md owns the status row contract in full"
);
for (name, superseded) in [
("theming.md", "then the header"),
("actions.md", "Priority while a run is in flight"),
] {
let text = spec(name);
assert!(
text.contains("layout-and-provenance.md#the-status-row"),
"{name} must redirect to the status row contract rather than drop the reader"
);
assert!(
!text.contains(superseded),
"{name} still states its own row priority (`{superseded}`), which is the \
second copy 0026 removed"
);
}
}
#[test]
fn the_status_row_ladder_floors_at_the_reserved_warning_indicator() {
let layout = spec("layout-and-provenance.md");
let section = layout
.split("## The status row")
.nth(1)
.expect("the status row section is present");
let ladder: Vec<(usize, &str)> = section
.split("```")
.nth(1)
.expect("the status row section carries a ladder")
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| {
let (width, rendered) = line
.trim_start()
.split_once(char::is_whitespace)
.expect("every rung is a width and the line it renders");
(
width
.parse::<usize>()
.expect("the rung's width is a number"),
rendered.trim(),
)
})
.collect();
for (width, rendered) in &ladder {
assert_eq!(
rendered.chars().count(),
*width,
"rung {width} renders {} columns: `{rendered}`",
rendered.chars().count()
);
}
let (floor_width, floor) = ladder.last().expect("the ladder has rungs");
assert!(
floor.starts_with('[') && floor.chars().count() == *floor_width,
"the narrowest rung is the reserved indicator alone, got `{floor}`"
);
assert!(
ladder.iter().all(|(_, rendered)| rendered.starts_with('[')),
"the indicator is reserved ahead of every item, so no rung may drop it"
);
}
fn ladder(name: &str, heading: &str) -> Vec<(usize, String)> {
let text = spec(name);
let section = text
.split(heading)
.nth(1)
.unwrap_or_else(|| panic!("docs/spec/{name} carries `{heading}`"));
section
.split("```")
.nth(1)
.unwrap_or_else(|| panic!("`{heading}` in docs/spec/{name} carries a ladder"))
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| {
let (width, rendered) = line
.trim_start()
.split_once(char::is_whitespace)
.expect("every rung is a width and the line it renders");
(
width
.parse::<usize>()
.expect("the rung's width is a number"),
rendered.trim().to_string(),
)
})
.collect()
}
#[test]
fn the_acknowledged_ladder_is_the_headers_own_shifted_by_the_reserved_indicator() {
let header = ladder("actions.md", "## The run on screen");
for (width, rendered) in &header {
assert_eq!(
rendered.chars().count(),
*width,
"header rung {width} renders {} columns: `{rendered}`",
rendered.chars().count()
);
}
let shifted: Vec<String> = header
.iter()
.map(|(width, _)| (width + 4).to_string())
.collect();
let layout = spec("layout-and-provenance.md");
let stated = layout
.split("shifted four columns by the reserved indicator: ")
.nth(1)
.expect("layout-and-provenance.md states the acknowledged ladder")
.split(", and the same")
.next()
.expect("the stated ladder ends before the floor");
assert_eq!(
stated,
shifted.join(", "),
"the acknowledged ladder must be the header's own plus the indicator's four columns"
);
}
#[test]
fn the_status_rows_first_item_names_the_active_set_rather_than_the_program() {
let layout = spec("layout-and-provenance.md");
assert!(
layout.contains("| 1 | the active Set's name and the entity count |"),
"rank 1 is the active Set's name and the count it bounds"
);
for name in ["layout-and-provenance.md", "actions.md"] {
assert!(
!spec(name).contains("repon 403 entities"),
"docs/spec/{name} still opens its ladder with the program's name"
);
}
}
}