use serenity::all::{ButtonStyle, Colour, CreateActionRow, CreateButton, CreateEmbed};
use shep_client::shep_core::{
protocol::{DogSource, Lamb, ProcessInfo},
status::ProcStatus,
values::{MemSize, UpDuration},
};
use crate::{
limits::{self, EMBED_TITLE_LIMIT, FIELD_VALUE_LIMIT},
shepherd::Verb,
};
pub const CUSTOM_ID_LIMIT: usize = 100;
fn verb_str(verb: Verb) -> &'static str {
match verb {
Verb::Start => "start",
Verb::Stop => "stop",
Verb::Restart => "restart",
Verb::Reload => "reload",
Verb::Delete => "delete",
Verb::Flush => "flush",
Verb::Save => "save",
Verb::Reopen => "reopen",
}
}
fn verb_from_str(raw: &str) -> Option<Verb> {
Some(match raw {
"start" => Verb::Start,
"stop" => Verb::Stop,
"restart" => Verb::Restart,
"reload" => Verb::Reload,
"delete" => Verb::Delete,
"flush" => Verb::Flush,
"save" => Verb::Save,
"reopen" => Verb::Reopen,
_ => return None,
})
}
#[must_use]
pub fn custom_id(verb: Verb, id: u32) -> String {
let id = format!("{}:{id}", verb_str(verb));
debug_assert!(id.chars().count() <= CUSTOM_ID_LIMIT, "{id}");
id
}
#[must_use]
pub fn parse_custom_id(raw: &str) -> Option<(Verb, u32)> {
let (verb, id) = raw.split_once(':')?;
let verb = verb_from_str(verb)?;
let id = id.parse().ok()?;
Some((verb, id))
}
fn embed_title(name: &str) -> String {
limits::fit(name, EMBED_TITLE_LIMIT)
}
fn cpu_value(cpu_percent: Option<f32>) -> String {
cpu_percent.map_or_else(|| "unknown".to_owned(), |percent| format!("{percent:.1}%"))
}
fn memory_value(memory_bytes: Option<u64>) -> String {
memory_bytes.map_or_else(
|| "unknown".to_owned(),
|bytes| MemSize::from_bytes(bytes).to_string(),
)
}
fn pid_value(pid: Option<u32>) -> String {
pid.map_or_else(|| "N/A".to_owned(), |pid| pid.to_string())
}
fn lambs_value(lambs: &[Lamb]) -> String {
if lambs.is_empty() {
return "none".to_owned();
}
let joined = lambs
.iter()
.map(|lamb| format!("{} ({})", lamb.name, lamb.pid))
.collect::<Vec<_>>()
.join(", ");
limits::fit(&joined, FIELD_VALUE_LIMIT)
}
fn dog_value(dog: &DogSource) -> String {
match dog {
DogSource::BuiltIn => "built in".to_owned(),
DogSource::Adopted { path } => limits::fit(&format!("adopted: {path}"), FIELD_VALUE_LIMIT),
_ => "unknown".to_owned(),
}
}
fn fields(info: &ProcessInfo) -> Vec<(&'static str, String)> {
let mut fields = vec![
("Status", info.status.to_string()),
(
"Uptime",
UpDuration::from_millis(info.uptime_ms).to_string(),
),
("CPU", cpu_value(info.cpu_percent)),
("Memory", memory_value(info.memory_bytes)),
("Restarts", info.restarts.to_string()),
("PID", pid_value(info.pid)),
("Sheep ID", info.id.to_string()),
];
if let Some(instance) = info.instance {
fields.push(("Instance", instance.to_string()));
}
if let Some(lambs) = &info.lambs {
fields.push(("Lambs", lambs_value(lambs)));
}
if let Some(fold) = &info.fold {
fields.push(("Fold", limits::fit(fold, FIELD_VALUE_LIMIT)));
}
if let Some(smit) = &info.smit {
fields.push(("Smit", limits::fit(smit, FIELD_VALUE_LIMIT)));
}
if let Some(dog) = &info.dog {
fields.push(("Dog", dog_value(dog)));
}
if info.dog_stale == Some(true) {
fields.push(("Dog Stale", "yes".to_owned()));
}
fields
}
pub fn process_embed(info: &ProcessInfo) -> CreateEmbed {
let colour = if info.status == ProcStatus::Online {
Colour::DARK_GREEN
} else {
Colour::RED
};
let mut embed = CreateEmbed::new()
.title(embed_title(&info.name))
.colour(colour);
for (name, value) in fields(info) {
embed = embed.field(name, value, true);
}
embed
}
#[must_use]
pub fn embed_character_count(info: &ProcessInfo) -> usize {
let title = embed_title(&info.name).chars().count();
let drawn: usize = fields(info)
.iter()
.map(|(name, value)| name.chars().count() + value.chars().count())
.sum();
title + drawn
}
pub fn process_buttons(info: &ProcessInfo) -> CreateActionRow {
let button = |verb: Verb, label: &str, style: ButtonStyle| {
CreateButton::new(custom_id(verb, info.id))
.label(label)
.style(style)
};
CreateActionRow::Buttons(vec![
button(Verb::Start, "Start", ButtonStyle::Success),
button(Verb::Stop, "Stop", ButtonStyle::Secondary),
button(Verb::Restart, "Restart", ButtonStyle::Secondary),
button(Verb::Delete, "Delete", ButtonStyle::Danger),
button(Verb::Flush, "Flush", ButtonStyle::Primary),
])
}
#[cfg(test)]
mod tests {
use shep_client::shep_core::protocol::ProcessInfo;
use super::*;
fn info(id: u32, name: &str) -> ProcessInfo {
ProcessInfo::builder(id, name, ProcStatus::Online).build()
}
fn button_count(row: &CreateActionRow) -> usize {
match row {
CreateActionRow::Buttons(buttons) => buttons.len(),
CreateActionRow::SelectMenu(_) | CreateActionRow::InputText(_) => 0,
}
}
#[test]
fn a_custom_id_is_short_whatever_the_sheep_is_called() {
let id = custom_id(Verb::Restart, u32::MAX);
assert!(id.chars().count() <= CUSTOM_ID_LIMIT, "{id}");
assert_eq!(id, "restart:4294967295");
}
#[test]
fn a_custom_id_round_trips() {
for verb in [
Verb::Start,
Verb::Stop,
Verb::Restart,
Verb::Delete,
Verb::Flush,
] {
assert_eq!(parse_custom_id(&custom_id(verb, 7)), Some((verb, 7)));
}
}
#[test]
fn a_custom_id_this_dog_did_not_write_is_refused() {
assert_eq!(parse_custom_id("restart"), None);
assert_eq!(parse_custom_id("restart:web"), None);
assert_eq!(parse_custom_id("explode:1"), None);
}
#[test]
fn the_button_row_holds_exactly_what_an_action_row_allows() {
let row = process_buttons(&info(1, "web"));
assert_eq!(
button_count(&row),
5,
"an action row holds at most 5 buttons and this row draws all 5"
);
}
#[test]
fn the_embed_carries_no_field_shep_cannot_answer() {
let rendered = format!("{:?}", process_embed(&info(1, "web")));
for absent in [
"Exec Mode",
"Namespace",
"Interpreter",
"Max Memory",
"Autorestart",
] {
assert!(
!rendered.contains(absent),
"{absent} has no ProcessInfo source"
);
}
}
#[test]
fn a_sheep_name_past_the_title_limit_is_truncated_not_refused() {
let long_name = "a".repeat(EMBED_TITLE_LIMIT + 50);
let embed = process_embed(&info(1, &long_name));
let json = serde_json::to_value(&embed).expect("json");
let title = json["title"].as_str().expect("title");
assert_eq!(title.chars().count(), EMBED_TITLE_LIMIT);
}
#[test]
fn nothing_printed_for_a_person_carries_a_dash() {
let built_in = ProcessInfo::builder(1, "web", ProcStatus::Online)
.instance(Some(0))
.lambs(Some(vec![Lamb::new(2, "child")]))
.fold(Some("main".to_owned()))
.smit(Some("smit".to_owned()))
.dog(Some(DogSource::BuiltIn))
.dog_stale(Some(true))
.build();
let adopted = ProcessInfo::builder(1, "web", ProcStatus::Online)
.dog(Some(DogSource::Adopted {
path: "/usr/local/bin/watchdog".to_owned(),
}))
.build();
for candidate in [built_in, adopted] {
let embed = process_embed(&candidate);
let json = serde_json::to_value(&embed).expect("json");
let title = json["title"].as_str().expect("title");
crate::test_support::assert_no_dashes(title);
for field in json["fields"].as_array().expect("fields") {
crate::test_support::assert_no_dashes(field["name"].as_str().expect("name"));
crate::test_support::assert_no_dashes(field["value"].as_str().expect("value"));
}
}
}
#[test]
fn dog_stale_only_shows_up_when_true() {
let has_dog_stale_field = |dog_stale: Option<bool>| {
let mut candidate = info(1, "web");
candidate.dog_stale = dog_stale;
let json = serde_json::to_value(process_embed(&candidate)).expect("json");
json["fields"]
.as_array()
.expect("fields")
.iter()
.any(|field| field["name"].as_str() == Some("Dog Stale"))
};
assert!(has_dog_stale_field(Some(true)));
assert!(!has_dog_stale_field(Some(false)));
assert!(!has_dog_stale_field(None));
}
#[test]
fn embed_worst_case_field_arithmetic_stays_under_budget() {
let long_name = "a".repeat(EMBED_TITLE_LIMIT + 50);
let mut worst = info(u32::MAX, &long_name);
worst.status = ProcStatus::WaitingRestart;
worst.pid = Some(u32::MAX);
worst.restarts = u32::MAX;
worst.uptime_ms = u64::MAX;
worst.cpu_percent = Some(f32::MIN);
worst.memory_bytes = Some(u64::MAX);
worst.instance = Some(u32::MAX);
worst.lambs = Some(vec![Lamb::new(u32::MAX, "x".repeat(2_000))]);
worst.fold = Some("f".repeat(2_000));
worst.smit = Some("s".repeat(2_000));
worst.dog = Some(DogSource::Adopted {
path: "p".repeat(2_000),
});
worst.dog_stale = Some(true);
let embed = process_embed(&worst);
let json = serde_json::to_value(&embed).expect("json");
let title_len = json["title"].as_str().expect("title").chars().count();
let field_len: usize = json["fields"]
.as_array()
.expect("fields")
.iter()
.map(|field| {
field["name"].as_str().expect("name").chars().count()
+ field["value"].as_str().expect("value").chars().count()
})
.sum();
for value_field in ["Lambs", "Fold", "Smit", "Dog"] {
let width = json["fields"]
.as_array()
.expect("fields")
.iter()
.find(|field| field["name"].as_str() == Some(value_field))
.and_then(|field| field["value"].as_str())
.expect("field present")
.chars()
.count();
assert!(
width <= FIELD_VALUE_LIMIT,
"{value_field} value is {width} chars, over FIELD_VALUE_LIMIT"
);
}
assert!(
title_len + field_len <= limits::MESSAGE_CHARACTER_BUDGET,
"{} over the message budget",
title_len + field_len
);
}
#[test]
fn embed_character_count_matches_the_built_embeds_own_json() {
let worst = crate::test_support::worst_case_sample(u32::MAX);
let json = serde_json::to_value(process_embed(&worst)).expect("json");
let title_len = json["title"].as_str().expect("title").chars().count();
let field_len: usize = json["fields"]
.as_array()
.expect("fields")
.iter()
.map(|field| {
field["name"].as_str().expect("name").chars().count()
+ field["value"].as_str().expect("value").chars().count()
})
.sum();
assert_eq!(embed_character_count(&worst), title_len + field_len);
}
}