use std::path::{Path, PathBuf};
use std::sync::Arc;
use usage_rs::config::{
Const, EnvLayer, FileLayer, FileScope, Layers, Resolved, Ty, Value, resolve,
};
#[derive(usage_rs::Config, Debug, Clone, PartialEq)]
#[usage(prefix = "api")]
pub struct SettingsApi {
#[usage(env = "PITCHFORK_API_AUTO_START", default = false)]
pub auto_start: bool,
#[usage(env = "PITCHFORK_API_BIND_ADDRESS", default = "127.0.0.1")]
pub bind_address: String,
#[usage(env = "PITCHFORK_API_BIND_PORT", default = 0)]
pub bind_port: i64,
#[usage(env = "PITCHFORK_API_PORT_ATTEMPTS", default = 10)]
pub port_attempts: i64,
#[usage(env = "PITCHFORK_API_TOKEN", default = "")]
pub token: String,
}
#[derive(usage_rs::Config, Debug, Clone, PartialEq)]
#[usage(prefix = "general")]
pub struct SettingsGeneral {
#[usage(env = "PITCHFORK_AUTOSTOP_DELAY", default = "1m", ty = "duration")]
pub autostop_delay: String,
#[usage(
env = "PITCHFORK_INTERVAL",
deprecated_env("PITCHFORK_INTERVAL_SECS"),
default = "10s",
ty = "duration"
)]
pub interval: String,
#[usage(env = "PITCHFORK_LOG_FILE_LEVEL", default = "info")]
pub log_file_level: String,
#[usage(env = "PITCHFORK_LOG", default = "info")]
pub log_level: String,
#[usage(env = "PITCHFORK_MISE", default = false)]
pub mise: bool,
#[usage(env = "PITCHFORK_MISE_BIN", default = "")]
pub mise_bin: String,
#[usage(env = "PITCHFORK_SHELL", default = "sh -c")]
pub shell: String,
#[usage(env = "PITCHFORK_STARTUP_LOG_TIMESTAMPS", default = false)]
pub startup_log_timestamps: bool,
#[usage(env = "PITCHFORK_READY_DELAY", default = "3s", ty = "duration")]
pub ready_delay: String,
#[usage(
env = "PITCHFORK_WORKTREE",
deprecated_env("PITCHFORK_PROXY_WORKTREE"),
alias("proxy.worktree"),
default = true
)]
pub worktree: bool,
}
#[derive(usage_rs::Config, Debug, Clone, PartialEq)]
#[usage(prefix = "ipc")]
pub struct SettingsIpc {
#[usage(env = "PITCHFORK_IPC_CONNECT_ATTEMPTS", default = 5)]
pub connect_attempts: i64,
#[usage(
env = "PITCHFORK_IPC_CONNECT_MAX_DELAY",
default = "1s",
ty = "duration"
)]
pub connect_max_delay: String,
#[usage(
env = "PITCHFORK_IPC_CONNECT_MIN_DELAY",
default = "100ms",
ty = "duration"
)]
pub connect_min_delay: String,
#[usage(env = "PITCHFORK_IPC_RATE_LIMIT", default = 100)]
pub rate_limit: i64,
#[usage(
env = "PITCHFORK_IPC_RATE_LIMIT_WINDOW",
default = "1s",
ty = "duration"
)]
pub rate_limit_window: String,
#[usage(env = "PITCHFORK_IPC_REQUEST_TIMEOUT", default = "5s", ty = "duration")]
pub request_timeout: String,
}
#[derive(usage_rs::Config, Debug, Clone, PartialEq)]
#[usage(prefix = "logs.archive_hook")]
pub struct SettingsLogsArchiveHook {
#[usage(env = "PITCHFORK_LOG_ARCHIVE_HOOK_BATCH_SIZE", default = 1000)]
pub batch_size: i64,
#[usage(env = "PITCHFORK_LOG_ARCHIVE_HOOK_COMMAND", default = "")]
pub command: String,
}
#[derive(usage_rs::Config, Debug, Clone, PartialEq)]
#[usage(prefix = "logs")]
pub struct SettingsLogs {
#[usage(flatten)]
pub archive_hook: SettingsLogsArchiveHook,
#[usage(env = "PITCHFORK_LOG_LINE_RETENTION", default = 0)]
pub line_retention: i64,
#[usage(env = "PITCHFORK_LOG_FORMAT", default = "text")]
pub log_format: String,
#[usage(env = "PITCHFORK_LOG_TIME_RETENTION", default = "", ty = "duration")]
pub time_retention: String,
#[usage(env = "PITCHFORK_LOG_TIMESTAMP", default = true)]
pub timestamp: bool,
#[usage(env = "PITCHFORK_LOG_TIMESTAMP_FORMAT", default = "%m-%d %H:%M:%S")]
pub timestamp_format: String,
}
#[derive(usage_rs::Config, Debug, Clone, PartialEq)]
#[usage(prefix = "proxy")]
pub struct SettingsProxy {
#[usage(env = "PITCHFORK_PROXY_AUTO_START", default = true)]
pub auto_start: bool,
#[usage(
env = "PITCHFORK_PROXY_AUTO_START_TIMEOUT",
default = "30s",
ty = "duration"
)]
pub auto_start_timeout: String,
#[usage(env = "PITCHFORK_PROXY_AUTO_TRUST", default = true)]
pub auto_trust: bool,
#[usage(env = "PITCHFORK_PROXY_ENABLE", default = false)]
pub enable: bool,
#[usage(env = "PITCHFORK_PROXY_HOST", default = "127.0.0.1")]
pub host: String,
#[usage(env = "PITCHFORK_PROXY_HTTPS", default = true)]
pub https: bool,
#[usage(env = "PITCHFORK_PROXY_LAN", default = false)]
pub lan: bool,
#[usage(env = "PITCHFORK_PROXY_LAN_IP", default = "")]
pub lan_ip: String,
#[usage(env = "PITCHFORK_PROXY_PORT", default = 443)]
pub port: i64,
#[usage(env = "PITCHFORK_PROXY_SYNC_HOSTS", default = true)]
pub sync_hosts: bool,
#[usage(env = "PITCHFORK_PROXY_TLD", default = "localhost")]
pub tld: String,
#[usage(env = "PITCHFORK_PROXY_TLS_CERT", default = "")]
pub tls_cert: String,
#[usage(env = "PITCHFORK_PROXY_TLS_KEY", default = "")]
pub tls_key: String,
#[usage(env = "PITCHFORK_PROXY_WILDCARD", default = true)]
pub wildcard: bool,
}
#[derive(usage_rs::Config, Debug, Clone, PartialEq)]
#[usage(prefix = "supervisor")]
pub struct SettingsSupervisor {
#[usage(env = "PITCHFORK_SUPERVISOR_AUTO_START", default = true)]
pub auto_start: bool,
#[usage(env = "PITCHFORK_CLEANUP_ORPHANS", default = true)]
pub cleanup_orphans: bool,
#[usage(env = "PITCHFORK_CONTAINER", default = false)]
pub container: bool,
#[usage(env = "PITCHFORK_CPU_VIOLATION_THRESHOLD", default = 3)]
pub cpu_violation_threshold: i64,
#[usage(
env = "PITCHFORK_CRON_CHECK_INTERVAL",
default = "10s",
ty = "duration"
)]
pub cron_check_interval: String,
#[usage(env = "PITCHFORK_FILE_WATCH_DEBOUNCE", default = "1s", ty = "duration")]
pub file_watch_debounce: String,
#[usage(
env = "PITCHFORK_HEALTH_CHECK_INTERVAL",
default = "10s",
ty = "duration"
)]
pub health_check_interval: String,
#[usage(env = "PITCHFORK_HEALTH_CHECK_RETRIES", default = 3)]
pub health_check_retries: i64,
#[usage(env = "PITCHFORK_HEALTH_CMD_TIMEOUT", default = "10s", ty = "duration")]
pub health_cmd_timeout: String,
#[usage(env = "PITCHFORK_HEALTH_HTTP_TIMEOUT", default = "5s", ty = "duration")]
pub health_http_timeout: String,
#[usage(env = "PITCHFORK_HEALTH_PORT_TIMEOUT", default = "5s", ty = "duration")]
pub health_port_timeout: String,
#[usage(env = "PITCHFORK_HTTP_CLIENT_TIMEOUT", default = "5s", ty = "duration")]
pub http_client_timeout: String,
#[usage(
env = "PITCHFORK_LOG_FLUSH_INTERVAL",
default = "500ms",
ty = "duration"
)]
pub log_flush_interval: String,
#[usage(env = "PITCHFORK_ORPHAN_POLICY", default = "adopt")]
pub orphan_policy: String,
#[usage(env = "PITCHFORK_PORT_BUMP_ATTEMPTS", default = 10)]
pub port_bump_attempts: i64,
#[usage(
env = "PITCHFORK_READY_CHECK_INTERVAL",
default = "500ms",
ty = "duration"
)]
pub ready_check_interval: String,
#[usage(env = "PITCHFORK_RESTART_DELAY", default = "100ms", ty = "duration")]
pub restart_delay: String,
#[usage(env = "PITCHFORK_STOP_TIMEOUT", default = "5s", ty = "duration")]
pub stop_timeout: String,
#[usage(env = "PITCHFORK_USER", default = "")]
pub user: String,
#[usage(
env = "PITCHFORK_WATCH_INTERVAL",
deprecated_env("PITCHFORK_WATCH_INTERVAL_MS"),
default = "10s",
ty = "duration"
)]
pub watch_interval: String,
#[usage(
env = "PITCHFORK_WATCH_POLL_INTERVAL",
default = "500ms",
ty = "duration"
)]
pub watch_poll_interval: String,
}
#[derive(usage_rs::Config, Debug, Clone, PartialEq)]
#[usage(prefix = "tui")]
pub struct SettingsTui {
#[usage(
env = "PITCHFORK_TUI_MESSAGE_DURATION",
default = "3s",
ty = "duration"
)]
pub message_duration: String,
#[usage(env = "PITCHFORK_TUI_REFRESH_RATE", default = "2s", ty = "duration")]
pub refresh_rate: String,
#[usage(env = "PITCHFORK_TUI_STAT_HISTORY", default = 60)]
pub stat_history: i64,
#[usage(env = "PITCHFORK_TUI_TICK_RATE", default = "100ms", ty = "duration")]
pub tick_rate: String,
}
#[derive(usage_rs::Config, Debug, Clone, PartialEq)]
#[usage(prefix = "web")]
pub struct SettingsWeb {
#[usage(env = "PITCHFORK_WEB_AUTO_START", default = false)]
pub auto_start: bool,
#[usage(env = "PITCHFORK_WEB_PATH", default = "")]
pub base_path: String,
#[usage(env = "PITCHFORK_WEB_BIND_ADDRESS", default = "127.0.0.1")]
pub bind_address: String,
#[usage(env = "PITCHFORK_WEB_BIND_PORT", default = 3120)]
pub bind_port: i64,
#[usage(env = "PITCHFORK_WEB_LOG_LINES", default = 100)]
pub log_lines: i64,
#[usage(env = "PITCHFORK_WEB_PORT_ATTEMPTS", default = 10)]
pub port_attempts: i64,
#[usage(
env = "PITCHFORK_WEB_SSE_POLL_INTERVAL",
default = "500ms",
ty = "duration"
)]
pub sse_poll_interval: String,
}
#[derive(usage_rs::Config, Debug, Clone, PartialEq)]
#[allow(clippy::duplicated_attributes)]
#[usage(
file(path = "/etc/pitchfork/config.toml", scope = "system", format = "toml"),
file(
path = "~/.config/pitchfork/config.toml",
scope = "global",
format = "toml"
),
file(
path = ".config/pitchfork.toml",
findup,
scope = "project",
format = "toml"
),
file(
path = ".config/pitchfork.local.toml",
findup,
scope = "project",
format = "toml"
),
file(path = "pitchfork.toml", findup, scope = "project", format = "toml"),
file(
path = "pitchfork.local.toml",
findup,
scope = "project",
format = "toml"
)
)]
pub struct Settings {
#[usage(flatten)]
pub api: SettingsApi,
#[usage(flatten)]
pub general: SettingsGeneral,
#[usage(flatten)]
pub ipc: SettingsIpc,
#[usage(flatten)]
pub logs: SettingsLogs,
#[usage(flatten)]
pub proxy: SettingsProxy,
#[usage(flatten)]
pub supervisor: SettingsSupervisor,
#[usage(flatten)]
pub tui: SettingsTui,
#[usage(flatten)]
pub web: SettingsWeb,
}
impl Default for Settings {
fn default() -> Self {
let resolved = resolve(Settings::SETTINGS_REGISTRY, Layers::new())
.expect("no layers were given, so there is nothing to fail");
Settings::read(&resolved).expect("every pitchfork setting has a declared default")
}
}
macro_rules! duration_getters {
($($name:ident => $group:ident . $field:ident, $default:literal;)+) => {
impl Settings {
$(
#[doc = concat!("Get `", stringify!($group), ".", stringify!($field), "` as Duration")]
#[allow(dead_code)]
pub fn $name(&self) -> std::time::Duration {
Self::parse_duration(&self.$group.$field).unwrap_or_else(|| {
humantime::parse_duration($default)
.unwrap_or(std::time::Duration::from_secs(1))
})
}
)+
}
};
}
duration_getters! {
general_autostop_delay => general.autostop_delay, "1m";
general_interval => general.interval, "10s";
general_ready_delay => general.ready_delay, "3s";
ipc_connect_max_delay => ipc.connect_max_delay, "1s";
ipc_connect_min_delay => ipc.connect_min_delay, "100ms";
ipc_rate_limit_window => ipc.rate_limit_window, "1s";
ipc_request_timeout => ipc.request_timeout, "5s";
logs_time_retention => logs.time_retention, "";
proxy_auto_start_timeout => proxy.auto_start_timeout, "30s";
supervisor_cron_check_interval => supervisor.cron_check_interval, "10s";
supervisor_file_watch_debounce => supervisor.file_watch_debounce, "1s";
supervisor_health_check_interval => supervisor.health_check_interval, "10s";
supervisor_health_cmd_timeout => supervisor.health_cmd_timeout, "10s";
supervisor_health_http_timeout => supervisor.health_http_timeout, "5s";
supervisor_health_port_timeout => supervisor.health_port_timeout, "5s";
supervisor_http_client_timeout => supervisor.http_client_timeout, "5s";
supervisor_log_flush_interval => supervisor.log_flush_interval, "500ms";
supervisor_ready_check_interval => supervisor.ready_check_interval, "500ms";
supervisor_restart_delay => supervisor.restart_delay, "100ms";
supervisor_stop_timeout => supervisor.stop_timeout, "5s";
supervisor_watch_interval => supervisor.watch_interval, "10s";
supervisor_watch_poll_interval => supervisor.watch_poll_interval, "500ms";
tui_message_duration => tui.message_duration, "3s";
tui_refresh_rate => tui.refresh_rate, "2s";
tui_tick_rate => tui.tick_rate, "100ms";
web_sse_poll_interval => web.sse_poll_interval, "500ms";
}
impl Settings {
pub fn general_ready_delay_secs(&self) -> Result<u64, String> {
let delay = self.general_ready_delay();
if delay.subsec_nanos() != 0 {
return Err(format!(
"settings.general.ready_delay must be a whole number of seconds, got \"{}\"",
self.general.ready_delay
));
}
Ok(delay.as_secs())
}
}
impl Settings {
#[allow(dead_code)]
pub fn load() -> Self {
Self::load_from_dir(&crate::env::CWD)
}
pub fn load_from_dir(start_dir: &Path) -> Self {
Self::resolve_from_dir(start_dir).0
}
pub(crate) fn resolve_from_dir(start_dir: &Path) -> (Self, Resolved) {
let env_layer = EnvLayer::from_process();
let file_layers = Self::file_layers_from(start_dir);
let mut layers = Layers::new().then(&env_layer);
for layer in &file_layers {
layers = layers.then(layer);
}
let mut resolved = match resolve(Settings::SETTINGS_REGISTRY, layers) {
Ok(resolved) => resolved,
Err(e) => {
eprintln!("pitchfork: warning: failed to resolve settings from config files: {e}");
resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&env_layer)).unwrap_or_else(
|_| {
resolve(Settings::SETTINGS_REGISTRY, Layers::new())
.expect("resolving only defaults cannot fail")
},
)
}
};
sanitize_durations(&mut resolved);
for warning in usage_rs::config::explain::warnings(&resolved) {
warn!("{warning}");
}
let (settings, errors) = Settings::read_lossy(&resolved);
for error in &errors.0 {
warn!("{error}");
}
let settings = settings.unwrap_or_else(|| {
warn!("a setting has no value and no default; falling back to built-in defaults");
Settings::default()
});
(settings, resolved)
}
fn file_layers_from(start_dir: &Path) -> Vec<FileLayer> {
let mut candidates: Vec<(PathBuf, FileScope)> = xx::file::find_up_all(
start_dir,
&[
"pitchfork.local.toml",
"pitchfork.toml",
".config/pitchfork.local.toml",
".config/pitchfork.toml",
],
)
.into_iter()
.map(|path| (path, FileScope::Project))
.collect();
let extras = crate::extra_configs::paths_for(start_dir)
.into_iter()
.rev()
.map(|p| (p, FileScope::Project));
candidates.splice(0..0, extras);
candidates.push((
crate::env::PITCHFORK_GLOBAL_CONFIG_USER.clone(),
FileScope::Global,
));
candidates.push((
crate::env::PITCHFORK_GLOBAL_CONFIG_SYSTEM.clone(),
FileScope::System,
));
candidates
.into_iter()
.filter(|(path, _)| readable_settings_file(path))
.map(|(path, scope)| FileLayer::at(path, scope).under("settings"))
.collect()
}
pub fn parse_duration(s: &str) -> Option<std::time::Duration> {
humantime::parse_duration(s).ok()
}
pub fn resolve_mise_bin(&self) -> Option<std::path::PathBuf> {
if !self.general.mise_bin.is_empty() {
let p = PathBuf::from(&self.general.mise_bin);
if p.is_file() {
return Some(p);
}
warn!(
"mise_bin is set to {:?} but the file does not exist",
self.general.mise_bin
);
return None;
}
let home = crate::env::HOME_DIR.as_path();
let candidates = [
home.join(".local/bin/mise"),
home.join(".cargo/bin/mise"),
PathBuf::from("/usr/local/bin/mise"),
PathBuf::from("/opt/homebrew/bin/mise"),
];
candidates.into_iter().find(|p| p.is_file())
}
pub fn default_port_bump_attempts(&self) -> u32 {
let v = u32::try_from(self.supervisor.port_bump_attempts).unwrap_or_else(|_| {
warn!(
"supervisor.port_bump_attempts value {} is out of range (0-{}), clamping to 10",
self.supervisor.port_bump_attempts,
u32::MAX
);
10
});
if v == 0 {
warn!("supervisor.port_bump_attempts is 0; defaulting to 1");
1
} else {
v
}
}
}
fn readable_settings_file(path: &Path) -> bool {
if !path.exists() {
return false;
}
let content = match std::fs::read_to_string(path) {
Ok(content) => content,
Err(e) => {
eprintln!(
"pitchfork: warning: failed to read {}: {}",
path.display(),
e
);
return false;
}
};
let table: toml::Table = match content.parse() {
Ok(table) => table,
Err(e) => {
eprintln!(
"pitchfork: warning: failed to parse {}: {}",
path.display(),
e
);
return false;
}
};
match table.get("settings") {
None => true,
Some(toml::Value::Table(_)) => true,
Some(_) => {
eprintln!(
"pitchfork: warning: invalid [settings] in {}: not a table",
path.display()
);
false
}
}
}
const NON_ZERO_DURATION_KEYS: &[&str] = &[
"supervisor.health_cmd_timeout",
"supervisor.health_http_timeout",
"supervisor.health_port_timeout",
];
fn sanitize_durations(resolved: &mut Resolved) {
let registry = Settings::SETTINGS_REGISTRY;
for id in registry.ids() {
let meta = registry.get(id);
if !matches!(meta.ty.inner(), Ty::Duration) {
continue;
}
let Some(Value::String(text)) = resolved.get(id) else {
continue;
};
let default_text = match meta.default {
Some(Const::Str(s)) => s,
_ => "",
};
if text == default_text {
continue;
}
let reason = match humantime::parse_duration(text) {
Err(_) => Some(format!("invalid duration {text:?}")),
Ok(d) if d.is_zero() && NON_ZERO_DURATION_KEYS.contains(&meta.key) => Some(format!(
"supervisor health probe timeout must be greater than 0, got {text:?}"
)),
Ok(_) => None,
};
let Some(reason) = reason else {
continue;
};
let origin = resolved
.origin(id)
.map(|o| o.describe().to_string())
.unwrap_or_default();
warn!("{reason} (set by {origin}), using default");
resolved.coerced(
id,
Value::String(default_text.to_string()),
format!("{reason}; the default stands"),
);
}
}
type SettingsState = (Arc<Settings>, Arc<Resolved>);
static SETTINGS: std::sync::RwLock<Option<SettingsState>> = std::sync::RwLock::new(None);
fn settings_state() -> SettingsState {
{
let lock = SETTINGS.read().unwrap();
if let Some(state) = lock.as_ref() {
return state.clone();
}
}
let mut lock = SETTINGS.write().unwrap();
if let Some(state) = lock.as_ref() {
return state.clone();
}
let (settings, resolved) = Settings::resolve_from_dir(&crate::env::CWD);
let state = (Arc::new(settings), Arc::new(resolved));
*lock = Some(state.clone());
state
}
pub fn settings() -> Arc<Settings> {
settings_state().0
}
pub(crate) fn settings_resolved() -> Arc<Resolved> {
settings_state().1
}
pub fn reload_settings() {
let (settings, resolved) = Settings::resolve_from_dir(&crate::env::CWD);
let mut lock = SETTINGS.write().unwrap();
*lock = Some((Arc::new(settings), Arc::new(resolved)));
}
macro_rules! settings_partial {
(
$(#[$meta:meta])*
$name:ident {
$(@group $group_field:ident: $group_ty:ty,)*
$($(#[$fdoc:meta])* $field:ident: $ty:ty,)*
}
) => {
$(#[$meta])*
#[derive(
Debug, Clone, Default, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
)]
#[serde(default)]
pub struct $name {
$(
#[serde(default, skip_serializing_if = "is_empty_partial")]
pub $group_field: $group_ty,
)*
$(
$(#[$fdoc])*
#[serde(skip_serializing_if = "Option::is_none", default)]
pub $field: Option<$ty>,
)*
}
#[allow(dead_code)]
impl $name {
pub fn has_any_set(&self) -> bool {
false
$(|| self.$group_field.has_any_set())*
$(|| self.$field.is_some())*
}
pub fn is_empty(&self) -> bool {
!self.has_any_set()
}
pub fn merge_from(&mut self, other: &Self) {
$(self.$group_field.merge_from(&other.$group_field);)*
$(
if other.$field.is_some() {
self.$field = other.$field.clone();
}
)*
}
}
};
}
fn is_empty_partial<T: HasAnySet>(v: &T) -> bool {
!v.has_any_set()
}
trait HasAnySet {
fn has_any_set(&self) -> bool;
}
macro_rules! impl_has_any_set {
($($name:ident),+ $(,)?) => {
$(impl HasAnySet for $name {
fn has_any_set(&self) -> bool {
$name::has_any_set(self)
}
})+
};
}
settings_partial! {
SettingsApiPartial {
auto_start: bool,
bind_address: String,
bind_port: i64,
port_attempts: i64,
token: String,
}
}
settings_partial! {
SettingsGeneralPartial {
autostop_delay: String,
interval: String,
log_file_level: String,
log_level: String,
mise: bool,
mise_bin: String,
shell: String,
startup_log_timestamps: bool,
ready_delay: String,
worktree: bool,
}
}
settings_partial! {
SettingsIpcPartial {
connect_attempts: i64,
connect_max_delay: String,
connect_min_delay: String,
rate_limit: i64,
rate_limit_window: String,
request_timeout: String,
}
}
settings_partial! {
SettingsLogsArchiveHookPartial {
batch_size: i64,
command: String,
}
}
settings_partial! {
SettingsLogsPartial {
@group archive_hook: SettingsLogsArchiveHookPartial,
line_retention: i64,
log_format: String,
time_retention: String,
timestamp: bool,
timestamp_format: String,
}
}
settings_partial! {
SettingsProxyPartial {
auto_start: bool,
auto_start_timeout: String,
auto_trust: bool,
enable: bool,
host: String,
https: bool,
lan: bool,
lan_ip: String,
port: i64,
sync_hosts: bool,
tld: String,
tls_cert: String,
tls_key: String,
wildcard: bool,
worktree: bool,
}
}
settings_partial! {
SettingsSupervisorPartial {
auto_start: bool,
cleanup_orphans: bool,
container: bool,
cpu_violation_threshold: i64,
cron_check_interval: String,
file_watch_debounce: String,
health_check_interval: String,
health_check_retries: i64,
health_cmd_timeout: String,
health_http_timeout: String,
health_port_timeout: String,
http_client_timeout: String,
log_flush_interval: String,
orphan_policy: String,
port_bump_attempts: i64,
ready_check_interval: String,
restart_delay: String,
stop_timeout: String,
user: String,
watch_interval: String,
watch_poll_interval: String,
}
}
settings_partial! {
SettingsTuiPartial {
message_duration: String,
refresh_rate: String,
stat_history: i64,
tick_rate: String,
}
}
settings_partial! {
SettingsWebPartial {
auto_start: bool,
base_path: String,
bind_address: String,
bind_port: i64,
log_lines: i64,
port_attempts: i64,
sse_poll_interval: String,
}
}
settings_partial! {
SettingsPartial {
@group api: SettingsApiPartial,
@group general: SettingsGeneralPartial,
@group ipc: SettingsIpcPartial,
@group logs: SettingsLogsPartial,
@group proxy: SettingsProxyPartial,
@group supervisor: SettingsSupervisorPartial,
@group tui: SettingsTuiPartial,
@group web: SettingsWebPartial,
}
}
impl_has_any_set!(
SettingsApiPartial,
SettingsGeneralPartial,
SettingsIpcPartial,
SettingsLogsArchiveHookPartial,
SettingsLogsPartial,
SettingsProxyPartial,
SettingsSupervisorPartial,
SettingsTuiPartial,
SettingsWebPartial,
);
impl SettingsPartial {
pub(crate) fn canonicalize_aliases(&mut self) {
if self.general.worktree.is_none() {
self.general.worktree = self.proxy.worktree.take();
} else {
self.proxy.worktree = None;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn test_default_settings() {
let settings = Settings::default();
assert_eq!(settings.general.autostop_delay, "1m");
assert_eq!(settings.general.interval, "10s");
assert_eq!(settings.general.log_level, "info");
assert_eq!(settings.general.ready_delay, "3s");
assert_eq!(settings.ipc.connect_attempts, 5);
assert_eq!(settings.ipc.request_timeout, "5s");
assert_eq!(settings.ipc.rate_limit, 100);
assert!(!settings.web.auto_start);
assert_eq!(settings.web.bind_address, "127.0.0.1");
assert_eq!(settings.web.bind_port, 3120);
assert_eq!(settings.web.log_lines, 100);
assert_eq!(settings.tui.refresh_rate, "2s");
assert_eq!(settings.tui.stat_history, 60);
assert!(settings.supervisor.auto_start);
assert_eq!(settings.supervisor.ready_check_interval, "500ms");
assert_eq!(settings.supervisor.file_watch_debounce, "1s");
assert_eq!(settings.supervisor.user, "");
}
#[test]
fn a_bad_value_costs_only_its_own_setting() {
let env = EnvLayer::new([("PITCHFORK_LOG".to_string(), "debug".to_string())]);
let mut resolved = resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&env)).unwrap();
let registry = Settings::SETTINGS_REGISTRY;
let rate_limit = registry.lookup("ipc.rate_limit").unwrap().id;
resolved.coerced(rate_limit, Value::Bool(true), "a hook that got it wrong");
assert!(
Settings::read(&resolved).is_err(),
"the strict read still refuses it"
);
let (settings, errors) = Settings::read_lossy(&resolved);
let settings = settings.expect("every setting declares a default");
assert_eq!(
settings.ipc.rate_limit, 100,
"the bad field takes its declared default"
);
assert_eq!(
settings.general.log_level, "debug",
"and PITCHFORK_LOG is not collateral damage"
);
assert_eq!(errors.0.len(), 1, "{errors}");
assert_eq!(errors.0[0].key, "ipc.rate_limit");
}
#[test]
fn every_setting_declares_a_default() {
let missing: Vec<&str> = Settings::SETTINGS_PROPS
.iter()
.filter(|meta| meta.default.is_none())
.map(|meta| meta.key)
.collect();
assert!(
missing.is_empty(),
"these settings declare no default: {missing:?}"
);
}
#[test]
fn test_registry_matches_previous_schema() {
let keys: Vec<&str> = Settings::SETTINGS_PROPS
.iter()
.map(|meta| meta.key)
.collect();
assert_eq!(keys.len(), 74, "{keys:?}");
assert!(keys.contains(&"general.autostop_delay"));
assert!(keys.contains(&"logs.archive_hook.command"));
assert!(keys.contains(&"supervisor.health_check_interval"));
assert!(keys.contains(&"supervisor.watch_interval"));
let registry = Settings::SETTINGS_REGISTRY;
let interval = registry.get(registry.lookup("general.interval").unwrap().id);
assert_eq!(interval.envs, &["PITCHFORK_INTERVAL"]);
assert_eq!(interval.deprecated_envs, &["PITCHFORK_INTERVAL_SECS"]);
let watch = registry.get(registry.lookup("supervisor.watch_interval").unwrap().id);
assert_eq!(watch.deprecated_envs, &["PITCHFORK_WATCH_INTERVAL_MS"]);
let log_level = registry.get(registry.lookup("general.log_level").unwrap().id);
assert_eq!(log_level.envs, &["PITCHFORK_LOG"]);
let worktree = registry.get(registry.lookup("general.worktree").unwrap().id);
assert_eq!(worktree.envs, &["PITCHFORK_WORKTREE"]);
assert_eq!(worktree.deprecated_envs, &["PITCHFORK_PROXY_WORKTREE"]);
assert_eq!(worktree.aliases, &["proxy.worktree"]);
assert_eq!(
registry.lookup("proxy.worktree").unwrap().id,
registry.lookup("general.worktree").unwrap().id,
);
let spec = Settings::spec_kdl();
let files = [
"file \"/etc/pitchfork/config.toml\" scope=\"system\" format=\"toml\"",
"file \"~/.config/pitchfork/config.toml\" scope=\"global\" format=\"toml\"",
"file \".config/pitchfork.toml\" findup=#true format=\"toml\"",
"file \".config/pitchfork.local.toml\" findup=#true format=\"toml\"",
"file \"pitchfork.toml\" findup=#true format=\"toml\"",
"file \"pitchfork.local.toml\" findup=#true format=\"toml\"",
];
let positions: Vec<usize> = files
.iter()
.map(|file| spec.find(file).unwrap_or_else(|| panic!("missing {file}")))
.collect();
assert!(
positions.windows(2).all(|pair| pair[0] < pair[1]),
"config files must be documented from lowest to highest precedence:\n{spec}"
);
}
#[test]
fn every_registry_key_round_trips_through_the_partial() {
for meta in Settings::SETTINGS_PROPS {
let value = match meta.ty.inner() {
Ty::Bool => toml::Value::Boolean(true),
Ty::Int | Ty::Uint => toml::Value::Integer(1),
_ => toml::Value::String("x".to_string()),
};
let parts: Vec<&str> = meta.key.split('.').collect();
let mut table = toml::Table::new();
let mut cursor = &mut table;
for part in &parts[..parts.len() - 1] {
cursor = cursor
.entry(part.to_string())
.or_insert_with(|| toml::Value::Table(toml::Table::new()))
.as_table_mut()
.unwrap();
}
cursor.insert(parts[parts.len() - 1].to_string(), value);
let partial: SettingsPartial = table.clone().try_into().unwrap();
let back = toml::Table::try_from(&partial).unwrap();
assert_eq!(
back, table,
"{} is declared in the registry but not mirrored in SettingsPartial",
meta.key
);
}
}
#[test]
fn test_parse_duration() {
assert_eq!(Settings::parse_duration("1s"), Some(Duration::from_secs(1)));
assert_eq!(
Settings::parse_duration("500ms"),
Some(Duration::from_millis(500))
);
assert_eq!(
Settings::parse_duration("1m"),
Some(Duration::from_secs(60))
);
assert_eq!(
Settings::parse_duration("2h"),
Some(Duration::from_secs(7200))
);
assert_eq!(Settings::parse_duration("invalid"), None);
}
#[test]
fn test_env_override() {
let env = EnvLayer::new([
("PITCHFORK_AUTOSTOP_DELAY".to_string(), "10m".to_string()),
("PITCHFORK_INTERVAL".to_string(), "5s".to_string()),
(
"PITCHFORK_IPC_CONNECT_ATTEMPTS".to_string(),
"20".to_string(),
),
(
"PITCHFORK_SUPERVISOR_AUTO_START".to_string(),
"false".to_string(),
),
("PITCHFORK_WEB_AUTO_START".to_string(), "true".to_string()),
]);
let resolved = resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&env)).unwrap();
let settings = Settings::read(&resolved).unwrap();
assert_eq!(settings.general.autostop_delay, "10m");
assert_eq!(settings.general.interval, "5s");
assert_eq!(settings.ipc.connect_attempts, 20);
assert!(!settings.supervisor.auto_start);
assert!(settings.web.auto_start);
assert_eq!(settings.general.log_level, "info");
assert_eq!(settings.ipc.rate_limit, 100);
}
#[test]
fn test_deprecated_env_still_works_and_warns() {
let env = EnvLayer::new([
("PITCHFORK_INTERVAL_SECS".to_string(), "30s".to_string()),
("PITCHFORK_PROXY_WORKTREE".to_string(), "false".to_string()),
]);
let resolved = resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&env)).unwrap();
let settings = Settings::read(&resolved).unwrap();
assert_eq!(settings.general.interval, "30s");
assert!(!settings.general.worktree);
let warnings = usage_rs::config::explain::warnings(&resolved);
assert!(
warnings
.iter()
.any(|w| w.contains("PITCHFORK_INTERVAL_SECS is deprecated")),
"{warnings:?}"
);
assert!(
warnings
.iter()
.any(|w| w.contains("PITCHFORK_PROXY_WORKTREE is deprecated")),
"{warnings:?}"
);
let env = EnvLayer::new([
("PITCHFORK_INTERVAL_SECS".to_string(), "30s".to_string()),
("PITCHFORK_INTERVAL".to_string(), "7s".to_string()),
("PITCHFORK_PROXY_WORKTREE".to_string(), "false".to_string()),
("PITCHFORK_WORKTREE".to_string(), "true".to_string()),
]);
let resolved = resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&env)).unwrap();
let settings = Settings::read(&resolved).unwrap();
assert_eq!(settings.general.interval, "7s");
assert!(settings.general.worktree);
}
#[test]
fn test_invalid_duration_from_env_falls_back_to_default() {
let env = EnvLayer::new([(
"PITCHFORK_AUTOSTOP_DELAY".to_string(),
"not_a_duration".to_string(),
)]);
let mut resolved = resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&env)).unwrap();
sanitize_durations(&mut resolved);
let settings = Settings::read(&resolved).unwrap();
assert_eq!(settings.general.autostop_delay, "1m");
assert_eq!(settings.general_autostop_delay(), Duration::from_secs(60));
}
#[test]
fn test_zero_health_timeout_from_env_falls_back_to_default() {
let env = EnvLayer::new([
("PITCHFORK_HEALTH_CMD_TIMEOUT".to_string(), "0s".to_string()),
(
"PITCHFORK_HEALTH_HTTP_TIMEOUT".to_string(),
"0s".to_string(),
),
(
"PITCHFORK_HEALTH_PORT_TIMEOUT".to_string(),
"0s".to_string(),
),
]);
let mut resolved = resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&env)).unwrap();
sanitize_durations(&mut resolved);
let settings = Settings::read(&resolved).unwrap();
assert_eq!(settings.supervisor.health_cmd_timeout, "10s");
assert_eq!(
settings.supervisor_health_cmd_timeout(),
Duration::from_secs(10)
);
assert_eq!(settings.supervisor.health_http_timeout, "5s");
assert_eq!(settings.supervisor.health_port_timeout, "5s");
}
#[test]
fn test_invalid_duration_fallback() {
let mut settings = Settings::default();
settings.general.autostop_delay = "invalid".to_string();
settings.general.interval = "not_a_duration".to_string();
assert_eq!(settings.general_autostop_delay(), Duration::from_secs(60)); assert_eq!(settings.general_interval(), Duration::from_secs(10)); }
#[test]
fn test_duration_methods_all_fields() {
let settings = Settings::default();
assert_eq!(settings.general_autostop_delay(), Duration::from_secs(60));
assert_eq!(settings.general_interval(), Duration::from_secs(10));
assert_eq!(settings.general_ready_delay(), Duration::from_secs(3));
assert_eq!(settings.ipc_connect_min_delay(), Duration::from_millis(100));
assert_eq!(settings.ipc_connect_max_delay(), Duration::from_secs(1));
assert_eq!(settings.ipc_request_timeout(), Duration::from_secs(5));
assert_eq!(settings.ipc_rate_limit_window(), Duration::from_secs(1));
assert_eq!(settings.web_sse_poll_interval(), Duration::from_millis(500));
assert_eq!(settings.tui_refresh_rate(), Duration::from_secs(2));
assert_eq!(settings.tui_tick_rate(), Duration::from_millis(100));
assert_eq!(settings.tui_message_duration(), Duration::from_secs(3));
assert_eq!(
settings.supervisor_ready_check_interval(),
Duration::from_millis(500)
);
assert_eq!(
settings.supervisor_file_watch_debounce(),
Duration::from_secs(1)
);
assert_eq!(
settings.supervisor_log_flush_interval(),
Duration::from_millis(500)
);
assert_eq!(settings.supervisor_stop_timeout(), Duration::from_secs(5));
assert_eq!(
settings.supervisor_restart_delay(),
Duration::from_millis(100)
);
assert_eq!(
settings.supervisor_cron_check_interval(),
Duration::from_secs(10)
);
assert_eq!(
settings.supervisor_http_client_timeout(),
Duration::from_secs(5)
);
}
#[test]
fn test_general_ready_delay_secs() {
let mut settings = Settings::default();
assert_eq!(settings.general_ready_delay_secs(), Ok(3));
settings.general.ready_delay = "500ms".to_string();
let err = settings.general_ready_delay_secs().unwrap_err();
assert!(err.contains("500ms"), "unexpected error: {err}");
assert!(
err.contains("whole number of seconds"),
"unexpected error: {err}"
);
settings.general.ready_delay = "1.5s".to_string();
let err = settings.general_ready_delay_secs().unwrap_err();
assert!(err.contains("1.5s"), "unexpected error: {err}");
settings.general.ready_delay = String::new();
assert_eq!(settings.general_ready_delay_secs(), Ok(3));
settings.general.ready_delay = "90s".to_string();
assert_eq!(settings.general_ready_delay_secs(), Ok(90));
}
struct Tree(std::path::PathBuf);
impl Tree {
fn new(name: &str) -> Self {
let dir = std::env::temp_dir()
.join(format!("pitchfork_settings_{}_{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
Self(dir)
}
fn write(&self, rel: &str, text: &str) -> std::path::PathBuf {
let path = self.0.join(rel);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(&path, text).unwrap();
path
}
}
impl Drop for Tree {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn test_settings_read_from_pitchfork_toml_settings_table() {
let tree = Tree::new("table");
let path = tree.write(
"pitchfork.toml",
r#"
[daemons.myapp]
run = "node server.js"
[settings.general]
autostop_delay = "5m"
log_level = "debug"
[settings.web]
auto_start = true
bind_port = 8080
[settings.supervisor]
auto_start = false
user = "postgres"
"#,
);
let layer = FileLayer::at(&path, FileScope::Project).under("settings");
let resolved = resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&layer)).unwrap();
let settings = Settings::read(&resolved).unwrap();
assert_eq!(settings.general.autostop_delay, "5m");
assert_eq!(settings.general.log_level, "debug");
assert!(settings.web.auto_start);
assert_eq!(settings.web.bind_port, 8080);
assert!(!settings.supervisor.auto_start);
assert_eq!(settings.supervisor.user, "postgres");
assert_eq!(settings.general.interval, "10s");
assert_eq!(settings.ipc.connect_attempts, 5);
assert!(resolved.warnings.is_empty(), "{:?}", resolved.warnings);
}
#[test]
fn test_proxy_worktree_config_alias_still_works() {
let tree = Tree::new("proxy_worktree_alias");
let path = tree.write("pitchfork.toml", "[settings.proxy]\nworktree = false\n");
let layer = FileLayer::at(&path, FileScope::Project).under("settings");
let resolved = resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&layer)).unwrap();
let settings = Settings::read(&resolved).unwrap();
assert!(!settings.general.worktree);
assert_eq!(
resolved.get(
Settings::SETTINGS_REGISTRY
.lookup("general.worktree")
.unwrap()
.id
),
Some(&Value::Bool(false)),
);
}
#[test]
fn test_explicit_default_value_in_higher_file_still_overrides() {
let tree = Tree::new("bug5");
let lower = tree.write("lower.toml", "[settings.general]\nlog_level = \"warn\"\n");
let higher = tree.write("higher.toml", "[settings.general]\nlog_level = \"info\"\n");
let lower = FileLayer::at(&lower, FileScope::Global).under("settings");
let higher = FileLayer::at(&higher, FileScope::Project).under("settings");
let resolved = resolve(
Settings::SETTINGS_REGISTRY,
Layers::new().then(&higher).then(&lower),
)
.unwrap();
let settings = Settings::read(&resolved).unwrap();
assert_eq!(settings.general.log_level, "info");
let id = Settings::SETTINGS_REGISTRY
.lookup("general.log_level")
.unwrap()
.id;
let origin = resolved.origin(id).unwrap().describe();
assert!(origin.contains("higher.toml"), "{origin}");
}
#[test]
fn test_file_precedence_local_over_base_and_nearer_dir_wins() {
let tree = Tree::new("precedence");
tree.write("pitchfork.toml", "[settings.general]\ninterval = \"9s\"\n");
tree.write(
"pitchfork.local.toml",
"[settings.general]\ninterval = \"2s\"\n",
);
tree.write(
".config/pitchfork.toml",
"[settings.general]\ninterval = \"7s\"\n[settings.web]\nbind_port = 9999\n",
);
let layers = Settings::file_layers_from(&tree.0);
let mut chain = Layers::new();
for layer in &layers {
chain = chain.then(layer);
}
let resolved = resolve(Settings::SETTINGS_REGISTRY, chain).unwrap();
let settings = Settings::read(&resolved).unwrap();
assert_eq!(settings.general.interval, "2s");
assert_eq!(settings.web.bind_port, 9999);
let deep = tree.0.join("a").join("b");
std::fs::create_dir_all(&deep).unwrap();
tree.write(
"a/b/pitchfork.toml",
"[settings.general]\ninterval = \"1s\"\n",
);
let layers = Settings::file_layers_from(&deep);
let mut chain = Layers::new();
for layer in &layers {
chain = chain.then(layer);
}
let resolved = resolve(Settings::SETTINGS_REGISTRY, chain).unwrap();
let settings = Settings::read(&resolved).unwrap();
assert_eq!(settings.general.interval, "1s");
}
#[test]
fn test_broken_file_is_skipped_rather_than_fatal() {
let tree = Tree::new("broken");
let broken = tree.write("pitchfork.toml", "[invalid toml [[");
let layers = Settings::file_layers_from(&tree.0);
assert!(
layers.iter().all(|layer| !layer.paths().contains(&broken)),
"the broken project file must not become a settings layer"
);
}
#[test]
fn test_unknown_settings_keys_do_not_fail_the_load() {
let tree = Tree::new("unknown");
let path = tree.write(
"pitchfork.toml",
"[settings.general]\nlog_level = \"debug\"\nfrom_the_future = true\n",
);
let layer = FileLayer::at(&path, FileScope::Project).under("settings");
let resolved = resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&layer)).unwrap();
let settings = Settings::read(&resolved).unwrap();
assert_eq!(settings.general.log_level, "debug");
assert_eq!(resolved.warnings.len(), 1, "{:?}", resolved.warnings);
}
#[test]
fn test_partial_merge_from() {
let mut base = SettingsPartial::default();
base.general.log_level = Some("warn".to_string());
base.web.bind_address = Some("0.0.0.0".to_string());
let mut overlay = SettingsPartial::default();
overlay.general.log_level = Some("debug".to_string());
overlay.tui.refresh_rate = Some("1s".to_string());
base.merge_from(&overlay);
assert_eq!(base.general.log_level.as_deref(), Some("debug"));
assert_eq!(base.web.bind_address.as_deref(), Some("0.0.0.0"));
assert_eq!(base.tui.refresh_rate.as_deref(), Some("1s"));
let sealed = base.clone();
base.merge_from(&SettingsPartial::default());
assert_eq!(base.general.log_level, sealed.general.log_level);
assert!(SettingsPartial::default().is_empty());
assert!(base.has_any_set());
}
#[test]
fn test_partial_serialization_skips_unset() {
let mut partial = SettingsPartial::default();
partial.general.interval = Some("5s".to_string());
let toml = toml::to_string_pretty(&partial).unwrap();
assert_eq!(toml, "[general]\ninterval = \"5s\"\n");
}
}