use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use serde_json::{Map, Value};
use shep_core::protocol::SheepConfigView;
use crate::lookout::field::{Field, FieldSet};
use crate::lookout::pane;
use crate::lookout::pane_sheep::SheepPane;
use crate::lookout::theme::Palette;
use super::super::flock::fit;
use super::layout::{
COLUMN_BODY_ROWS, COLUMN_HEADER_ROW, COLUMN_NAME_W, COLUMN_WIDTH, DIVIDER_COL,
};
pub(super) fn column_top_row(height: u16) -> u16 {
if height > COLUMN_HEADER_ROW {
COLUMN_HEADER_ROW
} else {
1
}
}
fn column_body(view: &SheepConfigView, palette: Palette) -> (Vec<Line<'static>>, Vec<String>) {
let (fields, values) = pane::sheep_fields(&view.config);
let mut lines = Vec::new();
let mut groups = Vec::new();
let mut current: Option<&str> = None;
for field in fields.fields() {
if field.key == "env" {
continue;
}
if field.group.as_deref() != current {
current = field.group.as_deref();
let label = current.unwrap_or_default().to_owned();
push_group_header(&mut lines, &label, palette);
groups.push(label);
}
let pending = view.pending.iter().any(|key| key == &field.key);
let overridden = view.overridden.iter().any(|key| key == &field.key);
lines.push(field_row_line(
&fields, field, &values, pending, overridden, palette,
));
}
push_group_header(&mut lines, "env", palette);
for key in &view.env_keys {
let sealed = view.env_secrets.iter().any(|secret| secret == key);
lines.push(env_row_line(key, sealed, palette));
}
(lines, groups)
}
fn push_group_header(lines: &mut Vec<Line<'static>>, label: &str, palette: Palette) {
lines.push(Line::default());
lines.push(Line::from(Span::styled(
"\u{2500}".repeat(usize::from(COLUMN_WIDTH)),
palette.muted(),
)));
lines.push(Line::from(Span::styled(label.to_owned(), palette.muted())));
}
fn column_body_lines(view: &SheepConfigView, palette: Palette) -> Vec<Line<'static>> {
column_body(view, palette).0
}
fn waiting_line(palette: Palette) -> Line<'static> {
Line::from(Span::styled("reading config\u{2026}", palette.muted()))
}
#[must_use]
pub(crate) fn column_len(config: Option<&SheepConfigView>) -> usize {
match config {
Some(view) => column_body_lines(view, Palette::detect(None, None, None)).len(),
None => 1,
}
}
fn field_row_line(
fields: &FieldSet,
field: &Field,
values: &Map<String, Value>,
pending: bool,
overridden: bool,
palette: Palette,
) -> Line<'static> {
let raw = field_value_text(fields, field, values);
let flag = if pending {
"!"
} else if overridden {
"*"
} else {
""
};
let note = if pending { "awaits respawn" } else { "" };
let value_w = usize::from(COLUMN_WIDTH).saturating_sub(usize::from(COLUMN_NAME_W) + 2);
let left_w = value_w.saturating_sub(note.chars().count());
let key_style = if pending {
palette.attention()
} else {
palette.muted()
};
let value_style = if pending {
palette.attention()
} else if matches!(raw.as_str(), "(unset)" | "(default)") {
palette.muted()
} else {
Style::default()
};
let mut spans = vec![
Span::styled(
fit(&format!("{flag}{}", field.key), COLUMN_NAME_W),
key_style,
),
Span::raw(" "),
Span::styled(fit(&raw, left_w as u16), value_style),
];
if !note.is_empty() {
spans.push(Span::styled(note.to_owned(), palette.attention()));
}
Line::from(spans)
}
fn field_value_text(fields: &FieldSet, field: &Field, values: &Map<String, Value>) -> String {
let dogs = (field.key == "dogs").then(|| values.get("dogs")).flatten();
if dogs
.and_then(Value::as_object)
.is_some_and(|tables| !tables.is_empty())
{
return pane::dog_names(dogs);
}
let raw = match values.get(&field.key) {
None | Some(Value::Null) => return "(unset)".to_owned(),
Some(Value::String(text)) => text.clone(),
Some(Value::Bool(flag)) => flag.to_string(),
Some(Value::Number(number)) => number.to_string(),
Some(other) => other.to_string(),
};
if field.secret {
"<set>".to_owned()
} else if field.default.as_deref() == Some(raw.as_str()) {
"(default)".to_owned()
} else {
pane::resolved_display(fields, &field.key, &raw)
}
}
fn env_row_line(key: &str, sealed: bool, palette: Palette) -> Line<'static> {
if !sealed {
return Line::from(Span::raw(key.to_owned()));
}
let note = "edit in S";
let value_w = usize::from(COLUMN_WIDTH).saturating_sub(usize::from(COLUMN_NAME_W) + 2);
let left_w = value_w.saturating_sub(note.chars().count());
let mid = format!("{} sealed", "\u{2588}".repeat(8));
Line::from(vec![
Span::styled(fit(key, COLUMN_NAME_W), palette.muted()),
Span::raw(" "),
Span::styled(fit(&mid, left_w as u16), palette.attention()),
Span::styled(note.to_owned(), palette.muted()),
])
}
fn column_header_line(pane: &SheepPane, palette: Palette) -> Line<'static> {
let mut text = "\u{2588}\u{2588} CONFIG & ENV e edit".to_owned();
if let Some(pending) = pane
.config()
.map(|view| view.pending.len())
.filter(|count| *count > 0)
{
text.push_str(&format!(" {pending} pending"));
}
Line::from(Span::styled(text, palette.muted()))
}
pub(super) fn draw_column(
pane: &SheepPane,
top: u16,
area: Rect,
buffer: &mut Buffer,
palette: Palette,
) {
write_column_row(buffer, area, top, &column_header_line(pane, palette));
let body = match pane.config() {
Some(view) => column_body_lines(view, palette),
None => vec![waiting_line(palette)],
};
let offset = pane.view().offset();
for (i, line) in body.iter().skip(offset).take(COLUMN_BODY_ROWS).enumerate() {
write_column_row(buffer, area, top + 1 + i as u16, line);
}
}
fn write_column_row(buffer: &mut Buffer, area: Rect, row: u16, line: &Line<'static>) {
if row >= area.height {
return;
}
buffer.set_line(area.x, area.y + row, line, COLUMN_WIDTH);
}
pub(super) fn draw_divider(top: u16, area: Rect, buffer: &mut Buffer, palette: Palette) {
let rule = Line::from(Span::styled("\u{2502}", palette.muted()));
for row in top..=(top + COLUMN_BODY_ROWS as u16) {
if row >= area.height {
break;
}
buffer.set_line(area.x + DIVIDER_COL, area.y + row, &rule, 1);
}
}
#[cfg(test)]
mod tests {
use shep_core::config::AppConfig;
use shep_core::protocol::{ProcessInfo, Response};
use shep_core::status::ProcStatus;
use crate::lookout::app::{Body, KeyPress, Msg, RowKey, Sent};
use crate::lookout::frames::render_text;
use super::super::super::fixtures;
use super::super::draw;
use super::super::layout::{COLUMN_LAST_ROW, FEED_WIDTH, FEED_X, MIN_HEIGHT_FOR_COLUMN};
use super::*;
fn web_view() -> SheepConfigView {
let mut config = AppConfig {
name: "web".to_owned(),
..AppConfig::default()
};
config
.env
.insert("DB_HOST".to_owned(), "db.internal".to_owned());
config
.env
.insert("API_KEY".to_owned(), "{{secret:API}}".to_owned());
SheepConfigView::new(config, Vec::new(), vec!["max_memory".to_owned()])
}
fn web_view_overridden() -> SheepConfigView {
let config = AppConfig {
name: "web".to_owned(),
..AppConfig::default()
};
SheepConfigView::new(config, vec!["max_memory".to_owned()], Vec::new())
}
fn view_with_env(pairs: &[(&str, &str)]) -> SheepConfigView {
let mut config = AppConfig {
name: "test".to_owned(),
..AppConfig::default()
};
for (key, value) in pairs {
config.env.insert((*key).to_owned(), (*value).to_owned());
}
SheepConfigView::new(config, Vec::new(), Vec::new())
}
fn line_text(line: &Line<'static>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect()
}
fn group_labels_of(view: &SheepConfigView) -> Vec<String> {
column_body(view, fixtures::plain()).1
}
fn field_row_of(view: &SheepConfigView, key: &str) -> String {
column_body_lines(view, fixtures::plain())
.iter()
.map(line_text)
.find(|line| line.trim_start_matches(['!', '*']).starts_with(key))
.unwrap_or_else(|| panic!("no row for {key}"))
}
fn env_rows_of(view: &SheepConfigView) -> Vec<String> {
let lines: Vec<String> = column_body_lines(view, fixtures::plain())
.iter()
.map(line_text)
.collect();
let label = lines
.iter()
.position(|line| line.trim() == "env")
.expect("column_body always closes with an env label");
lines[label + 1..].to_vec()
}
fn row_for<'a>(key: &str, rows: &'a [String]) -> &'a str {
rows.iter()
.find(|row| row.contains(key))
.unwrap_or_else(|| panic!("no row for {key} in {rows:?}"))
}
fn column_of(config: Option<SheepConfigView>) -> String {
let lines = match &config {
Some(view) => column_body_lines(view, fixtures::plain()),
None => vec![waiting_line(fixtures::plain())],
};
lines.iter().map(line_text).collect::<Vec<_>>().join("\n")
}
fn config_pane_group_labels(view: &SheepConfigView) -> Vec<String> {
let pane = crate::lookout::pane::ConfigPane::sheep(view.clone());
let mut labels = Vec::new();
let mut current: Option<&str> = None;
for field in pane.fields().fields() {
if field.group.as_deref() != current {
current = field.group.as_deref();
labels.push(current.unwrap_or_default().to_owned());
}
}
labels
}
#[test]
fn the_groups_are_the_schemas_eight_in_its_own_order() {
let view = web_view();
assert_eq!(group_labels_of(&view), config_pane_group_labels(&view));
}
#[test]
fn a_pending_field_is_marked_and_annotated() {
let row = field_row_of(&web_view(), "max_memory");
assert!(row.starts_with('!'), "{row:?}");
assert!(row.contains("awaits respawn"), "{row:?}");
}
#[test]
fn an_overridden_field_is_marked_with_the_editing_panes_own_glyph() {
let row = field_row_of(&web_view_overridden(), "max_memory");
assert!(row.starts_with('*'), "{row:?}");
assert!(
!row.contains("awaits respawn"),
"overridden is not pending: {row:?}"
);
let other_row = field_row_of(&web_view_overridden(), "restart_delay");
assert!(
!other_row.starts_with('*') && !other_row.starts_with('!'),
"an unrelated field must not pick up the marker: {other_row:?}"
);
}
#[test]
fn no_env_value_reaches_the_column() {
let rendered = env_rows_of(&web_view()).join("");
assert!(!rendered.contains("hunter2"), "{rendered:?}");
}
#[test]
fn a_sealed_key_is_marked_and_a_plain_one_is_not() {
let rows = env_rows_of(&view_with_env(&[
("PLAIN", "v"),
("SEALED", "{{secret:PW}}"),
]));
assert!(row_for("SEALED", &rows).contains("sealed"));
assert!(!row_for("PLAIN", &rows).contains("sealed"));
}
#[test]
fn a_pane_without_its_config_yet_says_so() {
assert!(column_of(None).contains("reading config"));
}
#[test]
fn the_longest_pending_field_name_is_not_truncated() {
let config = AppConfig {
name: "web".to_owned(),
..AppConfig::default()
};
let view = SheepConfigView::new(
config,
Vec::new(),
vec!["exp_backoff_restart_delay".to_owned()],
);
let row = field_row_of(&view, "exp_backoff_restart_delay");
assert!(
row.starts_with("!exp_backoff_restart_delay "),
"the flagged name and its separator must survive whole: {row:?}"
);
}
#[test]
fn the_column_and_the_editing_pane_resolve_the_same_mem_size_units() {
use shep_core::values::MemSize;
let mut config = AppConfig {
name: "web".to_owned(),
..AppConfig::default()
};
config.max_memory = Some(MemSize::from_bytes(1_234_567));
let column_view = SheepConfigView::new(config.clone(), Vec::new(), Vec::new());
let editing_view = SheepConfigView::new(config, Vec::new(), Vec::new());
let row = field_row_of(&column_view, "max_memory");
let pane = crate::lookout::pane::ConfigPane::sheep(editing_view);
let resolved = pane.display_value("max_memory");
assert!(
resolved.ends_with(" B"),
"setup: the fixture must actually exercise the unit suffix: {resolved:?}"
);
assert!(
row.contains(&resolved),
"column row {row:?} does not carry the editing pane's own {resolved:?}"
);
}
#[test]
fn a_secret_field_renders_set_and_never_its_value() {
use crate::lookout::field::FieldKind;
let field = Field {
key: "webhook".to_owned(),
help: String::new(),
group: None,
kind: FieldKind::Text,
value_kind: None,
default: None,
default_value: None,
secret: true,
editable: true,
example: None,
accepts: Vec::new(),
refuses: Vec::new(),
neighbours: Vec::new(),
bounds: crate::lookout::field::Bounds::default(),
};
let fields = FieldSet::from_fields(vec![field.clone()], &[]);
let mut values = Map::new();
values.insert("webhook".to_owned(), Value::String("hunter2".to_owned()));
assert_eq!(field_value_text(&fields, &field, &values), "<set>");
}
#[test]
fn the_dogs_row_names_the_dogs_and_never_a_value() {
let mut config = shep_core::config::AppConfig {
name: "web".into(),
..Default::default()
};
let (fields, values) = pane::sheep_fields(&config);
let dogs = fields.by_key("dogs").expect("dogs is a field");
assert_eq!(field_value_text(&fields, dogs, &values), "(default)");
let table = serde_json::json!({ "token": "sk-live-51Hx9Qa" });
let table = table.as_object().cloned().expect("a table");
config.dogs.insert("jobs".into(), table.into());
let (fields, values) = pane::sheep_fields(&config);
let dogs = fields.by_key("dogs").expect("dogs is a field");
assert_eq!(field_value_text(&fields, dogs, &values), "jobs");
}
#[test]
fn the_column_does_not_draw_the_sheep_that_replaced_the_pinned_one() {
let mut app = fixtures::app_with(
vec![
ProcessInfo::builder(1, "alpha", ProcStatus::Online).build(),
ProcessInfo::builder(2, "bravo", ProcStatus::Online).build(),
],
fixtures::plain(),
);
let _ = app.update(Msg::Key(KeyPress::Confirm));
assert!(
matches!(app.body(), Body::Sheep(_)),
"setup: the pane opened on alpha"
);
let alpha_config = AppConfig {
name: "alpha".to_owned(),
..AppConfig::default()
};
app.update(Msg::Replied {
sent: Sent::SheepConfig {
name: "alpha".to_string(),
},
result: Ok(Response::SheepConfig(Box::new(SheepConfigView::new(
alpha_config,
Vec::new(),
Vec::new(),
)))),
});
let _ = app.update(Msg::Snapshot {
rows: vec![ProcessInfo::builder(2, "bravo", ProcStatus::Online).build()],
at: std::time::Instant::now(),
});
assert_eq!(
app.selected(),
Some(RowKey::Sheep(2)),
"setup: the reseat moved the selection to bravo"
);
let Body::Sheep(pane) = app.body() else {
panic!("the pane is still open");
};
let area = Rect::new(0, 0, 80, MIN_HEIGHT_FOR_COLUMN);
let mut buffer = Buffer::empty(area);
draw(&app, pane, area, &mut buffer);
let text = render_text(&buffer);
assert!(
text.contains("name alpha"),
"must still draw the pinned sheep's own `name` field: {text:?}"
);
assert!(!text.contains("bravo"), "{text:?}");
}
#[test]
fn the_divider_runs_the_full_height_of_the_column_and_feed() {
let mut app = fixtures::app_with(
vec![ProcessInfo::builder(1, "alpha", ProcStatus::Online).build()],
fixtures::plain(),
);
let _ = app.update(Msg::Key(KeyPress::Confirm));
let Body::Sheep(pane) = app.body() else {
panic!("the pane is still open");
};
let area = Rect::new(0, 0, FEED_X + FEED_WIDTH, MIN_HEIGHT_FOR_COLUMN);
let mut buffer = Buffer::empty(area);
draw(&app, pane, area, &mut buffer);
for row in COLUMN_HEADER_ROW..=COLUMN_LAST_ROW {
assert_eq!(
buffer[(COLUMN_WIDTH, row)].symbol(),
"\u{2502}",
"row {row} has no divider"
);
}
}
}