Skip to main content

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::secrets;
18use crate::values::UpDuration;
19
20/// Shortest `interval` a `liveness_probe` may name.
21///
22/// The daemon's liveness loop floors whatever it is handed at this same
23/// value, so a smaller number would be silently honoured as this one with
24/// no warning.
25///
26/// One second is a floor no legitimate configuration wants to be under: for
27/// [`ProbeKind::Exec`](crate::config::ProbeKind::Exec) a shorter interval is
28/// that many process spawns per second, per sheep, for as long as it runs.
29const MIN_LIVENESS_INTERVAL: UpDuration = UpDuration::from_millis(1_000);
30
31/// Shortest `interval` a `readiness_probe` may name.
32///
33/// A whole second lower than [`MIN_LIVENESS_INTERVAL`]: the readiness wait
34/// honours its `interval` exactly as written, bounded by `listen_timeout`,
35/// so only zero needs rejecting. A fast app polling every 20ms to leave
36/// `starting` sooner must not lose that.
37const MIN_READINESS_INTERVAL: UpDuration = UpDuration::from_millis(1);
38
39/// Longest `action_timeout` an app may name.
40///
41/// The daemon clamps every RPC deadline to `MAX_DEADLINE_MS` (60s, in
42/// shep-daemon's `rpc` module), so a value at or above that line could
43/// never be honoured by any caller. Set 2s under the clamp so the daemon
44/// still has room to build the `TimedOut` row and send it back after the
45/// wait gives up.
46const MAX_ACTION_TIMEOUT: UpDuration = UpDuration::from_millis(58_000);
47
48/// A validated app config: only obtainable via [`normalize`].
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct ResolvedApp {
51    config: AppConfig,
52}
53
54impl ResolvedApp {
55    /// Borrow the validated configuration
56    #[must_use]
57    pub fn config(&self) -> &AppConfig {
58        &self.config
59    }
60
61    /// Unwrap the validated configuration (consumes the proof token)
62    #[must_use]
63    pub fn into_config(self) -> AppConfig {
64        self.config
65    }
66}
67
68/// Expands a leading `~/` against `home`, and refuses `~user/`.
69///
70/// `~/` only: `~user/...` needs a passwd lookup whose answer depends on who
71/// the daemon runs as, and `$VAR` is never expanded here or anywhere. A
72/// value with no leading `~` is returned unchanged. Thin wrapper over
73/// [`expand_home_tilde`], attaching the sheep name and field
74/// [`NormalizeError`] carries; `shep-cli`'s `shep adopt` calls
75/// [`expand_home_tilde`] directly instead.
76///
77/// # Errors
78/// - [`NormalizeError::TildeUser`] if the path names another user's home.
79/// - [`NormalizeError::NoHomeForTilde`] if `~/` is used and `home` is `None`.
80fn expand_tilde(
81    value: &str,
82    home: Option<&Path>,
83    name: &str,
84    field: &'static str,
85) -> Result<String, NormalizeError> {
86    expand_home_tilde(value, home).map_err(|err| match err {
87        TildeError::OtherUser => NormalizeError::TildeUser {
88            name: name.to_string(),
89            field,
90            value: value.to_string(),
91        },
92        TildeError::NoHome => NormalizeError::NoHomeForTilde {
93            name: name.to_string(),
94            field,
95        },
96    })
97}
98
99/// Why [`expand_home_tilde`] refused a value, with no per-field context
100/// attached, for a caller that has none to give.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum TildeError {
103    /// The value names another user's home (`~user/...`). Refused rather
104    /// than resolved: answering it means a passwd lookup, and under a
105    /// systemd unit the answer depends on who the process runs as rather
106    /// than on who wrote the value.
107    OtherUser,
108    /// The value begins `~/` and no home directory could be determined.
109    NoHome,
110}
111
112impl fmt::Display for TildeError {
113    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114        match self {
115            Self::OtherUser => write!(
116                f,
117                "shep expands only `~/` (your own home); another user's home needs a \
118                 passwd lookup whose answer depends on who the process runs as"
119            ),
120            Self::NoHome => write!(f, "begins with `~/` but no home directory could be found"),
121        }
122    }
123}
124
125impl core::error::Error for TildeError {}
126
127/// Expands a leading `~/` in `value` against `home`
128///
129/// Accepts `~` alone or `~/...`. `~user/` is refused, since resolving it
130/// takes a passwd lookup whose answer depends on who the process runs as.
131/// `$VAR` is never expanded. A value with no leading `~` comes back
132/// unchanged.
133///
134/// # Errors
135/// - [`TildeError::OtherUser`] if the value names another user's home.
136/// - [`TildeError::NoHome`] if `~/` is used and `home` is `None`.
137pub fn expand_home_tilde(value: &str, home: Option<&Path>) -> Result<String, TildeError> {
138    let Some(rest) = value.strip_prefix('~') else {
139        return Ok(value.to_string());
140    };
141    // `~` alone, or `~/...`. Anything else after the tilde names a user.
142    if !(rest.is_empty() || rest.starts_with('/')) {
143        return Err(TildeError::OtherUser);
144    }
145    let Some(home) = home else {
146        return Err(TildeError::NoHome);
147    };
148    // `join` would discard `home` for a rest that still looks absolute, so
149    // the separator is trimmed and the two halves are concatenated instead.
150    let joined = home.join(rest.trim_start_matches('/'));
151    Ok(joined.to_string_lossy().into_owned())
152}
153
154/// Every field of an [`AppConfig`] that carries a filesystem path.
155///
156/// Named once, and walked by [`expand_paths`] and by its own test, so a
157/// fifth path field added later fails that test until it is handled.
158/// Expanding `~/` in some path fields and not others would be worse than
159/// expanding in none: it teaches that tildes work and then fails somewhere
160/// the operator has no reason to suspect.
161#[cfg(test)]
162const PATH_FIELDS: &[&str] = &["script", "cwd", "out_file", "err_file"];
163
164/// Expands `~/` in every path field of `app`, in place.
165///
166/// # Errors
167/// Whatever [`expand_tilde`] refuses, named with the field that carried it.
168fn expand_paths(app: &mut AppConfig, home: Option<&Path>) -> Result<(), NormalizeError> {
169    let name = app.name.clone();
170    app.script = expand_tilde(&app.script, home, &name, "script")?;
171    for (field, slot) in [
172        ("cwd", &mut app.cwd),
173        ("out_file", &mut app.out_file),
174        ("err_file", &mut app.err_file),
175    ] {
176        if let Some(value) = slot {
177            *slot = Some(expand_tilde(value, home, &name, field)?);
178        }
179    }
180    Ok(())
181}
182
183/// Validates one app config
184///
185/// # Errors
186///
187/// - [`NormalizeError::MissingName`]: `name` is empty.
188/// - [`NormalizeError::InvalidName`]: `name` contains a path separator or a colon, or is `.`/`..`.
189/// - [`NormalizeError::InvalidEnvironment`]: `environment` is `all`, the secrets store's every-environment slot, or falls outside the store's name grammar.
190/// - [`NormalizeError::ReservedEnvVar`]: `env` sets `SHEP_INSTANCE`, `SHEP_NAME` or `SHEP_ENVIRONMENT`, which shep injects itself.
191/// - [`NormalizeError::IncrementVarRemoved`]: `increment_var` is set; removed in favour of `{{instance}}` templating.
192/// - [`NormalizeError::MissingScript`]: `script` is empty.
193/// - [`NormalizeError::ZeroInstances`]: `instances == 0`.
194/// - [`NormalizeError::InvalidCron`]: `cron_restart` is not valid in croner's dialect.
195/// - [`NormalizeError::InvalidTimezone`]: `cron_timezone` is not a name in the IANA time-zone database.
196/// - [`NormalizeError::InvalidProbe`]: a `readiness_probe`/`liveness_probe` target [`ProbeTarget::parse`] rejects.
197/// - [`NormalizeError::ZeroFailureThreshold`]: a probe's `failure_threshold` is explicitly `0`.
198/// - [`NormalizeError::IntervalBelowMinimum`]: a probe's `interval` is under the floor its own loop honours.
199/// - [`NormalizeError::ZeroMaxMemory`]: `max_memory` is `0`.
200/// - [`NormalizeError::ActionTimeoutTooLong`]: `action_timeout` is at or above the ceiling no RPC caller could wait past.
201/// - [`NormalizeError::InvalidKillSignal`]: `kill_signal` names a signal the daemon's stop ladder cannot send.
202/// - [`NormalizeError::WatchWithoutCwd`]: `watch` is `true` with no `cwd` set.
203/// - [`NormalizeError::ZeroWatchDelay`]: `watch_delay` is `0`.
204/// - [`NormalizeError::InvalidWatchGlob`]: a `watch_options` or `ignore_watch` pattern globset will not compile.
205/// - [`NormalizeError::BadTemplate`]: an `env`/`args`/log-path value carries an undefined or unclosed `{{...}}` token.
206/// - [`NormalizeError::SecretInLogPath`]: `out_file` or `err_file` carries a `{{secret:...}}`.
207/// - [`NormalizeError::SharedLogPath`]: `out_file` or `err_file` renders to the same path for two instances.
208/// - [`NormalizeError::TildeUser`]: a path field names another user's `~user` home.
209/// - [`NormalizeError::NoHomeForTilde`]: a path field expands `~/` but no home directory could be found.
210/// - [`NormalizeError::SelfDependency`]: an app names itself in `depends_on`.
211/// - [`NormalizeError::InstanceDependency`]: a `depends_on` entry is written `name:slot`.
212pub fn normalize(app: AppConfig) -> Result<ResolvedApp, NormalizeError> {
213    normalize_with_home(app, std::env::home_dir().as_deref())
214}
215
216/// [`normalize`], with the home directory supplied rather than read.
217///
218/// A parameter so the `~/` expansion above is testable without mutating the
219/// process environment, which is racy under a parallel `cargo test`. This is
220/// also the seam that matters for correctness rather than only for tests:
221/// the daemon may run as a different user than the CLI, so `~` has to be
222/// resolved where the config is normalised, not where it is executed.
223///
224/// # Errors
225/// The same set [`normalize`] documents.
226pub fn normalize_with_home(
227    mut app: AppConfig,
228    home: Option<&Path>,
229) -> Result<ResolvedApp, NormalizeError> {
230    if app.name.is_empty() {
231        return Err(NormalizeError::MissingName);
232    }
233    if app.name.contains(['/', '\\', ':']) || app.name == "." || app.name == ".." {
234        return Err(NormalizeError::InvalidName(app.name));
235    }
236    if let Some(environment) = &app.environment
237        && (environment == secrets::ALL_ENVIRONMENTS || !secrets::is_name(environment))
238    {
239        return Err(NormalizeError::InvalidEnvironment {
240            name: app.name.clone(),
241            value: environment.clone(),
242        });
243    }
244    for var in ["SHEP_INSTANCE", "SHEP_NAME", "SHEP_ENVIRONMENT"] {
245        if app.env.contains_key(var) {
246            return Err(NormalizeError::ReservedEnvVar {
247                name: app.name.clone(),
248                var,
249            });
250        }
251    }
252    if let Some(var) = app.increment_var.take() {
253        return Err(NormalizeError::IncrementVarRemoved {
254            name: app.name.clone(),
255            var,
256        });
257    }
258    for (key, value) in &app.env {
259        validate_template(&app.name, &format!("env.{key}"), value)?;
260    }
261    for (index, value) in app.args.iter().enumerate() {
262        validate_template(&app.name, &format!("args[{index}]"), value)?;
263    }
264    for (field, value) in [("out_file", &app.out_file), ("err_file", &app.err_file)] {
265        if let Some(value) = value {
266            validate_template(&app.name, field, value)?;
267            if crate::config::template::holds_secret(value) {
268                return Err(NormalizeError::SecretInLogPath {
269                    name: app.name.clone(),
270                    field,
271                });
272            }
273        }
274    }
275    if app.script.is_empty() {
276        return Err(NormalizeError::MissingScript);
277    }
278    // After the emptiness checks, so a missing script is reported as missing
279    // rather than as a path problem, and before every check below that reads
280    // a path.
281    expand_paths(&mut app, home)?;
282    if app.instances == 0 {
283        return Err(NormalizeError::ZeroInstances);
284    }
285    // After the template validation above, so a malformed `out_file`/`err_file`
286    // is reported as a bad template rather than as a shared path.
287    if app.instances > 1 && !app.merge_logs {
288        for (field, path) in [("out_file", &app.out_file), ("err_file", &app.err_file)] {
289            // Rendered rather than searched for a substring: an escaped
290            // `{{{{instance}}}}` contains the token's spelling but renders to
291            // one literal path for every instance, which is exactly the
292            // collision this refuses. Two slots that render alike collide.
293            if let Some(path) = path
294                && crate::config::template::render_positional(path, &app.name, 0)
295                    == crate::config::template::render_positional(path, &app.name, 1)
296            {
297                return Err(NormalizeError::SharedLogPath {
298                    name: app.name.clone(),
299                    field,
300                });
301            }
302        }
303    }
304    if let Some(pattern) = &app.cron_restart {
305        CronSchedule::parse(pattern, app.cron_timezone.as_deref()).map_err(|e| match e {
306            CronParseError::Pattern { pattern, reason } => {
307                NormalizeError::InvalidCron { pattern, reason }
308            }
309            CronParseError::Timezone { name } => NormalizeError::InvalidTimezone { name },
310        })?;
311    } else if let Some(tz_name) = &app.cron_timezone {
312        // A Flockfile can carry `cron_timezone` with no `cron_restart` to
313        // pair it with, still a typo the user wants to hear about.
314        crate::config::cron::parse_timezone_name(tz_name).ok_or_else(|| {
315            NormalizeError::InvalidTimezone {
316                name: tz_name.clone(),
317            }
318        })?;
319    }
320    validate_probe(
321        app.readiness_probe.as_ref(),
322        "readiness_probe",
323        MIN_READINESS_INTERVAL,
324    )?;
325    validate_probe(
326        app.liveness_probe.as_ref(),
327        "liveness_probe",
328        MIN_LIVENESS_INTERVAL,
329    )?;
330    if app.max_memory.is_some_and(|limit| limit.bytes() == 0) {
331        // A ceiling every live process is already over: the enforcer would
332        // report a breach on every reading, and the automatic restart that
333        // follows resets the restart budget rather than spending it, so
334        // `max_restarts` can never end the loop.
335        return Err(NormalizeError::ZeroMaxMemory { name: app.name });
336    }
337    if let Some(name) = &app.kill_signal
338        && KillSignal::parse(name).is_none()
339    {
340        // Rejected rather than clamped: a typo silently falling back to
341        // SIGTERM would cost every stop and reload for the life of the
342        // process, with no evidence but a detached daemon's log.
343        return Err(NormalizeError::InvalidKillSignal {
344            name: app.name,
345            value: name.clone(),
346        });
347    }
348    if app.action_timeout > MAX_ACTION_TIMEOUT {
349        // Rejected rather than clamped: every value above the ceiling is
350        // equally unreachable by any caller, so there is no honest lowered
351        // value to silently fall back to.
352        return Err(NormalizeError::ActionTimeoutTooLong {
353            name: app.name,
354            value: app.action_timeout,
355            max: MAX_ACTION_TIMEOUT,
356        });
357    }
358    if app.watch && app.cwd.is_none() {
359        // `watch` asked for a feature the daemon has no directory to arm:
360        // there is no cwd in the Flockfile, and defaulting to the daemon's
361        // own cwd risks watching the whole filesystem under a systemd unit
362        // with no `WorkingDirectory=`.
363        return Err(NormalizeError::WatchWithoutCwd { name: app.name });
364    }
365    if app.watch_delay == Some(UpDuration::from_millis(0)) {
366        // notify's debouncer derives its own poll tick as `watch_delay / 4`
367        // on a dedicated OS thread, so a zero turns that thread into
368        // `loop { sleep(0); lock(); }`, a CPU-spinning busy loop.
369        return Err(NormalizeError::ZeroWatchDelay { name: app.name });
370    }
371    // Both lists are checked whether or not `watch` is on, so a typo'd
372    // pattern is named now rather than the day `watch` flips on and nothing
373    // happens.
374    validate_watch_globs(&app.name, "watch_options", &app.watch_options)?;
375    validate_watch_globs(&app.name, "ignore_watch", &app.ignore_watch)?;
376    let mut seen = BTreeSet::new();
377    let mut deduped = Vec::with_capacity(app.depends_on.len());
378    for target in &app.depends_on {
379        if target == &app.name {
380            return Err(NormalizeError::SelfDependency(app.name.clone()));
381        }
382        if target.contains(':') {
383            return Err(NormalizeError::InstanceDependency {
384                sheep: app.name.clone(),
385                target: target.clone(),
386            });
387        }
388        if seen.insert(target.clone()) {
389            deduped.push(target.clone());
390        }
391    }
392    app.depends_on = deduped;
393    Ok(ResolvedApp { config: app })
394}
395
396/// Validates one `{{instance}}`/`{{name}}` template value, naming `field` in
397/// any rejection so the user knows which entry to edit.
398///
399/// # Errors
400/// [`NormalizeError::BadTemplate`] if `value` carries a `{{...}}` this
401/// grammar does not define, or a `{{` this value never closes. Both of
402/// [`crate::config::template::validate`]'s own rejections map here, so the
403/// two are told apart by the rendered `reason` the variant carries rather
404/// than by the variant.
405fn validate_template(name: &str, field: &str, value: &str) -> Result<(), NormalizeError> {
406    crate::config::template::validate(value).map_err(|reason| NormalizeError::BadTemplate {
407        name: name.to_string(),
408        field: field.to_string(),
409        reason: reason.to_string(),
410    })
411}
412
413/// Validates one of an app's two watch glob lists, rejecting any pattern
414/// globset will not compile. `field` is the Flockfile field name
415/// (`"watch_options"` or `"ignore_watch"`), carried into any error so the
416/// user knows which list to edit. The compiled globs are discarded: this
417/// function's job is rejection, and the daemon builds its own watch filter
418/// when it arms the watch.
419fn validate_watch_globs(
420    name: &str,
421    field: &'static str,
422    patterns: &[String],
423) -> Result<(), NormalizeError> {
424    for pattern in patterns {
425        Glob::new(pattern).map_err(|err| NormalizeError::InvalidWatchGlob {
426            name: name.to_string(),
427            field,
428            pattern: pattern.clone(),
429            reason: err.to_string(),
430        })?;
431    }
432    Ok(())
433}
434
435/// Validates one probe's target, `failure_threshold` and `interval`, if the
436/// probe is configured. `probe` is the Flockfile field name
437/// (`"readiness_probe"` or `"liveness_probe"`), carried into any error so
438/// the user knows which field to edit; `min_interval` is the floor that
439/// probe's own loop in the daemon honours. Its own parsed [`ProbeTarget`] is
440/// discarded: the daemon re-parses when it arms the probe.
441fn validate_probe(
442    probe: Option<&ProbeConfig>,
443    name: &'static str,
444    min_interval: UpDuration,
445) -> Result<(), NormalizeError> {
446    let Some(probe) = probe else {
447        return Ok(());
448    };
449    ProbeTarget::parse(probe).map_err(|reason| NormalizeError::InvalidProbe {
450        probe: name,
451        reason: reason.to_string(),
452    })?;
453    if probe.failure_threshold == 0 {
454        // Unhealthy before the first probe ever runs would make the liveness
455        // loop restart the sheep immediately and forever.
456        return Err(NormalizeError::ZeroFailureThreshold { probe: name });
457    }
458    if probe.interval < min_interval {
459        // A zero interval would spin either probe loop as fast as
460        // `ProbeKind::Exec` can spawn processes. A small but nonzero
461        // liveness interval is refused too: `spawn_liveness_task` rounds
462        // it up silently, leaving nothing to report the discrepancy.
463        return Err(NormalizeError::IntervalBelowMinimum {
464            probe: name,
465            value: probe.interval,
466            min: min_interval,
467        });
468    }
469    Ok(())
470}
471
472/// Validates a whole flock, rejecting duplicate sheep names
473///
474/// # Errors
475///
476/// Everything [`normalize`] returns, plus
477/// [`NormalizeError::DuplicateName`]: two apps share a `name`.
478/// [`NormalizeError::DependencyCycle`]: two apps in the document depend on each other.
479pub fn normalize_all(apps: Vec<AppConfig>) -> Result<Vec<ResolvedApp>, NormalizeError> {
480    let mut seen = BTreeSet::new();
481    let resolved: Vec<ResolvedApp> = apps
482        .into_iter()
483        .map(|app| {
484            if !seen.insert(app.name.clone()) {
485                return Err(NormalizeError::DuplicateName(app.name));
486            }
487            normalize(app)
488        })
489        .collect::<Result<_, _>>()?;
490    // Document-local only: a name this document does not hold is left to the
491    // daemon, which is the only place that knows the whole flock.
492    let nodes: Vec<crate::config::graph::BootNode> = resolved
493        .iter()
494        .map(|app| crate::config::graph::BootNode {
495            name: app.config().name.clone(),
496            depends_on: app.config().depends_on.clone(),
497            kind: crate::config::graph::NodeKind::Sheep,
498        })
499        .collect();
500    if let Some(cycle) = crate::config::graph::plan(&nodes).cycles.into_iter().next() {
501        return Err(NormalizeError::DependencyCycle(cycle));
502    }
503    Ok(resolved)
504}
505
506/// Error type returned from [`normalize`] and [`normalize_all`]
507///
508/// `#[non_exhaustive]`: every config surface this crate learns to validate
509/// brings its own rejection reasons with it.
510#[non_exhaustive]
511#[derive(Debug, Clone, PartialEq, Eq)]
512pub enum NormalizeError {
513    /// `name` is empty
514    MissingName,
515    /// `name` contains `/`, `\` or `:`, or is `.`/`..`. A path separator
516    /// would escape the shep home, since the name becomes a filesystem path
517    /// stem; a colon is the `name:slot` separator, and is also illegal in a
518    /// Windows filename, which a sheep name becomes part of. Carries the
519    /// name.
520    InvalidName(String),
521    /// `environment` is [`crate::secrets::ALL_ENVIRONMENTS`], the secrets
522    /// store's every-environment slot, or falls outside the grammar
523    /// [`crate::secrets`] keys and environment names share. Carries the
524    /// sheep name and the value as written.
525    InvalidEnvironment {
526        /// The sheep name, so the error names which Flockfile entry to edit.
527        name: String,
528        /// The value as the user wrote it.
529        value: String,
530    },
531    /// An app's `env` sets a variable shep injects itself. Carries the sheep
532    /// name and the variable, so the error names the entry to edit.
533    ReservedEnvVar {
534        /// The sheep name
535        name: String,
536        /// The variable the app tried to set
537        var: &'static str,
538    },
539    /// `increment_var` was removed in favour of `{{instance}}` templating.
540    /// Carries the variable the app named, so the error can show the exact
541    /// line to write instead.
542    IncrementVarRemoved {
543        /// The sheep name
544        name: String,
545        /// The variable the app asked for
546        var: String,
547    },
548    /// `script` is empty
549    MissingScript,
550    /// `instances` is zero
551    ZeroInstances,
552    /// `cron_restart` is not valid in croner's dialect. Carries the pattern
553    /// and the rejection reason.
554    InvalidCron {
555        /// The pattern as the user wrote it
556        pattern: String,
557        /// Why it was rejected
558        reason: String,
559    },
560    /// `cron_timezone` is not a name in the IANA time-zone database
561    InvalidTimezone {
562        /// The value as the user wrote it
563        name: String,
564    },
565    /// Two apps in one flock share this name
566    DuplicateName(String),
567    /// Two or more apps in one document depend on each other. Carries the
568    /// cycle as a path, so the refusal names it rather than only reporting
569    /// that one exists.
570    DependencyCycle(Vec<String>),
571    /// A `readiness_probe` or `liveness_probe` target is malformed. Carries
572    /// which probe and the rendered reason.
573    InvalidProbe {
574        /// `"readiness_probe"` or `"liveness_probe"`, so the error names the
575        /// line the user has to edit.
576        probe: &'static str,
577        /// [`ProbeTarget::parse`]'s rendered rejection reason.
578        reason: String,
579    },
580    /// A `readiness_probe` or `liveness_probe` has `failure_threshold == 0`.
581    ZeroFailureThreshold {
582        /// `"readiness_probe"` or `"liveness_probe"`, so the error names the
583        /// line the user has to edit.
584        probe: &'static str,
585    },
586    /// A `readiness_probe` or `liveness_probe` has an `interval` under the
587    /// floor its own loop in the daemon honours. At `0` that would spin the
588    /// loop as fast as the runtime allows; a `liveness_probe` under a full
589    /// second would instead be silently polled at that second.
590    IntervalBelowMinimum {
591        /// `"readiness_probe"` or `"liveness_probe"`, so the error names the
592        /// line the user has to edit.
593        probe: &'static str,
594        /// The value as the user wrote it.
595        value: UpDuration,
596        /// The floor it failed.
597        min: UpDuration,
598    },
599    /// `max_memory` is `0`, a ceiling every live process is already over, so
600    /// the enforcer would restart the sheep on every poll forever. Carries
601    /// the app name.
602    ZeroMaxMemory {
603        /// The sheep name, so the error names which Flockfile entry to edit.
604        name: String,
605    },
606    /// `action_timeout` is at or above `normalize`'s own ceiling: a wait no
607    /// RPC caller could ever be given enough deadline to outlast, since the
608    /// daemon clamps every deadline a caller can ask for. Carries the app
609    /// name, the value as written, and the ceiling it failed.
610    ActionTimeoutTooLong {
611        /// The sheep name, so the error names which Flockfile entry to edit.
612        name: String,
613        /// The value as the user wrote it.
614        value: UpDuration,
615        /// The ceiling it failed.
616        max: UpDuration,
617    },
618    /// `kill_signal` names a signal the daemon's stop ladder cannot send.
619    /// Carries the app name and the value as written.
620    InvalidKillSignal {
621        /// The sheep name, so the error names which Flockfile entry to edit.
622        name: String,
623        /// The value as the user wrote it.
624        value: String,
625    },
626    /// `watch` is enabled but the app sets no `cwd`, so there is no
627    /// directory to watch. Carries the app name.
628    WatchWithoutCwd {
629        /// The sheep name, so the error names which Flockfile entry to edit.
630        name: String,
631    },
632    /// A path begins `~user/`, naming another user's home.
633    ///
634    /// Refused rather than resolved: answering it means a passwd lookup, and
635    /// under a systemd unit the answer is not obviously the one anyone meant.
636    /// `~/` is supported; this is not.
637    TildeUser {
638        /// The sheep name, so the error names which Flockfile entry to edit.
639        name: String,
640        /// Which field carried it.
641        field: &'static str,
642        /// The path as written.
643        value: String,
644    },
645    /// A path begins `~/` and no home directory could be determined.
646    NoHomeForTilde {
647        /// The sheep name, so the error names which Flockfile entry to edit.
648        name: String,
649        /// Which field carried it.
650        field: &'static str,
651    },
652    /// `watch_delay` is `0`, which would spin the debouncer's own OS thread.
653    /// Carries the app name.
654    ZeroWatchDelay {
655        /// The sheep name, so the error names which Flockfile entry to edit.
656        name: String,
657    },
658    /// A `watch_options` or `ignore_watch` pattern is one globset will not
659    /// compile, so the watch it describes could never be armed.
660    InvalidWatchGlob {
661        /// The sheep name, so the error names which Flockfile entry to edit.
662        name: String,
663        /// `"watch_options"` or `"ignore_watch"`, so the error names which
664        /// of the two lists to edit.
665        field: &'static str,
666        /// The pattern as the user wrote it.
667        pattern: String,
668        /// globset's own rendered reason.
669        reason: String,
670    },
671    /// A value carries a `{{...}}` that is not a template token, or a `{{`
672    /// it never closes. Carries the sheep name, which field held it, and the
673    /// rejection rendered.
674    BadTemplate {
675        /// The sheep name
676        name: String,
677        /// Which field, for example `env.WORKER` or `args[1]`
678        field: String,
679        /// The template grammar's own error, rendered, so this
680        /// variant does not have to restate the grammar's own copy
681        reason: String,
682    },
683    /// An explicit `out_file` or `err_file` carries a `{{secret:...}}`.
684    ///
685    /// A resolved log path is not contained the way a resolved `env` value
686    /// is: it reaches `ProcessInfo`, every bus event carrying one,
687    /// `shep flock`, `shep describe`, `shep lookout`, and a filename on disk
688    /// under `$SHEP_HOME/logs`. `{{instance}}` and `{{name}}` still render
689    /// there; only a secret is refused.
690    SecretInLogPath {
691        /// The sheep name
692        name: String,
693        /// `out_file` or `err_file`
694        field: &'static str,
695    },
696    /// An explicit log path renders to the same string for two different
697    /// slots, the app runs more than one instance, and `merge_logs` is off,
698    /// so every instance would write to one file without having asked to.
699    /// A path with no `{{instance}}` is the ordinary case; a `{{name}}`-only
700    /// path and an escaped `{{{{instance}}}}` collide for the same reason.
701    SharedLogPath {
702        /// The sheep name
703        name: String,
704        /// `out_file` or `err_file`
705        field: &'static str,
706    },
707    /// An app names itself in `depends_on`. Carries the sheep name. A
708    /// one-node cycle, caught here rather than in the graph because it is
709    /// visible in a single `AppConfig`.
710    SelfDependency(String),
711    /// A `depends_on` entry names one instance rather than an app. Carries
712    /// the sheep and the offending target, so the refusal can name both.
713    InstanceDependency {
714        /// The sheep whose list holds the entry
715        sheep: String,
716        /// The entry as written
717        target: String,
718    },
719}
720
721impl fmt::Display for NormalizeError {
722    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
723        match self {
724            Self::MissingName => f.write_str("app config is missing a name"),
725            Self::InvalidName(n) => {
726                write!(
727                    f,
728                    "sheep name `{n}` may not contain a path separator or a colon, or be `.` or `..`; use `-` in place of a colon"
729                )
730            }
731            Self::InvalidEnvironment { name, value } => write!(
732                f,
733                "sheep `{name}` has environment = `{value}`: must be 1-128 bytes of \
734                 `[A-Za-z0-9._-]` not starting with `.`, and not `{}` (the secrets \
735                 store's every-environment slot)",
736                secrets::ALL_ENVIRONMENTS
737            ),
738            Self::ReservedEnvVar { name, var } => write!(
739                f,
740                "sheep `{name}` sets `{var}` in env, but shep injects it: use a different name, or `{{{{instance}}}}` in your own variable"
741            ),
742            Self::IncrementVarRemoved { name, var } => write!(
743                f,
744                "sheep `{name}` sets `increment_var`, which was removed: write `{var} = \"{{{{instance}}}}\"` under `[app.env]` instead"
745            ),
746            Self::MissingScript => f.write_str("app config is missing a script"),
747            Self::ZeroInstances => f.write_str("instances must be at least 1"),
748            Self::InvalidCron { pattern, reason } => {
749                write!(f, "invalid cron_restart pattern `{pattern}`: {reason}")
750            }
751            Self::InvalidTimezone { name } => {
752                write!(f, "`{name}` is not a recognized IANA timezone")
753            }
754            Self::DuplicateName(n) => write!(f, "duplicate sheep name `{n}`"),
755            Self::DependencyCycle(cycle) => write!(
756                f,
757                "dependency cycle: {}",
758                crate::config::graph::render_cycle(cycle)
759            ),
760            Self::InvalidProbe { probe, reason } => write!(f, "{probe}: {reason}"),
761            Self::ZeroFailureThreshold { probe } => {
762                write!(f, "{probe}.failure_threshold must be at least 1")
763            }
764            Self::IntervalBelowMinimum { probe, value, min } => {
765                write!(f, "{probe}.interval is `{value}`: must be at least {min}")
766            }
767            Self::ZeroMaxMemory { name } => {
768                write!(
769                    f,
770                    "sheep `{name}` has max_memory = 0, a limit nothing can stay under"
771                )
772            }
773            Self::ActionTimeoutTooLong { name, value, max } => {
774                write!(
775                    f,
776                    "sheep `{name}` has action_timeout = {value}: must be at most {max}, \
777                     the longest wait any caller's deadline could ever cover"
778                )
779            }
780            Self::InvalidKillSignal { name, value } => {
781                write!(
782                    f,
783                    "`{name}`: kill_signal `{value}` is not one shep can send (accepted: {})",
784                    KillSignal::ACCEPTED.join(", ")
785                )
786            }
787            Self::TildeUser { name, field, value } => write!(
788                f,
789                "`{name}`: {field} is `{value}`, and shep expands only `~/` (your own home). \
790                 Another user's home needs a passwd lookup whose answer depends on who the \
791                 daemon runs as, so write the path out in full instead."
792            ),
793            Self::NoHomeForTilde { name, field } => write!(
794                f,
795                "`{name}`: {field} begins with `~/` but no home directory could be found. \
796                 Set $HOME, or write the path out in full."
797            ),
798            Self::WatchWithoutCwd { name } => {
799                write!(f, "sheep `{name}` has watch = true but no cwd to watch")
800            }
801            Self::ZeroWatchDelay { name } => {
802                write!(
803                    f,
804                    "sheep `{name}` has watch_delay = 0: must be greater than 0"
805                )
806            }
807            Self::InvalidWatchGlob {
808                name,
809                field,
810                pattern,
811                reason,
812            } => write!(
813                f,
814                "sheep `{name}` has an invalid {field} pattern `{pattern}`: {reason}"
815            ),
816            Self::BadTemplate {
817                name,
818                field,
819                reason,
820            } => write!(f, "sheep `{name}`, {field}: {reason}"),
821            Self::SecretInLogPath { name, field } => write!(
822                f,
823                "sheep `{name}` puts a `{{{{secret:...}}}}` in `{field}`: a log path may not hold a secret, since it becomes a filename on disk and is reported by `shep flock` and `shep describe`"
824            ),
825            Self::SharedLogPath { name, field } => write!(
826                f,
827                "sheep `{name}` runs several instances and sets `{field}` to one path: put `{{{{instance}}}}` in it, or set `merge_logs = true` to share it on purpose"
828            ),
829            Self::SelfDependency(n) => {
830                write!(f, "`{n}` names itself in depends_on")
831            }
832            Self::InstanceDependency { sheep, target } => {
833                let app = target.split(':').next().unwrap_or(target);
834                write!(
835                    f,
836                    "`{sheep}` depends on `{target}`, which names one instance. \
837                     Depend on `{app}` instead: a dependency waits for every \
838                     instance of an app"
839                )
840            }
841        }
842    }
843}
844
845impl core::error::Error for NormalizeError {}
846
847#[cfg(test)]
848mod tests {
849    use super::*;
850
851    /// All four path fields expand `~/`, and expanding some but not others
852    /// would be worse than expanding none: it teaches that tildes work and
853    /// then fails where the operator has no reason to suspect it.
854    #[test]
855    fn every_path_field_expands_a_leading_tilde() {
856        let home = Path::new("/home/ada");
857        let mut app = AppConfig::minimal("web", "~/app/server.js");
858        app.cwd = Some("~/app".to_string());
859        app.out_file = Some("~/logs/out.log".to_string());
860        app.err_file = Some("~/logs/err.log".to_string());
861
862        let resolved = normalize_with_home(app, Some(home)).expect("all four expand");
863        let c = resolved.config();
864        // Expectations are built with `join` rather than written as literals:
865        // the separator is `/` here and `\` on Windows, and hardcoding one
866        // turned CI's three Windows legs red when this test first landed.
867        let expect = |rest: &str| home.join(rest).to_string_lossy().into_owned();
868        assert_eq!(c.script, expect("app/server.js"));
869        assert_eq!(c.cwd.as_deref(), Some(expect("app").as_str()));
870        assert_eq!(c.out_file.as_deref(), Some(expect("logs/out.log").as_str()));
871        assert_eq!(c.err_file.as_deref(), Some(expect("logs/err.log").as_str()));
872    }
873
874    /// The anti-drift half. A fifth path field added to `AppConfig` fails
875    /// here until `expand_paths` handles it, which is the only thing keeping
876    /// the "all four or none" rule true over time.
877    #[test]
878    fn the_path_field_list_matches_what_expand_paths_walks() {
879        let home = Path::new("/home/ada");
880        let mut app = AppConfig::minimal("web", "~/s");
881        app.cwd = Some("~/c".to_string());
882        app.out_file = Some("~/o".to_string());
883        app.err_file = Some("~/e".to_string());
884
885        let resolved = normalize_with_home(app, Some(home)).expect("expands");
886        let c = resolved.config();
887        let expanded = [
888            ("script", Some(c.script.as_str())),
889            ("cwd", c.cwd.as_deref()),
890            ("out_file", c.out_file.as_deref()),
891            ("err_file", c.err_file.as_deref()),
892        ];
893        assert_eq!(
894            expanded.len(),
895            PATH_FIELDS.len(),
896            "PATH_FIELDS and this test must name the same set"
897        );
898        for (field, value) in expanded {
899            assert!(
900                PATH_FIELDS.contains(&field),
901                "`{field}` is not in PATH_FIELDS"
902            );
903            assert!(
904                value.is_some_and(|v| v.starts_with("/home/ada")),
905                "`{field}` was not expanded: {value:?}"
906            );
907        }
908    }
909
910    /// A path with no tilde is untouched, so this is a no-op for every
911    /// absolute and relative path anyone already has.
912    #[test]
913    fn a_path_without_a_tilde_is_left_exactly_as_written() {
914        let app = AppConfig::minimal("web", "./server.js");
915        let resolved =
916            normalize_with_home(app, Some(Path::new("/home/ada"))).expect("no tilde, no change");
917        assert_eq!(resolved.config().script, "./server.js");
918    }
919
920    /// `~user/` needs a passwd lookup whose answer depends on who the daemon
921    /// runs as, so it is refused rather than guessed at.
922    #[test]
923    fn another_users_home_is_refused_rather_than_resolved() {
924        let app = AppConfig::minimal("web", "~deploy/app/server.js");
925        let err = normalize_with_home(app, Some(Path::new("/home/ada")))
926            .expect_err("~user/ must not resolve");
927        assert!(
928            matches!(err, NormalizeError::TildeUser { field, .. } if field == "script"),
929            "the refusal names the field: {err:?}"
930        );
931        let rendered = err.to_string();
932        assert!(
933            rendered.contains("~/"),
934            "and says what IS supported: {rendered}"
935        );
936        assert!(
937            !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
938            "no em or en dash in copy a user reads: {rendered}"
939        );
940    }
941
942    /// `$VAR` is not expanded, here or anywhere. A config file that expands
943    /// variables has to answer whose environment it means.
944    #[test]
945    fn a_dollar_variable_is_not_expanded() {
946        let app = AppConfig::minimal("web", "$HOME/server.js");
947        let resolved = normalize_with_home(app, Some(Path::new("/home/ada"))).expect("left alone");
948        assert_eq!(resolved.config().script, "$HOME/server.js");
949    }
950
951    /// `~/` with no home to expand against is an error naming the field
952    /// rather than a path containing a literal tilde.
953    #[test]
954    fn a_tilde_with_no_home_is_an_error_not_a_literal_path() {
955        let app = AppConfig::minimal("web", "~/server.js");
956        let err = normalize_with_home(app, None).expect_err("nothing to expand against");
957        assert!(
958            matches!(err, NormalizeError::NoHomeForTilde { .. }),
959            "{err:?}"
960        );
961    }
962
963    /// Pins [`expand_home_tilde`]'s own contract, apart from
964    /// [`expand_tilde`]'s wrapping into a [`NormalizeError`]: `shep-cli`'s
965    /// `shep adopt` calls it directly, with no app name or field to attach.
966    #[test]
967    fn expand_home_tilde_covers_its_four_documented_cases() {
968        let home = Path::new("/home/ada");
969        assert_eq!(
970            expand_home_tilde("~/bin/dog", Some(home)).unwrap(),
971            home.join("bin/dog").to_string_lossy()
972        );
973        assert_eq!(
974            expand_home_tilde("/opt/bin/dog", Some(home)).unwrap(),
975            "/opt/bin/dog",
976            "a value with no leading ~ is returned unchanged"
977        );
978        assert_eq!(
979            expand_home_tilde("~/bin/dog", None).unwrap_err(),
980            TildeError::NoHome
981        );
982        assert_eq!(
983            expand_home_tilde("~deploy/bin/dog", Some(home)).unwrap_err(),
984            TildeError::OtherUser
985        );
986    }
987
988    /// `reuse_port` loads because reload's overlap mode is chosen from it:
989    /// refusing it would deny an operator the only way to ask for an
990    /// overlapping reload.
991    #[test]
992    fn reuse_port_loads_now_that_reload_reads_it() {
993        let mut app = AppConfig::minimal("web", "./server");
994        app.reuse_port = true;
995
996        let resolved = normalize(app).expect("reuse_port is implemented");
997        assert!(resolved.config().reuse_port);
998    }
999
1000    /// The default is off, so every Flockfile that does not mention it keeps
1001    /// loading.
1002    #[test]
1003    fn an_app_that_never_mentions_reuse_port_still_normalizes() {
1004        let resolved = normalize(AppConfig::minimal("web", "./server"))
1005            .expect("the common case must be untouched");
1006        assert!(!resolved.config().reuse_port);
1007    }
1008    use crate::config::AppConfig;
1009
1010    #[test]
1011    fn a_colon_in_a_name_is_refused_because_it_is_the_instance_separator() {
1012        let err = normalize(AppConfig::minimal("web:2", "./srv")).unwrap_err();
1013        assert_eq!(err, NormalizeError::InvalidName("web:2".to_string()));
1014
1015        let rendered = err.to_string();
1016        assert!(rendered.contains(':'), "says which character: {rendered}");
1017        // Spec D3 requires the error to name the character and suggest `-`.
1018        assert!(
1019            rendered.contains("`-`"),
1020            "suggests the stand-in: {rendered}"
1021        );
1022        assert!(
1023            !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
1024            "no em or en dash in copy a user reads: {rendered}"
1025        );
1026
1027        assert!(normalize(AppConfig::minimal("web-2", "./srv")).is_ok());
1028    }
1029
1030    #[test]
1031    fn increment_var_is_refused_and_says_what_replaced_it() {
1032        let mut app = AppConfig::minimal("web", "./srv");
1033        app.increment_var = Some("WORKER_ID".to_string());
1034        let err = normalize(app).unwrap_err();
1035        let rendered = err.to_string();
1036        assert!(rendered.contains("increment_var"), "{rendered}");
1037        assert!(
1038            rendered.contains("WORKER_ID"),
1039            "keeps their name: {rendered}"
1040        );
1041        assert!(rendered.contains("{{instance}}"), "and the fix: {rendered}");
1042        assert!(
1043            !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
1044            "no em or en dash in copy a user reads: {rendered}"
1045        );
1046    }
1047
1048    #[test]
1049    fn the_reserved_env_vars_are_refused_rather_than_overwritten() {
1050        for var in ["SHEP_INSTANCE", "SHEP_NAME", "SHEP_ENVIRONMENT"] {
1051            let mut app = AppConfig::minimal("web", "./srv");
1052            app.env.insert(var.to_string(), "mine".to_string());
1053            let err = normalize(app).unwrap_err();
1054            let rendered = err.to_string();
1055            assert!(rendered.contains(var), "names the variable: {rendered}");
1056            assert!(
1057                !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
1058                "no em or en dash in copy a user reads: {rendered}"
1059            );
1060        }
1061    }
1062
1063    #[test]
1064    fn valid_minimal_config_normalizes() {
1065        let resolved = normalize(AppConfig::minimal("web", "./srv")).unwrap();
1066        assert_eq!(resolved.config().name, "web");
1067    }
1068
1069    #[test]
1070    fn names_that_reach_the_filesystem_are_rejected() {
1071        // A name becomes a log/pid file stem via Path::join; a slash-prefixed
1072        // or dotdot name would escape $SHEP_HOME. Reject at the config boundary.
1073        for bad in ["/etc/passwd", "..", ".", "a/b", "a\\b"] {
1074            assert_eq!(
1075                normalize(AppConfig::minimal(bad, "./srv")).unwrap_err(),
1076                NormalizeError::InvalidName(bad.to_string())
1077            );
1078        }
1079        assert!(normalize(AppConfig::minimal("web-1", "./srv")).is_ok());
1080    }
1081
1082    #[test]
1083    fn missing_name_and_script_are_distinct_errors() {
1084        assert_eq!(
1085            normalize(AppConfig::minimal("", "./srv")).unwrap_err(),
1086            NormalizeError::MissingName
1087        );
1088        assert_eq!(
1089            normalize(AppConfig::minimal("web", "")).unwrap_err(),
1090            NormalizeError::MissingScript
1091        );
1092    }
1093
1094    #[test]
1095    fn zero_instances_rejected() {
1096        let mut app = AppConfig::minimal("web", "./srv");
1097        app.instances = 0;
1098        assert_eq!(normalize(app).unwrap_err(), NormalizeError::ZeroInstances);
1099    }
1100
1101    #[test]
1102    fn bad_cron_pattern_rejected_with_pattern_and_reason_carried_through() {
1103        // fails if the reason is not carried through from croner.
1104        let mut app = AppConfig::minimal("web", "./srv");
1105        app.cron_restart = Some("not a cron".to_string());
1106        match normalize(app).unwrap_err() {
1107            NormalizeError::InvalidCron { pattern, reason } => {
1108                assert_eq!(pattern, "not a cron");
1109                assert!(!reason.is_empty());
1110            }
1111            other => panic!("expected InvalidCron, got {other:?}"),
1112        }
1113    }
1114
1115    #[test]
1116    fn five_tokens_of_garbage_cron_pattern_rejected() {
1117        // fails if the validator only counts whitespace-separated tokens
1118        // instead of checking each field's range: five numeric-looking
1119        // tokens, all out of range.
1120        let mut app = AppConfig::minimal("web", "./srv");
1121        app.cron_restart = Some("99 99 99 99 99".to_string());
1122        match normalize(app).unwrap_err() {
1123            NormalizeError::InvalidCron { pattern, .. } => {
1124                assert_eq!(pattern, "99 99 99 99 99");
1125            }
1126            other => panic!("expected InvalidCron, got {other:?}"),
1127        }
1128    }
1129
1130    #[test]
1131    fn bad_cron_timezone_rejected_alongside_a_valid_cron_restart() {
1132        // fails if the `cron_restart` branch maps CronParseError::Timezone to
1133        // anything but NormalizeError::InvalidTimezone. CronSchedule::parse
1134        // resolves the zone before the pattern, so a valid pattern paired
1135        // with a bad zone is the only input that reaches that arm.
1136        let mut app = AppConfig::minimal("web", "./srv");
1137        app.cron_restart = Some("0 3 * * *".to_string());
1138        app.cron_timezone = Some("Mars/Olympus".to_string());
1139        match normalize(app).unwrap_err() {
1140            NormalizeError::InvalidTimezone { name } => assert_eq!(name, "Mars/Olympus"),
1141            other => panic!("expected InvalidTimezone, got {other:?}"),
1142        }
1143    }
1144
1145    #[test]
1146    fn cron_timezone_validated_even_without_cron_restart() {
1147        // fails if timezone validation is skipped when there's no pattern to
1148        // pair it with: a Flockfile with only a bad `cron_timezone` is a
1149        // typo the user wants to hear about.
1150        let mut app = AppConfig::minimal("web", "./srv");
1151        app.cron_timezone = Some("Mars/Olympus".to_string());
1152        match normalize(app).unwrap_err() {
1153            NormalizeError::InvalidTimezone { name } => assert_eq!(name, "Mars/Olympus"),
1154            other => panic!("expected InvalidTimezone, got {other:?}"),
1155        }
1156    }
1157
1158    #[test]
1159    fn duplicate_names_rejected_across_a_flock() {
1160        let apps = vec![
1161            AppConfig::minimal("web", "./a"),
1162            AppConfig::minimal("web", "./b"),
1163        ];
1164        assert_eq!(
1165            normalize_all(apps).unwrap_err(),
1166            NormalizeError::DuplicateName("web".to_string())
1167        );
1168    }
1169
1170    #[test]
1171    fn a_cycle_inside_one_document_is_refused_and_named() {
1172        // fails if normalize_all accepts a cycle, or reports it without a path
1173        let mut a = AppConfig::minimal("a", "./a");
1174        a.depends_on = vec!["b".to_string()];
1175        let mut b = AppConfig::minimal("b", "./b");
1176        b.depends_on = vec!["a".to_string()];
1177        match normalize_all(vec![a, b]) {
1178            Err(NormalizeError::DependencyCycle(cycle)) => {
1179                let rendered = NormalizeError::DependencyCycle(cycle).to_string();
1180                assert!(rendered.contains("a"), "{rendered}");
1181                assert!(rendered.contains("b"), "{rendered}");
1182                assert!(
1183                    rendered.contains(" -> "),
1184                    "the path must be shown: {rendered}"
1185                );
1186            }
1187            other => panic!("expected DependencyCycle, got {other:?}"),
1188        }
1189    }
1190
1191    #[test]
1192    fn a_dependency_on_an_app_outside_the_document_is_accepted() {
1193        // fails if the document-local check refuses a cross-repository
1194        // dependency, which would make depends_on unusable across Flockfiles
1195        let mut api = AppConfig::minimal("api", "./api");
1196        api.depends_on = vec!["db-in-another-repo".to_string()];
1197        normalize_all(vec![api]).expect("an unresolved name is not a document error");
1198    }
1199
1200    fn probe_config(target: &str) -> crate::config::ProbeConfig {
1201        crate::config::ProbeConfig {
1202            kind: crate::config::ProbeKind::Http,
1203            target: target.to_string(),
1204            interval: crate::values::UpDuration::from_millis(10_000),
1205            timeout: crate::values::UpDuration::from_millis(5_000),
1206            failure_threshold: 3,
1207        }
1208    }
1209
1210    #[test]
1211    fn malformed_readiness_probe_target_rejected_naming_the_field() {
1212        // fails if validate_probe is never called for readiness_probe, or if
1213        // it drops which of the two probe fields the rejection came from
1214        let mut app = AppConfig::minimal("web", "./srv");
1215        app.readiness_probe = Some(probe_config("not-a-url"));
1216        match normalize(app).unwrap_err() {
1217            NormalizeError::InvalidProbe { probe, reason } => {
1218                assert_eq!(probe, "readiness_probe");
1219                assert!(!reason.is_empty());
1220            }
1221            other => panic!("expected InvalidProbe, got {other:?}"),
1222        }
1223    }
1224
1225    #[test]
1226    fn malformed_liveness_probe_target_rejected_naming_the_field() {
1227        // fails if only readiness_probe is ever validated, leaving a bad
1228        // liveness_probe target to surface later at the daemon's first poll
1229        let mut app = AppConfig::minimal("web", "./srv");
1230        app.liveness_probe = Some(probe_config("not-a-url"));
1231        match normalize(app).unwrap_err() {
1232            NormalizeError::InvalidProbe { probe, .. } => assert_eq!(probe, "liveness_probe"),
1233            other => panic!("expected InvalidProbe, got {other:?}"),
1234        }
1235    }
1236
1237    #[test]
1238    fn valid_probe_targets_accepted() {
1239        // fails if validate_probe rejects a well-formed target outright
1240        let mut app = AppConfig::minimal("web", "./srv");
1241        app.readiness_probe = Some(probe_config("http://127.0.0.1:8080/healthz"));
1242        assert!(normalize(app).is_ok());
1243    }
1244
1245    #[test]
1246    fn zero_failure_threshold_rejected() {
1247        // fails if failure_threshold is never inspected: a threshold of 0
1248        // means "unhealthy before the first probe ever runs"
1249        let mut app = AppConfig::minimal("web", "./srv");
1250        let mut probe = probe_config("http://127.0.0.1:8080/healthz");
1251        probe.failure_threshold = 0;
1252        app.readiness_probe = Some(probe);
1253        let err = normalize(app).unwrap_err();
1254        assert_eq!(
1255            err,
1256            NormalizeError::ZeroFailureThreshold {
1257                probe: "readiness_probe"
1258            }
1259        );
1260        // fails if the message regresses to a bare variant name with no
1261        // explanation.
1262        assert!(err.to_string().contains("at least 1"), "{err}");
1263    }
1264
1265    #[test]
1266    fn zero_interval_rejected() {
1267        // fails if interval is never inspected: a zero interval would spin
1268        // the readiness wait as fast as the runtime allows for the whole
1269        // `listen_timeout` (`await_ready` does not floor it)
1270        let mut app = AppConfig::minimal("web", "./srv");
1271        let mut probe = probe_config("http://127.0.0.1:8080/healthz");
1272        probe.interval = UpDuration::from_millis(0);
1273        app.readiness_probe = Some(probe);
1274        let err = normalize(app).unwrap_err();
1275        assert_eq!(
1276            err,
1277            NormalizeError::IntervalBelowMinimum {
1278                probe: "readiness_probe",
1279                value: UpDuration::from_millis(0),
1280                min: MIN_READINESS_INTERVAL,
1281            }
1282        );
1283        // fails if the message regresses to a bare variant name with no
1284        // explanation.
1285        assert!(err.to_string().contains("must be at least"), "{err}");
1286    }
1287
1288    #[test]
1289    fn a_liveness_interval_under_the_floor_is_rejected_rather_than_clamped() {
1290        // fails if the liveness check is `interval == 0` rather than a
1291        // floor: a 500ms interval survives equality and is then silently
1292        // rounded up to a full second by `MIN_PROBE_INTERVAL`. Also fails
1293        // if the rejection drops the value the user wrote.
1294        let mut app = AppConfig::minimal("web", "./srv");
1295        let mut probe = probe_config("http://127.0.0.1:8080/healthz");
1296        probe.interval = UpDuration::from_millis(500);
1297        app.liveness_probe = Some(probe);
1298        let err = normalize(app).unwrap_err();
1299        assert_eq!(
1300            err,
1301            NormalizeError::IntervalBelowMinimum {
1302                probe: "liveness_probe",
1303                value: UpDuration::from_millis(500),
1304                min: MIN_LIVENESS_INTERVAL,
1305            }
1306        );
1307        assert!(err.to_string().contains("500"), "{err}");
1308    }
1309
1310    #[test]
1311    fn a_liveness_interval_exactly_at_the_floor_is_accepted() {
1312        // fails if the comparison is `<=` rather than `<`: the floor is a
1313        // value the liveness loop honours exactly, so naming it must not be
1314        // an error.
1315        let mut app = AppConfig::minimal("web", "./srv");
1316        let mut probe = probe_config("http://127.0.0.1:8080/healthz");
1317        probe.interval = MIN_LIVENESS_INTERVAL;
1318        app.liveness_probe = Some(probe);
1319        assert!(normalize(app).is_ok());
1320    }
1321
1322    #[test]
1323    fn a_sub_second_readiness_interval_is_accepted() {
1324        // fails if both probes are validated against the liveness floor: a
1325        // readiness wait is bounded by `listen_timeout` and honours its
1326        // `interval` exactly as written, so a fast app polling every 50ms
1327        // to leave `starting` sooner must not be refused.
1328        let mut app = AppConfig::minimal("web", "./srv");
1329        let mut probe = probe_config("http://127.0.0.1:8080/healthz");
1330        probe.interval = UpDuration::from_millis(50);
1331        app.readiness_probe = Some(probe);
1332        assert!(normalize(app).is_ok());
1333    }
1334
1335    #[test]
1336    fn zero_max_memory_rejected() {
1337        // fails if `max_memory` is never inspected: zero is a ceiling every
1338        // live process is already over, so the enforcer breaches on every
1339        // reading and the automatic restart that follows resets the
1340        // restart budget instead of spending it.
1341        let mut app = AppConfig::minimal("web", "./srv");
1342        app.max_memory = Some(crate::values::MemSize::from_bytes(0));
1343        let err = normalize(app).unwrap_err();
1344        assert_eq!(
1345            err,
1346            NormalizeError::ZeroMaxMemory {
1347                name: "web".to_string()
1348            }
1349        );
1350        // fails if the message regresses to a bare variant name with no
1351        // explanation.
1352        assert!(err.to_string().contains("max_memory"), "{err}");
1353    }
1354
1355    #[test]
1356    fn a_nonzero_max_memory_is_accepted() {
1357        // fails if the check fires on `max_memory` being set at all rather
1358        // than on its being zero: that would refuse every app that
1359        // configures a limit, which is the whole feature
1360        let mut app = AppConfig::minimal("web", "./srv");
1361        app.max_memory = Some("512M".parse().unwrap());
1362        assert!(normalize(app).is_ok());
1363    }
1364
1365    /// fails if a `kill_signal` shep cannot send is accepted here: that puts
1366    /// SIGTERM on the wire for the life of the process with nothing but one
1367    /// daemon log line to say so.
1368    #[test]
1369    fn a_kill_signal_shep_cannot_send_is_refused_by_name() {
1370        let mut app = AppConfig::minimal("web", "./srv");
1371        app.kill_signal = Some("SIGUSR1".to_string());
1372
1373        let err = normalize(app).unwrap_err();
1374
1375        assert_eq!(
1376            err,
1377            NormalizeError::InvalidKillSignal {
1378                name: "web".to_string(),
1379                value: "SIGUSR1".to_string(),
1380            }
1381        );
1382        // The message has to name the accepted set, because the operator's next
1383        // move is picking a different word and there is nowhere else to look.
1384        let rendered = err.to_string();
1385        assert!(rendered.contains("SIGUSR1"), "{rendered}");
1386        assert!(rendered.contains("SIGTERM"), "{rendered}");
1387        assert!(rendered.contains("SIGUSR2"), "{rendered}");
1388    }
1389
1390    /// fails if the four supported names, their bare forms, or a lowercase
1391    /// spelling stop being accepted. This is the compatibility half: every
1392    /// spelling `stop_signal` accepted before this task must still normalize.
1393    #[test]
1394    fn every_spelling_the_daemon_already_accepted_still_normalizes() {
1395        for name in [
1396            "SIGTERM", "TERM", "sigterm", "term", "SIGINT", "INT", "SIGQUIT", "QUIT", "SIGUSR2",
1397            "USR2", "sigusr2",
1398        ] {
1399            let mut app = AppConfig::minimal("web", "./srv");
1400            app.kill_signal = Some(name.to_string());
1401            assert!(
1402                normalize(app).is_ok(),
1403                "`{name}` was accepted before this task and must still be"
1404            );
1405        }
1406    }
1407
1408    /// fails if an unset `kill_signal` is refused: the overwhelmingly common
1409    /// case, and the one a validation pass is most likely to break by
1410    /// treating `None` as an empty string.
1411    #[test]
1412    fn an_unset_kill_signal_is_not_a_config_error() {
1413        let app = AppConfig::minimal("web", "./srv");
1414        assert!(app.kill_signal.is_none());
1415        assert!(normalize(app).is_ok());
1416    }
1417
1418    #[test]
1419    fn a_sheep_cannot_claim_the_all_environment() {
1420        // `all` is the store's every-environment slot. A sheep resolving in
1421        // it would read that slot twice and never its own.
1422        let mut app = AppConfig::minimal("web", "./srv");
1423        app.environment = Some("all".to_string());
1424        let err = normalize(app).unwrap_err();
1425        assert!(err.to_string().contains("all"), "{err}");
1426    }
1427
1428    #[test]
1429    fn an_environment_outside_the_grammar_is_refused() {
1430        for bad in ["", "has space", "has/slash"] {
1431            let mut app = AppConfig::minimal("web", "./srv");
1432            app.environment = Some(bad.to_string());
1433            assert!(normalize(app).is_err(), "{bad:?} must be refused");
1434        }
1435    }
1436
1437    #[test]
1438    fn action_timeout_past_the_ceiling_is_rejected() {
1439        // fails if `action_timeout` is never inspected. One millisecond over
1440        // the ceiling is deliberate: a test at a round number like 60s could
1441        // pass by coincidence if the check used the wrong constant entirely
1442        // (`MAX_DEADLINE_MS` itself, say, instead of the margin under it).
1443        let mut app = AppConfig::minimal("web", "./srv");
1444        app.action_timeout = UpDuration::from_millis(MAX_ACTION_TIMEOUT.as_millis() + 1);
1445        let err = normalize(app).unwrap_err();
1446        assert_eq!(
1447            err,
1448            NormalizeError::ActionTimeoutTooLong {
1449                name: "web".to_string(),
1450                value: UpDuration::from_millis(MAX_ACTION_TIMEOUT.as_millis() + 1),
1451                max: MAX_ACTION_TIMEOUT,
1452            }
1453        );
1454        // fails if the message regresses to a bare variant name with no
1455        // explanation.
1456        assert!(err.to_string().contains("action_timeout"), "{err}");
1457    }
1458
1459    #[test]
1460    fn action_timeout_at_the_ceiling_is_accepted() {
1461        // fails if the comparison is `>=` rather than `>`: the ceiling
1462        // itself still leaves the daemon its full margin under the hard
1463        // clamp, so it is not one of the values nothing could ever satisfy.
1464        let mut app = AppConfig::minimal("web", "./srv");
1465        app.action_timeout = MAX_ACTION_TIMEOUT;
1466        assert!(normalize(app).is_ok());
1467    }
1468
1469    #[test]
1470    fn the_default_action_timeout_is_accepted() {
1471        // fails if `AppConfig::default()`'s own value ever drifts past the
1472        // ceiling normalize enforces: the one combination that must never
1473        // reject the config nobody customized.
1474        assert!(normalize(AppConfig::minimal("web", "./srv")).is_ok());
1475    }
1476
1477    #[test]
1478    fn zero_watch_delay_rejected() {
1479        // fails if `watch_delay` is never inspected. notify's debouncer
1480        // derives its poll tick as `watch_delay / 4` and sleeps it on its own
1481        // OS thread, so zero is `loop { sleep(0); lock(); }`, a CPU-spinning
1482        // busy loop.
1483        let mut app = AppConfig::minimal("web", "./srv");
1484        app.watch = true;
1485        app.cwd = Some("/srv/web".to_string());
1486        app.watch_delay = Some(UpDuration::from_millis(0));
1487        let err = normalize(app).unwrap_err();
1488        assert_eq!(
1489            err,
1490            NormalizeError::ZeroWatchDelay {
1491                name: "web".to_string()
1492            }
1493        );
1494        // fails if the message regresses to a bare variant name with no
1495        // explanation.
1496        assert!(err.to_string().contains("watch_delay"), "{err}");
1497    }
1498
1499    #[test]
1500    fn a_zero_watch_delay_is_rejected_with_watch_off() {
1501        // fails if the check is nested inside the `watch` block: an app
1502        // carrying `watch_delay = "0"` with `watch = false` would normalize
1503        // clean, and the spin would arrive the day someone flips `watch =
1504        // true`
1505        let mut app = AppConfig::minimal("web", "./srv");
1506        app.watch_delay = Some(UpDuration::from_millis(0));
1507        assert!(matches!(
1508            normalize(app).unwrap_err(),
1509            NormalizeError::ZeroWatchDelay { .. }
1510        ));
1511    }
1512
1513    #[test]
1514    fn a_nonzero_watch_delay_is_accepted() {
1515        // fails if the check fires on `watch_delay` being set at all rather
1516        // than on its being zero: that would refuse every app that tunes
1517        // its own debounce
1518        let mut app = AppConfig::minimal("web", "./srv");
1519        app.watch = true;
1520        app.cwd = Some("/srv/web".to_string());
1521        app.watch_delay = Some(UpDuration::from_millis(1));
1522        assert!(normalize(app).is_ok());
1523    }
1524
1525    #[test]
1526    fn default_failure_threshold_from_toml_accepted() {
1527        // fails if the check fires on the ordinary default instead of only
1528        // an explicit 0. Deserializes a Flockfile snippet that omits
1529        // `failure_threshold`, exercising the real serde default rather
1530        // than `probe_config`'s hardcoded `3`.
1531        let src = r#"
1532name = "web"
1533script = "./srv"
1534
1535[readiness_probe]
1536kind = "http"
1537target = "http://127.0.0.1:8080/healthz"
1538"#;
1539        let app: AppConfig = toml::from_str(src).unwrap();
1540        assert!(normalize(app).is_ok());
1541    }
1542
1543    #[test]
1544    fn watch_true_without_cwd_rejected_naming_the_app() {
1545        // fails if a validator never looks at `watch`, or looks at it but
1546        // carries no app name, leaving the user unable to tell which
1547        // Flockfile entry to edit
1548        let mut app = AppConfig::minimal("web", "./srv");
1549        app.watch = true;
1550        let err = normalize(app).unwrap_err();
1551        assert_eq!(
1552            err,
1553            NormalizeError::WatchWithoutCwd {
1554                name: "web".to_string()
1555            }
1556        );
1557        // fails if the message regresses to a bare variant name with no
1558        // explanation.
1559        assert!(err.to_string().contains("no cwd to watch"), "{err}");
1560    }
1561
1562    #[test]
1563    fn watch_true_with_cwd_accepted() {
1564        // fails if the check fires on `watch` alone, ignoring that a cwd was
1565        // actually provided
1566        let mut app = AppConfig::minimal("web", "./srv");
1567        app.watch = true;
1568        app.cwd = Some("/srv/web".to_string());
1569        assert!(normalize(app).is_ok());
1570    }
1571
1572    #[test]
1573    fn a_watch_options_glob_that_will_not_compile_is_rejected() {
1574        // fails if `watch_options` patterns are never compiled at config
1575        // time. Also fails if the rejection blames the whole list instead
1576        // of the one bad pattern: the valid `src/**` comes first, so
1577        // naming it, or the patterns joined together, is wrong.
1578        let mut app = AppConfig::minimal("web", "./srv");
1579        app.watch = true;
1580        app.cwd = Some("/srv/web".to_string());
1581        app.watch_options = vec!["src/**".to_string(), "[".to_string()];
1582        let err = normalize(app).unwrap_err();
1583        assert_eq!(
1584            err,
1585            NormalizeError::InvalidWatchGlob {
1586                name: "web".to_string(),
1587                field: "watch_options",
1588                pattern: "[".to_string(),
1589                reason: Glob::new("[").unwrap_err().to_string(),
1590            }
1591        );
1592        // fails if the message drops the app name, the list or the pattern:
1593        // the three things that name the Flockfile line to edit.
1594        let rendered = err.to_string();
1595        for expected in ["web", "watch_options", "`[`"] {
1596            assert!(
1597                rendered.contains(expected),
1598                "{expected} missing: {rendered}"
1599            );
1600        }
1601    }
1602
1603    #[test]
1604    fn an_ignore_watch_glob_that_will_not_compile_is_rejected() {
1605        // fails if only `watch_options` is ever compiled, leaving a mistyped
1606        // `ignore_watch` to cost the app its watch at arm time instead
1607        let mut app = AppConfig::minimal("web", "./srv");
1608        app.watch = true;
1609        app.cwd = Some("/srv/web".to_string());
1610        app.ignore_watch = vec!["[".to_string()];
1611        match normalize(app).unwrap_err() {
1612            NormalizeError::InvalidWatchGlob { field, pattern, .. } => {
1613                assert_eq!(field, "ignore_watch");
1614                assert_eq!(pattern, "[");
1615            }
1616            other => panic!("expected InvalidWatchGlob, got {other:?}"),
1617        }
1618    }
1619
1620    #[test]
1621    fn a_glob_that_will_not_compile_is_rejected_with_watch_off() {
1622        // fails if glob validation is nested inside the `watch` check: an app
1623        // carrying a mistyped glob with `watch = false` would then normalize
1624        // clean, and the typo would surface only the day someone flips
1625        // `watch = true`
1626        let mut app = AppConfig::minimal("web", "./srv");
1627        app.watch_options = vec!["[".to_string()];
1628        assert!(matches!(
1629            normalize(app).unwrap_err(),
1630            NormalizeError::InvalidWatchGlob { .. }
1631        ));
1632    }
1633
1634    #[test]
1635    fn well_formed_watch_globs_are_accepted() {
1636        // fails if the check rejects patterns globset compiles happily:
1637        // recursive `**`, a character class, a negated class and a brace
1638        // alternation. Also fails if it is wired to a parser that is not
1639        // globset's, since these are a syntax error to a regex engine.
1640        let mut app = AppConfig::minimal("web", "./srv");
1641        app.watch = true;
1642        app.cwd = Some("/srv/web".to_string());
1643        app.watch_options = vec!["src/**/*.rs".to_string(), "*.[ch]".to_string()];
1644        app.ignore_watch = vec!["target/**".to_string(), "**/[!.]*.{tmp,swp}".to_string()];
1645        assert!(normalize(app).is_ok());
1646    }
1647
1648    #[test]
1649    fn a_typo_in_an_env_template_is_refused_and_names_the_field() {
1650        let mut app = AppConfig::minimal("web", "./srv");
1651        app.env
1652            .insert("WORKER".to_string(), "w-{{instnace}}".to_string());
1653        let err = normalize(app).unwrap_err();
1654        let rendered = err.to_string();
1655        assert!(rendered.contains("instnace"), "names the typo: {rendered}");
1656        assert!(rendered.contains("WORKER"), "and the field: {rendered}");
1657    }
1658
1659    #[test]
1660    fn a_typo_in_an_arg_template_is_refused_too() {
1661        let mut app = AppConfig::minimal("web", "./srv");
1662        app.args = vec!["--port".to_string(), "91{{slot}}".to_string()];
1663        let err = normalize(app).unwrap_err();
1664        assert!(err.to_string().contains("slot"), "{err}");
1665    }
1666
1667    #[test]
1668    fn an_explicit_log_path_shared_by_every_instance_is_refused() {
1669        let mut app = AppConfig::minimal("web", "./srv");
1670        app.instances = 3;
1671        app.out_file = Some("/var/log/web.log".to_string());
1672        let err = normalize(app).unwrap_err();
1673        let rendered = err.to_string();
1674        assert!(rendered.contains("out_file"), "names the field: {rendered}");
1675        assert!(
1676            rendered.contains("{{instance}}") && rendered.contains("merge_logs"),
1677            "and both ways out: {rendered}"
1678        );
1679        assert!(
1680            !rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
1681            "no em or en dash in copy a user reads: {rendered}"
1682        );
1683    }
1684
1685    #[test]
1686    fn the_three_ways_out_of_the_shared_log_refusal_all_work() {
1687        // A slot in the path.
1688        let mut templated = AppConfig::minimal("web", "./srv");
1689        templated.instances = 3;
1690        templated.out_file = Some("/var/log/web-{{instance}}.log".to_string());
1691        assert!(normalize(templated).is_ok());
1692
1693        // Asking for the merge on purpose.
1694        let mut merged = AppConfig::minimal("web", "./srv");
1695        merged.instances = 3;
1696        merged.out_file = Some("/var/log/web.log".to_string());
1697        merged.merge_logs = true;
1698        assert!(normalize(merged).is_ok());
1699
1700        // One instance cannot collide with itself.
1701        let mut single = AppConfig::minimal("web", "./srv");
1702        single.out_file = Some("/var/log/web.log".to_string());
1703        assert!(normalize(single).is_ok());
1704    }
1705
1706    #[test]
1707    fn an_escaped_template_in_a_log_path_does_not_satisfy_the_refusal() {
1708        // `{{{{instance}}}}` spells the token but renders to one literal path
1709        // for every instance, so a substring check would wave it through.
1710        let mut app = AppConfig::minimal("web", "./srv");
1711        app.instances = 3;
1712        app.out_file = Some("/var/log/web-{{{{instance}}}}.log".to_string());
1713        assert!(normalize(app).is_err());
1714    }
1715
1716    #[test]
1717    fn a_name_only_template_does_not_resolve_the_collision() {
1718        // `{{name}}` is the same for every instance, so a path carrying only it
1719        // still puts every instance on one file. Presence of a token is not the
1720        // test; rendering differently is.
1721        let mut app = AppConfig::minimal("web", "./srv");
1722        app.instances = 3;
1723        app.out_file = Some("/var/log/{{name}}.log".to_string());
1724        assert!(normalize(app).is_err());
1725    }
1726
1727    /// A resolved `env` value reaches the child and nothing else. A
1728    /// resolved log path reaches `ProcessInfo`, every bus event carrying
1729    /// one, `shep flock`, `shep describe`, `shep lookout` and a filename
1730    /// under `$SHEP_HOME/logs`, which is four more places than the design
1731    /// says a plaintext secret ever lives.
1732    #[test]
1733    fn a_secret_in_a_log_path_is_refused() {
1734        for field in ["out_file", "err_file"] {
1735            let mut app = AppConfig::minimal("web", "./srv");
1736            let path = Some("/var/log/{{secret:TOKEN}}.log".to_string());
1737            if field == "out_file" {
1738                app.out_file = path;
1739            } else {
1740                app.err_file = path;
1741            }
1742            match normalize(app).unwrap_err() {
1743                NormalizeError::SecretInLogPath { name, field: got } => {
1744                    assert_eq!(name, "web");
1745                    assert_eq!(got, field);
1746                }
1747                other => panic!("expected SecretInLogPath for {field}, got {other:?}"),
1748            }
1749        }
1750    }
1751
1752    /// The refusal is the `secret:` prefix alone: a multi-instance app
1753    /// needs `{{instance}}` in its log paths, and `{{name}}` is how the
1754    /// other two path fields are written.
1755    #[test]
1756    fn the_positional_tokens_still_render_in_a_log_path() {
1757        let mut app = AppConfig::minimal("web", "./srv");
1758        app.instances = 2;
1759        app.out_file = Some("/var/log/{{name}}-{{instance}}.out".to_string());
1760        app.err_file = Some("/var/log/{{name}}-{{instance}}.err".to_string());
1761        assert!(normalize(app).is_ok());
1762    }
1763
1764    /// A namespaced reference is the same refusal: `SecretRef::parse` reads
1765    /// both shapes, and only one of them being refused would be a hole
1766    /// nobody could guess at.
1767    #[test]
1768    fn a_namespaced_secret_in_a_log_path_is_refused_too() {
1769        let mut app = AppConfig::minimal("web", "./srv");
1770        app.err_file = Some("/var/log/{{secret:vercel/TOKEN}}.log".to_string());
1771        assert!(matches!(
1772            normalize(app).unwrap_err(),
1773            NormalizeError::SecretInLogPath { .. }
1774        ));
1775    }
1776
1777    #[test]
1778    fn a_bad_template_in_a_log_path_is_reported_as_bad_template_not_shared_path() {
1779        let mut app = AppConfig::minimal("web", "./srv");
1780        app.instances = 3;
1781        app.out_file = Some("/var/log/web-{{instnace}}.log".to_string());
1782        match normalize(app).unwrap_err() {
1783            NormalizeError::BadTemplate { field, reason, .. } => {
1784                assert_eq!(field, "out_file");
1785                assert!(reason.contains("instnace"), "{reason}");
1786            }
1787            other => panic!("expected BadTemplate, got {other:?}"),
1788        }
1789    }
1790
1791    #[test]
1792    fn watch_options_without_watch_or_cwd_accepted() {
1793        // fails if the check is keyed on `watch_options` being non-empty
1794        // rather than on `watch` being true: that would reject a Flockfile
1795        // that never asked to be watched
1796        let mut app = AppConfig::minimal("web", "./srv");
1797        app.watch_options = vec!["src/**".to_string()];
1798        assert!(normalize(app).is_ok());
1799    }
1800
1801    #[test]
1802    fn a_sheep_that_depends_on_itself_is_refused_by_name() {
1803        // fails if the self-edge check is missing, or if it reports a bare
1804        // cycle rather than naming the sheep
1805        let mut app = AppConfig::minimal("api", "./api");
1806        app.depends_on = vec!["api".to_string()];
1807        match normalize(app) {
1808            Err(NormalizeError::SelfDependency(name)) => assert_eq!(name, "api"),
1809            other => panic!("expected SelfDependency, got {other:?}"),
1810        }
1811    }
1812
1813    #[test]
1814    fn a_dependency_on_one_instance_is_refused_naming_the_app_form() {
1815        // fails if `name:slot` is accepted as a dependency target
1816        let mut app = AppConfig::minimal("api", "./api");
1817        app.depends_on = vec!["db:2".to_string()];
1818        match normalize(app) {
1819            Err(NormalizeError::InstanceDependency { sheep, target }) => {
1820                assert_eq!(sheep, "api");
1821                assert_eq!(target, "db:2");
1822            }
1823            other => panic!("expected InstanceDependency, got {other:?}"),
1824        }
1825        let rendered = NormalizeError::InstanceDependency {
1826            sheep: "api".to_string(),
1827            target: "db:2".to_string(),
1828        }
1829        .to_string();
1830        assert!(
1831            rendered.contains("`db`"),
1832            "the refusal must name the app-level form: {rendered}"
1833        );
1834    }
1835
1836    #[test]
1837    fn duplicate_dependencies_dedupe_rather_than_refusing() {
1838        // fails if a repeated name is an error, or if it survives into the
1839        // normalized config twice
1840        let mut app = AppConfig::minimal("api", "./api");
1841        app.depends_on = vec!["db".to_string(), "db".to_string()];
1842        let resolved = normalize(app).expect("a repeated name is not an error");
1843        assert_eq!(resolved.config().depends_on, vec!["db".to_string()]);
1844    }
1845
1846    #[test]
1847    fn an_ordinary_dependency_list_survives_normalize() {
1848        // fails if the field is dropped or reordered
1849        let mut app = AppConfig::minimal("api", "./api");
1850        app.depends_on = vec!["db".to_string(), "cache".to_string()];
1851        let resolved = normalize(app).expect("an ordinary list normalizes");
1852        assert_eq!(
1853            resolved.config().depends_on,
1854            vec!["db".to_string(), "cache".to_string()]
1855        );
1856    }
1857}