use core::fmt;
use std::collections::BTreeMap;
use std::time::{Duration, Instant};
use shep_client::RequestError;
use shep_core::protocol::{
BusEvent, Lamb, ProcessEventKind, ProcessInfo, Request, Response, SelectorSpec,
};
use shep_core::status::ProcStatus;
use super::theme::Palette;
#[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,
FilterChar(char),
FilterBackspace,
FilterApply,
FilterAbandon,
}
#[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,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Effect {
None,
PollNow,
RefreshFeed,
RefreshSelected,
Send(Sent),
Quit,
}
#[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,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Sent {
Lambs {
id: u32,
},
Action {
verb: ActionVerb,
id: u32,
name: String,
},
}
impl Sent {
#[must_use]
pub fn request(&self) -> Request {
match self {
Self::Lambs { id } => Request::Describe {
selector: SelectorSpec::Id(*id),
},
Self::Action { verb, id, .. } => {
let selector = SelectorSpec::Id(*id);
match verb {
ActionVerb::Stop => Request::Stop { selector },
ActionVerb::Restart => Request::Restart { selector },
ActionVerb::Reload => Request::Reload { selector },
}
}
}
}
}
#[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 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,
id: u32,
name: String,
at: Instant,
stage: Stage,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ActionState<'a> {
pub verb: ActionVerb,
pub id: u32,
pub name: &'a str,
pub sent: bool,
}
pub const CONFIRM_EXPIRY: Duration = Duration::from_secs(10);
const LINK_GONE: &str = "the shepherd is gone — nothing left to ask";
#[derive(Debug)]
pub struct App {
flock: BTreeMap<u32, Row>,
selected: Option<u32>,
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>,
}
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,
}
}
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;
}
}
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, id, name } => self.on_action_reply(verb, id, &name, result),
},
Msg::Unsent { sent } => match sent {
Sent::Action { verb, id, name } => {
self.action = None;
self.notice = Some(Notice {
text: format!("{} {name} (id {id}): it was not sent", verb.label()),
grave: true,
});
Effect::None
}
Sent::Lambs { .. } => 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,
id: u32,
name: &str,
result: Result<Response, RequestError>,
) -> Effect {
self.action = None;
let prefix = format!("{} {name} (id {id})", verb.label());
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_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
.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::FilterChar(_)
| KeyPress::FilterBackspace
| KeyPress::FilterApply
| KeyPress::FilterAbandon => Effect::None,
}
}
fn arm(&mut self, verb: ActionVerb) -> Effect {
let refusal = if self.control == Control::ReadOnly {
Some("read-only: actions need --allow-control".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_row().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 row = self.selected_row().expect("checked just above");
self.action = Some(Action {
verb,
id: row.info.id,
name: row.info.name.clone(),
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.flock.contains_key(&action.id) {
self.notice = Some(Notice {
text: format!(
"{} {} (id {}): it is no longer in the flock",
action.verb.label(),
action.name,
action.id
),
grave: true,
});
return Effect::None;
}
let sent = Sent::Action {
verb: action.verb,
id: action.id,
name: action.name.clone(),
};
self.action = Some(Action {
stage: Stage::Sent,
..action
});
Effect::Send(sent)
}
fn forget_missing_target(&mut self) {
let gone = self.action.as_ref().is_some_and(|action| {
action.stage == Stage::Armed && !self.flock.contains_key(&action.id)
});
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 {
match key {
KeyPress::Quit => Effect::Quit,
KeyPress::FilterChar(typed) => {
let mut query = self.filter.clone();
query.push(typed);
self.set_filter(query)
}
KeyPress::FilterBackspace => {
let mut query = self.filter.clone();
query.pop();
self.set_filter(query)
}
KeyPress::FilterApply => {
self.mode = InputMode::Normal;
Effect::None
}
KeyPress::FilterAbandon => {
self.mode = InputMode::Normal;
self.set_filter(String::new())
}
_ => Effect::None,
}
}
fn visible_ids(&self) -> impl Iterator<Item = u32> + '_ {
let needle = self.filter.to_lowercase();
self.flock.iter().filter_map(move |(id, row)| {
let shown = needle.is_empty() || row.info.name.to_lowercase().contains(&needle);
shown.then_some(*id)
})
}
fn visible_len(&self) -> usize {
self.visible_ids().count()
}
fn reseat(&mut self, previous_index: Option<usize>) -> bool {
if self.selected_index().is_some() {
return false;
}
let before = self.selected;
let visible = self.visible_len();
if visible == 0 {
self.selected = None;
return before != self.selected;
}
let index = previous_index.unwrap_or(0).min(visible - 1);
let next = self.visible_ids().nth(index);
self.selected = next;
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_len();
if visible == 0 {
return Effect::None;
}
let next = self.visible_ids().nth(index.min(visible - 1));
if next == self.selected {
return Effect::None;
}
self.selected = next;
if matches!(self.link, Link::Lost { .. }) {
return Effect::None;
}
Effect::RefreshSelected
}
#[must_use]
pub fn rows(&self) -> Vec<&Row> {
self.visible_ids()
.filter_map(|id| self.flock.get(&id))
.collect()
}
#[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<u32> {
self.selected
}
#[must_use]
pub fn selected_index(&self) -> Option<usize> {
let id = self.selected?;
self.visible_ids().position(|key| key == id)
}
#[must_use]
pub fn selected_row(&self) -> Option<&Row> {
self.flock.get(&self.selected?)
}
#[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,
id: action.id,
name: &action.name,
sent: action.stage == Stage::Sent,
})
}
#[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();
}
}
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};
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/rin/.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/rin/.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
}
#[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.id, 2);
assert_eq!(armed.name, "api");
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(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,
id: 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(2, "api", 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(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(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(2), "api, at index 1");
app.update(Msg::Snapshot {
rows: vec![
sheep(1, "web", ProcStatus::Online),
sheep(3, "worker", ProcStatus::Online),
],
at: t0,
});
assert_eq!(app.selected(), Some(3), "the row that took index 1");
app.update(Msg::Key(KeyPress::SelectLast));
assert_eq!(app.selected(), Some(3));
app.update(Msg::Snapshot {
rows: vec![sheep(1, "web", ProcStatus::Online)],
at: t0,
});
assert_eq!(app.selected(), Some(1));
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(2), "but the cursor moved anyway");
assert_eq!(app.update(Msg::Key(KeyPress::SelectLast)), Effect::None);
assert_eq!(app.selected(), Some(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/rin/.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::FilterChar(letter)));
}
assert_eq!(app.rows().len(), 1, "narrowed before Enter");
app.update(Msg::Key(KeyPress::FilterApply));
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::FilterChar('w')));
app.update(Msg::Key(KeyPress::FilterChar('z')));
assert_eq!(app.rows().len(), 0);
app.update(Msg::Key(KeyPress::FilterBackspace));
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::FilterChar('w')));
app.update(Msg::Key(KeyPress::FilterAbandon));
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::FilterChar('w')));
app.update(Msg::Event(BusEvent::DaemonShutdown));
app.update(Msg::Key(KeyPress::FilterChar('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/rin/.shep".to_string(),
t0,
);
app.update(Msg::Snapshot {
rows: vec![
sheep(1, "web", ProcStatus::Online),
sheep(2, "api", ProcStatus::Online),
sheep(3, "web-worker", ProcStatus::Online),
sheep(4, "cron", ProcStatus::Online),
],
at: t0,
});
app.set_filter(query.to_string());
app
}
#[test]
fn a_filter_narrows_the_rows_and_leaves_the_real_size_readable() {
let app = filtered("web");
assert_eq!(app.rows().len(), 2, "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);
}
#[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/rin/.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(1), "the first visible sheep");
app.update(Msg::Key(KeyPress::SelectDown));
assert_eq!(app.selected(), Some(3), "web-worker, skipping api at id 2");
app.update(Msg::Key(KeyPress::SelectDown));
assert_eq!(app.selected(), Some(3), "clamped at the last visible row");
app.update(Msg::Key(KeyPress::SelectUp));
assert_eq!(app.selected(), Some(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(3), "web-worker, not cron at id 4");
}
#[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(4), "cron, position 3 of 4");
app.set_filter("web".to_string());
assert_eq!(
app.selected(),
Some(3),
"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, "web", ProcStatus::Online),
sheep(2, "api", ProcStatus::Online),
sheep(3, "web-worker", ProcStatus::Online),
sheep(4, "cron", 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(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,
id: 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,
id: 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,
id: 2,
name: "api".to_string(),
},
result: Err(RequestError::Rpc(RpcError {
code: RpcErrorCode::NotFound,
message: "selector matched no registered sheep".to_string(),
})),
});
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,
id: 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,
id: 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));
}
}
}