use super::app::KeyPress;
use super::textbox::TextBox;
pub const EMPTY_RENAME: &str = "a rename needs a value typed: this pane never reads \
the one the key already holds";
#[must_use]
pub fn is_env_key(name: &str) -> bool {
let mut chars = name.chars();
let Some(first) = chars.next() else {
return false;
};
(first.is_ascii_alphabetic() || first == '_')
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Grammar {
Secret,
Env,
}
impl Grammar {
#[must_use]
pub fn accepts(self, name: &str) -> bool {
match self {
Self::Secret => shep_core::secrets::is_name(name),
Self::Env => is_env_key(name),
}
}
#[must_use]
pub const fn rule(self) -> &'static str {
match self {
Self::Secret => {
"letters, digits, `.`, `_` and `-`, up to 128 bytes, not starting with a dot"
}
Self::Env => "a letter or `_`, then letters, digits or `_`",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Field {
Key,
Value,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Row {
Add,
Under(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Refusal {
Key(&'static str),
EmptyValue,
}
impl Refusal {
#[must_use]
pub fn sentence(self, key: &str) -> String {
match self {
Self::Key(rule) => format!("{key:?} is not a valid key: {rule}"),
Self::EmptyValue => EMPTY_RENAME.to_owned(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Outcome {
Refused(Refusal),
Moved,
Submitted,
}
#[derive(Clone, PartialEq, Eq)]
pub struct KeyValues {
grammar: Grammar,
row: Row,
key: TextBox,
value: TextBox,
focus: Field,
}
impl core::fmt::Debug for KeyValues {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(
f,
"KeyValues {{ add: {}, key: <{} chars>, value: <{} chars>, focus: {:?} }}",
matches!(self.row, Row::Add),
self.key.char_count(),
self.value.char_count(),
self.focus,
)
}
}
impl KeyValues {
#[must_use]
pub fn add(grammar: Grammar) -> Self {
Self {
grammar,
row: Row::Add,
key: TextBox::new(),
value: TextBox::new(),
focus: Field::Key,
}
}
#[must_use]
pub fn under(grammar: Grammar, key: &str) -> Self {
Self {
grammar,
row: Row::Under(key.to_owned()),
key: TextBox::from(key),
value: TextBox::new(),
focus: Field::Value,
}
}
#[must_use]
pub const fn focus(&self) -> Field {
self.focus
}
#[must_use]
pub fn key(&self) -> &TextBox {
&self.key
}
#[must_use]
pub fn value(&self) -> &TextBox {
&self.value
}
#[must_use]
pub fn opened_under(&self) -> Option<&str> {
match &self.row {
Row::Add => None,
Row::Under(key) => Some(key.as_str()),
}
}
#[must_use]
pub fn is_rename(&self) -> bool {
match &self.row {
Row::Add => false,
Row::Under(key) => self.key.as_str() != key.as_str(),
}
}
pub fn handle(&mut self, key: KeyPress) {
match self.focus {
Field::Key => self.key.handle(key),
Field::Value => self.value.handle(key),
}
}
pub fn submit(&mut self) -> Outcome {
match self.focus {
Field::Key => self.move_focus(Field::Value),
Field::Value => {
if self.is_rename() && self.value.is_empty() {
return Outcome::Refused(Refusal::EmptyValue);
}
Outcome::Submitted
}
}
}
pub fn step(&mut self, back: bool) -> Outcome {
let to = match (self.focus, back) {
(Field::Key, false) => Field::Value,
(Field::Value, true) => Field::Key,
(Field::Key, true) | (Field::Value, false) => self.focus,
};
self.move_focus(to)
}
fn move_focus(&mut self, to: Field) -> Outcome {
if self.focus == Field::Key
&& to == Field::Value
&& !self.grammar.accepts(self.key.as_str())
{
return Outcome::Refused(Refusal::Key(self.grammar.rule()));
}
self.focus = to;
Outcome::Moved
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lookout::app::{App, Body, Effect, Msg};
use crate::lookout::textbox::TextEdit;
use crate::lookout::view::fixtures;
use shep_core::secrets::MAX_KEY_BYTES;
fn typed(editor: &mut KeyValues, text: &str) {
for c in text.chars() {
editor.handle(KeyPress::TextChar(c));
}
}
#[test]
fn an_add_row_starts_on_key_with_both_fields_empty() {
let editor = KeyValues::add(Grammar::Env);
assert_eq!(editor.focus(), Field::Key);
assert_eq!(editor.opened_under(), None);
assert!(editor.key().is_empty());
assert!(editor.value().is_empty());
}
#[test]
fn an_existing_row_starts_on_value_with_the_cursor_at_its_end() {
let editor = KeyValues::under(Grammar::Env, "DB_HOST");
assert_eq!(editor.focus(), Field::Value);
assert_eq!(editor.opened_under(), Some("DB_HOST"));
assert_eq!(editor.key().as_str(), "DB_HOST");
assert_eq!(editor.value().cursor(), editor.value().char_count());
}
#[test]
fn tab_and_shift_tab_move_between_the_two_fields() {
let mut editor = KeyValues::add(Grammar::Env);
typed(&mut editor, "WEB");
assert_eq!(editor.step(false), Outcome::Moved);
assert_eq!(editor.focus(), Field::Value);
assert_eq!(editor.step(true), Outcome::Moved);
assert_eq!(editor.focus(), Field::Key);
}
#[test]
fn a_step_off_either_end_stays_on_the_field_it_was_on() {
let mut editor = KeyValues::add(Grammar::Env);
assert_eq!(editor.step(true), Outcome::Moved);
assert_eq!(editor.focus(), Field::Key);
typed(&mut editor, "WEB");
editor.step(false);
assert_eq!(editor.step(false), Outcome::Moved);
assert_eq!(editor.focus(), Field::Value);
}
#[test]
fn enter_on_value_submits_and_enter_on_key_moves_off_it() {
let mut editor = KeyValues::under(Grammar::Env, "DB_HOST");
assert_eq!(editor.submit(), Outcome::Submitted);
let mut adding = KeyValues::add(Grammar::Env);
typed(&mut adding, "WEB");
assert_eq!(adding.submit(), Outcome::Moved);
assert_eq!(adding.focus(), Field::Value);
assert_eq!(adding.submit(), Outcome::Submitted);
}
#[test]
fn a_typed_character_lands_in_the_focused_field_only() {
let mut editor = KeyValues::add(Grammar::Env);
typed(&mut editor, "WEB");
editor.handle(KeyPress::TextBackspace);
assert_eq!(editor.key().as_str(), "WE");
assert!(editor.value().is_empty());
editor.step(false);
typed(&mut editor, "value");
assert_eq!(editor.key().as_str(), "WE");
assert_eq!(editor.value().as_str(), "value");
editor.handle(KeyPress::TextEdit(TextEdit::DeleteToStart));
assert!(editor.value().is_empty());
assert_eq!(editor.key().as_str(), "WE", "the other field is untouched");
}
#[test]
fn a_key_the_grammar_refuses_keeps_the_cursor_on_key() {
for (grammar, text) in [(Grammar::Env, "9BAD"), (Grammar::Secret, ".bad")] {
let mut editor = KeyValues::add(grammar);
typed(&mut editor, text);
let refusal = Refusal::Key(grammar.rule());
assert_eq!(
editor.submit(),
Outcome::Refused(refusal),
"{grammar:?} {text}"
);
assert_eq!(editor.focus(), Field::Key, "{grammar:?} {text}");
assert_eq!(
editor.step(false),
Outcome::Refused(refusal),
"{grammar:?} {text}"
);
assert_eq!(editor.focus(), Field::Key, "{grammar:?} {text}");
assert_eq!(editor.key().as_str(), text, "and nothing was thrown away");
}
}
#[test]
fn each_grammar_accepts_its_own_keys_and_refuses_the_others() {
for key in ["DB_HOST", "_PRIVATE", "a1"] {
assert!(Grammar::Env.accepts(key), "{key}");
}
for key in ["DB-HOST", "a.b", "9BAD"] {
assert!(Grammar::Secret.accepts(key), "{key}");
}
for key in ["DB-HOST", "a.b", "9BAD", "", ".leading"] {
assert!(!Grammar::Env.accepts(key), "{key}");
}
for key in ["DB HOST", "", ".leading", &"x".repeat(MAX_KEY_BYTES + 1)] {
assert!(!Grammar::Secret.accepts(key), "{key}");
}
}
#[test]
fn a_rename_is_a_key_field_that_no_longer_names_the_row() {
let mut editor = KeyValues::under(Grammar::Env, "DB_HOST");
assert!(!editor.is_rename());
editor.step(true);
editor.handle(KeyPress::TextEdit(TextEdit::DeleteToStart));
typed(&mut editor, "DB_URL");
assert!(editor.is_rename());
assert_eq!(editor.opened_under(), Some("DB_HOST"));
}
#[test]
fn a_rename_with_nothing_typed_in_value_is_refused() {
let mut editor = KeyValues::under(Grammar::Env, "DB_HOST");
editor.step(true);
editor.handle(KeyPress::TextEdit(TextEdit::DeleteToStart));
typed(&mut editor, "DB_URL");
editor.step(false);
assert_eq!(editor.submit(), Outcome::Refused(Refusal::EmptyValue));
assert_eq!(editor.focus(), Field::Value);
typed(&mut editor, "db.internal");
assert_eq!(editor.submit(), Outcome::Submitted);
}
#[test]
fn an_empty_value_on_an_unchanged_key_still_submits() {
let mut editor = KeyValues::under(Grammar::Env, "DB_HOST");
assert_eq!(editor.submit(), Outcome::Submitted);
}
#[test]
fn each_refusal_says_the_same_words_in_either_pane() {
assert_eq!(
Refusal::Key(Grammar::Secret.rule()).sentence(".bad"),
"\".bad\" is not a valid key: letters, digits, `.`, `_` and `-`, \
up to 128 bytes, not starting with a dot"
);
assert_eq!(
Refusal::Key(Grammar::Env.rule()).sentence("9BAD"),
"\"9BAD\" is not a valid key: a letter or `_`, then letters, digits or `_`"
);
assert_eq!(Refusal::EmptyValue.sentence("DB_HOST"), EMPTY_RENAME);
}
#[test]
fn the_editors_debug_prints_two_lengths_and_neither_field() {
let mut editor = KeyValues::under(Grammar::Secret, "DB_PASSWORD");
typed(&mut editor, "hunter2");
assert_eq!(
format!("{editor:?}"),
"KeyValues { add: false, key: <11 chars>, value: <7 chars>, focus: Value }"
);
assert!(!format!("{editor:?}").contains("hunter2"));
assert!(!format!("{editor:?}").contains("DB_PASSWORD"));
}
#[test]
fn the_add_rows_debug_names_no_half_typed_key() {
let mut editor = KeyValues::add(Grammar::Secret);
typed(&mut editor, "STRIPE_KEY");
assert_eq!(
format!("{editor:?}"),
"KeyValues { add: true, key: <10 chars>, value: <0 chars>, focus: Key }"
);
assert!(!format!("{editor:?}").contains("STRIPE_KEY"));
}
const ENV_KEY: &str = "DB_HOST";
const SECRET_KEY: &str = "DB_PASSWORD";
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
struct Written(String, Option<String>);
enum Step {
Press(KeyPress),
Type(&'static str),
}
struct Case {
name: &'static str,
row: Option<&'static str>,
steps: Vec<Step>,
writes: Vec<(&'static str, Option<&'static str>)>,
}
fn press(key: KeyPress) -> Step {
Step::Press(key)
}
fn types(text: &'static str) -> Step {
Step::Type(text)
}
fn cases() -> Vec<Case> {
vec![
Case {
name: "add a key and a value",
row: None,
steps: vec![
press(KeyPress::Confirm),
types("NEW_KEY"),
press(KeyPress::NextField),
types("value"),
press(KeyPress::TextApply),
],
writes: vec![("NEW_KEY", Some("value"))],
},
Case {
name: "edit a value",
row: Some("@"),
steps: vec![
press(KeyPress::Confirm),
types("hunter2"),
press(KeyPress::TextApply),
],
writes: vec![("@", Some("hunter2"))],
},
Case {
name: "rename a key",
row: Some("@"),
steps: vec![
press(KeyPress::Confirm),
press(KeyPress::PrevField),
press(KeyPress::TextEdit(TextEdit::DeleteToStart)),
types("RENAMED"),
press(KeyPress::NextField),
types("hunter2"),
press(KeyPress::TextApply),
],
writes: vec![("@", None), ("RENAMED", Some("hunter2"))],
},
Case {
name: "cancel",
row: Some("@"),
steps: vec![
press(KeyPress::Confirm),
types("hunter2"),
press(KeyPress::TextAbandon),
],
writes: vec![],
},
Case {
name: "delete",
row: Some("@"),
steps: vec![press(KeyPress::ArmDelete), press(KeyPress::Confirm)],
writes: vec![("@", None)],
},
]
}
fn named(key: &str, existing: &str) -> String {
if key == "@" {
existing.to_owned()
} else {
key.to_owned()
}
}
fn expected(case: &Case, existing: &str) -> Vec<Written> {
let mut out: Vec<Written> = case
.writes
.iter()
.map(|(key, value)| Written(named(key, existing), value.map(|value| value.to_owned())))
.collect();
out.sort();
out
}
fn env_writes(app: &App) -> Vec<Written> {
let mut out: Vec<Written> = app
.config_pane()
.expect("the pane is open")
.edits()
.iter()
.filter_map(|(key, entry)| {
let crate::lookout::edits::EditKey::Env(key) = key else {
return None;
};
let crate::lookout::pane::PaneEdit::SetEnv { value, .. } = entry.edit() else {
return None;
};
Some(Written(
key.clone(),
value
.as_ref()
.map(shep_core::protocol::EnvValue::as_str)
.map(str::to_owned),
))
})
.collect();
out.sort();
out
}
fn secret_writes(effects: &[Effect]) -> Vec<Written> {
let mut out: Vec<Written> = Vec::new();
for effect in effects {
match effect {
Effect::WriteSecret(edit, _) => {
out.push(Written(edit.key.clone(), edit.value.clone()));
}
Effect::WriteSecrets(edits, _) => {
out.extend(
edits
.iter()
.map(|edit| Written(edit.key.clone(), edit.value.clone())),
);
}
_ => {}
}
}
out.sort();
out
}
fn run_env(case: &Case) -> Vec<Written> {
let mut app = fixtures::app_in_sheep_pane_with_env(&[(ENV_KEY, "db.internal")]);
if case.row.is_none() {
app.update(Msg::Key(KeyPress::SelectLast));
} else {
fixtures::select_env_key(&mut app, ENV_KEY);
}
for step in &case.steps {
apply(&mut app, step);
}
env_writes(&app)
}
fn run_secrets(case: &Case) -> (Vec<Written>, Vec<Effect>) {
let mut app = fixtures::app_with_secrets_and_control();
if case.row.is_none() {
app.update(Msg::Key(KeyPress::SelectLast));
}
let mut effects: Vec<Effect> = Vec::new();
for step in &case.steps {
let effect = apply(&mut app, step);
if matches!(effect, Effect::WriteSecret(..) | Effect::WriteSecrets(..)) {
effects.push(effect);
}
}
(secret_writes(&effects), effects)
}
fn apply(app: &mut App, step: &Step) -> Effect {
match step {
Step::Press(key) => app.update(Msg::Key(*key)),
Step::Type(text) => {
let mut effect = Effect::None;
for character in text.chars() {
let next = app.update(Msg::Key(KeyPress::TextChar(character)));
if effect == Effect::None {
effect = next;
}
}
effect
}
}
}
#[test]
fn one_table_of_cases_writes_the_same_thing_in_both_panes() {
for case in cases() {
assert_eq!(
run_env(&case),
expected(&case, ENV_KEY),
"env: {}",
case.name
);
let (writes, _) = run_secrets(&case);
assert_eq!(
writes,
expected(&case, SECRET_KEY),
"secrets: {}",
case.name
);
}
}
#[test]
fn a_rename_is_one_write_in_either_pane() {
let case = cases()
.into_iter()
.find(|case| case.name == "rename a key")
.expect("the table has a rename case");
let mut app = fixtures::app_in_sheep_pane_with_env(&[(ENV_KEY, "db.internal")]);
fixtures::select_env_key(&mut app, ENV_KEY);
for step in &case.steps {
apply(&mut app, step);
}
let pane = app.config_pane().expect("the pane is open");
assert_eq!(
pane.edits().len(),
2,
"the new key and the removal of the old one, from one Enter"
);
let (_, effects) = run_secrets(&case);
assert_eq!(effects.len(), 1, "one effect, both halves: {effects:?}");
let Effect::WriteSecrets(edits, _) = &effects[0] else {
panic!("a rename is not a single write: {effects:?}");
};
assert_eq!(edits.len(), 2);
}
#[test]
fn an_invalid_key_is_refused_the_same_way_in_either_pane() {
let mut env = fixtures::app_in_sheep_pane_with_env(&[(ENV_KEY, "db.internal")]);
env.update(Msg::Key(KeyPress::SelectLast));
env.update(Msg::Key(KeyPress::Confirm));
for character in "A BAD".chars() {
env.update(Msg::Key(KeyPress::TextChar(character)));
}
env.update(Msg::Key(KeyPress::NextField));
let env_notice = env.notice().expect("a refusal says so").to_string();
assert_eq!(
env.config_pane()
.expect("open")
.env_typing()
.expect("open")
.focus(),
Field::Key
);
let mut secrets = fixtures::app_with_secrets_and_control();
secrets.update(Msg::Key(KeyPress::SelectLast));
secrets.update(Msg::Key(KeyPress::Confirm));
for character in "A BAD".chars() {
secrets.update(Msg::Key(KeyPress::TextChar(character)));
}
secrets.update(Msg::Key(KeyPress::NextField));
let secret_notice = secrets.notice().expect("a refusal says so").to_string();
let Body::Secrets(pane) = secrets.body() else {
panic!("the secrets pane is not open");
};
assert_eq!(pane.typing.as_ref().expect("open").focus(), Field::Key);
for notice in [&env_notice, &secret_notice] {
assert!(
notice.contains("\"A BAD\" is not a valid key: "),
"{notice}"
);
}
assert!(env_notice.contains("a letter or `_`"), "{env_notice}");
assert!(
secret_notice.contains("not starting with a dot"),
"{secret_notice}"
);
}
}