use std::path::PathBuf;
use serde_json::{Map, Value};
use shep_core::config::{
AppConfig, ApplyGroup, GROUP_ORDER, apply_group, flockfile_schema_json, reaches_running,
};
use shep_core::protocol::{EnvValue, SheepConfigView};
use shep_core::values::{MemSize, UpDuration};
use super::edits::{EditKey, Edits};
use super::field::{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),
Env(usize),
AddEnv,
}
#[derive(Clone, PartialEq, Eq)]
pub struct FieldValue(Value);
impl FieldValue {
#[must_use]
pub fn as_value(&self) -> &Value {
&self.0
}
#[must_use]
pub fn safe_summary(&self) -> Option<String> {
match &self.0 {
Value::Bool(value) => Some(value.to_string()),
Value::Number(value) => Some(value.to_string()),
Value::Null | Value::String(_) | Value::Array(_) | Value::Object(_) => None,
}
}
}
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>,
},
}
#[derive(Clone, PartialEq, Eq)]
pub struct PaneTyping {
pub key: String,
pub buffer: String,
}
impl core::fmt::Debug for PaneTyping {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(
f,
"PaneTyping {{ key: {:?}, buffer: <{} chars> }}",
self.key,
self.buffer.chars().count()
)
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct EnvTyping {
key: Option<String>,
buffer: String,
}
impl core::fmt::Debug for EnvTyping {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(
f,
"EnvTyping {{ key: {}, buffer: <{} chars> }}",
self.key.is_some(),
self.buffer.chars().count()
)
}
}
impl EnvTyping {
#[must_use]
pub fn key(&self) -> Option<&str> {
self.key.as_deref()
}
#[must_use]
pub fn buffer(&self) -> &str {
&self.buffer
}
}
#[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(super) fn set_elements(&mut self, elements: Vec<String>) {
self.elements = elements;
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 array_elements(value: &Value) -> Vec<String> {
let Value::Array(values) = value else {
return Vec::new();
};
values
.iter()
.map(|value| match value {
Value::String(text) => text.clone(),
other => other.to_string(),
})
.collect()
}
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())
}
fn render_json(value: &Value) -> String {
match value {
Value::Null => "(unset)".to_owned(),
Value::String(text) => text.clone(),
Value::Bool(flag) => flag.to_string(),
Value::Number(number) => number.to_string(),
other => other.to_string(),
}
}
#[derive(Clone)]
pub struct ConfigPane {
target: PaneTarget,
fields: FieldSet,
values: Map<String, Value>,
env_keys: Vec<String>,
overridden: Vec<String>,
pending: Vec<String>,
group: usize,
view: Viewport,
typing: Option<PaneTyping>,
edits: Edits,
env_typing: Option<EnvTyping>,
list: Option<ListPane>,
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()
)
}
}
pub(crate) fn sheep_fields(config: &AppConfig) -> (FieldSet, Map<String, Value>) {
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(config)
.ok()
.and_then(|value| value.as_object().cloned())
.unwrap_or_default();
(fields, values)
}
pub(crate) fn resolved_display(fields: &FieldSet, key: &str, raw: &str) -> String {
if raw.is_empty() || !raw.bytes().all(|byte| byte.is_ascii_digit()) {
return raw.to_owned();
}
match 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(),
}
}
impl ConfigPane {
#[must_use]
pub fn sheep(view: SheepConfigView) -> Self {
let (fields, values) = sheep_fields(&view.config);
Self {
target: PaneTarget::Sheep { name: view.name },
fields,
values,
env_keys: view.env_keys,
overridden: view.overridden,
pending: view.pending,
group: 0,
view: Viewport::new(),
typing: None,
edits: Edits::default(),
env_typing: None,
list: None,
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(),
group: 0,
view: Viewport::new(),
typing: None,
edits: Edits::default(),
env_typing: None,
list: None,
section: Some(section),
}
}
#[must_use]
pub fn edited_section_with(&self, edits: &Edits) -> Option<String> {
let section = self.section.as_deref()?;
let mut doc: toml_edit::DocumentMut = section.parse().ok()?;
let mut wrote = false;
for (key, edit) in edits.iter() {
let EditKey::Field(name) = key else {
continue;
};
let PaneEdit::Set { value, .. } = edit.edit() else {
continue;
};
match value.as_value() {
Value::Null => {
doc.remove(name.as_str());
}
Value::Bool(flag) => doc[name.as_str()] = toml_edit::value(*flag),
Value::Number(number) => match (number.as_i64(), number.as_f64()) {
(Some(int), _) => doc[name.as_str()] = toml_edit::value(int),
(None, Some(float)) => doc[name.as_str()] = toml_edit::value(float),
(None, None) => return None,
},
Value::String(text) => doc[name.as_str()] = toml_edit::value(text.as_str()),
other => doc[name.as_str()] = toml_edit::value(other.to_string()),
}
wrote = true;
}
wrote.then(|| doc.to_string())
}
#[must_use]
pub fn typing(&self) -> Option<&PaneTyping> {
self.typing.as_ref()
}
#[must_use]
pub fn edits(&self) -> &Edits {
&self.edits
}
#[must_use]
pub(super) fn close(&mut self) -> Edits {
core::mem::take(&mut self.edits)
}
pub(super) fn undo_edit(&mut self) -> Option<EditKey> {
let undone = self.edits.undo();
if let Some(key) = self.list.as_ref().map(|list| list.key().to_owned()) {
let elements = self.filed_elements_of(&key);
if let Some(list) = self.list.as_mut() {
list.set_elements(elements);
}
}
undone
}
#[must_use]
pub fn env_key_names(&self) -> &[String] {
&self.env_keys
}
#[must_use]
pub fn env_typing(&self) -> Option<&EnvTyping> {
self.env_typing.as_ref()
}
pub(super) fn begin_env_typing(&mut self) {
self.env_typing = match self.cursor() {
Some(PaneRow::Env(index)) => Some(EnvTyping {
key: self.env_keys.get(index).cloned(),
buffer: String::new(),
}),
Some(PaneRow::AddEnv) => Some(EnvTyping {
key: None,
buffer: String::new(),
}),
Some(PaneRow::Field(_)) | None => None,
};
}
pub fn type_env_char(&mut self, typed: char) {
if let Some(typing) = self.env_typing.as_mut() {
typing.buffer.push(typed);
}
}
pub fn type_env_backspace(&mut self) {
if let Some(typing) = self.env_typing.as_mut() {
typing.buffer.pop();
}
}
pub fn abandon_env_typing(&mut self) {
self.env_typing = None;
}
pub fn apply_env_typing(&mut self) {
let Some(EnvTyping { key, buffer }) = self.env_typing.take() else {
return;
};
let (key, value) = match key {
Some(key) => (key, (!buffer.is_empty()).then_some(buffer)),
None => {
let Some((key, value)) = buffer.split_once('=') else {
return;
};
if key.is_empty() {
return;
}
(key.to_owned(), Some(value.to_owned()))
}
};
self.file_env(key, value.map(EnvValue::from));
}
#[must_use]
pub(super) fn cursor_env_key(&self) -> Option<Option<String>> {
match self.cursor()? {
PaneRow::Env(index) => Some(self.env_keys.get(index).cloned()),
PaneRow::AddEnv => Some(None),
PaneRow::Field(_) => None,
}
}
pub(super) fn adopt_env_cursor(&mut self, key: Option<&str>) {
let rows = self.rows();
let index = key
.and_then(|key| {
rows.iter().position(|row| match row {
PaneRow::Env(env_index) => {
self.env_keys.get(*env_index).map(String::as_str) == Some(key)
}
PaneRow::Field(_) | PaneRow::AddEnv => false,
})
})
.unwrap_or_else(|| rows.len().saturating_sub(1));
let len = rows.len();
self.view.move_to(index, len);
}
#[cfg(test)]
#[must_use]
pub(crate) fn cursor_env_key_name(&self) -> Option<&str> {
match self.cursor()? {
PaneRow::Env(index) => self.env_keys.get(index).map(String::as_str),
PaneRow::Field(_) | PaneRow::AddEnv => 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.filed_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> {
self.values.get(key).map(array_elements).unwrap_or_default()
}
fn filed_elements_of(&self, key: &str) -> Vec<String> {
let filed = match self.edits.get(&EditKey::Field(key.to_owned())) {
Some(entry) => match entry.edit() {
PaneEdit::Set { value, .. } => Some(value.as_value()),
PaneEdit::SetEnv { .. } => None,
},
None => None,
};
match filed {
Some(value) => array_elements(value),
None => self.elements_of(key),
}
}
pub(super) fn file_list_element(&mut self, text: String) {
let Some(elements) = self.list.as_ref().and_then(|list| list.with_element(text)) else {
return;
};
self.file_list(elements);
}
pub(super) fn file_list_removal(&mut self) {
let Some(elements) = self.list.as_ref().and_then(ListPane::without_element) else {
return;
};
self.file_list(elements);
}
pub(super) fn file_list_reorder(&mut self, delta: isize) {
let Some(elements) = self.list.as_ref().and_then(|list| list.reordered(delta)) else {
return;
};
self.file_list(elements);
}
fn file_list(&mut self, elements: Vec<String>) {
let Some(list) = self.list.as_ref() else {
return;
};
let key = list.key().to_owned();
let value = list_value(list.item(), &elements);
self.file_field(key, value);
if let Some(list) = self.list.as_mut() {
list.set_elements(elements);
}
}
#[must_use]
pub fn cursor_lock(&self) -> Option<(&str, Lock)> {
let PaneRow::Field(index) = self.cursor()? else {
return None;
};
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()? else {
return None;
};
self.fields.fields().get(index).map(|field| &field.kind)
}
pub fn cycle(&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() {
return;
}
let filed_here = match self.edits.get(&EditKey::Field(field.key.clone())) {
Some(entry) => match entry.edit() {
PaneEdit::Set { value, .. } => Some(value.as_value()),
PaneEdit::SetEnv { .. } => None,
},
None => None,
};
let current = filed_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 key = field.key.clone();
self.file_field(key, next);
}
fn file_field(&mut self, key: String, value: Value) {
if self.stored_value_is(&key, &value) {
self.edits.remove(&EditKey::Field(key));
return;
}
let impact = self.cost(&key);
self.edits.set(
PaneEdit::Set {
key,
value: value.into(),
},
impact,
);
}
fn stored_value_is(&self, key: &str, value: &Value) -> bool {
match self.values.get(key) {
Some(stored) => stored == value,
None => value.is_null(),
}
}
pub(super) fn file_default(&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() {
return;
}
let key = field.key.clone();
if self.field_shows_default(&key) {
return;
}
let value = self.default_for(field);
self.file_field(key, value);
}
fn default_for(&self, field: &Field) -> Value {
match &self.target {
PaneTarget::Sheep { .. } => field.default_value.clone().unwrap_or(Value::Null),
PaneTarget::Dog { .. } => Value::Null,
}
}
fn field_shows_default(&self, key: &str) -> bool {
if self.edits.get(&EditKey::Field(key.to_owned())).is_some() {
return false;
}
match &self.target {
PaneTarget::Sheep { .. } => !self.is_overridden(key),
PaneTarget::Dog { .. } => self.stored_value_is(key, &Value::Null),
}
}
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.typing = Some(PaneTyping {
key: field.key.clone(),
buffer: seed,
});
}
pub fn type_char(&mut self, typed: char) {
if let Some(typing) = self.typing.as_mut() {
typing.buffer.push(typed);
}
}
pub fn type_backspace(&mut self) {
if let Some(typing) = self.typing.as_mut() {
typing.buffer.pop();
}
}
pub fn apply_typing(&mut self) {
let Some(PaneTyping { key, buffer }) = self.typing.take() else {
return;
};
let field = self.fields.by_key(&key).cloned();
let kind = field.as_ref().map(|field| field.kind.clone());
let value = match (kind, buffer.as_str()) {
(_, "") => field
.as_ref()
.map_or(Value::Null, |field| self.default_for(field)),
(Some(FieldKind::Integer), text) => match text.parse::<i64>() {
Ok(number) => Value::from(number),
Err(_) => {
self.typing = Some(PaneTyping { key, buffer });
return;
}
},
(_, text) => Value::String(text.to_owned()),
};
self.file_field(key, value);
}
pub fn abandon_typing(&mut self) {
self.typing = None;
}
pub(super) fn file_env(&mut self, key: String, value: Option<EnvValue>) {
let impact = self.cost("env");
self.edits.set(PaneEdit::SetEnv { key, value }, impact);
}
#[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"),
};
}
self.values
.get(key)
.map_or_else(|| render_json(&Value::Null), render_json)
}
#[must_use]
pub fn edited_value(&self, key: &str) -> Option<String> {
let PaneEdit::Set { value, .. } = self.edits.get(&EditKey::Field(key.to_owned()))?.edit()
else {
return None;
};
let raw = render_json(value.as_value());
Some(resolved_display(&self.fields, key, &raw))
}
#[must_use]
pub fn display_value(&self, key: &str) -> String {
resolved_display(&self.fields, key, &self.value(key))
}
#[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(super) fn unsent_fields_needing_a_respawn(&self) -> Vec<String> {
self.edits
.iter()
.filter_map(|(key, _)| match key {
EditKey::Field(name) if !reaches_running(name) => Some(name.clone()),
EditKey::Env(name) => Some(format!("env {name}")),
EditKey::Field(_) => None,
})
.collect()
}
#[must_use]
pub(super) fn live_edit_count(&self) -> usize {
self.edits
.iter()
.filter(|(key, _)| matches!(key, EditKey::Field(name) if reaches_running(name)))
.count()
}
#[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 group(&self) -> &'static str {
GROUP_ORDER
.get(self.group)
.copied()
.unwrap_or(GROUP_ORDER[0])
}
pub fn next_group(&mut self) {
self.group = (self.group + 1) % GROUP_ORDER.len();
}
pub fn set_group(&mut self, digit: u8) {
let Some(index) = usize::from(digit).checked_sub(1) else {
return;
};
if index < GROUP_ORDER.len() {
self.group = index;
}
}
#[must_use]
pub fn rows(&self) -> Vec<PaneRow> {
let group = self.group();
let is_sheep = matches!(self.target, PaneTarget::Sheep { .. });
let mut rows: Vec<PaneRow> = self
.fields
.fields()
.iter()
.enumerate()
.filter(|(_, field)| !is_sheep || field.key != "env")
.filter(|(_, field)| field.group.as_deref().is_none_or(|g| g == group))
.map(|(index, _)| PaneRow::Field(index))
.collect();
if is_sheep {
rows.extend((0..self.env_keys.len()).map(PaneRow::Env));
rows.push(PaneRow::AddEnv);
}
rows
}
#[must_use]
pub(super) fn field_rows(&self) -> Vec<PaneRow> {
self.rows()
.into_iter()
.filter(|row| matches!(row, 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_edits(&mut self, previous: Edits) {
self.edits = previous;
}
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_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.filed_elements_of(key));
list.adopt_view(view);
self.list = Some(list);
}
#[cfg(test)]
pub(crate) fn move_to_key(&mut self, key: &str) {
let Some(field) = self.fields.by_key(key) else {
return;
};
if let Some(group) = field.group.clone()
&& let Some(index) = GROUP_ORDER.iter().position(|known| *known == group)
{
self.group = index;
}
if let Some(row_index) = self.rows().iter().position(|row| match row {
PaneRow::Field(field_index) => self.fields.fields()[*field_index].key == key,
PaneRow::Env(_) | PaneRow::AddEnv => false,
}) {
let len = self.rows().len();
self.view.move_to(row_index, len);
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReloadKind {
Overlap,
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 filed(pane: &ConfigPane, key: &str) -> Option<Value> {
match pane.edits().get(&EditKey::Field(key.to_owned()))?.edit() {
PaneEdit::Set { value, .. } => Some(value.as_value().clone()),
PaneEdit::SetEnv { .. } => None,
}
}
fn filed_impact(pane: &ConfigPane, key: &str) -> Option<ApplyGroup> {
pane.edits().get(&EditKey::Field(key.to_owned()))?.impact()
}
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_forty_two_fields_in_eight_groups() {
let pane = ConfigPane::sheep(web());
assert_eq!(pane.fields().len(), 42);
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::AddEnv));
assert_eq!(
pane.fields().fields()[9].key,
"user",
"the last field is process's own last one"
);
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::AddEnv));
}
#[test]
fn cycling_a_bool_files_a_set_with_the_flipped_value() {
let mut pane = ConfigPane::sheep(web());
pane.move_to_key("autorestart");
pane.cycle();
assert_eq!(filed(&pane, "autorestart"), Some(serde_json::json!(false)));
assert_eq!(pane.edits().len(), 1, "one key, one entry");
}
#[test]
fn cycling_a_bool_back_to_the_stored_value_unfiles_it() {
let mut pane = ConfigPane::sheep(web());
pane.move_to_key("autorestart");
pane.cycle();
pane.cycle();
assert_eq!(filed(&pane, "autorestart"), None);
assert!(pane.edits().is_empty());
}
#[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();
assert!(
filed(&pane, "kill_signal").is_some(),
"space files a suggestion"
);
pane.begin_typing();
assert!(pane.typing().is_some(), "e still opens a free-text editor");
}
#[test]
fn a_filed_edit_carries_the_fields_own_apply_group() {
for (key, want) in [
("autorestart", ApplyGroup::Live),
("watch", ApplyGroup::Live),
("autostart", ApplyGroup::NextSpawn),
("merge_logs", ApplyGroup::NeedsRespawn),
("shutdown_with_message", ApplyGroup::NeedsRespawn),
] {
let mut pane = ConfigPane::sheep(web());
pane.move_to_key(key);
pane.cycle();
assert_eq!(
pane.cost(key),
Some(want),
"the fixture must keep {key} {want:?} or the test means nothing"
);
assert_eq!(filed_impact(&pane, key), Some(want), "{key}");
}
}
#[test]
fn a_dogs_filed_edit_carries_no_apply_group() {
let mut pane = bark_pane();
pane.move_to_key("history_bytes");
pane.begin_typing();
for _ in 0..8 {
pane.type_backspace();
}
for typed in "8192".chars() {
pane.type_char(typed);
}
pane.apply_typing();
assert_eq!(filed(&pane, "history_bytes"), Some(serde_json::json!(8192)));
assert_eq!(filed_impact(&pane, "history_bytes"), None);
}
#[test]
fn a_filed_env_edit_carries_envs_own_apply_group() {
for value in [Some("hunter2".to_owned().into()), None] {
let mut pane = ConfigPane::sheep(web());
pane.file_env("DB_PASSWORD".into(), value);
let entry = pane
.edits()
.get(&EditKey::Env("DB_PASSWORD".into()))
.expect("filed under its env key");
assert_eq!(entry.impact(), Some(ApplyGroup::NeedsRespawn));
assert!(
pane.edits()
.get(&EditKey::Field("DB_PASSWORD".into()))
.is_none(),
"an env key is not a config field"
);
}
}
#[test]
fn no_key_files_an_edit_for_a_structural_field() {
let structural: Vec<String> = ConfigPane::sheep(web())
.fields()
.fields()
.iter()
.filter(|field| apply_group(&field.key) == ApplyGroup::Structural)
.map(|field| field.key.clone())
.collect();
assert_eq!(
structural,
vec!["instances".to_owned(), "name".to_owned()],
"the schema must still carry the two Structural fields"
);
for key in structural {
let mut pane = ConfigPane::sheep(web());
pane.move_to_key(&key);
assert_eq!(pane.lock(&key), Some(Lock::Refused), "{key}");
pane.cycle();
pane.begin_typing();
pane.type_char('x');
pane.apply_typing();
assert!(pane.edits().is_empty(), "{key} reached the set");
}
}
#[test]
fn typing_into_an_integer_and_applying_files_a_number_not_a_string() {
let mut pane = ConfigPane::sheep(web());
pane.move_to_key("max_restarts");
pane.begin_typing();
let typing = pane.typing().expect("the editor is open");
assert_eq!(typing.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();
assert_eq!(filed(&pane, "max_restarts"), Some(serde_json::json!(40)));
}
#[test]
fn emptying_an_integer_field_and_applying_files_its_default_not_null() {
let mut pane = ConfigPane::sheep(web());
pane.move_to_key("max_restarts");
pane.begin_typing();
for _ in 0..10 {
pane.type_backspace();
}
assert_eq!(pane.typing().expect("still open").buffer, "");
pane.apply_typing();
assert_eq!(filed(&pane, "max_restarts"), Some(serde_json::json!(16)));
}
#[test]
fn emptying_a_field_with_no_schema_default_still_unsets_to_null() {
let config = AppConfig {
name: "web".into(),
cwd: Some("/srv/web".into()),
..AppConfig::default()
};
let mut pane = ConfigPane::sheep(SheepConfigView::new(config, vec!["cwd".into()], vec![]));
pane.move_to_key("cwd");
pane.begin_typing();
assert_eq!(
pane.typing().expect("the editor is open").buffer,
"/srv/web",
"the editor opens on what is on screen"
);
for _ in 0..8 {
pane.type_backspace();
}
pane.apply_typing();
assert_eq!(filed(&pane, "cwd"), Some(serde_json::Value::Null));
}
#[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();
let writes = pane.close().into_writes();
let [PaneEdit::Set { key, value }] = writes.as_slice() else {
panic!("{writes:?}");
};
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 closing_hands_out_every_filed_edit_and_leaves_the_pane_empty() {
let mut pane = ConfigPane::sheep(web());
pane.move_to_key("autorestart");
pane.cycle();
pane.move_to_key("autostart");
pane.cycle();
assert_eq!(pane.edits().len(), 2);
let writes = pane.close().into_writes();
assert_eq!(writes.len(), 2, "{writes:?}");
assert!(pane.edits().is_empty(), "the pane keeps nothing back");
assert!(
pane.close().into_writes().is_empty(),
"a second close writes nothing twice"
);
}
#[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 a_unit_field_files_the_buffer_and_never_a_resolved_form() {
for typed in ["64", "banana"] {
let mut pane = ConfigPane::sheep(web());
pane.move_to_key("max_memory");
pane.begin_typing();
for c in typed.chars() {
pane.type_char(c);
}
pane.apply_typing();
assert_eq!(
filed(&pane, "max_memory"),
Some(serde_json::json!(typed)),
"{typed}"
);
}
}
#[test]
fn the_env_rows_list_keys_and_add_a_key_and_an_empty_apply_means_unset() {
let mut pane = ConfigPane::sheep(web());
let env_row_count = pane
.rows()
.into_iter()
.filter(|row| matches!(row, PaneRow::Env(_) | PaneRow::AddEnv))
.count();
assert_eq!(env_row_count, 2, "one key and a + add a key row");
pane.move_to_last();
assert_eq!(pane.cursor(), Some(PaneRow::AddEnv));
pane.begin_env_typing();
for c in "NEW_KEY=value".chars() {
pane.type_env_char(c);
}
pane.apply_env_typing();
let entry = pane
.edits()
.get(&EditKey::Env("NEW_KEY".to_owned()))
.expect("NEW_KEY was filed");
assert!(matches!(entry.edit(), PaneEdit::SetEnv { key, .. } if key == "NEW_KEY"));
pane.move_by(-1);
assert_eq!(pane.cursor(), Some(PaneRow::Env(0)));
pane.begin_env_typing();
pane.apply_env_typing();
let removal = pane
.edits()
.get(&EditKey::Env("DB_HOST".to_owned()))
.expect("DB_HOST was filed");
assert!(matches!(
removal.edit(),
PaneEdit::SetEnv { key, value: None } if key == "DB_HOST"
));
}
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_secret_field_seeds_its_editor_empty_and_files_what_was_typed() {
let mut pane = secret_dog_pane();
pane.move_to_key("webhook");
pane.begin_typing();
assert_eq!(
pane.typing().expect("the editor is open").buffer,
"",
"a secret seeds empty, however much the section holds"
);
for typed in "https://hook/NEW".chars() {
pane.type_char(typed);
}
pane.apply_typing();
assert_eq!(
filed(&pane, "webhook"),
Some(serde_json::json!("https://hook/NEW"))
);
let debug = format!("{pane:?}");
assert!(!debug.contains("OLD"), "{debug}");
assert!(!debug.contains("NEW"), "{debug}");
}
#[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 typing = pane.typing().expect("the editor is open");
assert_eq!(typing.buffer, "staging");
}
fn field(key: &str, value: serde_json::Value) -> PaneEdit {
PaneEdit::Set {
key: key.to_owned(),
value: value.into(),
}
}
#[test]
fn an_edited_section_keeps_its_comments_and_changes_one_key() {
let pane = bark_pane();
let mut edits = Edits::default();
edits.set(field("poll", serde_json::json!("30s")), None);
let out = pane
.edited_section_with(&edits)
.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 mut edits = Edits::default();
edits.set(field("history_bytes", serde_json::Value::Null), None);
let out = pane
.edited_section_with(&edits)
.expect("the fixture section parses");
assert!(!out.contains("history_bytes"), "{out}");
assert!(out.contains("# how often"), "{out}");
}
#[test]
fn d_on_a_dog_field_files_null_even_with_a_schema_default() {
let schema = serde_json::json!({
"properties": {
"merge_logs": { "type": "boolean", "default": true },
},
});
let mut pane = ConfigPane::dog("bark".into(), None, schema, "merge_logs = false\n".into());
pane.move_to_key("merge_logs");
pane.file_default();
assert_eq!(
filed(&pane, "merge_logs"),
Some(Value::Null),
"a dog restores by removing the key, not by filing its default"
);
let out = pane
.edited_section_with(pane.edits())
.expect("the fixture section parses");
assert!(!out.contains("merge_logs"), "{out}");
}
#[test]
fn a_dog_section_takes_every_filed_edit_in_one_write() {
let pane = bark_pane();
let mut edits = Edits::default();
edits.set(field("url", serde_json::json!("http://a")), None);
edits.set(field("timeout", serde_json::json!(30)), None);
let section = pane
.edited_section_with(&edits)
.expect("the fixture parses");
assert!(section.contains("http://a"), "{section}");
assert!(section.contains("30"), "{section}");
}
#[test]
fn an_env_edit_is_ignored_by_a_dog_section() {
let pane = bark_pane();
let mut edits = Edits::default();
edits.set(
PaneEdit::SetEnv {
key: "SECRET".to_owned(),
value: None,
},
None,
);
assert_eq!(pane.edited_section_with(&edits), None);
}
#[test]
fn a_field_edit_lands_even_when_the_same_batch_carries_an_env_edit() {
let pane = bark_pane();
let mut edits = Edits::default();
edits.set(field("poll", serde_json::json!("30s")), None);
edits.set(
PaneEdit::SetEnv {
key: "SECRET".to_owned(),
value: None,
},
None,
);
let out = pane
.edited_section_with(&edits)
.expect("the field edit alone should still write");
assert!(out.contains("poll = \"30s\""), "{out}");
assert!(!out.contains("SECRET"), "{out}");
}
#[test]
fn a_sheep_pane_has_no_section_to_edit() {
let pane = ConfigPane::sheep(web());
let mut edits = Edits::default();
edits.set(field("cwd", serde_json::json!("/srv")), None);
assert_eq!(pane.edited_section_with(&edits), 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_typing() {
let typing = PaneTyping {
key: "cwd".into(),
buffer: "/home/ada/secret-project".into(),
};
assert_eq!(
format!("{typing:?}"),
r#"PaneTyping { key: "cwd", buffer: <24 chars> }"#
);
}
#[test]
fn debug_names_no_key_and_no_value_on_an_env_typing() {
let mut pane = ConfigPane::sheep(web());
pane.move_to_last();
assert_eq!(pane.cursor(), Some(PaneRow::AddEnv));
pane.begin_env_typing();
let typed = "STRIPE_KEY=sk_live_1";
for typed in typed.chars() {
pane.type_env_char(typed);
}
let typing = pane.env_typing().expect("the editor is open");
assert_eq!(
format!("{typing:?}"),
format!(
"EnvTyping {{ key: false, buffer: <{} chars> }}",
typed.chars().count()
)
);
}
#[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: 42, 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.file_list_element("9090".into());
assert_eq!(
filed(&pane, "args"),
Some(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_file_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.file_list_removal();
assert_eq!(filed(&pane, "args"), Some(serde_json::json!(["a", "c"])));
pane.file_list_reorder(-1);
assert_eq!(
filed(&pane, "args"),
Some(serde_json::json!(["c", "a"])),
"the cursor is on `c` now, and moving it up files one entry, not two"
);
assert_eq!(pane.edits().len(), 1, "one field, one entry");
}
#[test]
fn a_second_list_keystroke_builds_on_the_first() {
let mut pane = ConfigPane::sheep(web_with_args(&[]));
pane.move_to_key("args");
pane.open_list();
pane.list_mut().expect("open").move_to_last();
pane.file_list_element("abc".into());
pane.list_mut().expect("open").move_to_last();
pane.file_list_element("def".into());
assert_eq!(
filed(&pane, "args"),
Some(serde_json::json!(["abc", "def"]))
);
assert_eq!(pane.edits().len(), 1, "one field, one entry");
}
#[test]
fn re_opening_the_sub_screen_shows_what_was_filed() {
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(0);
pane.file_list_removal();
assert_eq!(pane.list().expect("open").elements(), ["b"]);
pane.close_list();
pane.open_list();
assert_eq!(pane.list().expect("re-opened").elements(), ["b"]);
}
#[test]
fn moving_an_element_down_files_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.file_list_reorder(1);
assert_eq!(
filed(&pane, "args"),
Some(serde_json::json!(["b", "a", "c"]))
);
}
#[test]
fn a_removal_or_a_move_with_nothing_to_act_on_files_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.file_list_removal();
assert!(pane.edits().is_empty(), "the `+ new` row holds no element");
pane.list_mut().expect("open").move_to_first();
pane.file_list_reorder(-1);
assert!(pane.edits().is_empty(), "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.file_list_element("2".into());
assert_eq!(
filed(&pane, "stop_exit_codes"),
Some(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));
}
}