use std::io::IsTerminal;
use std::time::Duration;
use cli::{Commands, Format};
use commands::query;
use exit::ExitCode;
use output::Streams;
use shep_client::Client;
use shep_core::paths::ShepPaths;
use crate::client::unreachable_message;
use crate::{cli, commands, exit, output};
const RECOVERY_VERBS: [&str; 3] = ["kill", "daemon reload", "ping"];
fn recovery_verb(command: &Commands) -> Option<&'static str> {
match command {
Commands::Kill => Some("kill"),
Commands::Ping => Some("ping"),
Commands::Daemon(args) => match args.cmd {
Some(cli::DaemonCmd::Reload) => Some("daemon reload"),
None => None,
},
_ => None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum VersionGuard {
Enforce,
Exempt,
}
impl VersionGuard {
pub(crate) fn for_command(command: &Commands) -> Self {
match recovery_verb(command) {
Some(_) => Self::Exempt,
None => Self::Enforce,
}
}
}
const VERSION_SKEW_CAUSE: [&str; 2] = [
"`cargo install shep` replaced the binary. It did not restart the",
"shepherd, which is still running the old code.",
];
pub(crate) const VERSION_SKEW_REMEDY: &str = RECOVERY_VERBS[1];
fn version_skew_instruction(fmt: Format) -> String {
match fmt {
Format::Json => format!("Run `shep {VERSION_SKEW_REMEDY}`."),
Format::Table => format!("Run:\n shep {VERSION_SKEW_REMEDY}"),
}
}
pub(crate) fn refuse_version_skew(
streams: &mut Streams<'_>,
client: &Client,
guard: VersionGuard,
) -> Result<(), ExitCode> {
let running = client.daemon().daemon_version.as_str();
if guard == VersionGuard::Exempt || running == env!("CARGO_PKG_VERSION") {
return Ok(());
}
let code = ExitCode::VersionSkew;
let summary = format!(
"this shep is {}, the running shepherd is {running}",
env!("CARGO_PKG_VERSION")
);
match streams.fmt {
Format::Json => {
let cause = VERSION_SKEW_CAUSE.join(" ");
let instruction = version_skew_instruction(Format::Json);
streams.fail(code, &format!("{summary}. {cause} {instruction}"));
}
Format::Table => {
let cause = VERSION_SKEW_CAUSE.join("\n");
let summary = crate::terminal_safe::sanitise(&summary).0;
let instruction = version_skew_instruction(Format::Table);
streams.fail(code, &format!("{summary}\n\n{cause}\n\n{instruction}"));
}
}
Err(code)
}
pub(crate) async fn connect_client(
streams: &mut Streams<'_>,
paths: &ShepPaths,
guard: VersionGuard,
) -> Result<Client, ExitCode> {
match Client::connect(&paths.socket).await {
Ok(client) => {
refuse_version_skew(streams, &client, guard)?;
Ok(client)
}
Err(err) => {
let code = ExitCode::from(&err);
Err(streams.fail(code, &unreachable_message(&err)))
}
}
}
pub(crate) async fn flock_command(
streams: &mut Streams<'_>,
paths: &ShepPaths,
guard: VersionGuard,
args: &cli::FlockArgs,
) -> ExitCode {
if args.follow {
return follow_flock_command(streams, paths, guard, args).await;
}
match Client::connect(&paths.socket).await {
Ok(client) => match refuse_version_skew(streams, &client, guard) {
Ok(()) => query::flock(&client, streams).await,
Err(code) => code,
},
Err(err) if streams.fmt == Format::Json => {
let code = ExitCode::from(&err);
streams.fail(code, &unreachable_message(&err))
}
Err(shep_client::ConnectError::Connect { .. }) => query::flock_from_roll(streams, paths),
Err(err) => {
let code = ExitCode::from(&err);
streams.fail(code, &flock_connect_refusal_message(&err))
}
}
}
async fn follow_flock_command(
streams: &mut Streams<'_>,
paths: &ShepPaths,
guard: VersionGuard,
args: &cli::FlockArgs,
) -> ExitCode {
if streams.fmt == Format::Json {
return streams.fail(
ExitCode::Usage,
"`--follow` redraws a table; `--format json` has no follow form",
);
}
if !std::io::stdout().is_terminal() {
return streams.fail(
ExitCode::Usage,
"`--follow` needs a terminal; stdout is not one",
);
}
match connect_client(streams, paths, guard).await {
Ok(client) => {
query::flock_follow(&client, streams, Duration::from_secs(args.interval)).await
}
Err(code) => code,
}
}
fn flock_connect_refusal_message(err: &shep_client::ConnectError) -> String {
format!("{err}; run `shep {VERSION_SKEW_REMEDY}`")
}
#[cfg(test)]
mod tests {
use std::io::IsTerminal;
use cli::{DaemonArgs, StartArgs};
use super::*;
use crate::style;
fn buffered_streams<'a>(out: &'a mut Vec<u8>, err: &'a mut Vec<u8>) -> Streams<'a> {
Streams {
out,
err,
style: style::Presentation::BARE,
fmt: Format::Table,
}
}
async fn client_announcing(
addr: &std::path::Path,
version: &str,
) -> (Client, shep_client::testing::FakeDaemon) {
let ack = shep_core::protocol::HelloAck {
daemon_version: version.to_owned(),
protocol: shep_core::protocol::PROTOCOL_VERSION,
pid: 4242,
min_supported: None,
};
shep_client::testing::fake_client_with_ack(addr, ack).await
}
#[tokio::test]
async fn a_version_difference_with_no_protocol_difference_is_refused() {
let dir = tempfile::tempdir().unwrap();
let addr = shep_client::testing::control_address(dir.path());
let (client, _fake) = client_announcing(&addr, "0.1.8").await;
let mut out = Vec::new();
let mut err = Vec::new();
let mut streams = buffered_streams(&mut out, &mut err);
let code = refuse_version_skew(&mut streams, &client, VersionGuard::Enforce)
.expect_err("a differing crate version must be refused");
assert_eq!(code, ExitCode::VersionSkew);
}
#[tokio::test]
async fn the_error_names_the_command_that_fixes_it() {
let dir = tempfile::tempdir().unwrap();
let addr = shep_client::testing::control_address(dir.path());
let (client, _fake) = client_announcing(&addr, "0.1.8").await;
let mut out = Vec::new();
let mut err = Vec::new();
{
let mut streams = buffered_streams(&mut out, &mut err);
let _ = refuse_version_skew(&mut streams, &client, VersionGuard::Enforce);
}
let text = String::from_utf8(err).unwrap();
assert!(text.contains("error[version_skew]"), "{text}");
assert!(text.contains("this shep is"), "{text}");
assert!(text.contains(env!("CARGO_PKG_VERSION")), "{text}");
assert!(text.contains("the running shepherd is 0.1.8"), "{text}");
assert!(
text.contains("`cargo install shep` replaced the binary"),
"{text}"
);
assert!(text.contains("shep daemon reload"), "{text}");
}
#[tokio::test]
async fn the_table_form_names_the_remedy_as_an_instruction_not_only_a_line() {
let dir = tempfile::tempdir().unwrap();
let addr = shep_client::testing::control_address(dir.path());
let (client, _fake) = client_announcing(&addr, "0.1.8").await;
let mut out = Vec::new();
let mut err = Vec::new();
{
let mut streams = buffered_streams(&mut out, &mut err);
let _ = refuse_version_skew(&mut streams, &client, VersionGuard::Enforce);
}
let text = String::from_utf8(err).unwrap();
assert!(
text.contains("Run:\n shep daemon reload"),
"the label must sit directly on the copyable line it points at: {text}"
);
assert_eq!(
text.matches("shep daemon reload").count(),
1,
"the remedy is named once, not restated in prose: {text}"
);
}
#[tokio::test]
async fn flock_reports_a_refusal_as_a_refusal_not_as_no_daemon() {
let dir = tempfile::tempdir().unwrap();
let paths = ShepPaths::resolve(&|_| None, dir.path());
std::fs::create_dir_all(&paths.run).unwrap();
let refusal = shep_core::protocol::RpcError {
code: shep_core::protocol::RpcErrorCode::ProtocolMismatch,
message: "this daemon speaks protocol 1, this client speaks 2".to_string(),
daemon_version: Some("0.1.8".to_string()),
};
let _daemon = shep_client::testing::fake_daemon(&paths.socket, Err(refusal)).await;
let mut out = Vec::new();
let mut err = Vec::new();
let code = {
let mut streams = buffered_streams(&mut out, &mut err);
flock_command(&mut streams, &paths, VersionGuard::Enforce, &flock_args()).await
};
assert_ne!(code, ExitCode::Success);
let text = String::from_utf8(err).unwrap();
assert!(
!text.contains("no shepherd running"),
"a refusal is not an absence: {text}"
);
assert!(text.contains("shep daemon reload"), "{text}");
}
#[tokio::test]
async fn flock_json_reports_the_refusal_it_got_not_a_second_connect_failure() {
let dir = tempfile::tempdir().unwrap();
let paths = ShepPaths::resolve(&|_| None, dir.path());
std::fs::create_dir_all(&paths.run).unwrap();
let refusal = shep_core::protocol::RpcError {
code: shep_core::protocol::RpcErrorCode::ProtocolMismatch,
message: "this daemon speaks protocol 1, this client speaks 2".to_string(),
daemon_version: Some("0.1.8".to_string()),
};
let daemon = shep_client::testing::fake_daemon(&paths.socket, Err(refusal)).await;
let mut out = Vec::new();
let mut err = Vec::new();
let code = {
let mut streams = buffered_streams(&mut out, &mut err);
streams.fmt = Format::Json;
flock_command(&mut streams, &paths, VersionGuard::Enforce, &flock_args()).await
};
daemon.await.unwrap();
assert_eq!(code, ExitCode::ProtocolMismatch);
let text = String::from_utf8(err).unwrap();
assert!(text.contains("this daemon speaks protocol 1"), "{text}");
assert!(out.is_empty(), "{}", String::from_utf8_lossy(&out));
}
#[tokio::test]
async fn flock_still_falls_back_to_the_roll_for_a_genuine_absence() {
let dir = tempfile::tempdir().unwrap();
let paths = ShepPaths::resolve(&|_| None, dir.path());
std::fs::create_dir_all(&paths.run).unwrap();
let mut out = Vec::new();
let mut err = Vec::new();
let code = {
let mut streams = buffered_streams(&mut out, &mut err);
flock_command(&mut streams, &paths, VersionGuard::Enforce, &flock_args()).await
};
assert_eq!(code, ExitCode::DaemonUnreachable);
let text = String::from_utf8(err).unwrap();
assert!(text.contains("no shepherd running"), "{text}");
}
#[tokio::test]
async fn follow_refuses_json_and_a_pipe_for_different_reasons() {
let dir = tempfile::tempdir().unwrap();
let paths = ShepPaths::resolve(&|_| None, dir.path());
let args = cli::FlockArgs {
follow: true,
interval: 1,
};
let mut out = Vec::new();
let mut err = Vec::new();
let code = {
let mut streams = buffered_streams(&mut out, &mut err);
streams.fmt = Format::Json;
flock_command(&mut streams, &paths, VersionGuard::Enforce, &args).await
};
assert_eq!(code, ExitCode::Usage);
let text = String::from_utf8(err).unwrap();
assert!(
text.contains("no follow form"),
"the json guard answers before the terminal one: {text}"
);
if std::io::stdout().is_terminal() {
return;
}
let mut out = Vec::new();
let mut err = Vec::new();
let code = {
let mut streams = buffered_streams(&mut out, &mut err);
flock_command(&mut streams, &paths, VersionGuard::Enforce, &args).await
};
assert_eq!(code, ExitCode::Usage);
let text = String::from_utf8(err).unwrap();
assert!(text.contains("needs a terminal"), "{text}");
assert!(
out.is_empty(),
"a refused follow prints no listing: {}",
String::from_utf8_lossy(&out)
);
}
#[tokio::test]
async fn a_matching_version_passes_without_a_word() {
let dir = tempfile::tempdir().unwrap();
let addr = shep_client::testing::control_address(dir.path());
let (client, _fake) = client_announcing(&addr, env!("CARGO_PKG_VERSION")).await;
let mut out = Vec::new();
let mut err = Vec::new();
{
let mut streams = buffered_streams(&mut out, &mut err);
refuse_version_skew(&mut streams, &client, VersionGuard::Enforce)
.expect("a matching version is not a skew");
}
assert!(err.is_empty(), "{}", String::from_utf8_lossy(&err));
}
#[tokio::test]
async fn the_recovery_verbs_are_not_refused() {
let dir = tempfile::tempdir().unwrap();
let addr = shep_client::testing::control_address(dir.path());
let (client, _fake) = client_announcing(&addr, "0.1.8").await;
let mut out = Vec::new();
let mut err = Vec::new();
{
let mut streams = buffered_streams(&mut out, &mut err);
refuse_version_skew(&mut streams, &client, VersionGuard::Exempt)
.expect("a recovery verb is never refused on version skew");
}
assert!(err.is_empty(), "{}", String::from_utf8_lossy(&err));
}
fn add_args() -> StartArgs {
StartArgs {
targets: Vec::new(),
name: None,
fold: None,
cwd: None,
interpreter: None,
flockfile: false,
no_log_timestamps: false,
reset: None,
}
}
fn flock_args() -> cli::FlockArgs {
cli::FlockArgs {
follow: false,
interval: cli::FOLLOW_INTERVAL_FLOOR_SECONDS,
}
}
fn daemon_args(cmd: Option<cli::DaemonCmd>) -> DaemonArgs {
DaemonArgs {
cmd,
no_restore: false,
foreground: false,
log_json: None,
log_level: None,
socket: None,
max_cron_sleep: None,
}
}
#[test]
fn every_exempt_verb_is_one_of_the_documented_recovery_verbs() {
for command in [
Commands::Kill,
Commands::Ping,
Commands::Daemon(daemon_args(Some(cli::DaemonCmd::Reload))),
] {
let verb =
recovery_verb(&command).unwrap_or_else(|| panic!("{command:?} must stay exempt"));
assert!(
RECOVERY_VERBS.contains(&verb),
"{verb} is exempt but undocumented"
);
}
assert_eq!(
recovery_verb(&Commands::Daemon(daemon_args(Some(cli::DaemonCmd::Reload)))),
Some("daemon reload"),
"the verb the skew refusal names must be the verb the skew guard exempts"
);
assert_eq!(recovery_verb(&Commands::Daemon(daemon_args(None))), None);
assert_eq!(
VersionGuard::for_command(&Commands::Daemon(daemon_args(None))),
VersionGuard::Enforce
);
assert_eq!(recovery_verb(&Commands::Flock(flock_args())), None);
assert_eq!(
VersionGuard::for_command(&Commands::Flock(flock_args())),
VersionGuard::Enforce
);
assert_eq!(
VersionGuard::for_command(&Commands::Kill),
VersionGuard::Exempt
);
assert_eq!(
VersionGuard::for_command(&Commands::Add(add_args())),
VersionGuard::Enforce
);
}
}