#![forbid(unsafe_code)]
mod cli;
#[cfg(unix)]
mod commands;
mod completions;
#[cfg(unix)]
mod dog;
mod dog_index;
mod exit;
mod fetch;
mod flourish;
mod http;
#[cfg(unix)]
mod launch;
#[cfg(unix)]
mod lookout;
mod output;
mod serve;
#[cfg(unix)]
mod status;
mod style;
mod terminal_safe;
mod vocabulary;
mod welcome;
#[cfg(unix)]
mod whistle;
use std::ffi::OsString;
use std::io::{IsTerminal, Write};
use std::path::PathBuf;
use clap::Parser;
#[cfg(unix)]
use cli::{AdoptArgs, Commands, DaemonArgs, Format};
use cli::{Cli, GlobalArgs};
#[cfg(unix)]
use commands::admin;
#[cfg(unix)]
use commands::bleats;
#[cfg(unix)]
use commands::daemon::{daemon_exit_code, run_daemon};
#[cfg(unix)]
use commands::dev;
#[cfg(unix)]
use commands::dogs;
#[cfg(unix)]
use commands::import;
#[cfg(unix)]
use commands::kv;
#[cfg(unix)]
use commands::lifecycle;
#[cfg(unix)]
use commands::logs;
#[cfg(unix)]
use commands::muster;
#[cfg(unix)]
use commands::query;
#[cfg(unix)]
use commands::runtime;
#[cfg(unix)]
use commands::schema;
#[cfg(unix)]
use commands::serve::serve as serve_command;
#[cfg(unix)]
use commands::shep_toml::{ShepToml, ShepTomlError};
#[cfg(unix)]
use commands::signal;
#[cfg(unix)]
use commands::startup;
#[cfg(unix)]
use commands::trigger;
#[cfg(unix)]
use commands::whisper;
use exit::ExitCode;
#[cfg(unix)]
use launch::launch_daemon;
use output::Streams;
#[cfg(unix)]
use shep_client::Client;
#[cfg(unix)]
use shep_client::spawn::{SpawnOutcome, connect_or_spawn};
use shep_core::paths::ShepPaths;
#[cfg(unix)]
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 {
#[cfg(unix)]
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());
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, _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)) as u8)
}
#[cfg(unix)]
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, ExitCode> {
let env = |key: &str| match key {
"SHEP_HOME" => global
.home
.as_ref()
.map(|p| p.to_string_lossy().into_owned()),
other => std::env::var(other).ok(),
};
let home_dir = match (std::env::var_os("HOME"), env("SHEP_HOME")) {
(Some(dir), _) => PathBuf::from(dir),
(None, Some(_)) => PathBuf::new(),
(None, None) => return Err(ExitCode::Usage),
};
Ok(ShepPaths::resolve(&env, &home_dir))
}
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)
}
fn must_render_bare(stdout_is_terminal: bool, fmt: cli::Format) -> bool {
!stdout_is_terminal || fmt == cli::Format::Json
}
#[cfg(unix)]
#[derive(Debug)]
enum HomeRefusal {
Unresolved,
Missing(PathBuf),
Io {
path: PathBuf,
source: std::io::Error,
},
}
#[cfg(unix)]
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::Missing(path) => write!(
f,
"no flock at {path}\n \
did you mean to drop --home? the default is ~/.shep\n \
to set up a flock there deliberately: mkdir -p {path}",
path = path.display(),
),
Self::Io { path, source } => {
write!(f, "could not create {}: {source}", path.display())
}
}
}
}
#[cfg(unix)]
impl core::error::Error for HomeRefusal {
fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
match self {
Self::Unresolved | Self::Missing(_) => None,
Self::Io { source, .. } => Some(source),
}
}
}
#[cfg(unix)]
impl HomeRefusal {
fn code(&self) -> ExitCode {
match self {
Self::Unresolved | Self::Missing(_) => ExitCode::Usage,
Self::Io { .. } => ExitCode::Internal,
}
}
}
#[cfg(unix)]
fn ensure_home(global: &GlobalArgs) -> Result<(ShepPaths, bool), HomeRefusal> {
let paths = resolve_paths(global).map_err(|_| HomeRefusal::Unresolved)?;
ensure_home_at(paths, global.home.is_some())
}
#[cfg(unix)]
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,
}),
}
}
#[cfg(unix)]
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)]
async fn run(cli: Cli, style: style::Presentation) -> ExitCode {
let fmt = cli.global.format;
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) => return run_daemon_command(fmt, &cli.global, args).await,
Commands::Startup(ref args) => {
let (paths, home_is_new) = match ensure_home(&cli.global) {
Ok(resolved) => resolved,
Err(refusal) => {
let code = refusal.code();
emit_error_locked(fmt, code, &refusal.to_string());
return code;
}
};
if home_is_new {
scaffold_first_run_interpreters(&paths);
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());
}
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 startup::startup(&mut streams, Some(paths.home.as_path()), args);
}
Commands::Unstartup(ref 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,
};
return startup::unstartup(&mut streams, args);
}
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) => {
let code = refusal.code();
emit_error_locked(fmt, code, &refusal.to_string());
return code;
}
};
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).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).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) => {
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) => {
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 start_bare_shepherd(&mut streams, &paths).await;
}
let shep_toml_text = std::fs::read_to_string(&paths.daemon_config).ok();
let interpreters = interpreters_from_config(shep_toml_text.as_deref());
match connect_or_spawn_client(&mut streams, &paths).await {
Ok(client) => {
lifecycle::start(
&client,
&mut streams,
args,
discovered.as_deref(),
&interpreters,
)
.await
}
Err(code) => code,
}
}
Commands::Stop(ref args) | Commands::Thatlldo(ref args) => {
match connect_client(&mut streams, &paths).await {
Ok(client) => lifecycle::stop(&client, &mut streams, args).await,
Err(code) => code,
}
}
Commands::Restart(ref args) => match connect_client(&mut streams, &paths).await {
Ok(client) => lifecycle::restart(&client, &mut streams, args).await,
Err(code) => code,
},
Commands::Reload(ref args) => match connect_client(&mut streams, &paths).await {
Ok(client) => lifecycle::reload(&client, &mut streams, args).await,
Err(code) => code,
},
Commands::Delete(ref args) => match connect_client(&mut streams, &paths).await {
Ok(client) => lifecycle::delete(&client, &mut streams, args).await,
Err(code) => code,
},
Commands::Stock(ref args) => match connect_client(&mut streams, &paths).await {
Ok(client) => lifecycle::stock(&client, &mut streams, args).await,
Err(code) => code,
},
Commands::Trigger(ref args) => match connect_client(&mut streams, &paths).await {
Ok(client) => trigger::trigger(&client, &mut streams, args).await,
Err(code) => code,
},
Commands::Signal(ref args) => match connect_client(&mut streams, &paths).await {
Ok(client) => signal::signal(&client, &mut streams, args).await,
Err(code) => code,
},
Commands::Whisper(ref args) => match connect_client(&mut streams, &paths).await {
Ok(client) => whisper::whisper(&client, &mut streams, args).await,
Err(code) => code,
},
Commands::Flock => match Client::connect(&paths.socket).await {
Ok(client) => query::flock(&client, &mut streams).await,
Err(_) if fmt == Format::Json => match connect_client(&mut streams, &paths).await {
Ok(client) => query::flock(&client, &mut streams).await,
Err(code) => code,
},
Err(_) => query::flock_from_roll(&mut streams, &paths),
},
Commands::Dogs(ref args) if args.available => {
query::available_dogs(&mut streams, args).await
}
Commands::Dogs(ref args) => match connect_client(&mut streams, &paths).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 {
name: args.name.clone(),
path: path.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).await {
Ok(client) => query::describe(&client, &mut streams, args).await,
Err(code) => code,
},
Commands::Fold(ref args) => match connect_client(&mut streams, &paths).await {
Ok(client) => query::fold(&client, &mut streams, 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).await {
Ok(client) => muster::save(&client, &mut streams).await,
Err(code) => code,
},
Commands::Muster => match connect_or_spawn_client(&mut streams, &paths).await {
Ok(client) => muster::muster(&client, &mut streams).await,
Err(code) => code,
},
Commands::Reopen(ref args) => match connect_client(&mut streams, &paths).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).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::Kill => match connect_client(&mut streams, &paths).await {
Ok(client) => admin::kill(client, &mut streams).await,
Err(code) => code,
},
Commands::Init(ref args) => init::init(&mut streams, args).await,
Commands::Import(ref args) => import::import(&mut streams, args),
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(unix)]
const UNRESOLVED_HOME: &str = "none of --home, $SHEP_HOME, or $HOME resolves a root directory";
#[cfg(unix)]
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);
}
#[cfg(unix)]
async fn connect_or_spawn_client(
streams: &mut Streams<'_>,
paths: &ShepPaths,
) -> 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)) => Ok(client),
Err(err) => {
let code = ExitCode::from(&err);
Err(streams.fail(code, &err.to_string()))
}
}
}
#[cfg(unix)]
async fn start_bare_shepherd(streams: &mut Streams<'_>, paths: &ShepPaths) -> 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).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,
}
}
#[cfg(unix)]
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(),
}
}
#[cfg(unix)]
async fn connect_client(streams: &mut Streams<'_>, paths: &ShepPaths) -> Result<Client, ExitCode> {
match Client::connect(&paths.socket).await {
Ok(client) => Ok(client),
Err(err) => {
let code = ExitCode::from(&err);
Err(streams.fail(code, &unreachable_message(&err)))
}
}
}
#[cfg(unix)]
async fn run_daemon_command(fmt: Format, global: &GlobalArgs, args: &DaemonArgs) -> ExitCode {
let paths = match resolve_paths(global) {
Ok(paths) => paths,
Err(code) => {
emit_error_locked(fmt, code, UNRESOLVED_HOME);
return code;
}
};
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(windows)]
async fn run(cli: Cli, style: style::Presentation) -> ExitCode {
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: cli.global.format,
};
streams.fail(ExitCode::Failure, "shep does not yet support Windows")
}
#[cfg(test)]
mod tests {
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 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 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", "otel", "/opt/bin/shep-otel"])
.unwrap()
.command;
let Commands::Adopt(args) = adopted else {
panic!("expected adopt")
};
assert_eq!(args.name, "otel");
assert_eq!(args.path, PathBuf::from("/opt/bin/shep-otel"));
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 neither name nor path must be a usage error"
);
assert!(
Cli::try_parse_from(["shep", "adopt", "otel"]).is_err(),
"`shep adopt otel` 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_parses_to_its_own_command() {
use clap::Parser;
use cli::Commands;
assert!(matches!(
Cli::try_parse_from(["shep", "import"]).unwrap().command,
Commands::Import(_)
));
}
#[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).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).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"
);
}
#[test]
fn explicit_home_field_resolves_to_the_expected_shep_paths() {
let global = cli::GlobalArgs {
home: Some("/tmp/explicit".into()),
format: cli::Format::Table,
quiet: false,
style: None,
};
let paths = resolve_paths(&global).unwrap();
assert_eq!(paths.home, std::path::Path::new("/tmp/explicit"));
assert_eq!(
paths.socket,
std::path::Path::new("/tmp/explicit/run/shep.sock")
);
}
#[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).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).await, ExitCode::Success);
assert!(
!dir.path().join("shep.toml").exists(),
"the no-arg form must not create a shep.toml that was not there"
);
}
#[cfg(unix)]
#[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).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:?}"
);
}
}