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