Skip to main content

shep_core/config/
app.rs

1//! Per-app configuration schema — one sheep's Flockfile entry
2
3use core::fmt;
4
5use std::collections::BTreeMap;
6
7// use schemars::generate
8use serde::{Deserialize, Serialize};
9
10use crate::values::{MemSize, UpDuration};
11
12/// How a health probe checks a sheep
13// wire format: changing these strings is a breaking change
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
16#[serde(rename_all = "snake_case")]
17pub enum ProbeKind {
18    /// HTTP GET must return 2xx
19    Http,
20    /// TCP connect must succeed
21    Tcp,
22    /// Command must exit 0
23    Exec,
24}
25
26/// Readiness/liveness probe configuration (spec §7)
27// wire format: changing field names/defaults is a breaking change
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
30#[serde(deny_unknown_fields)]
31pub struct ProbeConfig {
32    /// Probe mechanism
33    pub kind: ProbeKind,
34    /// URL (http), `host:port` (tcp), or command line (exec)
35    pub target: String,
36    /// Time between probes (default 10s)
37    #[serde(default = "default_probe_interval")]
38    pub interval: UpDuration,
39    /// Per-probe timeout (default 5s)
40    #[serde(default = "default_probe_timeout")]
41    pub timeout: UpDuration,
42    /// Consecutive failures before the probe reports unhealthy (default 3)
43    #[serde(default = "default_failure_threshold")]
44    pub failure_threshold: u32,
45}
46
47fn default_probe_interval() -> UpDuration {
48    UpDuration::from_millis(10_000)
49}
50fn default_probe_timeout() -> UpDuration {
51    UpDuration::from_millis(5_000)
52}
53fn default_failure_threshold() -> u32 {
54    3
55}
56
57/// Per-app configuration — one sheep's entry in a Flockfile
58///
59/// Field names are the Flockfile contract (sheep-native; pm2 spellings are
60/// rejected — the importer translates them). Unknown fields are errors so
61/// typos fail loudly at parse time.
62///
63/// # Example
64/// ```
65/// use shep_core::config::AppConfig;
66///
67/// let app: AppConfig = toml::from_str("name = \"web\"\nscript = \"./srv\"").unwrap();
68/// assert!(app.autorestart); // spec default
69/// ```
70// wire format: changing field names/defaults is a breaking change
71#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
72#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
73#[serde(deny_unknown_fields, default)]
74pub struct AppConfig {
75    /// Unique sheep name (required)
76    #[cfg_attr(feature = "schema", schemars(extend("init" = {
77        "example": "my-first-sheep",
78        "group": "process",
79        "blurb": "A convenient and unique name for shep to display"
80    })))]
81    pub name: String,
82    /// Executable or script path (required)
83    #[cfg_attr(feature = "schema", schemars(extend("init" = {
84        "example": "./index.js",
85        "group": "process",
86        "blurb": "The script that shep should use to launch your app"
87    })))]
88    pub script: String,
89    /// Arguments passed to the script
90    #[cfg_attr(feature = "schema", schemars(extend("init" = {
91        "group": "inputs",
92        "blurb": "Arguments passed to the script, as a list"
93    })))]
94    pub args: Vec<String>,
95    /// Working directory (default: daemon's cwd at spawn registration)
96    #[cfg_attr(feature = "schema", schemars(extend("init" = {
97        "example": "/srv/app",
98        "group": "process",
99        "blurb": "Where the process runs. Without it, the daemon's own directory"
100    })))]
101    pub cwd: Option<String>,
102    /// Interpreter override (`"none"` = run script directly)
103    #[cfg_attr(feature = "schema", schemars(extend("init" = {
104        "example": "none",
105        "group": "process",
106        "blurb": "What runs the script. Set it to none to exec the file directly"
107    })))]
108    pub interpreter: Option<String>,
109    /// Environment for the sheep (merged over the daemon's filtered env)
110    #[cfg_attr(feature = "schema", schemars(extend("init" = {
111        "example": "{ NODE_ENV = 'production' }",
112        "group": "inputs",
113        "blurb": "Environment variables for this app, layered over the daemon's own"
114    })))]
115    pub env: BTreeMap<String, String>,
116    /// Instance count ("cluster" = N fork instances; spec §4)
117    #[cfg_attr(feature = "schema", schemars(extend("init" = {
118        "group": "process",
119        "blurb": "How many copies of this app to run"
120    })))]
121    pub instances: u32,
122    /// Restart on unexpected exit
123    #[cfg_attr(feature = "schema", schemars(extend("init" = {
124        "group": "control",
125        "blurb": "Restarts the process automatically when it exits unexpectedly"
126    })))]
127    pub autorestart: bool,
128    /// Start when the daemon starts / on `shep muster`
129    #[cfg_attr(feature = "schema", schemars(extend("init" = {
130        "group": "control",
131        "blurb": "Start this app when the daemon starts, and on shep muster"
132    })))]
133    pub autostart: bool,
134    /// Exit codes treated as clean stop (no restart)
135    #[cfg_attr(feature = "schema", schemars(extend("init" = {
136        "group": "control",
137        "blurb": "Exit codes that mean a clean stop, so shep will not restart"
138    })))]
139    pub stop_exit_codes: Vec<i32>,
140    /// Uptime below this marks an exit as unstable
141    #[cfg_attr(feature = "schema", schemars(extend("init" = {
142        "group": "control",
143        "blurb": "An exit sooner than this counts as unstable"
144    })))]
145    pub min_uptime: UpDuration,
146    /// Consecutive unstable exits before `errored`
147    #[cfg_attr(feature = "schema", schemars(extend("init" = {
148        "group": "control",
149        "blurb": "How many unstable exits in a row before shep gives up"
150    })))]
151    pub max_restarts: u32,
152    /// Fixed delay before every restart (alternative to backoff)
153    #[cfg_attr(feature = "schema", schemars(extend("init" = {
154        "example": "3s",
155        "group": "control",
156        "blurb": "A fixed wait before every restart, instead of growing backoff"
157    })))]
158    pub restart_delay: Option<UpDuration>,
159    /// Initial backoff delay; grows ×1.5 capped at 15s (spec §4)
160    #[cfg_attr(feature = "schema", schemars(extend("init" = {
161        "example": "5s",
162        "group": "control",
163        "blurb": "Starting delay between restarts, growing each time it fails again"
164    })))]
165    pub exp_backoff_restart_delay: Option<UpDuration>,
166    /// Stop signal, one of `SIGTERM`/`SIGINT`/`SIGQUIT`/`SIGUSR2` (the `SIG`
167    /// prefix and the case are both optional). Unset means `SIGTERM`.
168    ///
169    /// A `String` rather than a [`KillSignal`](crate::config::KillSignal) so
170    /// the Flockfile schema and this struct's wire form stay plain text;
171    /// `normalize` is what refuses a name outside that set, the same split
172    /// `cron_restart` and the watch globs already use.
173    #[cfg_attr(feature = "schema", schemars(extend("init" = {
174        "example": "SIGTERM",
175        "group": "process",
176        "blurb": "Which signal shep sends first when stopping this app"
177    })))]
178    pub kill_signal: Option<String>,
179    /// Grace period between stop signal and SIGKILL
180    #[cfg_attr(feature = "schema", schemars(extend("init" = {
181        "group": "control",
182        "blurb": "How long shep waits after the stop signal before SIGKILL"
183    })))]
184    pub kill_timeout: UpDuration,
185    /// Send `{"kind":"shutdown"}` on the shepherd channel instead of a signal
186    #[cfg_attr(feature = "schema", schemars(extend("init" = {
187        "group": "control",
188        "blurb": "Ask the app to stop over the channel instead of signalling it"
189    })))]
190    pub shutdown_with_message: bool,
191    /// Readiness fallback window when no ready signal/probe configured
192    #[cfg_attr(feature = "schema", schemars(extend("init" = {
193        "group": "control",
194        "blurb": "How long to wait for readiness when nothing else reports it"
195    })))]
196    pub listen_timeout: UpDuration,
197    /// Drain window for the old instance during reload
198    #[cfg_attr(feature = "schema", schemars(extend("init" = {
199        "group": "control",
200        "blurb": "How long the old instance gets to drain during a reload"
201    })))]
202    pub graceful_timeout: UpDuration,
203    /// How long a triggered action gets to answer on the shepherd channel
204    /// before its row becomes `ActionOutcome::TimedOut`.
205    ///
206    /// Defaults to 3s — comfortably under the 5s an RPC caller gets when it
207    /// sends no deadline of its own (`shep-client`'s `DEFAULT_DEADLINE`,
208    /// mirrored daemon-side as `rpc`'s `DEFAULT_DEADLINE_MS`). The margin
209    /// matters more than the number: push this past that budget and a caller
210    /// using the plain default gives up with `DeadlineExceeded` before the
211    /// daemon's own honest `TimedOut` row ever reaches it. A legitimately
212    /// slow action (a cache flush, say) can still ask for longer, but its
213    /// caller has to ask for a longer deadline in step —
214    /// `Client::request_with_deadline`, the way `shep logs -f` already asks
215    /// for `LOG_PLANE_DEADLINE` rather than the client's default. `normalize`
216    /// refuses a value no caller could ever satisfy, however long a deadline
217    /// it asks for; a value merely above the *default* budget is a caller's
218    /// choice to widen its own deadline, not a config error this crate can
219    /// see.
220    #[cfg_attr(feature = "schema", schemars(extend("init" = {
221        "group": "control",
222        "blurb": "How long a triggered action has to answer before shep gives up"
223    })))]
224    pub action_timeout: UpDuration,
225    /// Memory ceiling — polling enforcer restarts above this
226    #[cfg_attr(feature = "schema", schemars(extend("init" = {
227        "example": "512M",
228        "group": "control",
229        "blurb": "Restart the app if it climbs above this much memory"
230    })))]
231    pub max_memory: Option<MemSize>,
232    /// Watch files and restart on change
233    #[cfg_attr(feature = "schema", schemars(extend("init" = {
234        "group": "control",
235        "blurb": "Restart when a file changes"
236    })))]
237    pub watch: bool,
238    /// Watch ignore globs (defaults added daemon-side: dot-entries, node_modules)
239    #[cfg_attr(feature = "schema", schemars(extend("init" = {
240        "group": "control",
241        "blurb": "Paths watch should skip, on top of dotfiles and node_modules"
242    })))]
243    pub ignore_watch: Vec<String>,
244    /// Watch debounce window (default 500ms, applied daemon-side)
245    #[cfg_attr(feature = "schema", schemars(extend("init" = {
246        "example": "500",
247        "group": "control",
248        "blurb": "How long to wait after a change before restarting"
249    })))]
250    pub watch_delay: Option<UpDuration>,
251    /// Cron pattern for scheduled restarts (croner dialect)
252    #[cfg_attr(feature = "schema", schemars(extend("init" = {
253        "example": "* * * * *",
254        "group": "cron",
255        "blurb": "Restart on a schedule, written as a cron pattern"
256    })))]
257    pub cron_restart: Option<String>,
258    /// Fold (group) this sheep belongs to
259    #[cfg_attr(feature = "schema", schemars(extend("init" = {
260        "example": "backend",
261        "group": "process",
262        "blurb": "A fold to group this app with others, for commands that take one"
263    })))]
264    pub fold: Option<String>,
265    /// Run as this user (unix)
266    #[cfg_attr(feature = "schema", schemars(extend("init" = {
267        "example": "www-data",
268        "group": "process",
269        "blurb": "Run as this user, on unix"
270    })))]
271    pub user: Option<String>,
272    /// Run as this group (unix)
273    #[cfg_attr(feature = "schema", schemars(extend("init" = {
274        "example": "www-data",
275        "group": "process",
276        "blurb": "Run as this group, on unix"
277    })))]
278    pub group: Option<String>,
279    /// Stdout log file (default: `$SHEP_HOME/logs/<name>-<instance>-out.log`; `merge_logs` collapses to `<name>-out.log`)
280    #[cfg_attr(feature = "schema", schemars(extend("init" = {
281        "example": "/var/log/my-first-sheep/out.log",
282        "group": "process",
283        "blurb": "Where stdout goes. Defaults to a file under $SHEP_HOME/logs"
284    })))]
285    pub out_file: Option<String>,
286    /// Stderr log file (default: `$SHEP_HOME/logs/<name>-<instance>-err.log`; `merge_logs` collapses to `<name>-err.log`)
287    #[cfg_attr(feature = "schema", schemars(extend("init" = {
288        "example": "/var/log/my-first-sheep/err.log",
289        "group": "process",
290        "blurb": "Where stderr goes. Defaults to a file under $SHEP_HOME/logs"
291    })))]
292    pub err_file: Option<String>,
293    /// Merge instance logs into one file pair
294    #[cfg_attr(feature = "schema", schemars(extend("init" = {
295        "group": "process",
296        "blurb": "Put every instance's output in one pair of files"
297    })))]
298    pub merge_logs: bool,
299    /// Open the shepherd channel on fd 3 for this app on its own, without
300    /// needing `wait_ready` or `shutdown_with_message` to imply it.
301    ///
302    /// Defaults to `false`: a socketpair plus two pump tasks per sheep is
303    /// real cost weighed against spec §14.11's single-digit-MB idle-RSS
304    /// goal, so a channel is opened only when something asks for one.
305    #[cfg_attr(feature = "schema", schemars(extend("init" = {
306        "group": "inputs",
307        "blurb": "Opens fd 3 so the app can talk to shep directly"
308    })))]
309    pub channel: bool,
310    /// Open a pipe on this sheep's stdin, so `shep whisper` can write to it.
311    ///
312    /// Defaults to `false`, and the default is the decision rather than a
313    /// convenience. Without it a sheep gets `/dev/null` on fd 0, which is what
314    /// every sheep has had until now, and three things argue for keeping it
315    /// that way unless an app asks otherwise:
316    ///
317    /// - Flipping it for the whole flock is a behaviour change to processes
318    ///   nobody asked to change.
319    /// - **Programs detect stdin.** A closed or null fd 0 is how a great many
320    ///   programs decide they are non-interactive — no prompt, no pager, no
321    ///   readline, no colour. Handing them a pipe silently moves them to the
322    ///   other branch.
323    /// - It costs a descriptor and a pump task per sheep for the whole life of
324    ///   the process, against spec §14.11's single-digit-MB idle-RSS goal — the
325    ///   same budget [`Self::channel`]'s own default is protecting.
326    ///
327    /// Unlike `channel`, nothing implies this: `wait_ready` and
328    /// `shutdown_with_message` both need fd 3 and so turn `channel` on for you,
329    /// while nothing in shep needs a sheep's stdin except an operator typing
330    /// `shep whisper`. A sheep without it answers a `no_stdin` row and names
331    /// this field.
332    ///
333    /// The pipe's write end lives as long as the sheep does, so the app sees
334    /// EOF on stdin when the process is on its way out, never before.
335    #[cfg_attr(feature = "schema", schemars(extend("init" = {
336        "group": "inputs",
337        "blurb": "Keeps stdin open so shep whisper can write to the process"
338    })))]
339    pub stdin: bool,
340    /// Expect `{"kind":"ready"}` on the shepherd channel
341    #[cfg_attr(feature = "schema", schemars(extend("init" = {
342        "group": "control",
343        "blurb": "Wait for the app to say it is ready on the channel"
344    })))]
345    pub wait_ready: bool,
346    /// Asserts that the app itself sets `SO_REUSEPORT` before it binds —
347    /// shep binds nothing, so it cannot set the option on the app's behalf.
348    /// The child process owns the mechanism (Node ≥22's `reusePort`, Go's
349    /// `net.ListenConfig.Control`, nginx's `reuseport`); shep's contribution
350    /// is permission for the old and new instance to overlap during reload,
351    /// not the socket option itself.
352    ///
353    /// **This field is inert today.** shep never reads it: reload overlap
354    /// already happens unconditionally, so setting it changes nothing and
355    /// leaving it unset costs nothing. It is kept because `shep import`
356    /// writes it for a cluster-mode pm2 app and `shep flock` displays it, so
357    /// dropping it would silently discard a value out of an imported config.
358    /// It becomes load-bearing the day shep gains a reload mode that does NOT
359    /// overlap by default, which is when the permission it describes stops
360    /// being free — see `docs/specs/deferred.md`.
361    #[cfg_attr(feature = "schema", schemars(extend("init" = {
362        "group": "process",
363        "blurb": "Not built yet. Setting it is refused rather than quietly ignored"
364    })))]
365    pub reuse_port: bool,
366    /// Readiness probe — gates reload's AwaitReady (spec §7)
367    #[cfg_attr(feature = "schema", schemars(extend("init" = {
368        "example": { "kind": "http", "target": "http://127.0.0.1:8080/ready" },
369        "group": "control",
370        "blurb": "A health check shep waits on before it treats a reload as finished"
371    })))]
372    pub readiness_probe: Option<ProbeConfig>,
373    /// Liveness probe — failures feed the restart policy (spec §7)
374    #[cfg_attr(feature = "schema", schemars(extend("init" = {
375        "example": { "kind": "http", "target": "http://127.0.0.1:8080/healthz" },
376        "group": "control",
377        "blurb": "A health check that triggers a restart when it keeps failing"
378    })))]
379    pub liveness_probe: Option<ProbeConfig>,
380    /// Watch include globs (empty = watch cwd)
381    #[cfg_attr(feature = "schema", schemars(extend("init" = {
382        "group": "control",
383        "blurb": "Which paths to watch. Empty means the working directory"
384    })))]
385    pub watch_options: Vec<String>,
386    /// Timezone for `cron_restart` (IANA name)
387    #[cfg_attr(feature = "schema", schemars(extend("init" = {
388        "example": "US/Eastern",
389        "group": "cron",
390        "blurb": "Which timezone cron_restart is read in, as an IANA name"
391    })))]
392    pub cron_timezone: Option<String>,
393    /// Env var receiving the instance slot (default `SHEP_INSTANCE`)
394    #[cfg_attr(feature = "schema", schemars(extend("init" = {
395        "example": "INSTANCE_ID",
396        "group": "inputs",
397        "blurb": "The env var each instance finds its own slot number in"
398    })))]
399    pub increment_var: Option<String>,
400}
401
402/// Debug implementation does not leak env values (IR-41)
403impl fmt::Debug for AppConfig {
404    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
405        f.debug_struct("AppConfig")
406            .field("name", &self.name)
407            .field("script", &self.script)
408            .field("env", &format_args!("<{} vars>", self.env.len()))
409            .finish_non_exhaustive()
410    }
411}
412
413impl Default for AppConfig {
414    fn default() -> Self {
415        Self {
416            name: String::new(),
417            script: String::new(),
418            args: Vec::new(),
419            cwd: None,
420            interpreter: None,
421            env: BTreeMap::new(),
422            instances: 1,
423            autorestart: true,
424            autostart: true,
425            stop_exit_codes: Vec::new(),
426            min_uptime: UpDuration::from_millis(1000),
427            max_restarts: 16,
428            restart_delay: None,
429            exp_backoff_restart_delay: None,
430            kill_signal: None,
431            kill_timeout: UpDuration::from_millis(1600),
432            shutdown_with_message: false,
433            listen_timeout: UpDuration::from_millis(3000),
434            graceful_timeout: UpDuration::from_millis(8000),
435            action_timeout: UpDuration::from_millis(3000),
436            max_memory: None,
437            watch: false,
438            ignore_watch: Vec::new(),
439            watch_delay: None,
440            cron_restart: None,
441            fold: None,
442            user: None,
443            group: None,
444            out_file: None,
445            err_file: None,
446            merge_logs: false,
447            channel: false,
448            stdin: false,
449            wait_ready: false,
450            reuse_port: false,
451            readiness_probe: None,
452            liveness_probe: None,
453            watch_options: Vec::new(),
454            cron_timezone: None,
455            increment_var: None,
456        }
457    }
458}
459
460impl AppConfig {
461    /// A minimal config with spec defaults — the programmatic entry point
462    #[must_use]
463    pub fn minimal(name: &str, script: &str) -> Self {
464        Self {
465            name: name.to_string(),
466            script: script.to_string(),
467            ..Self::default()
468        }
469    }
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475    use crate::values::{MemSize, UpDuration};
476
477    #[test]
478    fn minimal_config_gets_spec_defaults() {
479        let app = AppConfig::minimal("web", "./server");
480        assert_eq!(app.name, "web");
481        assert_eq!(app.script, "./server");
482        assert!(app.autorestart);
483        assert!(app.autostart);
484        assert_eq!(app.instances, 1);
485        assert_eq!(app.min_uptime, UpDuration::from_millis(1000));
486        assert_eq!(app.max_restarts, 16);
487        assert_eq!(app.kill_timeout, UpDuration::from_millis(1600));
488        assert_eq!(app.listen_timeout, UpDuration::from_millis(3000));
489        assert_eq!(app.graceful_timeout, UpDuration::from_millis(8000));
490        assert_eq!(app.action_timeout, UpDuration::from_millis(3000));
491        assert!(app.max_memory.is_none());
492        assert!(app.fold.is_none());
493        assert!(!app.channel);
494    }
495
496    /// fails if `stdin` defaults to anything but false. The default is the
497    /// whole decision: piping stdin for every sheep would change how a great
498    /// many programs behave (a closed stdin is how they decide they are
499    /// non-interactive), and would hold a descriptor and a task per sheep for
500    /// the life of the process.
501    #[test]
502    fn stdin_is_not_piped_unless_the_app_asks() {
503        let app = AppConfig::minimal("web", "./srv");
504        assert!(!app.stdin);
505        let parsed: AppConfig = toml::from_str("name = \"web\"\nscript = \"./srv\"").unwrap();
506        assert!(!parsed.stdin);
507    }
508
509    /// fails if the Flockfile key is spelled anything but `stdin`. It is a
510    /// contract with every config file already written against it the moment
511    /// this ships, and `deny_unknown_fields` means a rename is a hard parse
512    /// failure for the operator rather than a silently ignored key.
513    #[test]
514    fn the_flockfile_key_is_stdin() {
515        let parsed: AppConfig =
516            toml::from_str("name = \"web\"\nscript = \"./srv\"\nstdin = true").unwrap();
517        assert!(parsed.stdin);
518    }
519
520    #[test]
521    fn toml_round_trip_with_newtypes() {
522        let toml_src = r#"
523name = "worker"
524script = "python3"
525args = ["job.py", "--fast"]
526max_memory = "512M"
527min_uptime = "5s"
528fold = "backend"
529env = { RUST_LOG = "info" }
530"#;
531        let app: AppConfig = toml::from_str(toml_src).unwrap();
532        assert_eq!(app.max_memory, Some("512M".parse::<MemSize>().unwrap()));
533        assert_eq!(app.min_uptime, UpDuration::from_millis(5000));
534        assert_eq!(app.fold.as_deref(), Some("backend"));
535        assert_eq!(app.env.get("RUST_LOG").map(String::as_str), Some("info"));
536        assert_eq!(app.args, vec!["job.py", "--fast"]);
537    }
538
539    #[test]
540    fn unknown_fields_are_rejected() {
541        let err = toml::from_str::<AppConfig>(
542            "name = \"x\"\nscript = \"y\"\nmax_memory_restart = \"1G\"",
543        )
544        .unwrap_err();
545        assert!(err.to_string().contains("max_memory_restart"), "{err}");
546    }
547
548    #[test]
549    fn probe_config_parses_with_defaults() {
550        let src = r#"
551name = "api"
552script = "./api"
553
554[readiness_probe]
555kind = "http"
556target = "http://127.0.0.1:8080/healthz"
557"#;
558        let app: AppConfig = toml::from_str(src).unwrap();
559        let probe = app.readiness_probe.unwrap();
560        assert_eq!(probe.kind, ProbeKind::Http);
561        assert_eq!(probe.target, "http://127.0.0.1:8080/healthz");
562        assert_eq!(probe.interval, UpDuration::from_millis(10_000));
563        assert_eq!(probe.timeout, UpDuration::from_millis(5_000));
564        assert_eq!(probe.failure_threshold, 3);
565        assert!(app.liveness_probe.is_none());
566    }
567
568    #[test]
569    fn debug_redacts_env_values() {
570        // IR-41: env may carry secrets; Debug output lands in daemon logs.
571        // Exact string pinned so a lazy derive(Debug) refactor fails here.
572        let mut app = AppConfig::minimal("web", "./srv");
573        app.env
574            .insert("DATABASE_URL".to_string(), "postgres://secret".to_string());
575        app.env.insert("RUST_LOG".to_string(), "info".to_string());
576        assert_eq!(
577            format!("{app:?}"),
578            "AppConfig { name: \"web\", script: \"./srv\", env: <2 vars>, .. }"
579        );
580    }
581}