#![forbid(unsafe_code)]
mod cli;
mod commands;
mod completions;
mod dog;
mod dog_index;
mod exit;
mod fetch;
mod flourish;
mod host;
mod http;
mod launch;
mod lookout;
mod output;
mod secret_readers;
mod serve;
mod shutdown;
mod status;
mod style;
mod terminal_safe;
mod vocabulary;
mod welcome;
mod whistle;
use std::ffi::OsString;
use std::io::{IsTerminal, Write};
use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
use clap::Parser;
use cli::{AdoptArgs, Commands, DaemonArgs, Format, ImportCommand, StartArgs};
use cli::{Cli, GlobalArgs};
use commands::admin;
use commands::bleats;
use commands::daemon::{daemon_exit_code, run_daemon};
use commands::dev;
use commands::dogs;
use commands::import;
use commands::kv;
use commands::lifecycle;
use commands::lifecycle::Load;
use commands::logs;
use commands::muster;
use commands::query;
use commands::runtime;
use commands::schema;
use commands::secret;
use commands::serve::serve as serve_command;
use commands::shep_toml::{ShepToml, ShepTomlError};
use commands::signal;
#[cfg(unix)]
use commands::startup;
use commands::trigger;
use commands::whisper;
use exit::ExitCode;
use launch::launch_daemon;
use output::Streams;
use shep_client::Client;
use shep_client::spawn::{SpawnOutcome, connect_or_spawn};
use shep_core::paths::{ShepPaths, user_home};
use crate::commands::init;
#[must_use]
pub fn main() -> std::process::ExitCode {
run_argv(std::env::args_os().collect())
}
#[must_use]
pub fn main_runtime() -> std::process::ExitCode {
run_argv(alias_argv("runtime", std::env::args_os().collect()))
}
#[must_use]
pub fn main_dev() -> std::process::ExitCode {
run_argv(alias_argv("dev", std::env::args_os().collect()))
}
fn alias_argv(verb: &str, mut argv: Vec<OsString>) -> Vec<OsString> {
let passthrough = matches!(
argv.get(1).and_then(|arg| arg.to_str()),
Some("daemon" | "dog")
);
if !passthrough {
argv.insert(1, OsString::from(verb));
}
argv
}
fn run_argv(argv: Vec<OsString>) -> std::process::ExitCode {
if std::env::var_os("SHEP_TERM_PANIC_PROBE").is_some() {
lookout::term::probe_panic_for_test();
}
let parsed = Cli::try_parse_from(argv.clone());
if let Err(ref err) = parsed
&& let Some(code) = dispatch_adopted_dog(&argv, err)
{
return code;
}
let runtime = match tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
{
Ok(runtime) => runtime,
Err(err) => {
eprintln!("shep: could not start an async runtime: {err}");
return std::process::ExitCode::from(ExitCode::Failure as u8);
}
};
let cli = match parsed {
Ok(cli) => cli,
Err(err) => {
#[cfg(unix)]
if matches!(
err.kind(),
clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::MissingSubcommand
) {
runtime.block_on(print_shepherd_status(&argv));
}
err.exit();
}
};
let (configured, style_source) = resolve_style(&cli.global);
let level = if must_render_bare(std::io::stdout().is_terminal(), cli.global.format) {
style::StyleLevel::Bare
} else {
configured
};
let style = style::Presentation::new(
level,
std::env::var_os("NO_COLOR").as_deref(),
std::env::var_os("TERM").as_deref(),
std::env::var_os("COLORTERM").as_deref(),
output::terminal_width(),
);
std::process::ExitCode::from(
runtime.block_on(run(cli, style, (configured, style_source))) as u8,
)
}
fn dispatch_adopted_dog(argv: &[OsString], err: &clap::Error) -> Option<std::process::ExitCode> {
if err.kind() != clap::error::ErrorKind::InvalidSubcommand {
return None;
}
let name = match err.get(clap::error::ContextKind::InvalidSubcommand) {
Some(clap::error::ContextValue::String(name)) => name.as_str(),
_ => return None,
};
let index = argv.iter().position(|arg| arg.to_str() == Some(name))?;
let global = GlobalArgs {
home: home_before(&argv[1..index]),
format: Format::Table,
quiet: false,
style: None,
};
let paths = resolve_paths(&global).ok()?;
let path = ShepToml::adopted_dog_path_readonly(&paths.daemon_config, name)
.ok()
.flatten()?;
let dog_argv = argv[index + 1..].to_vec();
Some(run_adopted_dog(&path, &paths.home, name, &dog_argv))
}
fn home_before(prefix: &[OsString]) -> Option<PathBuf> {
let mut tokens = prefix.iter();
while let Some(arg) = tokens.next() {
if let Some(value) = home_equals_value(arg) {
return Some(value);
}
if arg == "--home" {
return tokens.next().map(PathBuf::from);
}
}
std::env::var_os("SHEP_HOME").map(PathBuf::from)
}
#[cfg(unix)]
fn home_equals_value(arg: &std::ffi::OsStr) -> Option<PathBuf> {
use std::os::unix::ffi::OsStrExt as _;
let rest = arg.as_bytes().strip_prefix(b"--home=")?;
Some(PathBuf::from(std::ffi::OsStr::from_bytes(rest)))
}
#[cfg(windows)]
fn home_equals_value(arg: &std::ffi::OsStr) -> Option<PathBuf> {
use std::os::windows::ffi::{OsStrExt as _, OsStringExt as _};
let wide: Vec<u16> = arg.encode_wide().collect();
let prefix: Vec<u16> = "--home=".encode_utf16().collect();
let rest = wide.strip_prefix(prefix.as_slice())?;
Some(PathBuf::from(OsString::from_wide(rest)))
}
fn run_adopted_dog(
path: &Path,
home: &Path,
name: &str,
extra_args: &[OsString],
) -> std::process::ExitCode {
let status = std::process::Command::new(path)
.args(extra_args)
.env("SHEP_HOME", home)
.env("SHEP_DOG_NAME", name)
.status();
match status {
Ok(status) => std::process::ExitCode::from(dog_exit_code(status)),
Err(err) => {
eprintln!("shep: could not run adopted dog {}: {err}", path.display());
std::process::ExitCode::from(ExitCode::Failure as u8)
}
}
}
#[cfg(unix)]
fn dog_exit_code(status: std::process::ExitStatus) -> u8 {
use std::os::unix::process::ExitStatusExt as _;
match status.code() {
Some(code) => code as u8,
None => (128 + status.signal().unwrap_or(0)) as u8,
}
}
#[cfg(windows)]
fn dog_exit_code(status: std::process::ExitStatus) -> u8 {
status.code().unwrap_or(1) as u8
}
#[cfg_attr(windows, allow(dead_code))]
async fn print_shepherd_status(argv: &[OsString]) {
if !std::io::stderr().is_terminal() || argv.iter().any(|a| a == "--home") {
return;
}
let global = GlobalArgs {
home: None,
format: Format::Table,
quiet: false,
style: None,
};
let Ok(paths) = resolve_paths(&global) else {
return;
};
let status = status::ShepherdStatus::probe(&paths).await;
let mut err = std::io::stderr();
let _ = writeln!(err, "{}", status::one_line(&status));
}
fn resolve_paths(global: &GlobalArgs) -> Result<ShepPaths, HomeRefusal> {
resolve_paths_in(global, &|key| std::env::var_os(key))
}
fn resolve_paths_in(
global: &GlobalArgs,
var: &dyn Fn(&str) -> Option<OsString>,
) -> Result<ShepPaths, HomeRefusal> {
if let Some(named) = global.home.as_ref() {
require_absolute(HOME_KNOB, named)?;
require_utf8(HOME_KNOB, named)?;
}
let env = |key: &str| match key {
"SHEP_HOME" => global
.home
.as_ref()
.map(|p| p.to_string_lossy().into_owned()),
other => var(other).map(|value| value.to_string_lossy().into_owned()),
};
let home_dir = match (user_home(var), env("SHEP_HOME")) {
(Some(dir), _) => dir,
(None, Some(_)) => PathBuf::new(),
(None, None) => return Err(HomeRefusal::Unresolved),
};
if global.home.is_none() {
require_absolute(HOME_DIR_VAR, &home_dir)?;
require_utf8(HOME_DIR_VAR, &home_dir)?;
}
Ok(ShepPaths::resolve(&env, &home_dir))
}
fn require_utf8(knob: &'static str, candidate: &Path) -> Result<(), HomeRefusal> {
if candidate.to_str().is_some() {
return Ok(());
}
Err(HomeRefusal::NotUtf8 {
knob,
given: candidate.to_path_buf(),
})
}
fn require_absolute(knob: &'static str, candidate: &Path) -> Result<(), HomeRefusal> {
if candidate.is_absolute() {
return Ok(());
}
Err(HomeRefusal::Relative {
knob,
absolute: absolute_form(candidate),
given: candidate.to_path_buf(),
})
}
fn report_home_refusal(fmt: Format, refusal: &HomeRefusal) -> ExitCode {
let code = refusal.code();
emit_error_locked(fmt, code, &refusal.to_string());
code
}
pub(crate) fn absolute_form(candidate: &Path) -> Option<PathBuf> {
std::env::current_dir().ok().map(|cwd| cwd.join(candidate))
}
fn interpreters_from_config(shep_toml: Option<&str>) -> std::collections::BTreeMap<String, String> {
shep_core::config::DaemonConfig::load(shep_toml, &|_| None)
.map(|cfg| cfg.interpreters)
.unwrap_or_default()
}
fn style_from_config(shep_toml: Option<&str>) -> Option<style::StyleLevel> {
shep_core::config::DaemonConfig::load(shep_toml, &|_| None)
.ok()?
.style
.level
.and_then(|raw| style::StyleLevel::parse(&raw))
}
fn resolve_style(global: &GlobalArgs) -> (style::StyleLevel, style::StyleSource) {
let config_text = resolve_paths(global)
.ok()
.and_then(|paths| std::fs::read_to_string(paths.daemon_config).ok());
style::resolve(
global.style,
std::env::var("SHEP_STYLE").ok().as_deref(),
style_from_config(config_text.as_deref()),
)
}
#[cfg_attr(windows, allow(dead_code))]
fn style_write_is_overridden(source: style::StyleSource) -> bool {
matches!(source, style::StyleSource::Flag | style::StyleSource::Env)
}
#[cfg(windows)]
const WINDOWS_NO_SERVICE: &str = "\
shep startup installs a boot-time service, and on Windows that means \
registering with the Service Control Manager -- not yet built (Tier B in \
docs/specs/windows-estimate.md).\n\
the shepherd itself works here: run `shep start` in your own session, or wrap \
`shep runtime` in a service manager such as NSSM or WinSW.";
fn must_render_bare(stdout_is_terminal: bool, fmt: cli::Format) -> bool {
!stdout_is_terminal || fmt == cli::Format::Json
}
#[derive(Debug)]
pub(crate) enum HomeRefusal {
Unresolved,
Relative {
knob: &'static str,
given: PathBuf,
absolute: Option<PathBuf>,
},
NotUtf8 {
knob: &'static str,
given: PathBuf,
},
Missing(PathBuf),
Io {
path: PathBuf,
source: std::io::Error,
},
}
impl core::fmt::Display for HomeRefusal {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::Unresolved => f.write_str(UNRESOLVED_HOME),
Self::Relative {
knob,
given,
absolute,
} => write_relative_refusal(f, knob, given, absolute.as_deref()),
Self::NotUtf8 { knob, given } => write!(
f,
"{knob} must be valid UTF-8, and {given} is not\n \
shep carries the home path into the `{{{{SHEP_HOME}}}}` template, the log \
paths it reports, and the control socket's own name, all of which are text, \
so a byte that is not UTF-8 would be replaced and shep would use a \
directory you did not name",
given = given.display(),
),
Self::Missing(path) => write!(
f,
"no flock at {path}\n\
did you mean to drop {HOME_KNOB}? the default is {DEFAULT_HOME_SPELLING}\n\
to set up a flock there deliberately: {MKDIR_COMMAND} {quoted}",
path = one_line(path),
quoted = shell_quoted(path),
),
Self::Io { path, source } => write!(
f,
"could not create {path}: {source}",
path = one_line(path),
source = crate::terminal_safe::sanitise(&source.to_string()).0,
),
}
}
}
#[cfg(not(windows))]
fn shell_quoted(path: &Path) -> String {
format!("'{}'", one_line(path).replace('\'', r"'\''"))
}
#[cfg(windows)]
fn shell_quoted(path: &Path) -> String {
format!("\"{}\"", one_line(path))
}
fn one_line(path: &Path) -> String {
crate::terminal_safe::sanitise(&path.display().to_string()).0
}
impl core::error::Error for HomeRefusal {
fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
match self {
Self::Unresolved | Self::Relative { .. } | Self::NotUtf8 { .. } | Self::Missing(_) => {
None
}
Self::Io { source, .. } => Some(source),
}
}
}
impl HomeRefusal {
pub(crate) fn code(&self) -> ExitCode {
match self {
Self::Unresolved | Self::Relative { .. } | Self::NotUtf8 { .. } | Self::Missing(_) => {
ExitCode::Usage
}
Self::Io { .. } => ExitCode::Internal,
}
}
}
fn ensure_home(global: &GlobalArgs) -> Result<(ShepPaths, bool), HomeRefusal> {
let paths = resolve_paths(global)?;
ensure_home_at(paths, global.home.is_some())
}
fn ensure_home_at(paths: ShepPaths, explicit: bool) -> Result<(ShepPaths, bool), HomeRefusal> {
if paths.home.is_dir() {
return Ok((paths, false));
}
if explicit {
return Err(HomeRefusal::Missing(paths.home));
}
let built = {
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
std::fs::DirBuilder::new()
.recursive(true)
.mode(shep_daemon::boot::DIR_MODE)
.create(&paths.home)
}
#[cfg(not(unix))]
{
std::fs::DirBuilder::new()
.recursive(true)
.create(&paths.home)
}
};
match built {
Ok(()) => Ok((paths, true)),
Err(source) => Err(HomeRefusal::Io {
path: paths.home,
source,
}),
}
}
fn scaffold_first_run_interpreters(paths: &ShepPaths) {
if let Err(err) = ShepToml::edit(&paths.daemon_config, ShepToml::write_starter_interpreters) {
let mut err_stream = std::io::stderr();
let _ = writeln!(
err_stream,
"could not write a starter interpreter mapping to {}: {err}",
paths.daemon_config.display()
);
}
}
#[cfg(unix)]
pub(crate) fn create_default_home(
streams: &mut Streams<'_>,
paths: ShepPaths,
) -> Result<(), HomeRefusal> {
let (paths, home_is_new) = ensure_home_at(paths, false)?;
if home_is_new {
scaffold_first_run_interpreters(&paths);
welcome::on_first_run(streams, &paths.home, std::io::stderr().is_terminal());
}
Ok(())
}
async fn run(
cli: Cli,
style: style::Presentation,
resolved_style: (style::StyleLevel, style::StyleSource),
) -> ExitCode {
let fmt = cli.global.format;
let guard = VersionGuard::for_command(&cli.command);
match cli.command {
Commands::Completions(ref args) => {
if let Ok(paths) = resolve_paths(&cli.global) {
let shepherd = status::ShepherdStatus::probe(&paths).await;
if std::io::stderr().is_terminal() {
let mut err = std::io::stderr();
let _ = writeln!(err, "{}", status::one_line(&shepherd));
}
}
let mut out = std::io::stdout().lock();
return completions::completions(&mut out, args);
}
Commands::Daemon(ref args) => {
if let Some(cli::DaemonCmd::Reload) = args.cmd {
let paths = match resolve_paths(&cli.global) {
Ok(paths) => paths,
Err(refusal) => return report_home_refusal(fmt, &refusal),
};
let mut out = std::io::stdout();
let mut err = std::io::stderr();
let mut streams = Streams {
out: &mut out,
err: &mut err,
style,
fmt,
};
return commands::daemon::reload(&mut streams, &paths, guard).await;
}
return run_daemon_command(fmt, &cli.global, args).await;
}
Commands::Startup(ref args) => {
#[cfg(windows)]
let _ = args;
let named_home = if cli.global.home.is_some() {
match ensure_home(&cli.global) {
Ok((paths, _)) => Some(paths.home),
Err(refusal) => return report_home_refusal(fmt, &refusal),
}
} else {
None
};
#[cfg(windows)]
let _ = named_home;
let mut out = std::io::stdout().lock();
let mut err = std::io::stderr().lock();
let mut streams = Streams {
out: &mut out,
err: &mut err,
style,
fmt,
};
#[cfg(unix)]
return startup::startup(&mut streams, named_home.as_deref(), args);
#[cfg(windows)]
return streams.fail(ExitCode::Failure, WINDOWS_NO_SERVICE);
}
Commands::Unstartup(ref args) => {
#[cfg(windows)]
let _ = args;
let mut out = std::io::stdout().lock();
let mut err = std::io::stderr().lock();
let mut streams = Streams {
out: &mut out,
err: &mut err,
style,
fmt,
};
#[cfg(unix)]
return startup::unstartup(&mut streams, args);
#[cfg(windows)]
return streams.fail(ExitCode::Failure, WINDOWS_NO_SERVICE);
}
Commands::Schema => {
let mut out = std::io::stdout().lock();
let mut err = std::io::stderr().lock();
let mut streams = Streams {
out: &mut out,
err: &mut err,
style,
fmt,
};
return schema::schema(&mut streams);
}
_ => {}
}
if let Commands::Dev(ref args) = cli.command {
let mut out = std::io::stdout();
let mut err = std::io::stderr();
let mut streams = Streams {
out: &mut out,
err: &mut err,
style,
fmt,
};
return dev::dev(
&mut streams,
cli.global.quiet,
cli.global.home.is_some(),
args,
)
.await;
}
let (paths, home_is_new) = match ensure_home(&cli.global) {
Ok(resolved) => resolved,
Err(refusal) => return report_home_refusal(fmt, &refusal),
};
if home_is_new {
scaffold_first_run_interpreters(&paths);
}
if home_is_new && !matches!(cli.command, Commands::Welcome) {
let mut err = std::io::stderr();
let mut sink = std::io::sink();
let mut streams = Streams {
out: &mut sink,
err: &mut err,
style,
fmt,
};
welcome::on_first_run(&mut streams, &paths.home, std::io::stderr().is_terminal());
}
if let Commands::Dog(ref args) = cli.command {
return dog::run_dog(&args.name, paths).await;
}
if let Commands::Bleats(ref args) = cli.command {
let mut out = std::io::stdout();
let mut err = std::io::stderr();
let mut streams = Streams {
out: &mut out,
err: &mut err,
style,
fmt,
};
return match connect_client(&mut streams, &paths, guard).await {
Ok(client) => bleats::bleats(&client, &mut streams, cli.global.quiet, args).await,
Err(code) => code,
};
}
if let Commands::Lookout(ref args) = cli.command {
let mut out = std::io::stdout();
let mut err = std::io::stderr();
let mut streams = Streams {
out: &mut out,
err: &mut err,
style,
fmt,
};
return lookout::lookout(&mut streams, &paths, args, resolved_style).await;
}
if let Commands::Serve(ref args) = cli.command {
let mut out = std::io::stdout();
let mut err = std::io::stderr();
let mut streams = Streams {
out: &mut out,
err: &mut err,
style,
fmt,
};
return serve_command(&mut streams, &paths, args).await;
}
if let Commands::Runtime(ref args) = cli.command {
let mut out = std::io::stdout();
let mut err = std::io::stderr();
let mut streams = Streams {
out: &mut out,
err: &mut err,
style,
fmt,
};
return runtime::runtime(&mut streams, cli.global.quiet, paths, args).await;
}
if let Commands::Whistle = cli.command {
let mut err = std::io::stderr();
return whistle::whistle(&mut err, fmt, &paths).await;
}
let mut out = std::io::stdout().lock();
let mut err = std::io::stderr().lock();
let mut streams = Streams {
out: &mut out,
err: &mut err,
style,
fmt,
};
match cli.command {
Commands::Welcome => {
let shepherd = status::ShepherdStatus::probe(&paths).await;
let code = welcome::welcome(&mut streams, &paths.home);
if fmt == Format::Table && std::io::stderr().is_terminal() {
let _ = writeln!(streams.err, "{}", status::one_line(&shepherd));
}
code
}
Commands::Style(args) => match args.level {
None => {
let (level, source) = resolve_style(&cli.global);
let message = format!("{level} (from {source})");
streams.note("style", &message);
ExitCode::Success
}
Some(level) => {
#[cfg_attr(windows, allow(clippy::result_large_err))]
if let Err(err) =
ShepToml::try_edit(&paths.daemon_config, |cfg| cfg.set_style_level(level))
{
let code = match err {
ShepTomlError::Io { .. } => ExitCode::Failure,
ShepTomlError::Parse { .. } | ShepTomlError::WrongShape { .. } => {
ExitCode::InvalidConfig
}
};
return streams.fail(code, &err.to_string());
}
let (effective, source) = resolve_style(&cli.global);
let path = paths.daemon_config.display();
let message = if style_write_is_overridden(source) {
format!(
"wrote {level} to {path}, but {source} still governs; \
shep runs at {effective}"
)
} else {
format!("wrote {level} to {path}")
};
streams.note("style", &message);
ExitCode::Success
}
},
Commands::Start(ref args) => {
load_command(&mut streams, &paths, guard, args, Load::Start).await
}
Commands::Add(ref args) => load_command(&mut streams, &paths, guard, args, Load::Add).await,
Commands::Stop(ref args) | Commands::Thatlldo(ref args) => {
match connect_client(&mut streams, &paths, guard).await {
Ok(client) => lifecycle::stop(&client, &mut streams, args).await,
Err(code) => code,
}
}
Commands::Restart(ref args) => match connect_client(&mut streams, &paths, guard).await {
Ok(client) => lifecycle::restart(&client, &mut streams, &paths, args).await,
Err(code) => code,
},
Commands::Reload(ref args) => match connect_client(&mut streams, &paths, guard).await {
Ok(client) => lifecycle::reload(&client, &mut streams, args).await,
Err(code) => code,
},
Commands::Delete(ref args) => match connect_client(&mut streams, &paths, guard).await {
Ok(client) => lifecycle::delete(&client, &mut streams, args).await,
Err(code) => code,
},
Commands::Stock(ref args) => match connect_client(&mut streams, &paths, guard).await {
Ok(client) => lifecycle::stock(&client, &mut streams, args).await,
Err(code) => code,
},
Commands::Trigger(ref args) => match connect_client(&mut streams, &paths, guard).await {
Ok(client) => trigger::trigger(&client, &mut streams, args).await,
Err(code) => code,
},
Commands::Signal(ref args) => match connect_client(&mut streams, &paths, guard).await {
Ok(client) => signal::signal(&client, &mut streams, args).await,
Err(code) => code,
},
Commands::Whisper(ref args) => match connect_client(&mut streams, &paths, guard).await {
Ok(client) => whisper::whisper(&client, &mut streams, args).await,
Err(code) => code,
},
Commands::Flock(ref args) => flock_command(&mut streams, &paths, guard, args).await,
Commands::Dogs(ref args) if args.available => {
query::available_dogs(&mut streams, args).await
}
Commands::Dogs(ref args) => match connect_client(&mut streams, &paths, guard).await {
Ok(client) => query::dogs(&client, &mut streams, args).await,
Err(code) => code,
},
Commands::Enable(ref args) => match &args.exec {
Some(path) => {
dogs::adopt(
&mut streams,
&paths,
&AdoptArgs {
path: path.clone(),
name: Some(args.name.clone()),
},
)
.await
}
None => dogs::enable(&mut streams, &paths, &args.name).await,
},
Commands::Disable(ref args) => dogs::disable(&mut streams, &paths, &args.name).await,
Commands::Adopt(ref args) => dogs::adopt(&mut streams, &paths, args).await,
Commands::Rehome(ref args) => dogs::rehome(&mut streams, &paths, &args.name).await,
Commands::Describe(ref args) => match connect_client(&mut streams, &paths, guard).await {
Ok(client) => query::describe(&client, &mut streams, &paths, args).await,
Err(code) => code,
},
Commands::Fold(ref args) => match connect_client(&mut streams, &paths, guard).await {
Ok(client) => query::fold(&client, &mut streams, &paths, args).await,
Err(code) => code,
},
Commands::Ping => {
let status = status::ShepherdStatus::probe(&paths).await;
status::render_ping(&mut streams, &status)
}
Commands::Save => match connect_client(&mut streams, &paths, guard).await {
Ok(client) => muster::save(&client, &mut streams).await,
Err(code) => code,
},
Commands::Muster => match connect_or_spawn_client(&mut streams, &paths, guard).await {
Ok(client) => muster::muster(&client, &mut streams).await,
Err(code) => code,
},
Commands::Reopen(ref args) => match connect_client(&mut streams, &paths, guard).await {
Ok(client) => logs::reopen(&client, &mut streams, args).await,
Err(code) => code,
},
Commands::Flush(ref args) if args.daemon => logs::flush_daemon(&mut streams, &paths),
Commands::Flush(ref args) => match connect_client(&mut streams, &paths, guard).await {
Ok(client) => logs::flush(&client, &mut streams, args).await,
Err(code) => code,
},
Commands::Barks(ref args) => dogs::barks(&mut streams, &paths, args),
Commands::Set(ref args) => kv::set(&mut streams, &paths, args),
Commands::Get(ref args) => kv::get(&mut streams, &paths, args),
Commands::Unset(ref args) => kv::unset(&mut streams, &paths, args),
Commands::Secret(ref args) => secret::secret(&mut streams, &paths, args),
Commands::Kill => admin::kill(&paths, &mut streams).await,
Commands::Init(ref args) => init::init(&mut streams, args).await,
Commands::Import(ref args) => match &args.command {
ImportCommand::Pm2(args) => import::pm2::import(&mut streams, args),
ImportCommand::Env(args) => match connect_client(&mut streams, &paths, guard).await {
Ok(client) => import::dotenv::import_env(&client, &mut streams, &paths, args).await,
Err(code) => code,
},
},
Commands::Completions(_)
| Commands::Daemon(_)
| Commands::Startup(_)
| Commands::Unstartup(_)
| Commands::Schema
| Commands::Bleats(_)
| Commands::Lookout(_)
| Commands::Whistle
| Commands::Serve(_)
| Commands::Runtime(_)
| Commands::Dev(_)
| Commands::Dog(_) => {
unreachable!("handled above: before the shared $SHEP_HOME gate, or on unlocked handles")
}
}
}
#[cfg(not(windows))]
const UNRESOLVED_HOME: &str = "none of --home, $SHEP_HOME, or $HOME resolves a root directory";
#[cfg(windows)]
const UNRESOLVED_HOME: &str =
"none of --home, %SHEP_HOME%, or %USERPROFILE% resolves a root directory";
#[cfg(not(windows))]
pub(crate) const HOME_KNOB: &str = "--home/$SHEP_HOME";
#[cfg(windows)]
pub(crate) const HOME_KNOB: &str = "--home/%SHEP_HOME%";
#[cfg(not(windows))]
pub(crate) const HOME_DIR_VAR: &str = "$HOME";
#[cfg(windows)]
pub(crate) const HOME_DIR_VAR: &str = "%USERPROFILE%";
#[cfg(not(windows))]
const DEFAULT_HOME_SPELLING: &str = "~/.shep";
#[cfg(windows)]
const DEFAULT_HOME_SPELLING: &str = r"%USERPROFILE%\.shep";
#[cfg(not(windows))]
const MKDIR_COMMAND: &str = "mkdir -p";
#[cfg(windows)]
const MKDIR_COMMAND: &str = "mkdir";
pub(crate) fn write_relative_refusal(
f: &mut core::fmt::Formatter<'_>,
knob: &str,
given: &Path,
absolute: Option<&Path>,
) -> core::fmt::Result {
write!(
f,
"{knob} must be an absolute path, not {given}\n\
a relative home is read against whatever directory shep runs in, so the flock it \
names is reachable from that one directory and nowhere else",
given = one_line(given),
)?;
match absolute {
Some(absolute) => write!(f, "\ndid you mean: {}", one_line(absolute)),
None => Ok(()),
}
}
fn emit_error_locked(fmt: Format, code: ExitCode, message: &str) {
let mut err = std::io::stderr().lock();
let _ = output::emit_error(&mut err, fmt, code.code_str(), message);
}
pub(crate) async fn connect_or_spawn_client(
streams: &mut Streams<'_>,
paths: &ShepPaths,
guard: VersionGuard,
) -> Result<Client, ExitCode> {
let launch_paths = paths.clone();
match connect_or_spawn(&paths.socket, move || launch_daemon(&launch_paths)).await {
Ok(SpawnOutcome::Connected(client) | SpawnOutcome::Spawned(client)) => {
refuse_version_skew(streams, &client, guard)?;
Ok(client)
}
Err(err) => {
let code = ExitCode::from(&err);
Err(streams.fail(code, &err.to_string()))
}
}
}
async fn load_command(
streams: &mut Streams<'_>,
paths: &ShepPaths,
guard: VersionGuard,
args: &StartArgs,
mode: Load,
) -> ExitCode {
let discovered = if args.targets.is_empty() {
std::env::current_dir()
.ok()
.and_then(|cwd| shep_core::config::flockfile::discover(&cwd))
} else {
None
};
if args.targets.is_empty() && discovered.is_none() {
return match mode {
Load::Start => start_bare_shepherd(streams, paths, guard).await,
Load::Add => streams.fail(
ExitCode::Usage,
"no target and no Flockfile in this directory",
),
};
}
let shep_toml_text = std::fs::read_to_string(&paths.daemon_config).ok();
let interpreters = interpreters_from_config(shep_toml_text.as_deref());
let client = match connect_or_spawn_client(streams, paths, guard).await {
Ok(client) => client,
Err(code) => return code,
};
match mode {
Load::Start => {
lifecycle::start(&client, streams, args, discovered.as_deref(), &interpreters).await
}
Load::Add => {
lifecycle::add(&client, streams, args, discovered.as_deref(), &interpreters).await
}
}
}
async fn start_bare_shepherd(
streams: &mut Streams<'_>,
paths: &ShepPaths,
guard: VersionGuard,
) -> ExitCode {
let before = status::ShepherdStatus::probe(paths).await;
if let Some(online) = &before.online {
let message = format!(
"shepherd already up (pid {}). `shep start <target>` adds a sheep.",
online.pid
);
streams.aside("start", &message);
return ExitCode::Success;
}
match connect_or_spawn_client(streams, paths, guard).await {
Ok(client) => {
let restored = client
.request(shep_core::protocol::Request::ListFlock)
.await;
let known = match &restored {
Ok(shep_core::protocol::Response::Flock(procs)) => procs.len(),
_ => 0,
};
let message = if known == 0 {
format!(
"shepherd up, flock at {}. Nothing running yet; \
`shep start <target>` adds a sheep.",
paths.home.display()
)
} else {
format!(
"shepherd up, flock at {}. {known} sheep restored from the roll; \
`shep flock` lists them.",
paths.home.display()
)
};
streams.note("start", &message);
ExitCode::Success
}
Err(code) => code,
}
}
fn unreachable_message(err: &shep_client::ConnectError) -> String {
match err {
shep_client::ConnectError::Connect { path, source }
if source.kind() == std::io::ErrorKind::NotFound =>
{
format!(
"no shepherd is running (no socket at `{}`); \
start one with `shep start <target>`",
path.display()
)
}
other => other.to_string(),
}
}
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 {
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.",
];
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)
}
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)))
}
}
}
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(_) if streams.fmt == Format::Json => {
match connect_client(streams, paths, guard).await {
Ok(client) => query::flock(&client, streams).await,
Err(code) => code,
}
}
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}`")
}
async fn run_daemon_command(fmt: Format, global: &GlobalArgs, args: &DaemonArgs) -> ExitCode {
let paths = match resolve_paths(global) {
Ok(paths) => paths,
Err(refusal) => return report_home_refusal(fmt, &refusal),
};
match run_daemon(paths, args).await {
Ok(()) => ExitCode::Success,
Err(err) => {
let code = daemon_exit_code(&err);
emit_error_locked(fmt, code, &err.to_string());
code
}
}
}
#[cfg(test)]
mod tests {
#[cfg(not(windows))]
#[test]
fn the_mkdir_hint_survives_a_path_a_shell_would_split() {
let refusal = HomeRefusal::Missing(PathBuf::from("/tmp/my shep home"));
let text = refusal.to_string();
assert!(
text.contains("mkdir -p '/tmp/my shep home'"),
"the remedy must name one word: {text}"
);
assert!(text.contains("no flock at /tmp/my shep home"), "{text}");
}
#[test]
fn the_missing_home_refusal_reads_exactly_this() {
let text = HomeRefusal::Missing(PathBuf::from("/srv/api")).to_string();
let expected = if cfg!(windows) {
"no flock at /srv/api\n\
did you mean to drop --home/%SHEP_HOME%? the default is %USERPROFILE%\\.shep\n\
to set up a flock there deliberately: mkdir \"/srv/api\""
} else {
"no flock at /srv/api\n\
did you mean to drop --home/$SHEP_HOME? the default is ~/.shep\n\
to set up a flock there deliberately: mkdir -p '/srv/api'"
};
assert_eq!(text, expected);
}
#[test]
fn a_newline_in_a_missing_home_cannot_forge_a_line() {
let hostile = PathBuf::from("/tmp/forge-a\nnotice[ok]: your flock is fine");
let text = HomeRefusal::Missing(hostile).to_string();
assert_eq!(
text.lines().count(),
3,
"the refusal has three lines of its own: {text:?}"
);
assert!(
!text.lines().any(|line| line.starts_with("notice[")),
"a line was forged: {text:?}"
);
assert!(
text.starts_with("no flock at /tmp/forge-a notice[ok]: your flock is fine\n"),
"the newline must become a space, not vanish: {text:?}"
);
}
#[test]
fn a_newline_in_a_relative_home_cannot_forge_a_line() {
let refusal = HomeRefusal::Relative {
knob: "--home/$SHEP_HOME",
given: PathBuf::from("forge-b\nerror[internal]: shepherd compromised"),
absolute: Some(PathBuf::from(
"/w/forge-b\nerror[internal]: shepherd compromised",
)),
};
let text = refusal.to_string();
assert_eq!(text.lines().count(), 3, "{text:?}");
assert!(
!text.lines().any(|line| line.starts_with("error[")),
"a line was forged: {text:?}"
);
}
#[test]
fn neither_half_of_an_io_refusal_can_forge_a_line() {
let refusal = HomeRefusal::Io {
path: PathBuf::from("/tmp/forge-c\nnotice[ok]: created"),
source: std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"denied\nnotice[ok]: retried and it worked",
),
};
let text = refusal.to_string();
assert_eq!(text.lines().count(), 1, "{text:?}");
assert!(!text.contains('\n'), "{text:?}");
}
#[cfg(not(windows))]
#[test]
fn an_apostrophe_in_a_path_cannot_escape_the_mkdir_hint() {
let refusal = HomeRefusal::Missing(PathBuf::from("/tmp/rin's flock"));
let text = refusal.to_string();
assert!(
text.contains(r"mkdir -p '/tmp/rin'\''s flock'"),
"an embedded quote must close and reopen: {text}"
);
}
#[cfg(windows)]
#[test]
fn the_missing_home_remedy_is_one_a_windows_shell_can_run() {
let spaced = HomeRefusal::Missing(PathBuf::from(r"C:\tmp\my shep home")).to_string();
assert_eq!(
spaced,
"no flock at C:\\tmp\\my shep home\n\
did you mean to drop --home/%SHEP_HOME%? the default is %USERPROFILE%\\.shep\n\
to set up a flock there deliberately: mkdir \"C:\\tmp\\my shep home\""
);
let quoted = HomeRefusal::Missing(PathBuf::from(r"C:\tmp\rin's flock")).to_string();
assert_eq!(
quoted,
"no flock at C:\\tmp\\rin's flock\n\
did you mean to drop --home/%SHEP_HOME%? the default is %USERPROFILE%\\.shep\n\
to set up a flock there deliberately: mkdir \"C:\\tmp\\rin's flock\""
);
}
use super::*;
#[cfg(unix)]
fn paths_at(root: &std::path::Path) -> ShepPaths {
let home = root.join(".shep").to_string_lossy().into_owned();
let env = |key: &str| (key == "SHEP_HOME").then(|| home.clone());
ShepPaths::resolve(&env, std::path::Path::new("/nonexistent"))
}
#[cfg(unix)]
#[test]
fn a_missing_default_home_is_created_and_reported_as_new() {
let root = tempfile::tempdir().unwrap();
let (paths, created) =
ensure_home_at(paths_at(root.path()), false).expect("a default home is created");
assert_eq!(paths.home, root.path().join(".shep"));
assert!(
created,
"the first call must report that it created the home"
);
assert!(
paths.home.is_dir(),
"the home must exist on disk afterwards"
);
let (_, created_again) =
ensure_home_at(paths_at(root.path()), false).expect("second call succeeds");
assert!(
!created_again,
"a home that was already there is not newly created"
);
}
#[cfg(unix)]
#[test]
fn creating_startups_default_home_scaffolds_it_like_the_shared_gate() {
let root = tempfile::tempdir().unwrap();
let paths = paths_at(root.path());
let mut out = Vec::new();
let mut err = Vec::new();
let mut streams = Streams {
out: &mut out,
err: &mut err,
style: style::Presentation::BARE,
fmt: cli::Format::Table,
};
create_default_home(&mut streams, paths.clone()).expect("a default home is created");
assert!(paths.home.is_dir());
let written = std::fs::read_to_string(&paths.daemon_config).unwrap();
assert!(written.contains("[interpreters]"), "{written}");
create_default_home(&mut streams, paths).expect("a home already there is left alone");
}
#[cfg(unix)]
#[test]
fn an_explicitly_named_missing_home_is_refused_and_left_alone() {
let root = tempfile::tempdir().unwrap();
let paths = paths_at(&root.path().join("srv").join("typo"));
let named = paths.home.clone();
let refusal = ensure_home_at(paths, true).expect_err("a named missing home is refused");
assert_eq!(refusal.code(), ExitCode::Usage);
let message = refusal.to_string();
assert!(
message.contains(&named.display().to_string()),
"the refusal must name the path it refused: {message}"
);
assert!(
message.contains("~/.shep"),
"the refusal must point at the default as the way out: {message}"
);
assert!(
!named.exists(),
"a refused path must be left on disk exactly as it was found"
);
}
#[cfg(unix)]
#[test]
fn home_before_reads_a_separate_value_argument() {
let prefix = [OsString::from("--home"), OsString::from("/tmp/x")];
assert_eq!(home_before(&prefix), Some(PathBuf::from("/tmp/x")));
}
#[cfg(unix)]
#[test]
fn home_before_reads_an_equals_form() {
let prefix = [OsString::from("--home=/tmp/y")];
assert_eq!(home_before(&prefix), Some(PathBuf::from("/tmp/y")));
}
#[cfg(unix)]
#[test]
fn home_before_skips_unrelated_tokens_before_finding_home() {
let prefix = [
OsString::from("--format"),
OsString::from("json"),
OsString::from("--home"),
OsString::from("/tmp/z"),
];
assert_eq!(home_before(&prefix), Some(PathBuf::from("/tmp/z")));
}
#[cfg(unix)]
#[test]
fn dog_exit_code_reads_a_normal_exit_status() {
use std::os::unix::process::ExitStatusExt as _;
let status = std::process::ExitStatus::from_raw(7 << 8);
assert_eq!(dog_exit_code(status), 7);
}
#[cfg(unix)]
#[test]
fn dog_exit_code_reads_128_plus_signal_for_a_signalled_status() {
use std::os::unix::process::ExitStatusExt as _;
let status = std::process::ExitStatus::from_raw(9); assert_eq!(dog_exit_code(status), 128 + 9);
}
#[cfg(unix)]
#[test]
fn dispatch_adopted_dog_is_none_for_a_parse_error_that_is_not_invalid_subcommand() {
let argv: Vec<OsString> = ["shep", "adopt"].into_iter().map(OsString::from).collect();
let err = Cli::try_parse_from(&argv).unwrap_err();
assert_ne!(err.kind(), clap::error::ErrorKind::InvalidSubcommand);
assert!(dispatch_adopted_dog(&argv, &err).is_none());
}
#[cfg(unix)]
#[test]
fn dispatch_adopted_dog_is_none_for_a_name_shep_toml_has_never_heard_of() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path().join(".shep");
std::fs::create_dir_all(&home).unwrap();
let argv: Vec<OsString> = ["shep", "--home"]
.into_iter()
.map(OsString::from)
.chain([home.into_os_string(), OsString::from("nosuchdog")])
.collect();
let err = Cli::try_parse_from(&argv).unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::InvalidSubcommand);
assert!(dispatch_adopted_dog(&argv, &err).is_none());
}
#[cfg(unix)]
#[test]
fn dispatch_adopted_dog_creates_nothing_for_a_missing_shep_home() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path().join(".shep");
assert!(!home.exists(), "test setup must start with no $SHEP_HOME");
let argv: Vec<OsString> = ["shep", "--home"]
.into_iter()
.map(OsString::from)
.chain([home.clone().into_os_string(), OsString::from("nosuchdog")])
.collect();
let err = Cli::try_parse_from(&argv).unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::InvalidSubcommand);
assert!(dispatch_adopted_dog(&argv, &err).is_none());
assert!(
!home.exists(),
"a failed dog lookup must never create $SHEP_HOME: {}",
home.display()
);
}
#[cfg(unix)]
#[test]
fn dispatch_adopted_dog_finds_a_dog_shep_toml_really_has() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path().join(".shep");
std::fs::create_dir_all(&home).unwrap();
let script = dir.path().join("mydog.sh");
std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap();
let mut mode = std::fs::metadata(&script).unwrap().permissions();
std::os::unix::fs::PermissionsExt::set_mode(&mut mode, 0o755);
std::fs::set_permissions(&script, mode).unwrap();
ShepToml::edit(&home.join("shep.toml"), |cfg| {
cfg.adopt_dog("mydog", &script);
})
.unwrap();
let argv: Vec<OsString> = ["shep", "--home"]
.into_iter()
.map(OsString::from)
.chain([
home.into_os_string(),
OsString::from("mydog"),
OsString::from("koji"),
])
.collect();
let err = Cli::try_parse_from(&argv).unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::InvalidSubcommand);
assert!(
dispatch_adopted_dog(&argv, &err).is_some(),
"an adopted dog must dispatch instead of falling through to clap's own error"
);
}
#[cfg(unix)]
#[test]
fn a_dog_run_by_name_is_given_the_same_home_and_name_the_shepherd_gives_it() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path().join(".shep");
std::fs::create_dir_all(&home).unwrap();
let seen = dir.path().join("seen");
let script = dir.path().join("mydog.sh");
std::fs::write(
&script,
format!(
"#!/bin/sh\nprintf '%s\\n%s\\n%s\\n' \"$SHEP_HOME\" \"$SHEP_DOG_NAME\" \"$1\" > {}\n",
seen.display()
),
)
.unwrap();
let mut mode = std::fs::metadata(&script).unwrap().permissions();
std::os::unix::fs::PermissionsExt::set_mode(&mut mode, 0o755);
std::fs::set_permissions(&script, mode).unwrap();
run_adopted_dog(&script, &home, "telemetry", &[OsString::from("koji")]);
let seen = std::fs::read_to_string(&seen).unwrap();
assert_eq!(
seen.lines().collect::<Vec<_>>(),
vec![
home.display().to_string().as_str(),
"telemetry",
"koji",
]
);
}
#[cfg(unix)]
#[test]
fn a_created_home_is_owner_only_from_the_moment_it_exists() {
use std::os::unix::fs::PermissionsExt;
let root = tempfile::tempdir().unwrap();
let (paths, _) = ensure_home_at(paths_at(root.path()), false).unwrap();
let mode = std::fs::metadata(&paths.home).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o700, "a fresh $SHEP_HOME must be owner-only");
}
#[test]
fn an_alias_supplies_its_verb() {
let argv = alias_argv(
"runtime",
vec!["shep-runtime".into(), "./Flockfile.toml".into()],
);
assert_eq!(
argv,
vec![
OsString::from("shep-runtime"),
OsString::from("runtime"),
OsString::from("./Flockfile.toml"),
]
);
}
#[test]
fn an_alias_with_no_arguments_still_supplies_its_verb() {
let argv = alias_argv("dev", vec!["shep-dev".into()]);
assert_eq!(
argv,
vec![OsString::from("shep-dev"), OsString::from("dev")]
);
}
#[test]
fn an_alias_passes_the_two_re_exec_verbs_through_untouched() {
for verb in ["daemon", "dog"] {
let argv = alias_argv(
"runtime",
vec!["shep-runtime".into(), verb.into(), "metrics".into()],
);
assert_eq!(
argv[1],
OsString::from(verb),
"{verb} must not be rewritten"
);
assert_eq!(argv.len(), 3, "{verb}: nothing may be inserted");
}
}
#[test]
fn the_pass_through_matches_the_whole_argument_and_not_a_prefix() {
let argv = alias_argv("runtime", vec!["shep-runtime".into(), "dogfood".into()]);
assert_eq!(argv[1], OsString::from("runtime"));
assert_eq!(argv[2], OsString::from("dogfood"));
}
#[test]
fn the_alias_vector_parses_to_the_expected_command() {
use clap::Parser;
use cli::Commands;
let argv = alias_argv(
"dog",
vec!["shep-runtime".into(), "dog".into(), "metrics".into()],
);
let cli = Cli::try_parse_from(argv).expect("the passthrough vector must parse");
assert!(matches!(cli.command, Commands::Dog(_)));
}
#[test]
fn the_runtime_alias_vector_parses_to_the_runtime_command() {
use clap::Parser;
use cli::Commands;
let argv = alias_argv("runtime", vec!["shep-runtime".into(), "--supervise".into()]);
let cli = Cli::try_parse_from(argv).unwrap();
let Commands::Runtime(args) = cli.command else {
panic!("expected runtime")
};
assert!(args.supervise);
}
#[test]
fn a_subcommand_answers_version() {
use clap::Parser;
let err = Cli::try_parse_from(["shep", "dogs", "--version"]).unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::DisplayVersion);
}
#[test]
fn save_parses_to_its_own_command() {
use clap::Parser;
use cli::Commands;
assert!(matches!(
Cli::try_parse_from(["shep", "save"]).unwrap().command,
Commands::Save
));
}
#[test]
fn dogs_parses_to_its_own_command() {
use clap::Parser;
use cli::Commands;
assert!(matches!(
Cli::try_parse_from(["shep", "dogs"]).unwrap().command,
Commands::Dogs(_)
));
}
#[test]
fn dogs_available_parses_with_its_filter() {
use clap::Parser;
use cli::Commands;
let parsed = Cli::try_parse_from(["shep", "dogs", "--available", "spot"])
.unwrap()
.command;
let Commands::Dogs(args) = parsed else {
panic!("expected dogs")
};
assert!(args.available);
assert_eq!(args.filter.as_deref(), Some("spot"));
}
#[test]
fn enable_and_disable_parse_to_their_own_commands_and_require_a_name() {
use clap::Parser;
use cli::Commands;
let enabled = Cli::try_parse_from(["shep", "enable", "metrics"])
.unwrap()
.command;
let Commands::Enable(args) = enabled else {
panic!("expected enable")
};
assert_eq!(args.name, "metrics");
let disabled = Cli::try_parse_from(["shep", "disable", "metrics"])
.unwrap()
.command;
let Commands::Disable(args) = disabled else {
panic!("expected disable")
};
assert_eq!(args.name, "metrics");
assert!(
Cli::try_parse_from(["shep", "enable"]).is_err(),
"`shep enable` with no name must be a usage error"
);
assert!(
Cli::try_parse_from(["shep", "disable"]).is_err(),
"`shep disable` with no name must be a usage error"
);
}
#[test]
fn adopt_and_rehome_parse_to_their_own_commands_and_require_their_arguments() {
use clap::Parser;
use cli::Commands;
let adopted =
Cli::try_parse_from(["shep", "adopt", "/opt/bin/shep-otel", "--name", "otel"])
.unwrap()
.command;
let Commands::Adopt(args) = adopted else {
panic!("expected adopt")
};
assert_eq!(args.path, PathBuf::from("/opt/bin/shep-otel"));
assert_eq!(args.name, Some("otel".to_string()));
let unnamed = Cli::try_parse_from(["shep", "adopt", "/opt/bin/shep-otel"])
.unwrap()
.command;
let Commands::Adopt(args) = unnamed else {
panic!("expected adopt")
};
assert_eq!(args.path, PathBuf::from("/opt/bin/shep-otel"));
assert_eq!(args.name, None);
let rehomed = Cli::try_parse_from(["shep", "rehome", "otel"])
.unwrap()
.command;
let Commands::Rehome(args) = rehomed else {
panic!("expected rehome")
};
assert_eq!(args.name, "otel");
assert!(
Cli::try_parse_from(["shep", "adopt"]).is_err(),
"`shep adopt` with no path must be a usage error"
);
assert!(
Cli::try_parse_from(["shep", "rehome"]).is_err(),
"`shep rehome` with no name must be a usage error"
);
}
#[test]
fn the_hidden_pm2_spelling_reaches_adopt_with_the_arguments_the_right_way_round() {
use clap::Parser;
use cli::Commands;
let parsed = Cli::try_parse_from(["shep", "enable", "--exec", "/opt/bin/d", "otel"])
.unwrap()
.command;
let Commands::Enable(args) = parsed else {
panic!("expected enable")
};
assert_eq!(args.name, "otel");
assert_eq!(args.exec, Some(PathBuf::from("/opt/bin/d")));
let plain = Cli::try_parse_from(["shep", "enable", "metrics"])
.unwrap()
.command;
let Commands::Enable(args) = plain else {
panic!("expected enable")
};
assert_eq!(args.exec, None);
}
#[test]
fn muster_parses_to_its_own_command() {
use clap::Parser;
use cli::Commands;
assert!(matches!(
Cli::try_parse_from(["shep", "muster"]).unwrap().command,
Commands::Muster
));
}
#[test]
fn import_pm2_parses_to_its_own_subcommand() {
use clap::Parser;
use cli::{Commands, ImportCommand};
let cli = Cli::try_parse_from(["shep", "import", "pm2"]).unwrap();
let Commands::Import(args) = cli.command else {
panic!("`shep import pm2` did not reach the import verb");
};
assert!(matches!(args.command, ImportCommand::Pm2(_)));
}
#[test]
fn import_env_parses_its_file_app_and_secret_patterns() {
use clap::Parser;
use cli::{Commands, ImportCommand};
let cli = Cli::try_parse_from([
"shep", "import", "env", "app.env", "--app", "web", "--secret", "DB_*",
])
.unwrap();
let Commands::Import(args) = cli.command else {
panic!("`shep import env` did not reach the import verb");
};
let ImportCommand::Env(args) = args.command else {
panic!("`shep import env` did not reach its own subcommand");
};
assert_eq!(args.file.to_str(), Some("app.env"));
assert_eq!(args.app, "web");
assert_eq!(args.secret, ["DB_*"]);
}
#[test]
fn bare_import_no_longer_parses() {
use clap::Parser;
assert!(
Cli::try_parse_from(["shep", "import"]).is_err(),
"bare `shep import` must name a subcommand"
);
}
#[test]
fn barks_parses_to_its_own_command() {
use clap::Parser;
use cli::Commands;
assert!(matches!(
Cli::try_parse_from(["shep", "barks"]).unwrap().command,
Commands::Barks(_)
));
}
#[test]
fn startup_and_unstartup_parse_to_their_own_commands() {
use clap::Parser;
use cli::Commands;
assert!(matches!(
Cli::try_parse_from(["shep", "startup"]).unwrap().command,
Commands::Startup(_)
));
assert!(matches!(
Cli::try_parse_from(["shep", "unstartup"]).unwrap().command,
Commands::Unstartup(_)
));
let named = Cli::try_parse_from(["shep", "startup", "--user", "deploy"])
.unwrap()
.command;
let Commands::Startup(args) = named else {
panic!("expected startup")
};
assert_eq!(args.user.as_deref(), Some("deploy"));
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[tokio::test]
async fn startup_and_unstartup_reach_their_own_verbs() {
use clap::Parser;
if nix::unistd::geteuid().is_root() {
eprintln!("skipping: as root these verbs really install and remove a system unit");
return;
}
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("never-created");
let missing = missing.to_str().unwrap();
let cli = Cli::try_parse_from(["shep", "--home", missing, "startup"]).unwrap();
assert_eq!(
run(
cli,
style::Presentation::BARE,
(style::StyleLevel::Full, style::StyleSource::Default),
)
.await,
ExitCode::Usage,
"startup must refuse a $SHEP_HOME that is not there"
);
let cli = Cli::try_parse_from(["shep", "--home", missing, "unstartup"]).unwrap();
assert_ne!(
run(
cli,
style::Presentation::BARE,
(style::StyleLevel::Full, style::StyleSource::Default),
)
.await,
ExitCode::Usage,
"unstartup removes a unit and never reads the home a --home names"
);
}
#[test]
fn resurrect_is_a_hidden_alias_for_muster() {
use clap::{CommandFactory, Parser};
use cli::Commands;
assert!(matches!(
Cli::try_parse_from(["shep", "resurrect"]).unwrap().command,
Commands::Muster
));
let cmd = Cli::command();
let muster = cmd.find_subcommand("muster").unwrap();
assert!(
muster.get_visible_aliases().next().is_none(),
"resurrect must stay out of --help"
);
}
#[cfg(not(windows))]
const EXPLICIT_HOME: &str = "/tmp/explicit";
#[cfg(windows)]
const EXPLICIT_HOME: &str = r"C:\tmp\explicit";
#[test]
fn explicit_home_field_resolves_to_the_expected_shep_paths() {
let paths = resolve_paths(&global_with_home(Some(EXPLICIT_HOME))).unwrap();
assert_eq!(paths.home, std::path::Path::new(EXPLICIT_HOME));
#[cfg(unix)]
assert_eq!(
paths.socket,
std::path::Path::new("/tmp/explicit/run/shep.sock")
);
#[cfg(windows)]
assert_eq!(paths.socket, std::path::Path::new(&paths.pipe_name()));
}
fn global_with_home(home: Option<&str>) -> cli::GlobalArgs {
cli::GlobalArgs {
home: home.map(Into::into),
format: cli::Format::Table,
quiet: false,
style: None,
}
}
#[cfg(unix)]
#[test]
fn home_before_keeps_a_non_utf8_equals_value() {
use std::os::unix::ffi::OsStringExt as _;
let arg = OsString::from_vec(b"--home=/tmp/\xff".to_vec());
let found = home_before(&[arg]).expect("the value must survive the parse");
assert_eq!(
found,
non_utf8_tmp_home(),
"the bytes as typed, not a lossy rendering and not a fallback"
);
}
#[cfg(windows)]
#[test]
fn home_before_keeps_a_lone_surrogate_equals_value() {
use std::os::windows::ffi::OsStringExt as _;
let lone = 0xD800_u16;
let mut typed: Vec<u16> = r"--home=C:\tmp\".encode_utf16().collect();
typed.push(lone);
let arg = OsString::from_wide(&typed);
assert!(
arg.to_str().is_none(),
"the fixture must be the case under test, not valid UTF-16"
);
let found = home_before(&[arg]).expect("the value must survive the parse");
let mut want: Vec<u16> = r"C:\tmp\".encode_utf16().collect();
want.push(lone);
assert_eq!(
found,
PathBuf::from(OsString::from_wide(&want)),
"the units as typed, not a lossy rendering and not a fallback"
);
}
#[cfg(unix)]
fn non_utf8_tmp_home() -> std::path::PathBuf {
use std::os::unix::ffi::OsStringExt as _;
std::path::PathBuf::from(OsString::from_vec(b"/tmp/\xff".to_vec()))
}
#[cfg(unix)]
fn non_utf8_home() -> std::path::PathBuf {
use std::os::unix::ffi::OsStringExt as _;
std::path::PathBuf::from(std::ffi::OsString::from_vec(
b"/tmp/shep-\xff-home".to_vec(),
))
}
#[cfg(unix)]
#[test]
fn a_home_that_is_not_utf8_is_refused_before_any_path_is_derived() {
let home = non_utf8_home();
let global = cli::GlobalArgs {
home: Some(home.clone()),
format: cli::Format::Table,
quiet: false,
style: None,
};
let Err(refusal) = resolve_paths(&global) else {
panic!("a home that is not UTF-8 must not resolve a layout");
};
assert!(
matches!(&refusal, HomeRefusal::NotUtf8 { knob, given }
if *knob == HOME_KNOB && given == &home),
"its own refusal, not the relative or unresolved one: {refusal:?}"
);
assert_eq!(refusal.code(), ExitCode::Usage);
let rendered = refusal.to_string();
assert!(
rendered.contains(HOME_KNOB),
"names the spelling to fix: {rendered}"
);
assert!(
rendered.contains("UTF-8"),
"says what is wrong with it: {rendered}"
);
assert!(
!rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
"no em or en dash in copy a user reads: {rendered}"
);
}
#[cfg(unix)]
#[test]
fn a_relative_home_is_refused_as_relative_even_when_it_is_also_not_utf8() {
use std::os::unix::ffi::OsStringExt as _;
let home =
std::path::PathBuf::from(std::ffi::OsString::from_vec(b"rel-\xff-home".to_vec()));
let global = cli::GlobalArgs {
home: Some(home),
format: cli::Format::Table,
quiet: false,
style: None,
};
let Err(refusal) = resolve_paths(&global) else {
panic!("a relative home must not resolve a layout");
};
assert!(
matches!(refusal, HomeRefusal::Relative { .. }),
"the rootless reason wins, since it is the one an operator hits first"
);
}
#[test]
fn a_relative_home_is_refused_before_any_path_is_derived() {
let Err(refusal) = resolve_paths(&global_with_home(Some("rel-home"))) else {
panic!("a relative --home must not resolve a layout");
};
assert!(
matches!(&refusal, HomeRefusal::Relative { knob, given, .. }
if *knob == HOME_KNOB && given == std::path::Path::new("rel-home")),
"a rootless home is its own refusal, not the unresolved one"
);
assert_eq!(
refusal.code(),
ExitCode::Usage,
"a path shep cannot act on is the operator's to fix"
);
let rendered = refusal.to_string();
assert!(
rendered.contains("rel-home"),
"the refusal must quote the path as typed: {rendered}"
);
assert!(
rendered.contains(HOME_KNOB),
"the refusal must name the spelling to fix: {rendered}"
);
let cwd = std::env::current_dir().expect("a current directory");
assert!(
rendered.contains(&cwd.join("rel-home").display().to_string()),
"the remedy must name the absolute form of what was typed: {rendered}"
);
}
#[cfg(windows)]
#[test]
fn a_drive_relative_home_is_refused_on_windows() {
assert!(
matches!(
resolve_paths(&global_with_home(Some(r"\shep"))),
Err(HomeRefusal::Relative { .. })
),
r"`\shep` resolves against whichever drive is current"
);
}
#[cfg(unix)]
#[test]
fn a_home_directory_that_is_not_utf8_is_refused_and_names_its_own_variable() {
use std::os::unix::ffi::OsStringExt as _;
let mangled = |key: &str| {
matches!(key, "HOME" | "USERPROFILE" | "HOMEDRIVE" | "HOMEPATH")
.then(|| OsString::from_vec(b"/home/\xff".to_vec()))
};
let Err(refusal) = resolve_paths_in(&global_with_home(None), &mangled) else {
panic!("a home directory that is not UTF-8 must not resolve a layout");
};
assert!(
matches!(&refusal, HomeRefusal::NotUtf8 { knob, .. } if *knob == HOME_DIR_VAR),
"names the variable that supplied it, not the --home knob: {refusal:?}"
);
assert_eq!(refusal.code(), ExitCode::Usage);
assert!(
refusal.to_string().contains("UTF-8"),
"says what is wrong with it"
);
}
#[test]
fn a_relative_home_directory_is_refused_and_names_its_own_variable() {
let rootless = |key: &str| {
matches!(key, "HOME" | "USERPROFILE" | "HOMEDRIVE" | "HOMEPATH")
.then(|| OsString::from("ada"))
};
let Err(refusal) = resolve_paths_in(&global_with_home(None), &rootless) else {
panic!("a rootless home directory must not resolve a layout");
};
assert!(
matches!(&refusal, HomeRefusal::Relative { knob, given, .. }
if *knob == HOME_DIR_VAR && given == std::path::Path::new("ada")),
"the refusal must carry the home directory as supplied, not the joined `.shep`"
);
let rendered = refusal.to_string();
assert!(
rendered.contains(HOME_DIR_VAR),
"an operator cannot fix `--home` when `--home` is not what said it: {rendered}"
);
assert!(
!rendered.contains(HOME_KNOB),
"naming a knob the operator did not touch sends them to the wrong fix: {rendered}"
);
let cwd = std::env::current_dir().expect("a current directory");
assert!(
rendered.contains(&cwd.join("ada").display().to_string()),
"the remedy must name the absolute form of what was supplied: {rendered}"
);
}
#[test]
fn an_explicit_home_resolves_with_no_home_directory_at_all() {
let nothing = |_: &str| None;
let paths = resolve_paths_in(&global_with_home(Some(EXPLICIT_HOME)), ¬hing)
.expect("--home names a root on its own");
assert_eq!(
paths.home,
std::path::Path::new(EXPLICIT_HOME),
"the empty placeholder must not reach the resolved home"
);
assert!(
!paths.snapshot.starts_with(".shep"),
"a path rooted at the empty placeholder would start with `.shep`: {}",
paths.snapshot.display()
);
}
#[test]
fn an_absolute_home_directory_still_resolves_the_default_home() {
let rooted = |key: &str| (key == "HOME").then(|| OsString::from(EXPLICIT_HOME));
let paths = resolve_paths_in(&global_with_home(None), &rooted)
.expect("an absolute home directory resolves the default home");
assert_eq!(
paths.home,
std::path::Path::new(EXPLICIT_HOME).join(".shep")
);
}
#[test]
fn must_render_bare_is_true_exactly_for_a_piped_stdout_or_a_json_format() {
assert!(
!must_render_bare(true, cli::Format::Table),
"a real terminal asking for a table gets to render one"
);
assert!(
must_render_bare(false, cli::Format::Table),
"piped stdout must render bare even under --format table"
);
assert!(
must_render_bare(true, cli::Format::Json),
"--format json must render bare even at a real terminal"
);
assert!(must_render_bare(false, cli::Format::Json));
}
#[test]
fn style_from_config_reads_the_level_and_is_lenient_about_everything_else() {
assert_eq!(
style_from_config(Some("[style]\nlevel = \"plain\"\n")),
Some(style::StyleLevel::Plain)
);
assert_eq!(style_from_config(None), None, "no file at all");
assert_eq!(style_from_config(Some("")), None, "an empty file");
assert_eq!(
style_from_config(Some("[style")),
None,
"a file that will not parse"
);
assert_eq!(
style_from_config(Some("[daemon]\nlog_level = \"info\"\n")),
None,
"a config with no [style] table at all"
);
assert_eq!(
style_from_config(Some("[style]\nlevel = \"loud\"\n")),
None,
"a level this build does not recognise"
);
}
#[test]
fn style_from_config_trims_the_same_way_shep_style_does() {
for raw in ["full", " full ", "\tfull\n", "FULL", " FuLl "] {
assert_eq!(
style_from_config(Some(&format!("[style]\nlevel = {raw:?}\n"))),
Some(style::StyleLevel::Full),
"shep.toml's own level must accept {raw:?} exactly as \
$SHEP_STYLE would"
);
assert_eq!(
style::resolve(None, Some(raw), None),
(style::StyleLevel::Full, style::StyleSource::Env),
"$SHEP_STYLE must accept {raw:?}"
);
}
}
#[test]
fn resolve_style_reads_the_flag_and_the_real_shep_toml_it_names() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("shep.toml"), "[style]\nlevel = \"plain\"\n").unwrap();
let global = cli::GlobalArgs {
home: Some(dir.path().to_path_buf()),
format: cli::Format::Table,
quiet: false,
style: None,
};
assert_eq!(
resolve_style(&global),
(style::StyleLevel::Plain, style::StyleSource::Config),
"with no flag, shep.toml's own level answers"
);
let global = cli::GlobalArgs {
style: Some(style::StyleLevel::Bare),
..global
};
assert_eq!(
resolve_style(&global),
(style::StyleLevel::Bare, style::StyleSource::Flag),
"the flag wins over the very shep.toml that set plain above"
);
}
#[test]
fn style_write_is_overridden_only_by_flag_or_env() {
assert!(style_write_is_overridden(style::StyleSource::Flag));
assert!(style_write_is_overridden(style::StyleSource::Env));
assert!(!style_write_is_overridden(style::StyleSource::Config));
assert!(!style_write_is_overridden(style::StyleSource::Default));
}
#[cfg(unix)]
#[tokio::test]
async fn style_with_a_level_writes_shep_toml_and_the_config_reads_it_back() {
use clap::Parser;
for (raw, expected) in [
("full", style::StyleLevel::Full),
("plain", style::StyleLevel::Plain),
("bare", style::StyleLevel::Bare),
] {
let dir = tempfile::tempdir().unwrap();
let home = dir.path().to_str().unwrap();
let cli = Cli::try_parse_from(["shep", "--home", home, "style", raw]).unwrap();
assert_eq!(
run(
cli,
style::Presentation::BARE,
(style::StyleLevel::Full, style::StyleSource::Default),
)
.await,
ExitCode::Success,
"style {raw}"
);
let written = std::fs::read_to_string(dir.path().join("shep.toml")).unwrap();
assert_eq!(
style_from_config(Some(&written)),
Some(expected),
"style {raw}"
);
}
}
#[cfg(unix)]
#[tokio::test]
async fn style_with_no_level_reports_and_writes_nothing() {
use clap::Parser;
let dir = tempfile::tempdir().unwrap();
let home = dir.path().to_str().unwrap();
let cli = Cli::try_parse_from(["shep", "--home", home, "style"]).unwrap();
assert_eq!(
run(
cli,
style::Presentation::BARE,
(style::StyleLevel::Full, style::StyleSource::Default),
)
.await,
ExitCode::Success
);
assert!(
!dir.path().join("shep.toml").exists(),
"the no-arg form must not create a shep.toml that was not there"
);
}
#[tokio::test]
async fn completions_never_resolves_paths() {
use clap::Parser;
let argv = ["shep", "completions", "bash"];
let cli = Cli::try_parse_from(argv).unwrap_or_else(|e| panic!("{argv:?} failed: {e}"));
assert_eq!(
run(
cli,
style::Presentation::BARE,
(style::StyleLevel::Full, style::StyleSource::Default),
)
.await,
ExitCode::Success
);
}
#[cfg(unix)]
#[test]
fn an_absent_socket_names_the_next_command_and_other_failures_do_not() {
use std::io::{Error, ErrorKind};
let absent = shep_client::ConnectError::Connect {
path: std::path::PathBuf::from("/root/.shep/run/shep.sock"),
source: Error::from(ErrorKind::NotFound),
};
assert_eq!(
unreachable_message(&absent),
"no shepherd is running (no socket at `/root/.shep/run/shep.sock`); \
start one with `shep start <target>`"
);
let denied = shep_client::ConnectError::Connect {
path: std::path::PathBuf::from("/root/.shep/run/shep.sock"),
source: Error::from(ErrorKind::PermissionDenied),
};
let text = unreachable_message(&denied);
assert!(
text.starts_with("could not connect to `/root/.shep/run/shep.sock`:"),
"a permission failure must keep the library's wording, got {text:?}"
);
assert!(
!text.contains("shep start"),
"a permission failure must not send the operator to `shep start`, got {text:?}"
);
}
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_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,
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
);
}
#[cfg(unix)]
#[tokio::test]
async fn run_dispatches_kill_to_the_socket_free_path() {
use clap::Parser;
let dir = tempfile::tempdir().unwrap();
let home = dir.path().join("home");
let paths = ShepPaths::resolve(
&|key| (key == "SHEP_HOME").then(|| home.to_string_lossy().into_owned()),
std::path::Path::new("/nonexistent"),
);
std::fs::create_dir_all(&paths.pids).unwrap();
std::fs::create_dir_all(&paths.run).unwrap();
let argv = ["shep", "--home", home.to_str().unwrap(), "kill"];
let cli = Cli::try_parse_from(argv).unwrap_or_else(|e| panic!("{argv:?} failed: {e}"));
assert_eq!(
run(
cli,
style::Presentation::BARE,
(style::StyleLevel::Full, style::StyleSource::Default),
)
.await,
ExitCode::DaemonUnreachable,
"`kill` against an unowned home must reach its own socket-free path"
);
}
}