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