use shep_client::Client;
use shep_core::paths::ShepPaths;
use shep_core::protocol::{ProcessInfo, Request, Response, SelectorSpec};
use shep_core::status::ProcStatus;
use shep_daemon::snapshot::FlockSnapshot;
use crate::cli::{DogsArgs, FoldArgs, Format, SelectorArgs};
use crate::commands::selector::parse_selector;
use crate::dog_index::{self, AvailableDog, DogSourceKind};
use crate::exit::ExitCode;
use crate::flourish;
use crate::output::{
AvailableDogRows, DogRows, Render, RolledSheep, RolledSheepRows, Streams, emit, emit_described,
emit_flock, write_outcome,
};
async fn request_and_render<T, F>(
client: &Client,
streams: &mut Streams<'_>,
command: &str,
body: Request,
extract: F,
) -> ExitCode
where
T: Render,
F: FnOnce(Response) -> Option<T>,
{
match client.request(body).await {
Ok(response) => match extract(response) {
Some(payload) => write_outcome(emit(
&mut *streams.out,
streams.fmt,
command,
payload,
streams.style,
)),
None => {
let message = "the daemon answered with a response this client does not understand";
streams.fail(ExitCode::Internal, message)
}
},
Err(err) => {
let code = ExitCode::from(&err);
streams.fail(code, &err.to_string())
}
}
}
async fn describe_selector(
client: &Client,
streams: &mut Streams<'_>,
command: &str,
selector: SelectorSpec,
) -> ExitCode {
match client.request(Request::Describe { selector }).await {
Ok(Response::Described(procs)) => write_outcome(emit_described(
&mut *streams.out,
streams.fmt,
command,
procs,
streams.style,
)),
Ok(_) => {
let message = "the daemon answered with a response this client does not understand";
streams.fail(ExitCode::Internal, message)
}
Err(err) => {
let code = ExitCode::from(&err);
streams.fail(code, &err.to_string())
}
}
}
pub fn flock_from_roll(streams: &mut Streams<'_>, paths: &ShepPaths) -> ExitCode {
let saved = std::fs::read(&paths.snapshot)
.ok()
.and_then(|bytes| serde_json::from_slice::<FlockSnapshot>(&bytes).ok());
let sheep: Vec<RolledSheep> = saved
.map(|roll| {
roll.apps
.into_iter()
.map(|entry| RolledSheep {
name: entry.app.name.clone(),
instances: entry.instances_running,
status: "stopped",
})
.collect()
})
.unwrap_or_default();
if streams.fmt == Format::Table {
let _ = writeln!(
streams.err,
"no shepherd running. {}",
if sheep.is_empty() {
"nothing in the saved roll either.".to_owned()
} else {
format!(
"{} in the saved roll at {}:",
match sheep.len() {
1 => "1 sheep".to_owned(),
n => format!("{n} sheep"),
},
paths.snapshot.display()
)
}
);
}
let empty = sheep.is_empty();
if !(empty && streams.fmt == Format::Table) {
let _ = emit(
&mut *streams.out,
streams.fmt,
"flock",
RolledSheepRows(sheep),
streams.style,
);
}
if streams.fmt == Format::Table && !empty {
let _ = writeln!(streams.err, "`shep muster` brings them back.");
}
ExitCode::DaemonUnreachable
}
pub async fn flock(client: &Client, streams: &mut Streams<'_>) -> ExitCode {
match client.request(Request::ListFlock).await {
Ok(Response::Flock(procs)) => {
let art = (streams.fmt == Format::Table && streams.style.level.sheep())
.then(|| sheep_flourish(&procs))
.flatten();
if let Some(art) = &art {
let _ = write!(streams.out, "{art}");
}
write_outcome(emit_flock(
&mut *streams.out,
streams.fmt,
"flock",
procs,
streams.style,
))
}
Ok(_) => {
let message = "the daemon answered with a response this client does not understand";
streams.fail(ExitCode::Internal, message)
}
Err(err) => {
let code = ExitCode::from(&err);
streams.fail(code, &err.to_string())
}
}
}
fn sheep_flourish(listing: &[ProcessInfo]) -> Option<String> {
let sheep: Vec<&ProcessInfo> = listing.iter().filter(|p| p.dog.is_none()).collect();
if sheep.is_empty() {
return Some(flourish::empty_flock());
}
sheep
.iter()
.all(|p| p.status == ProcStatus::Stopped)
.then(|| flourish::all_asleep(sheep.len()))
}
pub async fn dogs(client: &Client, streams: &mut Streams<'_>, args: &DogsArgs) -> ExitCode {
let filter = args.filter.as_deref();
request_and_render(
client,
streams,
"dogs",
Request::ListFlock,
|response| match response {
Response::Flock(procs) => Some(DogRows(
procs
.into_iter()
.filter(|p| p.dog.is_some())
.filter(|p| filter.is_none_or(|f| matches_filter(f, &[&p.name])))
.collect(),
)),
_ => None,
},
)
.await
}
fn matches_filter(filter: &str, haystacks: &[&str]) -> bool {
let filter = filter.to_lowercase();
haystacks.iter().any(|h| h.to_lowercase().contains(&filter))
}
pub async fn available_dogs(streams: &mut Streams<'_>, args: &DogsArgs) -> ExitCode {
let url = dog_index::index_url();
let index = match dog_index::fetch_index(&url).await {
Ok(index) => index,
Err(err) => {
let message = format!("reading the dog index from {url}: {err}");
return streams.fail(ExitCode::Failure, &message);
}
};
let (skipped, sanitised) = (index.skipped, index.sanitised);
let filter = args.filter.as_deref();
let matched: Vec<AvailableDog> = index
.dogs
.into_iter()
.filter(|dog| {
filter.is_none_or(|f| matches_filter(f, &[&dog.name, &dog.package, &dog.description]))
})
.collect();
let code = if streams.fmt == Format::Table
&& matched.is_empty()
&& let Some(filter) = filter
{
let _ = writeln!(streams.out, "no dog matches {filter:?}");
ExitCode::Success
} else if streams.fmt == Format::Table
&& let [only] = matched.as_slice()
{
write_outcome(render_detail(&mut *streams.out, only))
} else {
write_outcome(emit(
&mut *streams.out,
streams.fmt,
"dogs",
AvailableDogRows(matched),
streams.style,
))
};
note_index_costs(streams, skipped, sanitised);
code
}
const INDEX_WIDE: &str = ", across the whole index rather than this listing";
fn note_index_costs(streams: &mut Streams<'_>, skipped: usize, sanitised: usize) {
if skipped > 0 {
streams.aside(
"dogs_skipped",
&format!(
"{skipped} entr{} skipped{INDEX_WIDE}",
if skipped == 1 { "y" } else { "ies" }
),
);
}
if sanitised > 0 {
streams.aside(
"dogs_sanitised",
&format!(
"{sanitised} entr{} contained control characters{INDEX_WIDE}",
if sanitised == 1 { "y" } else { "ies" }
),
);
}
}
fn render_detail(out: &mut dyn std::io::Write, dog: &AvailableDog) -> std::io::Result<()> {
writeln!(out, "{} . {} . {}", dog.name, dog.package, dog.category)?;
writeln!(out, "{}", dog.description)?;
writeln!(out, "{} . {}", dog.license, dog.repo)?;
writeln!(out)?;
writeln!(out, "{}", install_line(&dog.source))?;
writeln!(
out,
"{}",
adopt_line(&dog.source, &dog.adopt_as, &dog.package)
)
}
fn install_line(source: &DogSourceKind) -> String {
match source {
DogSourceKind::CargoGit { url } => format!(" $ cargo install --git {url}"),
DogSourceKind::GoInstall { module } => format!(" $ go install {module}@latest"),
DogSourceKind::Manual { instructions } => format!(" {instructions}"),
}
}
fn adopt_line(source: &DogSourceKind, adopt_as: &str, package: &str) -> String {
match source {
DogSourceKind::CargoGit { .. } => {
format!(" $ shep adopt {adopt_as} ~/.cargo/bin/{package}")
}
DogSourceKind::GoInstall { .. } => {
format!(" $ shep adopt {adopt_as} $(go env GOPATH)/bin/{package}")
}
DogSourceKind::Manual { .. } => format!(" $ shep adopt {adopt_as} <path to the binary>"),
}
}
pub async fn describe(client: &Client, streams: &mut Streams<'_>, args: &SelectorArgs) -> ExitCode {
let mut failure: Option<ExitCode> = None;
for raw in &args.selectors {
let selector = match parse_selector(streams, raw) {
Ok(selector) => SelectorSpec::from(&selector),
Err(code) => return code,
};
let code = describe_selector(client, streams, "describe", selector).await;
if code != ExitCode::Success {
failure = failure.or(Some(code));
}
}
failure.unwrap_or(ExitCode::Success)
}
pub async fn fold(client: &Client, streams: &mut Streams<'_>, args: &FoldArgs) -> ExitCode {
describe_selector(
client,
streams,
"fold",
SelectorSpec::Fold(args.name.clone()),
)
.await
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use shep_client::testing::{
fake_client_capturing_envelopes, fake_client_on, fake_client_with_ack, sample_ack,
sample_info,
};
use shep_core::protocol::DogSource;
use super::*;
const RECV_TIMEOUT: Duration = Duration::from_secs(5);
#[tokio::test]
async fn flock_asks_the_daemon_to_list_the_whole_flock() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("s.sock");
let (client, mut envelopes) = fake_client_capturing_envelopes(&path).await;
let mut out = Vec::new();
let mut err = Vec::new();
let mut streams = Streams {
out: &mut out,
err: &mut err,
style: crate::style::Presentation::BARE,
fmt: Format::Table,
};
let _ = flock(&client, &mut streams).await;
let sent = tokio::time::timeout(RECV_TIMEOUT, envelopes.recv())
.await
.expect("flock must reach the wire; it hung instead of sending a request")
.unwrap();
assert_eq!(sent.body, Request::ListFlock);
}
#[tokio::test]
async fn describe_sends_the_parsed_selector_in_its_compiled_form() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("s.sock");
let (client, mut envelopes) = fake_client_capturing_envelopes(&path).await;
for (input, expected) in [
("all", SelectorSpec::All),
("7", SelectorSpec::Id(7)),
("web", SelectorSpec::Name("web".into())),
("/^web-/", SelectorSpec::Regex("^web-".into())),
("fold:api", SelectorSpec::Fold("api".into())),
] {
let mut out = Vec::new();
let mut err = Vec::new();
let mut streams = Streams {
out: &mut out,
err: &mut err,
style: crate::style::Presentation::BARE,
fmt: Format::Table,
};
let args = SelectorArgs {
selectors: vec![input.into()],
};
let _ = describe(&client, &mut streams, &args).await;
let sent = tokio::time::timeout(RECV_TIMEOUT, envelopes.recv())
.await
.unwrap_or_else(|_| {
panic!("describe({input}) must reach the wire; it hung instead of sending a request")
})
.unwrap();
assert_eq!(
sent.body,
Request::Describe { selector: expected },
"{input}"
);
}
}
#[tokio::test]
async fn a_malformed_selector_exits_usage_without_a_round_trip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("s.sock");
let (client, mut envelopes) = fake_client_capturing_envelopes(&path).await;
let mut out = Vec::new();
let mut err = Vec::new();
let code = {
let mut streams = Streams {
out: &mut out,
err: &mut err,
style: crate::style::Presentation::BARE,
fmt: Format::Table,
};
describe(
&client,
&mut streams,
&SelectorArgs {
selectors: vec!["/[/".into()],
},
)
.await
};
assert_eq!(code, ExitCode::Usage);
assert!(
envelopes.try_recv().is_err(),
"a malformed selector must fail locally"
);
}
#[tokio::test]
async fn fold_asks_the_daemon_for_that_fold_and_nothing_wider() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("s.sock");
let (client, mut envelopes) = fake_client_capturing_envelopes(&path).await;
let mut out = Vec::new();
let mut err = Vec::new();
let mut streams = Streams {
out: &mut out,
err: &mut err,
style: crate::style::Presentation::BARE,
fmt: Format::Table,
};
let _ = fold(&client, &mut streams, &FoldArgs { name: "api".into() }).await;
let sent = tokio::time::timeout(RECV_TIMEOUT, envelopes.recv())
.await
.expect("fold must reach the wire; it hung instead of sending a request")
.unwrap();
assert_eq!(
sent.body,
Request::Describe {
selector: SelectorSpec::Fold("api".into())
}
);
}
#[tokio::test]
async fn flock_response_round_trips_into_rendered_flock_rows() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("s.sock");
let (client, daemon) = fake_client_with_ack(&path, sample_ack()).await;
daemon.reply_to_list(vec![sample_info()]);
let mut out = Vec::new();
let mut err = Vec::new();
let code = {
let mut streams = Streams {
out: &mut out,
err: &mut err,
style: crate::style::Presentation::BARE,
fmt: Format::Json,
};
flock(&client, &mut streams).await
};
assert_eq!(code, ExitCode::Success);
let json: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert_eq!(json["command"], "flock");
assert_eq!(json["data"][0]["name"], "web");
}
#[tokio::test]
async fn describe_response_round_trips_into_rendered_flock_rows() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("s.sock");
let (client, daemon) = fake_client_with_ack(&path, sample_ack()).await;
daemon.reply_to_describe(vec![sample_info()]);
let mut out = Vec::new();
let mut err = Vec::new();
let code = {
let mut streams = Streams {
out: &mut out,
err: &mut err,
style: crate::style::Presentation::BARE,
fmt: Format::Json,
};
describe(
&client,
&mut streams,
&SelectorArgs {
selectors: vec!["all".into()],
},
)
.await
};
assert_eq!(code, ExitCode::Success);
let json: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert_eq!(json["command"], "describe");
assert_eq!(json["data"][0]["name"], "web");
}
fn sheep(id: u32, status: ProcStatus) -> ProcessInfo {
ProcessInfo::builder(id, format!("s{id}"), status).build()
}
fn dog(id: u32) -> ProcessInfo {
ProcessInfo::builder(id, format!("d{id}"), ProcStatus::Online)
.dog(Some(DogSource::BuiltIn))
.build()
}
#[test]
fn sheep_flourish_fires_empty_flock_on_a_truly_empty_listing() {
let art = sheep_flourish(&[]).expect("an empty listing must flourish");
assert!(art.contains("no sheep in the flock yet"), "{art}");
}
#[test]
fn sheep_flourish_treats_dogs_only_as_an_empty_flock() {
let art =
sheep_flourish(&[dog(1), dog(2)]).expect("dogs alone must read as an empty flock");
assert!(art.contains("no sheep in the flock yet"), "{art}");
}
#[test]
fn sheep_flourish_fires_all_asleep_when_every_sheep_is_stopped() {
let listing = [
sheep(1, ProcStatus::Stopped),
sheep(2, ProcStatus::Stopped),
dog(3),
];
let art = sheep_flourish(&listing).expect("an all-stopped flock must flourish");
assert!(art.contains("2 in the flock, all asleep"), "{art}");
}
#[test]
fn a_live_dog_does_not_block_all_asleep() {
let listing = [sheep(1, ProcStatus::Stopped), dog(2)];
let art = sheep_flourish(&listing).expect("a live dog must not suppress all_asleep");
assert!(art.contains("1 in the flock, all asleep"), "{art}");
}
#[test]
fn sheep_flourish_is_silent_on_a_mixed_flock() {
let listing = [sheep(1, ProcStatus::Online), sheep(2, ProcStatus::Stopped)];
assert_eq!(
sheep_flourish(&listing),
None,
"a mixed flock is not a flourish moment"
);
}
#[test]
fn stopping_does_not_count_as_asleep() {
let listing = [
sheep(1, ProcStatus::Stopping),
sheep(2, ProcStatus::Stopping),
];
assert_eq!(
sheep_flourish(&listing),
None,
"Stopping is a transient, not rest"
);
}
#[tokio::test]
async fn the_flourish_only_prints_under_table_format_and_a_sheep_drawing_level() {
use crate::style::{Presentation, StyleLevel};
for (fmt, level, expect_art) in [
(Format::Table, StyleLevel::Full, true),
(Format::Json, StyleLevel::Full, false),
(Format::Table, StyleLevel::Plain, false),
(Format::Table, StyleLevel::Bare, false),
] {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("s.sock");
let (client, _daemon) = fake_client_on(&path).await;
let mut out = Vec::new();
let mut err = Vec::new();
let mut streams = Streams {
out: &mut out,
err: &mut err,
style: Presentation::new(level, None, None, None, 80),
fmt,
};
let _ = flock(&client, &mut streams).await;
let printed = String::from_utf8_lossy(&out);
assert_eq!(
printed.contains("no sheep in the flock yet"),
expect_art,
"fmt={fmt:?} level={level:?}: {printed}"
);
}
}
}