Skip to main content

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.auto_start` is enabled and `api.bind_port` is set, the API server binds
43/// independently of the web UI.
44#[derive(usage_rs::Config, Debug, Clone, PartialEq)]
45#[usage(prefix = "api")]
46pub struct SettingsApi {
47    /// Automatically start the standalone API server
48    ///
49    /// When true, pitchfork starts a standalone API server on api.bind_address +
50    /// api.bind_port when the supervisor launches. The web UI does not need to
51    /// be enabled for this; you can run the API alone and serve the Vue SPA from
52    /// a separate static host (e.g. nginx, Vite dev server).
53    ///
54    /// Default is false so the API is only available through the bundled web UI.
55    #[usage(env = "PITCHFORK_API_AUTO_START", default = false)]
56    pub auto_start: bool,
57
58    /// IP address the API server binds to
59    ///
60    /// Use "0.0.0.0" to make the API reachable from other devices on your network.
61    /// Keep "127.0.0.1" (default) to restrict it to localhost.
62    #[usage(env = "PITCHFORK_API_BIND_ADDRESS", default = "127.0.0.1")]
63    pub bind_address: String,
64
65    /// Port the standalone API server listens on
66    ///
67    /// Set to 0 (default) to disable the standalone API server.
68    /// Set to a valid port (e.g. 8081) and enable `api.auto_start` to start the API on its own port.
69    #[usage(env = "PITCHFORK_API_BIND_PORT", default = 0)]
70    pub bind_port: i64,
71
72    /// Number of consecutive ports to try if api.bind_port is in use
73    #[usage(env = "PITCHFORK_API_PORT_ATTEMPTS", default = 10)]
74    pub port_attempts: i64,
75
76    /// Authentication token for API access when bound to non-loopback addresses
77    ///
78    /// When the API server or web UI is bound to a non-loopback address (e.g.,
79    /// "0.0.0.0"), this token is required on every API request via the
80    /// X-Pitchfork-Token header.
81    ///
82    /// If left empty and the bind address is non-loopback, a random 32-byte
83    /// hex token is auto-generated on startup and printed to the supervisor log.
84    /// This token is injected into the served index.html, so the bundled Vue
85    /// SPA works without additional configuration.
86    ///
87    /// For external API consumers (e.g., curl, mobile apps), set this to a
88    /// fixed value or pass it via the PITCHFORK_API_TOKEN environment variable.
89    #[usage(env = "PITCHFORK_API_TOKEN", default = "")]
90    pub token: String,
91}
92
93/// The `general.*` settings.
94#[derive(usage_rs::Config, Debug, Clone, PartialEq)]
95#[usage(prefix = "general")]
96pub struct SettingsGeneral {
97    /// Delay before auto-stopping daemons when leaving a directory
98    ///
99    /// When using shell hooks with `auto = ["stop"]`, this controls how long pitchfork waits
100    /// before stopping a daemon after you leave its directory.
101    ///
102    /// This delay prevents unnecessary stop/start cycles when briefly switching directories.
103    ///
104    /// **Examples:**
105    /// - `"0s"` - Stop immediately (no delay)
106    /// - `"30s"` - Wait 30 seconds
107    /// - `"1m"` - Wait 1 minute (default)
108    /// - `"5m"` - Wait 5 minutes
109    ///
110    /// Set to `"0s"` to disable the delay and stop daemons immediately.
111    #[usage(env = "PITCHFORK_AUTOSTOP_DELAY", default = "1m", ty = "duration")]
112    pub autostop_delay: String,
113
114    /// Supervisor background task refresh interval
115    ///
116    /// Controls how often the supervisor refreshes its internal state and checks for:
117    /// - Daemon health status changes
118    /// - Configuration file updates
119    /// - Process state synchronization
120    ///
121    /// Lower values provide more responsive status updates but use more resources.
122    ///
123    /// **Recommended values:**
124    /// - `"5s"` - For development/testing
125    /// - `"10s"` - Default, balanced
126    /// - `"30s"` - For production with many daemons
127    #[usage(
128        env = "PITCHFORK_INTERVAL",
129        deprecated_env("PITCHFORK_INTERVAL_SECS"),
130        default = "10s",
131        ty = "duration"
132    )]
133    pub interval: String,
134
135    /// File log level (trace, debug, info, warn, error)
136    ///
137    /// Controls the verbosity of log output written to log files.
138    /// Can be set independently from `log_level` to have more verbose file logs.
139    ///
140    /// For example, set console to `"info"` but file to `"debug"` to keep
141    /// detailed logs for troubleshooting without cluttering the console.
142    #[usage(env = "PITCHFORK_LOG_FILE_LEVEL", default = "info")]
143    pub log_file_level: String,
144
145    /// Console log level (trace, debug, info, warn, error)
146    ///
147    /// Controls the verbosity of log output to the console.
148    ///
149    /// **Available levels:**
150    /// - `"trace"` - Most verbose, includes all internal details
151    /// - `"debug"` - Detailed information for debugging
152    /// - `"info"` - Normal operation messages (default)
153    /// - `"warn"` - Warnings and potential issues
154    /// - `"error"` - Only errors
155    #[usage(env = "PITCHFORK_LOG", default = "info")]
156    pub log_level: String,
157
158    /// Wrap daemon commands with mise x -- globally
159    ///
160    /// When enabled, pitchfork wraps every daemon command with `mise x --` so that
161    /// [mise](https://mise.jdx.dev) sets up the correct tool versions, PATH,
162    /// and environment variables before the daemon runs.
163    ///
164    /// This is especially useful when pitchfork is started as a login item or boot
165    /// daemon, where the shell profile (`.zshrc`, `.bashrc`) is not sourced and
166    /// tools installed via Homebrew or mise are not on PATH.
167    ///
168    /// Individual daemons can override this with `mise = true` or `mise = false`
169    /// in their configuration.
170    #[usage(env = "PITCHFORK_MISE", default = false)]
171    pub mise: bool,
172
173    /// Explicit path to the mise binary
174    ///
175    /// By default, pitchfork searches well-known locations for the mise binary:
176    /// - `~/.local/bin/mise`
177    /// - `~/.cargo/bin/mise`
178    /// - `/usr/local/bin/mise`
179    /// - `/opt/homebrew/bin/mise`
180    ///
181    /// Set this to an absolute path if mise is installed elsewhere.
182    #[usage(env = "PITCHFORK_MISE_BIN", default = "")]
183    pub mise_bin: String,
184
185    /// Shell command used to execute daemon run scripts
186    ///
187    /// Controls the shell used to execute daemon `run` commands.
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    /// [settings.logs.archive_hook]
341    /// command = "gzip -c >> /var/log/pitchfork/archive.jsonl.gz"
342    /// ```
343    ///
344    /// ```toml
345    /// [settings.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 its configured signal (SIGTERM by default) 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        let extras = crate::extra_configs::paths_for(start_dir)
1272            .into_iter()
1273            .rev()
1274            .map(|p| (p, FileScope::Project));
1275        candidates.splice(0..0, extras);
1276        candidates.push((
1277            crate::env::PITCHFORK_GLOBAL_CONFIG_USER.clone(),
1278            FileScope::Global,
1279        ));
1280        candidates.push((
1281            crate::env::PITCHFORK_GLOBAL_CONFIG_SYSTEM.clone(),
1282            FileScope::System,
1283        ));
1284        candidates
1285            .into_iter()
1286            .filter(|(path, _)| readable_settings_file(path))
1287            .map(|(path, scope)| FileLayer::at(path, scope).under("settings"))
1288            .collect()
1289    }
1290
1291    /// Parse a duration string (humantime format) to Duration
1292    pub fn parse_duration(s: &str) -> Option<std::time::Duration> {
1293        humantime::parse_duration(s).ok()
1294    }
1295
1296    /// Resolve the mise binary path.
1297    ///
1298    /// If `general.mise_bin` is explicitly set, returns that path.
1299    /// Otherwise, searches well-known install locations:
1300    /// - `~/.local/bin/mise`
1301    /// - `~/.cargo/bin/mise`
1302    /// - `/usr/local/bin/mise`
1303    /// - `/opt/homebrew/bin/mise`
1304    ///
1305    /// Returns `None` if mise cannot be found.
1306    pub fn resolve_mise_bin(&self) -> Option<std::path::PathBuf> {
1307        // Explicit configuration takes priority
1308        if !self.general.mise_bin.is_empty() {
1309            let p = PathBuf::from(&self.general.mise_bin);
1310            if p.is_file() {
1311                return Some(p);
1312            }
1313            warn!(
1314                "mise_bin is set to {:?} but the file does not exist",
1315                self.general.mise_bin
1316            );
1317            return None;
1318        }
1319
1320        // Search well-known install paths
1321        let home = crate::env::HOME_DIR.as_path();
1322        let candidates = [
1323            home.join(".local/bin/mise"),
1324            home.join(".cargo/bin/mise"),
1325            PathBuf::from("/usr/local/bin/mise"),
1326            PathBuf::from("/opt/homebrew/bin/mise"),
1327        ];
1328
1329        candidates.into_iter().find(|p| p.is_file())
1330    }
1331
1332    /// Return `supervisor.port_bump_attempts` as `u32`, clamping out-of-range
1333    /// values to the schema default (10) and zero to 1.
1334    ///
1335    /// This is the single source of truth for the fallback so that call-sites
1336    /// don't each duplicate the hardcoded `10`.
1337    pub fn default_port_bump_attempts(&self) -> u32 {
1338        let v = u32::try_from(self.supervisor.port_bump_attempts).unwrap_or_else(|_| {
1339            warn!(
1340                "supervisor.port_bump_attempts value {} is out of range (0-{}), clamping to 10",
1341                self.supervisor.port_bump_attempts,
1342                u32::MAX
1343            );
1344            10
1345        });
1346        if v == 0 {
1347            warn!("supervisor.port_bump_attempts is 0; defaulting to 1");
1348            1
1349        } else {
1350            v
1351        }
1352    }
1353}
1354
1355/// Whether `path` holds a settings file this loader can hand to a `FileLayer`.
1356///
1357/// Mirrors the previous loader's per-file error handling: a missing file is
1358/// the ordinary case, and a file that exists but cannot be read or parsed —
1359/// or whose `settings` key is not a table — is warned about and skipped
1360/// rather than turned into a startup failure.
1361fn readable_settings_file(path: &Path) -> bool {
1362    if !path.exists() {
1363        return false;
1364    }
1365    let content = match std::fs::read_to_string(path) {
1366        Ok(content) => content,
1367        Err(e) => {
1368            eprintln!(
1369                "pitchfork: warning: failed to read {}: {}",
1370                path.display(),
1371                e
1372            );
1373            return false;
1374        }
1375    };
1376    let table: toml::Table = match content.parse() {
1377        Ok(table) => table,
1378        Err(e) => {
1379            eprintln!(
1380                "pitchfork: warning: failed to parse {}: {}",
1381                path.display(),
1382                e
1383            );
1384            return false;
1385        }
1386    };
1387    match table.get("settings") {
1388        None => true,
1389        Some(toml::Value::Table(_)) => true,
1390        Some(_) => {
1391            eprintln!(
1392                "pitchfork: warning: invalid [settings] in {}: not a table",
1393                path.display()
1394            );
1395            false
1396        }
1397    }
1398}
1399
1400/// Validate every duration-typed setting in a resolution.
1401///
1402/// The registry's `duration` type stores the humantime text as-is, so an
1403/// invalid value would otherwise ride into the struct. This warns once and
1404/// rewrites the value to the schema default through [`Resolved::coerced`],
1405/// so `pitchfork settings get/list` and the typed getters all agree —
1406/// matching the previous loader, which rejected invalid durations at load
1407/// time. A value equal to the declared default is left alone (the
1408/// `logs.time_retention` default is the empty string, meaning "disabled").
1409/// Duration settings where `0s` is never meaningful: the value is passed
1410/// straight into `tokio::time::timeout` for a health probe, so a zero
1411/// timeout fails every probe immediately and would crash-kill a healthy
1412/// daemon after the retry threshold. Same defect family as the per-daemon
1413/// health timeout rejection in `config_types.rs` (`parse_positive_timeout`).
1414const NON_ZERO_DURATION_KEYS: &[&str] = &[
1415    "supervisor.health_cmd_timeout",
1416    "supervisor.health_http_timeout",
1417    "supervisor.health_port_timeout",
1418];
1419
1420fn sanitize_durations(resolved: &mut Resolved) {
1421    let registry = Settings::SETTINGS_REGISTRY;
1422    for id in registry.ids() {
1423        let meta = registry.get(id);
1424        if !matches!(meta.ty.inner(), Ty::Duration) {
1425            continue;
1426        }
1427        let Some(Value::String(text)) = resolved.get(id) else {
1428            continue;
1429        };
1430        let default_text = match meta.default {
1431            Some(Const::Str(s)) => s,
1432            _ => "",
1433        };
1434        if text == default_text {
1435            continue;
1436        }
1437        let reason = match humantime::parse_duration(text) {
1438            Err(_) => Some(format!("invalid duration {text:?}")),
1439            Ok(d) if d.is_zero() && NON_ZERO_DURATION_KEYS.contains(&meta.key) => Some(format!(
1440                "supervisor health probe timeout must be greater than 0, got {text:?}"
1441            )),
1442            Ok(_) => None,
1443        };
1444        let Some(reason) = reason else {
1445            continue;
1446        };
1447        let origin = resolved
1448            .origin(id)
1449            .map(|o| o.describe().to_string())
1450            .unwrap_or_default();
1451        warn!("{reason} (set by {origin}), using default");
1452        resolved.coerced(
1453            id,
1454            Value::String(default_text.to_string()),
1455            format!("{reason}; the default stands"),
1456        );
1457    }
1458}
1459
1460/// Global settings instance, kept beside the resolution it was read from
1461/// (RwLock<Arc> to support runtime reload).
1462type SettingsState = (Arc<Settings>, Arc<Resolved>);
1463static SETTINGS: std::sync::RwLock<Option<SettingsState>> = std::sync::RwLock::new(None);
1464
1465fn settings_state() -> SettingsState {
1466    // Fast path: if already initialized, clone the Arc pointers.
1467    {
1468        let lock = SETTINGS.read().unwrap();
1469        if let Some(state) = lock.as_ref() {
1470            return state.clone();
1471        }
1472    }
1473    // Slow path: initialize on first access
1474    let mut lock = SETTINGS.write().unwrap();
1475    if let Some(state) = lock.as_ref() {
1476        return state.clone();
1477    }
1478    let (settings, resolved) = Settings::resolve_from_dir(&crate::env::CWD);
1479    let state = (Arc::new(settings), Arc::new(resolved));
1480    *lock = Some(state.clone());
1481    state
1482}
1483
1484/// Get the global settings instance
1485pub fn settings() -> Arc<Settings> {
1486    settings_state().0
1487}
1488
1489/// The origin-tracked resolution behind [`settings()`], for the settings CLI.
1490pub(crate) fn settings_resolved() -> Arc<Resolved> {
1491    settings_state().1
1492}
1493
1494/// Reload settings from config files.
1495///
1496/// Called when the supervisor receives a ReloadConfig IPC request,
1497/// typically after `pitchfork settings set` modifies a config file.
1498///
1499/// Old Arc references remain valid until all holders drop them,
1500/// so no use-after-free can occur.
1501pub fn reload_settings() {
1502    let (settings, resolved) = Settings::resolve_from_dir(&crate::env::CWD);
1503    let mut lock = SETTINGS.write().unwrap();
1504    *lock = Some((Arc::new(settings), Arc::new(resolved)));
1505}
1506
1507// ============================================================================
1508// Partial (all-Option) mirror structs
1509//
1510// These exist for `pitchfork.toml` round-trips: `PitchforkToml` deserializes
1511// its `[settings]` table into `SettingsPartial`, merges per-file overrides,
1512// and serializes it back out (`pitchfork settings set`, `proxy add`, ...).
1513// Settings *resolution* no longer goes through these — usage-config's
1514// origin-tracked merge replaced the apply/merge chain.
1515// ============================================================================
1516
1517macro_rules! settings_partial {
1518    (
1519        $(#[$meta:meta])*
1520        $name:ident {
1521            $(@group $group_field:ident: $group_ty:ty,)*
1522            $($(#[$fdoc:meta])* $field:ident: $ty:ty,)*
1523        }
1524    ) => {
1525        $(#[$meta])*
1526        #[derive(
1527            Debug, Clone, Default, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
1528        )]
1529        #[serde(default)]
1530        pub struct $name {
1531            $(
1532                #[serde(default, skip_serializing_if = "is_empty_partial")]
1533                pub $group_field: $group_ty,
1534            )*
1535            $(
1536                $(#[$fdoc])*
1537                #[serde(skip_serializing_if = "Option::is_none", default)]
1538                pub $field: Option<$ty>,
1539            )*
1540        }
1541
1542        #[allow(dead_code)]
1543        impl $name {
1544            /// Whether any field (or nested group field) is set.
1545            pub fn has_any_set(&self) -> bool {
1546                false
1547                $(|| self.$group_field.has_any_set())*
1548                $(|| self.$field.is_some())*
1549            }
1550
1551            pub fn is_empty(&self) -> bool {
1552                !self.has_any_set()
1553            }
1554
1555            /// Merge another partial onto this one.
1556            /// All `Some` values in `other` override the corresponding values in `self`.
1557            pub fn merge_from(&mut self, other: &Self) {
1558                $(self.$group_field.merge_from(&other.$group_field);)*
1559                $(
1560                    if other.$field.is_some() {
1561                        self.$field = other.$field.clone();
1562                    }
1563                )*
1564            }
1565        }
1566    };
1567}
1568
1569/// serde `skip_serializing_if` helper for nested partial groups.
1570fn is_empty_partial<T: HasAnySet>(v: &T) -> bool {
1571    !v.has_any_set()
1572}
1573
1574/// Internal trait so one serde skip helper serves every partial group.
1575trait HasAnySet {
1576    fn has_any_set(&self) -> bool;
1577}
1578
1579macro_rules! impl_has_any_set {
1580    ($($name:ident),+ $(,)?) => {
1581        $(impl HasAnySet for $name {
1582            fn has_any_set(&self) -> bool {
1583                $name::has_any_set(self)
1584            }
1585        })+
1586    };
1587}
1588
1589settings_partial! {
1590    /// Partial mirror of [`SettingsApi`].
1591    SettingsApiPartial {
1592        /// Automatically start the standalone API server
1593        auto_start: bool,
1594        /// IP address the API server binds to
1595        bind_address: String,
1596        /// Port the standalone API server listens on
1597        bind_port: i64,
1598        /// Number of consecutive ports to try if api.bind_port is in use
1599        port_attempts: i64,
1600        /// Authentication token for API access when bound to non-loopback addresses
1601        token: String,
1602    }
1603}
1604
1605settings_partial! {
1606    /// Partial mirror of [`SettingsGeneral`].
1607    SettingsGeneralPartial {
1608        /// Delay before auto-stopping daemons when leaving a directory
1609        autostop_delay: String,
1610        /// Supervisor background task refresh interval
1611        interval: String,
1612        /// File log level (trace, debug, info, warn, error)
1613        log_file_level: String,
1614        /// Console log level (trace, debug, info, warn, error)
1615        log_level: String,
1616        /// Wrap daemon commands with mise x -- globally
1617        mise: bool,
1618        /// Explicit path to the mise binary
1619        mise_bin: String,
1620        /// Shell command used to execute daemon run scripts
1621        shell: String,
1622        /// Show timestamps in startup log output
1623        startup_log_timestamps: bool,
1624        /// Default readiness delay in seconds when a daemon has no ready check configured
1625        ready_delay: String,
1626        /// Enable git worktree / jj workspace auto-discovery
1627        worktree: bool,
1628    }
1629}
1630
1631settings_partial! {
1632    /// Partial mirror of [`SettingsIpc`].
1633    SettingsIpcPartial {
1634        /// Number of connection retry attempts
1635        connect_attempts: i64,
1636        /// Maximum delay between connection retries
1637        connect_max_delay: String,
1638        /// Minimum delay between connection retries
1639        connect_min_delay: String,
1640        /// Maximum IPC requests per second per connection
1641        rate_limit: i64,
1642        /// Rate limit sliding window duration
1643        rate_limit_window: String,
1644        /// Default timeout for IPC requests
1645        request_timeout: String,
1646    }
1647}
1648
1649settings_partial! {
1650    /// Partial mirror of [`SettingsLogsArchiveHook`].
1651    SettingsLogsArchiveHookPartial {
1652        /// Maximum log entries per archive hook invocation
1653        batch_size: i64,
1654        /// Command to run before retention deletes old log entries
1655        command: String,
1656    }
1657}
1658
1659settings_partial! {
1660    /// Partial mirror of [`SettingsLogs`].
1661    SettingsLogsPartial {
1662        @group archive_hook: SettingsLogsArchiveHookPartial,
1663        /// Count-based log retention (e.g. 10000)
1664        line_retention: i64,
1665        /// Default log format for daemons (json | logfmt | text)
1666        log_format: String,
1667        /// Time-based log retention duration (e.g. '7d', '30d')
1668        time_retention: String,
1669        /// Show timestamps in log output
1670        timestamp: bool,
1671        /// strftime format for log timestamps in `pitchfork logs` output
1672        timestamp_format: String,
1673    }
1674}
1675
1676settings_partial! {
1677    /// Partial mirror of [`SettingsProxy`].
1678    SettingsProxyPartial {
1679        /// Automatically start daemons when accessed via proxy URL
1680        auto_start: bool,
1681        /// Maximum time to wait for an auto-started daemon to become ready
1682        auto_start_timeout: String,
1683        /// Automatically install the proxy TLS certificate into the system trust store
1684        auto_trust: bool,
1685        /// Enable the reverse proxy server for daemons
1686        enable: bool,
1687        /// Bind address for the reverse proxy server
1688        host: String,
1689        /// Enable HTTPS for the reverse proxy
1690        https: bool,
1691        /// Enable LAN mode for the reverse proxy
1692        lan: bool,
1693        /// Pin a specific LAN IP address instead of auto-detecting
1694        lan_ip: String,
1695        /// Port the reverse proxy server listens on
1696        port: i64,
1697        /// Automatically sync slug hostnames to /etc/hosts
1698        sync_hosts: bool,
1699        /// Top-level domain used for proxy URLs
1700        tld: String,
1701        /// Path to TLS certificate file (PEM format) for HTTPS proxy
1702        tls_cert: String,
1703        /// Path to TLS private key file (PEM format) for HTTPS proxy
1704        tls_key: String,
1705        /// Enable wildcard subdomain matching for proxy routes
1706        wildcard: bool,
1707        /// Compatibility alias for general.worktree
1708        worktree: bool,
1709    }
1710}
1711
1712settings_partial! {
1713    /// Partial mirror of [`SettingsSupervisor`].
1714    SettingsSupervisorPartial {
1715        /// Automatically start the supervisor when a client command needs it
1716        auto_start: bool,
1717        /// Reconcile orphaned daemon processes when supervisor starts
1718        cleanup_orphans: bool,
1719        /// Enable container/PID1 mode for running inside Docker containers
1720        container: bool,
1721        /// Consecutive CPU-over-limit samples before killing a daemon
1722        cpu_violation_threshold: i64,
1723        /// Interval for checking cron schedules
1724        cron_check_interval: String,
1725        /// File watch debounce duration
1726        file_watch_debounce: String,
1727        /// Default time between health probes
1728        health_check_interval: String,
1729        /// Default consecutive health-check failures before killing a daemon
1730        health_check_retries: i64,
1731        /// Default per-probe timeout for health_cmd
1732        health_cmd_timeout: String,
1733        /// Default per-request timeout for health_http
1734        health_http_timeout: String,
1735        /// Default per-connect timeout for health_port
1736        health_port_timeout: String,
1737        /// Timeout for HTTP ready checks
1738        http_client_timeout: String,
1739        /// Daemon log buffer flush interval
1740        log_flush_interval: String,
1741        /// What to do with live orphaned daemons on supervisor startup: adopt or kill
1742        orphan_policy: String,
1743        /// Maximum port increment attempts when auto_bump_port is enabled
1744        port_bump_attempts: i64,
1745        /// Interval between ready checks (HTTP, TCP, command)
1746        ready_check_interval: String,
1747        /// Delay between stop and start during restart
1748        restart_delay: String,
1749        /// Maximum time to wait for daemon to stop gracefully
1750        stop_timeout: String,
1751        /// Default user to run daemon processes as
1752        user: String,
1753        /// File watcher config refresh interval
1754        watch_interval: String,
1755        /// Polling watcher filesystem scan interval
1756        watch_poll_interval: String,
1757    }
1758}
1759
1760settings_partial! {
1761    /// Partial mirror of [`SettingsTui`].
1762    SettingsTuiPartial {
1763        /// Status message display duration
1764        message_duration: String,
1765        /// Daemon list refresh interval
1766        refresh_rate: String,
1767        /// Number of stat samples to keep for graphs
1768        stat_history: i64,
1769        /// Event loop tick rate
1770        tick_rate: String,
1771    }
1772}
1773
1774settings_partial! {
1775    /// Partial mirror of [`SettingsWeb`].
1776    SettingsWebPartial {
1777        /// Automatically start web UI when supervisor starts
1778        auto_start: bool,
1779        /// URL path prefix for the web UI (e.g. "ps" serves at /ps/)
1780        base_path: String,
1781        /// Web server bind address
1782        bind_address: String,
1783        /// Default web server port
1784        bind_port: i64,
1785        /// Initial number of log lines to display
1786        log_lines: i64,
1787        /// Number of ports to try if default is in use
1788        port_attempts: i64,
1789        /// Server-Sent Events poll interval for log streaming
1790        sse_poll_interval: String,
1791    }
1792}
1793
1794settings_partial! {
1795    /// Partial mirror of [`Settings`], for `[settings]` tables in
1796    /// pitchfork.toml files.
1797    SettingsPartial {
1798        @group api: SettingsApiPartial,
1799        @group general: SettingsGeneralPartial,
1800        @group ipc: SettingsIpcPartial,
1801        @group logs: SettingsLogsPartial,
1802        @group proxy: SettingsProxyPartial,
1803        @group supervisor: SettingsSupervisorPartial,
1804        @group tui: SettingsTuiPartial,
1805        @group web: SettingsWebPartial,
1806    }
1807}
1808
1809impl_has_any_set!(
1810    SettingsApiPartial,
1811    SettingsGeneralPartial,
1812    SettingsIpcPartial,
1813    SettingsLogsArchiveHookPartial,
1814    SettingsLogsPartial,
1815    SettingsProxyPartial,
1816    SettingsSupervisorPartial,
1817    SettingsTuiPartial,
1818    SettingsWebPartial,
1819);
1820
1821impl SettingsPartial {
1822    /// Move supported legacy keys to their canonical locations before a
1823    /// read-modify-write cycle serializes this partial again.
1824    pub(crate) fn canonicalize_aliases(&mut self) {
1825        if self.general.worktree.is_none() {
1826            self.general.worktree = self.proxy.worktree.take();
1827        } else {
1828            self.proxy.worktree = None;
1829        }
1830    }
1831}
1832
1833#[cfg(test)]
1834mod tests {
1835    use super::*;
1836    use std::time::Duration;
1837
1838    #[test]
1839    fn test_default_settings() {
1840        let settings = Settings::default();
1841
1842        // Test general settings
1843        assert_eq!(settings.general.autostop_delay, "1m");
1844        assert_eq!(settings.general.interval, "10s");
1845        assert_eq!(settings.general.log_level, "info");
1846        assert_eq!(settings.general.ready_delay, "3s");
1847
1848        // Test IPC settings
1849        assert_eq!(settings.ipc.connect_attempts, 5);
1850        assert_eq!(settings.ipc.request_timeout, "5s");
1851        assert_eq!(settings.ipc.rate_limit, 100);
1852
1853        // Test web settings
1854        assert!(!settings.web.auto_start);
1855        assert_eq!(settings.web.bind_address, "127.0.0.1");
1856        assert_eq!(settings.web.bind_port, 3120);
1857        assert_eq!(settings.web.log_lines, 100);
1858
1859        // Test TUI settings
1860        assert_eq!(settings.tui.refresh_rate, "2s");
1861        assert_eq!(settings.tui.stat_history, 60);
1862
1863        // Test supervisor settings
1864        assert!(settings.supervisor.auto_start);
1865        assert_eq!(settings.supervisor.ready_check_interval, "500ms");
1866        assert_eq!(settings.supervisor.file_watch_debounce, "1s");
1867        assert_eq!(settings.supervisor.user, "");
1868    }
1869
1870    #[test]
1871    fn a_bad_value_costs_only_its_own_setting() {
1872        // `Settings::read` is all or nothing, and the fallback for its failure used to be
1873        // `Settings::default()` — one field the merge could not hand to its type demoting all
1874        // sixty-eight settings and discarding the environment along with them.
1875        let env = EnvLayer::new([("PITCHFORK_LOG".to_string(), "debug".to_string())]);
1876        let mut resolved = resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&env)).unwrap();
1877
1878        // The one door into a resolution the merge does not check: a post-merge hook, which is
1879        // what `sanitize_durations` is. A bool where an integer belongs is what a buggy one
1880        // could write.
1881        let registry = Settings::SETTINGS_REGISTRY;
1882        let rate_limit = registry.lookup("ipc.rate_limit").unwrap().id;
1883        resolved.coerced(rate_limit, Value::Bool(true), "a hook that got it wrong");
1884
1885        assert!(
1886            Settings::read(&resolved).is_err(),
1887            "the strict read still refuses it"
1888        );
1889
1890        let (settings, errors) = Settings::read_lossy(&resolved);
1891        let settings = settings.expect("every setting declares a default");
1892        assert_eq!(
1893            settings.ipc.rate_limit, 100,
1894            "the bad field takes its declared default"
1895        );
1896        assert_eq!(
1897            settings.general.log_level, "debug",
1898            "and PITCHFORK_LOG is not collateral damage"
1899        );
1900        assert_eq!(errors.0.len(), 1, "{errors}");
1901        assert_eq!(errors.0[0].key, "ipc.rate_limit");
1902    }
1903
1904    #[test]
1905    fn every_setting_declares_a_default() {
1906        // What makes the `None` arm of the lossy read unreachable: a setting with no value and
1907        // no default is a hole in this file, not something a user can cause. Adding one without
1908        // a default should fail here rather than silently demote the whole struct at runtime.
1909        let missing: Vec<&str> = Settings::SETTINGS_PROPS
1910            .iter()
1911            .filter(|meta| meta.default.is_none())
1912            .map(|meta| meta.key)
1913            .collect();
1914        assert!(
1915            missing.is_empty(),
1916            "these settings declare no default: {missing:?}"
1917        );
1918    }
1919
1920    #[test]
1921    fn test_registry_matches_previous_schema() {
1922        // The emitted config block is the settings documentation now; make
1923        // sure the registry still declares what settings.toml used to.
1924        let keys: Vec<&str> = Settings::SETTINGS_PROPS
1925            .iter()
1926            .map(|meta| meta.key)
1927            .collect();
1928        assert_eq!(keys.len(), 74, "{keys:?}");
1929        assert!(keys.contains(&"general.autostop_delay"));
1930        assert!(keys.contains(&"logs.archive_hook.command"));
1931        assert!(keys.contains(&"supervisor.health_check_interval"));
1932        assert!(keys.contains(&"supervisor.watch_interval"));
1933
1934        let registry = Settings::SETTINGS_REGISTRY;
1935        let interval = registry.get(registry.lookup("general.interval").unwrap().id);
1936        assert_eq!(interval.envs, &["PITCHFORK_INTERVAL"]);
1937        assert_eq!(interval.deprecated_envs, &["PITCHFORK_INTERVAL_SECS"]);
1938        let watch = registry.get(registry.lookup("supervisor.watch_interval").unwrap().id);
1939        assert_eq!(watch.deprecated_envs, &["PITCHFORK_WATCH_INTERVAL_MS"]);
1940        let log_level = registry.get(registry.lookup("general.log_level").unwrap().id);
1941        assert_eq!(log_level.envs, &["PITCHFORK_LOG"]);
1942        let worktree = registry.get(registry.lookup("general.worktree").unwrap().id);
1943        assert_eq!(worktree.envs, &["PITCHFORK_WORKTREE"]);
1944        assert_eq!(worktree.deprecated_envs, &["PITCHFORK_PROXY_WORKTREE"]);
1945        assert_eq!(worktree.aliases, &["proxy.worktree"]);
1946        assert_eq!(
1947            registry.lookup("proxy.worktree").unwrap().id,
1948            registry.lookup("general.worktree").unwrap().id,
1949        );
1950
1951        let spec = Settings::spec_kdl();
1952        let files = [
1953            "file \"/etc/pitchfork/config.toml\" scope=\"system\" format=\"toml\"",
1954            "file \"~/.config/pitchfork/config.toml\" scope=\"global\" format=\"toml\"",
1955            "file \".config/pitchfork.toml\" findup=#true format=\"toml\"",
1956            "file \".config/pitchfork.local.toml\" findup=#true format=\"toml\"",
1957            "file \"pitchfork.toml\" findup=#true format=\"toml\"",
1958            "file \"pitchfork.local.toml\" findup=#true format=\"toml\"",
1959        ];
1960        let positions: Vec<usize> = files
1961            .iter()
1962            .map(|file| spec.find(file).unwrap_or_else(|| panic!("missing {file}")))
1963            .collect();
1964        assert!(
1965            positions.windows(2).all(|pair| pair[0] < pair[1]),
1966            "config files must be documented from lowest to highest precedence:\n{spec}"
1967        );
1968    }
1969
1970    #[test]
1971    fn every_registry_key_round_trips_through_the_partial() {
1972        // A setting added to the derive structs but missed in `settings_partial!`
1973        // makes `pitchfork settings set` report success and write nothing.
1974        for meta in Settings::SETTINGS_PROPS {
1975            let value = match meta.ty.inner() {
1976                Ty::Bool => toml::Value::Boolean(true),
1977                Ty::Int | Ty::Uint => toml::Value::Integer(1),
1978                _ => toml::Value::String("x".to_string()),
1979            };
1980            let parts: Vec<&str> = meta.key.split('.').collect();
1981            let mut table = toml::Table::new();
1982            let mut cursor = &mut table;
1983            for part in &parts[..parts.len() - 1] {
1984                cursor = cursor
1985                    .entry(part.to_string())
1986                    .or_insert_with(|| toml::Value::Table(toml::Table::new()))
1987                    .as_table_mut()
1988                    .unwrap();
1989            }
1990            cursor.insert(parts[parts.len() - 1].to_string(), value);
1991
1992            let partial: SettingsPartial = table.clone().try_into().unwrap();
1993            let back = toml::Table::try_from(&partial).unwrap();
1994            assert_eq!(
1995                back, table,
1996                "{} is declared in the registry but not mirrored in SettingsPartial",
1997                meta.key
1998            );
1999        }
2000    }
2001
2002    #[test]
2003    fn test_parse_duration() {
2004        assert_eq!(Settings::parse_duration("1s"), Some(Duration::from_secs(1)));
2005        assert_eq!(
2006            Settings::parse_duration("500ms"),
2007            Some(Duration::from_millis(500))
2008        );
2009        assert_eq!(
2010            Settings::parse_duration("1m"),
2011            Some(Duration::from_secs(60))
2012        );
2013        assert_eq!(
2014            Settings::parse_duration("2h"),
2015            Some(Duration::from_secs(7200))
2016        );
2017        assert_eq!(Settings::parse_duration("invalid"), None);
2018    }
2019
2020    #[test]
2021    fn test_env_override() {
2022        // The environment is described rather than reached for, so this test
2023        // does not mutate process env vars (the old test needed a mutex).
2024        let env = EnvLayer::new([
2025            ("PITCHFORK_AUTOSTOP_DELAY".to_string(), "10m".to_string()),
2026            ("PITCHFORK_INTERVAL".to_string(), "5s".to_string()),
2027            (
2028                "PITCHFORK_IPC_CONNECT_ATTEMPTS".to_string(),
2029                "20".to_string(),
2030            ),
2031            (
2032                "PITCHFORK_SUPERVISOR_AUTO_START".to_string(),
2033                "false".to_string(),
2034            ),
2035            ("PITCHFORK_WEB_AUTO_START".to_string(), "true".to_string()),
2036        ]);
2037        let resolved = resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&env)).unwrap();
2038        let settings = Settings::read(&resolved).unwrap();
2039
2040        assert_eq!(settings.general.autostop_delay, "10m");
2041        assert_eq!(settings.general.interval, "5s");
2042        assert_eq!(settings.ipc.connect_attempts, 20);
2043        assert!(!settings.supervisor.auto_start);
2044        assert!(settings.web.auto_start);
2045
2046        // Fields with no corresponding env var set remain at defaults
2047        assert_eq!(settings.general.log_level, "info");
2048        assert_eq!(settings.ipc.rate_limit, 100);
2049    }
2050
2051    #[test]
2052    fn test_deprecated_env_still_works_and_warns() {
2053        let env = EnvLayer::new([
2054            ("PITCHFORK_INTERVAL_SECS".to_string(), "30s".to_string()),
2055            ("PITCHFORK_PROXY_WORKTREE".to_string(), "false".to_string()),
2056        ]);
2057        let resolved = resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&env)).unwrap();
2058        let settings = Settings::read(&resolved).unwrap();
2059        assert_eq!(settings.general.interval, "30s");
2060        assert!(!settings.general.worktree);
2061        let warnings = usage_rs::config::explain::warnings(&resolved);
2062        assert!(
2063            warnings
2064                .iter()
2065                .any(|w| w.contains("PITCHFORK_INTERVAL_SECS is deprecated")),
2066            "{warnings:?}"
2067        );
2068        assert!(
2069            warnings
2070                .iter()
2071                .any(|w| w.contains("PITCHFORK_PROXY_WORKTREE is deprecated")),
2072            "{warnings:?}"
2073        );
2074
2075        // The current name wins over the deprecated alias when both are set.
2076        let env = EnvLayer::new([
2077            ("PITCHFORK_INTERVAL_SECS".to_string(), "30s".to_string()),
2078            ("PITCHFORK_INTERVAL".to_string(), "7s".to_string()),
2079            ("PITCHFORK_PROXY_WORKTREE".to_string(), "false".to_string()),
2080            ("PITCHFORK_WORKTREE".to_string(), "true".to_string()),
2081        ]);
2082        let resolved = resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&env)).unwrap();
2083        let settings = Settings::read(&resolved).unwrap();
2084        assert_eq!(settings.general.interval, "7s");
2085        assert!(settings.general.worktree);
2086    }
2087
2088    #[test]
2089    fn test_invalid_duration_from_env_falls_back_to_default() {
2090        // The previous loader rejected invalid durations at load time; the
2091        // sanitize pass reproduces that through Resolved::coerced.
2092        let env = EnvLayer::new([(
2093            "PITCHFORK_AUTOSTOP_DELAY".to_string(),
2094            "not_a_duration".to_string(),
2095        )]);
2096        let mut resolved = resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&env)).unwrap();
2097        sanitize_durations(&mut resolved);
2098        let settings = Settings::read(&resolved).unwrap();
2099        assert_eq!(settings.general.autostop_delay, "1m");
2100        assert_eq!(settings.general_autostop_delay(), Duration::from_secs(60));
2101    }
2102
2103    #[test]
2104    fn test_zero_health_timeout_from_env_falls_back_to_default() {
2105        // A zero health probe timeout goes straight into tokio::time::timeout
2106        // and fails every probe immediately; the sanitize pass falls back to
2107        // the schema default like any other invalid duration.
2108        let env = EnvLayer::new([
2109            ("PITCHFORK_HEALTH_CMD_TIMEOUT".to_string(), "0s".to_string()),
2110            (
2111                "PITCHFORK_HEALTH_HTTP_TIMEOUT".to_string(),
2112                "0s".to_string(),
2113            ),
2114            (
2115                "PITCHFORK_HEALTH_PORT_TIMEOUT".to_string(),
2116                "0s".to_string(),
2117            ),
2118        ]);
2119        let mut resolved = resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&env)).unwrap();
2120        sanitize_durations(&mut resolved);
2121        let settings = Settings::read(&resolved).unwrap();
2122        assert_eq!(settings.supervisor.health_cmd_timeout, "10s");
2123        assert_eq!(
2124            settings.supervisor_health_cmd_timeout(),
2125            Duration::from_secs(10)
2126        );
2127        assert_eq!(settings.supervisor.health_http_timeout, "5s");
2128        assert_eq!(settings.supervisor.health_port_timeout, "5s");
2129    }
2130
2131    #[test]
2132    fn test_invalid_duration_fallback() {
2133        let mut settings = Settings::default();
2134
2135        // Set invalid duration values
2136        settings.general.autostop_delay = "invalid".to_string();
2137        settings.general.interval = "not_a_duration".to_string();
2138
2139        // Convenience methods should fallback to default values
2140        assert_eq!(settings.general_autostop_delay(), Duration::from_secs(60)); // default "1m"
2141        assert_eq!(settings.general_interval(), Duration::from_secs(10)); // default "10s"
2142    }
2143
2144    #[test]
2145    fn test_duration_methods_all_fields() {
2146        let settings = Settings::default();
2147
2148        assert_eq!(settings.general_autostop_delay(), Duration::from_secs(60));
2149        assert_eq!(settings.general_interval(), Duration::from_secs(10));
2150        assert_eq!(settings.general_ready_delay(), Duration::from_secs(3));
2151        assert_eq!(settings.ipc_connect_min_delay(), Duration::from_millis(100));
2152        assert_eq!(settings.ipc_connect_max_delay(), Duration::from_secs(1));
2153        assert_eq!(settings.ipc_request_timeout(), Duration::from_secs(5));
2154        assert_eq!(settings.ipc_rate_limit_window(), Duration::from_secs(1));
2155        assert_eq!(settings.web_sse_poll_interval(), Duration::from_millis(500));
2156        assert_eq!(settings.tui_refresh_rate(), Duration::from_secs(2));
2157        assert_eq!(settings.tui_tick_rate(), Duration::from_millis(100));
2158        assert_eq!(settings.tui_message_duration(), Duration::from_secs(3));
2159        assert_eq!(
2160            settings.supervisor_ready_check_interval(),
2161            Duration::from_millis(500)
2162        );
2163        assert_eq!(
2164            settings.supervisor_file_watch_debounce(),
2165            Duration::from_secs(1)
2166        );
2167        assert_eq!(
2168            settings.supervisor_log_flush_interval(),
2169            Duration::from_millis(500)
2170        );
2171        assert_eq!(settings.supervisor_stop_timeout(), Duration::from_secs(5));
2172        assert_eq!(
2173            settings.supervisor_restart_delay(),
2174            Duration::from_millis(100)
2175        );
2176        assert_eq!(
2177            settings.supervisor_cron_check_interval(),
2178            Duration::from_secs(10)
2179        );
2180        assert_eq!(
2181            settings.supervisor_http_client_timeout(),
2182            Duration::from_secs(5)
2183        );
2184    }
2185
2186    #[test]
2187    fn test_general_ready_delay_secs() {
2188        let mut settings = Settings::default();
2189
2190        // Default "3s" resolves to whole seconds.
2191        assert_eq!(settings.general_ready_delay_secs(), Ok(3));
2192
2193        // Subsecond values are rejected, not silently truncated by as_secs().
2194        settings.general.ready_delay = "500ms".to_string();
2195        let err = settings.general_ready_delay_secs().unwrap_err();
2196        assert!(err.contains("500ms"), "unexpected error: {err}");
2197        assert!(
2198            err.contains("whole number of seconds"),
2199            "unexpected error: {err}"
2200        );
2201
2202        settings.general.ready_delay = "1.5s".to_string();
2203        let err = settings.general_ready_delay_secs().unwrap_err();
2204        assert!(err.contains("1.5s"), "unexpected error: {err}");
2205
2206        // Empty string falls back to the schema default "3s".
2207        settings.general.ready_delay = String::new();
2208        assert_eq!(settings.general_ready_delay_secs(), Ok(3));
2209
2210        // Whole-second values resolve as seconds.
2211        settings.general.ready_delay = "90s".to_string();
2212        assert_eq!(settings.general_ready_delay_secs(), Ok(90));
2213    }
2214
2215    /// A directory tree, cleaned up when the test ends.
2216    struct Tree(std::path::PathBuf);
2217
2218    impl Tree {
2219        fn new(name: &str) -> Self {
2220            let dir = std::env::temp_dir()
2221                .join(format!("pitchfork_settings_{}_{name}", std::process::id()));
2222            let _ = std::fs::remove_dir_all(&dir);
2223            std::fs::create_dir_all(&dir).unwrap();
2224            Self(dir)
2225        }
2226
2227        fn write(&self, rel: &str, text: &str) -> std::path::PathBuf {
2228            let path = self.0.join(rel);
2229            if let Some(parent) = path.parent() {
2230                std::fs::create_dir_all(parent).unwrap();
2231            }
2232            std::fs::write(&path, text).unwrap();
2233            path
2234        }
2235    }
2236
2237    impl Drop for Tree {
2238        fn drop(&mut self) {
2239            let _ = std::fs::remove_dir_all(&self.0);
2240        }
2241    }
2242
2243    #[test]
2244    fn test_settings_read_from_pitchfork_toml_settings_table() {
2245        let tree = Tree::new("table");
2246        let path = tree.write(
2247            "pitchfork.toml",
2248            r#"
2249[daemons.myapp]
2250run = "node server.js"
2251
2252[settings.general]
2253autostop_delay = "5m"
2254log_level = "debug"
2255
2256[settings.web]
2257auto_start = true
2258bind_port = 8080
2259
2260[settings.supervisor]
2261auto_start = false
2262user = "postgres"
2263"#,
2264        );
2265        let layer = FileLayer::at(&path, FileScope::Project).under("settings");
2266        let resolved = resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&layer)).unwrap();
2267        let settings = Settings::read(&resolved).unwrap();
2268
2269        assert_eq!(settings.general.autostop_delay, "5m");
2270        assert_eq!(settings.general.log_level, "debug");
2271        assert!(settings.web.auto_start);
2272        assert_eq!(settings.web.bind_port, 8080);
2273        assert!(!settings.supervisor.auto_start);
2274        assert_eq!(settings.supervisor.user, "postgres");
2275        // Unset fields fall back to defaults, and [daemons] is not a warning.
2276        assert_eq!(settings.general.interval, "10s");
2277        assert_eq!(settings.ipc.connect_attempts, 5);
2278        assert!(resolved.warnings.is_empty(), "{:?}", resolved.warnings);
2279    }
2280
2281    #[test]
2282    fn test_proxy_worktree_config_alias_still_works() {
2283        let tree = Tree::new("proxy_worktree_alias");
2284        let path = tree.write("pitchfork.toml", "[settings.proxy]\nworktree = false\n");
2285        let layer = FileLayer::at(&path, FileScope::Project).under("settings");
2286        let resolved = resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&layer)).unwrap();
2287        let settings = Settings::read(&resolved).unwrap();
2288
2289        assert!(!settings.general.worktree);
2290        assert_eq!(
2291            resolved.get(
2292                Settings::SETTINGS_REGISTRY
2293                    .lookup("general.worktree")
2294                    .unwrap()
2295                    .id
2296            ),
2297            Some(&Value::Bool(false)),
2298        );
2299    }
2300
2301    #[test]
2302    fn test_explicit_default_value_in_higher_file_still_overrides() {
2303        // "Bug 5": the old merge_from skipped values equal to the default, so
2304        // a higher-precedence file explicitly setting log_level = "info"
2305        // could not override "warn" from a lower one. usage-config's
2306        // origin-tracked merge does this natively; pin it.
2307        let tree = Tree::new("bug5");
2308        let lower = tree.write("lower.toml", "[settings.general]\nlog_level = \"warn\"\n");
2309        let higher = tree.write("higher.toml", "[settings.general]\nlog_level = \"info\"\n");
2310        let lower = FileLayer::at(&lower, FileScope::Global).under("settings");
2311        let higher = FileLayer::at(&higher, FileScope::Project).under("settings");
2312        let resolved = resolve(
2313            Settings::SETTINGS_REGISTRY,
2314            Layers::new().then(&higher).then(&lower),
2315        )
2316        .unwrap();
2317        let settings = Settings::read(&resolved).unwrap();
2318        assert_eq!(settings.general.log_level, "info");
2319
2320        // And the provenance says the higher file set it, not the default.
2321        let id = Settings::SETTINGS_REGISTRY
2322            .lookup("general.log_level")
2323            .unwrap()
2324            .id;
2325        let origin = resolved.origin(id).unwrap().describe();
2326        assert!(origin.contains("higher.toml"), "{origin}");
2327    }
2328
2329    #[test]
2330    fn test_file_precedence_local_over_base_and_nearer_dir_wins() {
2331        let tree = Tree::new("precedence");
2332        tree.write("pitchfork.toml", "[settings.general]\ninterval = \"9s\"\n");
2333        tree.write(
2334            "pitchfork.local.toml",
2335            "[settings.general]\ninterval = \"2s\"\n",
2336        );
2337        tree.write(
2338            ".config/pitchfork.toml",
2339            "[settings.general]\ninterval = \"7s\"\n[settings.web]\nbind_port = 9999\n",
2340        );
2341        let layers = Settings::file_layers_from(&tree.0);
2342        let mut chain = Layers::new();
2343        for layer in &layers {
2344            chain = chain.then(layer);
2345        }
2346        let resolved = resolve(Settings::SETTINGS_REGISTRY, chain).unwrap();
2347        let settings = Settings::read(&resolved).unwrap();
2348        // pitchfork.local.toml > pitchfork.toml > .config/pitchfork.toml
2349        assert_eq!(settings.general.interval, "2s");
2350        // A key only the lowest file sets still applies.
2351        assert_eq!(settings.web.bind_port, 9999);
2352
2353        // A nested directory's file outranks everything above it.
2354        let deep = tree.0.join("a").join("b");
2355        std::fs::create_dir_all(&deep).unwrap();
2356        tree.write(
2357            "a/b/pitchfork.toml",
2358            "[settings.general]\ninterval = \"1s\"\n",
2359        );
2360        let layers = Settings::file_layers_from(&deep);
2361        let mut chain = Layers::new();
2362        for layer in &layers {
2363            chain = chain.then(layer);
2364        }
2365        let resolved = resolve(Settings::SETTINGS_REGISTRY, chain).unwrap();
2366        let settings = Settings::read(&resolved).unwrap();
2367        assert_eq!(settings.general.interval, "1s");
2368    }
2369
2370    #[test]
2371    fn test_broken_file_is_skipped_rather_than_fatal() {
2372        let tree = Tree::new("broken");
2373        let broken = tree.write("pitchfork.toml", "[invalid toml [[");
2374        // The broken file is skipped with a warning, so the resolution still
2375        // happens and produces the defaults.
2376        let layers = Settings::file_layers_from(&tree.0);
2377        assert!(
2378            layers.iter().all(|layer| !layer.paths().contains(&broken)),
2379            "the broken project file must not become a settings layer"
2380        );
2381    }
2382
2383    #[test]
2384    fn test_unknown_settings_keys_do_not_fail_the_load() {
2385        // Forward compatibility: a config written for a newer pitchfork must
2386        // still load. Unknown keys are warned about rather than fatal.
2387        let tree = Tree::new("unknown");
2388        let path = tree.write(
2389            "pitchfork.toml",
2390            "[settings.general]\nlog_level = \"debug\"\nfrom_the_future = true\n",
2391        );
2392        let layer = FileLayer::at(&path, FileScope::Project).under("settings");
2393        let resolved = resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&layer)).unwrap();
2394        let settings = Settings::read(&resolved).unwrap();
2395        assert_eq!(settings.general.log_level, "debug");
2396        assert_eq!(resolved.warnings.len(), 1, "{:?}", resolved.warnings);
2397    }
2398
2399    #[test]
2400    fn test_partial_merge_from() {
2401        let mut base = SettingsPartial::default();
2402        base.general.log_level = Some("warn".to_string());
2403        base.web.bind_address = Some("0.0.0.0".to_string());
2404
2405        let mut overlay = SettingsPartial::default();
2406        overlay.general.log_level = Some("debug".to_string());
2407        overlay.tui.refresh_rate = Some("1s".to_string());
2408
2409        base.merge_from(&overlay);
2410        assert_eq!(base.general.log_level.as_deref(), Some("debug"));
2411        assert_eq!(base.web.bind_address.as_deref(), Some("0.0.0.0"));
2412        assert_eq!(base.tui.refresh_rate.as_deref(), Some("1s"));
2413
2414        // An empty partial changes nothing.
2415        let sealed = base.clone();
2416        base.merge_from(&SettingsPartial::default());
2417        assert_eq!(base.general.log_level, sealed.general.log_level);
2418        assert!(SettingsPartial::default().is_empty());
2419        assert!(base.has_any_set());
2420    }
2421
2422    #[test]
2423    fn test_partial_serialization_skips_unset() {
2424        let mut partial = SettingsPartial::default();
2425        partial.general.interval = Some("5s".to_string());
2426        let toml = toml::to_string_pretty(&partial).unwrap();
2427        assert_eq!(toml, "[general]\ninterval = \"5s\"\n");
2428    }
2429}