use core::fmt;
use std::path::Path;
use std::collections::BTreeSet;
use globset::Glob;
use crate::config::{
AppConfig, CronParseError, CronSchedule, KillSignal, ProbeConfig, ProbeTarget,
};
use crate::values::UpDuration;
const MIN_LIVENESS_INTERVAL: UpDuration = UpDuration::from_millis(1_000);
const MIN_READINESS_INTERVAL: UpDuration = UpDuration::from_millis(1);
const MAX_ACTION_TIMEOUT: UpDuration = UpDuration::from_millis(58_000);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedApp {
config: AppConfig,
}
impl ResolvedApp {
#[must_use]
pub fn config(&self) -> &AppConfig {
&self.config
}
#[must_use]
pub fn into_config(self) -> AppConfig {
self.config
}
}
fn expand_tilde(
value: &str,
home: Option<&Path>,
name: &str,
field: &'static str,
) -> Result<String, NormalizeError> {
expand_home_tilde(value, home).map_err(|err| match err {
TildeError::OtherUser => NormalizeError::TildeUser {
name: name.to_string(),
field,
value: value.to_string(),
},
TildeError::NoHome => NormalizeError::NoHomeForTilde {
name: name.to_string(),
field,
},
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TildeError {
OtherUser,
NoHome,
}
impl fmt::Display for TildeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::OtherUser => write!(
f,
"shep expands only `~/` (your own home); another user's home needs a \
passwd lookup whose answer depends on who the process runs as"
),
Self::NoHome => write!(f, "begins with `~/` but no home directory could be found"),
}
}
}
impl core::error::Error for TildeError {}
pub fn expand_home_tilde(value: &str, home: Option<&Path>) -> Result<String, TildeError> {
let Some(rest) = value.strip_prefix('~') else {
return Ok(value.to_string());
};
if !(rest.is_empty() || rest.starts_with('/')) {
return Err(TildeError::OtherUser);
}
let Some(home) = home else {
return Err(TildeError::NoHome);
};
let joined = home.join(rest.trim_start_matches('/'));
Ok(joined.to_string_lossy().into_owned())
}
#[cfg(test)]
const PATH_FIELDS: &[&str] = &["script", "cwd", "out_file", "err_file"];
fn expand_paths(app: &mut AppConfig, home: Option<&Path>) -> Result<(), NormalizeError> {
let name = app.name.clone();
app.script = expand_tilde(&app.script, home, &name, "script")?;
for (field, slot) in [
("cwd", &mut app.cwd),
("out_file", &mut app.out_file),
("err_file", &mut app.err_file),
] {
if let Some(value) = slot {
*slot = Some(expand_tilde(value, home, &name, field)?);
}
}
Ok(())
}
pub fn normalize(app: AppConfig) -> Result<ResolvedApp, NormalizeError> {
normalize_with_home(app, std::env::home_dir().as_deref())
}
pub fn normalize_with_home(
mut app: AppConfig,
home: Option<&Path>,
) -> Result<ResolvedApp, NormalizeError> {
if app.name.is_empty() {
return Err(NormalizeError::MissingName);
}
if app.name.contains(['/', '\\']) || app.name == "." || app.name == ".." {
return Err(NormalizeError::InvalidName(app.name));
}
if app.script.is_empty() {
return Err(NormalizeError::MissingScript);
}
expand_paths(&mut app, home)?;
if app.instances == 0 {
return Err(NormalizeError::ZeroInstances);
}
if let Some(pattern) = &app.cron_restart {
CronSchedule::parse(pattern, app.cron_timezone.as_deref()).map_err(|e| match e {
CronParseError::Pattern { pattern, reason } => {
NormalizeError::InvalidCron { pattern, reason }
}
CronParseError::Timezone { name } => NormalizeError::InvalidTimezone { name },
})?;
} else if let Some(tz_name) = &app.cron_timezone {
crate::config::cron::parse_timezone_name(tz_name).ok_or_else(|| {
NormalizeError::InvalidTimezone {
name: tz_name.clone(),
}
})?;
}
validate_probe(
app.readiness_probe.as_ref(),
"readiness_probe",
MIN_READINESS_INTERVAL,
)?;
validate_probe(
app.liveness_probe.as_ref(),
"liveness_probe",
MIN_LIVENESS_INTERVAL,
)?;
if app.max_memory.is_some_and(|limit| limit.bytes() == 0) {
return Err(NormalizeError::ZeroMaxMemory { name: app.name });
}
if let Some(name) = &app.kill_signal
&& KillSignal::parse(name).is_none()
{
return Err(NormalizeError::InvalidKillSignal {
name: app.name,
value: name.clone(),
});
}
if app.action_timeout > MAX_ACTION_TIMEOUT {
return Err(NormalizeError::ActionTimeoutTooLong {
name: app.name,
value: app.action_timeout,
max: MAX_ACTION_TIMEOUT,
});
}
if app.watch && app.cwd.is_none() {
return Err(NormalizeError::WatchWithoutCwd { name: app.name });
}
if app.watch_delay == Some(UpDuration::from_millis(0)) {
return Err(NormalizeError::ZeroWatchDelay { name: app.name });
}
validate_watch_globs(&app.name, "watch_options", &app.watch_options)?;
validate_watch_globs(&app.name, "ignore_watch", &app.ignore_watch)?;
Ok(ResolvedApp { config: app })
}
fn validate_watch_globs(
name: &str,
field: &'static str,
patterns: &[String],
) -> Result<(), NormalizeError> {
for pattern in patterns {
Glob::new(pattern).map_err(|err| NormalizeError::InvalidWatchGlob {
name: name.to_string(),
field,
pattern: pattern.clone(),
reason: err.to_string(),
})?;
}
Ok(())
}
fn validate_probe(
probe: Option<&ProbeConfig>,
name: &'static str,
min_interval: UpDuration,
) -> Result<(), NormalizeError> {
let Some(probe) = probe else {
return Ok(());
};
ProbeTarget::parse(probe).map_err(|reason| NormalizeError::InvalidProbe {
probe: name,
reason: reason.to_string(),
})?;
if probe.failure_threshold == 0 {
return Err(NormalizeError::ZeroFailureThreshold { probe: name });
}
if probe.interval < min_interval {
return Err(NormalizeError::IntervalBelowMinimum {
probe: name,
value: probe.interval,
min: min_interval,
});
}
Ok(())
}
pub fn normalize_all(apps: Vec<AppConfig>) -> Result<Vec<ResolvedApp>, NormalizeError> {
let mut seen = BTreeSet::new();
apps.into_iter()
.map(|app| {
if !seen.insert(app.name.clone()) {
return Err(NormalizeError::DuplicateName(app.name));
}
normalize(app)
})
.collect()
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NormalizeError {
MissingName,
InvalidName(String),
MissingScript,
ZeroInstances,
InvalidCron {
pattern: String,
reason: String,
},
InvalidTimezone {
name: String,
},
DuplicateName(String),
InvalidProbe {
probe: &'static str,
reason: String,
},
ZeroFailureThreshold {
probe: &'static str,
},
IntervalBelowMinimum {
probe: &'static str,
value: UpDuration,
min: UpDuration,
},
ZeroMaxMemory {
name: String,
},
ActionTimeoutTooLong {
name: String,
value: UpDuration,
max: UpDuration,
},
InvalidKillSignal {
name: String,
value: String,
},
WatchWithoutCwd {
name: String,
},
TildeUser {
name: String,
field: &'static str,
value: String,
},
NoHomeForTilde {
name: String,
field: &'static str,
},
ZeroWatchDelay {
name: String,
},
InvalidWatchGlob {
name: String,
field: &'static str,
pattern: String,
reason: String,
},
}
impl fmt::Display for NormalizeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingName => f.write_str("app config is missing a name"),
Self::InvalidName(n) => {
write!(
f,
"sheep name `{n}` may not contain a path separator or be `.` or `..`"
)
}
Self::MissingScript => f.write_str("app config is missing a script"),
Self::ZeroInstances => f.write_str("instances must be at least 1"),
Self::InvalidCron { pattern, reason } => {
write!(f, "invalid cron_restart pattern `{pattern}`: {reason}")
}
Self::InvalidTimezone { name } => {
write!(f, "`{name}` is not a recognized IANA timezone")
}
Self::DuplicateName(n) => write!(f, "duplicate sheep name `{n}`"),
Self::InvalidProbe { probe, reason } => write!(f, "{probe}: {reason}"),
Self::ZeroFailureThreshold { probe } => {
write!(f, "{probe}.failure_threshold must be at least 1")
}
Self::IntervalBelowMinimum { probe, value, min } => {
write!(f, "{probe}.interval is `{value}`: must be at least {min}")
}
Self::ZeroMaxMemory { name } => {
write!(
f,
"sheep `{name}` has max_memory = 0, a limit nothing can stay under"
)
}
Self::ActionTimeoutTooLong { name, value, max } => {
write!(
f,
"sheep `{name}` has action_timeout = {value}: must be at most {max}, \
the longest wait any caller's deadline could ever cover"
)
}
Self::InvalidKillSignal { name, value } => {
write!(
f,
"`{name}`: kill_signal `{value}` is not one shep can send (accepted: {})",
KillSignal::ACCEPTED.join(", ")
)
}
Self::TildeUser { name, field, value } => write!(
f,
"`{name}`: {field} is `{value}`, and shep expands only `~/` (your own home). \
Another user's home needs a passwd lookup whose answer depends on who the \
daemon runs as, so write the path out in full instead."
),
Self::NoHomeForTilde { name, field } => write!(
f,
"`{name}`: {field} begins with `~/` but no home directory could be found. \
Set $HOME, or write the path out in full."
),
Self::WatchWithoutCwd { name } => {
write!(f, "sheep `{name}` has watch = true but no cwd to watch")
}
Self::ZeroWatchDelay { name } => {
write!(
f,
"sheep `{name}` has watch_delay = 0: must be greater than 0"
)
}
Self::InvalidWatchGlob {
name,
field,
pattern,
reason,
} => write!(
f,
"sheep `{name}` has an invalid {field} pattern `{pattern}`: {reason}"
),
}
}
}
impl core::error::Error for NormalizeError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_path_field_expands_a_leading_tilde() {
let home = Path::new("/home/ada");
let mut app = AppConfig::minimal("web", "~/app/server.js");
app.cwd = Some("~/app".to_string());
app.out_file = Some("~/logs/out.log".to_string());
app.err_file = Some("~/logs/err.log".to_string());
let resolved = normalize_with_home(app, Some(home)).expect("all four expand");
let c = resolved.config();
let expect = |rest: &str| home.join(rest).to_string_lossy().into_owned();
assert_eq!(c.script, expect("app/server.js"));
assert_eq!(c.cwd.as_deref(), Some(expect("app").as_str()));
assert_eq!(c.out_file.as_deref(), Some(expect("logs/out.log").as_str()));
assert_eq!(c.err_file.as_deref(), Some(expect("logs/err.log").as_str()));
}
#[test]
fn the_path_field_list_matches_what_expand_paths_walks() {
let home = Path::new("/home/ada");
let mut app = AppConfig::minimal("web", "~/s");
app.cwd = Some("~/c".to_string());
app.out_file = Some("~/o".to_string());
app.err_file = Some("~/e".to_string());
let resolved = normalize_with_home(app, Some(home)).expect("expands");
let c = resolved.config();
let expanded = [
("script", Some(c.script.as_str())),
("cwd", c.cwd.as_deref()),
("out_file", c.out_file.as_deref()),
("err_file", c.err_file.as_deref()),
];
assert_eq!(
expanded.len(),
PATH_FIELDS.len(),
"PATH_FIELDS and this test must name the same set"
);
for (field, value) in expanded {
assert!(
PATH_FIELDS.contains(&field),
"`{field}` is not in PATH_FIELDS"
);
assert!(
value.is_some_and(|v| v.starts_with("/home/ada")),
"`{field}` was not expanded: {value:?}"
);
}
}
#[test]
fn a_path_without_a_tilde_is_left_exactly_as_written() {
let app = AppConfig::minimal("web", "./server.js");
let resolved =
normalize_with_home(app, Some(Path::new("/home/ada"))).expect("no tilde, no change");
assert_eq!(resolved.config().script, "./server.js");
}
#[test]
fn another_users_home_is_refused_rather_than_resolved() {
let app = AppConfig::minimal("web", "~deploy/app/server.js");
let err = normalize_with_home(app, Some(Path::new("/home/ada")))
.expect_err("~user/ must not resolve");
assert!(
matches!(err, NormalizeError::TildeUser { field, .. } if field == "script"),
"the refusal names the field: {err:?}"
);
let rendered = err.to_string();
assert!(
rendered.contains("~/"),
"and says what IS supported: {rendered}"
);
assert!(
!rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
"no em or en dash in copy a user reads: {rendered}"
);
}
#[test]
fn a_dollar_variable_is_not_expanded() {
let app = AppConfig::minimal("web", "$HOME/server.js");
let resolved = normalize_with_home(app, Some(Path::new("/home/ada"))).expect("left alone");
assert_eq!(resolved.config().script, "$HOME/server.js");
}
#[test]
fn a_tilde_with_no_home_is_an_error_not_a_literal_path() {
let app = AppConfig::minimal("web", "~/server.js");
let err = normalize_with_home(app, None).expect_err("nothing to expand against");
assert!(
matches!(err, NormalizeError::NoHomeForTilde { .. }),
"{err:?}"
);
}
#[test]
fn expand_home_tilde_covers_its_four_documented_cases() {
let home = Path::new("/home/ada");
assert_eq!(
expand_home_tilde("~/bin/dog", Some(home)).unwrap(),
home.join("bin/dog").to_string_lossy()
);
assert_eq!(
expand_home_tilde("/opt/bin/dog", Some(home)).unwrap(),
"/opt/bin/dog",
"a value with no leading ~ is returned unchanged"
);
assert_eq!(
expand_home_tilde("~/bin/dog", None).unwrap_err(),
TildeError::NoHome
);
assert_eq!(
expand_home_tilde("~deploy/bin/dog", Some(home)).unwrap_err(),
TildeError::OtherUser
);
}
#[test]
fn reuse_port_loads_now_that_reload_reads_it() {
let mut app = AppConfig::minimal("web", "./server");
app.reuse_port = true;
let resolved = normalize(app).expect("reuse_port is implemented");
assert!(resolved.config().reuse_port);
}
#[test]
fn an_app_that_never_mentions_reuse_port_still_normalizes() {
let resolved = normalize(AppConfig::minimal("web", "./server"))
.expect("the common case must be untouched");
assert!(!resolved.config().reuse_port);
}
use crate::config::AppConfig;
#[test]
fn valid_minimal_config_normalizes() {
let resolved = normalize(AppConfig::minimal("web", "./srv")).unwrap();
assert_eq!(resolved.config().name, "web");
}
#[test]
fn names_that_reach_the_filesystem_are_rejected() {
for bad in ["/etc/passwd", "..", ".", "a/b", "a\\b"] {
assert_eq!(
normalize(AppConfig::minimal(bad, "./srv")).unwrap_err(),
NormalizeError::InvalidName(bad.to_string())
);
}
assert!(normalize(AppConfig::minimal("web-1", "./srv")).is_ok());
}
#[test]
fn missing_name_and_script_are_distinct_errors() {
assert_eq!(
normalize(AppConfig::minimal("", "./srv")).unwrap_err(),
NormalizeError::MissingName
);
assert_eq!(
normalize(AppConfig::minimal("web", "")).unwrap_err(),
NormalizeError::MissingScript
);
}
#[test]
fn zero_instances_rejected() {
let mut app = AppConfig::minimal("web", "./srv");
app.instances = 0;
assert_eq!(normalize(app).unwrap_err(), NormalizeError::ZeroInstances);
}
#[test]
fn bad_cron_pattern_rejected_with_pattern_and_reason_carried_through() {
let mut app = AppConfig::minimal("web", "./srv");
app.cron_restart = Some("not a cron".to_string());
match normalize(app).unwrap_err() {
NormalizeError::InvalidCron { pattern, reason } => {
assert_eq!(pattern, "not a cron");
assert!(!reason.is_empty());
}
other => panic!("expected InvalidCron, got {other:?}"),
}
}
#[test]
fn five_tokens_of_garbage_cron_pattern_rejected() {
let mut app = AppConfig::minimal("web", "./srv");
app.cron_restart = Some("99 99 99 99 99".to_string());
match normalize(app).unwrap_err() {
NormalizeError::InvalidCron { pattern, .. } => {
assert_eq!(pattern, "99 99 99 99 99");
}
other => panic!("expected InvalidCron, got {other:?}"),
}
}
#[test]
fn bad_cron_timezone_rejected_alongside_a_valid_cron_restart() {
let mut app = AppConfig::minimal("web", "./srv");
app.cron_restart = Some("0 3 * * *".to_string());
app.cron_timezone = Some("Mars/Olympus".to_string());
match normalize(app).unwrap_err() {
NormalizeError::InvalidTimezone { name } => assert_eq!(name, "Mars/Olympus"),
other => panic!("expected InvalidTimezone, got {other:?}"),
}
}
#[test]
fn cron_timezone_validated_even_without_cron_restart() {
let mut app = AppConfig::minimal("web", "./srv");
app.cron_timezone = Some("Mars/Olympus".to_string());
match normalize(app).unwrap_err() {
NormalizeError::InvalidTimezone { name } => assert_eq!(name, "Mars/Olympus"),
other => panic!("expected InvalidTimezone, got {other:?}"),
}
}
#[test]
fn duplicate_names_rejected_across_a_flock() {
let apps = vec![
AppConfig::minimal("web", "./a"),
AppConfig::minimal("web", "./b"),
];
assert_eq!(
normalize_all(apps).unwrap_err(),
NormalizeError::DuplicateName("web".to_string())
);
}
fn probe_config(target: &str) -> crate::config::ProbeConfig {
crate::config::ProbeConfig {
kind: crate::config::ProbeKind::Http,
target: target.to_string(),
interval: crate::values::UpDuration::from_millis(10_000),
timeout: crate::values::UpDuration::from_millis(5_000),
failure_threshold: 3,
}
}
#[test]
fn malformed_readiness_probe_target_rejected_naming_the_field() {
let mut app = AppConfig::minimal("web", "./srv");
app.readiness_probe = Some(probe_config("not-a-url"));
match normalize(app).unwrap_err() {
NormalizeError::InvalidProbe { probe, reason } => {
assert_eq!(probe, "readiness_probe");
assert!(!reason.is_empty());
}
other => panic!("expected InvalidProbe, got {other:?}"),
}
}
#[test]
fn malformed_liveness_probe_target_rejected_naming_the_field() {
let mut app = AppConfig::minimal("web", "./srv");
app.liveness_probe = Some(probe_config("not-a-url"));
match normalize(app).unwrap_err() {
NormalizeError::InvalidProbe { probe, .. } => assert_eq!(probe, "liveness_probe"),
other => panic!("expected InvalidProbe, got {other:?}"),
}
}
#[test]
fn valid_probe_targets_accepted() {
let mut app = AppConfig::minimal("web", "./srv");
app.readiness_probe = Some(probe_config("http://127.0.0.1:8080/healthz"));
assert!(normalize(app).is_ok());
}
#[test]
fn zero_failure_threshold_rejected() {
let mut app = AppConfig::minimal("web", "./srv");
let mut probe = probe_config("http://127.0.0.1:8080/healthz");
probe.failure_threshold = 0;
app.readiness_probe = Some(probe);
let err = normalize(app).unwrap_err();
assert_eq!(
err,
NormalizeError::ZeroFailureThreshold {
probe: "readiness_probe"
}
);
assert!(err.to_string().contains("at least 1"), "{err}");
}
#[test]
fn zero_interval_rejected() {
let mut app = AppConfig::minimal("web", "./srv");
let mut probe = probe_config("http://127.0.0.1:8080/healthz");
probe.interval = UpDuration::from_millis(0);
app.readiness_probe = Some(probe);
let err = normalize(app).unwrap_err();
assert_eq!(
err,
NormalizeError::IntervalBelowMinimum {
probe: "readiness_probe",
value: UpDuration::from_millis(0),
min: MIN_READINESS_INTERVAL,
}
);
assert!(err.to_string().contains("must be at least"), "{err}");
}
#[test]
fn a_liveness_interval_under_the_floor_is_rejected_rather_than_clamped() {
let mut app = AppConfig::minimal("web", "./srv");
let mut probe = probe_config("http://127.0.0.1:8080/healthz");
probe.interval = UpDuration::from_millis(500);
app.liveness_probe = Some(probe);
let err = normalize(app).unwrap_err();
assert_eq!(
err,
NormalizeError::IntervalBelowMinimum {
probe: "liveness_probe",
value: UpDuration::from_millis(500),
min: MIN_LIVENESS_INTERVAL,
}
);
assert!(err.to_string().contains("500"), "{err}");
}
#[test]
fn a_liveness_interval_exactly_at_the_floor_is_accepted() {
let mut app = AppConfig::minimal("web", "./srv");
let mut probe = probe_config("http://127.0.0.1:8080/healthz");
probe.interval = MIN_LIVENESS_INTERVAL;
app.liveness_probe = Some(probe);
assert!(normalize(app).is_ok());
}
#[test]
fn a_sub_second_readiness_interval_is_accepted() {
let mut app = AppConfig::minimal("web", "./srv");
let mut probe = probe_config("http://127.0.0.1:8080/healthz");
probe.interval = UpDuration::from_millis(50);
app.readiness_probe = Some(probe);
assert!(normalize(app).is_ok());
}
#[test]
fn zero_max_memory_rejected() {
let mut app = AppConfig::minimal("web", "./srv");
app.max_memory = Some(crate::values::MemSize::from_bytes(0));
let err = normalize(app).unwrap_err();
assert_eq!(
err,
NormalizeError::ZeroMaxMemory {
name: "web".to_string()
}
);
assert!(err.to_string().contains("max_memory"), "{err}");
}
#[test]
fn a_nonzero_max_memory_is_accepted() {
let mut app = AppConfig::minimal("web", "./srv");
app.max_memory = Some("512M".parse().unwrap());
assert!(normalize(app).is_ok());
}
#[test]
fn a_kill_signal_shep_cannot_send_is_refused_by_name() {
let mut app = AppConfig::minimal("web", "./srv");
app.kill_signal = Some("SIGUSR1".to_string());
let err = normalize(app).unwrap_err();
assert_eq!(
err,
NormalizeError::InvalidKillSignal {
name: "web".to_string(),
value: "SIGUSR1".to_string(),
}
);
let rendered = err.to_string();
assert!(rendered.contains("SIGUSR1"), "{rendered}");
assert!(rendered.contains("SIGTERM"), "{rendered}");
assert!(rendered.contains("SIGUSR2"), "{rendered}");
}
#[test]
fn every_spelling_the_daemon_already_accepted_still_normalizes() {
for name in [
"SIGTERM", "TERM", "sigterm", "term", "SIGINT", "INT", "SIGQUIT", "QUIT", "SIGUSR2",
"USR2", "sigusr2",
] {
let mut app = AppConfig::minimal("web", "./srv");
app.kill_signal = Some(name.to_string());
assert!(
normalize(app).is_ok(),
"`{name}` was accepted before this task and must still be"
);
}
}
#[test]
fn an_unset_kill_signal_is_not_a_config_error() {
let app = AppConfig::minimal("web", "./srv");
assert!(app.kill_signal.is_none());
assert!(normalize(app).is_ok());
}
#[test]
fn action_timeout_past_the_ceiling_is_rejected() {
let mut app = AppConfig::minimal("web", "./srv");
app.action_timeout = UpDuration::from_millis(MAX_ACTION_TIMEOUT.as_millis() + 1);
let err = normalize(app).unwrap_err();
assert_eq!(
err,
NormalizeError::ActionTimeoutTooLong {
name: "web".to_string(),
value: UpDuration::from_millis(MAX_ACTION_TIMEOUT.as_millis() + 1),
max: MAX_ACTION_TIMEOUT,
}
);
assert!(err.to_string().contains("action_timeout"), "{err}");
}
#[test]
fn action_timeout_at_the_ceiling_is_accepted() {
let mut app = AppConfig::minimal("web", "./srv");
app.action_timeout = MAX_ACTION_TIMEOUT;
assert!(normalize(app).is_ok());
}
#[test]
fn the_default_action_timeout_is_accepted() {
assert!(normalize(AppConfig::minimal("web", "./srv")).is_ok());
}
#[test]
fn zero_watch_delay_rejected() {
let mut app = AppConfig::minimal("web", "./srv");
app.watch = true;
app.cwd = Some("/srv/web".to_string());
app.watch_delay = Some(UpDuration::from_millis(0));
let err = normalize(app).unwrap_err();
assert_eq!(
err,
NormalizeError::ZeroWatchDelay {
name: "web".to_string()
}
);
assert!(err.to_string().contains("watch_delay"), "{err}");
}
#[test]
fn a_zero_watch_delay_is_rejected_with_watch_off() {
let mut app = AppConfig::minimal("web", "./srv");
app.watch_delay = Some(UpDuration::from_millis(0));
assert!(matches!(
normalize(app).unwrap_err(),
NormalizeError::ZeroWatchDelay { .. }
));
}
#[test]
fn a_nonzero_watch_delay_is_accepted() {
let mut app = AppConfig::minimal("web", "./srv");
app.watch = true;
app.cwd = Some("/srv/web".to_string());
app.watch_delay = Some(UpDuration::from_millis(1));
assert!(normalize(app).is_ok());
}
#[test]
fn default_failure_threshold_from_toml_accepted() {
let src = r#"
name = "web"
script = "./srv"
[readiness_probe]
kind = "http"
target = "http://127.0.0.1:8080/healthz"
"#;
let app: AppConfig = toml::from_str(src).unwrap();
assert!(normalize(app).is_ok());
}
#[test]
fn watch_true_without_cwd_rejected_naming_the_app() {
let mut app = AppConfig::minimal("web", "./srv");
app.watch = true;
let err = normalize(app).unwrap_err();
assert_eq!(
err,
NormalizeError::WatchWithoutCwd {
name: "web".to_string()
}
);
assert!(err.to_string().contains("no cwd to watch"), "{err}");
}
#[test]
fn watch_true_with_cwd_accepted() {
let mut app = AppConfig::minimal("web", "./srv");
app.watch = true;
app.cwd = Some("/srv/web".to_string());
assert!(normalize(app).is_ok());
}
#[test]
fn a_watch_options_glob_that_will_not_compile_is_rejected() {
let mut app = AppConfig::minimal("web", "./srv");
app.watch = true;
app.cwd = Some("/srv/web".to_string());
app.watch_options = vec!["src/**".to_string(), "[".to_string()];
let err = normalize(app).unwrap_err();
assert_eq!(
err,
NormalizeError::InvalidWatchGlob {
name: "web".to_string(),
field: "watch_options",
pattern: "[".to_string(),
reason: Glob::new("[").unwrap_err().to_string(),
}
);
let rendered = err.to_string();
for expected in ["web", "watch_options", "`[`"] {
assert!(
rendered.contains(expected),
"{expected} missing: {rendered}"
);
}
}
#[test]
fn an_ignore_watch_glob_that_will_not_compile_is_rejected() {
let mut app = AppConfig::minimal("web", "./srv");
app.watch = true;
app.cwd = Some("/srv/web".to_string());
app.ignore_watch = vec!["[".to_string()];
match normalize(app).unwrap_err() {
NormalizeError::InvalidWatchGlob { field, pattern, .. } => {
assert_eq!(field, "ignore_watch");
assert_eq!(pattern, "[");
}
other => panic!("expected InvalidWatchGlob, got {other:?}"),
}
}
#[test]
fn a_glob_that_will_not_compile_is_rejected_with_watch_off() {
let mut app = AppConfig::minimal("web", "./srv");
app.watch_options = vec!["[".to_string()];
assert!(matches!(
normalize(app).unwrap_err(),
NormalizeError::InvalidWatchGlob { .. }
));
}
#[test]
fn well_formed_watch_globs_are_accepted() {
let mut app = AppConfig::minimal("web", "./srv");
app.watch = true;
app.cwd = Some("/srv/web".to_string());
app.watch_options = vec!["src/**/*.rs".to_string(), "*.[ch]".to_string()];
app.ignore_watch = vec!["target/**".to_string(), "**/[!.]*.{tmp,swp}".to_string()];
assert!(normalize(app).is_ok());
}
#[test]
fn watch_options_without_watch_or_cwd_accepted() {
let mut app = AppConfig::minimal("web", "./srv");
app.watch_options = vec!["src/**".to_string()];
assert!(normalize(app).is_ok());
}
}