pitchfork_cli/settings.rs
1//! User-configurable settings for pitchfork.
2//!
3//! Settings can be configured in multiple ways (in order of precedence):
4//! 1. Environment variables (highest priority)
5//! 2. Project-level `pitchfork.toml` or `pitchfork.local.toml` (in `[settings]` section)
6//! 3. User-level `~/.config/pitchfork/config.toml` (in `[settings]` section)
7//! 4. System-level `/etc/pitchfork/config.toml` (in `[settings]` section)
8//! 5. Built-in defaults (lowest priority)
9//!
10//! Example pitchfork.toml with settings:
11//! ```toml
12//! [daemons.myapp]
13//! run = "node server.js"
14//!
15//! [settings.general]
16//! autostop_delay = "5m"
17//! log_level = "debug"
18//!
19//! [settings.web]
20//! auto_start = true
21//! ```
22//!
23//! The structs below are the single declaration of every setting:
24//! `#[derive(usage_rs::Config)]` generates the usage-config registry, the
25//! reader that fills the structs from a resolution, and the spec `config`
26//! block that documents them (carried into `pitchfork.usage.kdl` through
27//! `#[usage(config = ...)]` on the CLI root). There is no `settings.toml`
28//! and no build-script generator left to keep in step with this file.
29//!
30//! Resolution is usage-config's single origin-tracked merge, so explicitly
31//! setting a value equal to the default in a higher-precedence file still
32//! overrides a lower one, and `pitchfork settings` renders provenance from
33//! the same merge that produced the values.
34
35use std::path::{Path, PathBuf};
36use std::sync::Arc;
37use usage_rs::config::{
38 Const, EnvLayer, FileLayer, FileScope, Layers, Resolved, SourceKind, Ty, Value, resolve,
39};
40
41/// The `api.*` settings: the standalone API server (JSON REST endpoints for
42/// the Vue SPA). When `api.auto_start` is enabled and `api.bind_port` is set, the API server binds
43/// independently of the web UI.
44#[derive(usage_rs::Config, Debug, Clone, PartialEq)]
45#[usage(prefix = "api")]
46pub struct SettingsApi {
47 /// Automatically start the standalone API server
48 ///
49 /// When true, pitchfork starts a standalone API server on api.bind_address +
50 /// api.bind_port when the supervisor launches. The web UI does not need to
51 /// be enabled for this; you can run the API alone and serve the Vue SPA from
52 /// a separate static host (e.g. nginx, Vite dev server).
53 ///
54 /// Default is false so the API is only available through the bundled web UI.
55 #[usage(env = "PITCHFORK_API_AUTO_START", default = false)]
56 pub auto_start: bool,
57
58 /// IP address the API server binds to
59 ///
60 /// Use "0.0.0.0" to make the API reachable from other devices on your network.
61 /// Keep "127.0.0.1" (default) to restrict it to localhost.
62 #[usage(env = "PITCHFORK_API_BIND_ADDRESS", default = "127.0.0.1")]
63 pub bind_address: String,
64
65 /// Port the standalone API server listens on
66 ///
67 /// Set to 0 (default) to disable the standalone API server.
68 /// Set to a valid port (e.g. 8081) and enable `api.auto_start` to start the API on its own port.
69 #[usage(env = "PITCHFORK_API_BIND_PORT", default = 0)]
70 pub bind_port: i64,
71
72 /// Number of consecutive ports to try if api.bind_port is in use
73 #[usage(env = "PITCHFORK_API_PORT_ATTEMPTS", default = 10)]
74 pub port_attempts: i64,
75
76 /// Authentication token for API access when bound to non-loopback addresses
77 ///
78 /// When the API server or web UI is bound to a non-loopback address (e.g.,
79 /// "0.0.0.0"), this token is required on every API request via the
80 /// X-Pitchfork-Token header.
81 ///
82 /// If left empty and the bind address is non-loopback, a random 32-byte
83 /// hex token is auto-generated on startup and printed to the supervisor log.
84 /// This token is injected into the served index.html, so the bundled Vue
85 /// SPA works without additional configuration.
86 ///
87 /// For external API consumers (e.g., curl, mobile apps), set this to a
88 /// fixed value or pass it via the PITCHFORK_API_TOKEN environment variable.
89 #[usage(env = "PITCHFORK_API_TOKEN", default = "")]
90 pub token: String,
91}
92
93/// The `general.*` settings.
94#[derive(usage_rs::Config, Debug, Clone, PartialEq)]
95#[usage(prefix = "general")]
96pub struct SettingsGeneral {
97 /// Delay before auto-stopping daemons when leaving a directory
98 ///
99 /// When using shell hooks with `auto = ["stop"]`, this controls how long pitchfork waits
100 /// before stopping a daemon after you leave its directory.
101 ///
102 /// This delay prevents unnecessary stop/start cycles when briefly switching directories.
103 ///
104 /// **Examples:**
105 /// - `"0s"` - Stop immediately (no delay)
106 /// - `"30s"` - Wait 30 seconds
107 /// - `"1m"` - Wait 1 minute (default)
108 /// - `"5m"` - Wait 5 minutes
109 ///
110 /// Set to `"0s"` to disable the delay and stop daemons immediately.
111 #[usage(env = "PITCHFORK_AUTOSTOP_DELAY", default = "1m", ty = "duration")]
112 pub autostop_delay: String,
113
114 /// Supervisor background task refresh interval
115 ///
116 /// Controls how often the supervisor refreshes its internal state and checks for:
117 /// - Daemon health status changes
118 /// - Configuration file updates
119 /// - Process state synchronization
120 ///
121 /// Lower values provide more responsive status updates but use more resources.
122 ///
123 /// **Recommended values:**
124 /// - `"5s"` - For development/testing
125 /// - `"10s"` - Default, balanced
126 /// - `"30s"` - For production with many daemons
127 #[usage(
128 env = "PITCHFORK_INTERVAL",
129 deprecated_env("PITCHFORK_INTERVAL_SECS"),
130 default = "10s",
131 ty = "duration"
132 )]
133 pub interval: String,
134
135 /// File log level (trace, debug, info, warn, error)
136 ///
137 /// Controls the verbosity of log output written to log files.
138 /// Can be set independently from `log_level` to have more verbose file logs.
139 ///
140 /// For example, set console to `"info"` but file to `"debug"` to keep
141 /// detailed logs for troubleshooting without cluttering the console.
142 #[usage(env = "PITCHFORK_LOG_FILE_LEVEL", default = "info")]
143 pub log_file_level: String,
144
145 /// Console log level (trace, debug, info, warn, error)
146 ///
147 /// Controls the verbosity of log output to the console.
148 ///
149 /// **Available levels:**
150 /// - `"trace"` - Most verbose, includes all internal details
151 /// - `"debug"` - Detailed information for debugging
152 /// - `"info"` - Normal operation messages (default)
153 /// - `"warn"` - Warnings and potential issues
154 /// - `"error"` - Only errors
155 #[usage(env = "PITCHFORK_LOG", default = "info")]
156 pub log_level: String,
157
158 /// Wrap daemon commands with mise x -- globally
159 ///
160 /// When enabled, pitchfork wraps every daemon command with `mise x --` so that
161 /// [mise](https://mise.jdx.dev) sets up the correct tool versions, PATH,
162 /// and environment variables before the daemon runs.
163 ///
164 /// This is especially useful when pitchfork is started as a login item or boot
165 /// daemon, where the shell profile (`.zshrc`, `.bashrc`) is not sourced and
166 /// tools installed via Homebrew or mise are not on PATH.
167 ///
168 /// Individual daemons can override this with `mise = true` or `mise = false`
169 /// in their configuration.
170 #[usage(env = "PITCHFORK_MISE", default = false)]
171 pub mise: bool,
172
173 /// Explicit path to the mise binary
174 ///
175 /// By default, pitchfork searches well-known locations for the mise binary:
176 /// - `~/.local/bin/mise`
177 /// - `~/.cargo/bin/mise`
178 /// - `/usr/local/bin/mise`
179 /// - `/opt/homebrew/bin/mise`
180 ///
181 /// Set this to an absolute path if mise is installed elsewhere.
182 #[usage(env = "PITCHFORK_MISE_BIN", default = "")]
183 pub mise_bin: String,
184
185 /// Shell command used to execute daemon run scripts
186 ///
187 /// Controls the shell used to execute daemon `run` commands, as well as
188 /// `ready_cmd` / `health_cmd` probes, lifecycle hooks and the log archive
189 /// hook.
190 ///
191 /// The value is split with `shell_words::split` into a program and arguments,
192 /// then the daemon's `run` string is appended verbatim as the final argument
193 /// (passed to the shell's command flag, e.g. `sh -c "<run>"`).
194 ///
195 /// This means the `run` string is interpreted directly by the shell, so
196 /// variable expansion (`$VAR`), globs (`*.txt`), pipes (`|`), and command
197 /// chaining (`&&`) all work as expected.
198 ///
199 /// **Common configurations:**
200 /// - `"sh -c"` — Default, POSIX shell
201 /// - `"sh -o errexit -c"` — Exit on the first failing command
202 /// - `"bash -c"` — Use bash instead of sh
203 /// - `"bash -o errexit -o pipefail -c"` — `pipefail` is not a POSIX option,
204 /// so name a shell that has it rather than relying on `sh`
205 ///
206 /// On Windows this setting applies only when it is set explicitly (in a
207 /// config file or via `PITCHFORK_SHELL`); otherwise
208 /// `general.windows_shell` is used. Setting it is the way to use one shell
209 /// on every platform, e.g. `"sh -c"` with Git for Windows' `sh.exe` on
210 /// `PATH`.
211 ///
212 /// When `mise = true` is enabled for a daemon, the shell wraps inside
213 /// `mise x --`, e.g. `mise x -- sh -c "<run>"`.
214 #[usage(env = "PITCHFORK_SHELL", default = "sh -c")]
215 pub shell: String,
216
217 /// Shell command used to execute daemon run scripts on Windows
218 ///
219 /// Used in place of `general.shell` on Windows, unless `general.shell` is
220 /// set explicitly. Split and used the same way: the program and arguments
221 /// come from this value, and the `run` string is appended as the final
222 /// argument, e.g. `cmd /C "<run>"`.
223 ///
224 /// The default is `cmd /C`, which is always available, so `run` strings are
225 /// read by cmd.exe: use `%VAR%` rather than `$VAR`, and double quotes
226 /// rather than single quotes.
227 ///
228 /// The split follows POSIX rules, so a path with spaces or backslashes has
229 /// to be quoted: `"'C:\Program Files\Git\bin\sh.exe' -c"`.
230 ///
231 /// **Common configurations:**
232 /// - `"cmd /C"` — Default
233 /// - `"powershell -Command"` / `"pwsh -Command"` — PowerShell
234 /// - `"sh -c"` — Git for Windows' sh, when `Git\bin` is on `PATH`
235 #[usage(env = "PITCHFORK_WINDOWS_SHELL", default = "cmd /C")]
236 pub windows_shell: String,
237
238 /// Show timestamps in startup log output
239 ///
240 /// When enabled, pitchfork prefixes each startup log line and result line
241 /// with a timestamp (e.g. `19:03:15`), making it easier to see how long
242 /// each daemon took to start.
243 ///
244 /// When disabled (default), a dim bullet (`•`) is used instead to keep
245 /// the output compact and aligned with the spinner / status icons.
246 #[usage(env = "PITCHFORK_STARTUP_LOG_TIMESTAMPS", default = false)]
247 pub startup_log_timestamps: bool,
248
249 /// Default readiness delay in seconds when a daemon has no ready check configured
250 ///
251 /// When a daemon has no other readiness check (output, HTTP, port, command),
252 /// pitchfork waits this long before considering it ready. Daemons can
253 /// override this with their own `ready_delay` setting.
254 #[usage(env = "PITCHFORK_READY_DELAY", default = "3s", ty = "duration")]
255 pub ready_delay: String,
256
257 /// Enable git worktree / jj workspace auto-discovery
258 ///
259 /// When enabled (default), pitchfork discovers git worktrees and jj workspaces for proxy
260 /// hostname routing and supervisor config discovery. Each worktree gets its own namespace,
261 /// and its daemons answer at `<daemon>.<worktree>.<project>.<tld>`.
262 ///
263 /// Set to `false` to disable all worktree/workspace discovery. The deprecated
264 /// `PITCHFORK_PROXY_WORKTREE` environment variable remains an alias during migration.
265 #[usage(
266 env = "PITCHFORK_WORKTREE",
267 deprecated_env("PITCHFORK_PROXY_WORKTREE"),
268 alias("proxy.worktree"),
269 default = true
270 )]
271 pub worktree: bool,
272}
273
274/// The `ipc.*` (inter-process communication) settings.
275#[derive(usage_rs::Config, Debug, Clone, PartialEq)]
276#[usage(prefix = "ipc")]
277pub struct SettingsIpc {
278 /// Number of connection retry attempts
279 ///
280 /// How many times to retry connecting to the supervisor before giving up.
281 /// Each attempt uses exponential backoff between `connect_min_delay` and `connect_max_delay`.
282 #[usage(env = "PITCHFORK_IPC_CONNECT_ATTEMPTS", default = 5)]
283 pub connect_attempts: i64,
284
285 /// Maximum delay between connection retries
286 ///
287 /// The maximum delay between connection retry attempts.
288 /// Exponential backoff will not exceed this value.
289 #[usage(
290 env = "PITCHFORK_IPC_CONNECT_MAX_DELAY",
291 default = "1s",
292 ty = "duration"
293 )]
294 pub connect_max_delay: String,
295
296 /// Minimum delay between connection retries
297 ///
298 /// The initial delay between connection retry attempts.
299 /// The actual delay increases exponentially up to `connect_max_delay`.
300 #[usage(
301 env = "PITCHFORK_IPC_CONNECT_MIN_DELAY",
302 default = "100ms",
303 ty = "duration"
304 )]
305 pub connect_min_delay: String,
306
307 /// Maximum IPC requests per second per connection
308 ///
309 /// Rate limiting for IPC connections to prevent local DoS attacks.
310 /// Uses a sliding window algorithm.
311 ///
312 /// Most users won't need to change this. Increase if you have automated
313 /// tools making many rapid requests to the supervisor.
314 #[usage(env = "PITCHFORK_IPC_RATE_LIMIT", default = 100)]
315 pub rate_limit: i64,
316
317 /// Rate limit sliding window duration
318 ///
319 /// The time window for rate limiting calculations.
320 /// `rate_limit` requests are allowed within each window.
321 #[usage(
322 env = "PITCHFORK_IPC_RATE_LIMIT_WINDOW",
323 default = "1s",
324 ty = "duration"
325 )]
326 pub rate_limit_window: String,
327
328 /// Default timeout for IPC requests
329 ///
330 /// Maximum time to wait for a response from the supervisor for most operations.
331 ///
332 /// Note: Daemon start operations may use a longer timeout calculated from
333 /// the daemon's `ready_delay` setting plus a buffer.
334 #[usage(env = "PITCHFORK_IPC_REQUEST_TIMEOUT", default = "5s", ty = "duration")]
335 pub request_timeout: String,
336}
337
338/// The `logs.archive_hook.*` settings.
339#[derive(usage_rs::Config, Debug, Clone, PartialEq)]
340#[usage(prefix = "logs.archive_hook")]
341pub struct SettingsLogsArchiveHook {
342 /// Maximum log entries per archive hook invocation
343 ///
344 /// Log entries selected for pruning are passed to the archive hook in batches of
345 /// this size. Smaller batches use less memory and are easier to retry, while larger
346 /// batches reduce hook invocation overhead.
347 #[usage(env = "PITCHFORK_LOG_ARCHIVE_HOOK_BATCH_SIZE", default = 1000)]
348 pub batch_size: i64,
349
350 /// Command to run before retention deletes old log entries
351 ///
352 /// When set, the given command is invoked before log entries are permanently
353 /// pruned by retention. The command receives the entries about to be deleted as
354 /// JSON Lines (JSONL) on stdin. Each line is an object with the fields:
355 ///
356 /// - `id`: the SQLite row id
357 /// - `daemon_id`: the qualified daemon id (e.g. `"myproject/api"`)
358 /// - `timestamp`: the log timestamp in RFC 3339 format
359 /// - `message`: the raw log message text
360 ///
361 /// The following environment variables are also available:
362 ///
363 /// - `PITCHFORK_DAEMON_ID`: the qualified daemon id
364 /// - `PITCHFORK_ARCHIVE_REASON`: either `"age"` or `"count"`
365 ///
366 /// If the command exits with a non-zero status, the pruning is skipped for that
367 /// batch. This prevents data loss when the archive destination is unavailable.
368 ///
369 /// **Examples:**
370 ///
371 /// ```toml
372 /// [settings.logs.archive_hook]
373 /// command = "gzip -c >> /var/log/pitchfork/archive.jsonl.gz"
374 /// ```
375 ///
376 /// ```toml
377 /// [settings.logs.archive_hook]
378 /// command = "aws s3 cp - s3://my-bucket/pitchfork-logs/"
379 /// ```
380 #[usage(env = "PITCHFORK_LOG_ARCHIVE_HOOK_COMMAND", default = "")]
381 pub command: String,
382}
383
384/// The `logs.*` settings.
385#[derive(usage_rs::Config, Debug, Clone, PartialEq)]
386#[usage(prefix = "logs")]
387pub struct SettingsLogs {
388 #[usage(flatten)]
389 pub archive_hook: SettingsLogsArchiveHook,
390
391 /// Count-based log retention (e.g. 10000)
392 ///
393 /// Maximum number of log entries to keep per daemon in the SQLite log store.
394 ///
395 /// When set, only the most recent N log entries are retained. Older entries are
396 /// automatically pruned.
397 ///
398 /// When set to `0` (default), no count-based pruning is performed. You can combine
399 /// this with `time_retention` to enforce both a time limit and a line count limit.
400 ///
401 /// Logs are pruned automatically by the supervisor during its interval watcher
402 /// cycle (no more than once per hour). No manual rotation command is needed.
403 #[usage(env = "PITCHFORK_LOG_LINE_RETENTION", default = 0)]
404 pub line_retention: i64,
405
406 /// Default log format for daemons (json | logfmt | text)
407 #[usage(env = "PITCHFORK_LOG_FORMAT", default = "text")]
408 pub log_format: String,
409
410 /// Time-based log retention duration (e.g. '7d', '30d')
411 ///
412 /// Maximum age of log entries to keep in the SQLite log store.
413 ///
414 /// When set, log entries older than this duration are automatically pruned.
415 /// Examples: `"7d"` for 7 days, `"30d"` for 30 days, `"1h"` for 1 hour.
416 ///
417 /// When empty (default), no time-based pruning is performed. You can combine
418 /// this with `line_retention` to enforce both a time limit and a line count limit.
419 ///
420 /// Logs are pruned automatically by the supervisor during its interval watcher
421 /// cycle (no more than once per hour). No manual rotation command is needed.
422 #[usage(env = "PITCHFORK_LOG_TIME_RETENTION", default = "", ty = "duration")]
423 pub time_retention: String,
424
425 /// Show timestamps in log output
426 ///
427 /// When enabled (default), pitchfork prefixes each log line with a timestamp
428 /// (e.g. `07-10 10:30:00`).
429 ///
430 /// When disabled, timestamps are omitted from `pitchfork logs` output. This is
431 /// useful when piping logs to tools like `lnav` that expect raw JSON or when
432 /// you want to process log output without the timestamp prefix.
433 ///
434 /// Can be overridden per-invocation with `pitchfork logs --no-timestamp`.
435 #[usage(env = "PITCHFORK_LOG_TIMESTAMP", default = true)]
436 pub timestamp: bool,
437
438 /// strftime format for log timestamps in `pitchfork logs` output
439 ///
440 /// Controls the date/time format used when displaying timestamps in `pitchfork logs`
441 /// output. Uses chrono strftime syntax.
442 ///
443 /// Common format specifiers:
444 /// - `%Y` — full year (2024)
445 /// - `%m` — month (01-12)
446 /// - `%d` — day (01-31)
447 /// - `%H` — hour (00-23)
448 /// - `%M` — minute (00-59)
449 /// - `%S` — second (00-59)
450 ///
451 /// Examples:
452 /// - `%m-%d %H:%M:%S` — `07-10 10:30:00` (default)
453 /// - `%Y-%m-%d %H:%M:%S` — `2024-07-10 10:30:00`
454 /// - `%H:%M:%S` — `10:30:00` (time only)
455 /// - `%Y/%m/%d %H:%M` — `2024/07/10 10:30`
456 ///
457 /// Only affects the text display output, not `--json` or `--raw` modes.
458 #[usage(env = "PITCHFORK_LOG_TIMESTAMP_FORMAT", default = "%m-%d %H:%M:%S")]
459 pub timestamp_format: String,
460}
461
462/// The `proxy.*` settings.
463#[derive(usage_rs::Config, Debug, Clone, PartialEq)]
464#[usage(prefix = "proxy")]
465pub struct SettingsProxy {
466 /// Automatically start daemons when accessed via proxy URL
467 ///
468 /// When enabled (default), visiting a proxy URL for a stopped daemon will
469 /// automatically start that daemon. The browser receives a "Starting…" page
470 /// that refreshes every 2 seconds until the daemon is ready, at which point
471 /// the request is proxied normally.
472 ///
473 /// Set to `false` to disable auto-start and return a plain 502 error for
474 /// stopped daemons (the previous behaviour).
475 #[usage(env = "PITCHFORK_PROXY_AUTO_START", default = true)]
476 pub auto_start: bool,
477
478 /// Maximum time to wait for an auto-started daemon to become ready
479 ///
480 /// When a daemon is auto-started via a proxy request, the proxy waits up to
481 /// this duration for the **entire** auto-start operation to complete — including
482 /// waiting for the daemon's readiness signal and detecting the bound port.
483 ///
484 /// If the daemon does not become ready and bind a port within this timeout,
485 /// the browser receives an error page indicating the startup timed out.
486 ///
487 /// **Examples:**
488 /// - `"15s"` - Shorter timeout for fast-starting services
489 /// - `"30s"` - Default, suitable for most daemons
490 /// - `"60s"` - For daemons with slow initialisation (e.g. large Java apps)
491 #[usage(
492 env = "PITCHFORK_PROXY_AUTO_START_TIMEOUT",
493 default = "30s",
494 ty = "duration"
495 )]
496 pub auto_start_timeout: String,
497
498 /// Automatically install the proxy TLS certificate into the system trust store
499 ///
500 /// When enabled (default), pitchfork automatically installs the proxy's
501 /// self-signed CA certificate into the system trust store during supervisor
502 /// startup, so that browsers and tools trust HTTPS proxy URLs without
503 /// certificate warnings.
504 ///
505 /// On macOS, this triggers a system authorization dialog (Touch ID or password).
506 /// On Linux, this requires write access to the system CA directory (typically
507 /// needs `sudo`).
508 ///
509 /// If auto-trust fails (e.g. due to permissions), it is silently skipped and
510 /// a warning is logged. You can manually install the certificate with:
511 /// pitchfork proxy trust
512 ///
513 /// Set to `false` to disable auto-trust entirely.
514 #[usage(env = "PITCHFORK_PROXY_AUTO_TRUST", default = true)]
515 pub auto_trust: bool,
516
517 /// Enable the reverse proxy server for daemons
518 ///
519 /// When enabled, pitchfork starts a reverse proxy that routes a stable
520 /// hostname to the daemon's actual listening port.
521 ///
522 /// Every daemon with a `port` gets a hostname built from its name, its
523 /// worktree when it lives in one, and its project, unless it opts out with
524 /// `proxy = false`. A daemon without a port is not routed.
525 ///
526 /// Example: `api.myproject.localhost:7777` -> `localhost:3000`
527 #[usage(env = "PITCHFORK_PROXY_ENABLE", default = false)]
528 pub enable: bool,
529
530 /// Bind address for the reverse proxy server
531 ///
532 /// IP address the reverse proxy listens on.
533 ///
534 /// **Security Warning:** The default `127.0.0.1` only allows local connections.
535 /// Setting this to `0.0.0.0` will expose the proxy on every network interface,
536 /// including externally routable ones -- anyone on the same LAN can then reach
537 /// your local daemons.
538 ///
539 /// **Examples:**
540 /// - `"127.0.0.1"` - Local only (default, recommended)
541 /// - `"0.0.0.0"` - All interfaces (use with caution)
542 /// - `"::1"` - IPv6 loopback
543 #[usage(env = "PITCHFORK_PROXY_HOST", default = "127.0.0.1")]
544 pub host: String,
545
546 /// Enable HTTPS for the reverse proxy
547 ///
548 /// When enabled (default), the proxy serves HTTPS instead of HTTP.
549 ///
550 /// You must also configure `proxy.tls_cert` and `proxy.tls_key`, or pitchfork
551 /// will auto-generate a self-signed certificate stored in the state directory.
552 ///
553 /// Set to `false` to use plain HTTP (e.g. for simple local development).
554 #[usage(env = "PITCHFORK_PROXY_HTTPS", default = true)]
555 pub https: bool,
556
557 /// Enable LAN mode for the reverse proxy
558 ///
559 /// When enabled, the proxy switches to the `.local` TLD and publishes slug
560 /// hostnames via mDNS so that other devices on the same network can reach
561 /// your daemons (e.g. `myapp.local` from a phone or another computer).
562 ///
563 /// LAN mode:
564 /// - Forces `proxy.tld` to `local` (mDNS requirement)
565 /// - Publishes each slug as an mDNS address record (`<slug>.local → <LAN-IP>`)
566 /// - Binds the proxy to `0.0.0.0` instead of `127.0.0.1` (overridable via `proxy.host`)
567 /// - Auto-detects your LAN IP and re-publishes mDNS records if it changes
568 ///
569 /// Other devices must trust the pitchfork CA certificate to use HTTPS.
570 /// Run `pitchfork proxy trust` on each device, or use `proxy.https = false`.
571 #[usage(env = "PITCHFORK_PROXY_LAN", default = false)]
572 pub lan: bool,
573
574 /// Pin a specific LAN IP address instead of auto-detecting
575 ///
576 /// When set, skips auto-detection and uses this IP for mDNS publishing.
577 /// Implies `proxy.lan = true` if a non-empty value is provided.
578 #[usage(env = "PITCHFORK_PROXY_LAN_IP", default = "")]
579 pub lan_ip: String,
580
581 /// Port the reverse proxy server listens on
582 ///
583 /// The port pitchfork's reverse proxy binds to. Must be in the range 1-65535.
584 ///
585 /// Default is 443 (standard HTTPS port) since the proxy defaults to HTTPS.
586 /// Users can override this to any port (e.g. 7777) to avoid requiring
587 /// elevated privileges.
588 ///
589 /// Ports below 1024 require the supervisor to be started with elevated
590 /// privileges (e.g. `sudo pitchfork supervisor start`).
591 #[usage(env = "PITCHFORK_PROXY_PORT", default = 443)]
592 pub port: i64,
593
594 /// Automatically sync slug hostnames to /etc/hosts
595 ///
596 /// When enabled (default), pitchfork automatically adds entries to `/etc/hosts`
597 /// for registered slugs (e.g. `127.0.0.1 myapp.localhost`) so that browsers
598 /// can resolve them.
599 ///
600 /// This is needed because Safari does not auto-resolve `.localhost` subdomains,
601 /// and custom TLDs (e.g. `.test`) always require DNS entries.
602 ///
603 /// Entries are managed in a marked block in `/etc/hosts` and cleaned up when
604 /// the proxy shuts down. Writing to `/etc/hosts` may require `sudo`.
605 ///
606 /// Set to `false` to disable automatic hosts file management. You will need to
607 /// configure DNS resolution yourself (e.g. `dnsmasq`, `/etc/resolver/` on macOS).
608 #[usage(env = "PITCHFORK_PROXY_SYNC_HOSTS", default = true)]
609 pub sync_hosts: bool,
610
611 /// Top-level domain used for proxy URLs
612 ///
613 /// The TLD appended to daemon hostnames in proxy URLs.
614 ///
615 /// With the default `localhost`, daemon URLs look like:
616 /// `api.myproject.localhost:7777` (daemon `api` of project `myproject`)
617 ///
618 /// For custom TLDs (e.g. `test`), you need wildcard DNS resolution.
619 /// On macOS, you can use dnsmasq or add entries to `/etc/resolver/`.
620 #[usage(env = "PITCHFORK_PROXY_TLD", default = "localhost")]
621 pub tld: String,
622
623 /// Path to TLS certificate file (PEM format) for HTTPS proxy
624 ///
625 /// Path to a PEM-encoded TLS certificate file used when `proxy.https = true`.
626 ///
627 /// If left empty and `proxy.https = true`, pitchfork will auto-generate a
628 /// self-signed certificate and store it in `$PITCHFORK_STATE_DIR/proxy/cert.pem`.
629 #[usage(env = "PITCHFORK_PROXY_TLS_CERT", default = "")]
630 pub tls_cert: String,
631
632 /// Path to TLS private key file (PEM format) for HTTPS proxy
633 ///
634 /// Path to a PEM-encoded private key file used when `proxy.https = true`.
635 ///
636 /// If left empty and `proxy.https = true`, pitchfork will auto-generate a
637 /// self-signed key and store it in `$PITCHFORK_STATE_DIR/proxy/key.pem`.
638 #[usage(env = "PITCHFORK_PROXY_TLS_KEY", default = "")]
639 pub tls_key: String,
640
641 /// Enable wildcard subdomain matching for proxy routes
642 ///
643 /// When enabled (default), extra labels to the left of a daemon's hostname
644 /// route to that same daemon.
645 ///
646 /// For example, with a daemon reachable at `api.myproject.localhost`:
647 ///
648 /// - `api.myproject.localhost` → exact match (always works)
649 ///
650 /// - `tenant.api.myproject.localhost` → wildcard fallback to the same daemon
651 ///
652 /// The same holds for a legacy slug, where `tenant.myapp.localhost` falls
653 /// back to the slug `myapp`.
654 ///
655 /// This is useful for multi-tenant apps where each tenant gets a unique
656 /// subdomain (e.g. `acme.myapp.localhost`, `globex.myapp.localhost`) but
657 /// all share the same backend server.
658 ///
659 /// Set to `false` to require exact hostname matches only.
660 #[usage(env = "PITCHFORK_PROXY_WILDCARD", default = true)]
661 pub wildcard: bool,
662}
663
664/// The `supervisor.*` settings.
665#[derive(usage_rs::Config, Debug, Clone, PartialEq)]
666#[usage(prefix = "supervisor")]
667pub struct SettingsSupervisor {
668 /// Automatically start the supervisor when a client command needs it
669 ///
670 /// When enabled (default), commands such as `pitchfork start`, `pitchfork list`,
671 /// the TUI, and shell activation start a background supervisor automatically when
672 /// one is not already running.
673 ///
674 /// Disable this when the supervisor is managed by systemd, launchd, or another
675 /// service manager:
676 ///
677 /// ```toml
678 /// [settings.supervisor]
679 /// auto_start = false
680 /// ```
681 ///
682 /// With auto-start disabled, client commands wait for the configured IPC
683 /// connection attempts and then fail with an actionable error instead of spawning
684 /// an unmanaged supervisor. Explicit `pitchfork supervisor start` and
685 /// `pitchfork supervisor run` commands are unaffected.
686 #[usage(env = "PITCHFORK_SUPERVISOR_AUTO_START", default = true)]
687 pub auto_start: bool,
688
689 /// Reconcile orphaned daemon processes when supervisor starts
690 ///
691 /// When enabled, the supervisor scans the state file on startup for daemon
692 /// processes left behind by a previous supervisor instance that was killed
693 /// unexpectedly (for example, with `kill -9`) and reconciles them according
694 /// to `supervisor.orphan_policy` (re-adopt by default, or terminate).
695 ///
696 /// Before acting, the recorded process identity (PID plus kernel start
697 /// time) is verified so that a PID recycled by the OS to an unrelated process
698 /// is never adopted or killed — in that case only the stale state entry is
699 /// cleared. When terminating, Linux and Windows also pin that identity with a
700 /// pidfd or open process handle, so a recycled PID cannot be signaled. If the
701 /// identity cannot be verified or pinned, reconciliation fails closed: the
702 /// live process and its running state are retained rather than risk acting on
703 /// the wrong process or allowing a duplicate instance to start.
704 ///
705 /// Disabling this leaves orphaned processes and their state entries
706 /// completely untouched. This is a legacy escape hatch; prefer
707 /// `orphan_policy = "adopt"` (the default), which keeps daemons running
708 /// across a supervisor crash while resuming supervision.
709 #[usage(env = "PITCHFORK_CLEANUP_ORPHANS", default = true)]
710 pub cleanup_orphans: bool,
711
712 /// Enable container/PID1 mode for running inside Docker containers
713 ///
714 /// When enabled, pitchfork operates as a proper PID 1 process inside a container:
715 /// - Installs a SIGCHLD handler to reap all orphaned/zombie child processes
716 /// - Routes SIGTERM/SIGINT through the graceful shutdown sequence
717 ///
718 /// This is essential when running pitchfork as the entrypoint of a Docker container,
719 /// where PID 1 must reap zombie processes to prevent process table exhaustion.
720 ///
721 /// Can also be enabled via the `--container` CLI flag on `pitchfork supervisor run`.
722 #[usage(env = "PITCHFORK_CONTAINER", default = false)]
723 pub container: bool,
724
725 /// Consecutive CPU-over-limit samples before killing a daemon
726 ///
727 /// When a daemon has `cpu_limit` configured, the supervisor checks CPU usage at
728 /// each interval tick. To avoid killing daemons during transient spikes (e.g. JIT
729 /// warm-up, burst responses), the process is only killed after this many
730 /// **consecutive** samples exceed the limit. A single sample below the limit
731 /// resets the counter.
732 ///
733 /// **Examples:**
734 /// - `1` - Kill immediately on first over-limit sample (no grace period)
735 /// - `3` - Require 3 consecutive over-limit samples (default)
736 /// - `5` - More tolerant of short bursts
737 ///
738 /// With the default interval of `10s`, a threshold of `3` means a daemon must
739 /// exceed its CPU limit for ~30 seconds before being killed.
740 #[usage(env = "PITCHFORK_CPU_VIOLATION_THRESHOLD", default = 3)]
741 pub cpu_violation_threshold: i64,
742
743 /// Interval for checking cron schedules
744 ///
745 /// How often to check if any cron-scheduled daemons should be triggered.
746 ///
747 /// The default of 10 seconds supports sub-minute cron schedules.
748 /// Increase for lower resource usage if you don't need fine-grained scheduling.
749 #[usage(
750 env = "PITCHFORK_CRON_CHECK_INTERVAL",
751 default = "10s",
752 ty = "duration"
753 )]
754 pub cron_check_interval: String,
755
756 /// File watch debounce duration
757 ///
758 /// When using `watch` patterns to auto-restart daemons on file changes,
759 /// this controls how long to wait after the last change before triggering
760 /// a restart.
761 ///
762 /// This prevents rapid restart cycles when many files change at once
763 /// (e.g., during a build or git checkout).
764 #[usage(env = "PITCHFORK_FILE_WATCH_DEBOUNCE", default = "1s", ty = "duration")]
765 pub file_watch_debounce: String,
766
767 /// Default time between health probes
768 ///
769 /// When a daemon has `health_cmd`, `health_http` or `health_port`
770 /// configured but does not set its own `interval`, the supervisor probes
771 /// it this often.
772 #[usage(
773 env = "PITCHFORK_HEALTH_CHECK_INTERVAL",
774 default = "10s",
775 ty = "duration"
776 )]
777 pub health_check_interval: String,
778
779 /// Default consecutive health-check failures before killing a daemon
780 ///
781 /// When a daemon's `health_cmd`, `health_http` or `health_port` fails this
782 /// many times in a row, the daemon is killed as a crash so the retry logic
783 /// restarts it. Individual daemons can override this with `retries` in
784 /// their health check configuration.
785 #[usage(env = "PITCHFORK_HEALTH_CHECK_RETRIES", default = 3)]
786 pub health_check_retries: i64,
787
788 /// Default per-probe timeout for `health_cmd`
789 ///
790 /// Maximum time to wait for a `health_cmd` shell command to finish before
791 /// counting the probe as failed and cancelling it.
792 #[usage(env = "PITCHFORK_HEALTH_CMD_TIMEOUT", default = "10s", ty = "duration")]
793 pub health_cmd_timeout: String,
794
795 /// Default per-request timeout for `health_http`
796 ///
797 /// Maximum time to wait for a response from a `health_http` endpoint before
798 /// counting the probe as failed.
799 #[usage(env = "PITCHFORK_HEALTH_HTTP_TIMEOUT", default = "5s", ty = "duration")]
800 pub health_http_timeout: String,
801
802 /// Default per-connect timeout for `health_port`
803 ///
804 /// Maximum time to wait for a TCP connection to a `health_port` to
805 /// establish before counting the probe as failed.
806 #[usage(env = "PITCHFORK_HEALTH_PORT_TIMEOUT", default = "5s", ty = "duration")]
807 pub health_port_timeout: String,
808
809 /// Timeout for HTTP ready checks
810 ///
811 /// Maximum time to wait for a response when checking `ready_http` endpoints.
812 ///
813 /// Increase if your services take a while to respond during startup.
814 #[usage(env = "PITCHFORK_HTTP_CLIENT_TIMEOUT", default = "5s", ty = "duration")]
815 pub http_client_timeout: String,
816
817 /// Daemon log buffer flush interval
818 ///
819 /// How often daemon log output is flushed to disk.
820 /// Lower values mean logs appear faster in the UI but may impact performance.
821 #[usage(
822 env = "PITCHFORK_LOG_FLUSH_INTERVAL",
823 default = "500ms",
824 ty = "duration"
825 )]
826 pub log_flush_interval: String,
827
828 /// What to do with live orphaned daemons on supervisor startup: adopt or kill
829 ///
830 /// When the supervisor starts and finds daemons in the state file whose
831 /// processes are still alive from a previous supervisor instance that died
832 /// uncleanly, this policy decides what happens (after the process identity
833 /// is verified via PID plus kernel start time):
834 ///
835 /// - `adopt` (default): keep the process running and resume supervision.
836 /// The daemon keeps its state (status, ports, proxy routing) and is
837 /// monitored by polling. Log capture is unaffected, because a daemon's
838 /// output is read by a sibling sink process rather than by the supervisor,
839 /// so it continues uninterrupted across the crash. Exit codes of adopted
840 /// daemons cannot be observed, though;
841 /// an adopted daemon that dies unexpectedly is marked `errored` with an
842 /// unknown exit code, which makes it eligible for its configured retries.
843 /// - `kill`: terminate the orphaned process group so the new supervisor
844 /// starts with a clean slate, matching pre-adoption behavior.
845 ///
846 /// Daemons whose recorded PID is dead, or whose PID now belongs to a
847 /// different process, have their state reset under either policy. If the
848 /// process identity cannot be verified, reconciliation fails closed and
849 /// retains the running state without adopting or killing.
850 ///
851 /// The same policy applies when the interval watcher finds a running daemon
852 /// that has lost its monitor at runtime.
853 ///
854 /// This setting has no effect when `cleanup_orphans` is disabled.
855 #[usage(env = "PITCHFORK_ORPHAN_POLICY", default = "adopt")]
856 pub orphan_policy: String,
857
858 /// Maximum port increment attempts when auto_bump_port is enabled
859 ///
860 /// When `auto_bump_port = true` is set on a daemon, pitchfork will try incrementing
861 /// all of the daemon's ports by the same offset to find a free range. This setting
862 /// controls how many offsets are tried before giving up with an error.
863 ///
864 /// For example, with `port = [3000]` and `port_bump_attempts = 10`, pitchfork will
865 /// try ports 3000, 3001, 3002, ... up to 3009 before reporting failure.
866 ///
867 /// This is a global default; individual daemons can override it with
868 /// `port_bump_attempts` in their daemon configuration.
869 #[usage(env = "PITCHFORK_PORT_BUMP_ATTEMPTS", default = 10)]
870 pub port_bump_attempts: i64,
871
872 /// Interval between ready checks (HTTP, TCP, command)
873 ///
874 /// How often to poll when checking if a daemon is ready using:
875 /// - `ready_http` - HTTP health endpoint
876 /// - `ready_port` - TCP port listening
877 /// - `ready_cmd` - Shell command exit code
878 ///
879 /// Lower values detect readiness faster but use more resources.
880 #[usage(
881 env = "PITCHFORK_READY_CHECK_INTERVAL",
882 default = "500ms",
883 ty = "duration"
884 )]
885 pub ready_check_interval: String,
886
887 /// Delay between stop and start during restart
888 ///
889 /// Brief pause after stopping a daemon before starting it again.
890 /// Helps ensure resources (like ports) are fully released.
891 #[usage(env = "PITCHFORK_RESTART_DELAY", default = "100ms", ty = "duration")]
892 pub restart_delay: String,
893
894 /// Maximum time to wait for a oneshot daemon to finish
895 ///
896 /// A `oneshot = true` daemon is ready when its process exits `0`, so there is no
897 /// readiness check to bound the wait. `pitchfork start` gives up after this long
898 /// and reports a timeout; the task itself keeps running and is still recorded as
899 /// `completed` if it later exits successfully. A nonzero exit remains a failure.
900 ///
901 /// Set to `0` for no limit. Raise it for long migrations, backfills, or seeds.
902 #[usage(env = "PITCHFORK_ONESHOT_TIMEOUT", default = "1h", ty = "duration")]
903 pub oneshot_timeout: String,
904
905 /// Maximum time to wait for daemon to stop gracefully
906 ///
907 /// When stopping a daemon, pitchfork sends its configured signal (SIGTERM by default) and waits this long
908 /// for the process to exit gracefully before sending SIGKILL.
909 ///
910 /// Increase for daemons that need time to clean up (e.g., flush data).
911 #[usage(env = "PITCHFORK_STOP_TIMEOUT", default = "5s", ty = "duration")]
912 pub stop_timeout: String,
913
914 /// Default user to run daemon processes as
915 ///
916 /// Default Unix user for daemon processes spawned by the supervisor.
917 ///
918 /// When set, all daemons run as this user unless an individual daemon sets
919 /// `user = "..."`. The value may be a username (for example `"postgres"`) or
920 /// a numeric UID (for example `"501"`).
921 ///
922 /// If unset and the supervisor is running as root via `sudo`, daemons default to
923 /// the sudo-calling user from `SUDO_UID`/`SUDO_GID` instead of running as root.
924 #[usage(env = "PITCHFORK_USER", default = "")]
925 pub user: String,
926
927 /// File watcher config refresh interval
928 ///
929 /// How often the supervisor refreshes file watch configuration when using `watch` patterns.
930 ///
931 /// This controls how quickly newly started/stopped daemons with watch patterns are reflected
932 /// in the active watcher set.
933 ///
934 /// For polling watcher cadence, use `supervisor.watch_poll_interval`.
935 ///
936 /// Lower values react faster to configuration/runtime changes but use more CPU.
937 /// The default `"10s"` is appropriate for most environments.
938 #[usage(
939 env = "PITCHFORK_WATCH_INTERVAL",
940 deprecated_env("PITCHFORK_WATCH_INTERVAL_MS"),
941 default = "10s",
942 ty = "duration"
943 )]
944 pub watch_interval: String,
945
946 /// Polling watcher filesystem scan interval
947 ///
948 /// How often polling-based file watchers scan for changes.
949 ///
950 /// This applies when daemon `watch_mode` is `poll`, or when `watch_mode = "auto"`
951 /// falls back to polling because native watchers are unavailable.
952 ///
953 /// Lower values detect changes faster but use more CPU and I/O.
954 /// `"100ms"` is useful for highly interactive workflows;
955 /// `"500ms"` is a practical default for remote/networked filesystems.
956 #[usage(
957 env = "PITCHFORK_WATCH_POLL_INTERVAL",
958 default = "500ms",
959 ty = "duration"
960 )]
961 pub watch_poll_interval: String,
962}
963
964/// The `tui.*` (terminal UI) settings.
965#[derive(usage_rs::Config, Debug, Clone, PartialEq)]
966#[usage(prefix = "tui")]
967pub struct SettingsTui {
968 /// Status message display duration
969 ///
970 /// How long status messages (like "Daemon started") remain visible
971 /// in the TUI before automatically clearing.
972 #[usage(
973 env = "PITCHFORK_TUI_MESSAGE_DURATION",
974 default = "3s",
975 ty = "duration"
976 )]
977 pub message_duration: String,
978
979 /// Daemon list refresh interval
980 ///
981 /// How often the TUI refreshes the daemon list and status information.
982 ///
983 /// Lower values provide more responsive updates but may increase CPU usage,
984 /// especially with many daemons.
985 ///
986 /// **Recommended values:**
987 /// - `"1s"` - More responsive
988 /// - `"2s"` - Default, balanced
989 /// - `"5s"` - Lower resource usage
990 #[usage(env = "PITCHFORK_TUI_REFRESH_RATE", default = "2s", ty = "duration")]
991 pub refresh_rate: String,
992
993 /// Number of stat samples to keep for graphs
994 ///
995 /// How many CPU/memory stat samples to keep for each daemon's graph.
996 /// With the default refresh rate of 2s, 60 samples = ~2 minutes of history.
997 ///
998 /// Increase for longer history in graphs, at the cost of more memory.
999 #[usage(env = "PITCHFORK_TUI_STAT_HISTORY", default = 60)]
1000 pub stat_history: i64,
1001
1002 /// Event loop tick rate
1003 ///
1004 /// How often the TUI checks for keyboard input and other events.
1005 /// This affects input responsiveness.
1006 ///
1007 /// Most users won't need to change this. Lower values make the UI more
1008 /// responsive but use more CPU.
1009 #[usage(env = "PITCHFORK_TUI_TICK_RATE", default = "100ms", ty = "duration")]
1010 pub tick_rate: String,
1011}
1012
1013/// The `web.*` (web UI) settings.
1014#[derive(usage_rs::Config, Debug, Clone, PartialEq)]
1015#[usage(prefix = "web")]
1016pub struct SettingsWeb {
1017 /// Automatically start web UI when supervisor starts
1018 ///
1019 /// When enabled, the web UI server will automatically start alongside the supervisor.
1020 ///
1021 /// By default, this is disabled. You can also start the web UI manually with:
1022 /// ```text
1023 /// pitchfork supervisor start --web-port=3120
1024 /// ```
1025 #[usage(env = "PITCHFORK_WEB_AUTO_START", default = false)]
1026 pub auto_start: bool,
1027
1028 /// URL path prefix for the web UI (e.g. "ps" serves at /ps/)
1029 ///
1030 /// Serves the web UI under a sub-path prefix, useful when running behind a reverse
1031 /// proxy that routes a path prefix to pitchfork.
1032 ///
1033 /// **Examples:**
1034 /// - `""` - Serve at root `/` (default)
1035 /// - `"ps"` - Serve at `/ps/`
1036 /// - `"tools/pitchfork"` - Serve at `/tools/pitchfork/`
1037 ///
1038 /// Equivalent to the `--web-path` CLI flag. The CLI flag takes priority over this setting.
1039 #[usage(env = "PITCHFORK_WEB_PATH", default = "")]
1040 pub base_path: String,
1041
1042 /// Web server bind address
1043 ///
1044 /// IP address for the web UI to listen on.
1045 ///
1046 /// **Security Warning:** The default `127.0.0.1` only allows local connections.
1047 /// Setting this to `0.0.0.0` will expose the web UI to your network.
1048 ///
1049 /// **Examples:**
1050 /// - `"127.0.0.1"` - Local only (default, recommended)
1051 /// - `"0.0.0.0"` - All interfaces (use with caution)
1052 /// - `"192.168.1.100"` - Specific interface
1053 #[usage(env = "PITCHFORK_WEB_BIND_ADDRESS", default = "127.0.0.1")]
1054 pub bind_address: String,
1055
1056 /// Default web server port
1057 ///
1058 /// The port number for the web UI. If this port is in use, pitchfork will
1059 /// try subsequent ports up to `port_attempts` times.
1060 #[usage(env = "PITCHFORK_WEB_BIND_PORT", default = 3120)]
1061 pub bind_port: i64,
1062
1063 /// Initial number of log lines to display
1064 ///
1065 /// How many lines of logs to show initially when viewing daemon logs in the web UI.
1066 /// More lines means slower initial load but more history visible.
1067 #[usage(env = "PITCHFORK_WEB_LOG_LINES", default = 100)]
1068 pub log_lines: i64,
1069
1070 /// Number of ports to try if default is in use
1071 ///
1072 /// If the default port is occupied, pitchfork will try this many consecutive
1073 /// ports before giving up.
1074 ///
1075 /// For example, with `bind_port = 3120` and `port_attempts = 10`,
1076 /// it will try ports 3120, 3121, 3122, ... up to 3129.
1077 #[usage(env = "PITCHFORK_WEB_PORT_ATTEMPTS", default = 10)]
1078 pub port_attempts: i64,
1079
1080 /// Server-Sent Events poll interval for log streaming
1081 ///
1082 /// How often the web UI checks for new log lines when streaming logs.
1083 /// Lower values provide more real-time updates but use more resources.
1084 #[usage(
1085 env = "PITCHFORK_WEB_SSE_POLL_INTERVAL",
1086 default = "500ms",
1087 ty = "duration"
1088 )]
1089 pub sse_poll_interval: String,
1090}
1091
1092/// Every setting pitchfork has.
1093///
1094/// `SETTINGS_PROPS`, `SETTINGS_REGISTRY`, `read`, and `spec_kdl` are generated
1095/// from these fields; the CLI's emitted spec carries the `config` block
1096/// through `#[usage(config = ...)]` on the root.
1097#[derive(usage_rs::Config, Debug, Clone, PartialEq)]
1098// `file(...)` is intentionally repeatable in usage's configuration DSL.
1099#[allow(clippy::duplicated_attributes)]
1100#[usage(
1101 file(path = "/etc/pitchfork/config.toml", scope = "system", format = "toml"),
1102 file(
1103 path = "~/.config/pitchfork/config.toml",
1104 scope = "global",
1105 format = "toml"
1106 ),
1107 file(
1108 path = ".config/pitchfork.toml",
1109 findup,
1110 scope = "project",
1111 format = "toml"
1112 ),
1113 file(
1114 path = ".config/pitchfork.local.toml",
1115 findup,
1116 scope = "project",
1117 format = "toml"
1118 ),
1119 file(path = "pitchfork.toml", findup, scope = "project", format = "toml"),
1120 file(
1121 path = "pitchfork.local.toml",
1122 findup,
1123 scope = "project",
1124 format = "toml"
1125 )
1126)]
1127pub struct Settings {
1128 #[usage(flatten)]
1129 pub api: SettingsApi,
1130 #[usage(flatten)]
1131 pub general: SettingsGeneral,
1132 #[usage(flatten)]
1133 pub ipc: SettingsIpc,
1134 #[usage(flatten)]
1135 pub logs: SettingsLogs,
1136 #[usage(flatten)]
1137 pub proxy: SettingsProxy,
1138 #[usage(flatten)]
1139 pub supervisor: SettingsSupervisor,
1140 #[usage(flatten)]
1141 pub tui: SettingsTui,
1142 #[usage(flatten)]
1143 pub web: SettingsWeb,
1144}
1145
1146impl Default for Settings {
1147 /// The declared defaults, read the same way any other resolution is.
1148 fn default() -> Self {
1149 let resolved = resolve(Settings::SETTINGS_REGISTRY, Layers::new())
1150 .expect("no layers were given, so there is nothing to fail");
1151 Settings::read(&resolved).expect("every pitchfork setting has a declared default")
1152 }
1153}
1154
1155/// Generate the `Duration` convenience getters the rest of the codebase uses
1156/// (e.g. `settings().general_autostop_delay()`), each parsing its humantime
1157/// string field and silently falling back to the schema default. Invalid
1158/// values were already warned about once at load time.
1159macro_rules! duration_getters {
1160 ($($name:ident => $group:ident . $field:ident, $default:literal;)+) => {
1161 impl Settings {
1162 $(
1163 #[doc = concat!("Get `", stringify!($group), ".", stringify!($field), "` as Duration")]
1164 #[allow(dead_code)]
1165 pub fn $name(&self) -> std::time::Duration {
1166 Self::parse_duration(&self.$group.$field).unwrap_or_else(|| {
1167 humantime::parse_duration($default)
1168 .unwrap_or(std::time::Duration::from_secs(1))
1169 })
1170 }
1171 )+
1172 }
1173 };
1174}
1175
1176duration_getters! {
1177 general_autostop_delay => general.autostop_delay, "1m";
1178 general_interval => general.interval, "10s";
1179 general_ready_delay => general.ready_delay, "3s";
1180 ipc_connect_max_delay => ipc.connect_max_delay, "1s";
1181 ipc_connect_min_delay => ipc.connect_min_delay, "100ms";
1182 ipc_rate_limit_window => ipc.rate_limit_window, "1s";
1183 ipc_request_timeout => ipc.request_timeout, "5s";
1184 logs_time_retention => logs.time_retention, "";
1185 proxy_auto_start_timeout => proxy.auto_start_timeout, "30s";
1186 supervisor_cron_check_interval => supervisor.cron_check_interval, "10s";
1187 supervisor_file_watch_debounce => supervisor.file_watch_debounce, "1s";
1188 supervisor_health_check_interval => supervisor.health_check_interval, "10s";
1189 supervisor_health_cmd_timeout => supervisor.health_cmd_timeout, "10s";
1190 supervisor_health_http_timeout => supervisor.health_http_timeout, "5s";
1191 supervisor_health_port_timeout => supervisor.health_port_timeout, "5s";
1192 supervisor_http_client_timeout => supervisor.http_client_timeout, "5s";
1193 supervisor_log_flush_interval => supervisor.log_flush_interval, "500ms";
1194 supervisor_oneshot_timeout => supervisor.oneshot_timeout, "1h";
1195 supervisor_ready_check_interval => supervisor.ready_check_interval, "500ms";
1196 supervisor_restart_delay => supervisor.restart_delay, "100ms";
1197 supervisor_stop_timeout => supervisor.stop_timeout, "5s";
1198 supervisor_watch_interval => supervisor.watch_interval, "10s";
1199 supervisor_watch_poll_interval => supervisor.watch_poll_interval, "500ms";
1200 tui_message_duration => tui.message_duration, "3s";
1201 tui_refresh_rate => tui.refresh_rate, "2s";
1202 tui_tick_rate => tui.tick_rate, "100ms";
1203 web_sse_poll_interval => web.sse_poll_interval, "500ms";
1204}
1205
1206impl Settings {
1207 /// How long to wait for a `oneshot` daemon to finish.
1208 ///
1209 /// `supervisor.oneshot_timeout = "0"` means no limit: a task's natural end
1210 /// is its exit, and a long migration should not be called failed merely for
1211 /// outlasting a default. That is an absent deadline, not a very large one,
1212 /// so no task can outlive the substitute and be reported as timed out
1213 /// despite the setting promising otherwise.
1214 pub fn supervisor_oneshot_wait(&self) -> crate::config_types::OneshotWait {
1215 let configured = self.supervisor_oneshot_timeout();
1216 if configured.is_zero() {
1217 crate::config_types::OneshotWait::Unlimited
1218 } else {
1219 crate::config_types::OneshotWait::For(configured)
1220 }
1221 }
1222
1223 /// Resolve `general.ready_delay` as whole seconds.
1224 ///
1225 /// The ready-check pipeline (RunOptions/IPC/supervisor) models the delay
1226 /// in whole seconds. Reject subsecond values that would otherwise be
1227 /// silently truncated to a zero delay by `as_secs()`.
1228 pub fn general_ready_delay_secs(&self) -> Result<u64, String> {
1229 let delay = self.general_ready_delay();
1230 if delay.subsec_nanos() != 0 {
1231 return Err(format!(
1232 "settings.general.ready_delay must be a whole number of seconds, got \"{}\"",
1233 self.general.ready_delay
1234 ));
1235 }
1236 Ok(delay.as_secs())
1237 }
1238}
1239
1240impl Settings {
1241 /// Load settings from pitchfork.toml files, then overlay environment variables.
1242 /// Settings are loaded from all pitchfork.toml files in precedence order:
1243 /// 1. System-level: /etc/pitchfork/config.toml
1244 /// 2. User-level: ~/.config/pitchfork/config.toml
1245 /// 3. Project-level: pitchfork.toml files from root to current directory
1246 ///
1247 /// Environment variables override all file-based settings.
1248 ///
1249 /// The global [`settings()`] accessor resolves through
1250 /// [`Settings::resolve_from_dir`] so it can keep the provenance; this
1251 /// stays as the public one-shot entry point.
1252 #[allow(dead_code)]
1253 pub fn load() -> Self {
1254 Self::load_from_dir(&crate::env::CWD)
1255 }
1256
1257 /// Load settings for a specific project directory, then overlay environment variables.
1258 ///
1259 /// This is used when operating on daemons from registered namespaces whose project
1260 /// settings may differ from those of the invoking process's current directory.
1261 pub fn load_from_dir(start_dir: &Path) -> Self {
1262 Self::resolve_from_dir(start_dir).0
1263 }
1264
1265 /// Resolve settings for `start_dir`, returning both the typed struct and
1266 /// the origin-tracked resolution it was read from (for `pitchfork settings`).
1267 pub(crate) fn resolve_from_dir(start_dir: &Path) -> (Self, Resolved) {
1268 let env_layer = EnvLayer::from_process();
1269 // Highest precedence first, below the environment.
1270 let file_layers = Self::file_layers_from(start_dir);
1271 let mut layers = Layers::new().then(&env_layer);
1272 for layer in &file_layers {
1273 layers = layers.then(layer);
1274 }
1275 let mut resolved = match resolve(Settings::SETTINGS_REGISTRY, layers) {
1276 Ok(resolved) => resolved,
1277 Err(e) => {
1278 // Should not happen: every file was pre-validated above. Fall
1279 // back to env + defaults rather than failing to start.
1280 eprintln!("pitchfork: warning: failed to resolve settings from config files: {e}");
1281 resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&env_layer)).unwrap_or_else(
1282 |_| {
1283 resolve(Settings::SETTINGS_REGISTRY, Layers::new())
1284 .expect("resolving only defaults cannot fail")
1285 },
1286 )
1287 }
1288 };
1289 sanitize_durations(&mut resolved);
1290 for warning in usage_rs::config::explain::warnings(&resolved) {
1291 warn!("{warning}");
1292 }
1293 // Lossy, because the alternative is worse than the problem: `read` is all or nothing,
1294 // so one field the merge could not hand to its type used to cost every *other* setting
1295 // too — `Settings::default()` throws away the environment and every config file over a
1296 // single bad value. Here the offending field falls back to its own declared default and
1297 // nothing else is touched. Warn and keep going is pitchfork's call to make: a
1298 // supervisor that refuses to start because one duration is misspelled is a supervisor
1299 // that took the whole machine's daemons down with it.
1300 let (settings, errors) = Settings::read_lossy(&resolved);
1301 for error in &errors.0 {
1302 warn!("{error}");
1303 }
1304 // Every setting declares a default, so the only way this is `None` is a setting added
1305 // without one — a hole in the declaration rather than anything a user did.
1306 let settings = settings.unwrap_or_else(|| {
1307 warn!("a setting has no value and no default; falling back to built-in defaults");
1308 Settings::default()
1309 });
1310 (settings, resolved)
1311 }
1312
1313 /// The config file layers for `start_dir`, highest precedence first.
1314 ///
1315 /// Project files are found by walking up from `start_dir`; within one
1316 /// directory `pitchfork.local.toml` outranks `pitchfork.toml`, which
1317 /// outranks the same pair under `.config/`, and any file in a nearer
1318 /// directory outranks every file in a farther one. Below the project
1319 /// files come the user-level and system-level configs.
1320 ///
1321 /// Files that exist but cannot be parsed are warned about and skipped,
1322 /// matching the previous loader: a broken file must not stop pitchfork
1323 /// from starting.
1324 fn file_layers_from(start_dir: &Path) -> Vec<FileLayer> {
1325 let mut candidates: Vec<(PathBuf, FileScope)> = xx::file::find_up_all(
1326 start_dir,
1327 &[
1328 "pitchfork.local.toml",
1329 "pitchfork.toml",
1330 ".config/pitchfork.local.toml",
1331 ".config/pitchfork.toml",
1332 ],
1333 )
1334 .into_iter()
1335 .map(|path| (path, FileScope::Project))
1336 .collect();
1337 let extras = crate::extra_configs::paths_for(start_dir)
1338 .into_iter()
1339 .rev()
1340 .map(|p| (p, FileScope::Project));
1341 candidates.splice(0..0, extras);
1342 candidates.push((
1343 crate::env::PITCHFORK_GLOBAL_CONFIG_USER.clone(),
1344 FileScope::Global,
1345 ));
1346 candidates.push((
1347 crate::env::PITCHFORK_GLOBAL_CONFIG_SYSTEM.clone(),
1348 FileScope::System,
1349 ));
1350 candidates
1351 .into_iter()
1352 .filter(|(path, _)| readable_settings_file(path))
1353 .map(|(path, scope)| FileLayer::at(path, scope).under("settings"))
1354 .collect()
1355 }
1356
1357 /// Parse a duration string (humantime format) to Duration
1358 pub fn parse_duration(s: &str) -> Option<std::time::Duration> {
1359 humantime::parse_duration(s).ok()
1360 }
1361
1362 /// Resolve the mise binary path.
1363 ///
1364 /// If `general.mise_bin` is explicitly set, returns that path.
1365 /// Otherwise, searches well-known install locations:
1366 /// - `~/.local/bin/mise`
1367 /// - `~/.cargo/bin/mise`
1368 /// - `/usr/local/bin/mise`
1369 /// - `/opt/homebrew/bin/mise`
1370 ///
1371 /// Returns `None` if mise cannot be found.
1372 pub fn resolve_mise_bin(&self) -> Option<std::path::PathBuf> {
1373 // Explicit configuration takes priority
1374 if !self.general.mise_bin.is_empty() {
1375 let p = PathBuf::from(&self.general.mise_bin);
1376 if p.is_file() {
1377 return Some(p);
1378 }
1379 warn!(
1380 "mise_bin is set to {:?} but the file does not exist",
1381 self.general.mise_bin
1382 );
1383 return None;
1384 }
1385
1386 // Search well-known install paths
1387 let home = crate::env::HOME_DIR.as_path();
1388 let candidates = [
1389 home.join(".local/bin/mise"),
1390 home.join(".cargo/bin/mise"),
1391 PathBuf::from("/usr/local/bin/mise"),
1392 PathBuf::from("/opt/homebrew/bin/mise"),
1393 ];
1394
1395 candidates.into_iter().find(|p| p.is_file())
1396 }
1397
1398 /// Resolve the shell used for daemon `run` scripts, `ready_cmd` /
1399 /// `health_cmd` probes, lifecycle hooks and the log archive hook, split
1400 /// into a program and its arguments.
1401 ///
1402 /// `general.shell` applies everywhere. On Windows it applies only when it
1403 /// was set explicitly (`shell_is_explicit`); a Windows machine that leaves
1404 /// it at its default uses `general.windows_shell` instead, because the
1405 /// `sh` that `general.shell` defaults to is not on a stock Windows `PATH`.
1406 /// [`crate::settings::resolve_shell`] supplies that flag from the origin
1407 /// of the merged value; it is a parameter here so the precedence can be
1408 /// tested without a resolution.
1409 ///
1410 /// `Err` names the key whose value is empty or could not be split.
1411 pub fn resolve_shell(&self, shell_is_explicit: bool) -> Result<Vec<String>, String> {
1412 let (key, configured) = if cfg!(windows) && !shell_is_explicit {
1413 ("general.windows_shell", &self.general.windows_shell)
1414 } else {
1415 ("general.shell", &self.general.shell)
1416 };
1417 match shell_words::split(configured) {
1418 Ok(parts) if !parts.is_empty() => Ok(parts),
1419 Ok(_) => Err(format!("{key} setting is empty")),
1420 Err(e) => Err(format!("failed to parse {key} setting {configured:?}: {e}")),
1421 }
1422 }
1423
1424 /// Return `supervisor.port_bump_attempts` as `u32`, clamping out-of-range
1425 /// values to the schema default (10) and zero to 1.
1426 ///
1427 /// This is the single source of truth for the fallback so that call-sites
1428 /// don't each duplicate the hardcoded `10`.
1429 pub fn default_port_bump_attempts(&self) -> u32 {
1430 let v = u32::try_from(self.supervisor.port_bump_attempts).unwrap_or_else(|_| {
1431 warn!(
1432 "supervisor.port_bump_attempts value {} is out of range (0-{}), clamping to 10",
1433 self.supervisor.port_bump_attempts,
1434 u32::MAX
1435 );
1436 10
1437 });
1438 if v == 0 {
1439 warn!("supervisor.port_bump_attempts is 0; defaulting to 1");
1440 1
1441 } else {
1442 v
1443 }
1444 }
1445}
1446
1447/// Whether `path` holds a settings file this loader can hand to a `FileLayer`.
1448///
1449/// Mirrors the previous loader's per-file error handling: a missing file is
1450/// the ordinary case, and a file that exists but cannot be read or parsed —
1451/// or whose `settings` key is not a table — is warned about and skipped
1452/// rather than turned into a startup failure.
1453fn readable_settings_file(path: &Path) -> bool {
1454 if !path.exists() {
1455 return false;
1456 }
1457 let content = match std::fs::read_to_string(path) {
1458 Ok(content) => content,
1459 Err(e) => {
1460 eprintln!(
1461 "pitchfork: warning: failed to read {}: {}",
1462 path.display(),
1463 e
1464 );
1465 return false;
1466 }
1467 };
1468 let table: toml::Table = match content.parse() {
1469 Ok(table) => table,
1470 Err(e) => {
1471 eprintln!(
1472 "pitchfork: warning: failed to parse {}: {}",
1473 path.display(),
1474 e
1475 );
1476 return false;
1477 }
1478 };
1479 match table.get("settings") {
1480 None => true,
1481 Some(toml::Value::Table(_)) => true,
1482 Some(_) => {
1483 eprintln!(
1484 "pitchfork: warning: invalid [settings] in {}: not a table",
1485 path.display()
1486 );
1487 false
1488 }
1489 }
1490}
1491
1492/// Validate every duration-typed setting in a resolution.
1493///
1494/// The registry's `duration` type stores the humantime text as-is, so an
1495/// invalid value would otherwise ride into the struct. This warns once and
1496/// rewrites the value to the schema default through [`Resolved::coerced`],
1497/// so `pitchfork settings get/list` and the typed getters all agree —
1498/// matching the previous loader, which rejected invalid durations at load
1499/// time. A value equal to the declared default is left alone (the
1500/// `logs.time_retention` default is the empty string, meaning "disabled").
1501/// Duration settings where `0s` is never meaningful: the value is passed
1502/// straight into `tokio::time::timeout` for a health probe, so a zero
1503/// timeout fails every probe immediately and would crash-kill a healthy
1504/// daemon after the retry threshold. Same defect family as the per-daemon
1505/// health timeout rejection in `config_types.rs` (`parse_positive_timeout`).
1506const NON_ZERO_DURATION_KEYS: &[&str] = &[
1507 "supervisor.health_cmd_timeout",
1508 "supervisor.health_http_timeout",
1509 "supervisor.health_port_timeout",
1510];
1511
1512fn sanitize_durations(resolved: &mut Resolved) {
1513 let registry = Settings::SETTINGS_REGISTRY;
1514 for id in registry.ids() {
1515 let meta = registry.get(id);
1516 if !matches!(meta.ty.inner(), Ty::Duration) {
1517 continue;
1518 }
1519 let Some(Value::String(text)) = resolved.get(id) else {
1520 continue;
1521 };
1522 let default_text = match meta.default {
1523 Some(Const::Str(s)) => s,
1524 _ => "",
1525 };
1526 if text == default_text {
1527 continue;
1528 }
1529 let reason = match humantime::parse_duration(text) {
1530 Err(_) => Some(format!("invalid duration {text:?}")),
1531 Ok(d) if d.is_zero() && NON_ZERO_DURATION_KEYS.contains(&meta.key) => Some(format!(
1532 "supervisor health probe timeout must be greater than 0, got {text:?}"
1533 )),
1534 Ok(_) => None,
1535 };
1536 let Some(reason) = reason else {
1537 continue;
1538 };
1539 let origin = resolved
1540 .origin(id)
1541 .map(|o| o.describe().to_string())
1542 .unwrap_or_default();
1543 warn!("{reason} (set by {origin}), using default");
1544 resolved.coerced(
1545 id,
1546 Value::String(default_text.to_string()),
1547 format!("{reason}; the default stands"),
1548 );
1549 }
1550}
1551
1552/// Global settings instance, kept beside the resolution it was read from
1553/// (RwLock<Arc> to support runtime reload).
1554type SettingsState = (Arc<Settings>, Arc<Resolved>);
1555static SETTINGS: std::sync::RwLock<Option<SettingsState>> = std::sync::RwLock::new(None);
1556
1557fn settings_state() -> SettingsState {
1558 // Fast path: if already initialized, clone the Arc pointers.
1559 {
1560 let lock = SETTINGS.read().unwrap();
1561 if let Some(state) = lock.as_ref() {
1562 return state.clone();
1563 }
1564 }
1565 // Slow path: initialize on first access
1566 let mut lock = SETTINGS.write().unwrap();
1567 if let Some(state) = lock.as_ref() {
1568 return state.clone();
1569 }
1570 let (settings, resolved) = Settings::resolve_from_dir(&crate::env::CWD);
1571 let state = (Arc::new(settings), Arc::new(resolved));
1572 *lock = Some(state.clone());
1573 state
1574}
1575
1576/// Get the global settings instance
1577pub fn settings() -> Arc<Settings> {
1578 settings_state().0
1579}
1580
1581/// The origin-tracked resolution behind [`settings()`], for the settings CLI.
1582pub(crate) fn settings_resolved() -> Arc<Resolved> {
1583 settings_state().1
1584}
1585
1586/// Whether `general.shell` was set by the user rather than left at its default.
1587///
1588/// Asked of the merge, not of the text, the same way `pitchfork settings`
1589/// decides what to mark as default: `shell = "sh -c"` written in a config file
1590/// is the same string as the default, and only its origin tells them apart.
1591fn shell_is_explicit(resolved: &Resolved) -> bool {
1592 resolved
1593 .origin_key("general.shell")
1594 .is_some_and(|origin| origin.kind != SourceKind::DEFAULTS)
1595}
1596
1597/// Resolve the shell for daemon `run` scripts, command probes and hooks from
1598/// the global settings. See [`Settings::resolve_shell`].
1599pub fn resolve_shell() -> Result<Vec<String>, String> {
1600 let (settings, resolved) = settings_state();
1601 settings.resolve_shell(shell_is_explicit(&resolved))
1602}
1603
1604/// Reload settings from config files.
1605///
1606/// Called when the supervisor receives a ReloadConfig IPC request,
1607/// typically after `pitchfork settings set` modifies a config file.
1608///
1609/// Old Arc references remain valid until all holders drop them,
1610/// so no use-after-free can occur.
1611pub fn reload_settings() {
1612 let (settings, resolved) = Settings::resolve_from_dir(&crate::env::CWD);
1613 let mut lock = SETTINGS.write().unwrap();
1614 *lock = Some((Arc::new(settings), Arc::new(resolved)));
1615}
1616
1617// ============================================================================
1618// Partial (all-Option) mirror structs
1619//
1620// These exist for `pitchfork.toml` round-trips: `PitchforkToml` deserializes
1621// its `[settings]` table into `SettingsPartial`, merges per-file overrides,
1622// and serializes it back out (`pitchfork settings set`, `proxy add`, ...).
1623// Settings *resolution* no longer goes through these — usage-config's
1624// origin-tracked merge replaced the apply/merge chain.
1625// ============================================================================
1626
1627macro_rules! settings_partial {
1628 (
1629 $(#[$meta:meta])*
1630 $name:ident {
1631 $(@group $group_field:ident: $group_ty:ty,)*
1632 $($(#[$fdoc:meta])* $field:ident: $ty:ty,)*
1633 }
1634 ) => {
1635 $(#[$meta])*
1636 #[derive(
1637 Debug, Clone, Default, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
1638 )]
1639 #[serde(default)]
1640 pub struct $name {
1641 $(
1642 #[serde(default, skip_serializing_if = "is_empty_partial")]
1643 pub $group_field: $group_ty,
1644 )*
1645 $(
1646 $(#[$fdoc])*
1647 #[serde(skip_serializing_if = "Option::is_none", default)]
1648 pub $field: Option<$ty>,
1649 )*
1650 }
1651
1652 #[allow(dead_code)]
1653 impl $name {
1654 /// Whether any field (or nested group field) is set.
1655 pub fn has_any_set(&self) -> bool {
1656 false
1657 $(|| self.$group_field.has_any_set())*
1658 $(|| self.$field.is_some())*
1659 }
1660
1661 pub fn is_empty(&self) -> bool {
1662 !self.has_any_set()
1663 }
1664
1665 /// Merge another partial onto this one.
1666 /// All `Some` values in `other` override the corresponding values in `self`.
1667 pub fn merge_from(&mut self, other: &Self) {
1668 $(self.$group_field.merge_from(&other.$group_field);)*
1669 $(
1670 if other.$field.is_some() {
1671 self.$field = other.$field.clone();
1672 }
1673 )*
1674 }
1675 }
1676 };
1677}
1678
1679/// serde `skip_serializing_if` helper for nested partial groups.
1680fn is_empty_partial<T: HasAnySet>(v: &T) -> bool {
1681 !v.has_any_set()
1682}
1683
1684/// Internal trait so one serde skip helper serves every partial group.
1685trait HasAnySet {
1686 fn has_any_set(&self) -> bool;
1687}
1688
1689macro_rules! impl_has_any_set {
1690 ($($name:ident),+ $(,)?) => {
1691 $(impl HasAnySet for $name {
1692 fn has_any_set(&self) -> bool {
1693 $name::has_any_set(self)
1694 }
1695 })+
1696 };
1697}
1698
1699settings_partial! {
1700 /// Partial mirror of [`SettingsApi`].
1701 SettingsApiPartial {
1702 /// Automatically start the standalone API server
1703 auto_start: bool,
1704 /// IP address the API server binds to
1705 bind_address: String,
1706 /// Port the standalone API server listens on
1707 bind_port: i64,
1708 /// Number of consecutive ports to try if api.bind_port is in use
1709 port_attempts: i64,
1710 /// Authentication token for API access when bound to non-loopback addresses
1711 token: String,
1712 }
1713}
1714
1715settings_partial! {
1716 /// Partial mirror of [`SettingsGeneral`].
1717 SettingsGeneralPartial {
1718 /// Delay before auto-stopping daemons when leaving a directory
1719 autostop_delay: String,
1720 /// Supervisor background task refresh interval
1721 interval: String,
1722 /// File log level (trace, debug, info, warn, error)
1723 log_file_level: String,
1724 /// Console log level (trace, debug, info, warn, error)
1725 log_level: String,
1726 /// Wrap daemon commands with mise x -- globally
1727 mise: bool,
1728 /// Explicit path to the mise binary
1729 mise_bin: String,
1730 /// Shell command used to execute daemon run scripts
1731 shell: String,
1732 /// Shell command used to execute daemon run scripts on Windows
1733 windows_shell: String,
1734 /// Show timestamps in startup log output
1735 startup_log_timestamps: bool,
1736 /// Default readiness delay in seconds when a daemon has no ready check configured
1737 ready_delay: String,
1738 /// Enable git worktree / jj workspace auto-discovery
1739 worktree: bool,
1740 }
1741}
1742
1743settings_partial! {
1744 /// Partial mirror of [`SettingsIpc`].
1745 SettingsIpcPartial {
1746 /// Number of connection retry attempts
1747 connect_attempts: i64,
1748 /// Maximum delay between connection retries
1749 connect_max_delay: String,
1750 /// Minimum delay between connection retries
1751 connect_min_delay: String,
1752 /// Maximum IPC requests per second per connection
1753 rate_limit: i64,
1754 /// Rate limit sliding window duration
1755 rate_limit_window: String,
1756 /// Default timeout for IPC requests
1757 request_timeout: String,
1758 }
1759}
1760
1761settings_partial! {
1762 /// Partial mirror of [`SettingsLogsArchiveHook`].
1763 SettingsLogsArchiveHookPartial {
1764 /// Maximum log entries per archive hook invocation
1765 batch_size: i64,
1766 /// Command to run before retention deletes old log entries
1767 command: String,
1768 }
1769}
1770
1771settings_partial! {
1772 /// Partial mirror of [`SettingsLogs`].
1773 SettingsLogsPartial {
1774 @group archive_hook: SettingsLogsArchiveHookPartial,
1775 /// Count-based log retention (e.g. 10000)
1776 line_retention: i64,
1777 /// Default log format for daemons (json | logfmt | text)
1778 log_format: String,
1779 /// Time-based log retention duration (e.g. '7d', '30d')
1780 time_retention: String,
1781 /// Show timestamps in log output
1782 timestamp: bool,
1783 /// strftime format for log timestamps in `pitchfork logs` output
1784 timestamp_format: String,
1785 }
1786}
1787
1788settings_partial! {
1789 /// Partial mirror of [`SettingsProxy`].
1790 SettingsProxyPartial {
1791 /// Automatically start daemons when accessed via proxy URL
1792 auto_start: bool,
1793 /// Maximum time to wait for an auto-started daemon to become ready
1794 auto_start_timeout: String,
1795 /// Automatically install the proxy TLS certificate into the system trust store
1796 auto_trust: bool,
1797 /// Enable the reverse proxy server for daemons
1798 enable: bool,
1799 /// Bind address for the reverse proxy server
1800 host: String,
1801 /// Enable HTTPS for the reverse proxy
1802 https: bool,
1803 /// Enable LAN mode for the reverse proxy
1804 lan: bool,
1805 /// Pin a specific LAN IP address instead of auto-detecting
1806 lan_ip: String,
1807 /// Port the reverse proxy server listens on
1808 port: i64,
1809 /// Automatically sync slug hostnames to /etc/hosts
1810 sync_hosts: bool,
1811 /// Top-level domain used for proxy URLs
1812 tld: String,
1813 /// Path to TLS certificate file (PEM format) for HTTPS proxy
1814 tls_cert: String,
1815 /// Path to TLS private key file (PEM format) for HTTPS proxy
1816 tls_key: String,
1817 /// Enable wildcard subdomain matching for proxy routes
1818 wildcard: bool,
1819 /// Compatibility alias for general.worktree
1820 worktree: bool,
1821 }
1822}
1823
1824settings_partial! {
1825 /// Partial mirror of [`SettingsSupervisor`].
1826 SettingsSupervisorPartial {
1827 /// Automatically start the supervisor when a client command needs it
1828 auto_start: bool,
1829 /// Reconcile orphaned daemon processes when supervisor starts
1830 cleanup_orphans: bool,
1831 /// Enable container/PID1 mode for running inside Docker containers
1832 container: bool,
1833 /// Consecutive CPU-over-limit samples before killing a daemon
1834 cpu_violation_threshold: i64,
1835 /// Interval for checking cron schedules
1836 cron_check_interval: String,
1837 /// File watch debounce duration
1838 file_watch_debounce: String,
1839 /// Default time between health probes
1840 health_check_interval: String,
1841 /// Default consecutive health-check failures before killing a daemon
1842 health_check_retries: i64,
1843 /// Default per-probe timeout for health_cmd
1844 health_cmd_timeout: String,
1845 /// Default per-request timeout for health_http
1846 health_http_timeout: String,
1847 /// Default per-connect timeout for health_port
1848 health_port_timeout: String,
1849 /// Timeout for HTTP ready checks
1850 http_client_timeout: String,
1851 /// Daemon log buffer flush interval
1852 log_flush_interval: String,
1853 /// What to do with live orphaned daemons on supervisor startup: adopt or kill
1854 orphan_policy: String,
1855 /// Maximum port increment attempts when auto_bump_port is enabled
1856 port_bump_attempts: i64,
1857 /// Maximum time to wait for a oneshot daemon to finish
1858 oneshot_timeout: String,
1859 /// Interval between ready checks (HTTP, TCP, command)
1860 ready_check_interval: String,
1861 /// Delay between stop and start during restart
1862 restart_delay: String,
1863 /// Maximum time to wait for daemon to stop gracefully
1864 stop_timeout: String,
1865 /// Default user to run daemon processes as
1866 user: String,
1867 /// File watcher config refresh interval
1868 watch_interval: String,
1869 /// Polling watcher filesystem scan interval
1870 watch_poll_interval: String,
1871 }
1872}
1873
1874settings_partial! {
1875 /// Partial mirror of [`SettingsTui`].
1876 SettingsTuiPartial {
1877 /// Status message display duration
1878 message_duration: String,
1879 /// Daemon list refresh interval
1880 refresh_rate: String,
1881 /// Number of stat samples to keep for graphs
1882 stat_history: i64,
1883 /// Event loop tick rate
1884 tick_rate: String,
1885 }
1886}
1887
1888settings_partial! {
1889 /// Partial mirror of [`SettingsWeb`].
1890 SettingsWebPartial {
1891 /// Automatically start web UI when supervisor starts
1892 auto_start: bool,
1893 /// URL path prefix for the web UI (e.g. "ps" serves at /ps/)
1894 base_path: String,
1895 /// Web server bind address
1896 bind_address: String,
1897 /// Default web server port
1898 bind_port: i64,
1899 /// Initial number of log lines to display
1900 log_lines: i64,
1901 /// Number of ports to try if default is in use
1902 port_attempts: i64,
1903 /// Server-Sent Events poll interval for log streaming
1904 sse_poll_interval: String,
1905 }
1906}
1907
1908settings_partial! {
1909 /// Partial mirror of [`Settings`], for `[settings]` tables in
1910 /// pitchfork.toml files.
1911 SettingsPartial {
1912 @group api: SettingsApiPartial,
1913 @group general: SettingsGeneralPartial,
1914 @group ipc: SettingsIpcPartial,
1915 @group logs: SettingsLogsPartial,
1916 @group proxy: SettingsProxyPartial,
1917 @group supervisor: SettingsSupervisorPartial,
1918 @group tui: SettingsTuiPartial,
1919 @group web: SettingsWebPartial,
1920 }
1921}
1922
1923impl_has_any_set!(
1924 SettingsApiPartial,
1925 SettingsGeneralPartial,
1926 SettingsIpcPartial,
1927 SettingsLogsArchiveHookPartial,
1928 SettingsLogsPartial,
1929 SettingsProxyPartial,
1930 SettingsSupervisorPartial,
1931 SettingsTuiPartial,
1932 SettingsWebPartial,
1933);
1934
1935impl SettingsPartial {
1936 /// Move supported legacy keys to their canonical locations before a
1937 /// read-modify-write cycle serializes this partial again.
1938 pub(crate) fn canonicalize_aliases(&mut self) {
1939 if self.general.worktree.is_none() {
1940 self.general.worktree = self.proxy.worktree.take();
1941 } else {
1942 self.proxy.worktree = None;
1943 }
1944 }
1945}
1946
1947#[cfg(test)]
1948mod tests {
1949 use super::*;
1950 use std::time::Duration;
1951
1952 #[test]
1953 fn shell_defaults() {
1954 let settings = Settings::default();
1955 assert_eq!(settings.general.shell, "sh -c");
1956 assert_eq!(settings.general.windows_shell, "cmd /C");
1957 }
1958
1959 #[test]
1960 fn shell_precedence() {
1961 let mut settings = Settings::default();
1962 settings.general.shell = "unix-sh -c".to_string();
1963 settings.general.windows_shell = "win-sh /C".to_string();
1964 let unix = vec!["unix-sh".to_string(), "-c".to_string()];
1965 let windows = vec!["win-sh".to_string(), "/C".to_string()];
1966
1967 // An explicit general.shell wins on every platform.
1968 assert_eq!(settings.resolve_shell(true).unwrap(), unix);
1969
1970 // Left at its default, general.shell yields to windows_shell on
1971 // Windows and is the only setting consulted elsewhere.
1972 let expected = if cfg!(windows) { windows } else { unix };
1973 assert_eq!(settings.resolve_shell(false).unwrap(), expected);
1974 }
1975
1976 #[test]
1977 fn shell_is_explicit_comes_from_the_origin_not_the_text() {
1978 // `PITCHFORK_SHELL="sh -c"` is the same string as the default, and
1979 // must still count as set: on Windows it decides whether
1980 // windows_shell is consulted at all.
1981 let untouched =
1982 resolve(Settings::SETTINGS_REGISTRY, Layers::new()).expect("resolving defaults");
1983 assert!(!shell_is_explicit(&untouched));
1984
1985 let layer = EnvLayer::new([("PITCHFORK_SHELL".to_string(), "sh -c".to_string())]);
1986 let explicit = resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&layer))
1987 .expect("resolving an override");
1988 assert!(shell_is_explicit(&explicit));
1989 }
1990
1991 #[test]
1992 fn an_unusable_shell_names_the_key_it_came_from() {
1993 let mut settings = Settings::default();
1994 settings.general.shell = "sh -c 'unbalanced".to_string();
1995 let err = settings.resolve_shell(true).unwrap_err();
1996 assert!(err.contains("general.shell"), "{err}");
1997
1998 settings.general.shell = String::new();
1999 let err = settings.resolve_shell(true).unwrap_err();
2000 assert!(err.contains("general.shell setting is empty"), "{err}");
2001
2002 settings.general.windows_shell = String::new();
2003 let key = if cfg!(windows) {
2004 "general.windows_shell"
2005 } else {
2006 "general.shell"
2007 };
2008 let err = settings.resolve_shell(false).unwrap_err();
2009 assert!(err.contains(key), "{err}");
2010 }
2011
2012 #[test]
2013 fn test_default_settings() {
2014 let settings = Settings::default();
2015
2016 // Test general settings
2017 assert_eq!(settings.general.autostop_delay, "1m");
2018 assert_eq!(settings.general.interval, "10s");
2019 assert_eq!(settings.general.log_level, "info");
2020 assert_eq!(settings.general.ready_delay, "3s");
2021
2022 // Test IPC settings
2023 assert_eq!(settings.ipc.connect_attempts, 5);
2024 assert_eq!(settings.ipc.request_timeout, "5s");
2025 assert_eq!(settings.ipc.rate_limit, 100);
2026
2027 // Test web settings
2028 assert!(!settings.web.auto_start);
2029 assert_eq!(settings.web.bind_address, "127.0.0.1");
2030 assert_eq!(settings.web.bind_port, 3120);
2031 assert_eq!(settings.web.log_lines, 100);
2032
2033 // Test TUI settings
2034 assert_eq!(settings.tui.refresh_rate, "2s");
2035 assert_eq!(settings.tui.stat_history, 60);
2036
2037 // Test supervisor settings
2038 assert!(settings.supervisor.auto_start);
2039 assert_eq!(settings.supervisor.ready_check_interval, "500ms");
2040 assert_eq!(settings.supervisor.file_watch_debounce, "1s");
2041 assert_eq!(settings.supervisor.user, "");
2042 }
2043
2044 #[test]
2045 fn a_bad_value_costs_only_its_own_setting() {
2046 // `Settings::read` is all or nothing, and the fallback for its failure used to be
2047 // `Settings::default()` — one field the merge could not hand to its type demoting all
2048 // sixty-eight settings and discarding the environment along with them.
2049 let env = EnvLayer::new([("PITCHFORK_LOG".to_string(), "debug".to_string())]);
2050 let mut resolved = resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&env)).unwrap();
2051
2052 // The one door into a resolution the merge does not check: a post-merge hook, which is
2053 // what `sanitize_durations` is. A bool where an integer belongs is what a buggy one
2054 // could write.
2055 let registry = Settings::SETTINGS_REGISTRY;
2056 let rate_limit = registry.lookup("ipc.rate_limit").unwrap().id;
2057 resolved.coerced(rate_limit, Value::Bool(true), "a hook that got it wrong");
2058
2059 assert!(
2060 Settings::read(&resolved).is_err(),
2061 "the strict read still refuses it"
2062 );
2063
2064 let (settings, errors) = Settings::read_lossy(&resolved);
2065 let settings = settings.expect("every setting declares a default");
2066 assert_eq!(
2067 settings.ipc.rate_limit, 100,
2068 "the bad field takes its declared default"
2069 );
2070 assert_eq!(
2071 settings.general.log_level, "debug",
2072 "and PITCHFORK_LOG is not collateral damage"
2073 );
2074 assert_eq!(errors.0.len(), 1, "{errors}");
2075 assert_eq!(errors.0[0].key, "ipc.rate_limit");
2076 }
2077
2078 #[test]
2079 fn every_setting_declares_a_default() {
2080 // What makes the `None` arm of the lossy read unreachable: a setting with no value and
2081 // no default is a hole in this file, not something a user can cause. Adding one without
2082 // a default should fail here rather than silently demote the whole struct at runtime.
2083 let missing: Vec<&str> = Settings::SETTINGS_PROPS
2084 .iter()
2085 .filter(|meta| meta.default.is_none())
2086 .map(|meta| meta.key)
2087 .collect();
2088 assert!(
2089 missing.is_empty(),
2090 "these settings declare no default: {missing:?}"
2091 );
2092 }
2093
2094 #[test]
2095 fn test_registry_matches_previous_schema() {
2096 // The emitted config block is the settings documentation now; make
2097 // sure the registry still declares what settings.toml used to.
2098 let keys: Vec<&str> = Settings::SETTINGS_PROPS
2099 .iter()
2100 .map(|meta| meta.key)
2101 .collect();
2102 assert_eq!(keys.len(), 76, "{keys:?}");
2103 assert!(keys.contains(&"general.autostop_delay"));
2104 assert!(keys.contains(&"supervisor.oneshot_timeout"));
2105 assert!(keys.contains(&"logs.archive_hook.command"));
2106 assert!(keys.contains(&"supervisor.health_check_interval"));
2107 assert!(keys.contains(&"supervisor.watch_interval"));
2108
2109 let registry = Settings::SETTINGS_REGISTRY;
2110 let interval = registry.get(registry.lookup("general.interval").unwrap().id);
2111 assert_eq!(interval.envs, &["PITCHFORK_INTERVAL"]);
2112 assert_eq!(interval.deprecated_envs, &["PITCHFORK_INTERVAL_SECS"]);
2113 let watch = registry.get(registry.lookup("supervisor.watch_interval").unwrap().id);
2114 assert_eq!(watch.deprecated_envs, &["PITCHFORK_WATCH_INTERVAL_MS"]);
2115 let log_level = registry.get(registry.lookup("general.log_level").unwrap().id);
2116 assert_eq!(log_level.envs, &["PITCHFORK_LOG"]);
2117 let worktree = registry.get(registry.lookup("general.worktree").unwrap().id);
2118 assert_eq!(worktree.envs, &["PITCHFORK_WORKTREE"]);
2119 assert_eq!(worktree.deprecated_envs, &["PITCHFORK_PROXY_WORKTREE"]);
2120 assert_eq!(worktree.aliases, &["proxy.worktree"]);
2121 assert_eq!(
2122 registry.lookup("proxy.worktree").unwrap().id,
2123 registry.lookup("general.worktree").unwrap().id,
2124 );
2125
2126 let spec = Settings::spec_kdl();
2127 let files = [
2128 "file \"/etc/pitchfork/config.toml\" scope=\"system\" format=\"toml\"",
2129 "file \"~/.config/pitchfork/config.toml\" scope=\"global\" format=\"toml\"",
2130 "file \".config/pitchfork.toml\" findup=#true format=\"toml\"",
2131 "file \".config/pitchfork.local.toml\" findup=#true format=\"toml\"",
2132 "file \"pitchfork.toml\" findup=#true format=\"toml\"",
2133 "file \"pitchfork.local.toml\" findup=#true format=\"toml\"",
2134 ];
2135 let positions: Vec<usize> = files
2136 .iter()
2137 .map(|file| spec.find(file).unwrap_or_else(|| panic!("missing {file}")))
2138 .collect();
2139 assert!(
2140 positions.windows(2).all(|pair| pair[0] < pair[1]),
2141 "config files must be documented from lowest to highest precedence:\n{spec}"
2142 );
2143 }
2144
2145 #[test]
2146 fn every_registry_key_round_trips_through_the_partial() {
2147 // A setting added to the derive structs but missed in `settings_partial!`
2148 // makes `pitchfork settings set` report success and write nothing.
2149 for meta in Settings::SETTINGS_PROPS {
2150 let value = match meta.ty.inner() {
2151 Ty::Bool => toml::Value::Boolean(true),
2152 Ty::Int | Ty::Uint => toml::Value::Integer(1),
2153 _ => toml::Value::String("x".to_string()),
2154 };
2155 let parts: Vec<&str> = meta.key.split('.').collect();
2156 let mut table = toml::Table::new();
2157 let mut cursor = &mut table;
2158 for part in &parts[..parts.len() - 1] {
2159 cursor = cursor
2160 .entry(part.to_string())
2161 .or_insert_with(|| toml::Value::Table(toml::Table::new()))
2162 .as_table_mut()
2163 .unwrap();
2164 }
2165 cursor.insert(parts[parts.len() - 1].to_string(), value);
2166
2167 let partial: SettingsPartial = table.clone().try_into().unwrap();
2168 let back = toml::Table::try_from(&partial).unwrap();
2169 assert_eq!(
2170 back, table,
2171 "{} is declared in the registry but not mirrored in SettingsPartial",
2172 meta.key
2173 );
2174 }
2175 }
2176
2177 #[test]
2178 fn test_parse_duration() {
2179 assert_eq!(Settings::parse_duration("1s"), Some(Duration::from_secs(1)));
2180 assert_eq!(
2181 Settings::parse_duration("500ms"),
2182 Some(Duration::from_millis(500))
2183 );
2184 assert_eq!(
2185 Settings::parse_duration("1m"),
2186 Some(Duration::from_secs(60))
2187 );
2188 assert_eq!(
2189 Settings::parse_duration("2h"),
2190 Some(Duration::from_secs(7200))
2191 );
2192 assert_eq!(Settings::parse_duration("invalid"), None);
2193 }
2194
2195 #[test]
2196 fn test_env_override() {
2197 // The environment is described rather than reached for, so this test
2198 // does not mutate process env vars (the old test needed a mutex).
2199 let env = EnvLayer::new([
2200 ("PITCHFORK_AUTOSTOP_DELAY".to_string(), "10m".to_string()),
2201 ("PITCHFORK_INTERVAL".to_string(), "5s".to_string()),
2202 (
2203 "PITCHFORK_IPC_CONNECT_ATTEMPTS".to_string(),
2204 "20".to_string(),
2205 ),
2206 (
2207 "PITCHFORK_SUPERVISOR_AUTO_START".to_string(),
2208 "false".to_string(),
2209 ),
2210 ("PITCHFORK_WEB_AUTO_START".to_string(), "true".to_string()),
2211 ]);
2212 let resolved = resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&env)).unwrap();
2213 let settings = Settings::read(&resolved).unwrap();
2214
2215 assert_eq!(settings.general.autostop_delay, "10m");
2216 assert_eq!(settings.general.interval, "5s");
2217 assert_eq!(settings.ipc.connect_attempts, 20);
2218 assert!(!settings.supervisor.auto_start);
2219 assert!(settings.web.auto_start);
2220
2221 // Fields with no corresponding env var set remain at defaults
2222 assert_eq!(settings.general.log_level, "info");
2223 assert_eq!(settings.ipc.rate_limit, 100);
2224 }
2225
2226 #[test]
2227 fn test_deprecated_env_still_works_and_warns() {
2228 let env = EnvLayer::new([
2229 ("PITCHFORK_INTERVAL_SECS".to_string(), "30s".to_string()),
2230 ("PITCHFORK_PROXY_WORKTREE".to_string(), "false".to_string()),
2231 ]);
2232 let resolved = resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&env)).unwrap();
2233 let settings = Settings::read(&resolved).unwrap();
2234 assert_eq!(settings.general.interval, "30s");
2235 assert!(!settings.general.worktree);
2236 let warnings = usage_rs::config::explain::warnings(&resolved);
2237 assert!(
2238 warnings
2239 .iter()
2240 .any(|w| w.contains("PITCHFORK_INTERVAL_SECS is deprecated")),
2241 "{warnings:?}"
2242 );
2243 assert!(
2244 warnings
2245 .iter()
2246 .any(|w| w.contains("PITCHFORK_PROXY_WORKTREE is deprecated")),
2247 "{warnings:?}"
2248 );
2249
2250 // The current name wins over the deprecated alias when both are set.
2251 let env = EnvLayer::new([
2252 ("PITCHFORK_INTERVAL_SECS".to_string(), "30s".to_string()),
2253 ("PITCHFORK_INTERVAL".to_string(), "7s".to_string()),
2254 ("PITCHFORK_PROXY_WORKTREE".to_string(), "false".to_string()),
2255 ("PITCHFORK_WORKTREE".to_string(), "true".to_string()),
2256 ]);
2257 let resolved = resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&env)).unwrap();
2258 let settings = Settings::read(&resolved).unwrap();
2259 assert_eq!(settings.general.interval, "7s");
2260 assert!(settings.general.worktree);
2261 }
2262
2263 #[test]
2264 fn test_invalid_duration_from_env_falls_back_to_default() {
2265 // The previous loader rejected invalid durations at load time; the
2266 // sanitize pass reproduces that through Resolved::coerced.
2267 let env = EnvLayer::new([(
2268 "PITCHFORK_AUTOSTOP_DELAY".to_string(),
2269 "not_a_duration".to_string(),
2270 )]);
2271 let mut resolved = resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&env)).unwrap();
2272 sanitize_durations(&mut resolved);
2273 let settings = Settings::read(&resolved).unwrap();
2274 assert_eq!(settings.general.autostop_delay, "1m");
2275 assert_eq!(settings.general_autostop_delay(), Duration::from_secs(60));
2276 }
2277
2278 #[test]
2279 fn test_zero_health_timeout_from_env_falls_back_to_default() {
2280 // A zero health probe timeout goes straight into tokio::time::timeout
2281 // and fails every probe immediately; the sanitize pass falls back to
2282 // the schema default like any other invalid duration.
2283 let env = EnvLayer::new([
2284 ("PITCHFORK_HEALTH_CMD_TIMEOUT".to_string(), "0s".to_string()),
2285 (
2286 "PITCHFORK_HEALTH_HTTP_TIMEOUT".to_string(),
2287 "0s".to_string(),
2288 ),
2289 (
2290 "PITCHFORK_HEALTH_PORT_TIMEOUT".to_string(),
2291 "0s".to_string(),
2292 ),
2293 ]);
2294 let mut resolved = resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&env)).unwrap();
2295 sanitize_durations(&mut resolved);
2296 let settings = Settings::read(&resolved).unwrap();
2297 assert_eq!(settings.supervisor.health_cmd_timeout, "10s");
2298 assert_eq!(
2299 settings.supervisor_health_cmd_timeout(),
2300 Duration::from_secs(10)
2301 );
2302 assert_eq!(settings.supervisor.health_http_timeout, "5s");
2303 assert_eq!(settings.supervisor.health_port_timeout, "5s");
2304 }
2305
2306 #[test]
2307 fn test_invalid_duration_fallback() {
2308 let mut settings = Settings::default();
2309
2310 // Set invalid duration values
2311 settings.general.autostop_delay = "invalid".to_string();
2312 settings.general.interval = "not_a_duration".to_string();
2313
2314 // Convenience methods should fallback to default values
2315 assert_eq!(settings.general_autostop_delay(), Duration::from_secs(60)); // default "1m"
2316 assert_eq!(settings.general_interval(), Duration::from_secs(10)); // default "10s"
2317 }
2318
2319 #[test]
2320 fn oneshot_wait_defaults_to_an_hour() {
2321 use crate::config_types::OneshotWait;
2322 let settings = Settings::default();
2323 assert_eq!(
2324 settings.supervisor_oneshot_timeout(),
2325 Duration::from_secs(3600)
2326 );
2327 assert_eq!(
2328 settings.supervisor_oneshot_wait(),
2329 OneshotWait::For(Duration::from_secs(3600))
2330 );
2331 }
2332
2333 #[test]
2334 fn oneshot_wait_of_zero_means_no_deadline_at_all() {
2335 // A task's natural end is its exit, so `0` has to read as "wait",
2336 // never as "give up immediately" — and never as a distant substitute
2337 // deadline that a long enough task could still outlive.
2338 use crate::config_types::OneshotWait;
2339 let mut settings = Settings::default();
2340 for value in ["0", "0s"] {
2341 settings.supervisor.oneshot_timeout = value.to_string();
2342 assert!(settings.supervisor_oneshot_timeout().is_zero());
2343 assert_eq!(
2344 settings.supervisor_oneshot_wait(),
2345 OneshotWait::Unlimited,
2346 "{value} should wait without a deadline"
2347 );
2348 assert_eq!(settings.supervisor_oneshot_wait().duration(), None);
2349 }
2350 }
2351
2352 #[test]
2353 fn oneshot_wait_honours_a_configured_value() {
2354 use crate::config_types::OneshotWait;
2355 let mut settings = Settings::default();
2356 settings.supervisor.oneshot_timeout = "6h".to_string();
2357 assert_eq!(
2358 settings.supervisor_oneshot_wait(),
2359 OneshotWait::For(Duration::from_secs(6 * 3600))
2360 );
2361 assert_eq!(
2362 settings.supervisor_oneshot_wait().duration(),
2363 Some(Duration::from_secs(6 * 3600))
2364 );
2365 }
2366
2367 #[test]
2368 fn test_duration_methods_all_fields() {
2369 let settings = Settings::default();
2370
2371 assert_eq!(settings.general_autostop_delay(), Duration::from_secs(60));
2372 assert_eq!(settings.general_interval(), Duration::from_secs(10));
2373 assert_eq!(settings.general_ready_delay(), Duration::from_secs(3));
2374 assert_eq!(settings.ipc_connect_min_delay(), Duration::from_millis(100));
2375 assert_eq!(settings.ipc_connect_max_delay(), Duration::from_secs(1));
2376 assert_eq!(settings.ipc_request_timeout(), Duration::from_secs(5));
2377 assert_eq!(settings.ipc_rate_limit_window(), Duration::from_secs(1));
2378 assert_eq!(settings.web_sse_poll_interval(), Duration::from_millis(500));
2379 assert_eq!(settings.tui_refresh_rate(), Duration::from_secs(2));
2380 assert_eq!(settings.tui_tick_rate(), Duration::from_millis(100));
2381 assert_eq!(settings.tui_message_duration(), Duration::from_secs(3));
2382 assert_eq!(
2383 settings.supervisor_ready_check_interval(),
2384 Duration::from_millis(500)
2385 );
2386 assert_eq!(
2387 settings.supervisor_file_watch_debounce(),
2388 Duration::from_secs(1)
2389 );
2390 assert_eq!(
2391 settings.supervisor_log_flush_interval(),
2392 Duration::from_millis(500)
2393 );
2394 assert_eq!(settings.supervisor_stop_timeout(), Duration::from_secs(5));
2395 assert_eq!(
2396 settings.supervisor_restart_delay(),
2397 Duration::from_millis(100)
2398 );
2399 assert_eq!(
2400 settings.supervisor_cron_check_interval(),
2401 Duration::from_secs(10)
2402 );
2403 assert_eq!(
2404 settings.supervisor_http_client_timeout(),
2405 Duration::from_secs(5)
2406 );
2407 }
2408
2409 #[test]
2410 fn test_general_ready_delay_secs() {
2411 let mut settings = Settings::default();
2412
2413 // Default "3s" resolves to whole seconds.
2414 assert_eq!(settings.general_ready_delay_secs(), Ok(3));
2415
2416 // Subsecond values are rejected, not silently truncated by as_secs().
2417 settings.general.ready_delay = "500ms".to_string();
2418 let err = settings.general_ready_delay_secs().unwrap_err();
2419 assert!(err.contains("500ms"), "unexpected error: {err}");
2420 assert!(
2421 err.contains("whole number of seconds"),
2422 "unexpected error: {err}"
2423 );
2424
2425 settings.general.ready_delay = "1.5s".to_string();
2426 let err = settings.general_ready_delay_secs().unwrap_err();
2427 assert!(err.contains("1.5s"), "unexpected error: {err}");
2428
2429 // Empty string falls back to the schema default "3s".
2430 settings.general.ready_delay = String::new();
2431 assert_eq!(settings.general_ready_delay_secs(), Ok(3));
2432
2433 // Whole-second values resolve as seconds.
2434 settings.general.ready_delay = "90s".to_string();
2435 assert_eq!(settings.general_ready_delay_secs(), Ok(90));
2436 }
2437
2438 /// A directory tree, cleaned up when the test ends.
2439 struct Tree(std::path::PathBuf);
2440
2441 impl Tree {
2442 fn new(name: &str) -> Self {
2443 let dir = std::env::temp_dir()
2444 .join(format!("pitchfork_settings_{}_{name}", std::process::id()));
2445 let _ = std::fs::remove_dir_all(&dir);
2446 std::fs::create_dir_all(&dir).unwrap();
2447 Self(dir)
2448 }
2449
2450 fn write(&self, rel: &str, text: &str) -> std::path::PathBuf {
2451 let path = self.0.join(rel);
2452 if let Some(parent) = path.parent() {
2453 std::fs::create_dir_all(parent).unwrap();
2454 }
2455 std::fs::write(&path, text).unwrap();
2456 path
2457 }
2458 }
2459
2460 impl Drop for Tree {
2461 fn drop(&mut self) {
2462 let _ = std::fs::remove_dir_all(&self.0);
2463 }
2464 }
2465
2466 #[test]
2467 fn test_settings_read_from_pitchfork_toml_settings_table() {
2468 let tree = Tree::new("table");
2469 let path = tree.write(
2470 "pitchfork.toml",
2471 r#"
2472[daemons.myapp]
2473run = "node server.js"
2474
2475[settings.general]
2476autostop_delay = "5m"
2477log_level = "debug"
2478
2479[settings.web]
2480auto_start = true
2481bind_port = 8080
2482
2483[settings.supervisor]
2484auto_start = false
2485user = "postgres"
2486"#,
2487 );
2488 let layer = FileLayer::at(&path, FileScope::Project).under("settings");
2489 let resolved = resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&layer)).unwrap();
2490 let settings = Settings::read(&resolved).unwrap();
2491
2492 assert_eq!(settings.general.autostop_delay, "5m");
2493 assert_eq!(settings.general.log_level, "debug");
2494 assert!(settings.web.auto_start);
2495 assert_eq!(settings.web.bind_port, 8080);
2496 assert!(!settings.supervisor.auto_start);
2497 assert_eq!(settings.supervisor.user, "postgres");
2498 // Unset fields fall back to defaults, and [daemons] is not a warning.
2499 assert_eq!(settings.general.interval, "10s");
2500 assert_eq!(settings.ipc.connect_attempts, 5);
2501 assert!(resolved.warnings.is_empty(), "{:?}", resolved.warnings);
2502 }
2503
2504 #[test]
2505 fn test_proxy_worktree_config_alias_still_works() {
2506 let tree = Tree::new("proxy_worktree_alias");
2507 let path = tree.write("pitchfork.toml", "[settings.proxy]\nworktree = false\n");
2508 let layer = FileLayer::at(&path, FileScope::Project).under("settings");
2509 let resolved = resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&layer)).unwrap();
2510 let settings = Settings::read(&resolved).unwrap();
2511
2512 assert!(!settings.general.worktree);
2513 assert_eq!(
2514 resolved.get(
2515 Settings::SETTINGS_REGISTRY
2516 .lookup("general.worktree")
2517 .unwrap()
2518 .id
2519 ),
2520 Some(&Value::Bool(false)),
2521 );
2522 }
2523
2524 #[test]
2525 fn test_explicit_default_value_in_higher_file_still_overrides() {
2526 // "Bug 5": the old merge_from skipped values equal to the default, so
2527 // a higher-precedence file explicitly setting log_level = "info"
2528 // could not override "warn" from a lower one. usage-config's
2529 // origin-tracked merge does this natively; pin it.
2530 let tree = Tree::new("bug5");
2531 let lower = tree.write("lower.toml", "[settings.general]\nlog_level = \"warn\"\n");
2532 let higher = tree.write("higher.toml", "[settings.general]\nlog_level = \"info\"\n");
2533 let lower = FileLayer::at(&lower, FileScope::Global).under("settings");
2534 let higher = FileLayer::at(&higher, FileScope::Project).under("settings");
2535 let resolved = resolve(
2536 Settings::SETTINGS_REGISTRY,
2537 Layers::new().then(&higher).then(&lower),
2538 )
2539 .unwrap();
2540 let settings = Settings::read(&resolved).unwrap();
2541 assert_eq!(settings.general.log_level, "info");
2542
2543 // And the provenance says the higher file set it, not the default.
2544 let id = Settings::SETTINGS_REGISTRY
2545 .lookup("general.log_level")
2546 .unwrap()
2547 .id;
2548 let origin = resolved.origin(id).unwrap().describe();
2549 assert!(origin.contains("higher.toml"), "{origin}");
2550 }
2551
2552 #[test]
2553 fn test_file_precedence_local_over_base_and_nearer_dir_wins() {
2554 let tree = Tree::new("precedence");
2555 tree.write("pitchfork.toml", "[settings.general]\ninterval = \"9s\"\n");
2556 tree.write(
2557 "pitchfork.local.toml",
2558 "[settings.general]\ninterval = \"2s\"\n",
2559 );
2560 tree.write(
2561 ".config/pitchfork.toml",
2562 "[settings.general]\ninterval = \"7s\"\n[settings.web]\nbind_port = 9999\n",
2563 );
2564 let layers = Settings::file_layers_from(&tree.0);
2565 let mut chain = Layers::new();
2566 for layer in &layers {
2567 chain = chain.then(layer);
2568 }
2569 let resolved = resolve(Settings::SETTINGS_REGISTRY, chain).unwrap();
2570 let settings = Settings::read(&resolved).unwrap();
2571 // pitchfork.local.toml > pitchfork.toml > .config/pitchfork.toml
2572 assert_eq!(settings.general.interval, "2s");
2573 // A key only the lowest file sets still applies.
2574 assert_eq!(settings.web.bind_port, 9999);
2575
2576 // A nested directory's file outranks everything above it.
2577 let deep = tree.0.join("a").join("b");
2578 std::fs::create_dir_all(&deep).unwrap();
2579 tree.write(
2580 "a/b/pitchfork.toml",
2581 "[settings.general]\ninterval = \"1s\"\n",
2582 );
2583 let layers = Settings::file_layers_from(&deep);
2584 let mut chain = Layers::new();
2585 for layer in &layers {
2586 chain = chain.then(layer);
2587 }
2588 let resolved = resolve(Settings::SETTINGS_REGISTRY, chain).unwrap();
2589 let settings = Settings::read(&resolved).unwrap();
2590 assert_eq!(settings.general.interval, "1s");
2591 }
2592
2593 #[test]
2594 fn test_broken_file_is_skipped_rather_than_fatal() {
2595 let tree = Tree::new("broken");
2596 let broken = tree.write("pitchfork.toml", "[invalid toml [[");
2597 // The broken file is skipped with a warning, so the resolution still
2598 // happens and produces the defaults.
2599 let layers = Settings::file_layers_from(&tree.0);
2600 assert!(
2601 layers.iter().all(|layer| !layer.paths().contains(&broken)),
2602 "the broken project file must not become a settings layer"
2603 );
2604 }
2605
2606 #[test]
2607 fn test_unknown_settings_keys_do_not_fail_the_load() {
2608 // Forward compatibility: a config written for a newer pitchfork must
2609 // still load. Unknown keys are warned about rather than fatal.
2610 let tree = Tree::new("unknown");
2611 let path = tree.write(
2612 "pitchfork.toml",
2613 "[settings.general]\nlog_level = \"debug\"\nfrom_the_future = true\n",
2614 );
2615 let layer = FileLayer::at(&path, FileScope::Project).under("settings");
2616 let resolved = resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&layer)).unwrap();
2617 let settings = Settings::read(&resolved).unwrap();
2618 assert_eq!(settings.general.log_level, "debug");
2619 assert_eq!(resolved.warnings.len(), 1, "{:?}", resolved.warnings);
2620 }
2621
2622 #[test]
2623 fn test_partial_merge_from() {
2624 let mut base = SettingsPartial::default();
2625 base.general.log_level = Some("warn".to_string());
2626 base.web.bind_address = Some("0.0.0.0".to_string());
2627
2628 let mut overlay = SettingsPartial::default();
2629 overlay.general.log_level = Some("debug".to_string());
2630 overlay.tui.refresh_rate = Some("1s".to_string());
2631
2632 base.merge_from(&overlay);
2633 assert_eq!(base.general.log_level.as_deref(), Some("debug"));
2634 assert_eq!(base.web.bind_address.as_deref(), Some("0.0.0.0"));
2635 assert_eq!(base.tui.refresh_rate.as_deref(), Some("1s"));
2636
2637 // An empty partial changes nothing.
2638 let sealed = base.clone();
2639 base.merge_from(&SettingsPartial::default());
2640 assert_eq!(base.general.log_level, sealed.general.log_level);
2641 assert!(SettingsPartial::default().is_empty());
2642 assert!(base.has_any_set());
2643 }
2644
2645 #[test]
2646 fn test_partial_serialization_skips_unset() {
2647 let mut partial = SettingsPartial::default();
2648 partial.general.interval = Some("5s".to_string());
2649 let toml = toml::to_string_pretty(&partial).unwrap();
2650 assert_eq!(toml, "[general]\ninterval = \"5s\"\n");
2651 }
2652}