Skip to main content

pitchfork_cli/
settings.rs

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