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