use super::*;
use crate::commands::settings::{STYLE_LEVEL_KEY, Shape, dotted};
use crate::lookout::field::{Child, Field, FieldKind, shep_toml_shape};
use std::collections::{BTreeMap, BTreeSet};
mod dogs;
mod edit;
mod pending;
mod rows;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SettingsRow {
Table(usize),
Field(usize),
Entry {
map: usize,
index: usize,
},
AddEntry(usize),
Dog(usize),
DogKey {
dog: usize,
key: DogKey,
},
DogGrant {
dog: usize,
index: usize,
},
DogEnv(usize),
DogEnvKey {
dog: usize,
index: usize,
},
DogEnvNew(usize),
DogField {
dog: usize,
row: usize,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DogKey {
BootFirst,
Grants,
Source,
Channel,
}
impl DogKey {
const ALL: [Self; 4] = [Self::BootFirst, Self::Grants, Self::Source, Self::Channel];
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::BootFirst => "boot first",
Self::Grants => "grants",
Self::Source => "source",
Self::Channel => "channel",
}
}
#[must_use]
pub const fn read_only_reason(self) -> Option<&'static str> {
match self {
Self::BootFirst | Self::Grants => None,
Self::Source => Some(
"the path is `shep adopt`'s to change: it vets the binary before the \
shepherd will run it",
),
Self::Channel => Some(
"the dog asked for the channel itself when `shep adopt` ran it; this is what \
it answered, not a setting",
),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DogTune {
BootFirst {
name: String,
first: bool,
},
Grant {
name: String,
permission: String,
grant: bool,
},
}
impl DogTune {
#[must_use]
pub fn dog(&self) -> &str {
match self {
Self::BootFirst { name, .. } | Self::Grant { name, .. } => name,
}
}
#[must_use]
pub fn text(&self) -> String {
match self {
Self::BootFirst { name, first: true } => {
format!("boot {name} first? the next shepherd boot starts it ahead of the flock")
}
Self::BootFirst { name, first: false } => {
format!("stop booting {name} first? it starts with the rest of the flock")
}
Self::Grant {
name,
permission,
grant: true,
} => format!(
"grant {name} `{permission}`? it may ask the shepherd for it from its next start"
),
Self::Grant {
name,
permission,
grant: false,
} => format!("revoke `{permission}` from {name}? it stops being able to ask for it"),
}
}
}
#[derive(Debug, Clone)]
pub enum DogTree {
Probing,
Reading {
schema: serde_json::Value,
},
Refused {
why: String,
},
Open {
pane: Box<ConfigPane>,
schema: serde_json::Value,
},
Shut {
pane: Box<ConfigPane>,
schema: serde_json::Value,
},
}
impl DogTree {
#[must_use]
pub fn schema(&self) -> Option<&serde_json::Value> {
match self {
Self::Reading { schema } | Self::Open { schema, .. } | Self::Shut { schema, .. } => {
Some(schema)
}
Self::Probing | Self::Refused { .. } => None,
}
}
#[must_use]
pub fn pane(&self) -> Option<&ConfigPane> {
match self {
Self::Open { pane, .. } | Self::Shut { pane, .. } => Some(pane),
Self::Probing | Self::Reading { .. } | Self::Refused { .. } => None,
}
}
#[must_use]
pub fn is_open(&self) -> bool {
matches!(self, Self::Open { .. })
}
#[must_use]
pub fn is_reading(&self) -> bool {
matches!(self, Self::Probing | Self::Reading { .. })
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum DogFold {
Nothing,
Probe {
name: String,
adopted_path: Option<PathBuf>,
},
Opened,
Closed,
}
#[derive(Debug, Clone)]
pub struct Settings {
pub(super) snapshot: SettingsSnapshot,
pub(super) folded: BTreeSet<String>,
pub(super) view: Viewport,
pub(super) pending: Option<Pending>,
pub(super) dog_trees: BTreeMap<String, DogTree>,
}
#[derive(Debug, Clone)]
pub(super) enum Pending {
Typing {
target: Target,
buffer: TextBox,
},
Armed {
edit: SettingEdit,
text: String,
at: Instant,
},
DogArmed {
edit: DogEdit,
text: String,
at: Instant,
},
DogTuneArmed {
tune: DogTune,
text: String,
at: Instant,
},
DogSectionArmed {
name: String,
toml: DogSectionToml,
text: String,
at: Instant,
},
TypingEnv {
dog: String,
editor: KeyValues,
},
DogEnvArmed {
name: String,
edits: Vec<(String, Option<EnvValue>)>,
text: String,
at: Instant,
},
Sent {
text: String,
ticket: u64,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum Target {
Value {
path: Vec<String>,
shape: Shape,
},
NewEntry {
path: Vec<String>,
},
DogValue {
name: String,
key: String,
row: usize,
secret: bool,
},
}
impl Target {
pub(super) fn label(&self) -> String {
match self {
Self::Value { path, .. } => dotted(path),
Self::NewEntry { path } => format!("new {} entry", dotted(path)),
Self::DogValue { name, key, .. } => format!("{name} {key}"),
}
}
pub(super) fn edit(&self, settings: &Settings, text: &str) -> Result<TargetEdit, String> {
match self {
Self::Value { path, .. } if text.is_empty() => {
Ok(TargetEdit::Setting(SettingEdit::Unset {
path: path.clone(),
}))
}
Self::Value { path, shape } => Ok(TargetEdit::Setting(SettingEdit::Set {
path: path.clone(),
shape: *shape,
value: text.to_owned(),
})),
Self::NewEntry { path } => {
let (name, value) = text.split_once('=').ok_or("type name=value".to_owned())?;
let (name, value) = (name.trim(), value.trim());
if name.is_empty() || value.is_empty() {
return Err("type name=value".to_owned());
}
let mut path = path.clone();
path.push(name.to_owned());
Ok(TargetEdit::Setting(SettingEdit::Set {
path,
shape: Shape::Text,
value: value.to_owned(),
}))
}
Self::DogValue { name, key, row, .. } => {
match settings.dog_section_edit(name, *row, text) {
Ok(Some(toml)) => Ok(TargetEdit::DogSection {
name: name.clone(),
toml,
}),
Ok(None) => Err(format!(
"{name} already says {text} for {key}, so there is nothing to write"
)),
Err(why) => Err(why),
}
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum TargetEdit {
Setting(SettingEdit),
DogSection {
name: String,
toml: DogSectionToml,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SettingsPrompt<'a> {
pub text: &'a str,
pub sent: bool,
}
fn is_typed(kind: &FieldKind) -> bool {
matches!(
kind,
FieldKind::Integer
| FieldKind::Number
| FieldKind::Text
| FieldKind::Suggested(_)
| FieldKind::List(_)
)
}
pub(super) fn reopened_editor(edit: &SettingEdit) -> Option<(Target, TextBox)> {
let shape = shep_toml_shape();
let field = shape.fields.by_key(&edit.key());
match (edit, field) {
(
SettingEdit::Set {
path,
shape: kind,
value,
},
Some(field),
) if is_typed(&field.kind) => Some((
Target::Value {
path: path.clone(),
shape: *kind,
},
TextBox::from(value.as_str()),
)),
(
SettingEdit::Set {
path,
shape: kind,
value,
},
None,
) if is_entry(path) => Some((
Target::Value {
path: path.clone(),
shape: *kind,
},
TextBox::from(value.as_str()),
)),
(SettingEdit::Unset { path }, Some(field)) if is_typed(&field.kind) => Some((
Target::Value {
path: path.clone(),
shape: Shape::of(&field.kind)?,
},
TextBox::new(),
)),
_ => None,
}
}
fn is_entry(path: &[String]) -> bool {
let [map, _] = path else {
return false;
};
shep_toml_shape()
.fields
.by_key(map)
.is_some_and(|field| field.kind == FieldKind::Map)
}
impl App {
#[must_use]
pub fn settings(&self) -> Option<&Settings> {
self.body.settings()
}
pub(super) fn settings_mut(&mut self) -> Option<&mut Settings> {
self.body.settings_mut()
}
#[must_use]
pub fn style(&self) -> (StyleLevel, StyleSource) {
self.style
}
pub(crate) fn set_style(&mut self, style: (StyleLevel, StyleSource)) {
self.style = style;
}
pub(crate) fn set_remote_host(&mut self, dest: &str) {
self.remote_host = Some(dest.to_owned());
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lookout::field::PER_DOG_KEYS;
use crate::lookout::view::fixtures;
fn schema_leaves(node: &serde_json::Value, defs: &serde_json::Value, at: &str) -> Vec<String> {
let mut leaves = Vec::new();
let properties = node["properties"].as_object().into_iter().flatten();
for (name, property) in properties {
let key = if at.is_empty() {
name.clone()
} else {
format!("{at}.{name}")
};
let target = property["$ref"]
.as_str()
.and_then(|reference| reference.strip_prefix("#/$defs/"))
.map_or(property, |def| &defs[def]);
if target["properties"].is_object() {
leaves.extend(schema_leaves(target, defs, &key));
} else {
leaves.push(key);
}
}
leaves
}
#[test]
fn the_tree_has_a_row_for_every_leaf_of_the_schema_but_the_per_dog_keys() {
let schema = shep_core::config::daemon_config_schema_json().to_value();
let mut expected = schema_leaves(&schema, &schema["$defs"], "");
expected.retain(|key| !PER_DOG_KEYS.contains(&key.as_str()));
expected.sort();
let app = fixtures::app_in_settings();
let settings = app.settings().unwrap();
let fields = crate::lookout::field::shep_toml_shape().fields.fields();
let mut drawn: Vec<String> = settings
.rows()
.into_iter()
.filter_map(|row| match row {
SettingsRow::Field(index) => Some(fields[index].key.clone()),
_ => None,
})
.collect();
drawn.sort();
assert!(
expected.len() > 10,
"the walk found the schema: {expected:?}"
);
assert_eq!(drawn, expected);
}
#[test]
fn set_style_round_trips_exactly() {
let mut app = fixtures::full_app();
assert_eq!(
app.style(),
(StyleLevel::Full, StyleSource::Default),
"the default before anyone calls set_style"
);
app.set_style((StyleLevel::Bare, StyleSource::Flag));
assert_eq!(app.style(), (StyleLevel::Bare, StyleSource::Flag));
}
#[test]
fn the_style_set_on_the_app_reaches_the_settings_row_undropped() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shep.toml");
std::fs::write(&path, "[style]\nlevel = \"bare\"\n").unwrap();
let socket_default = dir.path().join("run").join("shep.sock");
let mut app = fixtures::full_app();
app.set_style((StyleLevel::Plain, StyleSource::Flag));
let result = crate::commands::settings::load_settings(&path, &socket_default, app.style())
.map_err(|err| err.to_string());
let _ = app.update(Msg::Settings { result });
let field = crate::lookout::field::shep_toml_shape()
.fields
.by_key("style.level")
.unwrap();
let row = app.settings().unwrap().snapshot().view(field);
assert_eq!(
row.source,
StyleSource::Flag,
"the flag beats the file rather than being dropped by it"
);
assert_eq!(row.value, "plain");
}
}