use std::time::Instant;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use super::super::app::{App, Control, REVEAL_HOLDS, Reveal, SecretsPane, TypingWhat};
use super::super::secrets::{SecretRow, Source};
use super::super::theme::Palette;
use super::flock::{GUTTER, fit, gutter};
use super::status;
use crate::output::human_duration;
use crate::output::width::char_columns;
use crate::secret_readers::Reader;
use crate::vocabulary::Role;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Column {
Key,
Value,
InForce,
SetIn,
ReadBy,
Lands,
}
impl Column {
pub(super) const fn width(self) -> u16 {
match self {
Self::Key => 28,
Self::Value => 30,
Self::InForce => 14,
Self::SetIn => 22,
Self::ReadBy => 22,
Self::Lands => 42,
}
}
const fn heading(self) -> &'static str {
match self {
Self::Key => "KEY",
Self::Value => "VALUE",
Self::InForce => "IN FORCE",
Self::SetIn => "SET IN",
Self::ReadBy => "READ BY",
Self::Lands => "LANDS",
}
}
}
pub(super) const SECRET_TIERS: &[(u16, &[Column])] = &[
(
160,
&[
Column::Key,
Column::Value,
Column::InForce,
Column::SetIn,
Column::ReadBy,
Column::Lands,
],
),
(
118,
&[
Column::Key,
Column::Value,
Column::InForce,
Column::SetIn,
Column::ReadBy,
],
),
(
96,
&[Column::Key, Column::Value, Column::InForce, Column::SetIn],
),
(74, &[Column::Key, Column::Value, Column::InForce]),
];
const MIN_WIDTH: u16 = SECRET_TIERS[SECRET_TIERS.len() - 1].0;
const PANE_BAND_LABEL: &str = "SECRETS flock-wide values a Flockfile refers to and never carries";
const STORE_LINE: &str = "store $SHEP_HOME/secrets.json \u{b7} not encrypted \u{b7} never \
printed to a log, never carried in a bleat \u{b7} read at spawn, not now";
fn pane_band(width: u16, palette: Palette) -> Line<'static> {
Line::from(Span::styled(
super::cell::band(PANE_BAND_LABEL, usize::from(width)),
palette.band(Role::Butter),
))
}
fn gates_line(pane: &SecretsPane, control: Control, palette: Palette) -> Line<'static> {
let allowed = matches!(control, Control::Allowed);
Line::from(Span::styled(
format!(
"reveal [secrets] allow_read = {} in shep.toml \u{b7} change \
lookout.allow_control = {allowed}",
pane.model.allow_read
),
palette.muted(),
))
}
pub(super) fn columns_for(width: u16) -> &'static [Column] {
SECRET_TIERS
.iter()
.find(|(threshold, _)| width >= *threshold)
.map_or(SECRET_TIERS[SECRET_TIERS.len() - 1].1, |(_, columns)| {
*columns
})
}
fn byte_count(len: usize) -> String {
if len == 1 {
"1 byte".to_string()
} else {
format!("{len} bytes")
}
}
fn tail(text: &str, width: u16) -> String {
let width = usize::from(width);
let columns: usize = text.chars().map(char_columns).sum();
if columns <= width {
let mut out = String::from(text);
out.extend(core::iter::repeat_n(' ', width - columns));
return out;
}
if width == 0 {
return String::new();
}
let budget = width - 1;
let mut kept = String::new();
let mut used = 0;
for c in text.chars().rev() {
let c_width = char_columns(c);
if used + c_width > budget {
break;
}
kept.push(c);
used += c_width;
}
let mut out = String::from("\u{2026}");
out.extend(core::iter::repeat_n(' ', budget - used));
out.extend(kept.chars().rev());
out
}
fn typed_cell(buffer: &str, width: u16) -> String {
tail(&format!("{buffer}\u{2588}"), width.saturating_sub(1))
}
fn value_cell(
row: &SecretRow,
revealed: Option<&Reveal>,
typing: Option<&str>,
width: u16,
) -> String {
if let Some(buffer) = typing {
return fit(&typed_cell(buffer, width), width);
}
if let Some(reveal) = revealed {
return fit(&reveal.value, width);
}
let Some(len) = row.byte_len else {
return "not set here".to_string();
};
let suffix = format!(" {}", byte_count(len));
let run = usize::from(width)
.saturating_sub(suffix.len())
.saturating_sub(1)
.min(len);
format!("{}{suffix}", "â–ˆ".repeat(run.max(1)))
}
fn set_in_cell(row: &SecretRow, environment_count: usize) -> String {
if row.set_in.is_empty() {
return "-".to_string();
}
let count = row.set_in.len();
if environment_count > 0 && count >= environment_count {
format!("{count} of {environment_count}")
} else {
format!(
"{count} of {environment_count} \u{b7} {}",
row.set_in.join(", ")
)
}
}
const GAUGE_CELLS: usize = 10;
fn countdown(until: Instant, now: Instant) -> String {
let left = until
.saturating_duration_since(now)
.as_millis()
.div_ceil(1000);
let hold = u128::from(REVEAL_HOLDS.as_secs()).max(1);
let cells = u128::try_from(GAUGE_CELLS).unwrap_or(0);
let filled = usize::try_from(left * cells / hold)
.unwrap_or(0)
.min(GAUGE_CELLS);
format!(
"visible {left}s {}{}",
"\u{2588}".repeat(filled),
"\u{2591}".repeat(GAUGE_CELLS - filled)
)
}
fn row_cell(
row: &SecretRow,
column: Column,
revealed: Option<&Reveal>,
typing: Option<&str>,
environment_count: usize,
now: Instant,
) -> String {
match column {
Column::Key => row.key.clone(),
Column::Value => value_cell(row, revealed, typing, column.width()),
Column::InForce => row.in_force.clone().unwrap_or_else(|| "-".to_string()),
Column::SetIn => set_in_cell(row, environment_count),
Column::ReadBy => {
if row.readers.is_empty() {
"-".to_string()
} else {
let online = row.readers.iter().filter(|reader| reader.online).count();
format!("{} ({online} online)", row.readers.len())
}
}
Column::Lands => {
revealed.map_or_else(|| "-".to_string(), |reveal| countdown(reveal.until, now))
}
}
}
fn row_line(
pane: &SecretsPane,
row: &SecretRow,
columns: &[Column],
width: u16,
palette: Palette,
selected: bool,
now: Instant,
) -> Line<'static> {
let ground = if selected {
palette.ground()
} else {
Style::default()
};
let revealed = pane.reveal.as_ref().filter(|reveal| reveal.key == row.key);
let typing = if selected {
pane.typing.as_ref().and_then(|typing| match &typing.what {
TypingWhat::ValueFor(key) if key == &row.key => Some(typing.buffer.as_str()),
TypingWhat::ValueFor(_) | TypingWhat::NewKey => None,
})
} else {
None
};
let environment_count = pane.model.environments.len();
let mut spans: Vec<Span<'static>> = Vec::with_capacity(columns.len());
let mut used = 0u16;
for column in columns {
let text = fit(
&row_cell(row, *column, revealed, typing, environment_count, now),
column.width(),
);
spans.push(Span::styled(text, ground));
used += column.width();
}
pad(&mut spans, used, width, ground);
Line::from(spans)
}
fn pad(spans: &mut Vec<Span<'static>>, used: u16, width: u16, style: Style) {
let short = width.saturating_sub(used);
if short > 0 {
spans.push(Span::styled(" ".repeat(usize::from(short)), style));
}
}
fn new_key_row_line(
pane: &SecretsPane,
columns: &[Column],
width: u16,
palette: Palette,
selected: bool,
) -> Line<'static> {
let ground = if selected {
palette.ground()
} else {
Style::default()
};
let typing_name = selected
.then_some(pane.typing.as_ref())
.flatten()
.and_then(|typing| {
matches!(typing.what, TypingWhat::NewKey).then_some(typing.buffer.as_str())
});
let mut spans: Vec<Span<'static>> = Vec::with_capacity(columns.len());
let mut used = 0u16;
for column in columns {
let text = match column {
Column::Key => "+ new key".to_string(),
Column::Value => {
typing_name.map_or_else(String::new, |buffer| typed_cell(buffer, column.width()))
}
Column::InForce | Column::SetIn | Column::ReadBy | Column::Lands => String::new(),
};
spans.push(Span::styled(fit(&text, column.width()), ground));
used += column.width();
}
pad(&mut spans, used, width, ground);
Line::from(spans)
}
fn heading_line(columns: &[Column], palette: Palette) -> Line<'static> {
let mut text = String::new();
for column in columns {
text.push_str(&fit(column.heading(), column.width()));
}
Line::from(Span::styled(text, palette.muted()))
}
fn roll_status_line(pane: &SecretsPane, palette: Palette) -> Line<'static> {
if let Some(message) = &pane.model.unreadable {
return Line::from(Span::styled(
format!("operator store unreadable: {message}"),
palette.refusal(),
));
}
if let Some(age) = pane.model.roll_age {
let ms = u64::try_from(age.as_millis()).unwrap_or(u64::MAX);
return Line::from(Span::styled(
format!("READ BY as of the roll, read {} ago", human_duration(ms)),
palette.muted(),
));
}
Line::from(Span::styled(
"no muster roll yet: READ BY and WHO READS IT show nothing until the shepherd writes one",
palette.muted(),
))
}
fn tab_line(pane: &SecretsPane, palette: Palette, width: u16) -> Line<'static> {
let labels: Vec<String> = pane
.model
.environments
.iter()
.enumerate()
.map(|(index, name)| {
if index == pane.tab {
format!("[{name}]")
} else {
name.clone()
}
})
.collect();
let environment_count = labels.len();
let suffix = format!("{environment_count} environments in this store \u{b7} \u{2190}/\u{2192}");
let budget = usize::from(width).saturating_sub(suffix.chars().count() + TAB_GAP);
let anchor = if pane.tab < labels.len() { pane.tab } else { 0 };
let (first, last) = tab_window(&labels, anchor, budget);
let mut spans = Vec::with_capacity(labels.len() * 2 + 4);
let mut drawn = 0usize;
if first > 0 {
spans.push(Span::styled("\u{2026}", palette.muted()));
drawn += 1;
}
for (index, label) in labels.iter().enumerate().take(last + 1).skip(first) {
if !spans.is_empty() {
spans.push(Span::raw(" "));
drawn += TAB_GAP;
}
drawn += label.chars().count();
let style = if index == pane.tab {
palette.attention()
} else {
palette.muted()
};
spans.push(Span::styled(label.clone(), style));
}
if last + 1 < labels.len() {
spans.push(Span::raw(" "));
spans.push(Span::styled("\u{2026}", palette.muted()));
drawn += TAB_ELISION;
}
let pad = usize::from(width)
.saturating_sub(drawn)
.saturating_sub(suffix.chars().count());
if pad > 0 {
spans.push(Span::raw(" ".repeat(pad)));
}
spans.push(Span::styled(suffix, palette.muted()));
Line::from(spans)
}
const TAB_GAP: usize = 2;
const TAB_ELISION: usize = TAB_GAP + 1;
fn tab_window(labels: &[String], active: usize, budget: usize) -> (usize, usize) {
let (mut first, mut last) = (active, active);
let mut used = labels.get(active).map_or(0, |l| l.chars().count());
loop {
let owed_left = usize::from(first > 0) * TAB_ELISION;
let owed_right = usize::from(last + 1 < labels.len()) * TAB_ELISION;
if let Some(next) = labels.get(last + 1) {
let cost = TAB_GAP + next.chars().count();
let still_owed = usize::from(last + 2 < labels.len()) * TAB_ELISION;
if used + cost + owed_left + still_owed <= budget {
used += cost;
last += 1;
continue;
}
}
if first > 0 {
let cost = TAB_GAP + labels[first - 1].chars().count();
let still_owed = usize::from(first > 1) * TAB_ELISION;
if used + cost + still_owed + owed_right <= budget {
used += cost;
first -= 1;
continue;
}
}
return (first, last);
}
}
fn group_header_line(
pane: &SecretsPane,
source: &Source,
palette: Palette,
width: u16,
) -> Line<'static> {
let count = pane.model.rows_for(source).count();
let text = match source {
Source::Operator => format!("\u{25be} operator \u{d7}{count} \u{b7} you set these"),
Source::Namespace(namespace) => {
let marker = if pane.collapsed.contains(namespace) {
'\u{25b8}'
} else {
'\u{25be}'
};
format!(
"{marker} {namespace} (dog) \u{d7}{count} \u{b7} pushed by a provider \u{b7} \
read-only here"
)
}
};
Line::from(Span::styled(fit(&text, width), palette.muted()))
}
const FOCUSED_WIDTH: u16 = 88;
const PANEL_CONTENT_ROWS: u16 = 4;
const PANEL_ROWS: u16 = 1 + 1 + PANEL_CONTENT_ROWS;
fn reader_line(reader: &Reader) -> String {
if reader.online {
format!(
"\u{2588} {} online, was given a value at spawn",
reader.name
)
} else {
format!(
"\u{2591} {} not running, reads it at next start",
reader.name
)
}
}
const NO_KEY_SELECTED: &str = "no key selected";
fn focused_lines(
row: Option<&SecretRow>,
width: u16,
) -> [Line<'static>; PANEL_CONTENT_ROWS as usize] {
let Some(row) = row else {
return [
Line::from(Span::raw(fit(NO_KEY_SELECTED, width))),
Line::default(),
Line::default(),
Line::default(),
];
};
let set_in = if row.set_in.is_empty() {
"not set here".to_string()
} else {
format!("set in {}", row.set_in.join(", "))
};
let detail = match row.byte_len {
Some(len) => format!(
"{set_in} \u{b7} length {} \u{b7} named by {} of the flock",
byte_count(len),
row.readers.len()
),
None => set_in,
};
[
Line::from(Span::raw(fit(
"the value leaves the screen after 10s. nothing records that you looked.",
width,
))),
Line::from(Span::raw(fit(
"the store is 0600: anyone who can reveal can also delete a log.",
width,
))),
Line::from(Span::raw(fit(
"the system clipboard is readable by every process on the desktop.",
width,
))),
Line::from(Span::raw(fit(&detail, width))),
]
}
const READER_ROWS: usize = PANEL_CONTENT_ROWS as usize - 1;
fn reader_row_texts(readers: &[Reader]) -> Vec<String> {
if readers.is_empty() {
return vec!["nothing names this key".to_string()];
}
if readers.len() <= READER_ROWS {
return readers.iter().map(reader_line).collect();
}
let mut texts: Vec<String> = readers[..READER_ROWS - 1].iter().map(reader_line).collect();
let overflow = readers.len() - (READER_ROWS - 1);
texts.push(format!(
"+ {overflow} more, named by {} of the flock",
readers.len()
));
texts
}
fn who_reads_it_lines(
row: Option<&SecretRow>,
width: u16,
) -> [Line<'static>; PANEL_CONTENT_ROWS as usize] {
let Some(row) = row else {
return [
Line::from(Span::raw(fit(NO_KEY_SELECTED, width))),
Line::default(),
Line::default(),
Line::default(),
];
};
let texts = reader_row_texts(&row.readers);
let mut lines: Vec<Line<'static>> = (0..READER_ROWS)
.map(|index| match texts.get(index) {
Some(text) => Line::from(Span::raw(fit(text, width))),
None => Line::default(),
})
.collect();
lines.push(Line::from(Span::styled(
fit("named in env, args, out_file or err_file", width),
Style::default(),
)));
lines.try_into().unwrap_or_else(|_| {
[
Line::default(),
Line::default(),
Line::default(),
Line::default(),
]
})
}
fn draw_panels(pane: &SecretsPane, palette: Palette, area: Rect, buffer: &mut Buffer, top: u16) {
let width = area.width;
buffer.set_line(
area.x,
top,
&status::rule_line(palette.line(), width),
width,
);
let row = pane.model.rows.get(pane.selected);
let key = row.map_or("", |row| row.key.as_str());
let right_x = area.x + FOCUSED_WIDTH.min(width);
let right_width = width.saturating_sub(FOCUSED_WIDTH);
buffer.set_line(
area.x,
top + 1,
&Line::from(Span::styled(
fit(&format!("FOCUSED {key}"), FOCUSED_WIDTH.min(width)),
palette.muted(),
)),
FOCUSED_WIDTH.min(width),
);
if right_width > 0 {
buffer.set_line(
right_x,
top + 1,
&Line::from(Span::styled(
fit(&format!("WHO READS IT {key}"), right_width),
palette.muted(),
)),
right_width,
);
}
for (offset, line) in focused_lines(row, FOCUSED_WIDTH.min(width))
.iter()
.enumerate()
{
let offset = u16::try_from(offset).unwrap_or(0);
buffer.set_line(area.x, top + 2 + offset, line, FOCUSED_WIDTH.min(width));
}
if right_width > 0 {
for (offset, line) in who_reads_it_lines(row, right_width).iter().enumerate() {
let offset = u16::try_from(offset).unwrap_or(0);
buffer.set_line(right_x, top + 2 + offset, line, right_width);
}
}
}
const CHROME_ROWS: u16 = 7;
const PANELS_MIN_ROWS: u16 = CHROME_ROWS + 2 + PANEL_ROWS;
fn content_bottom(area: Rect) -> u16 {
let bottom = area.y + area.height;
if area.height >= PANELS_MIN_ROWS {
bottom - PANEL_ROWS
} else {
bottom
}
}
fn draw_new_key_row(
pane: &SecretsPane,
columns: &[Column],
palette: Palette,
area: Rect,
buffer: &mut Buffer,
y: &mut u16,
) {
let bottom = content_bottom(area);
if *y >= bottom {
return;
}
let table_width = area.width.saturating_sub(GUTTER);
let selected = pane.selected_is_new_key_row();
let (gutter_text, gutter_style) = gutter(selected, palette);
buffer.set_line(
area.x,
*y,
&Line::from(Span::styled(gutter_text, gutter_style)),
1,
);
buffer.set_line(
area.x + GUTTER,
*y,
&new_key_row_line(pane, columns, table_width, palette, selected),
table_width,
);
*y += 1;
}
fn draw_too_narrow(width: u16, palette: Palette, area: Rect, buffer: &mut Buffer) {
buffer.set_line(
area.x,
area.y,
&Line::from(Span::styled("too narrow for secrets", palette.refusal())),
width,
);
if area.height < 2 {
return;
}
buffer.set_line(
area.x,
area.y + 1,
&Line::from(Span::styled(
format!("need {MIN_WIDTH} columns"),
palette.muted(),
)),
width,
);
}
pub fn draw(app: &App, pane: &SecretsPane, area: Rect, buffer: &mut Buffer) {
if area.width == 0 || area.height == 0 {
return;
}
let palette = app.palette();
let width = area.width;
if width < MIN_WIDTH {
draw_too_narrow(width, palette, area, buffer);
return;
}
let table_width = width.saturating_sub(GUTTER);
let columns = columns_for(width);
let bottom = area.y + area.height;
let content_bottom = content_bottom(area);
let mut y = area.y;
buffer.set_line(area.x, y, &pane_band(width, palette), width);
y += 1;
if y >= content_bottom {
return;
}
buffer.set_line(
area.x,
y,
&Line::from(Span::styled(STORE_LINE, palette.muted())),
width,
);
y += 1;
if y >= content_bottom {
return;
}
buffer.set_line(area.x, y, &gates_line(pane, app.control(), palette), width);
y += 1;
if y >= content_bottom {
return;
}
buffer.set_line(area.x, y, &roll_status_line(pane, palette), width);
y += 1;
if y >= content_bottom {
return;
}
buffer.set_line(area.x, y, &tab_line(pane, palette, width), width);
y += 1;
if y >= content_bottom {
return;
}
buffer.set_line(
area.x + GUTTER,
y,
&heading_line(columns, palette),
table_width,
);
y += 1;
if y >= content_bottom {
return;
}
buffer.set_line(area.x, y, &status::rule_line(palette.line(), width), width);
y += 1;
let anchor = pane.new_key_anchor();
let mut new_key_row_drawn = false;
if anchor.is_none() {
draw_new_key_row(pane, columns, palette, area, buffer, &mut y);
new_key_row_drawn = true;
}
let mut last_source: Option<&Source> = None;
for (index, row) in pane.model.rows.iter().enumerate() {
if y >= content_bottom {
break;
}
if last_source != Some(&row.source) {
buffer.set_line(
area.x + GUTTER,
y,
&group_header_line(pane, &row.source, palette, table_width),
table_width,
);
y += 1;
last_source = Some(&row.source);
if y >= content_bottom {
break;
}
}
if pane.is_collapsed(&row.source) {
continue;
}
let selected = index == pane.selected;
let (gutter_text, gutter_style) = gutter(selected, palette);
buffer.set_line(
area.x,
y,
&Line::from(Span::styled(gutter_text, gutter_style)),
1,
);
buffer.set_line(
area.x + GUTTER,
y,
&row_line(
pane,
row,
columns,
table_width,
palette,
selected,
app.now(),
),
table_width,
);
y += 1;
if !new_key_row_drawn && anchor == Some(index) {
draw_new_key_row(pane, columns, palette, area, buffer, &mut y);
new_key_row_drawn = true;
if y >= content_bottom {
break;
}
}
}
if !new_key_row_drawn {
draw_new_key_row(pane, columns, palette, area, buffer, &mut y);
}
if content_bottom < bottom {
draw_panels(pane, palette, area, buffer, content_bottom);
}
}
#[cfg(test)]
pub(super) fn cell(buffer: &Buffer, row: u16, column: Column) -> String {
let columns = columns_for(buffer.area.width);
let mut x = GUTTER;
for candidate in columns {
if *candidate == column {
return (x..x + column.width())
.map(|x| buffer[(x, row)].symbol())
.collect();
}
x += candidate.width();
}
panic!("{column:?} is not drawn at width {}", buffer.area.width);
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use std::collections::HashSet;
use super::*;
use crate::lookout::app::{Body, KeyPress, Msg};
use crate::lookout::secrets::SecretsModel;
use crate::lookout::view::fixtures;
fn first_row() -> u16 {
10
}
fn row_of(buffer: &Buffer, needle: &str) -> u16 {
let area = buffer.area;
(0..area.height)
.find(|&y| {
let line: String = (0..area.width).map(|x| buffer[(x, y)].symbol()).collect();
line.contains(needle)
})
.unwrap_or_else(|| panic!("{needle:?} is not drawn anywhere"))
}
fn heading_row() -> u16 {
7
}
fn header_environment_count(buffer: &Buffer) -> usize {
let row = row_of(buffer, "environments in this store");
let line: String = (0..buffer.area.width)
.map(|x| buffer[(x, row)].symbol())
.collect();
line.split("environments")
.next()
.and_then(|prefix| prefix.split_whitespace().next_back())
.and_then(|number| number.parse().ok())
.unwrap_or_else(|| panic!("no leading number in {line:?}"))
}
const FIRST_DATA_ROW: &str =
"\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588} 9 bytes";
fn frame_text(buffer: &Buffer) -> String {
(0..buffer.area.height)
.map(|y| {
let line: String = (0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol())
.collect();
format!("{}\n", line.trim_end())
})
.collect()
}
#[test]
fn the_chrome_is_the_rows_it_claims() {
let app = fixtures::app_with_secrets();
let buffer = fixtures::render(&app, 160, 48);
let band = row_of(&buffer, "SECRETS flock-wide");
let hairline = row_of(&buffer, "\u{2500}\u{2500}\u{2500}");
assert_eq!(hairline - band + 1, CHROME_ROWS, "{}", frame_text(&buffer));
}
#[test]
fn a_terminal_too_short_for_both_spends_the_panel_rows_on_the_table() {
let app = fixtures::app_with_secrets();
let buffer = fixtures::render(&app, 160, 14);
let text = frame_text(&buffer);
assert!(
!text.contains("FOCUSED"),
"no room for the panels at this height: {text}"
);
assert!(
text.contains(FIRST_DATA_ROW),
"their rows go to the table instead: {text}"
);
assert!(
text.contains("operator \u{d7}"),
"the group header is drawn too: {text}"
);
}
#[test]
fn the_panels_arrive_with_the_row_that_makes_room_for_them() {
let app = fixtures::app_with_secrets();
let short = usize::from(PANELS_MIN_ROWS - 1);
let exact = usize::from(PANELS_MIN_ROWS);
let below = frame_text(&fixtures::render(
&app,
160,
u16::try_from(short + 2).unwrap(),
));
let at = frame_text(&fixtures::render(
&app,
160,
u16::try_from(exact + 2).unwrap(),
));
assert!(!below.contains("FOCUSED"), "one row short: {below}");
assert!(at.contains("FOCUSED"), "exactly enough: {at}");
assert!(at.contains("WHO READS IT"), "both panels or neither: {at}");
assert!(
at.contains("operator \u{d7}"),
"and the group header above them: {at}"
);
assert!(
at.contains(FIRST_DATA_ROW),
"and a secret under it, which is the point of the pane: {at}"
);
}
#[test]
fn no_supported_height_reserves_panel_rows_it_never_draws() {
let app = fixtures::app_with_secrets();
for height in crate::lookout::view::flock::MIN_HEIGHT..=40 {
let buffer = fixtures::render(&app, 160, height);
let area = Rect::new(0, 0, 160, crate::lookout::view::body_rows(buffer.area));
let text = frame_text(&buffer);
if content_bottom(area) < area.y + area.height {
assert!(
text.contains("FOCUSED"),
"{height} rows reserved the panels: {text}"
);
assert!(
text.contains(FIRST_DATA_ROW),
"{height} rows drew the panels over an empty table: {text}"
);
} else {
assert!(
!text.contains("FOCUSED"),
"{height} rows reserved nothing for them: {text}"
);
}
}
}
#[test]
fn every_tier_fits_the_width_it_claims() {
for (threshold, columns) in SECRET_TIERS {
let spent: u16 = columns.iter().map(|column| column.width()).sum();
assert!(
spent + GUTTER <= *threshold,
"tier {threshold} spends {spent} plus {GUTTER} of gutter"
);
}
}
#[test]
fn a_terminal_narrower_than_the_floor_tier_says_so_instead_of_clipping() {
let app = fixtures::app_with_secrets();
assert!(
SECRET_TIERS
.iter()
.all(|(threshold, _)| *threshold >= MIN_WIDTH),
"MIN_WIDTH is the floor tier's own threshold, so no tier below \
it is refused away unreached"
);
let spent: u16 = columns_for(MIN_WIDTH).iter().map(|c| c.width()).sum();
assert!(
spent + GUTTER <= MIN_WIDTH,
"the width the refusal names has to be one the floor tier \
actually fits: {spent} plus {GUTTER} of gutter"
);
for width in crate::lookout::view::MIN_TERM_WIDTH..MIN_WIDTH {
let text = frame_text(&fixtures::render(&app, width, 24));
assert!(text.contains("too narrow for secrets"), "{width}: {text}");
assert!(
text.contains(&format!("need {MIN_WIDTH} columns")),
"{width}: {text}"
);
assert!(
!text.contains("DB_PASSWORD"),
"no half-drawn table at {width}: {text}"
);
}
let text = frame_text(&fixtures::render(&app, MIN_WIDTH, 24));
assert!(text.contains("DB_PASSWORD"), "the floor tier draws: {text}");
assert!(!text.contains("too narrow"), "{text}");
}
fn pane_with_environments(count: usize, tab: usize) -> SecretsPane {
SecretsPane {
model: Box::new(SecretsModel {
environments: (0..count).map(|n| format!("environment-{n:02}")).collect(),
..SecretsModel::default()
}),
tab,
selected: 0,
collapsed: HashSet::new(),
reveal: None,
pending_reveal: None,
armed: None,
typing: None,
}
}
#[test]
fn the_tab_row_elides_its_tabs_rather_than_losing_its_own_count() {
let palette = Palette::detect(None, None, None);
for count in 1..=20usize {
for width in [MIN_WIDTH, 100, 160] {
for tab in [0, count / 2, count - 1] {
let pane = pane_with_environments(count, tab);
let line = fixtures::rendered(&tab_line(&pane, palette, width));
assert!(
line.chars().count() <= usize::from(width),
"{count} tabs at {width}, on {tab}: {line:?}"
);
assert!(
line.trim_end().ends_with(&format!(
"{count} environments in this store \u{b7} \u{2190}/\u{2192}"
)),
"the count survives whole: {count} tabs at {width}: {line:?}"
);
assert!(
line.contains(&format!("[environment-{tab:02}]")),
"the tab you are on is drawn: {count} tabs at {width}: {line:?}"
);
for token in line.split_whitespace() {
let name = token.trim_start_matches('[').trim_end_matches(']');
if name.starts_with("environment-") {
assert_eq!(
name.len(),
"environment-00".len(),
"cut mid-name: {line:?}"
);
}
}
}
}
}
}
#[test]
fn the_elision_marker_stands_on_the_side_the_tabs_went() {
let palette = Palette::detect(None, None, None);
let head = fixtures::rendered(&tab_line(&pane_with_environments(20, 0), palette, 100));
let tail = fixtures::rendered(&tab_line(&pane_with_environments(20, 19), palette, 100));
assert!(
!head.trim_start().starts_with('\u{2026}'),
"nothing is hidden before the first tab: {head:?}"
);
assert!(head.contains('\u{2026}'), "and plenty after it: {head:?}");
assert!(
tail.trim_start().starts_with('\u{2026}'),
"hidden tabs before the last one: {tail:?}"
);
}
fn app_typing(buffer: &str) -> App {
let mut app = fixtures::app_with_secrets_and_control();
app.update(Msg::Key(KeyPress::Confirm));
for typed in buffer.chars() {
app.update(Msg::Key(KeyPress::TextChar(typed)));
}
app
}
#[test]
fn a_value_outgrowing_its_column_is_shown_from_the_end_being_typed() {
let typed = "abcdefghijklmnopqrstuvwxyz0123456789-THE-TAIL";
let app = app_typing(typed);
let buffer = fixtures::render(&app, 160, 48);
let drawn = cell(&buffer, row_of(&buffer, "DB_PASSWORD"), Column::Value);
assert!(
drawn.trim_end().ends_with('\u{2588}'),
"the cursor is the last thing on the cell: {drawn:?}"
);
assert!(
drawn.contains("THE-TAIL"),
"and the characters just typed are next to it: {drawn:?}"
);
assert!(
drawn.starts_with('\u{2026}'),
"with the marker where the head went: {drawn:?}"
);
assert!(
!drawn.contains("abcdef"),
"the head is what gives way, not the tail: {drawn:?}"
);
assert!(
drawn.ends_with(' '),
"one column short, so the cursor never touches IN FORCE: {drawn:?}"
);
assert_eq!(
drawn.chars().count(),
usize::from(Column::Value.width()),
"the cell still measures its column: {drawn:?}"
);
}
#[test]
fn a_new_key_name_outgrowing_its_column_is_shown_from_the_end_too() {
let short = typed_cell("SHORT", Column::Value.width());
let long = typed_cell(
"A_VERY_LONG_KEY_NAME_THAT_RUNS_PAST_THE_COLUMN",
Column::Value.width(),
);
assert!(short.starts_with("SHORT\u{2588}"), "{short:?}");
assert!(!short.contains('\u{2026}'), "nothing dropped: {short:?}");
assert!(long.starts_with('\u{2026}'), "{long:?}");
assert!(long.ends_with("THE_COLUMN\u{2588}"), "{long:?}");
}
#[test]
fn the_tail_drops_a_wide_character_rather_than_splitting_it() {
let exact = tail("\u{3042}\u{3044}\u{3046}\u{3048}\u{304a}", 9);
assert_eq!(exact, "\u{2026}\u{3044}\u{3046}\u{3048}\u{304a}");
assert_eq!(exact.chars().map(char_columns).sum::<usize>(), 9);
let padded = tail("\u{3042}\u{3044}\u{3046}\u{3048}\u{304a}", 8);
assert_eq!(padded, "\u{2026} \u{3046}\u{3048}\u{304a}");
assert_eq!(padded.chars().map(char_columns).sum::<usize>(), 8);
}
#[test]
fn the_columns_drop_in_the_specified_order() {
let widest = columns_for(160);
assert!(widest.contains(&Column::Lands));
assert!(
!columns_for(118).contains(&Column::Lands),
"LANDS goes first"
);
assert!(columns_for(118).contains(&Column::ReadBy));
assert!(
!columns_for(96).contains(&Column::ReadBy),
"READ BY goes second"
);
assert!(
!columns_for(74).contains(&Column::SetIn),
"SET IN goes third"
);
for width in [160, 118, 96, 74, 40] {
let columns = columns_for(width);
assert!(columns.contains(&Column::Key), "KEY is the pane at {width}");
assert!(
columns.contains(&Column::Value),
"VALUE is the pane at {width}"
);
assert!(
columns.contains(&Column::InForce),
"IN FORCE is the pane at {width}"
);
}
}
#[test]
fn in_force_is_read_out_of_its_own_column_not_found_anywhere_in_the_row() {
let app = fixtures::app_with_secrets();
let buffer = fixtures::render(&app, 160, 48);
assert_eq!(
cell(&buffer, first_row(), Column::InForce).trim(),
"production"
);
}
#[test]
fn a_key_with_no_slot_here_says_so_rather_than_showing_a_block_run() {
let app = fixtures::app_with_secrets();
let buffer = fixtures::render(&app, 160, 48);
let row = row_of(&buffer, "ELSEWHERE_ONLY");
assert_eq!(cell(&buffer, row, Column::Value).trim(), "not set here");
assert_eq!(cell(&buffer, row, Column::InForce).trim(), "-");
}
#[test]
fn a_four_kilobyte_value_never_overflows_its_column() {
let app = fixtures::app_with_a_maximum_length_secret();
let buffer = fixtures::render(&app, 160, 48);
let value = cell(&buffer, first_row(), Column::Value);
assert!(
value.chars().count() <= Column::Value.width() as usize,
"spilled: {value:?}"
);
assert!(
value.contains("4096 bytes"),
"the exact length is stated: {value:?}"
);
}
#[test]
fn a_short_value_s_block_run_is_proportional_to_its_length() {
let app = fixtures::app_with_secrets();
let buffer = fixtures::render(&app, 160, 48);
let value = cell(&buffer, first_row(), Column::Value);
let blocks = value.chars().take_while(|&c| c == '\u{2588}').count();
assert_eq!(
blocks, 9,
"the run states the value's own length: {value:?}"
);
}
#[test]
fn the_new_key_row_sits_before_the_first_namespace_group() {
let app = fixtures::app_with_secrets_and_a_provider_row();
let buffer = fixtures::render(&app, 160, 48);
let operator_row = row_of(&buffer, "DB_PASSWORD");
let new_key_row = row_of(&buffer, "+ new key");
let provider_header = row_of(&buffer, "vercel (dog)");
assert_eq!(
new_key_row,
operator_row + 1,
"right after the last operator row"
);
assert_eq!(
provider_header,
new_key_row + 1,
"and right before the provider group's own header"
);
}
#[test]
fn interleaved_sources_still_agree_between_the_cursor_and_the_drawn_affordance() {
let mut app = fixtures::app_with_interleaved_secret_sources();
app.update(Msg::Key(KeyPress::SelectLast));
let Body::Secrets(pane) = app.body() else {
panic!("pane is open");
};
assert!(
pane.selected_is_new_key_row(),
"G lands on the affordance: it is the last screen slot here"
);
let buffer = fixtures::render(&app, 160, 48);
let last_operator_row = row_of(&buffer, "SECOND_OPERATOR_KEY");
let new_key_row = row_of(&buffer, "+ new key");
assert_eq!(
new_key_row,
last_operator_row + 1,
"the affordance draws right after the highest-index operator \
row, matching the cursor's own anchor"
);
}
#[test]
fn a_one_byte_value_is_one_byte_in_both_places_that_print_a_length() {
assert_eq!(byte_count(1), "1 byte");
assert_eq!(byte_count(0), "0 bytes");
assert_eq!(byte_count(9), "9 bytes");
}
#[test]
fn the_new_key_row_says_no_key_is_selected_in_both_panels() {
let mut app = fixtures::app_with_secrets_and_control();
app.update(Msg::Key(KeyPress::SelectLast));
let buffer = fixtures::render(&app, 160, 48);
let text = fixtures::rows_of(&buffer);
assert!(
!text.iter().any(|l| l.contains("nothing names this key")),
"there is no key here for anything to name: {text:?}"
);
let panels = text
.iter()
.find(|l| l.contains("no key selected"))
.unwrap_or_else(|| panic!("neither panel says it: {text:?}"));
assert_eq!(
panels.matches("no key selected").count(),
2,
"FOCUSED and WHO READS IT sit on one line and both say it: {panels:?}"
);
}
#[test]
fn a_provider_group_says_it_is_read_only() {
let app = fixtures::app_with_a_pushed_secret();
let buffer = fixtures::render(&app, 160, 48);
let text = fixtures::rows_of(&buffer);
assert!(
text.iter().any(|line| line.contains("read-only here")),
"the group header states it: {text:?}"
);
}
#[test]
fn the_header_denominator_matches_set_in_for_all_and_a_named_environment() {
let app = fixtures::app_with_secrets();
let buffer = fixtures::render(&app, 160, 48);
let header_count = header_environment_count(&buffer);
let named_row = row_of(&buffer, "DB_PASSWORD");
let all_row = row_of(&buffer, "SET_EVERYWHERE");
assert_eq!(
cell(&buffer, named_row, Column::SetIn).trim(),
format!("1 of {header_count} \u{b7} production")
);
assert_eq!(
cell(&buffer, all_row, Column::SetIn).trim(),
format!("1 of {header_count} \u{b7} all")
);
}
#[test]
fn lands_is_drawn_at_the_widest_tier() {
let app = fixtures::app_with_secrets();
let buffer = fixtures::render(&app, 160, 48);
assert_eq!(cell(&buffer, heading_row(), Column::Lands).trim(), "LANDS");
assert_eq!(cell(&buffer, first_row(), Column::Lands).trim(), "-");
}
#[test]
fn a_key_set_everywhere_names_no_environments() {
let app = fixtures::app_with_secrets();
let buffer = fixtures::render(&app, 160, 48);
let header_count = header_environment_count(&buffer);
let row = row_of(&buffer, "SET_IN_ALL_THREE");
assert_eq!(
cell(&buffer, row, Column::SetIn).trim(),
format!("{header_count} of {header_count}")
);
}
#[test]
fn a_revealed_row_prints_its_value_and_its_countdown() {
let dir = tempfile::tempdir().unwrap();
let app = fixtures::app_revealing(dir.path());
let buffer = fixtures::render(&app, 160, 48);
let revealed = row_of(&buffer, "DB_PASSWORD");
assert_eq!(
cell(&buffer, revealed, Column::Value).trim(),
fixtures::REVEALED_VALUE
);
assert_eq!(
cell(&buffer, revealed, Column::Lands).trim(),
"visible 10s \u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}"
);
let untouched = row_of(&buffer, "OTHER_KEY");
assert_eq!(
cell(&buffer, untouched, Column::Value).trim(),
"\u{2588}\u{2588}\u{2588} 3 bytes",
"a reveal reaches one row, not the pane"
);
assert_eq!(cell(&buffer, untouched, Column::Lands).trim(), "-");
}
#[test]
fn the_countdown_and_its_gauge_spend_together() {
let dir = tempfile::tempdir().unwrap();
let mut app = fixtures::app_revealing(dir.path());
let start = app.now();
let _ = app.update(Msg::Tick {
now: start + Duration::from_secs(6),
});
let buffer = fixtures::render(&app, 160, 48);
assert_eq!(
cell(&buffer, row_of(&buffer, "DB_PASSWORD"), Column::Lands).trim(),
"visible 4s \u{2588}\u{2588}\u{2588}\u{2588}\u{2591}\u{2591}\u{2591}\u{2591}\u{2591}\u{2591}"
);
let _ = app.update(Msg::Tick {
now: start + Duration::from_millis(6_500),
});
let buffer = fixtures::render(&app, 160, 48);
assert_eq!(
cell(&buffer, row_of(&buffer, "DB_PASSWORD"), Column::Lands).trim(),
"visible 4s \u{2588}\u{2588}\u{2588}\u{2588}\u{2591}\u{2591}\u{2591}\u{2591}\u{2591}\u{2591}"
);
}
#[test]
fn a_reader_is_never_said_to_hold_the_current_value() {
let buffer = fixtures::render_secrets_with_readers();
let text = fixtures::rows_of(&buffer);
assert!(
text.iter()
.any(|l| l.contains("was given a value at spawn")),
"an online reader: {text:?}"
);
assert!(
text.iter().any(|l| l.contains("reads it at next start")),
"an offline one: {text:?}"
);
assert!(
!text.iter().any(|l| l.contains("holds the value")),
"nothing can tell a sheep spawned before a set from one spawned \
after, so the pane must not claim it: {text:?}"
);
}
#[test]
fn the_chrome_states_the_gate_and_no_panel_promises_an_audit() {
let buffer = fixtures::render_secrets_gate_shut();
let text = fixtures::rows_of(&buffer);
assert!(
text.iter()
.any(|l| l.contains("allow_read") && l.contains("reveal")),
"the chrome's own gate line: {text:?}"
);
assert!(
text.iter()
.any(|l| l.contains("nothing records that you looked")),
"FOCUSED says what is not kept: {text:?}"
);
assert!(
!text.iter().any(|l| l.contains("audit")),
"there is no audit log, so promising one is a promise nothing keeps"
);
}
#[test]
fn a_stale_roll_states_its_age() {
let buffer = fixtures::render_secrets_with_roll_age(Duration::from_secs(3600));
let text = fixtures::rows_of(&buffer);
assert!(
text.iter().any(|l| l.contains("roll") && l.contains("1h")),
"a failed roll write only warns, so the age is the only signal: {text:?}"
);
}
#[test]
fn who_reads_it_states_an_overflow_it_cannot_list() {
let buffer = fixtures::render_secrets_with_more_readers_than_fit();
let text = fixtures::rows_of(&buffer);
assert!(
text.iter().any(|l| l.contains("named by 5 of the flock")),
"FOCUSED states the true count: {text:?}"
);
assert!(
text.iter().any(|l| l.contains("+ 3 more")),
"WHO READS IT states what it could not list: {text:?}"
);
}
#[test]
fn a_missing_roll_says_so_rather_than_showing_an_empty_reader_list() {
let buffer = fixtures::render_secrets_with_no_roll();
let text = fixtures::rows_of(&buffer);
assert!(
text.iter().any(|l| l.contains("no muster roll")),
"an absent roll and a key nothing reads look identical otherwise: {text:?}"
);
}
}