use std::path::PathBuf;
use std::time::Instant;
use serde_json::{Map, Value};
use shep_core::config::{ApplyGroup, GROUP_ORDER, apply_group, flockfile_schema_json};
use shep_core::protocol::{EnvValue, SheepConfigView};
use shep_core::values::{MemSize, UpDuration};
use super::field::{FieldKind, FieldSet, ListItem, ValueKind};
use super::viewport::Viewport;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PaneTarget {
Sheep {
name: String,
},
Dog {
name: String,
adopted_path: Option<PathBuf>,
},
}
impl PaneTarget {
#[must_use]
pub fn name(&self) -> &str {
match self {
Self::Sheep { name } | Self::Dog { name, .. } => name,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Lock {
Refused,
NoWidget,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PaneRow {
Field(usize),
}
#[derive(Clone, PartialEq, Eq)]
pub struct FieldValue(Value);
impl FieldValue {
#[must_use]
pub fn as_value(&self) -> &Value {
&self.0
}
}
impl From<Value> for FieldValue {
fn from(value: Value) -> Self {
Self(value)
}
}
impl core::fmt::Debug for FieldValue {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
let kind = match &self.0 {
Value::Null => "null",
Value::Bool(_) => "bool",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
};
write!(f, "FieldValue(<{kind}>)")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PaneEdit {
Set {
key: String,
value: FieldValue,
},
SetEnv {
key: String,
value: Option<EnvValue>,
},
}
impl PaneEdit {
#[must_use]
pub fn key(&self) -> &str {
match self {
Self::Set { key, .. } | Self::SetEnv { key, .. } => key,
}
}
}
#[derive(Clone, PartialEq, Eq)]
pub enum PanePending {
Typing {
key: String,
buffer: String,
},
Armed {
edit: PaneEdit,
text: String,
at: Instant,
},
Sent {
ticket: u64,
key: String,
text: String,
},
}
impl core::fmt::Debug for PanePending {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::Typing { key, buffer } => write!(
f,
"PanePending::Typing {{ key: {key:?}, buffer: <{} chars> }}",
buffer.chars().count()
),
Self::Armed { edit, .. } => write!(f, "PanePending::Armed {{ edit: {edit:?} }}"),
Self::Sent { ticket, key, .. } => {
write!(f, "PanePending::Sent {{ ticket: {ticket}, key: {key:?} }}")
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EnvRow {
Key(usize),
New,
}
#[derive(Clone, PartialEq, Eq)]
pub struct EnvPane {
keys: Vec<String>,
view: Viewport,
typing: Option<(Option<String>, String)>,
}
impl core::fmt::Debug for EnvPane {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(
f,
"EnvPane {{ keys: {}, cursor: {}, typing: {} }}",
self.keys.len(),
self.view.cursor(),
self.typing.is_some()
)
}
}
impl EnvPane {
#[must_use]
pub fn new(keys: Vec<String>) -> Self {
Self {
keys,
view: Viewport::new(),
typing: None,
}
}
#[must_use]
pub fn rows(&self) -> Vec<EnvRow> {
let mut rows: Vec<EnvRow> = (0..self.keys.len()).map(EnvRow::Key).collect();
rows.push(EnvRow::New);
rows
}
#[must_use]
pub fn cursor(&self) -> Option<EnvRow> {
let rows = self.rows();
rows.get(self.view.cursor()).copied()
}
#[must_use]
pub fn keys(&self) -> &[String] {
&self.keys
}
#[must_use]
pub fn cursor_key(&self) -> Option<&str> {
match self.cursor()? {
EnvRow::Key(index) => self.keys.get(index).map(String::as_str),
EnvRow::New => None,
}
}
#[must_use]
pub fn view(&self) -> &Viewport {
&self.view
}
#[must_use]
pub fn typing(&self) -> Option<(Option<&str>, &str)> {
self.typing
.as_ref()
.map(|(key, buffer)| (key.as_deref(), buffer.as_str()))
}
pub fn set_rows(&mut self, rows: usize) {
let len = self.rows().len();
self.view.set_rows(rows, len);
}
pub(super) fn move_by(&mut self, delta: isize) {
let len = self.rows().len();
self.view.move_by(delta, len);
}
pub(super) fn move_to_first(&mut self) {
let len = self.rows().len();
self.view.move_to(0, len);
}
pub(super) fn move_to_last(&mut self) {
let len = self.rows().len();
self.view.move_to(len.saturating_sub(1), len);
}
pub(super) fn adopt_view(&mut self, view: Viewport, cursor_key: Option<&str>) {
self.view = view;
let len = self.rows().len();
let index = match cursor_key {
Some(key) => self
.keys
.iter()
.position(|name| name == key)
.unwrap_or(len - 1),
None => len - 1,
};
self.view.move_to(index, len);
}
pub fn begin_typing(&mut self) {
self.typing = match self.cursor() {
Some(EnvRow::Key(index)) => Some((Some(self.keys[index].clone()), String::new())),
Some(EnvRow::New) => Some((None, String::new())),
None => None,
};
}
pub fn type_char(&mut self, typed: char) {
if let Some((_, buffer)) = self.typing.as_mut() {
buffer.push(typed);
}
}
pub fn type_backspace(&mut self) {
if let Some((_, buffer)) = self.typing.as_mut() {
buffer.pop();
}
}
pub fn abandon_typing(&mut self) {
self.typing = None;
}
pub fn apply_typing(&mut self) -> Option<(String, Option<String>)> {
let (key, buffer) = self.typing.take()?;
match key {
Some(key) => Some((key, (!buffer.is_empty()).then_some(buffer))),
None => {
let (key, value) = buffer.split_once('=')?;
if key.is_empty() {
return None;
}
Some((key.to_owned(), Some(value.to_owned())))
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ListRow {
Item(usize),
New,
}
#[derive(Clone, PartialEq, Eq)]
pub struct ListPane {
key: String,
item: ListItem,
elements: Vec<String>,
view: Viewport,
typing: Option<(Option<usize>, String)>,
}
impl ListPane {
#[must_use]
pub fn new(key: String, item: ListItem, elements: Vec<String>) -> Self {
Self {
key,
item,
elements,
view: Viewport::new(),
typing: None,
}
}
#[must_use]
pub fn key(&self) -> &str {
&self.key
}
#[must_use]
pub fn item(&self) -> ListItem {
self.item
}
#[must_use]
pub fn elements(&self) -> &[String] {
&self.elements
}
#[must_use]
pub fn rows(&self) -> Vec<ListRow> {
let mut rows: Vec<ListRow> = (0..self.elements.len()).map(ListRow::Item).collect();
rows.push(ListRow::New);
rows
}
#[must_use]
pub fn cursor(&self) -> Option<ListRow> {
self.rows().get(self.view.cursor()).copied()
}
#[must_use]
pub fn view(&self) -> &Viewport {
&self.view
}
#[must_use]
pub fn typing(&self) -> Option<(Option<usize>, &str)> {
self.typing
.as_ref()
.map(|(index, buffer)| (*index, buffer.as_str()))
}
pub fn set_rows(&mut self, rows: usize) {
let len = self.rows().len();
self.view.set_rows(rows, len);
}
pub(super) fn move_by(&mut self, delta: isize) {
let len = self.rows().len();
self.view.move_by(delta, len);
}
pub(super) fn move_to(&mut self, index: usize) {
let len = self.rows().len();
self.view.move_to(index, len);
}
pub(super) fn move_to_first(&mut self) {
self.move_to(0);
}
pub(super) fn move_to_last(&mut self) {
let len = self.rows().len();
self.move_to(len.saturating_sub(1));
}
pub(super) fn adopt_view(&mut self, view: Viewport) {
self.view = view;
let len = self.rows().len();
self.view.clamp(len);
}
pub fn begin_typing(&mut self) {
self.typing = match self.cursor() {
Some(ListRow::Item(index)) => self
.elements
.get(index)
.map(|element| (Some(index), element.clone())),
Some(ListRow::New) => Some((None, String::new())),
None => None,
};
}
pub fn type_char(&mut self, typed: char) {
if let Some((_, buffer)) = self.typing.as_mut() {
buffer.push(typed);
}
}
pub fn type_backspace(&mut self) {
if let Some((_, buffer)) = self.typing.as_mut() {
buffer.pop();
}
}
pub fn abandon_typing(&mut self) {
self.typing = None;
}
pub fn apply_typing(&mut self) -> Option<String> {
let (_, buffer) = self.typing.as_ref()?;
if self.item == ListItem::Integer && buffer.parse::<i64>().is_err() && !buffer.is_empty() {
return None;
}
let (_, buffer) = self.typing.take()?;
(!buffer.is_empty()).then_some(buffer)
}
pub(super) fn with_element(&self, text: String) -> Option<Vec<String>> {
let mut elements = self.elements.clone();
match self.cursor()? {
ListRow::Item(index) => *elements.get_mut(index)? = text,
ListRow::New => elements.push(text),
}
Some(elements)
}
pub(super) fn without_element(&self) -> Option<Vec<String>> {
let ListRow::Item(index) = self.cursor()? else {
return None;
};
let mut elements = self.elements.clone();
(index < elements.len()).then(|| {
elements.remove(index);
elements
})
}
pub(super) fn reordered(&self, delta: isize) -> Option<Vec<String>> {
let ListRow::Item(index) = self.cursor()? else {
return None;
};
let target = usize::try_from(isize::try_from(index).ok()? + delta).ok()?;
if target >= self.elements.len() {
return None;
}
let mut elements = self.elements.clone();
elements.swap(index, target);
Some(elements)
}
}
fn list_value(item: ListItem, elements: &[String]) -> Value {
let element = |text: &String| match item {
ListItem::Text => Value::String(text.clone()),
ListItem::Integer => text
.parse::<i64>()
.map_or_else(|_| Value::String(text.clone()), Value::from),
};
Value::Array(elements.iter().map(element).collect())
}
#[derive(Clone)]
pub struct ConfigPane {
target: PaneTarget,
fields: FieldSet,
values: Map<String, Value>,
env_keys: Vec<String>,
overridden: Vec<String>,
pending: Vec<String>,
view: Viewport,
pending_edit: Option<PanePending>,
env: Option<EnvPane>,
list: Option<ListPane>,
help_open: bool,
section: Option<String>,
}
impl core::fmt::Debug for ListPane {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(
f,
"ListPane {{ key: {:?}, item: {:?}, elements: {}, typing: {} }}",
self.key,
self.item,
self.elements.len(),
if self.typing.is_some() {
"some"
} else {
"none"
}
)
}
}
impl core::fmt::Debug for ConfigPane {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(
f,
"ConfigPane {{ target: {:?}, fields: {}, env_keys: {}, cursor: {} }}",
self.target,
self.fields.len(),
self.env_keys.len(),
self.view.cursor()
)
}
}
impl ConfigPane {
#[must_use]
pub fn sheep(view: SheepConfigView) -> Self {
let schema = flockfile_schema_json().to_value();
let defs = schema
.get("$defs")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
let properties = defs
.get("AppConfig")
.and_then(|app| app.get("properties"))
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
let set = FieldSet::from_properties(&properties, &defs, GROUP_ORDER);
let fields = FieldSet::from_fields(
set.fields()
.iter()
.cloned()
.map(|mut field| {
if apply_group(&field.key) == ApplyGroup::Structural {
field.editable = false;
}
field
})
.collect(),
GROUP_ORDER,
);
let values = serde_json::to_value(&view.config)
.ok()
.and_then(|value| value.as_object().cloned())
.unwrap_or_default();
Self {
target: PaneTarget::Sheep { name: view.name },
fields,
values,
env_keys: view.env_keys,
overridden: view.overridden,
pending: view.pending,
view: Viewport::new(),
pending_edit: None,
env: None,
list: None,
help_open: false,
section: None,
}
}
#[must_use]
pub fn dog(
name: String,
adopted_path: Option<PathBuf>,
schema: Value,
section: String,
) -> Self {
let defs = schema
.get("$defs")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
let properties = schema
.get("properties")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
let set = FieldSet::from_properties(&properties, &defs, &[]);
let fields = FieldSet::from_fields(
set.fields()
.iter()
.cloned()
.map(|mut field| {
if matches!(field.kind, FieldKind::Map | FieldKind::List(_)) {
field.editable = false;
}
field
})
.collect(),
&[],
);
let values: Map<String, Value> = section
.parse::<toml::Table>()
.ok()
.and_then(|table| serde_json::to_value(table).ok())
.and_then(|value| value.as_object().cloned())
.unwrap_or_default();
Self {
target: PaneTarget::Dog { name, adopted_path },
fields,
values,
env_keys: Vec::new(),
overridden: Vec::new(),
pending: Vec::new(),
view: Viewport::new(),
pending_edit: None,
env: None,
list: None,
help_open: false,
section: Some(section),
}
}
#[must_use]
pub fn edited_section(&self, edit: &PaneEdit) -> Option<String> {
let section = self.section.as_deref()?;
let PaneEdit::Set { key, value } = edit else {
return None;
};
let mut doc: toml_edit::DocumentMut = section.parse().ok()?;
match value.as_value() {
Value::Null => {
doc.remove(key);
}
Value::Bool(flag) => doc[key] = toml_edit::value(*flag),
Value::Number(number) => match (number.as_i64(), number.as_f64()) {
(Some(int), _) => doc[key] = toml_edit::value(int),
(None, Some(float)) => doc[key] = toml_edit::value(float),
(None, None) => return None,
},
Value::String(text) => doc[key] = toml_edit::value(text.as_str()),
other => doc[key] = toml_edit::value(other.to_string()),
}
Some(doc.to_string())
}
#[must_use]
pub fn pending_edit(&self) -> Option<&PanePending> {
self.pending_edit.as_ref()
}
#[must_use]
pub fn env(&self) -> Option<&EnvPane> {
self.env.as_ref()
}
pub(super) fn env_mut(&mut self) -> Option<&mut EnvPane> {
self.env.as_mut()
}
pub(super) fn open_env(&mut self) {
self.env = Some(EnvPane::new(self.env_keys.clone()));
}
pub(super) fn close_env(&mut self) {
self.env = None;
}
#[must_use]
pub fn list(&self) -> Option<&ListPane> {
self.list.as_ref()
}
pub(super) fn list_mut(&mut self) -> Option<&mut ListPane> {
self.list.as_mut()
}
pub(super) fn open_list(&mut self) {
let Some(PaneRow::Field(index)) = self.cursor() else {
return;
};
let Some(field) = self.fields.fields().get(index) else {
return;
};
let FieldKind::List(item) = field.kind else {
return;
};
let key = field.key.clone();
let elements = self.elements_of(&key);
self.list = Some(ListPane::new(key, item, elements));
}
pub(super) fn close_list(&mut self) {
self.list = None;
}
fn elements_of(&self, key: &str) -> Vec<String> {
let Some(Value::Array(values)) = self.values.get(key) else {
return Vec::new();
};
values
.iter()
.map(|value| match value {
Value::String(text) => text.clone(),
other => other.to_string(),
})
.collect()
}
pub(super) fn arm_list_element(&mut self, text: String, now: Instant) {
let Some(elements) = self.list.as_ref().and_then(|list| list.with_element(text)) else {
return;
};
self.arm_list(elements, now);
}
pub(super) fn arm_list_removal(&mut self, now: Instant) {
let Some(elements) = self.list.as_ref().and_then(ListPane::without_element) else {
return;
};
self.arm_list(elements, now);
}
pub(super) fn arm_list_reorder(&mut self, delta: isize, now: Instant) {
let Some(elements) = self.list.as_ref().and_then(|list| list.reordered(delta)) else {
return;
};
self.arm_list(elements, now);
}
fn arm_list(&mut self, elements: Vec<String>, now: Instant) {
let Some(list) = self.list.as_ref() else {
return;
};
let edit = PaneEdit::Set {
key: list.key().to_owned(),
value: list_value(list.item(), &elements).into(),
};
let text = self.confirm_text(&edit);
self.pending_edit = Some(PanePending::Armed {
edit,
text,
at: now,
});
}
#[must_use]
pub fn help_open(&self) -> bool {
self.help_open
}
pub(super) fn toggle_help(&mut self) {
self.help_open = !self.help_open;
}
pub(super) fn close_help(&mut self) {
self.help_open = false;
}
pub(super) fn set_help_open(&mut self, open: bool) {
self.help_open = open;
}
#[must_use]
pub fn cursor_lock(&self) -> Option<(&str, Lock)> {
let PaneRow::Field(index) = self.cursor()?;
let field = self.fields.fields().get(index)?;
self.lock(&field.key).map(|lock| (field.key.as_str(), lock))
}
#[must_use]
pub fn cursor_kind(&self) -> Option<&FieldKind> {
let PaneRow::Field(index) = self.cursor()?;
self.fields.fields().get(index).map(|field| &field.kind)
}
pub fn cycle(&mut self, now: Instant) {
let Some(PaneRow::Field(index)) = self.cursor() else {
return;
};
let Some(field) = self.fields.fields().get(index) else {
return;
};
if self.lock(&field.key).is_some() {
return;
}
let armed_here = match &self.pending_edit {
Some(PanePending::Armed {
edit: PaneEdit::Set { key, value },
..
}) if *key == field.key => Some(value.as_value()),
_ => None,
};
let current = armed_here.or_else(|| self.values.get(&field.key));
let next = match &field.kind {
FieldKind::Bool => Value::Bool(!current.and_then(Value::as_bool).unwrap_or(false)),
FieldKind::Choice(names) | FieldKind::Suggested(names) if !names.is_empty() => {
let current = current.and_then(Value::as_str);
let next = current
.and_then(|value| names.iter().position(|name| name == value))
.map_or(0, |i| (i + 1) % names.len());
Value::String(names[next].clone())
}
FieldKind::Choice(_)
| FieldKind::Suggested(_)
| FieldKind::Text
| FieldKind::Integer
| FieldKind::Map
| FieldKind::List(_)
| FieldKind::Opaque => return,
};
let edit = PaneEdit::Set {
key: field.key.clone(),
value: next.into(),
};
let text = self.confirm_text(&edit);
self.pending_edit = Some(PanePending::Armed {
edit,
text,
at: now,
});
}
pub fn begin_typing(&mut self) {
let Some(PaneRow::Field(index)) = self.cursor() else {
return;
};
let Some(field) = self.fields.fields().get(index) else {
return;
};
if self.lock(&field.key).is_some()
|| !matches!(
field.kind,
FieldKind::Text | FieldKind::Integer | FieldKind::Suggested(_)
)
{
return;
}
let seed = if field.secret {
String::new()
} else {
match self.value(&field.key) {
unset if unset == "(unset)" => String::new(),
value => value,
}
};
self.pending_edit = Some(PanePending::Typing {
key: field.key.clone(),
buffer: seed,
});
}
pub fn type_char(&mut self, typed: char) {
if let Some(PanePending::Typing { buffer, .. }) = self.pending_edit.as_mut() {
buffer.push(typed);
}
}
pub fn type_backspace(&mut self) {
if let Some(PanePending::Typing { buffer, .. }) = self.pending_edit.as_mut() {
buffer.pop();
}
}
pub fn apply_typing(&mut self, now: Instant) {
let Some(PanePending::Typing { key, buffer }) = self.pending_edit.take() else {
return;
};
let kind = self.fields.by_key(&key).map(|field| field.kind.clone());
let value = match (kind, buffer.as_str()) {
(_, "") => Value::Null,
(Some(FieldKind::Integer), text) => match text.parse::<i64>() {
Ok(number) => Value::from(number),
Err(_) => {
self.pending_edit = Some(PanePending::Typing { key, buffer });
return;
}
},
(_, text) => Value::String(text.to_owned()),
};
let edit = PaneEdit::Set {
key,
value: value.into(),
};
let text = self.confirm_text(&edit);
self.pending_edit = Some(PanePending::Armed {
edit,
text,
at: now,
});
}
pub fn abandon_typing(&mut self) {
if matches!(self.pending_edit, Some(PanePending::Typing { .. })) {
self.pending_edit = None;
}
}
pub fn cancel(&mut self) {
if matches!(self.pending_edit, Some(PanePending::Armed { .. })) {
self.pending_edit = None;
}
}
#[must_use]
pub fn is_armed(&self) -> bool {
matches!(self.pending_edit, Some(PanePending::Armed { .. }))
}
#[must_use]
pub fn armed_at(&self) -> Option<Instant> {
match self.pending_edit {
Some(PanePending::Armed { at, .. }) => Some(at),
_ => None,
}
}
pub fn take_armed(&mut self, ticket: u64) -> Option<PaneEdit> {
match self.pending_edit.take() {
Some(PanePending::Armed { edit, text, .. }) => {
self.pending_edit = Some(PanePending::Sent {
ticket,
key: edit.key().to_owned(),
text,
});
Some(edit)
}
other => {
self.pending_edit = other;
None
}
}
}
pub fn settle(&mut self, ticket: u64) {
if matches!(&self.pending_edit, Some(PanePending::Sent { ticket: sent, .. }) if *sent == ticket)
{
self.pending_edit = None;
}
}
pub(super) fn arm_env(&mut self, key: String, value: Option<EnvValue>, now: Instant) {
let edit = PaneEdit::SetEnv { key, value };
let text = self.confirm_text(&edit);
self.pending_edit = Some(PanePending::Armed {
edit,
text,
at: now,
});
}
fn confirm_text(&self, edit: &PaneEdit) -> String {
let name = self.target.name();
let (key, value) = match edit {
PaneEdit::SetEnv {
key,
value: Some(_),
} => {
return format!("set env {key}? {key} waits for `shep reload {name}`");
}
PaneEdit::SetEnv { key, value: None } => {
return format!(
"remove env {key}? it waits for `shep reload {name}`, and lookout cannot \
read the value back to put it there again"
);
}
PaneEdit::Set { key, value } => (key, value),
};
let secret = self.fields.by_key(key).is_some_and(|field| field.secret);
let shown = match value.as_value() {
Value::Null => "(unset)".to_owned(),
_ if secret => "<set>".to_owned(),
Value::String(text) => self.resolved_display(key, text),
other => other.to_string(),
};
match self.cost(key) {
Some(ApplyGroup::Live) => format!("set {key} = {shown}? {key} is a live setting"),
Some(ApplyGroup::NextSpawn) => {
format!("set {key} = {shown}? {key} is read when {name} spawns")
}
Some(_) => format!("set {key} = {shown}? {key} waits for `shep reload {name}`"),
None => format!("set {key} = {shown}? {name} is told, and decides what to reload"),
}
}
#[must_use]
pub fn target(&self) -> &PaneTarget {
&self.target
}
#[must_use]
pub fn fields(&self) -> &FieldSet {
&self.fields
}
#[must_use]
pub fn value(&self, key: &str) -> String {
if key == "env" && matches!(self.target, PaneTarget::Sheep { .. }) {
return match self.env_keys.len() {
1 => "1 key".to_owned(),
count => format!("{count} keys"),
};
}
match self.values.get(key) {
None | Some(Value::Null) => "(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(),
}
}
#[must_use]
pub fn display_value(&self, key: &str) -> String {
self.resolved_display(key, &self.value(key))
}
fn resolved_display(&self, key: &str, raw: &str) -> String {
if raw.is_empty() || !raw.bytes().all(|byte| byte.is_ascii_digit()) {
return raw.to_owned();
}
match self.fields.by_key(key).and_then(|field| field.value_kind) {
Some(ValueKind::MemSize) => raw
.parse::<MemSize>()
.map_or_else(|_| raw.to_owned(), |_| format!("{raw} B")),
Some(ValueKind::UpDuration) => raw
.parse::<UpDuration>()
.map_or_else(|_| raw.to_owned(), |_| format!("{raw}ms")),
None => raw.to_owned(),
}
}
#[must_use]
pub fn cost(&self, key: &str) -> Option<ApplyGroup> {
match self.target {
PaneTarget::Sheep { .. } => Some(apply_group(key)),
PaneTarget::Dog { .. } => None,
}
}
#[must_use]
pub fn lock(&self, key: &str) -> Option<Lock> {
if self.cost(key) == Some(ApplyGroup::Structural) {
return Some(Lock::Refused);
}
match self.fields.by_key(key) {
Some(field) if !field.editable => Some(Lock::NoWidget),
_ => None,
}
}
#[must_use]
pub fn is_overridden(&self, key: &str) -> bool {
self.overridden.iter().any(|name| name == key)
}
#[must_use]
pub fn is_pending(&self, key: &str) -> bool {
self.pending.iter().any(|name| name == key)
}
#[must_use]
pub fn parked_count(&self) -> usize {
self.pending.len()
}
#[must_use]
pub fn reload_kind(&self) -> ReloadKind {
let flag = |key: &str| {
self.values
.get(key)
.and_then(Value::as_bool)
.unwrap_or(false)
};
let has_probe = self
.values
.get("readiness_probe")
.is_some_and(|probe| !probe.is_null());
reload_mode(flag("wait_ready"), has_probe, flag("reuse_port"))
}
#[must_use]
pub fn view(&self) -> &Viewport {
&self.view
}
pub fn set_rows(&mut self, rows: usize) {
let len = self.rows().len();
self.view.set_rows(rows, len);
}
#[must_use]
pub fn rows(&self) -> Vec<PaneRow> {
(0..self.fields.len()).map(PaneRow::Field).collect()
}
#[must_use]
pub fn cursor(&self) -> Option<PaneRow> {
let rows = self.rows();
rows.get(self.view.cursor()).copied()
}
pub(super) fn move_by(&mut self, delta: isize) {
let len = self.rows().len();
self.view.move_by(delta, len);
}
pub(super) fn move_to_first(&mut self) {
let len = self.rows().len();
self.view.move_to(0, len);
}
pub(super) fn move_to_last(&mut self) {
let len = self.rows().len();
self.view.move_to(len.saturating_sub(1), len);
}
pub(super) fn adopt_pending_edit(&mut self, previous: Option<PanePending>) {
self.pending_edit = match previous {
Some(carried @ (PanePending::Armed { .. } | PanePending::Sent { .. })) => Some(carried),
Some(PanePending::Typing { .. }) | None => None,
};
}
pub(super) fn adopt_view(&mut self, view: Viewport) {
self.view = view;
let len = self.rows().len();
self.view.clamp(len);
}
pub(super) fn adopt_env_view(&mut self, view: Viewport, cursor_key: Option<&str>) {
let mut env = EnvPane::new(self.env_keys.clone());
env.adopt_view(view, cursor_key);
self.env = Some(env);
}
pub(super) fn adopt_list_view(&mut self, key: &str, view: Viewport) {
let Some(item) = self.fields.by_key(key).and_then(|field| match field.kind {
FieldKind::List(item) => Some(item),
_ => None,
}) else {
return;
};
let mut list = ListPane::new(key.to_owned(), item, self.elements_of(key));
list.adopt_view(view);
self.list = Some(list);
}
#[cfg(test)]
pub(crate) fn move_to_key(&mut self, key: &str) {
if let Some(index) = self
.fields
.fields()
.iter()
.position(|field| field.key == key)
{
let len = self.rows().len();
self.view.move_to(index, len);
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReloadKind {
Overlap,
Serial,
}
impl ReloadKind {
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Overlap => "overlapping",
Self::Serial => "serial",
}
}
}
const fn reload_mode(wait_ready: bool, has_probe: bool, reuse_port: bool) -> ReloadKind {
if !wait_ready && has_probe && !reuse_port {
ReloadKind::Serial
} else {
ReloadKind::Overlap
}
}
#[cfg(test)]
mod tests {
use shep_core::config::{AppConfig, ProbeConfig, ProbeKind};
use super::*;
fn web() -> SheepConfigView {
let mut config = AppConfig {
name: "web".into(),
max_restarts: 32,
..AppConfig::default()
};
config
.env
.insert("DB_HOST".into(), "{{shared:DB_HOST}}".into());
SheepConfigView::new(config, vec!["max_restarts".into()], vec!["env".into()])
}
fn web_with_args(args: &[&str]) -> SheepConfigView {
let config = AppConfig {
name: "web".into(),
args: args.iter().map(|arg| (*arg).to_string()).collect(),
stop_exit_codes: vec![0, 143],
..AppConfig::default()
};
SheepConfigView::new(config, Vec::new(), Vec::new())
}
#[test]
fn a_sheep_pane_has_thirty_nine_fields_in_eight_groups() {
let pane = ConfigPane::sheep(web());
assert_eq!(pane.fields().len(), 39);
assert!(!pane.fields().is_empty());
let mut groups: Vec<&str> = Vec::new();
for field in pane.fields().fields() {
let group = field.group.as_deref().expect("every field carries a group");
if groups.last() != Some(&group) {
groups.push(group);
}
}
assert_eq!(
groups,
[
"process",
"logging",
"inputs",
"restart",
"readiness",
"shutdown",
"watch",
"cron"
]
);
}
#[test]
fn a_value_renders_bare_for_a_scalar_and_as_a_count_for_env() {
let pane = ConfigPane::sheep(web());
assert_eq!(pane.value("max_restarts"), "32");
assert_eq!(pane.value("autorestart"), "true");
assert_eq!(pane.value("cwd"), "(unset)");
assert_eq!(pane.value("env"), "1 key");
}
#[test]
fn cost_comes_from_apply_group_for_a_sheep() {
let pane = ConfigPane::sheep(web());
assert_eq!(pane.cost("max_restarts"), Some(ApplyGroup::Live));
assert_eq!(pane.cost("kill_signal"), Some(ApplyGroup::NextSpawn));
assert_eq!(pane.cost("script"), Some(ApplyGroup::NeedsRespawn));
assert_eq!(pane.cost("instances"), Some(ApplyGroup::Structural));
}
#[test]
fn structural_fields_are_not_editable_and_the_rest_are() {
let pane = ConfigPane::sheep(web());
for key in ["name", "instances"] {
assert!(!pane.fields().by_key(key).unwrap().editable, "{key}");
}
assert!(pane.fields().by_key("max_restarts").unwrap().editable);
}
#[test]
fn the_menu_counts_the_parked_fields_once() {
let mut config = AppConfig {
name: "web".into(),
..AppConfig::default()
};
config.env.insert("DB_HOST".into(), "db.internal".into());
let view =
SheepConfigView::new(config, Vec::new(), vec!["env".into(), "kill_signal".into()]);
assert_eq!(ConfigPane::sheep(view).parked_count(), 2);
assert_eq!(ConfigPane::sheep(web_with_args(&[])).parked_count(), 0);
}
#[test]
fn a_probe_without_reuse_port_is_the_only_serial_reload() {
assert_eq!(reload_mode(false, true, false), ReloadKind::Serial);
assert_eq!(reload_mode(true, true, false), ReloadKind::Overlap);
assert_eq!(reload_mode(false, true, true), ReloadKind::Overlap);
assert_eq!(reload_mode(false, false, false), ReloadKind::Overlap);
}
#[test]
fn a_pane_reads_its_reload_kind_off_the_three_fields_it_turns_on() {
let probed = |wait_ready, reuse_port| {
let config = AppConfig {
name: "web".into(),
wait_ready,
reuse_port,
readiness_probe: Some(ProbeConfig {
kind: ProbeKind::Tcp,
target: "127.0.0.1:8080".into(),
interval: UpDuration::from_millis(10_000),
timeout: UpDuration::from_millis(5_000),
failure_threshold: 3,
}),
..AppConfig::default()
};
ConfigPane::sheep(SheepConfigView::new(config, Vec::new(), Vec::new())).reload_kind()
};
assert_eq!(probed(false, false), ReloadKind::Serial);
assert_eq!(probed(true, false), ReloadKind::Overlap);
assert_eq!(probed(false, true), ReloadKind::Overlap);
assert_eq!(
ConfigPane::sheep(web()).reload_kind(),
ReloadKind::Overlap,
"no probe, so nothing an outgoing instance could answer"
);
}
#[test]
fn overridden_and_pending_are_read_off_the_view() {
let pane = ConfigPane::sheep(web());
assert!(pane.is_overridden("max_restarts"));
assert!(!pane.is_overridden("autorestart"));
assert!(pane.is_pending("env"));
assert!(!pane.is_pending("max_restarts"));
}
#[test]
fn the_cursor_walks_the_rows_and_clamps_at_both_ends() {
let mut pane = ConfigPane::sheep(web());
assert_eq!(pane.cursor(), Some(PaneRow::Field(0)));
pane.move_by(1);
assert_eq!(pane.cursor(), Some(PaneRow::Field(1)));
pane.move_by(-5);
assert_eq!(pane.cursor(), Some(PaneRow::Field(0)));
pane.move_to_last();
assert_eq!(pane.cursor(), Some(PaneRow::Field(38)));
assert_eq!(
pane.fields().fields()[38].key,
"cron_timezone",
"the last row is the last field"
);
pane.move_to_first();
assert_eq!(pane.cursor(), Some(PaneRow::Field(0)));
}
#[test]
fn a_refreshed_pane_keeps_the_cursor_it_had() {
let mut pane = ConfigPane::sheep(web());
pane.set_rows(10);
pane.move_to_last();
let carried = pane.view().clone();
let mut fresh = ConfigPane::sheep(web());
fresh.adopt_view(carried);
assert_eq!(fresh.cursor(), Some(PaneRow::Field(38)));
}
#[test]
fn cycling_a_bool_arms_a_set_with_the_flipped_value() {
let mut pane = ConfigPane::sheep(web());
pane.move_to_key("autorestart");
pane.cycle(Instant::now());
let Some(PanePending::Armed {
edit: PaneEdit::Set { key, value },
text,
..
}) = pane.pending_edit()
else {
panic!("{:?}", pane.pending_edit());
};
assert_eq!(key, "autorestart");
assert_eq!(value.as_value(), &serde_json::json!(false));
assert!(text.contains("autorestart"), "{text}");
}
#[test]
fn space_cycles_a_suggested_field_and_e_still_opens_the_editor() {
let mut pane = ConfigPane::sheep(web());
pane.move_to_key("kill_signal");
pane.cycle(Instant::now());
assert!(pane.pending_edit().is_some(), "space arms a suggestion");
pane.cancel();
pane.begin_typing();
assert!(
matches!(pane.pending_edit(), Some(PanePending::Typing { .. })),
"e still opens a free-text editor"
);
}
#[test]
fn a_live_confirm_names_the_class_and_promises_no_outcome() {
for key in ["autorestart", "watch"] {
let mut pane = ConfigPane::sheep(web());
pane.move_to_key(key);
pane.cycle(Instant::now());
let Some(PanePending::Armed { text, .. }) = pane.pending_edit() else {
panic!("{:?}", pane.pending_edit());
};
assert_eq!(
pane.cost(key),
Some(ApplyGroup::Live),
"the fixture must keep {key} Live or the test means nothing"
);
assert!(text.contains("is a live setting"), "{text}");
assert!(!text.contains("takes it now"), "{text}");
assert!(!text.contains("respawn"), "{text}");
assert!(!text.contains("next start"), "{text}");
}
}
#[test]
fn a_next_spawn_confirm_names_when_the_field_is_read() {
let mut pane = ConfigPane::sheep(web());
pane.move_to_key("autostart");
pane.cycle(Instant::now());
let Some(PanePending::Armed { text, .. }) = pane.pending_edit() else {
panic!("{:?}", pane.pending_edit());
};
assert_eq!(pane.cost("autostart"), Some(ApplyGroup::NextSpawn));
assert!(text.contains("is read when web spawns"), "{text}");
assert!(!text.contains("respawn"), "{text}");
}
#[test]
fn a_respawn_field_arms_a_confirm_that_names_the_reload_it_waits_for() {
for key in ["merge_logs", "shutdown_with_message"] {
let mut pane = ConfigPane::sheep(web());
pane.move_to_key(key);
pane.cycle(Instant::now());
let Some(PanePending::Armed { text, .. }) = pane.pending_edit() else {
panic!("{:?}", pane.pending_edit());
};
assert_eq!(
pane.cost(key),
Some(ApplyGroup::NeedsRespawn),
"the fixture must keep {key} NeedsRespawn or the test means nothing"
);
assert!(text.contains("waits for `shep reload web`"), "{text}");
assert!(!text.contains("is respawned"), "{text}");
}
}
#[test]
fn an_env_confirm_names_the_reload_it_waits_for_and_never_the_value() {
for value in [Some("hunter2".to_owned().into()), None] {
let pane = ConfigPane::sheep(web());
let text = pane.confirm_text(&PaneEdit::SetEnv {
key: "DB_PASSWORD".into(),
value,
});
assert!(text.contains("waits for `shep reload web`"), "{text}");
assert!(!text.contains("respawn"), "{text}");
assert!(!text.contains("hunter2"), "{text}");
}
}
#[test]
fn a_read_only_field_does_not_arm() {
let mut pane = ConfigPane::sheep(web());
pane.move_to_key("instances");
pane.cycle(Instant::now());
assert!(pane.pending_edit().is_none());
pane.begin_typing();
assert!(pane.pending_edit().is_none());
}
#[test]
fn typing_into_an_integer_and_applying_arms_a_number_not_a_string() {
let mut pane = ConfigPane::sheep(web());
pane.move_to_key("max_restarts");
pane.begin_typing();
let Some(PanePending::Typing { buffer, .. }) = pane.pending_edit() else {
panic!("{:?}", pane.pending_edit());
};
assert_eq!(buffer, "32", "the editor opens on what is on screen");
pane.type_backspace();
pane.type_backspace();
for c in "40".chars() {
pane.type_char(c);
}
pane.apply_typing(Instant::now());
let Some(PanePending::Armed {
edit: PaneEdit::Set { value, .. },
..
}) = pane.pending_edit()
else {
panic!("{:?}", pane.pending_edit());
};
assert_eq!(value.as_value(), &serde_json::json!(40));
}
#[test]
fn an_edit_carries_the_key_and_the_typed_value_and_nothing_else() {
let mut pane = ConfigPane::sheep(web());
pane.move_to_key("max_restarts");
pane.begin_typing();
pane.type_backspace();
pane.type_backspace();
for c in "40".chars() {
pane.type_char(c);
}
pane.apply_typing(Instant::now());
let Some(PaneEdit::Set { key, value }) = pane.take_armed(7) else {
panic!("{:?}", pane.pending_edit());
};
assert_eq!(key, "max_restarts");
assert_eq!(value.as_value(), &serde_json::json!(40));
assert!(
!matches!(value.as_value(), serde_json::Value::String(_)),
"an integer field must not travel as a string"
);
}
#[test]
fn a_bare_up_duration_shows_its_resolved_unit_and_a_suffixed_one_is_unchanged() {
let pane = ConfigPane::sheep(web());
assert_eq!(pane.display_value("kill_timeout"), "1600ms");
assert_eq!(pane.value("kill_timeout"), "1600");
assert_eq!(pane.display_value("listen_timeout"), "3s");
}
#[test]
fn a_dogs_own_spelling_of_a_duration_survives_the_pane() {
let schema = serde_json::json!({
"properties": {
"poll": { "type": "string", "$ref": "#/$defs/UpDuration" }
},
"$defs": { "UpDuration": { "type": "string" } }
});
let pane = ConfigPane::dog("bark".into(), None, schema, "poll = \"60s\"\n".into());
assert_eq!(pane.display_value("poll"), "60s");
}
#[test]
fn a_bare_mem_size_shows_its_resolved_unit_and_a_suffixed_one_is_unchanged() {
for (bytes, want) in [(64, "64 B"), (512 << 20, "512M")] {
let config = AppConfig {
name: "web".into(),
max_memory: Some(MemSize::from_bytes(bytes)),
..AppConfig::default()
};
let pane = ConfigPane::sheep(SheepConfigView::new(config, Vec::new(), Vec::new()));
assert_eq!(pane.display_value("max_memory"), want, "{bytes} bytes");
}
}
#[test]
fn a_field_with_no_unit_grammar_displays_the_same_either_way() {
let pane = ConfigPane::sheep(web());
assert_eq!(pane.display_value("cwd"), pane.value("cwd"));
assert_eq!(
pane.display_value("max_restarts"),
pane.value("max_restarts")
);
}
#[test]
fn arming_a_bare_number_on_a_mem_size_field_confirms_the_resolved_value() {
let mut pane = ConfigPane::sheep(web());
pane.move_to_key("max_memory");
pane.begin_typing();
for c in "64".chars() {
pane.type_char(c);
}
pane.apply_typing(Instant::now());
let Some(PanePending::Armed { text, .. }) = pane.pending_edit() else {
panic!("{:?}", pane.pending_edit());
};
assert!(text.contains("set max_memory = 64 B?"), "{text}");
}
#[test]
fn an_unparseable_buffer_on_a_unit_field_confirms_as_typed() {
let mut pane = ConfigPane::sheep(web());
pane.move_to_key("max_memory");
pane.begin_typing();
for c in "banana".chars() {
pane.type_char(c);
}
pane.apply_typing(Instant::now());
let Some(PanePending::Armed { text, .. }) = pane.pending_edit() else {
panic!("{:?}", pane.pending_edit());
};
assert!(text.contains("set max_memory = banana?"), "{text}");
}
#[test]
fn toggling_help_flips_it_and_closing_it_is_idempotent() {
let mut pane = ConfigPane::sheep(web());
assert!(!pane.help_open());
pane.toggle_help();
assert!(pane.help_open());
pane.toggle_help();
assert!(!pane.help_open());
pane.toggle_help();
pane.close_help();
assert!(!pane.help_open());
pane.close_help();
assert!(
!pane.help_open(),
"closing an already-closed help is a no-op"
);
}
#[test]
fn the_env_pane_lists_keys_and_a_new_row_and_an_empty_apply_means_unset() {
let mut env = EnvPane::new(vec!["A".into(), "B".into()]);
assert_eq!(env.rows().len(), 3, "two keys and a + new row");
env.move_to_last();
env.begin_typing();
for c in "C=3".chars() {
env.type_char(c);
}
assert_eq!(
env.apply_typing(),
Some(("C".to_owned(), Some("3".to_owned())))
);
env.move_to_first();
env.begin_typing();
assert_eq!(env.apply_typing(), Some(("A".to_owned(), None)));
}
fn bark_section() -> String {
"# how often\npoll = \"60s\"\nhistory_bytes = 4096\n\n[sinks.ops]\nkind = \"slack\"\nurl = \"https://hooks.example/x\"\n".to_owned()
}
fn bark_pane() -> ConfigPane {
let schema = crate::dog::builtin_schema("bark").expect("bark is a built-in");
ConfigPane::dog("bark".into(), None, schema, bark_section())
}
#[test]
fn a_dog_pane_is_flat_in_schema_order_and_marks_the_secret() {
let pane = bark_pane();
assert!(
pane.fields()
.fields()
.iter()
.all(|field| field.group.is_none()),
"a dog's schema carries no group, so the pane draws no headers"
);
let keys: Vec<&str> = pane
.fields()
.fields()
.iter()
.map(|f| f.key.as_str())
.collect();
assert_eq!(
keys,
["history_bytes", "poll", "rules", "sink_timeout", "sinks"],
"schema order, which for a serde_json map is alphabetical"
);
assert!(
pane.fields()
.by_key("sinks")
.expect("sinks is a field")
.secret
);
assert_eq!(pane.value("poll"), "60s");
assert_eq!(pane.cost("poll"), None);
}
#[test]
fn a_dogs_map_field_is_locked_for_want_of_a_widget_and_not_refused() {
let pane = bark_pane();
assert_eq!(pane.lock("sinks"), Some(Lock::NoWidget));
assert_eq!(pane.lock("rules"), Some(Lock::NoWidget));
assert_eq!(pane.lock("poll"), None);
}
fn secret_dog_pane() -> ConfigPane {
let schema = serde_json::json!({
"properties": {
"webhook": { "type": "string", "x-shep-secret": true },
}
});
ConfigPane::dog(
"pydog".into(),
None,
schema,
"webhook = \"https://hook/OLD\"\n".to_owned(),
)
}
#[test]
fn a_dogs_own_env_field_reads_its_section_and_not_the_sheep_key_count() {
let schema = serde_json::json!({
"properties": {
"env": { "type": "string" },
}
});
let mut pane = ConfigPane::dog(
"pydog".into(),
None,
schema,
"env = \"staging\"\n".to_owned(),
);
assert_eq!(pane.value("env"), "staging");
pane.move_to_key("env");
pane.begin_typing();
let Some(PanePending::Typing { buffer, .. }) = pane.pending_edit() else {
panic!("{:?}", pane.pending_edit());
};
assert_eq!(buffer, "staging");
}
#[test]
fn a_secret_fields_confirm_never_quotes_what_was_typed() {
let mut pane = secret_dog_pane();
pane.move_to_key("webhook");
pane.begin_typing();
for typed in "https://hook/T0PS3CRET".chars() {
pane.type_char(typed);
}
pane.apply_typing(Instant::now());
let Some(PanePending::Armed { text, .. }) = pane.pending_edit() else {
panic!("{:?}", pane.pending_edit());
};
assert!(!text.contains("T0PS3CRET"), "{text}");
assert!(text.contains("set webhook = <set>?"), "{text}");
}
#[test]
fn an_edited_section_keeps_its_comments_and_changes_one_key() {
let pane = bark_pane();
let out = pane
.edited_section(&PaneEdit::Set {
key: "poll".into(),
value: serde_json::json!("30s").into(),
})
.expect("the fixture section parses");
assert!(out.contains("# how often"), "{out}");
assert!(out.contains("poll = \"30s\""), "{out}");
assert!(out.contains("history_bytes = 4096"), "{out}");
assert!(out.contains("url = \"https://hooks.example/x\""), "{out}");
}
#[test]
fn a_null_edit_removes_the_key_from_the_section() {
let pane = bark_pane();
let out = pane
.edited_section(&PaneEdit::Set {
key: "history_bytes".into(),
value: serde_json::Value::Null.into(),
})
.expect("the fixture section parses");
assert!(!out.contains("history_bytes"), "{out}");
assert!(out.contains("# how often"), "{out}");
}
#[test]
fn a_sheep_pane_has_no_section_to_edit() {
let pane = ConfigPane::sheep(web());
assert_eq!(
pane.edited_section(&PaneEdit::Set {
key: "cwd".into(),
value: serde_json::json!("/srv").into(),
}),
None
);
}
#[test]
fn a_field_values_debug_names_no_value() {
for (value, want) in [
(serde_json::json!("/home/ada/secret-project"), "<string>"),
(serde_json::json!(40), "<number>"),
(serde_json::json!(false), "<bool>"),
(serde_json::json!(null), "<null>"),
(serde_json::json!(["--token", "hunter2"]), "<array>"),
(serde_json::json!({ "a": 1 }), "<object>"),
] {
let wrapped: FieldValue = value.into();
assert_eq!(format!("{wrapped:?}"), format!("FieldValue({want})"));
}
}
#[test]
fn a_pane_edits_debug_names_no_value() {
let set = PaneEdit::Set {
key: "cwd".into(),
value: serde_json::json!("/home/ada/secret-project").into(),
};
assert_eq!(
format!("{set:?}"),
r#"Set { key: "cwd", value: FieldValue(<string>) }"#
);
let env = PaneEdit::SetEnv {
key: "DB_PASSWORD".into(),
value: Some("hunter2".to_owned().into()),
};
assert_eq!(
format!("{env:?}"),
r#"SetEnv { key: "DB_PASSWORD", value: Some(EnvValue(<7 bytes>)) }"#
);
}
#[test]
fn debug_names_no_value_on_a_pane_pending() {
let typing = PanePending::Typing {
key: "cwd".into(),
buffer: "/home/ada/secret-project".into(),
};
assert_eq!(
format!("{typing:?}"),
r#"PanePending::Typing { key: "cwd", buffer: <24 chars> }"#
);
let armed = PanePending::Armed {
edit: PaneEdit::Set {
key: "cwd".into(),
value: serde_json::json!("/home/ada/secret-project").into(),
},
text: "set cwd = /home/ada/secret-project? web takes it now".into(),
at: Instant::now(),
};
assert_eq!(
format!("{armed:?}"),
r#"PanePending::Armed { edit: Set { key: "cwd", value: FieldValue(<string>) } }"#
);
let sent = PanePending::Sent {
ticket: 7,
key: "cwd".into(),
text: "set cwd = /home/ada/secret-project? web takes it now".into(),
};
assert_eq!(
format!("{sent:?}"),
r#"PanePending::Sent { ticket: 7, key: "cwd" }"#
);
}
#[test]
fn an_env_panes_debug_names_no_key_and_no_value() {
let mut env = EnvPane::new(vec!["DB_PASSWORD".into(), "API_TOKEN".into()]);
env.move_to_last();
env.begin_typing();
for typed in "STRIPE_KEY=sk_live_1".chars() {
env.type_char(typed);
}
assert_eq!(
format!("{env:?}"),
"EnvPane { keys: 2, cursor: 2, typing: true }"
);
}
#[test]
fn a_dog_panes_debug_names_no_section() {
let pane = bark_pane();
assert_eq!(
format!("{pane:?}"),
r#"ConfigPane { target: Dog { name: "bark", adopted_path: None }, fields: 5, env_keys: 0, cursor: 0 }"#
);
}
#[test]
fn the_panes_debug_names_no_value_it_holds() {
let mut config = AppConfig {
name: "web".into(),
cwd: Some("/home/ada/secret-project".into()),
args: vec!["--token".into(), "hunter2".into()],
..AppConfig::default()
};
config.env.insert("DB_PASSWORD".into(), "hunter2".into());
let pane = ConfigPane::sheep(SheepConfigView::new(config, Vec::new(), Vec::new()));
assert_eq!(
format!("{pane:?}"),
r#"ConfigPane { target: Sheep { name: "web" }, fields: 39, env_keys: 1, cursor: 0 }"#
);
}
#[test]
fn editing_one_element_sends_the_whole_array() {
let mut pane = ConfigPane::sheep(web_with_args(&["--port", "8080"]));
pane.move_to_key("args");
pane.open_list();
pane.list_mut().expect("open").move_to(1);
pane.arm_list_element("9090".into(), Instant::now());
let Some(PaneEdit::Set { key, value }) = pane.take_armed(1) else {
panic!("expected a set");
};
assert_eq!(key, "args");
assert_eq!(value.as_value(), &serde_json::json!(["--port", "9090"]));
}
#[test]
fn a_list_panes_debug_names_no_element() {
let list = ListPane::new(
"args".into(),
ListItem::Text,
vec!["--token".into(), "hunter2".into()],
);
assert_eq!(
format!("{list:?}"),
r#"ListPane { key: "args", item: Text, elements: 2, typing: none }"#
);
}
#[test]
fn an_integer_element_that_does_not_parse_keeps_the_editor_open() {
let mut pane = ConfigPane::sheep(web_with_args(&[]));
pane.move_to_key("stop_exit_codes");
pane.open_list();
let list = pane.list_mut().expect("open");
list.move_to_last();
list.begin_typing();
list.type_char('-');
assert_eq!(list.apply_typing(), None);
assert!(list.typing().is_some(), "the editor is still open");
list.type_char('1');
assert_eq!(list.apply_typing().as_deref(), Some("-1"));
}
#[test]
fn removing_and_reordering_arm_the_whole_array_too() {
let mut pane = ConfigPane::sheep(web_with_args(&["a", "b", "c"]));
pane.move_to_key("args");
pane.open_list();
pane.list_mut().expect("open").move_to(1);
pane.arm_list_removal(Instant::now());
let Some(PaneEdit::Set { value, .. }) = pane.take_armed(1) else {
panic!("expected a set");
};
assert_eq!(value.as_value(), &serde_json::json!(["a", "c"]));
pane.arm_list_reorder(-1, Instant::now());
let Some(PaneEdit::Set { value, .. }) = pane.take_armed(2) else {
panic!("expected a set");
};
assert_eq!(value.as_value(), &serde_json::json!(["b", "a", "c"]));
}
#[test]
fn moving_an_element_down_arms_the_whole_array() {
let mut pane = ConfigPane::sheep(web_with_args(&["a", "b", "c"]));
pane.move_to_key("args");
pane.open_list();
pane.list_mut().expect("open").move_to(0);
pane.arm_list_reorder(1, Instant::now());
let Some(PaneEdit::Set { value, .. }) = pane.take_armed(1) else {
panic!("expected a set");
};
assert_eq!(value.as_value(), &serde_json::json!(["b", "a", "c"]));
}
#[test]
fn a_removal_or_a_move_with_nothing_to_act_on_arms_nothing() {
let mut pane = ConfigPane::sheep(web_with_args(&["a", "b"]));
pane.move_to_key("args");
pane.open_list();
pane.list_mut().expect("open").move_to_last();
pane.arm_list_removal(Instant::now());
assert!(!pane.is_armed(), "the `+ new` row holds no element");
pane.list_mut().expect("open").move_to_first();
pane.arm_list_reorder(-1, Instant::now());
assert!(!pane.is_armed(), "the first element cannot move up");
}
#[test]
fn an_integer_array_travels_as_numbers() {
let mut pane = ConfigPane::sheep(web_with_args(&[]));
pane.move_to_key("stop_exit_codes");
pane.open_list();
assert_eq!(pane.list().expect("open").elements(), ["0", "143"]);
pane.list_mut().expect("open").move_to(0);
pane.arm_list_element("2".into(), Instant::now());
let Some(PaneEdit::Set { key, value }) = pane.take_armed(1) else {
panic!("expected a set");
};
assert_eq!(key, "stop_exit_codes");
assert_eq!(value.as_value(), &serde_json::json!([2, 143]));
}
#[test]
fn a_dogs_array_field_has_no_widget_here() {
let schema = serde_json::json!({
"properties": { "sinks": { "type": "array", "items": { "type": "string" } } }
});
let pane = ConfigPane::dog("bark".into(), None, schema, String::new());
assert!(!pane.fields().by_key("sinks").expect("declared").editable);
assert_eq!(pane.lock("sinks"), Some(Lock::NoWidget));
}
}