use super::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SettingsRow {
Scalar(SettingField),
Dog(usize),
}
#[derive(Debug, Clone)]
pub struct Settings {
pub(super) snapshot: SettingsSnapshot,
fields: FieldSet,
pub(super) view: Viewport,
pub(super) pending: Option<Pending>,
}
#[derive(Debug, Clone)]
pub(super) enum Pending {
Typing {
field: SettingField,
buffer: String,
},
Armed {
edit: SettingEdit,
text: String,
at: Instant,
},
DogArmed {
edit: DogEdit,
text: String,
at: Instant,
},
Sent {
text: String,
ticket: u64,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SettingsPrompt<'a> {
pub text: &'a str,
pub sent: bool,
}
impl Settings {
pub(super) fn new(snapshot: SettingsSnapshot) -> Self {
Self {
snapshot,
fields: settings_field_set(),
view: Viewport::new(),
pending: None,
}
}
#[must_use]
pub fn pending(&self) -> Option<SettingsPrompt<'_>> {
match &self.pending {
Some(Pending::Armed { text, .. } | Pending::DogArmed { text, .. }) => {
Some(SettingsPrompt { text, sent: false })
}
Some(Pending::Sent { text, .. }) => Some(SettingsPrompt { text, sent: true }),
Some(Pending::Typing { .. }) | None => None,
}
}
pub(super) fn resolve(&mut self, ticket: u64) -> bool {
let mine = matches!(
self.pending,
Some(Pending::Sent { ticket: waiting, .. }) if waiting == ticket
);
if mine {
self.pending = None;
}
mine
}
pub(super) fn is_armed(&self) -> bool {
matches!(
self.pending,
Some(Pending::Armed { .. } | Pending::DogArmed { .. })
)
}
#[must_use]
pub fn typing(&self) -> Option<(&SettingField, &str)> {
match &self.pending {
Some(Pending::Typing { field, buffer }) => Some((field, buffer.as_str())),
_ => None,
}
}
pub(super) fn next_candidate(&self, field: SettingField) -> Option<String> {
let armed_here = match &self.pending {
Some(Pending::Armed {
edit:
SettingEdit::Set {
field: armed_field,
value,
},
..
}) if *armed_field == field => Some(value.as_str()),
_ => None,
};
let in_file = (field == SettingField::StyleLevel)
.then_some(self.snapshot.style_level_in_file.as_deref())
.flatten();
let base: String = match (armed_here, in_file) {
(Some(value), _) | (None, Some(value)) => value.to_string(),
(None, None) if field == SettingField::StyleLevel => STYLE_LEVEL_ORDER[0].to_string(),
(None, None) => self.current_value(field)?.to_string(),
};
Some(match field {
SettingField::LogLevel => next_log_level(&base),
SettingField::LogJson | SettingField::AllowControl => next_bool(&base),
SettingField::StyleLevel => next_style_level(&base),
SettingField::Socket | SettingField::MaxCronSleep => return None,
})
}
fn current_value(&self, field: SettingField) -> Option<&str> {
Some(match field {
SettingField::LogLevel => self.snapshot.log_level.value.as_str(),
SettingField::LogJson => self.snapshot.log_json.value.as_str(),
SettingField::AllowControl => self.snapshot.allow_control.value.as_str(),
SettingField::StyleLevel => self.snapshot.style_level.value.as_str(),
SettingField::Socket | SettingField::MaxCronSleep => return None,
})
}
pub(super) fn source_of(&self, field: SettingField) -> StyleSource {
match field {
SettingField::LogLevel => self.snapshot.log_level.source,
SettingField::LogJson => self.snapshot.log_json.source,
SettingField::Socket => self.snapshot.socket.source,
SettingField::MaxCronSleep => self.snapshot.max_cron_sleep.source,
SettingField::AllowControl => self.snapshot.allow_control.source,
SettingField::StyleLevel => self.snapshot.style_level.source,
}
}
pub(super) fn text_seed(&self, field: SettingField) -> &str {
match field {
SettingField::Socket => self.snapshot.socket.value.as_str(),
SettingField::MaxCronSleep => self.snapshot.max_cron_sleep.value.as_str(),
SettingField::LogLevel
| SettingField::LogJson
| SettingField::AllowControl
| SettingField::StyleLevel => {
unreachable!("text_seed only ever reaches the two free-text fields")
}
}
}
#[must_use]
pub fn snapshot(&self) -> &SettingsSnapshot {
&self.snapshot
}
#[must_use]
pub fn rows(&self) -> Vec<SettingsRow> {
let mut rows: Vec<SettingsRow> = self
.fields
.fields()
.iter()
.filter_map(|f| SettingField::from_key(&f.key))
.map(SettingsRow::Scalar)
.collect();
rows.extend((0..self.snapshot.dogs.len()).map(SettingsRow::Dog));
rows
}
#[must_use]
pub fn rows_len(&self) -> usize {
self.fields
.fields()
.iter()
.filter(|f| SettingField::from_key(&f.key).is_some())
.count()
+ self.snapshot.dogs.len()
}
#[must_use]
pub fn fields(&self) -> &FieldSet {
&self.fields
}
#[must_use]
pub fn cursor(&self) -> Option<SettingsRow> {
let rows = self.rows();
rows.get(self.view.cursor().min(rows.len().saturating_sub(1)))
.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);
}
#[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);
}
}
pub(crate) const LOG_LEVEL_ORDER: [LogLevel; 6] = [
LogLevel::Off,
LogLevel::Error,
LogLevel::Warn,
LogLevel::Info,
LogLevel::Debug,
LogLevel::Trace,
];
fn next_log_level(current: &str) -> String {
let index = LogLevel::from_name(current)
.and_then(|level| {
LOG_LEVEL_ORDER
.iter()
.position(|candidate| *candidate == level)
})
.unwrap_or(2);
LOG_LEVEL_ORDER[(index + 1) % LOG_LEVEL_ORDER.len()]
.as_str()
.to_string()
}
fn next_bool(current: &str) -> String {
(current != "true").to_string()
}
pub(crate) const STYLE_LEVEL_ORDER: [StyleLevel; 3] =
[StyleLevel::Full, StyleLevel::Plain, StyleLevel::Bare];
fn next_style_level(current: &str) -> String {
let index = StyleLevel::parse(current)
.and_then(|level| {
STYLE_LEVEL_ORDER
.iter()
.position(|candidate| *candidate == level)
})
.unwrap_or(0);
STYLE_LEVEL_ORDER[(index + 1) % STYLE_LEVEL_ORDER.len()].to_string()
}
pub(super) fn confirm_text(field: SettingField, value: &str, source: StyleSource) -> String {
match field {
SettingField::LogLevel => format!(
"set log_level to {value}? needs shep daemon reload, and will not apply if the shepherd was booted with SHEP_LOG_LEVEL or --log-level"
),
SettingField::LogJson => format!(
"set log_json to {value}? needs shep daemon reload, and will not apply if the shepherd was booted with SHEP_LOG_JSON or --log-json"
),
SettingField::AllowControl => {
let word = if value == "true" { "on" } else { "off" };
format!("turn whistle control tools {word}? needs shep whistle restarted")
}
SettingField::StyleLevel => style_confirm_text(value, source),
SettingField::Socket | SettingField::MaxCronSleep => unreachable!(
"Settings::next_candidate never arms these two -- they are task 8's Pending::Typing"
),
}
}
fn style_confirm_text(value: &str, source: StyleSource) -> String {
match source {
StyleSource::Config | StyleSource::Default => {
format!("set style level to {value}? the next command reads it")
}
StyleSource::Env => format!(
"set style level to {value}? it goes in the file, but $SHEP_STYLE is set and keeps winning until it is unset"
),
StyleSource::Flag => format!(
"set style level to {value}? it goes in the file, but --style was passed to this lookout and keeps winning for as long as it runs"
),
}
}
pub(super) fn confirm_text_for_edit(edit: &SettingEdit) -> String {
match edit {
SettingEdit::Set {
field: SettingField::Socket,
value,
} => format!(
"set socket to {value}? needs the shepherd stopped and started; a reload will not move it, and it will not apply if the shepherd was booted with SHEP_SOCKET or --socket"
),
SettingEdit::Set {
field: SettingField::MaxCronSleep,
value,
} => format!(
"set max_cron_sleep to {value}? needs shep daemon reload, and will not apply if the shepherd was booted with SHEP_MAX_CRON_SLEEP or --max-cron-sleep"
),
SettingEdit::Unset {
field: SettingField::Socket,
} => "unset socket? it goes back to the default under $SHEP_HOME, and needs the shepherd stopped and started"
.to_string(),
SettingEdit::Unset {
field: SettingField::MaxCronSleep,
} => "unset max_cron_sleep? it goes back to the daemon's own default, and needs shep daemon reload"
.to_string(),
SettingEdit::Set { .. } | SettingEdit::Unset { .. } => unreachable!(
"on_settings_text_key only ever builds an edit for socket or max_cron_sleep"
),
}
}
pub(super) fn typed_text_of(edit: &SettingEdit) -> Option<(SettingField, String)> {
match edit {
SettingEdit::Set {
field: field @ (SettingField::Socket | SettingField::MaxCronSleep),
value,
} => Some((*field, value.clone())),
SettingEdit::Unset {
field: field @ (SettingField::Socket | SettingField::MaxCronSleep),
} => Some((*field, String::new())),
_ => None,
}
}
impl App {
#[must_use]
pub fn settings(&self) -> Option<&Settings> {
match &self.body {
Body::Settings(settings) => Some(settings),
Body::FlockTable
| Body::ConfigPane(_)
| Body::Bleats(_)
| Body::Secrets(_)
| Body::Sheep(_) => None,
}
}
pub(super) fn settings_mut(&mut self) -> Option<&mut Settings> {
match &mut self.body {
Body::Settings(settings) => Some(settings),
Body::FlockTable
| Body::ConfigPane(_)
| Body::Bleats(_)
| Body::Secrets(_)
| Body::Sheep(_) => None,
}
}
#[must_use]
pub fn style(&self) -> (StyleLevel, StyleSource) {
self.style
}
pub(crate) fn set_style(&mut self, style: (StyleLevel, StyleSource)) {
self.style = style;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lookout::view::fixtures;
#[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 row = &app.settings().unwrap().snapshot().style_level;
assert_eq!(
row.source,
StyleSource::Flag,
"the flag beats the file rather than being dropped by it"
);
assert_eq!(row.value, "plain");
}
}