Skip to main content

pitchfork_cli/
settings.rs

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