shep_core/config/normalize.rs
1//! Validation and normalization: `AppConfig` -> `ResolvedApp`
2//!
3//! `ResolvedApp` is a proof token: constructing one is only possible through
4//! [`normalize`], so daemon code can require it and skip re-validation.
5
6use core::fmt;
7
8use std::path::Path;
9
10use std::collections::BTreeSet;
11
12use globset::Glob;
13
14use crate::config::{
15 AppConfig, CronParseError, CronSchedule, KillSignal, ProbeConfig, ProbeTarget,
16};
17use crate::values::UpDuration;
18
19/// Shortest `interval` a `liveness_probe` may name.
20///
21/// The daemon's liveness loop floors whatever it is handed at this same value
22/// (its own `MIN_PROBE_INTERVAL`), so a smaller number would be *honoured* as
23/// this one with nothing to say so — in a detached daemon, not even a log
24/// line. That is the reasoning `max_cron_sleep` was settled on (`MIN_CRON_SLEEP`
25/// rejects rather than clamps), and it applies here for the same reason: the
26/// user's file is the only place the discrepancy could ever be noticed.
27///
28/// One second is a floor no legitimate configuration wants to be under. A
29/// liveness check asked for more often than that is polling, and for
30/// [`ProbeKind::Exec`](crate::config::ProbeKind::Exec) it is that many process
31/// spawns per second, per sheep, for as long as the sheep runs.
32const MIN_LIVENESS_INTERVAL: UpDuration = UpDuration::from_millis(1_000);
33
34/// Shortest `interval` a `readiness_probe` may name.
35///
36/// A whole second lower than [`MIN_LIVENESS_INTERVAL`], and deliberately so:
37/// the readiness wait honours its `interval` exactly as written and is bounded
38/// by the app's `listen_timeout`, so there is no clamp for a rejection to keep
39/// honest here — only the zero, which would spin that wait for the whole
40/// `listen_timeout`. A fast app that polls every 20ms to leave `starting`
41/// sooner is asking for something the daemon really does, so this floor must
42/// not take it away.
43const MIN_READINESS_INTERVAL: UpDuration = UpDuration::from_millis(1);
44
45/// Longest `action_timeout` an app may name.
46///
47/// Not a floor this time but a ceiling, and for a different reason than
48/// [`MIN_LIVENESS_INTERVAL`]'s: there, a smaller number was silently
49/// honoured as the floor; here, a larger one could never be honoured by
50/// ANY caller at all. The daemon clamps every RPC deadline a client can
51/// possibly ask for — its own `MAX_DEADLINE_MS`, 60s, in `shep-daemon`'s
52/// `rpc` module — so an `action_timeout` at or above that line describes a
53/// wait the daemon could never finish inside any request budget, no matter
54/// how generous a caller's own `Client::request_with_deadline` call is.
55/// That is not "the caller forgot to widen its deadline" (this crate has no
56/// way to see a caller's choice, and does not try to); it is "no choice
57/// exists", which is what makes it a config error rather than a caller's to
58/// fix. Set 2s under the hard clamp — the same margin `action_timeout`'s own
59/// default keeps under the *default* RPC budget — so the daemon still has
60/// room to build the `TimedOut` row and get it back down the wire after the
61/// wait itself gives up.
62const MAX_ACTION_TIMEOUT: UpDuration = UpDuration::from_millis(58_000);
63
64/// A validated app config — only obtainable via [`normalize`]
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct ResolvedApp {
67 config: AppConfig,
68}
69
70impl ResolvedApp {
71 /// Borrow the validated configuration
72 #[must_use]
73 pub fn config(&self) -> &AppConfig {
74 &self.config
75 }
76
77 /// Unwrap the validated configuration (consumes the proof token)
78 #[must_use]
79 pub fn into_config(self) -> AppConfig {
80 self.config
81 }
82}
83
84/// Expands a leading `~/` against `home`, and refuses `~user/`.
85///
86/// `~` is a shell feature. A shell expands it before a program ever sees the
87/// argument, so a value read out of a Flockfile has nothing between it and
88/// the parser and arrives literally: shep would look for a directory named
89/// `~`. Rin's call, 2026-08-19, was that a process manager standing in for
90/// the shell that would otherwise have started the process should inherit
91/// this narrow piece of its job.
92///
93/// Deliberately narrow. `~/` only:
94///
95/// - `~user/...` is refused. Resolving it means a passwd lookup, and under a
96/// systemd unit the answer depends on who the daemon runs as rather than
97/// on who wrote the file.
98/// - `$VAR` is NOT expanded, here or anywhere. Once a config file expands
99/// variables it has to answer WHICH environment it means -- the operator's,
100/// the daemon's, or the app's own `env` table -- and there is no good
101/// answer.
102///
103/// A path that does not start with `~` is returned untouched, so this is a
104/// no-op for every absolute and relative path anyone already has.
105///
106/// Thin wrapper over [`expand_home_tilde`], the one piece of tilde-expansion
107/// logic in the workspace: this attaches the sheep name and field
108/// [`NormalizeError`] carries, so a Flockfile refusal names the line to
109/// edit. A caller with no such context — `shep-cli`'s own `shep adopt`,
110/// resolving a bare CLI argument — calls [`expand_home_tilde`] directly.
111///
112/// # Errors
113/// - [`NormalizeError::TildeUser`] if the path names another user's home.
114/// - [`NormalizeError::NoHomeForTilde`] if `~/` is used and `home` is `None`.
115fn expand_tilde(
116 value: &str,
117 home: Option<&Path>,
118 name: &str,
119 field: &'static str,
120) -> Result<String, NormalizeError> {
121 expand_home_tilde(value, home).map_err(|err| match err {
122 TildeError::OtherUser => NormalizeError::TildeUser {
123 name: name.to_string(),
124 field,
125 value: value.to_string(),
126 },
127 TildeError::NoHome => NormalizeError::NoHomeForTilde {
128 name: name.to_string(),
129 field,
130 },
131 })
132}
133
134/// Why [`expand_home_tilde`] refused a value — the two failure modes with
135/// no per-field context attached, for a caller that has none to give.
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub enum TildeError {
138 /// The value names another user's home (`~user/...`). Refused rather
139 /// than resolved: answering it means a passwd lookup, and under a
140 /// systemd unit the answer depends on who the process runs as rather
141 /// than on who wrote the value.
142 OtherUser,
143 /// The value begins `~/` and no home directory could be determined.
144 NoHome,
145}
146
147impl fmt::Display for TildeError {
148 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149 match self {
150 Self::OtherUser => write!(
151 f,
152 "shep expands only `~/` (your own home); another user's home needs a \
153 passwd lookup whose answer depends on who the process runs as"
154 ),
155 Self::NoHome => write!(f, "begins with `~/` but no home directory could be found"),
156 }
157 }
158}
159
160impl core::error::Error for TildeError {}
161
162/// Expands a leading `~/` in `value` against `home` — the one piece of
163/// tilde-expansion logic in the workspace, shared by `expand_tilde` above
164/// (Flockfile path fields, private to this module) and `shep-cli`'s own
165/// `shep adopt` (its `<path>` argument). Adopt's own CLI path used to run
166/// without this entirely, which is why `~/.cargo/bin/whatever` worked in
167/// a Flockfile and not at `shep adopt`.
168///
169/// Deliberately narrow, matching `expand_tilde`'s own doc: `~/` only.
170/// `~user/...` is refused (a passwd lookup, whose answer depends on who
171/// the process runs as) and `$VAR` is never expanded, here or anywhere.
172///
173/// A value that does not start with `~` is returned unchanged, so this is
174/// a no-op for every absolute and relative path anyone already has.
175///
176/// # Errors
177/// - [`TildeError::OtherUser`] if the value names another user's home.
178/// - [`TildeError::NoHome`] if `~/` is used and `home` is `None`.
179pub fn expand_home_tilde(value: &str, home: Option<&Path>) -> Result<String, TildeError> {
180 let Some(rest) = value.strip_prefix('~') else {
181 return Ok(value.to_string());
182 };
183 // `~` alone, or `~/...`. Anything else after the tilde names a user.
184 if !(rest.is_empty() || rest.starts_with('/')) {
185 return Err(TildeError::OtherUser);
186 }
187 let Some(home) = home else {
188 return Err(TildeError::NoHome);
189 };
190 // `join` would discard `home` for a rest that still looks absolute, so
191 // the separator is trimmed and the two halves are concatenated instead.
192 let joined = home.join(rest.trim_start_matches('/'));
193 Ok(joined.to_string_lossy().into_owned())
194}
195
196/// Every field of an [`AppConfig`] that carries a filesystem path.
197///
198/// Named once, and walked by [`expand_paths`] and by its own test, so a
199/// fifth path field added later fails that test until it is handled.
200/// Expanding `~/` in some path fields and not others would be worse than
201/// expanding in none: it teaches that tildes work and then fails somewhere
202/// the operator has no reason to suspect.
203#[cfg(test)]
204const PATH_FIELDS: &[&str] = &["script", "cwd", "out_file", "err_file"];
205
206/// Expands `~/` in every path field of `app`, in place.
207///
208/// # Errors
209/// Whatever [`expand_tilde`] refuses, named with the field that carried it.
210fn expand_paths(app: &mut AppConfig, home: Option<&Path>) -> Result<(), NormalizeError> {
211 let name = app.name.clone();
212 app.script = expand_tilde(&app.script, home, &name, "script")?;
213 for (field, slot) in [
214 ("cwd", &mut app.cwd),
215 ("out_file", &mut app.out_file),
216 ("err_file", &mut app.err_file),
217 ] {
218 if let Some(value) = slot {
219 *slot = Some(expand_tilde(value, home, &name, field)?);
220 }
221 }
222 Ok(())
223}
224
225/// Validates one app config
226///
227/// # Errors
228///
229/// - [`NormalizeError::MissingName`] — `name` is empty.
230/// - [`NormalizeError::InvalidName`] — `name` contains a path separator or is `.`/`..`.
231/// - [`NormalizeError::MissingScript`] — `script` is empty.
232/// - [`NormalizeError::ZeroInstances`] — `instances == 0`.
233/// - [`NormalizeError::InvalidCron`] — `cron_restart` is not valid in
234/// croner's dialect (carries the pattern and the rejection reason).
235/// - [`NormalizeError::InvalidTimezone`] — `cron_timezone` is not a name in
236/// the IANA time-zone database.
237/// - [`NormalizeError::InvalidProbe`] — `readiness_probe` or `liveness_probe`
238/// has a target [`ProbeTarget::parse`] rejects (carries which probe and
239/// the rendered reason).
240/// - [`NormalizeError::ZeroFailureThreshold`] — a probe's `failure_threshold`
241/// is explicitly `0`.
242/// - [`NormalizeError::IntervalBelowMinimum`] — a probe's `interval` is under
243/// the floor its own loop honours: a full second for `liveness_probe`, and
244/// only "greater than zero" for `readiness_probe` (carries which probe, the
245/// value and the floor).
246/// - [`NormalizeError::ZeroMaxMemory`] — `max_memory` is `0`.
247/// - [`NormalizeError::ActionTimeoutTooLong`] — `action_timeout` is at or
248/// above the ceiling no RPC caller could ever be given room to wait past
249/// (carries the app name, the value and the ceiling).
250/// - [`NormalizeError::InvalidKillSignal`] — `kill_signal` names a signal the
251/// daemon's stop ladder cannot send (carries the app name and the value).
252/// - [`NormalizeError::WatchWithoutCwd`] — `watch` is `true` with no `cwd`
253/// set.
254/// - [`NormalizeError::ZeroWatchDelay`] — `watch_delay` is `0`.
255/// - [`NormalizeError::InvalidWatchGlob`] — a `watch_options` or
256/// `ignore_watch` pattern globset will not compile (carries the app name,
257/// which of the two lists, the pattern and the reason).
258pub fn normalize(app: AppConfig) -> Result<ResolvedApp, NormalizeError> {
259 normalize_with_home(app, std::env::home_dir().as_deref())
260}
261
262/// [`normalize`], with the home directory supplied rather than read.
263///
264/// A parameter so the `~/` expansion above is testable without mutating the
265/// process environment, which is racy under a parallel `cargo test`. This is
266/// also the seam that matters for correctness rather than only for tests:
267/// the daemon may run as a different user than the CLI, so `~` has to be
268/// resolved where the config is normalised, not where it is executed.
269///
270/// # Errors
271/// The same set [`normalize`] documents.
272pub fn normalize_with_home(
273 mut app: AppConfig,
274 home: Option<&Path>,
275) -> Result<ResolvedApp, NormalizeError> {
276 if app.name.is_empty() {
277 return Err(NormalizeError::MissingName);
278 }
279 if app.name.contains(['/', '\\']) || app.name == "." || app.name == ".." {
280 return Err(NormalizeError::InvalidName(app.name));
281 }
282 if app.script.is_empty() {
283 return Err(NormalizeError::MissingScript);
284 }
285 // After the emptiness checks, so a missing script is reported as missing
286 // rather than as a path problem, and before every check below that reads
287 // a path.
288 expand_paths(&mut app, home)?;
289 if app.instances == 0 {
290 return Err(NormalizeError::ZeroInstances);
291 }
292 if let Some(pattern) = &app.cron_restart {
293 CronSchedule::parse(pattern, app.cron_timezone.as_deref()).map_err(|e| match e {
294 CronParseError::Pattern { pattern, reason } => {
295 NormalizeError::InvalidCron { pattern, reason }
296 }
297 CronParseError::Timezone { name } => NormalizeError::InvalidTimezone { name },
298 })?;
299 } else if let Some(tz_name) = &app.cron_timezone {
300 // A Flockfile can carry `cron_timezone` with no `cron_restart` to
301 // pair it with — still a typo the user wants to hear about (spec §5).
302 crate::config::cron::parse_timezone_name(tz_name).ok_or_else(|| {
303 NormalizeError::InvalidTimezone {
304 name: tz_name.clone(),
305 }
306 })?;
307 }
308 validate_probe(
309 app.readiness_probe.as_ref(),
310 "readiness_probe",
311 MIN_READINESS_INTERVAL,
312 )?;
313 validate_probe(
314 app.liveness_probe.as_ref(),
315 "liveness_probe",
316 MIN_LIVENESS_INTERVAL,
317 )?;
318 if app.max_memory.is_some_and(|limit| limit.bytes() == 0) {
319 // A ceiling every live process is over, armed against every poll: the
320 // enforcer would report a breach on its first reading and on every
321 // reading after it, and the restart that follows is automatic, which
322 // RESETS the restart budget rather than spending it. `max_restarts`
323 // cannot end that loop, so it has to be refused here.
324 return Err(NormalizeError::ZeroMaxMemory { name: app.name });
325 }
326 if let Some(name) = &app.kill_signal
327 && KillSignal::parse(name).is_none()
328 {
329 // Rejected rather than clamped, and this one is the sharpest case of
330 // that trade in the file. The daemon's stop ladder used to fall back
331 // to SIGTERM and log a warning, which meant a typo cost the operator
332 // every stop and every reload for the life of the process, with the
333 // only evidence in a detached daemon's log at the moment of a stop.
334 // `max_cron_sleep` and `MIN_LIVENESS_INTERVAL` reject for the same
335 // reason at lower stakes: the user's file is the only place a
336 // silently-substituted value could ever be noticed.
337 return Err(NormalizeError::InvalidKillSignal {
338 name: app.name,
339 value: name.clone(),
340 });
341 }
342 if app.action_timeout > MAX_ACTION_TIMEOUT {
343 // Rejected rather than clamped, the same trade `MIN_LIVENESS_INTERVAL`
344 // and `max_cron_sleep` already made: a daemon running detached has no
345 // reader for a log line saying the value was silently lowered, so the
346 // Flockfile would be the only place the discrepancy ever showed up —
347 // and here there is no honest lowered value to fall back to anyway,
348 // since every value above the ceiling is equally unreachable by any
349 // caller.
350 return Err(NormalizeError::ActionTimeoutTooLong {
351 name: app.name,
352 value: app.action_timeout,
353 max: MAX_ACTION_TIMEOUT,
354 });
355 }
356 if app.watch && app.cwd.is_none() {
357 // `watch` asked for a feature the daemon has no directory to arm:
358 // there is no cwd in the Flockfile, and defaulting to the daemon's
359 // own cwd risks watching the whole filesystem under a systemd unit
360 // with no `WorkingDirectory=` (Rin, 2026-08-08).
361 return Err(NormalizeError::WatchWithoutCwd { name: app.name });
362 }
363 if app.watch_delay == Some(UpDuration::from_millis(0)) {
364 // notify's debouncer derives its own poll tick as `watch_delay / 4`
365 // and runs it on a dedicated OS thread, so a zero turns that thread
366 // into `loop { sleep(0); lock(); }`: measured at 5.98s of user CPU
367 // across a three-second watch that costs 0.00s at the 500ms default.
368 // shep-daemon's watch arming floors this independently too (its
369 // `MIN_WATCH_DELAY`), the same belt-and-suspenders shape
370 // `validate_probe`'s interval check has opposite the liveness loop's
371 // own floor.
372 return Err(NormalizeError::ZeroWatchDelay { name: app.name });
373 }
374 // Both lists are checked whether or not `watch` is on. A pattern globset
375 // will not compile is a typo, and the user wants it named now rather than
376 // the day they flip `watch = true` and wonder why saving a file changes
377 // nothing — the same reasoning that makes `watch` without `cwd` a config
378 // error above (Rin, 2026-08-09).
379 validate_watch_globs(&app.name, "watch_options", &app.watch_options)?;
380 validate_watch_globs(&app.name, "ignore_watch", &app.ignore_watch)?;
381 Ok(ResolvedApp { config: app })
382}
383
384/// Validates one of an app's two watch glob lists, rejecting any pattern
385/// globset will not compile. `field` is the Flockfile field name
386/// (`"watch_options"` or `"ignore_watch"`), carried into any error so the
387/// user knows which list to edit. The compiled globs are discarded — this
388/// function's job is rejection; the daemon builds its own watch filter when
389/// it arms the watch.
390fn validate_watch_globs(
391 name: &str,
392 field: &'static str,
393 patterns: &[String],
394) -> Result<(), NormalizeError> {
395 for pattern in patterns {
396 Glob::new(pattern).map_err(|err| NormalizeError::InvalidWatchGlob {
397 name: name.to_string(),
398 field,
399 pattern: pattern.clone(),
400 reason: err.to_string(),
401 })?;
402 }
403 Ok(())
404}
405
406/// Validates one probe's target, `failure_threshold` and `interval`, if the
407/// probe is configured. `probe` is the Flockfile field name
408/// (`"readiness_probe"` or `"liveness_probe"`), carried into any error so the
409/// user knows which field to edit; `min_interval` is the floor that probe's
410/// own loop in the daemon honours, which is why the two call sites pass
411/// different ones. Its own parsed [`ProbeTarget`] is discarded — this
412/// function's job is rejection; the daemon re-parses when it arms the probe.
413fn validate_probe(
414 probe: Option<&ProbeConfig>,
415 name: &'static str,
416 min_interval: UpDuration,
417) -> Result<(), NormalizeError> {
418 let Some(probe) = probe else {
419 return Ok(());
420 };
421 ProbeTarget::parse(probe).map_err(|reason| NormalizeError::InvalidProbe {
422 probe: name,
423 reason: reason.to_string(),
424 })?;
425 if probe.failure_threshold == 0 {
426 // Unhealthy before the first probe ever runs — not a configuration
427 // anybody wants, and it would make the liveness loop restart the
428 // sheep immediately and forever.
429 return Err(NormalizeError::ZeroFailureThreshold { probe: name });
430 }
431 if probe.interval < min_interval {
432 // Not a configuration anybody wants either. Both probe loops sleep
433 // `interval` between attempts, so a zero turns either into a hot
434 // spin — for `ProbeKind::Exec`, hundreds of process spawns per
435 // second, per sheep. A liveness interval that is merely *small*
436 // is refused for a second reason: `spawn_liveness_task` rounds it UP
437 // to its own `MIN_PROBE_INTERVAL`, which would leave the user's file
438 // the only place the discrepancy exists and nothing anywhere to
439 // report it. Rejecting rather than clamping is what `max_cron_sleep`
440 // settled on for that same trade; the daemon-side floor stays too,
441 // because this crate does not own the boot wiring that guarantees
442 // every `ProbeConfig` reaching the loop came through here.
443 return Err(NormalizeError::IntervalBelowMinimum {
444 probe: name,
445 value: probe.interval,
446 min: min_interval,
447 });
448 }
449 Ok(())
450}
451
452/// Validates a whole flock, rejecting duplicate sheep names
453///
454/// # Errors
455///
456/// Everything [`normalize`] returns, plus
457/// [`NormalizeError::DuplicateName`] — two apps share a `name`.
458pub fn normalize_all(apps: Vec<AppConfig>) -> Result<Vec<ResolvedApp>, NormalizeError> {
459 let mut seen = BTreeSet::new();
460 apps.into_iter()
461 .map(|app| {
462 if !seen.insert(app.name.clone()) {
463 return Err(NormalizeError::DuplicateName(app.name));
464 }
465 normalize(app)
466 })
467 .collect()
468}
469
470/// Error type returned from [`normalize`] and [`normalize_all`]
471///
472/// Growth is expected: every config surface this crate learns to validate
473/// brings its own rejection reasons with it (IR-20).
474#[non_exhaustive]
475#[derive(Debug, Clone, PartialEq, Eq)]
476pub enum NormalizeError {
477 /// `name` is empty
478 MissingName,
479 /// `name` contains `/` or `\` or is `.`/`..` — it becomes a filesystem
480 /// path stem, so these would escape the shep home (carries the name)
481 InvalidName(String),
482 /// `script` is empty
483 MissingScript,
484 /// `instances` is zero
485 ZeroInstances,
486 /// `cron_restart` is not valid in croner's dialect. Carries the pattern
487 /// and the rejection reason — croner's own sentence where croner did the
488 /// rejecting, ours where shep's pre-parse pass did.
489 InvalidCron {
490 /// The pattern as the user wrote it
491 pattern: String,
492 /// Why it was rejected
493 reason: String,
494 },
495 /// `cron_timezone` is not a name in the IANA time-zone database
496 InvalidTimezone {
497 /// The value as the user wrote it
498 name: String,
499 },
500 /// Two apps in one flock share this name
501 DuplicateName(String),
502 /// A `readiness_probe` or `liveness_probe` target is malformed. Carries
503 /// which probe and the rendered reason.
504 InvalidProbe {
505 /// `"readiness_probe"` or `"liveness_probe"` — the Flockfile field
506 /// name, so the error names the line the user has to edit.
507 probe: &'static str,
508 /// [`ProbeTarget::parse`]'s rendered rejection reason.
509 reason: String,
510 },
511 /// A `readiness_probe` or `liveness_probe` has `failure_threshold == 0`.
512 ZeroFailureThreshold {
513 /// `"readiness_probe"` or `"liveness_probe"` — the Flockfile field
514 /// name, so the error names the line the user has to edit.
515 probe: &'static str,
516 },
517 /// A `readiness_probe` or `liveness_probe` has an `interval` under the
518 /// floor its own loop in the daemon honours. At `0` that would spin the
519 /// loop as fast as the runtime allows; a `liveness_probe` under a full
520 /// second would instead be silently polled at that second.
521 IntervalBelowMinimum {
522 /// `"readiness_probe"` or `"liveness_probe"` — the Flockfile field
523 /// name, so the error names the line the user has to edit.
524 probe: &'static str,
525 /// The value as the user wrote it.
526 value: UpDuration,
527 /// The floor it failed.
528 min: UpDuration,
529 },
530 /// `max_memory` is `0` — a ceiling every live process is already over, so
531 /// the enforcer would restart the sheep on every poll forever. Carries
532 /// the app name.
533 ZeroMaxMemory {
534 /// The sheep name, so the error names which Flockfile entry to edit.
535 name: String,
536 },
537 /// `action_timeout` is at or above `normalize`'s own ceiling — a wait no
538 /// RPC caller could ever be given enough deadline to outlast, since the
539 /// daemon clamps every deadline a caller can ask for. Carries the app
540 /// name, the value as written, and the ceiling it failed.
541 ActionTimeoutTooLong {
542 /// The sheep name, so the error names which Flockfile entry to edit.
543 name: String,
544 /// The value as the user wrote it.
545 value: UpDuration,
546 /// The ceiling it failed.
547 max: UpDuration,
548 },
549 /// `kill_signal` names a signal the daemon's stop ladder cannot send.
550 /// Carries the app name and the value as written.
551 InvalidKillSignal {
552 /// The sheep name, so the error names which Flockfile entry to edit.
553 name: String,
554 /// The value as the user wrote it.
555 value: String,
556 },
557 /// `watch` is enabled but the app sets no `cwd`, so there is no
558 /// directory to watch. Carries the app name.
559 WatchWithoutCwd {
560 /// The sheep name, so the error names which Flockfile entry to edit.
561 name: String,
562 },
563 /// A path begins `~user/`, naming another user's home.
564 ///
565 /// Refused rather than resolved: answering it means a passwd lookup, and
566 /// under a systemd unit the answer is not obviously the one anyone meant.
567 /// `~/` is supported; this is not.
568 TildeUser {
569 /// The sheep name, so the error names which Flockfile entry to edit.
570 name: String,
571 /// Which field carried it.
572 field: &'static str,
573 /// The path as written.
574 value: String,
575 },
576 /// A path begins `~/` and no home directory could be determined.
577 NoHomeForTilde {
578 /// The sheep name, so the error names which Flockfile entry to edit.
579 name: String,
580 /// Which field carried it.
581 field: &'static str,
582 },
583 /// `watch_delay` is `0`, which would spin the debouncer's own OS thread.
584 /// Carries the app name.
585 ZeroWatchDelay {
586 /// The sheep name, so the error names which Flockfile entry to edit.
587 name: String,
588 },
589 /// A `watch_options` or `ignore_watch` pattern is one globset will not
590 /// compile, so the watch it describes could never be armed.
591 InvalidWatchGlob {
592 /// The sheep name, so the error names which Flockfile entry to edit.
593 name: String,
594 /// `"watch_options"` or `"ignore_watch"` — the Flockfile field name,
595 /// so the error names which of the two lists to edit.
596 field: &'static str,
597 /// The pattern as the user wrote it.
598 pattern: String,
599 /// globset's own rendered reason.
600 reason: String,
601 },
602}
603
604impl fmt::Display for NormalizeError {
605 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
606 match self {
607 Self::MissingName => f.write_str("app config is missing a name"),
608 Self::InvalidName(n) => {
609 write!(
610 f,
611 "sheep name `{n}` may not contain a path separator or be `.` or `..`"
612 )
613 }
614 Self::MissingScript => f.write_str("app config is missing a script"),
615 Self::ZeroInstances => f.write_str("instances must be at least 1"),
616 Self::InvalidCron { pattern, reason } => {
617 write!(f, "invalid cron_restart pattern `{pattern}`: {reason}")
618 }
619 Self::InvalidTimezone { name } => {
620 write!(f, "`{name}` is not a recognized IANA timezone")
621 }
622 Self::DuplicateName(n) => write!(f, "duplicate sheep name `{n}`"),
623 Self::InvalidProbe { probe, reason } => write!(f, "{probe}: {reason}"),
624 Self::ZeroFailureThreshold { probe } => {
625 write!(f, "{probe}.failure_threshold must be at least 1")
626 }
627 Self::IntervalBelowMinimum { probe, value, min } => {
628 write!(f, "{probe}.interval is `{value}`: must be at least {min}")
629 }
630 Self::ZeroMaxMemory { name } => {
631 write!(
632 f,
633 "sheep `{name}` has max_memory = 0, a limit nothing can stay under"
634 )
635 }
636 Self::ActionTimeoutTooLong { name, value, max } => {
637 write!(
638 f,
639 "sheep `{name}` has action_timeout = {value}: must be at most {max}, \
640 the longest wait any caller's deadline could ever cover"
641 )
642 }
643 Self::InvalidKillSignal { name, value } => {
644 write!(
645 f,
646 "`{name}`: kill_signal `{value}` is not one shep can send (accepted: {})",
647 KillSignal::ACCEPTED.join(", ")
648 )
649 }
650 Self::TildeUser { name, field, value } => write!(
651 f,
652 "`{name}`: {field} is `{value}`, and shep expands only `~/` (your own home). \
653 Another user's home needs a passwd lookup whose answer depends on who the \
654 daemon runs as, so write the path out in full instead."
655 ),
656 Self::NoHomeForTilde { name, field } => write!(
657 f,
658 "`{name}`: {field} begins with `~/` but no home directory could be found. \
659 Set $HOME, or write the path out in full."
660 ),
661 Self::WatchWithoutCwd { name } => {
662 write!(f, "sheep `{name}` has watch = true but no cwd to watch")
663 }
664 Self::ZeroWatchDelay { name } => {
665 write!(
666 f,
667 "sheep `{name}` has watch_delay = 0: must be greater than 0"
668 )
669 }
670 Self::InvalidWatchGlob {
671 name,
672 field,
673 pattern,
674 reason,
675 } => write!(
676 f,
677 "sheep `{name}` has an invalid {field} pattern `{pattern}`: {reason}"
678 ),
679 }
680 }
681}
682
683impl core::error::Error for NormalizeError {}
684
685#[cfg(test)]
686mod tests {
687 use super::*;
688
689 /// All four path fields expand `~/`, and expanding some but not others
690 /// would be worse than expanding none: it teaches that tildes work and
691 /// then fails where the operator has no reason to suspect it.
692 #[test]
693 fn every_path_field_expands_a_leading_tilde() {
694 let home = Path::new("/home/rin");
695 let mut app = AppConfig::minimal("web", "~/app/server.js");
696 app.cwd = Some("~/app".to_string());
697 app.out_file = Some("~/logs/out.log".to_string());
698 app.err_file = Some("~/logs/err.log".to_string());
699
700 let resolved = normalize_with_home(app, Some(home)).expect("all four expand");
701 let c = resolved.config();
702 // Expectations are built with `join` rather than written as literals:
703 // the separator is `/` here and `\` on Windows, and hardcoding one
704 // turned CI's three Windows legs red when this test first landed.
705 let expect = |rest: &str| home.join(rest).to_string_lossy().into_owned();
706 assert_eq!(c.script, expect("app/server.js"));
707 assert_eq!(c.cwd.as_deref(), Some(expect("app").as_str()));
708 assert_eq!(c.out_file.as_deref(), Some(expect("logs/out.log").as_str()));
709 assert_eq!(c.err_file.as_deref(), Some(expect("logs/err.log").as_str()));
710 }
711
712 /// The anti-drift half. A fifth path field added to `AppConfig` fails
713 /// here until `expand_paths` handles it, which is the only thing keeping
714 /// the "all four or none" rule true over time.
715 #[test]
716 fn the_path_field_list_matches_what_expand_paths_walks() {
717 let home = Path::new("/home/rin");
718 let mut app = AppConfig::minimal("web", "~/s");
719 app.cwd = Some("~/c".to_string());
720 app.out_file = Some("~/o".to_string());
721 app.err_file = Some("~/e".to_string());
722
723 let resolved = normalize_with_home(app, Some(home)).expect("expands");
724 let c = resolved.config();
725 let expanded = [
726 ("script", Some(c.script.as_str())),
727 ("cwd", c.cwd.as_deref()),
728 ("out_file", c.out_file.as_deref()),
729 ("err_file", c.err_file.as_deref()),
730 ];
731 assert_eq!(
732 expanded.len(),
733 PATH_FIELDS.len(),
734 "PATH_FIELDS and this test must name the same set"
735 );
736 for (field, value) in expanded {
737 assert!(
738 PATH_FIELDS.contains(&field),
739 "`{field}` is not in PATH_FIELDS"
740 );
741 assert!(
742 value.is_some_and(|v| v.starts_with("/home/rin")),
743 "`{field}` was not expanded: {value:?}"
744 );
745 }
746 }
747
748 /// A path with no tilde is untouched, so this is a no-op for every
749 /// absolute and relative path anyone already has.
750 #[test]
751 fn a_path_without_a_tilde_is_left_exactly_as_written() {
752 let app = AppConfig::minimal("web", "./server.js");
753 let resolved =
754 normalize_with_home(app, Some(Path::new("/home/rin"))).expect("no tilde, no change");
755 assert_eq!(resolved.config().script, "./server.js");
756 }
757
758 /// `~user/` needs a passwd lookup whose answer depends on who the daemon
759 /// runs as, so it is refused rather than guessed at.
760 #[test]
761 fn another_users_home_is_refused_rather_than_resolved() {
762 let app = AppConfig::minimal("web", "~deploy/app/server.js");
763 let err = normalize_with_home(app, Some(Path::new("/home/rin")))
764 .expect_err("~user/ must not resolve");
765 assert!(
766 matches!(err, NormalizeError::TildeUser { field, .. } if field == "script"),
767 "the refusal names the field: {err:?}"
768 );
769 let rendered = err.to_string();
770 assert!(
771 rendered.contains("~/"),
772 "and says what IS supported: {rendered}"
773 );
774 assert!(
775 !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
776 "no em or en dash in copy a user reads: {rendered}"
777 );
778 }
779
780 /// `$VAR` is not expanded, here or anywhere. A config file that expands
781 /// variables has to answer whose environment it means.
782 #[test]
783 fn a_dollar_variable_is_not_expanded() {
784 let app = AppConfig::minimal("web", "$HOME/server.js");
785 let resolved = normalize_with_home(app, Some(Path::new("/home/rin"))).expect("left alone");
786 assert_eq!(resolved.config().script, "$HOME/server.js");
787 }
788
789 /// `~/` with no home to expand against is an error naming the field
790 /// rather than a path containing a literal tilde.
791 #[test]
792 fn a_tilde_with_no_home_is_an_error_not_a_literal_path() {
793 let app = AppConfig::minimal("web", "~/server.js");
794 let err = normalize_with_home(app, None).expect_err("nothing to expand against");
795 assert!(
796 matches!(err, NormalizeError::NoHomeForTilde { .. }),
797 "{err:?}"
798 );
799 }
800
801 /// Pins [`expand_home_tilde`]'s own public contract directly, apart
802 /// from [`expand_tilde`]'s wrapping of it into a [`NormalizeError`]:
803 /// `shep-cli`'s own `shep adopt` calls this function directly (it has
804 /// no app name or field to attach), so its behavior needs its own
805 /// coverage independent of whatever `AppConfig`-shaped test drives it
806 /// above.
807 #[test]
808 fn expand_home_tilde_covers_its_four_documented_cases() {
809 let home = Path::new("/home/rin");
810 assert_eq!(
811 expand_home_tilde("~/bin/dog", Some(home)).unwrap(),
812 home.join("bin/dog").to_string_lossy()
813 );
814 assert_eq!(
815 expand_home_tilde("/opt/bin/dog", Some(home)).unwrap(),
816 "/opt/bin/dog",
817 "a value with no leading ~ is returned unchanged"
818 );
819 assert_eq!(
820 expand_home_tilde("~/bin/dog", None).unwrap_err(),
821 TildeError::NoHome
822 );
823 assert_eq!(
824 expand_home_tilde("~deploy/bin/dog", Some(home)).unwrap_err(),
825 TildeError::OtherUser
826 );
827 }
828
829 /// Loads, as of the day the daemon started reading it. The refusal that
830 /// stood here from 2026-08-19 was honest while no production code
831 /// consulted the field; now reload's mode is chosen from it, so refusing
832 /// would deny an operator the only way to ask for an overlapping reload.
833 ///
834 /// Nothing had to be migrated to get here: `normalize` refused every
835 /// config carrying the field, so no Flockfile that loads today can
836 /// contain it, and un-refusing on its own changes nothing about them.
837 /// The reload they get does change, for the apps that configure a
838 /// `readiness_probe`, and that change is the daemon's rather than this
839 /// function's.
840 #[test]
841 fn reuse_port_loads_now_that_reload_reads_it() {
842 let mut app = AppConfig::minimal("web", "./server");
843 app.reuse_port = true;
844
845 let resolved = normalize(app).expect("reuse_port is implemented");
846 assert!(resolved.config().reuse_port);
847 }
848
849 /// The default is off, so every Flockfile that does not mention it keeps
850 /// loading.
851 #[test]
852 fn an_app_that_never_mentions_reuse_port_still_normalizes() {
853 let resolved = normalize(AppConfig::minimal("web", "./server"))
854 .expect("the common case must be untouched");
855 assert!(!resolved.config().reuse_port);
856 }
857 use crate::config::AppConfig;
858
859 #[test]
860 fn valid_minimal_config_normalizes() {
861 let resolved = normalize(AppConfig::minimal("web", "./srv")).unwrap();
862 assert_eq!(resolved.config().name, "web");
863 }
864
865 #[test]
866 fn names_that_reach_the_filesystem_are_rejected() {
867 // A name becomes a log/pid file stem via Path::join; a slash-prefixed
868 // or dotdot name would escape $SHEP_HOME. Reject at the config boundary.
869 for bad in ["/etc/passwd", "..", ".", "a/b", "a\\b"] {
870 assert_eq!(
871 normalize(AppConfig::minimal(bad, "./srv")).unwrap_err(),
872 NormalizeError::InvalidName(bad.to_string())
873 );
874 }
875 assert!(normalize(AppConfig::minimal("web-1", "./srv")).is_ok());
876 }
877
878 #[test]
879 fn missing_name_and_script_are_distinct_errors() {
880 assert_eq!(
881 normalize(AppConfig::minimal("", "./srv")).unwrap_err(),
882 NormalizeError::MissingName
883 );
884 assert_eq!(
885 normalize(AppConfig::minimal("web", "")).unwrap_err(),
886 NormalizeError::MissingScript
887 );
888 }
889
890 #[test]
891 fn zero_instances_rejected() {
892 let mut app = AppConfig::minimal("web", "./srv");
893 app.instances = 0;
894 assert_eq!(normalize(app).unwrap_err(), NormalizeError::ZeroInstances);
895 }
896
897 #[test]
898 fn bad_cron_pattern_rejected_with_pattern_and_reason_carried_through() {
899 // fails if the reason is not carried through from croner. This
900 // input is three tokens, already rejected by the token-count
901 // stopgap that used to sit here, so it guards the pattern/reason
902 // plumbing, not the dialect check itself — see the next test for
903 // the case that actually proves the stopgap is gone.
904 let mut app = AppConfig::minimal("web", "./srv");
905 app.cron_restart = Some("not a cron".to_string());
906 match normalize(app).unwrap_err() {
907 NormalizeError::InvalidCron { pattern, reason } => {
908 assert_eq!(pattern, "not a cron");
909 assert!(!reason.is_empty());
910 }
911 other => panic!("expected InvalidCron, got {other:?}"),
912 }
913 }
914
915 #[test]
916 fn five_tokens_of_garbage_cron_pattern_rejected() {
917 // fails if the validator is still a token counter: the stopgap this
918 // replaced accepted exactly this input, since it only counted
919 // whitespace-separated tokens.
920 let mut app = AppConfig::minimal("web", "./srv");
921 app.cron_restart = Some("99 99 99 99 99".to_string());
922 match normalize(app).unwrap_err() {
923 NormalizeError::InvalidCron { pattern, .. } => {
924 assert_eq!(pattern, "99 99 99 99 99");
925 }
926 other => panic!("expected InvalidCron, got {other:?}"),
927 }
928 }
929
930 #[test]
931 fn bad_cron_timezone_rejected_alongside_a_valid_cron_restart() {
932 // fails if the `cron_restart` branch maps CronParseError::Timezone to
933 // anything but NormalizeError::InvalidTimezone. CronSchedule::parse
934 // resolves the zone before it looks at the pattern, so a valid pattern
935 // paired with a bad zone is the only input that reaches that arm — the
936 // zone-with-no-pattern test below takes the separate `else if` branch.
937 let mut app = AppConfig::minimal("web", "./srv");
938 app.cron_restart = Some("0 3 * * *".to_string());
939 app.cron_timezone = Some("Mars/Olympus".to_string());
940 match normalize(app).unwrap_err() {
941 NormalizeError::InvalidTimezone { name } => assert_eq!(name, "Mars/Olympus"),
942 other => panic!("expected InvalidTimezone, got {other:?}"),
943 }
944 }
945
946 #[test]
947 fn cron_timezone_validated_even_without_cron_restart() {
948 // fails if timezone validation is skipped when there's no pattern to
949 // pair it with — a Flockfile with only a bad `cron_timezone` is a
950 // typo the user wants to hear about (spec §5).
951 let mut app = AppConfig::minimal("web", "./srv");
952 app.cron_timezone = Some("Mars/Olympus".to_string());
953 match normalize(app).unwrap_err() {
954 NormalizeError::InvalidTimezone { name } => assert_eq!(name, "Mars/Olympus"),
955 other => panic!("expected InvalidTimezone, got {other:?}"),
956 }
957 }
958
959 #[test]
960 fn duplicate_names_rejected_across_a_flock() {
961 let apps = vec![
962 AppConfig::minimal("web", "./a"),
963 AppConfig::minimal("web", "./b"),
964 ];
965 assert_eq!(
966 normalize_all(apps).unwrap_err(),
967 NormalizeError::DuplicateName("web".to_string())
968 );
969 }
970
971 fn probe_config(target: &str) -> crate::config::ProbeConfig {
972 crate::config::ProbeConfig {
973 kind: crate::config::ProbeKind::Http,
974 target: target.to_string(),
975 interval: crate::values::UpDuration::from_millis(10_000),
976 timeout: crate::values::UpDuration::from_millis(5_000),
977 failure_threshold: 3,
978 }
979 }
980
981 #[test]
982 fn malformed_readiness_probe_target_rejected_naming_the_field() {
983 // fails if validate_probe is never called for readiness_probe, or if
984 // it drops which of the two probe fields the rejection came from
985 let mut app = AppConfig::minimal("web", "./srv");
986 app.readiness_probe = Some(probe_config("not-a-url"));
987 match normalize(app).unwrap_err() {
988 NormalizeError::InvalidProbe { probe, reason } => {
989 assert_eq!(probe, "readiness_probe");
990 assert!(!reason.is_empty());
991 }
992 other => panic!("expected InvalidProbe, got {other:?}"),
993 }
994 }
995
996 #[test]
997 fn malformed_liveness_probe_target_rejected_naming_the_field() {
998 // fails if only readiness_probe is ever validated, leaving a bad
999 // liveness_probe target to surface later at the daemon's first poll
1000 let mut app = AppConfig::minimal("web", "./srv");
1001 app.liveness_probe = Some(probe_config("not-a-url"));
1002 match normalize(app).unwrap_err() {
1003 NormalizeError::InvalidProbe { probe, .. } => assert_eq!(probe, "liveness_probe"),
1004 other => panic!("expected InvalidProbe, got {other:?}"),
1005 }
1006 }
1007
1008 #[test]
1009 fn valid_probe_targets_accepted() {
1010 // fails if validate_probe rejects a well-formed target outright
1011 let mut app = AppConfig::minimal("web", "./srv");
1012 app.readiness_probe = Some(probe_config("http://127.0.0.1:8080/healthz"));
1013 assert!(normalize(app).is_ok());
1014 }
1015
1016 #[test]
1017 fn zero_failure_threshold_rejected() {
1018 // fails if failure_threshold is never inspected — a threshold of 0
1019 // means "unhealthy before the first probe ever runs"
1020 let mut app = AppConfig::minimal("web", "./srv");
1021 let mut probe = probe_config("http://127.0.0.1:8080/healthz");
1022 probe.failure_threshold = 0;
1023 app.readiness_probe = Some(probe);
1024 let err = normalize(app).unwrap_err();
1025 assert_eq!(
1026 err,
1027 NormalizeError::ZeroFailureThreshold {
1028 probe: "readiness_probe"
1029 }
1030 );
1031 // fails if the message regresses to a bare variant name with no
1032 // explanation — following the sibling precedent at app.rs:261.
1033 assert!(err.to_string().contains("at least 1"), "{err}");
1034 }
1035
1036 #[test]
1037 fn zero_interval_rejected() {
1038 // fails if interval is never inspected — a zero interval would spin
1039 // the readiness wait as fast as the runtime allows for the whole
1040 // `listen_timeout` (`await_ready` deliberately does not floor it)
1041 let mut app = AppConfig::minimal("web", "./srv");
1042 let mut probe = probe_config("http://127.0.0.1:8080/healthz");
1043 probe.interval = UpDuration::from_millis(0);
1044 app.readiness_probe = Some(probe);
1045 let err = normalize(app).unwrap_err();
1046 assert_eq!(
1047 err,
1048 NormalizeError::IntervalBelowMinimum {
1049 probe: "readiness_probe",
1050 value: UpDuration::from_millis(0),
1051 min: MIN_READINESS_INTERVAL,
1052 }
1053 );
1054 // fails if the message regresses to a bare variant name with no
1055 // explanation — following the sibling precedent at app.rs:261.
1056 assert!(err.to_string().contains("must be at least"), "{err}");
1057 }
1058
1059 #[test]
1060 fn a_liveness_interval_under_the_floor_is_rejected_rather_than_clamped() {
1061 // fails if the liveness check is `interval == 0` rather than a
1062 // floor. A 500ms interval survives an equality check and is then
1063 // rounded UP to a full second by `spawn_liveness_task`'s own
1064 // `MIN_PROBE_INTERVAL` — an app polled at half the rate its
1065 // Flockfile asks for, with nothing anywhere to say so: that clamp
1066 // writes no record at all, so not even the daemon's own log names
1067 // it. Also fails if the rejection drops the value
1068 // the user wrote, which is the one number that tells them what to
1069 // edit.
1070 let mut app = AppConfig::minimal("web", "./srv");
1071 let mut probe = probe_config("http://127.0.0.1:8080/healthz");
1072 probe.interval = UpDuration::from_millis(500);
1073 app.liveness_probe = Some(probe);
1074 let err = normalize(app).unwrap_err();
1075 assert_eq!(
1076 err,
1077 NormalizeError::IntervalBelowMinimum {
1078 probe: "liveness_probe",
1079 value: UpDuration::from_millis(500),
1080 min: MIN_LIVENESS_INTERVAL,
1081 }
1082 );
1083 assert!(err.to_string().contains("500"), "{err}");
1084 }
1085
1086 #[test]
1087 fn a_liveness_interval_exactly_at_the_floor_is_accepted() {
1088 // fails if the comparison is `<=` rather than `<` — the floor is a
1089 // value the liveness loop honours exactly, so naming it must not be
1090 // an error (IR-40: sweep the boundary, not just past it).
1091 let mut app = AppConfig::minimal("web", "./srv");
1092 let mut probe = probe_config("http://127.0.0.1:8080/healthz");
1093 probe.interval = MIN_LIVENESS_INTERVAL;
1094 app.liveness_probe = Some(probe);
1095 assert!(normalize(app).is_ok());
1096 }
1097
1098 #[test]
1099 fn a_sub_second_readiness_interval_is_accepted() {
1100 // fails if both probes are validated against the liveness floor. A
1101 // readiness wait is bounded by `listen_timeout` and honours its
1102 // `interval` exactly as written (`await_ready` argues the case
1103 // itself), so a fast app polling every 50ms to leave `starting`
1104 // sooner is asking for something the daemon really does — refusing
1105 // it would take a working feature away to fix a clamp that only the
1106 // liveness loop has.
1107 let mut app = AppConfig::minimal("web", "./srv");
1108 let mut probe = probe_config("http://127.0.0.1:8080/healthz");
1109 probe.interval = UpDuration::from_millis(50);
1110 app.readiness_probe = Some(probe);
1111 assert!(normalize(app).is_ok());
1112 }
1113
1114 #[test]
1115 fn zero_max_memory_rejected() {
1116 // fails if `max_memory` is never inspected. Zero is a ceiling every
1117 // live process is already over, so the enforcer breaches on its
1118 // first reading and every reading after it — and the restart that
1119 // follows is automatic, which RESETS the restart budget, so
1120 // `max_restarts` never ends the loop.
1121 let mut app = AppConfig::minimal("web", "./srv");
1122 app.max_memory = Some(crate::values::MemSize::from_bytes(0));
1123 let err = normalize(app).unwrap_err();
1124 assert_eq!(
1125 err,
1126 NormalizeError::ZeroMaxMemory {
1127 name: "web".to_string()
1128 }
1129 );
1130 // fails if the message regresses to a bare variant name with no
1131 // explanation — following the sibling precedent at app.rs:261.
1132 assert!(err.to_string().contains("max_memory"), "{err}");
1133 }
1134
1135 #[test]
1136 fn a_nonzero_max_memory_is_accepted() {
1137 // fails if the check fires on `max_memory` being set at all rather
1138 // than on its being zero — that would refuse every app that
1139 // configures a limit, which is the whole feature
1140 let mut app = AppConfig::minimal("web", "./srv");
1141 app.max_memory = Some("512M".parse().unwrap());
1142 assert!(normalize(app).is_ok());
1143 }
1144
1145 /// fails if a `kill_signal` shep cannot send is accepted here. Accepting it
1146 /// is what put SIGTERM on the wire for the life of the process with nothing
1147 /// but one daemon log line to say so — the clamp this rejection replaces.
1148 #[test]
1149 fn a_kill_signal_shep_cannot_send_is_refused_by_name() {
1150 let mut app = AppConfig::minimal("web", "./srv");
1151 app.kill_signal = Some("SIGUSR1".to_string());
1152
1153 let err = normalize(app).unwrap_err();
1154
1155 assert_eq!(
1156 err,
1157 NormalizeError::InvalidKillSignal {
1158 name: "web".to_string(),
1159 value: "SIGUSR1".to_string(),
1160 }
1161 );
1162 // The message has to name the accepted set, because the operator's next
1163 // move is picking a different word and there is nowhere else to look.
1164 let rendered = err.to_string();
1165 assert!(rendered.contains("SIGUSR1"), "{rendered}");
1166 assert!(rendered.contains("SIGTERM"), "{rendered}");
1167 assert!(rendered.contains("SIGUSR2"), "{rendered}");
1168 }
1169
1170 /// fails if the four supported names, their bare forms, or a lowercase
1171 /// spelling stop being accepted. This is the compatibility half: every
1172 /// spelling `stop_signal` accepted before this task must still normalize.
1173 #[test]
1174 fn every_spelling_the_daemon_already_accepted_still_normalizes() {
1175 for name in [
1176 "SIGTERM", "TERM", "sigterm", "term", "SIGINT", "INT", "SIGQUIT", "QUIT", "SIGUSR2",
1177 "USR2", "sigusr2",
1178 ] {
1179 let mut app = AppConfig::minimal("web", "./srv");
1180 app.kill_signal = Some(name.to_string());
1181 assert!(
1182 normalize(app).is_ok(),
1183 "`{name}` was accepted before this task and must still be"
1184 );
1185 }
1186 }
1187
1188 /// fails if an unset `kill_signal` is refused — the overwhelmingly common
1189 /// case, and the one a validation pass is most likely to break by treating
1190 /// `None` as an empty string.
1191 #[test]
1192 fn an_unset_kill_signal_is_not_a_config_error() {
1193 let app = AppConfig::minimal("web", "./srv");
1194 assert!(app.kill_signal.is_none());
1195 assert!(normalize(app).is_ok());
1196 }
1197
1198 #[test]
1199 fn action_timeout_past_the_ceiling_is_rejected() {
1200 // fails if `action_timeout` is never inspected. One millisecond over
1201 // the ceiling is deliberate: a test at a round number like 60s could
1202 // pass by coincidence if the check used the wrong constant entirely
1203 // (`MAX_DEADLINE_MS` itself, say, instead of the margin under it).
1204 let mut app = AppConfig::minimal("web", "./srv");
1205 app.action_timeout = UpDuration::from_millis(MAX_ACTION_TIMEOUT.as_millis() + 1);
1206 let err = normalize(app).unwrap_err();
1207 assert_eq!(
1208 err,
1209 NormalizeError::ActionTimeoutTooLong {
1210 name: "web".to_string(),
1211 value: UpDuration::from_millis(MAX_ACTION_TIMEOUT.as_millis() + 1),
1212 max: MAX_ACTION_TIMEOUT,
1213 }
1214 );
1215 // fails if the message regresses to a bare variant name with no
1216 // explanation — following the sibling precedent at app.rs:261.
1217 assert!(err.to_string().contains("action_timeout"), "{err}");
1218 }
1219
1220 #[test]
1221 fn action_timeout_at_the_ceiling_is_accepted() {
1222 // fails if the comparison is `>=` rather than `>` — the ceiling
1223 // itself still leaves the daemon its full margin under the hard
1224 // clamp, so it is not one of the values nothing could ever satisfy.
1225 let mut app = AppConfig::minimal("web", "./srv");
1226 app.action_timeout = MAX_ACTION_TIMEOUT;
1227 assert!(normalize(app).is_ok());
1228 }
1229
1230 #[test]
1231 fn the_default_action_timeout_is_accepted() {
1232 // fails if `AppConfig::default()`'s own value ever drifts past the
1233 // ceiling normalize enforces — the one combination that must never
1234 // reject the config nobody customized.
1235 assert!(normalize(AppConfig::minimal("web", "./srv")).is_ok());
1236 }
1237
1238 #[test]
1239 fn zero_watch_delay_rejected() {
1240 // fails if `watch_delay` is never inspected. notify's debouncer
1241 // derives its poll tick as `watch_delay / 4` and sleeps it on its own
1242 // OS thread, so zero is `loop { sleep(0); lock(); }` — measured at
1243 // 5.98s of user CPU across a three-second watch that costs 0.00s at
1244 // the 500ms default.
1245 let mut app = AppConfig::minimal("web", "./srv");
1246 app.watch = true;
1247 app.cwd = Some("/srv/web".to_string());
1248 app.watch_delay = Some(UpDuration::from_millis(0));
1249 let err = normalize(app).unwrap_err();
1250 assert_eq!(
1251 err,
1252 NormalizeError::ZeroWatchDelay {
1253 name: "web".to_string()
1254 }
1255 );
1256 // fails if the message regresses to a bare variant name with no
1257 // explanation — following the sibling precedent at app.rs:261.
1258 assert!(err.to_string().contains("watch_delay"), "{err}");
1259 }
1260
1261 #[test]
1262 fn a_zero_watch_delay_is_rejected_with_watch_off() {
1263 // fails if the check is nested inside the `watch` block: an app
1264 // carrying `watch_delay = "0"` with `watch = false` would normalize
1265 // clean, and the spin would arrive the day someone flips `watch =
1266 // true` — the same reasoning that puts the glob checks outside it
1267 let mut app = AppConfig::minimal("web", "./srv");
1268 app.watch_delay = Some(UpDuration::from_millis(0));
1269 assert!(matches!(
1270 normalize(app).unwrap_err(),
1271 NormalizeError::ZeroWatchDelay { .. }
1272 ));
1273 }
1274
1275 #[test]
1276 fn a_nonzero_watch_delay_is_accepted() {
1277 // fails if the check fires on `watch_delay` being set at all rather
1278 // than on its being zero — that would refuse every app that tunes
1279 // its own debounce
1280 let mut app = AppConfig::minimal("web", "./srv");
1281 app.watch = true;
1282 app.cwd = Some("/srv/web".to_string());
1283 app.watch_delay = Some(UpDuration::from_millis(1));
1284 assert!(normalize(app).is_ok());
1285 }
1286
1287 #[test]
1288 fn default_failure_threshold_from_toml_accepted() {
1289 // fails if the check fires on the ordinary default instead of only
1290 // an explicit 0. Deserializes a Flockfile snippet that omits
1291 // `failure_threshold` entirely, so this exercises the real
1292 // `#[serde(default = "default_failure_threshold")]` path
1293 // (config/app.rs) rather than duplicating `probe_config`'s
1294 // hardcoded `3` — a literal that wouldn't notice if the wired
1295 // default ever changed.
1296 let src = r#"
1297name = "web"
1298script = "./srv"
1299
1300[readiness_probe]
1301kind = "http"
1302target = "http://127.0.0.1:8080/healthz"
1303"#;
1304 let app: AppConfig = toml::from_str(src).unwrap();
1305 assert!(normalize(app).is_ok());
1306 }
1307
1308 #[test]
1309 fn watch_true_without_cwd_rejected_naming_the_app() {
1310 // fails if a validator never looks at `watch`, or looks at it but
1311 // carries no app name, leaving the user unable to tell which
1312 // Flockfile entry to edit
1313 let mut app = AppConfig::minimal("web", "./srv");
1314 app.watch = true;
1315 let err = normalize(app).unwrap_err();
1316 assert_eq!(
1317 err,
1318 NormalizeError::WatchWithoutCwd {
1319 name: "web".to_string()
1320 }
1321 );
1322 // fails if the message regresses to a bare variant name with no
1323 // explanation — following the sibling precedent at app.rs:261.
1324 assert!(err.to_string().contains("no cwd to watch"), "{err}");
1325 }
1326
1327 #[test]
1328 fn watch_true_with_cwd_accepted() {
1329 // fails if the check fires on `watch` alone, ignoring that a cwd was
1330 // actually provided
1331 let mut app = AppConfig::minimal("web", "./srv");
1332 app.watch = true;
1333 app.cwd = Some("/srv/web".to_string());
1334 assert!(normalize(app).is_ok());
1335 }
1336
1337 #[test]
1338 fn a_watch_options_glob_that_will_not_compile_is_rejected() {
1339 // fails if `watch_options` patterns are never compiled at config
1340 // time — the sheep would then report `online` with no watch armed
1341 // and nothing but a log line to say so. Also fails if the rejection
1342 // blames the whole list instead of the one bad pattern: the valid
1343 // `src/**` comes first, so an error carrying it, or carrying the
1344 // patterns joined together, is not the pattern the user must fix.
1345 let mut app = AppConfig::minimal("web", "./srv");
1346 app.watch = true;
1347 app.cwd = Some("/srv/web".to_string());
1348 app.watch_options = vec!["src/**".to_string(), "[".to_string()];
1349 let err = normalize(app).unwrap_err();
1350 assert_eq!(
1351 err,
1352 NormalizeError::InvalidWatchGlob {
1353 name: "web".to_string(),
1354 field: "watch_options",
1355 pattern: "[".to_string(),
1356 reason: Glob::new("[").unwrap_err().to_string(),
1357 }
1358 );
1359 // fails if the message drops the app name, the list or the pattern —
1360 // the three things that name the Flockfile line to edit.
1361 let rendered = err.to_string();
1362 for expected in ["web", "watch_options", "`[`"] {
1363 assert!(
1364 rendered.contains(expected),
1365 "{expected} missing: {rendered}"
1366 );
1367 }
1368 }
1369
1370 #[test]
1371 fn an_ignore_watch_glob_that_will_not_compile_is_rejected() {
1372 // fails if only `watch_options` is ever compiled, leaving a mistyped
1373 // `ignore_watch` to cost the app its watch at arm time instead
1374 let mut app = AppConfig::minimal("web", "./srv");
1375 app.watch = true;
1376 app.cwd = Some("/srv/web".to_string());
1377 app.ignore_watch = vec!["[".to_string()];
1378 match normalize(app).unwrap_err() {
1379 NormalizeError::InvalidWatchGlob { field, pattern, .. } => {
1380 assert_eq!(field, "ignore_watch");
1381 assert_eq!(pattern, "[");
1382 }
1383 other => panic!("expected InvalidWatchGlob, got {other:?}"),
1384 }
1385 }
1386
1387 #[test]
1388 fn a_glob_that_will_not_compile_is_rejected_with_watch_off() {
1389 // fails if glob validation is nested inside the `watch` check: an app
1390 // carrying a mistyped glob with `watch = false` would then normalize
1391 // clean, and the typo would surface only the day someone flips
1392 // `watch = true`
1393 let mut app = AppConfig::minimal("web", "./srv");
1394 app.watch_options = vec!["[".to_string()];
1395 assert!(matches!(
1396 normalize(app).unwrap_err(),
1397 NormalizeError::InvalidWatchGlob { .. }
1398 ));
1399 }
1400
1401 #[test]
1402 fn well_formed_watch_globs_are_accepted() {
1403 // fails if the new check rejects patterns globset compiles happily —
1404 // recursive `**`, a character class, a negated character class and a
1405 // brace alternation are all ordinary globset syntax a Flockfile is
1406 // entitled to use. Also fails if the check is wired to a parser that
1407 // is not globset's: every one of these is valid to globset and a
1408 // syntax error to a regex engine.
1409 let mut app = AppConfig::minimal("web", "./srv");
1410 app.watch = true;
1411 app.cwd = Some("/srv/web".to_string());
1412 app.watch_options = vec!["src/**/*.rs".to_string(), "*.[ch]".to_string()];
1413 app.ignore_watch = vec!["target/**".to_string(), "**/[!.]*.{tmp,swp}".to_string()];
1414 assert!(normalize(app).is_ok());
1415 }
1416
1417 #[test]
1418 fn watch_options_without_watch_or_cwd_accepted() {
1419 // fails if the check is keyed on `watch_options` being non-empty
1420 // rather than on `watch` being true — that would reject a Flockfile
1421 // that never asked to be watched
1422 let mut app = AppConfig::minimal("web", "./srv");
1423 app.watch_options = vec!["src/**".to_string()];
1424 assert!(normalize(app).is_ok());
1425 }
1426}