use core::fmt;
use std::collections::BTreeMap;
use std::time::{Duration, Instant};
use shep_client::RequestError;
use shep_core::config::LogLevel;
use shep_core::protocol::{
BusEvent, DogSource, Lamb, ProcessEventKind, ProcessInfo, Request, Response, SelectorSpec,
};
use shep_core::status::ProcStatus;
use super::theme::Palette;
use crate::commands::settings::{SettingEdit, SettingField, SettingsSnapshot};
use crate::style::{StyleLevel, StyleSource};
use crate::vocabulary::Reported;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Control {
ReadOnly,
Allowed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputMode {
Normal,
Text,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeyPress {
Quit,
Escape,
SelectUp,
SelectDown,
SelectFirst,
SelectLast,
Refresh,
Action(ActionVerb),
Confirm,
FilterStart,
TextChar(char),
TextBackspace,
TextApply,
TextAbandon,
Settings,
Cycle,
}
#[derive(Debug, Clone)]
pub enum Msg {
Snapshot {
rows: Vec<ProcessInfo>,
at: Instant,
},
Event(BusEvent),
BusLagged {
count: u64,
},
Retrying {
attempt: u32,
},
Relinked,
Frozen {
at_local: String,
},
Key(KeyPress),
Tick {
now: Instant,
},
Resize,
Host {
sample: Option<super::source::HostSample>,
},
Bleats {
tail: super::tail::Tail,
},
Replied {
sent: Sent,
result: Result<Response, RequestError>,
},
Unsent {
sent: Sent,
},
Settings {
result: Result<SettingsSnapshot, String>,
},
SettingWritten {
edit: SettingEdit,
result: Result<(), String>,
},
DogWritten {
edit: DogEdit,
result: Result<DogSource, String>,
},
}
mod authority {
use super::App;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WriteAuthority(());
impl WriteAuthority {
#[must_use]
pub fn granted(app: &App) -> Option<Self> {
match app.control {
super::Control::ReadOnly => None,
super::Control::Allowed => Some(Self(())),
}
}
}
}
pub use authority::WriteAuthority;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Effect {
None,
PollNow,
RefreshFeed,
RefreshSelected,
Send(Sent),
Quit,
LoadSettings,
WriteSetting(SettingEdit, WriteAuthority),
WriteDog(DogEdit, WriteAuthority),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Link {
Live,
Retrying {
attempt: u32,
},
Lost {
at_local: String,
},
}
#[derive(Debug, Clone)]
pub struct Row {
pub info: ProcessInfo,
pub anchor: Instant,
}
impl Row {
#[must_use]
pub(crate) fn reported(&self) -> Reported {
if self.info.dog.is_none() {
return Reported::Live(self.info.status);
}
Reported::of(self.info.status, self.info.handshook)
}
}
#[derive(Debug, Clone)]
pub struct GroupTotals {
pub count: usize,
pub restarts: u32,
pub cpu: Option<f32>,
pub memory: Option<u64>,
pub uptime_ms: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Sent {
Lambs {
id: u32,
},
Action {
verb: ActionVerb,
target: RowKey,
name: String,
},
Dog {
name: String,
enable: bool,
source: DogSource,
},
}
impl Sent {
#[must_use]
pub fn request(&self) -> Request {
match self {
Self::Lambs { id } => Request::Describe {
selector: SelectorSpec::Id(*id),
},
Self::Action { verb, target, .. } => {
let selector = match target {
RowKey::Sheep(id) => SelectorSpec::Id(*id),
RowKey::Group(name) => SelectorSpec::Name(name.clone()),
};
match verb {
ActionVerb::Stop => Request::Stop { selector },
ActionVerb::Restart => Request::Restart { selector },
ActionVerb::Reload => Request::Reload { selector },
}
}
Self::Dog {
name,
enable: true,
source,
} => Request::EnableDog {
name: name.clone(),
source: source.clone(),
},
Self::Dog {
name,
enable: false,
..
} => Request::DisableDog { name: name.clone() },
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DogEdit {
pub name: String,
pub enable: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum RowKey {
Group(String),
Sheep(u32),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LambWalk {
Walked(Vec<Lamb>),
NotWalked,
Failed,
}
#[derive(Debug, Clone)]
pub struct LambReading {
id: u32,
at: Instant,
walk: LambWalk,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Notice {
text: String,
grave: bool,
}
impl Notice {
#[must_use]
pub fn is_grave(&self) -> bool {
self.grave
}
}
impl fmt::Display for Notice {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.text)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SettingsRow {
Scalar(SettingField),
Dog(usize),
}
#[derive(Debug, Clone)]
pub struct Settings {
snapshot: SettingsSnapshot,
cursor: usize,
pending: Option<Pending>,
}
#[derive(Debug, Clone)]
enum Pending {
Typing {
field: SettingField,
buffer: String,
},
Armed {
edit: SettingEdit,
text: String,
at: Instant,
},
DogArmed {
edit: DogEdit,
text: String,
at: Instant,
},
Sent {
text: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SettingsPrompt<'a> {
pub text: &'a str,
pub sent: bool,
}
impl Settings {
fn new(snapshot: SettingsSnapshot) -> Self {
Self {
snapshot,
cursor: 0,
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,
}
}
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,
}
}
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,
})
}
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,
}
}
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::Scalar(SettingField::LogLevel),
SettingsRow::Scalar(SettingField::LogJson),
SettingsRow::Scalar(SettingField::Socket),
SettingsRow::Scalar(SettingField::MaxCronSleep),
SettingsRow::Scalar(SettingField::AllowControl),
SettingsRow::Scalar(SettingField::StyleLevel),
];
rows.extend((0..self.snapshot.dogs.len()).map(SettingsRow::Dog));
rows
}
#[must_use]
pub fn cursor(&self) -> Option<SettingsRow> {
let rows = self.rows();
rows.get(self.cursor.min(rows.len().saturating_sub(1)))
.copied()
}
fn move_by(&mut self, delta: isize) {
let len = self.rows().len();
if len == 0 {
self.cursor = 0;
return;
}
let next = self.cursor as isize + delta;
self.cursor = next.clamp(0, len as isize - 1) as usize;
}
fn move_to_first(&mut self) {
self.cursor = 0;
}
fn move_to_last(&mut self) {
self.cursor = self.rows().len().saturating_sub(1);
}
}
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()
}
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()
}
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"
),
}
}
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"
),
}
}
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,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActionVerb {
Stop,
Restart,
Reload,
}
impl ActionVerb {
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Stop => "stop",
Self::Restart => "restart",
Self::Reload => "reload",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Stage {
Armed,
Sent,
}
#[derive(Debug, Clone)]
struct Action {
verb: ActionVerb,
target: RowKey,
name: String,
count: usize,
at: Instant,
stage: Stage,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ActionState<'a> {
pub verb: ActionVerb,
pub target: &'a RowKey,
pub name: &'a str,
pub count: usize,
pub sent: bool,
}
pub const CONFIRM_EXPIRY: Duration = Duration::from_secs(10);
const LINK_GONE: &str = "the shepherd is gone — nothing left to ask";
const READ_ONLY_REFUSAL: &str = "read-only: actions need --allow-control";
#[derive(Debug)]
pub struct App {
flock: BTreeMap<u32, Row>,
selected: Option<RowKey>,
filter: String,
mode: InputMode,
link: Link,
notice: Option<Notice>,
palette: Palette,
control: Control,
home: String,
now: Instant,
host: Option<super::source::HostSample>,
host_unsupported: bool,
feed: super::tail::Tail,
lambs: Option<LambReading>,
action: Option<Action>,
settings: Option<Settings>,
style: (StyleLevel, StyleSource),
}
impl App {
#[must_use]
pub fn new(palette: Palette, control: Control, home: String, now: Instant) -> Self {
Self {
flock: BTreeMap::new(),
selected: None,
filter: String::new(),
mode: InputMode::Normal,
link: Link::Live,
notice: None,
palette,
control,
home,
now,
host: None,
host_unsupported: false,
feed: super::tail::Tail::default(),
lambs: None,
action: None,
settings: None,
style: (StyleLevel::Full, StyleSource::Default),
}
}
pub fn update(&mut self, msg: Msg) -> Effect {
match msg {
Msg::Snapshot { rows, at } => {
if matches!(self.link, Link::Lost { .. }) {
return Effect::None;
}
let previous = self.selected_index();
self.flock = rows
.into_iter()
.map(|info| (info.id, Row { info, anchor: at }))
.collect();
self.reseat(previous);
self.forget_missing_target();
Effect::RefreshFeed
}
Msg::Event(event) => self.on_event(event),
Msg::BusLagged { count } => {
self.notice = Some(Notice {
text: format!(
"lookout fell behind and lost {count} events; re-reading the flock"
),
grave: false,
});
Effect::PollNow
}
Msg::Retrying { attempt } => {
if !matches!(self.link, Link::Lost { .. }) {
self.link = Link::Retrying { attempt };
self.disarm_on_link_change();
}
Effect::None
}
Msg::Relinked => {
if !matches!(self.link, Link::Lost { .. }) {
self.link = Link::Live;
}
Effect::None
}
Msg::Frozen { at_local } => {
self.link = Link::Lost { at_local };
self.disarm_on_link_change();
Effect::None
}
Msg::Tick { now } => {
if !matches!(self.link, Link::Lost { .. }) {
self.now = now;
let expired = self.action.as_ref().is_some_and(|action| {
action.stage == Stage::Armed
&& now.saturating_duration_since(action.at) >= CONFIRM_EXPIRY
});
if expired {
self.action = None;
}
}
if let Some(settings) = self.settings.as_mut() {
let expired = matches!(
settings.pending,
Some(Pending::Armed { at, .. } | Pending::DogArmed { at, .. })
if now.saturating_duration_since(at) >= CONFIRM_EXPIRY
);
if expired {
settings.pending = None;
}
}
Effect::None
}
Msg::Resize => Effect::None,
Msg::Key(key) => self.on_key(key),
Msg::Host { sample } => {
if matches!(self.link, Link::Lost { .. }) {
return Effect::None;
}
self.host_unsupported = sample.is_none();
self.host = sample;
Effect::None
}
Msg::Bleats { tail } => {
if matches!(self.link, Link::Lost { .. }) {
return Effect::None;
}
self.feed = tail;
Effect::None
}
Msg::Replied { sent, result } => match sent {
Sent::Lambs { id } => self.on_lambs(id, result),
Sent::Action { verb, target, name } => {
self.on_action_reply(verb, target, &name, result)
}
Sent::Dog { name, enable, .. } => self.on_dog_reply(name, enable, result),
},
Msg::Unsent { sent } => match sent {
Sent::Action { verb, target, name } => {
self.action = None;
self.notice = Some(Notice {
text: format!("{}: it was not sent", target_prefix(verb, &target, &name)),
grave: true,
});
Effect::None
}
Sent::Lambs { .. } => Effect::None,
Sent::Dog { name, enable, .. } => {
if let Some(settings) = self.settings.as_mut() {
settings.pending = None;
}
let verb = if enable { "enable" } else { "disable" };
self.notice = Some(Notice {
text: format!("{verb} {name}: it was not sent"),
grave: true,
});
Effect::None
}
},
Msg::Settings { result } => {
let opening = self.settings.is_none();
match result {
Ok(snapshot) => {
self.action = None;
self.mode = InputMode::Normal;
let cursor = self.settings.as_ref().map_or(0, |settings| settings.cursor);
let mut settings = Settings::new(snapshot);
if !opening {
settings.cursor = cursor;
}
self.settings = Some(settings);
}
Err(message) => {
self.notice = Some(Notice {
text: message,
grave: true,
});
}
}
Effect::None
}
Msg::SettingWritten { edit, result } => match result {
Ok(()) => {
if let Some(settings) = self.settings.as_mut() {
settings.pending = None;
}
Effect::LoadSettings
}
Err(message) => {
if let Some((field, buffer)) = typed_text_of(&edit) {
if let Some(settings) = self.settings.as_mut() {
settings.pending = Some(Pending::Typing { field, buffer });
}
self.mode = InputMode::Text;
} else if let Some(settings) = self.settings.as_mut() {
settings.pending = None;
}
self.notice = Some(Notice {
text: message,
grave: true,
});
Effect::None
}
},
Msg::DogWritten { edit, result } => match result {
Ok(source) => Effect::Send(Sent::Dog {
name: edit.name,
enable: edit.enable,
source,
}),
Err(message) => {
if let Some(settings) = self.settings.as_mut() {
settings.pending = None;
}
self.notice = Some(Notice {
text: message,
grave: true,
});
Effect::None
}
},
}
}
fn on_lambs(&mut self, id: u32, result: Result<Response, RequestError>) -> Effect {
if matches!(self.link, Link::Lost { .. }) {
return Effect::None;
}
let walk = match result {
Ok(Response::Described(rows)) => rows
.into_iter()
.find(|info| info.id == id)
.map_or(LambWalk::Failed, |info| {
info.lambs.map_or(LambWalk::NotWalked, LambWalk::Walked)
}),
_ => LambWalk::Failed,
};
self.lambs = Some(LambReading {
id,
at: self.now,
walk,
});
Effect::None
}
fn on_action_reply(
&mut self,
verb: ActionVerb,
target: RowKey,
name: &str,
result: Result<Response, RequestError>,
) -> Effect {
self.action = None;
let prefix = target_prefix(verb, &target, name);
let rows = match result {
Ok(Response::Stopped(rows)) if verb == ActionVerb::Stop => rows,
Ok(Response::Restarted(rows)) if verb == ActionVerb::Restart => rows,
Ok(Response::Reloading(rows)) if verb == ActionVerb::Reload => rows,
Ok(_unrecognised) => {
self.notice = Some(Notice {
text: format!(
"{prefix}: the shepherd answered something this lookout does not understand"
),
grave: true,
});
return Effect::None;
}
Err(RequestError::Rpc(err)) => {
self.notice = Some(Notice {
text: format!("{prefix}: {}", err.message),
grave: true,
});
return Effect::None;
}
Err(other) => {
self.notice = Some(Notice {
text: format!("{prefix}: {other}"),
grave: true,
});
return Effect::None;
}
};
let anchor = self.now;
let was_empty = self.flock.is_empty();
for info in rows {
self.flock.insert(info.id, Row { info, anchor });
}
self.notice = Some(Notice {
text: format!("{prefix}: {}", outcome(verb)),
grave: false,
});
if was_empty && self.reseat(None) {
return Effect::RefreshSelected;
}
Effect::None
}
fn on_dog_reply(
&mut self,
name: String,
enable: bool,
result: Result<Response, RequestError>,
) -> Effect {
if let Some(settings) = self.settings.as_mut() {
settings.pending = None;
}
let verb = if enable { "enable" } else { "disable" };
let prefix = format!("{verb} {name}");
match result {
Ok(Response::DogStarted(_)) if enable => {
self.notice = Some(Notice {
text: format!("{prefix}: the shepherd started it"),
grave: false,
});
}
Ok(Response::Deleted(_)) if !enable => {
self.notice = Some(Notice {
text: format!("{prefix}: the shepherd stopped and deregistered it"),
grave: false,
});
}
Ok(_unrecognised) => {
self.notice = Some(Notice {
text: format!(
"{prefix}: the shepherd answered something this lookout does not understand"
),
grave: true,
});
}
Err(RequestError::Rpc(err)) => {
self.notice = Some(Notice {
text: format!("{prefix}: {}", err.message),
grave: true,
});
}
Err(other) => {
self.notice = Some(Notice {
text: format!("{prefix}: {other}"),
grave: true,
});
}
}
Effect::LoadSettings
}
fn on_event(&mut self, event: BusEvent) -> Effect {
match event {
BusEvent::Process { event, info, .. } => {
if matches!(event, ProcessEventKind::Delete) {
let previous = self.selected_index();
self.flock.remove(&info.id);
self.forget_missing_target();
return if self.reseat(previous) {
Effect::RefreshSelected
} else {
Effect::None
};
}
let previous = self.selected_index();
let anchor = self.now;
self.flock.insert(info.id, Row { info, anchor });
if self.reseat(previous) {
return Effect::RefreshSelected;
}
Effect::None
}
BusEvent::Dropped { count } => {
self.notice = Some(Notice {
text: format!("the shepherd dropped {count} events; re-reading the flock"),
grave: false,
});
Effect::PollNow
}
BusEvent::DaemonShutdown => {
self.notice = Some(Notice {
text: "the shepherd is shutting down".to_string(),
grave: true,
});
Effect::None
}
_ => Effect::None,
}
}
fn on_key(&mut self, key: KeyPress) -> Effect {
if self.mode == InputMode::Text {
return self.on_text_key(key);
}
if self.settings.is_some() {
return self.on_settings_key(key);
}
if self
.action
.as_ref()
.is_some_and(|action| action.stage == Stage::Armed)
{
if key == KeyPress::Confirm {
return self.confirm();
}
if key == KeyPress::Quit {
return Effect::Quit;
}
self.action = None;
return Effect::None;
}
self.notice = None;
match key {
KeyPress::Quit => Effect::Quit,
KeyPress::Escape => {
if self.filter.is_empty() {
Effect::Quit
} else {
self.set_filter(String::new())
}
}
KeyPress::Refresh => {
if matches!(self.link, Link::Lost { .. }) {
self.notice = Some(Notice {
text: LINK_GONE.to_string(),
grave: true,
});
return Effect::None;
}
Effect::PollNow
}
KeyPress::SelectUp => self.select_by(-1),
KeyPress::SelectDown => self.select_by(1),
KeyPress::SelectFirst => self.select_at(0),
KeyPress::SelectLast => self.select_at(self.visible_len().saturating_sub(1)),
KeyPress::Action(verb) => self.arm(verb),
KeyPress::Confirm => Effect::None,
KeyPress::FilterStart => {
self.mode = InputMode::Text;
Effect::None
}
KeyPress::TextChar(_)
| KeyPress::TextBackspace
| KeyPress::TextApply
| KeyPress::TextAbandon => Effect::None,
KeyPress::Settings => Effect::LoadSettings,
KeyPress::Cycle => Effect::None,
}
}
fn on_settings_key(&mut self, key: KeyPress) -> Effect {
self.notice = None;
match key {
KeyPress::Quit => return Effect::Quit,
KeyPress::Settings | KeyPress::Escape => {
let armed = self.settings.as_ref().is_some_and(Settings::is_armed);
if armed {
if let Some(settings) = self.settings.as_mut() {
settings.pending = None;
}
} else {
self.settings = None;
}
}
KeyPress::SelectUp
| KeyPress::SelectDown
| KeyPress::SelectFirst
| KeyPress::SelectLast => {
if let Some(settings) = self.settings.as_mut() {
if settings.is_armed() {
settings.pending = None;
} else {
match key {
KeyPress::SelectUp => settings.move_by(-1),
KeyPress::SelectDown => settings.move_by(1),
KeyPress::SelectFirst => settings.move_to_first(),
KeyPress::SelectLast => settings.move_to_last(),
_ => unreachable!(),
}
}
}
}
KeyPress::Cycle => return self.cycle_setting(),
KeyPress::Confirm => return self.confirm_setting(),
KeyPress::Refresh => {
if let Some(settings) = self.settings.as_mut()
&& settings.is_armed()
{
settings.pending = None;
return Effect::None;
}
return Effect::LoadSettings;
}
KeyPress::Action(_)
| KeyPress::FilterStart
| KeyPress::TextChar(_)
| KeyPress::TextBackspace
| KeyPress::TextApply
| KeyPress::TextAbandon => {}
}
Effect::None
}
fn authorize_write(&mut self) -> Option<WriteAuthority> {
let authority = WriteAuthority::granted(self);
if authority.is_none() {
self.notice = Some(Notice {
text: READ_ONLY_REFUSAL.to_string(),
grave: true,
});
}
authority
}
fn cycle_setting(&mut self) -> Effect {
if self.authorize_write().is_none() {
return Effect::None;
}
let Some(cursor) = self.settings.as_ref().and_then(Settings::cursor) else {
return Effect::None;
};
match cursor {
SettingsRow::Scalar(field) => self.cycle_scalar(field),
SettingsRow::Dog(index) => self.cycle_dog(index),
}
}
fn cycle_scalar(&mut self, field: SettingField) -> Effect {
let Some(settings) = self.settings.as_mut() else {
return Effect::None;
};
let Some(value) = settings.next_candidate(field) else {
return Effect::None;
};
let source = settings.source_of(field);
let text = confirm_text(field, &value, source);
settings.pending = Some(Pending::Armed {
edit: SettingEdit::Set { field, value },
text,
at: self.now,
});
Effect::None
}
fn cycle_dog(&mut self, index: usize) -> Effect {
if matches!(self.link, Link::Lost { .. }) {
self.notice = Some(Notice {
text: LINK_GONE.to_string(),
grave: true,
});
return Effect::None;
}
let Some(settings) = self.settings.as_mut() else {
return Effect::None;
};
let Some(dog) = settings.snapshot.dogs.get(index) else {
return Effect::None;
};
let name = dog.name.clone();
let enable = !dog.enabled;
let text = if enable {
format!("enable {name}? it starts now, no reload")
} else {
format!("disable {name}? it stops now and is deregistered")
};
settings.pending = Some(Pending::DogArmed {
edit: DogEdit { name, enable },
text,
at: self.now,
});
Effect::None
}
fn confirm_setting(&mut self) -> Effect {
let Some(settings) = self.settings.as_ref() else {
return Effect::None;
};
let opens_editor = settings.pending.is_none()
&& matches!(
settings.cursor(),
Some(SettingsRow::Scalar(
SettingField::Socket | SettingField::MaxCronSleep
))
);
if !opens_editor && !settings.is_armed() {
return Effect::None;
}
let Some(authority) = self.authorize_write() else {
return Effect::None;
};
let Some(settings) = self.settings.as_mut() else {
return Effect::None;
};
if opens_editor {
let Some(SettingsRow::Scalar(field)) = settings.cursor() else {
return Effect::None;
};
let buffer = settings.text_seed(field).to_string();
settings.pending = Some(Pending::Typing { field, buffer });
self.mode = InputMode::Text;
return Effect::None;
}
match settings.pending.take() {
Some(Pending::Armed { edit, text, .. }) => {
settings.pending = Some(Pending::Sent { text });
Effect::WriteSetting(edit, authority)
}
Some(Pending::DogArmed { edit, text, .. }) => {
settings.pending = Some(Pending::Sent { text });
Effect::WriteDog(edit, authority)
}
other => {
settings.pending = other;
Effect::None
}
}
}
fn arm(&mut self, verb: ActionVerb) -> Effect {
let refusal = if self.control == Control::ReadOnly {
Some(READ_ONLY_REFUSAL.to_string())
} else if let Link::Retrying { attempt } = self.link {
Some(format!(
"the shepherd stopped answering — reconnecting (attempt {attempt})"
))
} else if matches!(self.link, Link::Lost { .. }) {
Some(LINK_GONE.to_string())
} else if self.selected.is_none() {
Some("no sheep is selected".to_string())
} else if self.action.is_some() {
Some("one action is already in flight".to_string())
} else {
None
};
if let Some(text) = refusal {
self.notice = Some(Notice { text, grave: true });
return Effect::None;
}
let key = self.selected.clone().expect("checked just above");
let (target, name, count) = match &key {
RowKey::Sheep(id) => {
let row = self
.flock
.get(id)
.expect("a selected sheep is in the flock");
(RowKey::Sheep(*id), row.info.name.clone(), 1)
}
RowKey::Group(group_name) => {
let count = self
.flock
.values()
.filter(|row| &row.info.name == group_name)
.count();
(RowKey::Group(group_name.clone()), group_name.clone(), count)
}
};
self.action = Some(Action {
verb,
target,
name,
count,
at: self.now,
stage: Stage::Armed,
});
Effect::None
}
fn confirm(&mut self) -> Effect {
let Some(action) = self.action.take() else {
return Effect::None;
};
if !self.target_present(&action.target) {
self.notice = Some(Notice {
text: format!(
"{}: it is no longer in the flock",
target_prefix(action.verb, &action.target, &action.name)
),
grave: true,
});
return Effect::None;
}
let sent = Sent::Action {
verb: action.verb,
target: action.target.clone(),
name: action.name.clone(),
};
self.action = Some(Action {
stage: Stage::Sent,
..action
});
Effect::Send(sent)
}
fn target_present(&self, target: &RowKey) -> bool {
match target {
RowKey::Sheep(id) => self.flock.contains_key(id),
RowKey::Group(name) => self.flock.values().any(|row| &row.info.name == name),
}
}
fn forget_missing_target(&mut self) {
let gone = self.action.as_ref().is_some_and(|action| {
action.stage == Stage::Armed && !self.target_present(&action.target)
});
if gone {
self.action = None;
}
}
fn disarm_on_link_change(&mut self) {
if self
.action
.as_ref()
.is_some_and(|action| action.stage == Stage::Armed)
{
self.action = None;
}
}
fn on_text_key(&mut self, key: KeyPress) -> Effect {
if self.settings.is_some() {
return self.on_settings_text_key(key);
}
self.on_filter_text_key(key)
}
fn on_filter_text_key(&mut self, key: KeyPress) -> Effect {
match key {
KeyPress::Quit => Effect::Quit,
KeyPress::TextChar(typed) => {
let mut query = self.filter.clone();
query.push(typed);
self.set_filter(query)
}
KeyPress::TextBackspace => {
let mut query = self.filter.clone();
query.pop();
self.set_filter(query)
}
KeyPress::TextApply => {
self.mode = InputMode::Normal;
Effect::None
}
KeyPress::TextAbandon => {
self.mode = InputMode::Normal;
self.set_filter(String::new())
}
_ => Effect::None,
}
}
fn on_settings_text_key(&mut self, key: KeyPress) -> Effect {
let now = self.now;
let Some(settings) = self.settings.as_mut() else {
return Effect::None;
};
match key {
KeyPress::Quit => return Effect::Quit,
KeyPress::TextChar(typed) => {
if let Some(Pending::Typing { buffer, .. }) = settings.pending.as_mut() {
buffer.push(typed);
}
}
KeyPress::TextBackspace => {
if let Some(Pending::Typing { buffer, .. }) = settings.pending.as_mut() {
buffer.pop();
}
}
KeyPress::TextApply => {
if let Some(Pending::Typing { field, buffer }) = settings.pending.take() {
let edit = if buffer.is_empty() {
SettingEdit::Unset { field }
} else {
SettingEdit::Set {
field,
value: buffer,
}
};
let text = confirm_text_for_edit(&edit);
settings.pending = Some(Pending::Armed {
edit,
text,
at: now,
});
}
self.mode = InputMode::Normal;
}
KeyPress::TextAbandon => {
settings.pending = None;
self.mode = InputMode::Normal;
}
_ => {}
}
Effect::None
}
#[must_use]
pub fn visible_rows(&self) -> Vec<RowKey> {
let needle = self.filter.to_lowercase();
let mut visible: Vec<(&str, Option<u32>, u32)> = self
.flock
.iter()
.filter(|(_, row)| needle.is_empty() || row.info.name.to_lowercase().contains(&needle))
.map(|(id, row)| (row.info.name.as_str(), row.info.instance, *id))
.collect();
visible.sort_unstable_by(|a, b| a.0.cmp(b.0).then(a.1.cmp(&b.1)).then(a.2.cmp(&b.2)));
let mut out = Vec::with_capacity(visible.len());
let mut at = 0;
while at < visible.len() {
let name = visible[at].0;
let end = visible[at..]
.iter()
.position(|entry| entry.0 != name)
.map_or(visible.len(), |offset| at + offset);
let group = &visible[at..end];
if self.is_grouped(name) {
out.push(RowKey::Group(name.to_string()));
}
out.extend(group.iter().map(|entry| RowKey::Sheep(entry.2)));
at = end;
}
out
}
fn visible_len(&self) -> usize {
self.visible_rows().len()
}
fn reseat(&mut self, previous_index: Option<usize>) -> bool {
if self.selected_index().is_some() {
return false;
}
let before = self.selected.clone();
let visible = self.visible_rows();
if visible.is_empty() {
self.selected = None;
return before != self.selected;
}
let index = previous_index.unwrap_or(0).min(visible.len() - 1);
self.selected = Some(visible[index].clone());
before != self.selected
}
fn select_by(&mut self, delta: isize) -> Effect {
let Some(index) = self.selected_index() else {
return Effect::None;
};
let next = index.saturating_add_signed(delta);
self.select_at(next)
}
fn select_at(&mut self, index: usize) -> Effect {
let visible = self.visible_rows();
if visible.is_empty() {
return Effect::None;
}
let next = visible[index.min(visible.len() - 1)].clone();
if Some(&next) == self.selected.as_ref() {
return Effect::None;
}
self.selected = Some(next);
if matches!(self.link, Link::Lost { .. }) {
return Effect::None;
}
Effect::RefreshSelected
}
#[must_use]
pub fn rows(&self) -> Vec<&Row> {
let needle = self.filter.to_lowercase();
let mut visible: Vec<&Row> = self
.flock
.values()
.filter(|row| needle.is_empty() || row.info.name.to_lowercase().contains(&needle))
.collect();
visible.sort_unstable_by(|a, b| {
(a.info.name.as_str(), a.info.id).cmp(&(b.info.name.as_str(), b.info.id))
});
visible
}
#[must_use]
pub fn all_rows(&self) -> Vec<&Row> {
self.flock.values().collect()
}
fn set_filter(&mut self, query: String) -> Effect {
if self.filter == query {
return Effect::None;
}
let previous = self.selected_index();
self.filter = query;
if self.reseat(previous) && !matches!(self.link, Link::Lost { .. }) {
return Effect::RefreshSelected;
}
Effect::None
}
#[must_use]
pub fn filter(&self) -> &str {
&self.filter
}
#[must_use]
pub fn mode(&self) -> InputMode {
self.mode
}
#[must_use]
pub fn flock_len(&self) -> usize {
self.flock.len()
}
#[must_use]
pub fn selected(&self) -> Option<RowKey> {
self.selected.clone()
}
#[must_use]
pub fn selected_index(&self) -> Option<usize> {
let key = self.selected.clone()?;
self.visible_rows().iter().position(|row| *row == key)
}
#[must_use]
pub fn selected_row(&self) -> Option<&Row> {
match &self.selected {
Some(RowKey::Sheep(id)) => self.flock.get(id),
_ => None,
}
}
#[must_use]
pub fn row(&self, id: u32) -> Option<&Row> {
self.flock.get(&id)
}
#[must_use]
pub fn group_members(&self, name: &str) -> Vec<&Row> {
let mut members: Vec<&Row> = self
.flock
.values()
.filter(|row| row.info.name == name)
.collect();
members.sort_by_key(|row| row.info.instance.unwrap_or(u32::MAX));
members
}
#[must_use]
pub fn is_grouped(&self, name: &str) -> bool {
let members = self.group_members(name);
members.len() > 1 && members.iter().all(|row| row.info.instance.is_some())
}
#[must_use]
pub fn group_totals(&self, name: &str) -> GroupTotals {
let members = self.group_members(name);
GroupTotals {
count: members.len(),
restarts: members.iter().map(|row| row.info.restarts).sum(),
cpu: members
.iter()
.filter_map(|row| row.info.cpu_percent)
.fold(None, |acc, cpu| Some(acc.unwrap_or(0.0) + cpu)),
memory: members
.iter()
.filter_map(|row| row.info.memory_bytes)
.fold(None, |acc, mem| Some(acc.unwrap_or(0) + mem)),
uptime_ms: members
.iter()
.filter_map(|row| self.uptime_ms(row.info.id))
.min(),
}
}
#[must_use]
pub fn group_status_text(&self, name: &str) -> String {
let members = self.group_members(name);
let Some(first) = members.first().map(|row| row.info.status) else {
return String::new();
};
if members.iter().all(|row| row.info.status == first) {
return first.to_string();
}
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
for row in &members {
*counts.entry(row.info.status.to_string()).or_default() += 1;
}
counts
.into_iter()
.map(|(status, n)| format!("{n} {status}"))
.collect::<Vec<_>>()
.join(", ")
}
#[must_use]
pub fn group_uniform_status(&self, name: &str) -> Option<ProcStatus> {
let members = self.group_members(name);
let first = members.first()?.info.status;
members
.iter()
.all(|row| row.info.status == first)
.then_some(first)
}
#[must_use]
pub fn link(&self) -> &Link {
&self.link
}
#[must_use]
pub fn notice(&self) -> Option<&Notice> {
self.notice.as_ref()
}
#[must_use]
pub fn palette(&self) -> Palette {
self.palette
}
#[must_use]
pub fn control(&self) -> Control {
self.control
}
#[must_use]
pub fn home(&self) -> &str {
&self.home
}
#[must_use]
pub fn host(&self) -> Option<super::source::HostSample> {
self.host
}
#[must_use]
pub fn host_unsupported(&self) -> bool {
self.host_unsupported
}
#[must_use]
pub fn feed(&self) -> &super::tail::Tail {
&self.feed
}
#[must_use]
pub fn uptime_ms(&self, id: u32) -> Option<u64> {
let row = self.flock.get(&id)?;
if !matches!(row.info.status, ProcStatus::Online | ProcStatus::Starting) {
return Some(row.info.uptime_ms);
}
let elapsed = self.now.saturating_duration_since(row.anchor);
Some(row.info.uptime_ms.saturating_add(millis(elapsed)))
}
#[must_use]
pub fn lambs_for(&self, id: u32) -> Option<(&LambWalk, u64)> {
let reading = self.lambs.as_ref().filter(|reading| reading.id == id)?;
Some((
&reading.walk,
millis(self.now.saturating_duration_since(reading.at)),
))
}
#[must_use]
pub fn action(&self) -> Option<ActionState<'_>> {
let action = self.action.as_ref()?;
Some(ActionState {
verb: action.verb,
target: &action.target,
name: &action.name,
count: action.count,
sent: action.stage == Stage::Sent,
})
}
#[must_use]
pub fn settings(&self) -> Option<&Settings> {
self.settings.as_ref()
}
#[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)]
pub(crate) fn set_control_for_tests(&mut self, control: Control) {
self.control = control;
}
#[cfg(test)]
pub(crate) fn set_filter_for_tests(&mut self, query: &str) {
self.filter = query.to_string();
}
#[cfg(test)]
fn select(&mut self, key: RowKey) {
self.selected = Some(key);
}
}
fn target_prefix(verb: ActionVerb, target: &RowKey, name: &str) -> String {
match target {
RowKey::Sheep(id) => format!("{} {name} (id {id})", verb.label()),
RowKey::Group(_) => format!("{} all instances of {name}", verb.label()),
}
}
fn millis(d: Duration) -> u64 {
u64::try_from(d.as_millis()).unwrap_or(u64::MAX)
}
const fn outcome(verb: ActionVerb) -> &'static str {
match verb {
ActionVerb::Stop => "the shepherd stopped it",
ActionVerb::Restart => "the shepherd restarted it",
ActionVerb::Reload => "accepted, the swaps report themselves as they happen",
}
}
#[cfg(test)]
mod tests {
use super::*;
use shep_core::protocol::{ProcessEventKind, RpcError, RpcErrorCode};
use super::super::view::fixtures;
use crate::commands::settings::ScalarView;
fn sheep(id: u32, name: &str, status: ProcStatus) -> ProcessInfo {
ProcessInfo::builder(id, name, status)
.pid(Some(1000 + id))
.uptime_ms(60_000)
.build()
}
fn started() -> (App, Instant) {
let t0 = Instant::now();
let mut app = App::new(
Palette::detect(None, None, None),
Control::ReadOnly,
"/home/ada/.shep".to_string(),
t0,
);
app.update(Msg::Snapshot {
rows: vec![
sheep(1, "web", ProcStatus::Online),
sheep(2, "api", ProcStatus::Errored),
sheep(3, "worker", ProcStatus::Online),
],
at: t0,
});
(app, t0)
}
fn allowed() -> App {
let t0 = Instant::now();
let mut app = App::new(
Palette::detect(None, None, None),
Control::Allowed,
"/home/ada/.shep".to_string(),
t0,
);
app.update(Msg::Snapshot {
rows: vec![
sheep(1, "web", ProcStatus::Online),
sheep(2, "api", ProcStatus::Online),
sheep(3, "worker", ProcStatus::Online),
],
at: t0,
});
app.update(Msg::Tick { now: t0 });
app.update(Msg::Key(KeyPress::SelectDown));
app
}
fn allowed_with_instances() -> App {
let t0 = Instant::now();
let mut app = App::new(
Palette::detect(None, None, None),
Control::Allowed,
"/home/ada/.shep".to_string(),
t0,
);
app.update(Msg::Snapshot {
rows: instanced_rows(),
at: t0,
});
app
}
fn instanced_rows() -> Vec<ProcessInfo> {
(0..3)
.map(|slot| {
ProcessInfo::builder(slot + 1, "web", ProcStatus::Online)
.instance(Some(slot))
.build()
})
.collect()
}
fn status_line_text(app: &App) -> String {
super::super::view::fixtures::rendered(&super::super::view::status::status_line(app, 200))
}
#[test]
fn a_multi_instance_app_shows_a_group_row_above_its_slots() {
let app = allowed_with_instances();
assert_eq!(
app.visible_rows().len(),
4,
"three slots and the group row above them"
);
assert!(matches!(app.visible_rows()[0], RowKey::Group(ref n) if n == "web"));
}
#[test]
fn an_action_on_a_group_row_targets_the_whole_app_by_name() {
let mut app = allowed_with_instances();
app.select(RowKey::Group("web".to_string()));
app.update(Msg::Key(KeyPress::Action(ActionVerb::Stop)));
let Effect::Send(sent) = app.update(Msg::Key(KeyPress::Confirm)) else {
panic!("Enter sends");
};
assert_eq!(
sent.request(),
Request::Stop {
selector: SelectorSpec::Name("web".to_string())
}
);
}
#[test]
fn a_group_confirm_states_how_many_processes_it_reaches() {
let mut app = allowed_with_instances();
app.select(RowKey::Group("web".to_string()));
app.update(Msg::Key(KeyPress::Action(ActionVerb::Stop)));
let prompt = status_line_text(&app);
assert!(prompt.contains('3'), "names the blast radius: {prompt}");
}
#[test]
fn selection_survives_a_poll_on_both_row_kinds() {
let mut app = allowed_with_instances();
app.select(RowKey::Group("web".to_string()));
app.update(Msg::Snapshot {
rows: instanced_rows(),
at: Instant::now(),
});
assert_eq!(app.selected(), Some(RowKey::Group("web".to_string())));
}
#[test]
fn arming_a_group_action_refuses_when_read_only() {
let mut app = allowed_with_instances();
app.set_control_for_tests(Control::ReadOnly);
app.select(RowKey::Group("web".to_string()));
app.update(Msg::Key(KeyPress::Action(ActionVerb::Stop)));
assert!(app.action().is_none());
assert_eq!(
app.notice().map(ToString::to_string).as_deref(),
Some("read-only: actions need --allow-control")
);
}
#[test]
fn arming_a_group_action_refuses_while_the_link_is_not_live() {
for link in [
Msg::Retrying { attempt: 2 },
Msg::Frozen {
at_local: "2026-08-16 09:00:00".to_string(),
},
] {
let mut app = allowed_with_instances();
app.select(RowKey::Group("web".to_string()));
app.update(link);
app.update(Msg::Key(KeyPress::Action(ActionVerb::Stop)));
assert!(app.action().is_none());
assert!(app.notice().is_some_and(Notice::is_grave));
}
}
#[test]
fn arming_a_group_action_refuses_while_one_is_already_in_flight() {
let mut app = allowed_with_instances();
app.select(RowKey::Group("web".to_string()));
app.update(Msg::Key(KeyPress::Action(ActionVerb::Stop)));
app.update(Msg::Key(KeyPress::Confirm));
assert!(app.action().is_some_and(|action| action.sent));
app.update(Msg::Key(KeyPress::Action(ActionVerb::Restart)));
assert_eq!(
app.notice().map(ToString::to_string).as_deref(),
Some("one action is already in flight")
);
let action = app.action().expect("the first one is untouched");
assert_eq!(action.verb, ActionVerb::Stop);
assert!(action.sent);
}
#[test]
fn an_action_key_arms_a_confirm_and_sends_nothing() {
let mut app = allowed();
assert_eq!(
app.update(Msg::Key(KeyPress::Action(ActionVerb::Stop))),
Effect::None
);
let armed = app.action().expect("armed");
assert_eq!(armed.verb, ActionVerb::Stop);
assert_eq!(armed.target, &RowKey::Sheep(1));
assert_eq!(armed.name, "web");
assert!(!armed.sent, "nothing has gone out");
}
#[test]
fn only_enter_confirms_and_every_other_key_cancels() {
for key in [
KeyPress::SelectDown,
KeyPress::SelectUp,
KeyPress::SelectFirst,
KeyPress::Refresh,
KeyPress::Escape,
KeyPress::FilterStart,
KeyPress::Action(ActionVerb::Stop),
KeyPress::Action(ActionVerb::Restart),
] {
let mut app = allowed();
app.update(Msg::Key(KeyPress::Action(ActionVerb::Stop)));
assert!(app.action().is_some(), "armed before {key:?}");
assert_eq!(
app.update(Msg::Key(key)),
Effect::None,
"{key:?} sent something"
);
assert!(app.action().is_none(), "{key:?} did not cancel");
}
let mut app = allowed();
app.update(Msg::Key(KeyPress::Action(ActionVerb::Stop)));
assert!(
matches!(app.update(Msg::Key(KeyPress::Confirm)), Effect::Send(_)),
"and Enter is the one key that sends"
);
}
#[test]
fn a_cancelling_key_is_consumed_and_does_not_also_move_the_selection() {
let mut app = allowed();
app.update(Msg::Key(KeyPress::Action(ActionVerb::Restart)));
let before = app.selected();
let effect = app.update(Msg::Key(KeyPress::SelectDown));
assert!(app.action().is_none(), "the stray j cancelled the confirm");
assert_eq!(app.selected(), before, "and did not also move the cursor");
assert_eq!(effect, Effect::None, "nor ask for a feed read or a walk");
}
#[test]
fn the_confirm_is_pinned_to_the_id_it_was_armed_on() {
let mut app = allowed();
app.set_filter("api".to_string());
app.update(Msg::Key(KeyPress::Action(ActionVerb::Stop)));
app.update(Msg::Snapshot {
rows: vec![
sheep(2, "gateway", ProcStatus::Online),
sheep(9, "api-new", ProcStatus::Online),
],
at: Instant::now(),
});
assert_eq!(
app.selected(),
Some(RowKey::Sheep(9)),
"sanity: the cursor followed the filter off the armed id"
);
let Effect::Send(sent) = app.update(Msg::Key(KeyPress::Confirm)) else {
panic!("Enter sends");
};
assert_eq!(
sent,
Sent::Action {
verb: ActionVerb::Stop,
target: RowKey::Sheep(2),
name: "api".to_string()
}
);
}
#[test]
fn a_confirm_whose_sheep_left_the_flock_refuses_instead_of_sending() {
let mut app = allowed();
app.update(Msg::Key(KeyPress::Action(ActionVerb::Stop)));
app.update(Msg::Event(BusEvent::Process {
event: ProcessEventKind::Delete,
info: sheep(1, "web", ProcStatus::Stopped),
manually: true,
at_ms: 0,
}));
assert!(app.action().is_none());
assert_eq!(app.update(Msg::Key(KeyPress::Confirm)), Effect::None);
}
#[test]
fn a_confirm_expires_after_ten_seconds_of_ticks() {
let mut app = allowed();
let t0 = Instant::now();
app.update(Msg::Tick { now: t0 });
app.update(Msg::Key(KeyPress::Action(ActionVerb::Reload)));
app.update(Msg::Tick {
now: t0 + Duration::from_secs(9),
});
assert!(app.action().is_some(), "nine seconds is still armed");
app.update(Msg::Tick {
now: t0 + Duration::from_secs(10),
});
assert!(app.action().is_none(), "ten is not");
}
#[test]
fn every_action_key_refuses_while_the_link_is_not_live() {
for link in [
Msg::Retrying { attempt: 2 },
Msg::Frozen {
at_local: "2026-08-16 09:00:00".to_string(),
},
] {
let mut app = allowed();
app.update(link);
app.update(Msg::Key(KeyPress::Action(ActionVerb::Stop)));
assert!(app.action().is_none());
assert!(app.notice().is_some_and(Notice::is_grave));
}
}
#[test]
fn a_second_action_refuses_while_one_is_in_flight() {
let mut app = allowed();
app.update(Msg::Key(KeyPress::Action(ActionVerb::Stop)));
app.update(Msg::Key(KeyPress::Confirm));
assert!(app.action().is_some_and(|action| action.sent));
app.update(Msg::Key(KeyPress::Action(ActionVerb::Restart)));
assert_eq!(
app.notice().map(ToString::to_string).as_deref(),
Some("one action is already in flight")
);
let action = app.action().expect("the first one is untouched");
assert_eq!(action.verb, ActionVerb::Stop);
assert!(action.sent);
}
#[test]
fn an_in_flight_line_survives_a_keypress() {
let mut app = allowed();
app.update(Msg::Key(KeyPress::Action(ActionVerb::Stop)));
app.update(Msg::Key(KeyPress::Confirm));
app.update(Msg::Key(KeyPress::SelectDown));
assert!(
app.action().is_some_and(|action| action.sent),
"the keypress moved the cursor and left the in-flight state alone"
);
}
#[test]
fn quit_still_quits_while_a_confirm_is_armed() {
let mut app = allowed();
app.update(Msg::Key(KeyPress::Action(ActionVerb::Stop)));
assert_eq!(app.update(Msg::Key(KeyPress::Quit)), Effect::Quit);
}
#[test]
fn enter_outside_an_armed_confirm_does_nothing() {
let mut app = allowed();
assert_eq!(app.update(Msg::Key(KeyPress::Confirm)), Effect::None);
assert!(app.action().is_none());
app.update(Msg::Key(KeyPress::Action(ActionVerb::Stop)));
app.update(Msg::Key(KeyPress::Confirm));
assert!(app.action().is_some_and(|action| action.sent), "in flight");
assert_eq!(
app.update(Msg::Key(KeyPress::Confirm)),
Effect::None,
"a second Enter does not re-send"
);
}
#[test]
fn a_request_that_could_not_be_sent_says_so_and_clears_the_state() {
let mut app = allowed();
app.update(Msg::Key(KeyPress::Action(ActionVerb::Stop)));
let Effect::Send(sent) = app.update(Msg::Key(KeyPress::Confirm)) else {
panic!("Enter sends");
};
app.update(Msg::Unsent { sent });
assert!(app.action().is_none());
assert!(app.notice().is_some_and(Notice::is_grave));
}
#[test]
fn a_link_that_stops_being_live_takes_an_armed_prompt_down() {
for link in [
Msg::Retrying { attempt: 2 },
Msg::Frozen {
at_local: "2026-08-16 09:00:00".to_string(),
},
] {
let mut app = allowed();
app.update(Msg::Key(KeyPress::Action(ActionVerb::Stop)));
assert!(app.action().is_some(), "armed while live");
app.update(link);
assert!(app.action().is_none(), "and gone once the link is not");
assert_eq!(
app.update(Msg::Key(KeyPress::Confirm)),
Effect::None,
"so Enter has nothing to send"
);
}
}
#[test]
fn a_snapshot_replaces_the_flock_wholesale() {
let (mut app, t0) = started();
app.update(Msg::Event(BusEvent::Process {
event: ProcessEventKind::Start,
info: sheep(9, "ghost", ProcStatus::Starting),
manually: true,
at_ms: 0,
}));
assert_eq!(app.rows().len(), 4, "the bus event upserted");
app.update(Msg::Snapshot {
rows: vec![sheep(1, "web", ProcStatus::Online)],
at: t0,
});
assert_eq!(app.rows().len(), 1);
assert!(app.rows().iter().all(|row| row.info.id == 1));
}
#[test]
fn a_snapshot_that_shrinks_the_flock_pulls_the_selection_back() {
let (mut app, t0) = started();
app.update(Msg::Key(KeyPress::SelectLast));
assert_eq!(app.selected_index(), Some(2));
app.update(Msg::Snapshot {
rows: vec![sheep(1, "web", ProcStatus::Online)],
at: t0,
});
assert_eq!(
app.selected_index(),
Some(0),
"the selection came back with the flock"
);
app.update(Msg::Snapshot {
rows: vec![],
at: t0,
});
assert_eq!(app.selected_index(), None, "an empty flock selects nothing");
}
#[test]
fn the_selection_follows_the_sheep_and_not_the_row_number() {
let (mut app, t0) = started();
app.update(Msg::Key(KeyPress::SelectDown));
app.update(Msg::Key(KeyPress::SelectDown));
assert_eq!(
app.selected(),
Some(RowKey::Sheep(3)),
"the third row, worker"
);
app.update(Msg::Snapshot {
rows: vec![
sheep(2, "api", ProcStatus::Errored),
sheep(3, "worker", ProcStatus::Online),
],
at: t0,
});
assert_eq!(app.selected(), Some(RowKey::Sheep(3)), "still worker");
assert_eq!(app.selected_index(), Some(1), "which is now row 1");
}
#[test]
fn a_deleted_selection_falls_to_the_row_that_took_its_place() {
let (mut app, t0) = started();
app.update(Msg::Key(KeyPress::SelectDown));
assert_eq!(
app.selected(),
Some(RowKey::Sheep(1)),
"web, at index 1 by name"
);
app.update(Msg::Snapshot {
rows: vec![
sheep(2, "api", ProcStatus::Online),
sheep(3, "worker", ProcStatus::Online),
],
at: t0,
});
assert_eq!(
app.selected(),
Some(RowKey::Sheep(3)),
"the row that took index 1"
);
app.update(Msg::Key(KeyPress::SelectLast));
assert_eq!(app.selected(), Some(RowKey::Sheep(3)));
app.update(Msg::Snapshot {
rows: vec![sheep(2, "api", ProcStatus::Online)],
at: t0,
});
assert_eq!(app.selected(), Some(RowKey::Sheep(2)));
app.update(Msg::Snapshot {
rows: vec![],
at: t0,
});
assert_eq!(app.selected(), None);
assert_eq!(app.selected_index(), None);
}
#[test]
fn a_selection_that_moves_refreshes_the_feed_and_one_that_cannot_does_not() {
let (mut app, _) = started();
assert_eq!(
app.update(Msg::Key(KeyPress::SelectDown)),
Effect::RefreshSelected
);
assert_eq!(
app.update(Msg::Key(KeyPress::SelectFirst)),
Effect::RefreshSelected
);
assert_eq!(
app.update(Msg::Key(KeyPress::SelectUp)),
Effect::None,
"already at the top: nothing moved, so nothing is re-read"
);
assert_eq!(
app.update(Msg::Key(KeyPress::SelectLast)),
Effect::RefreshSelected
);
assert_eq!(
app.update(Msg::Key(KeyPress::SelectDown)),
Effect::None,
"already at the bottom"
);
}
#[test]
fn moving_the_selection_asks_for_lambs() {
let (mut app, _t0) = started();
assert_eq!(
app.update(Msg::Key(KeyPress::SelectDown)),
Effect::RefreshSelected
);
}
#[test]
fn a_snapshot_refreshes_the_feed_and_does_not_ask_for_lambs() {
let (mut app, t0) = started();
assert_eq!(
app.update(Msg::Snapshot {
rows: vec![sheep(1, "web", ProcStatus::Online)],
at: t0,
}),
Effect::RefreshFeed
);
}
#[test]
fn nothing_is_requested_while_the_link_is_lost() {
let (mut app, _t0) = started();
app.update(Msg::Frozen {
at_local: "2026-08-16 09:00:00".to_string(),
});
assert_eq!(app.update(Msg::Key(KeyPress::SelectDown)), Effect::None);
}
#[test]
fn a_snapshot_refreshes_the_feed_unless_the_link_is_frozen() {
let (mut app, t0) = started();
assert_eq!(
app.update(Msg::Snapshot {
rows: vec![sheep(1, "web", ProcStatus::Online)],
at: t0
}),
Effect::RefreshFeed
);
app.update(Msg::Frozen {
at_local: "2026-08-14 14:32:07".to_string(),
});
assert_eq!(
app.update(Msg::Snapshot {
rows: vec![sheep(1, "web", ProcStatus::Online)],
at: t0
}),
Effect::None,
"a frozen dashboard does not re-read anything"
);
}
#[test]
fn a_frozen_dashboard_moves_the_cursor_without_touching_a_file() {
let (mut app, _) = started();
app.update(Msg::Frozen {
at_local: "2026-08-14 14:32:07".to_string(),
});
assert_eq!(
app.update(Msg::Key(KeyPress::SelectDown)),
Effect::None,
"no file is read once the link is lost"
);
assert_eq!(
app.selected(),
Some(RowKey::Sheep(1)),
"but the cursor moved anyway"
);
assert_eq!(app.update(Msg::Key(KeyPress::SelectLast)), Effect::None);
assert_eq!(app.selected(), Some(RowKey::Sheep(3)));
}
#[test]
fn a_drop_and_a_lag_both_ask_for_an_immediate_poll() {
let (mut app, _) = started();
assert_eq!(
app.update(Msg::Event(BusEvent::Dropped { count: 12 })),
Effect::PollNow
);
assert_eq!(app.update(Msg::BusLagged { count: 3 }), Effect::PollNow);
assert_eq!(
app.update(Msg::Event(BusEvent::Process {
event: ProcessEventKind::Online,
info: sheep(1, "web", ProcStatus::Online),
manually: false,
at_ms: 0,
})),
Effect::None,
"an ordinary event needs no repair"
);
}
#[test]
fn a_shepherd_side_drop_and_a_local_lag_read_differently() {
let (mut app, _) = started();
app.update(Msg::Event(BusEvent::Dropped { count: 12 }));
let shepherd_side = app.notice().expect("a drop leaves a notice").to_string();
app.update(Msg::BusLagged { count: 3 });
let local = app.notice().expect("a lag leaves a notice").to_string();
assert!(shepherd_side.contains("the shepherd dropped"));
assert!(local.contains("lookout fell behind"));
assert_ne!(shepherd_side, local);
}
#[test]
fn a_running_sheeps_uptime_advances_with_the_heartbeat() {
let (mut app, t0) = started();
assert_eq!(app.uptime_ms(app.rows()[0].info.id), Some(60_000));
app.update(Msg::Tick {
now: t0 + Duration::from_secs(5),
});
assert_eq!(app.uptime_ms(1), Some(65_000));
}
#[test]
fn a_frozen_dashboard_stops_the_uptime_clock() {
let (mut app, t0) = started();
app.update(Msg::Tick {
now: t0 + Duration::from_secs(5),
});
app.update(Msg::Frozen {
at_local: "2026-08-14 14:32:07".to_string(),
});
let at_freeze = app.uptime_ms(1);
app.update(Msg::Tick {
now: t0 + Duration::from_secs(400),
});
assert_eq!(
app.uptime_ms(1),
at_freeze,
"the clock stopped with the link"
);
assert_eq!(at_freeze, Some(65_000));
}
#[test]
fn a_stopped_sheeps_uptime_does_not_advance() {
let t0 = Instant::now();
let mut app = App::new(
Palette::detect(None, None, None),
Control::ReadOnly,
"/home/ada/.shep".to_string(),
t0,
);
app.update(Msg::Snapshot {
rows: vec![sheep(1, "web", ProcStatus::Stopped)],
at: t0,
});
app.update(Msg::Tick {
now: t0 + Duration::from_secs(30),
});
assert_eq!(app.uptime_ms(1), Some(60_000));
}
#[test]
fn every_action_key_refuses_while_the_gate_is_closed() {
for verb in [ActionVerb::Stop, ActionVerb::Restart, ActionVerb::Reload] {
let (mut app, _t0) = started();
app.update(Msg::Key(KeyPress::Action(verb)));
assert!(
app.action().is_none(),
"{verb:?} armed behind a closed gate"
);
assert_eq!(
app.notice().map(ToString::to_string).as_deref(),
Some("read-only: actions need --allow-control"),
"{verb:?}"
);
}
}
#[test]
fn nothing_but_a_keypress_quits() {
let (mut app, _) = started();
for msg in [
Msg::Event(BusEvent::DaemonShutdown),
Msg::Event(BusEvent::Dropped { count: 1 }),
Msg::BusLagged { count: 1 },
Msg::Retrying { attempt: 5 },
Msg::Frozen {
at_local: "2026-08-14 14:32:07".to_string(),
},
] {
assert_ne!(app.update(msg), Effect::Quit);
}
assert_eq!(app.update(Msg::Key(KeyPress::Quit)), Effect::Quit);
}
#[test]
fn esc_clears_the_filter_instead_of_quitting_while_one_is_set() {
let mut app = filtered("web");
assert_eq!(app.update(Msg::Key(KeyPress::Escape)), Effect::None);
assert_eq!(app.filter(), "");
assert_eq!(app.rows().len(), 4);
}
#[test]
fn esc_still_quits_with_no_filter_set() {
let (mut app, _t0) = started();
assert_eq!(app.update(Msg::Key(KeyPress::Escape)), Effect::Quit);
}
#[test]
fn the_table_narrows_while_the_query_is_still_being_typed() {
let (mut app, _t0) = started();
app.update(Msg::Key(KeyPress::FilterStart));
assert_eq!(app.mode(), InputMode::Text);
for letter in ['w', 'e', 'b'] {
app.update(Msg::Key(KeyPress::TextChar(letter)));
}
assert_eq!(app.rows().len(), 1, "narrowed before Enter");
app.update(Msg::Key(KeyPress::TextApply));
assert_eq!(app.mode(), InputMode::Normal);
assert_eq!(
app.rows().len(),
1,
"and applying changed nothing but the mode"
);
}
#[test]
fn backspace_widens_the_table_back_out() {
let (mut app, _t0) = started();
app.update(Msg::Key(KeyPress::FilterStart));
app.update(Msg::Key(KeyPress::TextChar('w')));
app.update(Msg::Key(KeyPress::TextChar('z')));
assert_eq!(app.rows().len(), 0);
app.update(Msg::Key(KeyPress::TextBackspace));
assert_eq!(
app.rows().len(),
2,
"wz became w, which matches web and worker"
);
}
#[test]
fn esc_while_editing_clears_the_filter_and_leaves_the_box() {
let (mut app, _t0) = started();
app.update(Msg::Key(KeyPress::FilterStart));
app.update(Msg::Key(KeyPress::TextChar('w')));
app.update(Msg::Key(KeyPress::TextAbandon));
assert_eq!(app.mode(), InputMode::Normal);
assert_eq!(app.filter(), "");
assert_eq!(app.rows().len(), 3);
}
#[test]
fn opening_the_filter_takes_a_notice_off_the_bar() {
let (mut app, _t0) = started();
app.update(Msg::Event(BusEvent::Dropped { count: 3 }));
assert!(app.notice().is_some());
app.update(Msg::Key(KeyPress::FilterStart));
assert!(app.notice().is_none(), "the box is what the bar shows now");
}
#[test]
fn a_notice_raised_while_typing_is_deferred_and_not_destroyed() {
let (mut app, _t0) = started();
app.update(Msg::Key(KeyPress::FilterStart));
app.update(Msg::Key(KeyPress::TextChar('w')));
app.update(Msg::Event(BusEvent::DaemonShutdown));
app.update(Msg::Key(KeyPress::TextChar('e')));
assert!(
app.notice().is_some(),
"typing did not wipe the shepherd's announcement"
);
assert_eq!(app.filter(), "we", "and the box kept the query");
}
#[test]
fn the_link_state_walks_live_to_retrying_to_lost_and_back() {
let (mut app, t0) = started();
assert_eq!(app.link(), &Link::Live);
app.update(Msg::Retrying { attempt: 1 });
assert_eq!(app.link(), &Link::Retrying { attempt: 1 });
app.update(Msg::Relinked);
assert_eq!(app.link(), &Link::Live);
app.update(Msg::Retrying { attempt: 5 });
app.update(Msg::Frozen {
at_local: "2026-08-14 14:32:07".to_string(),
});
assert_eq!(
app.link(),
&Link::Lost {
at_local: "2026-08-14 14:32:07".to_string()
}
);
app.update(Msg::Snapshot {
rows: vec![],
at: t0,
});
assert!(matches!(app.link(), Link::Lost { .. }));
}
#[test]
fn the_selection_clamps_at_both_ends() {
let (mut app, _) = started();
for _ in 0..10 {
app.update(Msg::Key(KeyPress::SelectUp));
}
assert_eq!(
app.selected_index(),
Some(0),
"up past the first row stays on it"
);
for _ in 0..10 {
app.update(Msg::Key(KeyPress::SelectDown));
}
assert_eq!(
app.selected_index(),
Some(2),
"down past the last row stays on it"
);
app.update(Msg::Key(KeyPress::SelectFirst));
assert_eq!(app.selected_index(), Some(0));
app.update(Msg::Key(KeyPress::SelectLast));
assert_eq!(app.selected_index(), Some(2));
}
#[test]
fn refresh_polls_while_live_and_says_why_it_cannot_once_frozen() {
let (mut app, _) = started();
assert_eq!(app.update(Msg::Key(KeyPress::Refresh)), Effect::PollNow);
app.update(Msg::Frozen {
at_local: "2026-08-14 14:32:07".to_string(),
});
assert_eq!(
app.update(Msg::Key(KeyPress::Refresh)),
Effect::None,
"there is no link task left to ask"
);
let notice = app.notice().expect("a refusal is a notice").to_string();
assert!(notice.contains("the shepherd is gone"));
assert!(notice.contains("nothing left to ask"));
}
#[test]
fn the_next_keypress_clears_the_notice() {
let (mut app, _) = started();
app.update(Msg::Key(KeyPress::Action(ActionVerb::Stop)));
assert!(app.notice().is_some());
app.update(Msg::Key(KeyPress::SelectDown));
assert!(app.notice().is_none());
}
#[test]
fn a_frozen_dashboard_ignores_a_host_sample() {
let (mut app, _) = started();
app.update(Msg::Host {
sample: Some(super::super::source::HostSample {
load: (2.31, 4.10, 3.88),
cores: Some(10),
memory_total_bytes: 32 << 30,
memory_used_bytes: 12 << 30,
uptime_seconds: 600,
}),
});
assert!(app.host().is_some(), "a live dashboard takes the sample");
app.update(Msg::Frozen {
at_local: "2026-08-14 14:32:07".to_string(),
});
let frozen = app.host();
assert_eq!(app.update(Msg::Host { sample: None }), Effect::None);
assert_eq!(app.host(), frozen, "the last values stay, unchanged");
assert!(
!app.host_unsupported(),
"and a refused sample changes no flag"
);
}
#[test]
fn applying_a_tail_does_not_ask_for_another_one() {
let (mut app, _) = started();
assert_eq!(
app.update(Msg::Bleats {
tail: super::super::tail::Tail::default()
}),
Effect::None
);
}
#[test]
fn a_frozen_dashboard_ignores_a_bleats_tail_in_flight_at_the_freeze() {
let (mut app, _) = started();
let live_tail = super::super::tail::Tail {
lines: vec![super::super::tail::TailLine {
stream: super::super::tail::Stream::Out,
text: "read before the freeze".to_string(),
}],
..Default::default()
};
app.update(Msg::Bleats {
tail: live_tail.clone(),
});
assert_eq!(app.feed(), &live_tail, "a live dashboard takes the tail");
app.update(Msg::Frozen {
at_local: "2026-08-14 14:32:07".to_string(),
});
let in_flight_tail = super::super::tail::Tail {
lines: vec![super::super::tail::TailLine {
stream: super::super::tail::Stream::Out,
text: "read after the freeze".to_string(),
}],
..Default::default()
};
assert_eq!(
app.update(Msg::Bleats {
tail: in_flight_tail
}),
Effect::None
);
assert_eq!(
app.feed(),
&live_tail,
"the tail read after the freeze must not reach the rendered frame"
);
}
fn filtered(query: &str) -> App {
let t0 = Instant::now();
let mut app = App::new(
Palette::detect(None, None, None),
Control::ReadOnly,
"/home/ada/.shep".to_string(),
t0,
);
app.update(Msg::Snapshot {
rows: vec![
sheep(1, "api-web", ProcStatus::Online),
sheep(2, "cron", ProcStatus::Online),
sheep(3, "queue", ProcStatus::Online),
sheep(4, "web-worker", ProcStatus::Online),
],
at: t0,
});
app.set_filter(query.to_string());
app
}
#[test]
fn the_table_draws_by_name_then_by_id() {
let t0 = Instant::now();
let mut app = App::new(
Palette::detect(None, None, None),
Control::ReadOnly,
"/home/ada/.shep".to_string(),
t0,
);
app.update(Msg::Snapshot {
rows: vec![
sheep(0, "web", ProcStatus::Online),
sheep(1, "api", ProcStatus::Online),
sheep(2, "web", ProcStatus::Online),
],
at: t0,
});
let drawn: Vec<(&str, u32)> = app
.rows()
.iter()
.map(|row| (row.info.name.as_str(), row.info.id))
.collect();
assert_eq!(drawn, vec![("api", 1), ("web", 0), ("web", 2)]);
}
#[test]
fn a_filter_narrows_the_rows_and_leaves_the_real_size_readable() {
let app = filtered("web");
assert_eq!(app.rows().len(), 2, "api-web and web-worker");
assert_eq!(app.flock_len(), 4, "the flock did not get smaller");
}
#[test]
fn the_filter_matches_a_substring_and_not_a_whole_name() {
assert_eq!(filtered("wor").rows().len(), 1, "web-worker, by its middle");
assert_eq!(filtered("w").rows().len(), 2, "api-web, by its own middle");
}
#[test]
fn the_filter_ignores_case_in_both_directions() {
let t0 = Instant::now();
let mut app = App::new(
Palette::detect(None, None, None),
Control::ReadOnly,
"/home/ada/.shep".to_string(),
t0,
);
app.update(Msg::Snapshot {
rows: vec![sheep(1, "WebEdge", ProcStatus::Online)],
at: t0,
});
app.set_filter("webedge".to_string());
assert_eq!(
app.rows().len(),
1,
"a lowercase query against a mixed name"
);
app.set_filter("WEBEDGE".to_string());
assert_eq!(app.rows().len(), 1, "and an uppercase one");
}
#[test]
fn j_and_k_step_only_over_visible_rows() {
let mut app = filtered("web");
assert_eq!(
app.selected(),
Some(RowKey::Sheep(1)),
"api-web, the first visible sheep"
);
app.update(Msg::Key(KeyPress::SelectDown));
assert_eq!(
app.selected(),
Some(RowKey::Sheep(4)),
"web-worker, skipping the hidden cron and queue"
);
app.update(Msg::Key(KeyPress::SelectDown));
assert_eq!(
app.selected(),
Some(RowKey::Sheep(4)),
"clamped at the last visible row"
);
app.update(Msg::Key(KeyPress::SelectUp));
assert_eq!(app.selected(), Some(RowKey::Sheep(1)));
}
#[test]
fn select_last_lands_on_the_last_visible_row() {
let mut app = filtered("web");
app.update(Msg::Key(KeyPress::SelectLast));
assert_eq!(
app.selected(),
Some(RowKey::Sheep(4)),
"web-worker, not queue at id 3"
);
}
#[test]
fn a_filter_that_hides_the_selection_clamps_to_the_nearest_visible_row() {
let mut app = filtered("");
app.update(Msg::Key(KeyPress::SelectLast));
assert_eq!(
app.selected(),
Some(RowKey::Sheep(4)),
"web-worker, position 3 of 4"
);
app.set_filter("web".to_string());
assert_eq!(
app.selected(),
Some(RowKey::Sheep(4)),
"position 3 clamps to the last visible row, which is web-worker"
);
}
#[test]
fn nothing_visible_means_nothing_selected() {
let app = filtered("zzz");
assert_eq!(app.rows().len(), 0);
assert_eq!(app.selected(), None);
assert!(app.selected_row().is_none());
assert_eq!(app.flock_len(), 4, "the flock is still four sheep");
}
#[test]
fn a_filter_survives_the_two_second_snapshot() {
let mut app = filtered("web");
let t1 = Instant::now();
app.update(Msg::Snapshot {
rows: vec![
sheep(1, "api-web", ProcStatus::Online),
sheep(2, "cron", ProcStatus::Online),
sheep(3, "queue", ProcStatus::Online),
sheep(4, "web-worker", ProcStatus::Online),
],
at: t1,
});
assert_eq!(app.filter(), "web", "the snapshot did not clear it");
assert_eq!(app.rows().len(), 2, "and did not widen the table");
assert_eq!(app.flock_len(), 4);
}
#[test]
fn an_empty_query_is_the_same_as_no_filter() {
let mut app = filtered("zzz");
app.set_filter(String::new());
assert_eq!(app.rows().len(), 4);
assert_eq!(app.selected(), Some(RowKey::Sheep(1)), "seated again");
}
#[test]
fn a_lamb_reply_records_which_of_the_three_states_it_saw() {
let (mut app, t0) = started();
let walked = ProcessInfo::builder(1, "web", ProcStatus::Online)
.lambs(Some(vec![Lamb::new(48_220, "node")]))
.build();
app.update(Msg::Replied {
sent: Sent::Lambs { id: 1 },
result: Ok(Response::Described(vec![walked])),
});
assert!(matches!(app.lambs_for(1), Some((LambWalk::Walked(lambs), _)) if lambs.len() == 1));
let empty = ProcessInfo::builder(1, "web", ProcStatus::Online)
.lambs(Some(Vec::new()))
.build();
app.update(Msg::Replied {
sent: Sent::Lambs { id: 1 },
result: Ok(Response::Described(vec![empty])),
});
assert!(matches!(app.lambs_for(1), Some((LambWalk::Walked(lambs), _)) if lambs.is_empty()));
let unwalked = ProcessInfo::builder(1, "web", ProcStatus::Stopped).build();
app.update(Msg::Replied {
sent: Sent::Lambs { id: 1 },
result: Ok(Response::Described(vec![unwalked])),
});
assert!(matches!(app.lambs_for(1), Some((LambWalk::NotWalked, _))));
let _ = t0;
}
#[test]
fn a_reading_for_another_sheep_reads_as_not_read_yet() {
let (mut app, _t0) = started();
app.update(Msg::Replied {
sent: Sent::Lambs { id: 1 },
result: Ok(Response::Described(vec![
ProcessInfo::builder(1, "web", ProcStatus::Online)
.lambs(Some(vec![Lamb::new(48_220, "node")]))
.build(),
])),
});
assert!(app.lambs_for(1).is_some());
assert!(app.lambs_for(2).is_none(), "not this sheep's reading");
}
#[test]
fn a_failed_lamb_fetch_says_so_in_the_pane_and_raises_no_notice() {
let (mut app, _t0) = started();
app.update(Msg::Replied {
sent: Sent::Lambs { id: 1 },
result: Err(RequestError::Closed),
});
assert!(matches!(app.lambs_for(1), Some((LambWalk::Failed, _))));
assert!(app.notice().is_none(), "no notice for a decoration");
}
#[test]
fn an_unrecognised_lamb_reply_is_a_failure_and_not_an_empty_walk() {
let (mut app, _t0) = started();
app.update(Msg::Replied {
sent: Sent::Lambs { id: 1 },
result: Ok(Response::Pong),
});
assert!(matches!(app.lambs_for(1), Some((LambWalk::Failed, _))));
}
#[test]
fn a_lamb_reply_after_a_freeze_is_refused() {
let (mut app, _t0) = started();
app.update(Msg::Frozen {
at_local: "2026-08-16 09:00:00".to_string(),
});
app.update(Msg::Replied {
sent: Sent::Lambs { id: 1 },
result: Ok(Response::Described(vec![
ProcessInfo::builder(1, "web", ProcStatus::Online)
.lambs(Some(vec![Lamb::new(48_220, "node")]))
.build(),
])),
});
assert!(
app.lambs_for(1).is_none(),
"the frozen frame learned nothing"
);
}
#[test]
fn an_accepted_stop_upserts_the_rows_the_shepherd_returned() {
let mut app = allowed();
app.update(Msg::Key(KeyPress::Action(ActionVerb::Stop)));
app.update(Msg::Key(KeyPress::Confirm));
app.update(Msg::Replied {
sent: Sent::Action {
verb: ActionVerb::Stop,
target: RowKey::Sheep(2),
name: "api".to_string(),
},
result: Ok(Response::Stopped(vec![sheep(
2,
"api",
ProcStatus::Stopped,
)])),
});
assert_eq!(
app.rows()
.iter()
.find(|row| row.info.id == 2)
.map(|row| row.info.status),
Some(ProcStatus::Stopped),
"the table shows what the shepherd said, without waiting for a poll"
);
assert_eq!(
app.notice().map(ToString::to_string).as_deref(),
Some("stop api (id 2): the shepherd stopped it")
);
assert!(app.action().is_none(), "the in-flight state cleared");
}
#[test]
fn a_reload_reply_does_not_claim_the_swap_finished() {
let mut app = allowed();
app.update(Msg::Key(KeyPress::Action(ActionVerb::Reload)));
app.update(Msg::Key(KeyPress::Confirm));
app.update(Msg::Replied {
sent: Sent::Action {
verb: ActionVerb::Reload,
target: RowKey::Sheep(2),
name: "api".to_string(),
},
result: Ok(Response::Reloading(vec![sheep(
2,
"api",
ProcStatus::Online,
)])),
});
let said = app.notice().map(ToString::to_string).unwrap_or_default();
assert_eq!(
said,
"reload api (id 2): accepted, the swaps report themselves as they happen"
);
assert!(!said.contains("reloaded"), "got {said:?}");
}
#[test]
fn a_daemon_refusal_reaches_the_bar_in_the_daemons_own_words() {
let mut app = allowed();
app.update(Msg::Key(KeyPress::Action(ActionVerb::Restart)));
app.update(Msg::Key(KeyPress::Confirm));
app.update(Msg::Replied {
sent: Sent::Action {
verb: ActionVerb::Restart,
target: RowKey::Sheep(2),
name: "api".to_string(),
},
result: Err(RequestError::Rpc(RpcError {
code: RpcErrorCode::NotFound,
message: "selector matched no registered sheep".to_string(),
daemon_version: None,
})),
});
let said = app.notice().map(ToString::to_string).unwrap_or_default();
assert_eq!(
said,
"restart api (id 2): selector matched no registered sheep"
);
assert!(!said.contains("NotFound"), "no Rust identifiers: {said:?}");
assert!(app.notice().is_some_and(Notice::is_grave));
}
#[test]
fn a_connection_that_died_mid_request_says_so_under_the_same_prefix() {
let mut app = allowed();
app.update(Msg::Key(KeyPress::Action(ActionVerb::Stop)));
app.update(Msg::Key(KeyPress::Confirm));
app.update(Msg::Replied {
sent: Sent::Action {
verb: ActionVerb::Stop,
target: RowKey::Sheep(2),
name: "api".to_string(),
},
result: Err(RequestError::Closed),
});
let said = app.notice().map(ToString::to_string).unwrap_or_default();
assert!(said.starts_with("stop api (id 2): "), "got {said:?}");
assert!(said.contains(&RequestError::Closed.to_string()));
}
#[test]
fn an_unrecognised_reply_says_so_rather_than_reading_as_success() {
for reply in [
Response::Pong,
Response::Stopped(vec![sheep(2, "api", ProcStatus::Stopped)]),
] {
let mut app = allowed();
app.update(Msg::Key(KeyPress::Action(ActionVerb::Restart)));
app.update(Msg::Key(KeyPress::Confirm));
app.update(Msg::Replied {
sent: Sent::Action {
verb: ActionVerb::Restart,
target: RowKey::Sheep(2),
name: "api".to_string(),
},
result: Ok(reply),
});
assert_eq!(
app.notice().map(ToString::to_string).as_deref(),
Some(
"restart api (id 2): the shepherd answered something this lookout does not understand"
)
);
assert!(app.notice().is_some_and(Notice::is_grave));
}
}
#[test]
fn s_asks_for_the_file_before_the_screen_opens() {
let mut app = fixtures::full_app();
assert_eq!(
app.update(Msg::Key(KeyPress::Settings)),
Effect::LoadSettings
);
assert!(
app.settings().is_none(),
"nothing opens until the read lands"
);
}
#[test]
fn the_screen_opens_when_the_read_lands() {
let mut app = fixtures::full_app();
let _ = app.update(Msg::Key(KeyPress::Settings));
let _ = app.update(Msg::Settings {
result: Ok(fixtures::settings_snapshot()),
});
assert!(app.settings().is_some());
}
#[test]
fn a_read_that_failed_says_so_and_leaves_the_dashboard_up() {
let mut app = fixtures::full_app();
let _ = app.update(Msg::Key(KeyPress::Settings));
let _ = app.update(Msg::Settings {
result: Err("no such file".into()),
});
assert!(app.settings().is_none());
let notice = app.notice().expect("a failed read has to say so");
assert!(notice.is_grave());
assert!(notice.to_string().contains("no such file"));
}
#[test]
fn s_closes_the_screen_again() {
let mut app = fixtures::app_in_settings();
let _ = app.update(Msg::Key(KeyPress::Settings));
assert!(app.settings().is_none());
}
#[test]
fn escape_closes_the_screen_and_never_quits() {
let mut app = fixtures::app_in_settings();
assert_eq!(app.update(Msg::Key(KeyPress::Escape)), Effect::None);
assert!(app.settings().is_none());
}
#[test]
fn the_flock_cursor_and_the_filter_survive_the_swap() {
let mut app = fixtures::full_app();
let _ = app.update(Msg::Key(KeyPress::FilterStart));
for c in "web".chars() {
let _ = app.update(Msg::Key(KeyPress::TextChar(c)));
}
let _ = app.update(Msg::Key(KeyPress::TextApply));
let selected = app.selected();
let filter = app.filter().to_string();
let _ = app.update(Msg::Key(KeyPress::Settings));
let _ = app.update(Msg::Settings {
result: Ok(fixtures::settings_snapshot()),
});
let _ = app.update(Msg::Key(KeyPress::Settings));
assert_eq!(app.selected(), selected);
assert_eq!(app.filter(), filter);
}
#[test]
fn the_settings_cursor_starts_at_the_first_row_on_every_open() {
let mut app = fixtures::app_in_settings();
let _ = app.update(Msg::Key(KeyPress::SelectDown));
let _ = app.update(Msg::Key(KeyPress::SelectDown));
let _ = app.update(Msg::Key(KeyPress::Settings));
let _ = app.update(Msg::Key(KeyPress::Settings));
let _ = app.update(Msg::Settings {
result: Ok(fixtures::settings_snapshot()),
});
let first = app.settings().unwrap().rows()[0];
assert_eq!(app.settings().unwrap().cursor(), Some(first));
}
#[test]
fn the_cursor_moves_through_the_scalars_and_into_the_dogs() {
let mut app = fixtures::app_in_settings();
let rows = app.settings().unwrap().rows();
for _ in 0..rows.len() - 1 {
let _ = app.update(Msg::Key(KeyPress::SelectDown));
}
assert_eq!(
app.settings().unwrap().cursor(),
Some(*rows.last().unwrap())
);
let _ = app.update(Msg::Key(KeyPress::SelectDown));
assert_eq!(
app.settings().unwrap().cursor(),
Some(*rows.last().unwrap())
);
}
#[test]
fn an_action_key_from_the_dashboard_is_unreachable_while_the_screen_is_up() {
let mut app = fixtures::app_in_settings_with_control();
let _ = app.update(Msg::Key(KeyPress::Action(ActionVerb::Stop)));
assert!(app.action().is_none(), "no sheep confirm can arm from here");
}
#[test]
fn no_key_route_writes_the_config_while_the_gate_is_closed() {
use KeyPress::{Confirm, Cycle, SelectDown, TextApply, TextChar};
let routes: &[(&str, &[KeyPress])] = &[
("space on a cycled scalar", &[Cycle, Confirm]),
(
"the socket editor",
&[
SelectDown,
SelectDown,
Confirm,
TextChar('/'),
TextChar('x'),
TextApply,
Confirm,
],
),
(
"the max_cron_sleep editor",
&[
SelectDown,
SelectDown,
SelectDown,
Confirm,
TextChar('9'),
TextChar('s'),
TextApply,
Confirm,
],
),
(
"the whistle gate",
&[
SelectDown, SelectDown, SelectDown, SelectDown, Cycle, Confirm,
],
),
(
"space on a dog row",
&[
SelectDown, SelectDown, SelectDown, SelectDown, SelectDown, SelectDown, Cycle,
Confirm,
],
),
];
for (what, keys) in routes {
let mut app = fixtures::app_in_settings(); let mut refused = false;
for key in *keys {
let effect = app.update(Msg::Key(*key));
assert!(
!matches!(effect, Effect::WriteSetting(..) | Effect::WriteDog(..)),
"{what}: a read-only lookout reached {effect:?}"
);
refused |= app.notice().is_some_and(Notice::is_grave);
}
assert!(refused, "{what}: the refusal has to say why");
assert!(
app.settings().unwrap().pending().is_none(),
"{what}: nothing is left armed"
);
assert!(
app.settings().unwrap().typing().is_none(),
"{what}: no editor is left open"
);
}
}
#[test]
fn every_one_of_those_routes_writes_once_the_gate_is_open() {
use KeyPress::{Confirm, Cycle, SelectDown, TextApply, TextChar};
let routes: &[(&str, &[KeyPress])] = &[
("space on a cycled scalar", &[Cycle, Confirm]),
(
"the socket editor",
&[
SelectDown,
SelectDown,
Confirm,
TextChar('/'),
TextChar('x'),
TextApply,
Confirm,
],
),
(
"space on a dog row",
&[
SelectDown, SelectDown, SelectDown, SelectDown, SelectDown, SelectDown, Cycle,
Confirm,
],
),
];
for (what, keys) in routes {
let mut app = fixtures::app_in_settings_with_control();
let mut wrote = false;
for key in *keys {
let effect = app.update(Msg::Key(*key));
wrote |= matches!(effect, Effect::WriteSetting(..) | Effect::WriteDog(..));
}
assert!(wrote, "{what}: an open gate has to reach the write");
}
}
#[test]
fn a_read_only_lookout_opens_the_screen_and_refuses_the_edit_key() {
let mut app = fixtures::app_in_settings(); assert!(app.settings().is_some(), "reading shep.toml is not gated");
let _ = app.update(Msg::Key(KeyPress::Cycle));
let notice = app.notice().expect("the refusal has to say why");
assert!(notice.is_grave());
}
#[test]
fn space_arms_a_candidate_without_changing_the_row() {
let mut app = fixtures::app_in_settings_with_control();
let before = app.settings().unwrap().snapshot().log_level.value.clone();
assert_eq!(app.update(Msg::Key(KeyPress::Cycle)), Effect::None);
assert_eq!(
app.settings().unwrap().snapshot().log_level.value,
before,
"arming is a question, so the row still shows what the file says"
);
assert!(app.settings().unwrap().pending().is_some());
}
#[test]
fn space_advances_the_candidate_rather_than_needing_a_cancel() {
let mut app = fixtures::app_in_settings_with_control();
let _ = app.update(Msg::Key(KeyPress::Cycle));
let first = app.settings().unwrap().pending().unwrap().text.to_string();
let _ = app.update(Msg::Key(KeyPress::Cycle));
let second = app.settings().unwrap().pending().unwrap().text.to_string();
assert_ne!(first, second);
}
#[test]
fn the_daemon_confirm_names_both_layers_lookout_cannot_see() {
let mut app = fixtures::app_in_settings_with_control();
let _ = app.update(Msg::Key(KeyPress::Cycle));
let text = app.settings().unwrap().pending().unwrap().text.to_string();
assert!(text.contains("shep daemon reload"), "got: {text}");
assert!(text.contains("SHEP_LOG_LEVEL"), "got: {text}");
assert!(text.contains("--log-level"), "got: {text}");
}
#[test]
fn the_whistle_confirm_names_a_whistle_restart_and_not_a_reload() {
let mut app = fixtures::app_in_settings_on(SettingField::AllowControl);
let _ = app.update(Msg::Key(KeyPress::Cycle));
let text = app.settings().unwrap().pending().unwrap().text.to_string();
assert!(text.contains("shep whistle restarted"), "got: {text}");
assert!(
!text.contains("daemon reload"),
"a whistle key needs no reload: {text}"
);
}
#[test]
fn the_style_confirm_promises_nothing_beyond_the_next_command() {
let mut app = fixtures::app_in_settings_on(SettingField::StyleLevel);
let _ = app.update(Msg::Key(KeyPress::Cycle));
let text = app.settings().unwrap().pending().unwrap().text.to_string();
assert!(text.contains("the next command reads it"), "got: {text}");
}
#[test]
fn a_shadowed_style_confirm_names_the_layer_that_keeps_winning() {
for (source, layer) in [
(StyleSource::Env, "$SHEP_STYLE"),
(StyleSource::Flag, "--style"),
] {
let mut app = fixtures::app_in_settings_with_shadowed_style(source);
let _ = app.update(Msg::Key(KeyPress::Cycle));
let text = app.settings().unwrap().pending().unwrap().text.to_string();
assert!(text.contains(layer), "{source} must name itself: {text}");
assert!(
text.contains("keeps winning"),
"{source} must say what it does: {text}"
);
assert!(
!text.contains("the next command reads it"),
"{source}: the next command reads {source}, not the file: {text}"
);
}
}
#[test]
fn the_style_cycle_starts_from_the_file_and_not_the_level_in_force() {
let mut app = fixtures::app_in_settings_with_shadowed_style(StyleSource::Env);
let _ = app.update(Msg::Key(KeyPress::Cycle));
let text = app.settings().unwrap().pending().unwrap().text.to_string();
assert!(
text.contains("set style level to plain"),
"the file says full, so one step is plain: {text}"
);
}
#[test]
fn enter_sends_the_armed_edit() {
let mut app = fixtures::app_in_settings_with_control();
let _ = app.update(Msg::Key(KeyPress::Cycle));
let effect = app.update(Msg::Key(KeyPress::Confirm));
assert!(matches!(
effect,
Effect::WriteSetting(
SettingEdit::Set {
field: SettingField::LogLevel,
..
},
_
)
));
assert!(app.settings().unwrap().pending().unwrap().sent);
}
#[test]
fn a_written_edit_updates_the_row_and_its_source() {
let mut app = fixtures::app_in_settings_with_control();
let _ = app.update(Msg::Key(KeyPress::Cycle));
let Effect::WriteSetting(edit, _) = app.update(Msg::Key(KeyPress::Confirm)) else {
panic!("Enter must send");
};
let SettingEdit::Set {
value: candidate, ..
} = edit.clone()
else {
panic!("cycling only ever arms Set");
};
let effect = app.update(Msg::SettingWritten {
edit,
result: Ok(()),
});
assert_eq!(
effect,
Effect::LoadSettings,
"a landed write re-reads rather than hand-folding the row"
);
assert!(app.settings().unwrap().pending().is_none());
let mut updated = fixtures::settings_snapshot();
updated.log_level = ScalarView {
value: candidate,
source: StyleSource::Config,
};
let _ = app.update(Msg::Settings {
result: Ok(updated.clone()),
});
assert_eq!(app.settings().unwrap().snapshot(), &updated);
}
#[test]
fn an_unset_write_returns_the_row_to_the_default() {
let mut app = fixtures::app_in_settings_on(SettingField::MaxCronSleep);
let _ = app.update(Msg::Key(KeyPress::Confirm));
for _ in 0..8 {
let _ = app.update(Msg::Key(KeyPress::TextBackspace));
}
let _ = app.update(Msg::Key(KeyPress::TextApply));
let Effect::WriteSetting(edit, _) = app.update(Msg::Key(KeyPress::Confirm)) else {
panic!("Enter must send");
};
assert!(matches!(
edit,
SettingEdit::Unset {
field: SettingField::MaxCronSleep
}
));
let effect = app.update(Msg::SettingWritten {
edit,
result: Ok(()),
});
assert_eq!(effect, Effect::LoadSettings);
let mut updated = fixtures::settings_snapshot();
updated.max_cron_sleep = ScalarView {
value: "30s".to_string(),
source: StyleSource::Default,
};
let _ = app.update(Msg::Settings {
result: Ok(updated.clone()),
});
assert_eq!(app.settings().unwrap().snapshot(), &updated);
}
#[test]
fn the_cursor_survives_a_landed_writes_reload() {
let mut app = fixtures::app_in_settings_on(SettingField::MaxCronSleep);
let before = app.settings().unwrap().cursor();
let _ = app.update(Msg::Key(KeyPress::Confirm));
let _ = app.update(Msg::Key(KeyPress::TextApply));
let Effect::WriteSetting(edit, _) = app.update(Msg::Key(KeyPress::Confirm)) else {
panic!("Enter must send");
};
let _ = app.update(Msg::SettingWritten {
edit,
result: Ok(()),
});
let _ = app.update(Msg::Settings {
result: Ok(fixtures::settings_snapshot()),
});
assert_eq!(app.settings().unwrap().cursor(), before);
}
#[test]
fn the_cursor_survives_a_refresh() {
let mut app = fixtures::app_in_settings_on(SettingField::MaxCronSleep);
let before = app.settings().unwrap().cursor();
assert_eq!(
app.update(Msg::Key(KeyPress::Refresh)),
Effect::LoadSettings
);
let _ = app.update(Msg::Settings {
result: Ok(fixtures::settings_snapshot()),
});
assert_eq!(app.settings().unwrap().cursor(), before);
}
#[test]
fn a_refused_write_says_why_and_leaves_the_row_alone() {
let mut app = fixtures::app_in_settings_with_control();
let before = app.settings().unwrap().snapshot().log_level.clone();
let _ = app.update(Msg::Key(KeyPress::Cycle));
let Effect::WriteSetting(edit, _) = app.update(Msg::Key(KeyPress::Confirm)) else {
panic!("Enter must send");
};
let _ = app.update(Msg::SettingWritten {
edit,
result: Err("max_cron_sleep is 500ms, below the 1s floor".into()),
});
assert_eq!(app.settings().unwrap().snapshot().log_level, before);
let notice = app.notice().unwrap();
assert!(notice.is_grave());
assert!(notice.to_string().contains("below the 1s floor"));
}
#[test]
fn a_lost_link_leaves_a_scalar_confirm_armed() {
let mut app = fixtures::app_in_settings_with_control();
let _ = app.update(Msg::Key(KeyPress::Cycle));
let _ = app.update(Msg::Frozen {
at_local: "12:00:00".into(),
});
assert!(
app.settings().unwrap().pending().is_some(),
"a scalar never leaves the machine, so a dead shepherd is irrelevant to it"
);
}
#[test]
fn a_settings_confirm_expires_on_a_frozen_dashboard() {
let (mut app, start) = fixtures::app_in_settings_at();
let _ = app.update(Msg::Key(KeyPress::Cycle));
let _ = app.update(Msg::Frozen {
at_local: "12:00:00".into(),
});
let _ = app.update(Msg::Tick {
now: start + CONFIRM_EXPIRY,
});
assert!(app.settings().unwrap().pending().is_none());
}
#[test]
fn escape_cancels_the_confirm_before_it_closes_the_screen() {
let mut app = fixtures::app_in_settings_with_control();
let _ = app.update(Msg::Key(KeyPress::Cycle));
let _ = app.update(Msg::Key(KeyPress::Escape));
assert!(app.settings().unwrap().pending().is_none());
assert!(
app.settings().is_some(),
"the first Esc cancels, it does not close"
);
let _ = app.update(Msg::Key(KeyPress::Escape));
assert!(app.settings().is_none());
}
#[test]
fn opening_the_screen_clears_an_action_armed_while_the_read_was_in_flight() {
let mut app = fixtures::allowed_app();
let _ = app.update(Msg::Key(KeyPress::Settings));
let _ = app.update(Msg::Key(KeyPress::Action(ActionVerb::Stop)));
assert!(
app.action().is_some(),
"the arm must still succeed before the read lands"
);
let _ = app.update(Msg::Settings {
result: Ok(fixtures::settings_snapshot()),
});
assert!(
app.action().is_none(),
"no armed action may survive the screen opening"
);
}
#[test]
fn opening_the_screen_closes_a_filter_box_left_open_while_the_read_was_in_flight() {
let mut app = fixtures::allowed_app();
let _ = app.update(Msg::Key(KeyPress::Settings));
let _ = app.update(Msg::Key(KeyPress::FilterStart));
let _ = app.update(Msg::Key(KeyPress::TextChar('w')));
let _ = app.update(Msg::Key(KeyPress::TextChar('e')));
assert_eq!(
app.mode(),
InputMode::Text,
"the box is open before the read lands"
);
let _ = app.update(Msg::Settings {
result: Ok(fixtures::settings_snapshot()),
});
assert!(app.settings().is_some(), "the screen opened");
assert_eq!(
app.mode(),
InputMode::Normal,
"the box must not survive the screen opening"
);
assert_eq!(app.filter(), "we", "the typed query is kept, not discarded");
}
#[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");
}
#[test]
fn enter_on_a_text_row_opens_the_editor_seeded_with_the_current_value() {
let mut app = fixtures::app_in_settings_on(SettingField::MaxCronSleep);
let _ = app.update(Msg::Key(KeyPress::Confirm));
let (field, buffer) = app.settings().unwrap().typing().expect("the editor opens");
assert_eq!(*field, SettingField::MaxCronSleep);
assert_eq!(buffer, "30s");
}
#[test]
fn typing_then_enter_arms_rather_than_writing() {
let mut app = fixtures::app_in_settings_on(SettingField::MaxCronSleep);
let _ = app.update(Msg::Key(KeyPress::Confirm));
for _ in 0..3 {
let _ = app.update(Msg::Key(KeyPress::TextBackspace));
}
for c in "45s".chars() {
let _ = app.update(Msg::Key(KeyPress::TextChar(c)));
}
assert_eq!(app.update(Msg::Key(KeyPress::TextApply)), Effect::None);
let prompt = app.settings().unwrap().pending().unwrap();
assert!(
!prompt.sent,
"the editor arms; a second Enter is what sends"
);
assert!(prompt.text.contains("45s"), "got: {}", prompt.text);
assert!(
prompt.text.contains("SHEP_MAX_CRON_SLEEP"),
"got: {}",
prompt.text
);
}
#[test]
fn an_empty_editor_arms_an_unset() {
let mut app = fixtures::app_in_settings_on(SettingField::MaxCronSleep);
let _ = app.update(Msg::Key(KeyPress::Confirm));
for _ in 0..8 {
let _ = app.update(Msg::Key(KeyPress::TextBackspace));
}
let _ = app.update(Msg::Key(KeyPress::TextApply));
let text = app.settings().unwrap().pending().unwrap().text.to_string();
assert!(text.starts_with("unset max_cron_sleep?"), "got: {text}");
}
#[test]
fn the_socket_confirm_rules_out_the_reload_it_would_otherwise_imply() {
let mut app = fixtures::app_in_settings_on(SettingField::Socket);
let _ = app.update(Msg::Key(KeyPress::Confirm));
let _ = app.update(Msg::Key(KeyPress::TextApply));
let text = app.settings().unwrap().pending().unwrap().text.to_string();
assert!(text.contains("stopped and started"), "got: {text}");
assert!(text.contains("a reload will not move it"), "got: {text}");
}
#[test]
fn a_refused_write_reopens_the_editor_with_the_text_intact() {
let mut app = fixtures::app_in_settings_on(SettingField::MaxCronSleep);
let _ = app.update(Msg::Key(KeyPress::Confirm));
for _ in 0..3 {
let _ = app.update(Msg::Key(KeyPress::TextBackspace));
}
for c in "500ms".chars() {
let _ = app.update(Msg::Key(KeyPress::TextChar(c)));
}
let _ = app.update(Msg::Key(KeyPress::TextApply));
let Effect::WriteSetting(edit, _) = app.update(Msg::Key(KeyPress::Confirm)) else {
panic!("Enter must send");
};
let _ = app.update(Msg::SettingWritten {
edit,
result: Err("max_cron_sleep is 500ms, below the 1s floor".into()),
});
let (_, buffer) = app
.settings()
.unwrap()
.typing()
.expect("the editor reopens");
assert_eq!(buffer, "500ms");
assert!(
app.notice()
.unwrap()
.to_string()
.contains("below the 1s floor")
);
}
#[test]
fn escape_abandons_the_editor_and_keeps_the_screen_open() {
let mut app = fixtures::app_in_settings_on(SettingField::Socket);
let _ = app.update(Msg::Key(KeyPress::Confirm));
let _ = app.update(Msg::Key(KeyPress::TextAbandon));
assert!(app.settings().unwrap().typing().is_none());
assert!(app.settings().is_some());
}
#[test]
fn a_closed_scalar_has_no_editor() {
let mut app = fixtures::app_in_settings_with_control(); let _ = app.update(Msg::Key(KeyPress::Confirm));
assert!(
app.settings().unwrap().typing().is_none(),
"log_level is a cycle, not a text field"
);
}
#[test]
fn movement_cancels_an_armed_candidate_rather_than_also_moving() {
let mut app = fixtures::app_in_settings_with_control(); let before = app.settings().unwrap().cursor();
let _ = app.update(Msg::Key(KeyPress::Cycle));
assert!(
app.settings().unwrap().pending().is_some(),
"space must arm before this test means anything"
);
let _ = app.update(Msg::Key(KeyPress::SelectDown));
assert!(
app.settings().unwrap().pending().is_none(),
"the armed candidate must not survive the movement key"
);
assert_eq!(
app.settings().unwrap().cursor(),
before,
"the cursor must not also move on the same keypress"
);
}
#[test]
fn refresh_cancels_an_armed_candidate_rather_than_silently_dropping_it() {
let mut app = fixtures::app_in_settings_with_control(); let _ = app.update(Msg::Key(KeyPress::Cycle));
assert!(
app.settings().unwrap().pending().is_some(),
"space must arm before this test means anything"
);
let effect = app.update(Msg::Key(KeyPress::Refresh));
assert_eq!(
effect,
Effect::None,
"a cancel must not also raise a reload"
);
assert!(
app.settings().unwrap().pending().is_none(),
"the armed candidate must not survive `r`"
);
}
#[test]
fn arming_a_dog_names_the_live_apply_and_not_a_reload() {
let mut app = fixtures::app_in_settings_on_dog("metrics");
let _ = app.update(Msg::Key(KeyPress::Cycle));
let text = app.settings().unwrap().pending().unwrap().text.to_string();
assert!(text.contains("it starts now, no reload"), "got: {text}");
}
#[test]
fn disabling_says_it_deregisters() {
let mut app = fixtures::app_in_settings_on_enabled_dog("otel");
let _ = app.update(Msg::Key(KeyPress::Cycle));
let text = app.settings().unwrap().pending().unwrap().text.to_string();
assert!(text.contains("deregistered"), "got: {text}");
}
#[test]
fn a_written_dog_toggle_raises_the_daemon_half() {
let mut app = fixtures::app_in_settings_on_dog("metrics");
let _ = app.update(Msg::Key(KeyPress::Cycle));
let Effect::WriteDog(edit, _) = app.update(Msg::Key(KeyPress::Confirm)) else {
panic!("Enter must send the file half first");
};
let effect = app.update(Msg::DogWritten {
edit,
result: Ok(DogSource::BuiltIn),
});
assert!(matches!(
effect,
Effect::Send(Sent::Dog { enable: true, .. })
));
}
#[test]
fn a_refused_file_half_never_reaches_the_shepherd() {
let mut app = fixtures::app_in_settings_on_dog("metrics");
let _ = app.update(Msg::Key(KeyPress::Cycle));
let Effect::WriteDog(edit, _) = app.update(Msg::Key(KeyPress::Confirm)) else {
panic!("Enter must send the file half first");
};
let effect = app.update(Msg::DogWritten {
edit,
result: Err("permission denied".into()),
});
assert_eq!(
effect,
Effect::None,
"a failed write must not ask the shepherd"
);
assert!(app.notice().unwrap().is_grave());
}
#[test]
fn a_dog_toggle_refuses_while_the_link_is_gone() {
let mut app = fixtures::app_in_settings_on_dog("metrics");
let _ = app.update(Msg::Frozen {
at_local: "12:00:00".into(),
});
let effect = app.update(Msg::Key(KeyPress::Cycle));
assert_eq!(effect, Effect::None);
assert!(app.settings().unwrap().pending().is_none(), "nothing arms");
assert!(app.notice().unwrap().is_grave());
}
#[test]
fn a_scalar_still_edits_while_the_link_is_gone() {
let mut app = fixtures::app_in_settings_with_control(); let _ = app.update(Msg::Frozen {
at_local: "12:00:00".into(),
});
let _ = app.update(Msg::Key(KeyPress::Cycle));
assert!(
app.settings().unwrap().pending().is_some(),
"a scalar is local file I/O and needs no shepherd"
);
}
fn armed_and_sent_dog(name: &str) -> (App, Sent) {
let mut app = fixtures::app_in_settings_on_dog(name);
let _ = app.update(Msg::Key(KeyPress::Cycle));
let Effect::WriteDog(edit, _) = app.update(Msg::Key(KeyPress::Confirm)) else {
panic!("Enter must send the file half first");
};
let Effect::Send(sent) = app.update(Msg::DogWritten {
edit,
result: Ok(DogSource::BuiltIn),
}) else {
panic!("a landed write must raise the daemon half");
};
(app, sent)
}
#[test]
fn a_landed_enable_names_what_the_shepherd_did() {
let (mut app, sent) = armed_and_sent_dog("metrics");
assert!(
app.settings().unwrap().pending().is_some(),
"the sent line stays up until the reply lands"
);
let info = ProcessInfo::builder(50, "metrics", ProcStatus::Online)
.pid(Some(50_000))
.dog(Some(DogSource::BuiltIn))
.build();
app.update(Msg::Replied {
sent,
result: Ok(Response::DogStarted(info)),
});
assert_eq!(
app.notice().map(ToString::to_string).as_deref(),
Some("enable metrics: the shepherd started it")
);
assert!(!app.notice().unwrap().is_grave());
assert!(
app.settings().unwrap().pending().is_none(),
"the sent line clears once the reply lands"
);
}
#[test]
fn a_landed_disable_names_the_deregistration() {
let (mut app, sent) = armed_and_sent_dog("otel");
app.update(Msg::Replied {
sent,
result: Ok(Response::Deleted(vec![50])),
});
assert_eq!(
app.notice().map(ToString::to_string).as_deref(),
Some("disable otel: the shepherd stopped and deregistered it")
);
assert!(!app.notice().unwrap().is_grave());
}
#[track_caller]
fn dog_enabled_in_view(app: &App, name: &str) -> bool {
app.settings()
.expect("the settings screen is open")
.snapshot()
.dogs
.iter()
.find(|dog| dog.name == name)
.expect("the fixture carries this dog")
.enabled
}
#[test]
fn a_landed_toggle_re_reads_the_file_in_both_directions() {
for (name, enable) in [("metrics", true), ("otel", false)] {
let (mut app, sent) = armed_and_sent_dog(name);
assert_eq!(
dog_enabled_in_view(&app, name),
!enable,
"{name}: the fixture starts on the other bit"
);
let reply = if enable {
Response::DogStarted(
ProcessInfo::builder(50, name, ProcStatus::Online)
.pid(Some(50_000))
.dog(Some(DogSource::BuiltIn))
.build(),
)
} else {
Response::Deleted(vec![50])
};
let effect = app.update(Msg::Replied {
sent,
result: Ok(reply),
});
assert_eq!(
effect,
Effect::LoadSettings,
"{name}: a landed toggle has to re-read the file it changed"
);
assert_eq!(
dog_enabled_in_view(&app, name),
!enable,
"{name}: nothing is folded into the row by hand -- the re-read is the repair"
);
let mut fresh = app.settings().unwrap().snapshot().clone();
for dog in &mut fresh.dogs {
if dog.name == name {
dog.enabled = enable;
}
}
app.update(Msg::Settings { result: Ok(fresh) });
assert_eq!(
dog_enabled_in_view(&app, name),
enable,
"{name}: and the row agrees with the file once it lands"
);
}
}
#[test]
fn an_unrecognised_dog_reply_says_so_rather_than_reading_as_success() {
for reply in [Response::Pong, Response::Deleted(vec![1])] {
let (mut app, sent) = armed_and_sent_dog("metrics");
app.update(Msg::Replied {
sent,
result: Ok(reply),
});
assert_eq!(
app.notice().map(ToString::to_string).as_deref(),
Some(
"enable metrics: the shepherd answered something this lookout does not understand"
)
);
assert!(app.notice().unwrap().is_grave());
}
}
#[test]
fn a_dog_reply_that_failed_to_send_says_so_under_the_same_prefix() {
let (mut app, sent) = armed_and_sent_dog("metrics");
app.update(Msg::Replied {
sent,
result: Err(RequestError::Closed),
});
let said = app.notice().map(ToString::to_string).unwrap_or_default();
assert!(said.starts_with("enable metrics: "), "got {said:?}");
assert!(said.contains(&RequestError::Closed.to_string()));
}
}