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//! This module is generated from `settings.toml` at build time.
24
25// Include the generated code from build.rs.
26// Wrapped in a module so that `#[allow(clippy::all)]` suppresses all clippy
27// warnings for the generated code without affecting the rest of this file.
28#[allow(clippy::all)]
29mod generated {
30    include!(concat!(env!("OUT_DIR"), "/generated/settings.rs"));
31}
32pub use generated::*;
33
34// Include metadata for introspection
35#[allow(clippy::all, dead_code)]
36mod meta {
37    include!(concat!(env!("OUT_DIR"), "/generated/settings_meta.rs"));
38}
39
40#[allow(unused_imports)]
41pub use meta::*;
42
43impl Settings {
44    /// Resolve the mise binary path.
45    ///
46    /// If `general.mise_bin` is explicitly set, returns that path.
47    /// Otherwise, searches well-known install locations:
48    /// - `~/.local/bin/mise`
49    /// - `~/.cargo/bin/mise`
50    /// - `/usr/local/bin/mise`
51    /// - `/opt/homebrew/bin/mise`
52    ///
53    /// Returns `None` if mise cannot be found.
54    pub fn resolve_mise_bin(&self) -> Option<std::path::PathBuf> {
55        use std::path::PathBuf;
56
57        // Explicit configuration takes priority
58        if !self.general.mise_bin.is_empty() {
59            let p = PathBuf::from(&self.general.mise_bin);
60            if p.is_file() {
61                return Some(p);
62            }
63            warn!(
64                "mise_bin is set to {:?} but the file does not exist",
65                self.general.mise_bin
66            );
67            return None;
68        }
69
70        // Search well-known install paths
71        let home = crate::env::HOME_DIR.as_path();
72        let candidates = [
73            home.join(".local/bin/mise"),
74            home.join(".cargo/bin/mise"),
75            PathBuf::from("/usr/local/bin/mise"),
76            PathBuf::from("/opt/homebrew/bin/mise"),
77        ];
78
79        candidates.into_iter().find(|p| p.is_file())
80    }
81
82    /// Return `supervisor.port_bump_attempts` as `u32`, clamping out-of-range
83    /// values to the schema default (10) and zero to 1.
84    ///
85    /// This is the single source of truth for the fallback so that call-sites
86    /// don't each duplicate the hardcoded `10`.
87    pub fn default_port_bump_attempts(&self) -> u32 {
88        let v = u32::try_from(self.supervisor.port_bump_attempts).unwrap_or_else(|_| {
89            warn!(
90                "supervisor.port_bump_attempts value {} is out of range (0-{}), clamping to 10",
91                self.supervisor.port_bump_attempts,
92                u32::MAX
93            );
94            10
95        });
96        if v == 0 {
97            warn!("supervisor.port_bump_attempts is 0; defaulting to 1");
98            1
99        } else {
100            v
101        }
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use std::time::Duration;
109
110    #[test]
111    fn test_default_settings() {
112        let settings = Settings::default();
113
114        // Test general settings
115        assert_eq!(settings.general.autostop_delay, "1m");
116        assert_eq!(settings.general.interval, "10s");
117        assert_eq!(settings.general.log_level, "info");
118
119        // Test IPC settings
120        assert_eq!(settings.ipc.connect_attempts, 5);
121        assert_eq!(settings.ipc.request_timeout, "5s");
122        assert_eq!(settings.ipc.rate_limit, 100);
123
124        // Test web settings
125        assert!(!settings.web.auto_start);
126        assert_eq!(settings.web.bind_address, "127.0.0.1");
127        assert_eq!(settings.web.bind_port, 3120);
128        assert_eq!(settings.web.log_lines, 100);
129
130        // Test TUI settings
131        assert_eq!(settings.tui.refresh_rate, "2s");
132        assert_eq!(settings.tui.stat_history, 60);
133
134        // Test supervisor settings
135        assert!(settings.supervisor.auto_start);
136        assert_eq!(settings.supervisor.ready_check_interval, "500ms");
137        assert_eq!(settings.supervisor.file_watch_debounce, "1s");
138        assert_eq!(settings.supervisor.user, "");
139    }
140
141    #[test]
142    fn test_parse_duration() {
143        assert_eq!(Settings::parse_duration("1s"), Some(Duration::from_secs(1)));
144        assert_eq!(
145            Settings::parse_duration("500ms"),
146            Some(Duration::from_millis(500))
147        );
148        assert_eq!(
149            Settings::parse_duration("1m"),
150            Some(Duration::from_secs(60))
151        );
152        assert_eq!(
153            Settings::parse_duration("2h"),
154            Some(Duration::from_secs(7200))
155        );
156        assert_eq!(Settings::parse_duration("invalid"), None);
157    }
158
159    #[test]
160    fn test_convenience_methods() {
161        let settings = Settings::default();
162
163        assert_eq!(settings.general_autostop_delay(), Duration::from_secs(60));
164        assert_eq!(settings.general_interval(), Duration::from_secs(10));
165    }
166
167    #[test]
168    fn test_load_from_toml_string() {
169        // Test loading from a complete TOML string
170        let toml_content = r#"
171[general]
172autostop_delay = "5m"
173interval = "30s"
174log_level = "debug"
175
176[ipc]
177connect_attempts = 10
178request_timeout = "10s"
179
180[web]
181auto_start = true
182bind_port = 8080
183"#;
184
185        let settings: Settings = toml::from_str(toml_content).unwrap();
186
187        // Explicitly set values
188        assert_eq!(settings.general.autostop_delay, "5m");
189        assert_eq!(settings.general.interval, "30s");
190        assert_eq!(settings.general.log_level, "debug");
191        assert_eq!(settings.ipc.connect_attempts, 10);
192        assert_eq!(settings.ipc.request_timeout, "10s");
193        assert!(settings.web.auto_start);
194        assert_eq!(settings.web.bind_port, 8080);
195        assert_eq!(settings.general.log_file_level, "info");
196        assert_eq!(settings.ipc.rate_limit, 100);
197        assert_eq!(settings.web.bind_address, "127.0.0.1");
198        assert_eq!(settings.tui.refresh_rate, "2s");
199    }
200
201    #[test]
202    fn test_partial_config_uses_defaults() {
203        // Test that missing sections use defaults
204        let toml_content = r#"
205[general]
206log_level = "warn"
207"#;
208
209        let settings: Settings = toml::from_str(toml_content).unwrap();
210
211        // Explicitly set value
212        assert_eq!(settings.general.log_level, "warn");
213
214        // All other values should be defaults
215        assert_eq!(settings.general.autostop_delay, "1m");
216        assert_eq!(settings.general.interval, "10s");
217        assert_eq!(settings.ipc.connect_attempts, 5);
218        assert!(!settings.web.auto_start);
219        assert_eq!(settings.tui.stat_history, 60);
220        assert_eq!(settings.supervisor.stop_timeout, "5s");
221    }
222
223    #[test]
224    fn test_empty_config_uses_all_defaults() {
225        // Empty TOML should result in all defaults
226        let settings: Settings = toml::from_str("").unwrap();
227
228        assert_eq!(settings.general.autostop_delay, "1m");
229        assert_eq!(settings.general.interval, "10s");
230        assert_eq!(settings.general.log_level, "info");
231        assert_eq!(settings.ipc.connect_attempts, 5);
232        assert!(!settings.web.auto_start);
233        assert_eq!(settings.tui.refresh_rate, "2s");
234    }
235
236    #[test]
237    fn test_env_override() {
238        // Test load_from_env() directly on a fresh Settings instance.
239        // NOTE: We deliberately do NOT use settings() here. settings() is a
240        // process-wide OnceLock singleton that is initialized exactly once, so
241        // any env-var changes made after first access would be invisible to it.
242        // By calling Settings::default() + load_from_env() directly we get a
243        // proper unit test of the env-reading code path.
244        //
245        // Cargo runs tests in the same process on multiple threads. Mutating
246        // env vars from concurrent threads is a data race (UB in Rust's memory
247        // model). We therefore hold a process-wide mutex for the entire
248        // set/test/unset sequence so that at most one test touches the env at
249        // a time.
250        use std::sync::{LazyLock, Mutex};
251        static ENV_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
252        let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
253
254        // SAFETY: we hold ENV_LOCK so no other thread in this process is
255        // concurrently reading or writing these variables.
256        unsafe {
257            std::env::set_var("PITCHFORK_AUTOSTOP_DELAY", "10m");
258            std::env::set_var("PITCHFORK_INTERVAL", "5s");
259            std::env::set_var("PITCHFORK_IPC_CONNECT_ATTEMPTS", "20");
260            std::env::set_var("PITCHFORK_SUPERVISOR_AUTO_START", "false");
261            std::env::set_var("PITCHFORK_WEB_AUTO_START", "true");
262        }
263
264        let mut settings = Settings::default();
265        settings.load_from_env();
266
267        // Verify the env vars were picked up
268        assert_eq!(settings.general.autostop_delay, "10m");
269        assert_eq!(settings.general.interval, "5s");
270        assert_eq!(settings.ipc.connect_attempts, 20);
271        assert!(!settings.supervisor.auto_start);
272        assert!(settings.web.auto_start);
273
274        // Fields with no corresponding env var set remain at defaults
275        assert_eq!(settings.general.log_level, "info");
276        assert_eq!(settings.ipc.rate_limit, 100);
277
278        // Clean up to avoid polluting other tests.
279        // SAFETY: same guarantee as above – we still hold ENV_LOCK.
280        unsafe {
281            std::env::remove_var("PITCHFORK_AUTOSTOP_DELAY");
282            std::env::remove_var("PITCHFORK_INTERVAL");
283            std::env::remove_var("PITCHFORK_IPC_CONNECT_ATTEMPTS");
284            std::env::remove_var("PITCHFORK_SUPERVISOR_AUTO_START");
285            std::env::remove_var("PITCHFORK_WEB_AUTO_START");
286        }
287    }
288
289    #[test]
290    fn test_invalid_duration_fallback() {
291        let mut settings = Settings::default();
292
293        // Set invalid duration values
294        settings.general.autostop_delay = "invalid".to_string();
295        settings.general.interval = "not_a_duration".to_string();
296
297        // Convenience methods should fallback to default values
298        assert_eq!(settings.general_autostop_delay(), Duration::from_secs(60)); // default "1m"
299        assert_eq!(settings.general_interval(), Duration::from_secs(10)); // default "10s"
300    }
301
302    #[test]
303    fn test_duration_methods_all_fields() {
304        let settings = Settings::default();
305
306        // Test all Duration convenience methods return expected defaults
307        assert_eq!(settings.general_autostop_delay(), Duration::from_secs(60));
308        assert_eq!(settings.general_interval(), Duration::from_secs(10));
309        assert_eq!(settings.ipc_connect_min_delay(), Duration::from_millis(100));
310        assert_eq!(settings.ipc_connect_max_delay(), Duration::from_secs(1));
311        assert_eq!(settings.ipc_request_timeout(), Duration::from_secs(5));
312        assert_eq!(settings.ipc_rate_limit_window(), Duration::from_secs(1));
313        assert_eq!(settings.web_sse_poll_interval(), Duration::from_millis(500));
314        assert_eq!(settings.tui_refresh_rate(), Duration::from_secs(2));
315        assert_eq!(settings.tui_tick_rate(), Duration::from_millis(100));
316        assert_eq!(settings.tui_message_duration(), Duration::from_secs(3));
317        assert_eq!(
318            settings.supervisor_ready_check_interval(),
319            Duration::from_millis(500)
320        );
321        assert_eq!(
322            settings.supervisor_file_watch_debounce(),
323            Duration::from_secs(1)
324        );
325        assert_eq!(
326            settings.supervisor_log_flush_interval(),
327            Duration::from_millis(500)
328        );
329        assert_eq!(settings.supervisor_stop_timeout(), Duration::from_secs(5));
330        assert_eq!(
331            settings.supervisor_restart_delay(),
332            Duration::from_millis(100)
333        );
334        assert_eq!(
335            settings.supervisor_cron_check_interval(),
336            Duration::from_secs(10)
337        );
338        assert_eq!(
339            settings.supervisor_http_client_timeout(),
340            Duration::from_secs(5)
341        );
342    }
343
344    #[test]
345    fn test_unknown_fields_ignored() {
346        // serde's default behaviour (without #[serde(deny_unknown_fields)]) is to
347        // silently discard unrecognised keys.  Our generated structs rely on this
348        // so that future pitchfork versions with new settings don't break older
349        // configs – and so that users can add comments/custom keys without errors.
350        let toml_content = r#"
351[general]
352log_level = "debug"
353unknown_field = "should be ignored"
354
355[unknown_section]
356foo = "bar"
357"#;
358
359        let result: Result<Settings, _> = toml::from_str(toml_content);
360        // Must succeed: our structs do NOT use deny_unknown_fields.
361        let settings = result.expect("unknown fields should be silently ignored by serde");
362        assert_eq!(settings.general.log_level, "debug");
363        // Known fields in unrecognised sections (unknown_section) are dropped;
364        // all other fields fall back to their defaults.
365        assert_eq!(settings.general.autostop_delay, "1m");
366    }
367
368    #[test]
369    fn test_serialize_roundtrip() {
370        let settings = Settings::default();
371
372        // Serialize to TOML
373        let toml_str = toml::to_string_pretty(&settings).unwrap();
374
375        // Parse back
376        let parsed: Settings = toml::from_str(&toml_str).unwrap();
377
378        // Verify roundtrip
379        assert_eq!(
380            settings.general.autostop_delay,
381            parsed.general.autostop_delay
382        );
383        assert_eq!(settings.general.interval, parsed.general.interval);
384        assert_eq!(settings.ipc.connect_attempts, parsed.ipc.connect_attempts);
385        assert_eq!(settings.web.auto_start, parsed.web.auto_start);
386        assert_eq!(settings.tui.stat_history, parsed.tui.stat_history);
387    }
388
389    #[test]
390    fn test_type_coercion() {
391        // Test that integer and boolean values are correctly parsed
392        let toml_content = r#"
393[ipc]
394connect_attempts = 3
395rate_limit = 50
396
397[web]
398auto_start = true
399bind_port = 9000
400log_lines = 200
401
402[tui]
403stat_history = 120
404"#;
405
406        let settings: Settings = toml::from_str(toml_content).unwrap();
407
408        assert_eq!(settings.ipc.connect_attempts, 3);
409        assert_eq!(settings.ipc.rate_limit, 50);
410        assert!(settings.web.auto_start);
411        assert_eq!(settings.web.bind_port, 9000);
412        assert_eq!(settings.web.log_lines, 200);
413        assert_eq!(settings.tui.stat_history, 120);
414    }
415
416    #[test]
417    fn test_merge_from_non_default_values() {
418        let mut base = Settings::default();
419
420        // Build a partial with only the values we want to override
421        let mut partial = SettingsPartial::default();
422        partial.general.autostop_delay = Some("5m".to_string());
423        partial.general.log_level = Some("debug".to_string());
424        partial.ipc.connect_attempts = Some(10);
425        partial.web.auto_start = Some(true);
426
427        // Apply
428        base.apply_partial(&partial);
429
430        // Explicitly set values should be applied
431        assert_eq!(base.general.autostop_delay, "5m");
432        assert_eq!(base.general.log_level, "debug");
433        assert_eq!(base.ipc.connect_attempts, 10);
434        assert!(base.web.auto_start);
435
436        // Unset fields in partial remain at base defaults
437        assert_eq!(base.general.interval, "10s");
438        assert_eq!(base.ipc.rate_limit, 100);
439    }
440
441    #[test]
442    fn test_merge_from_preserves_existing() {
443        let mut base = Settings::default();
444        base.general.autostop_delay = "2m".to_string();
445        base.web.bind_port = 8080;
446
447        // An empty partial has all-None fields - nothing should change
448        let empty_partial = SettingsPartial::default();
449        base.apply_partial(&empty_partial);
450
451        assert_eq!(base.general.autostop_delay, "2m"); // preserved
452        assert_eq!(base.web.bind_port, 8080); // preserved
453    }
454
455    #[test]
456    fn test_merge_chain() {
457        // Simulate system -> user -> project merge chain
458        let mut settings = Settings::default();
459
460        // System config: set some values
461        let mut system_partial = SettingsPartial::default();
462        system_partial.general.log_level = Some("warn".to_string());
463        system_partial.web.bind_address = Some("0.0.0.0".to_string());
464        settings.apply_partial(&system_partial);
465
466        // User config: override log_level back to info, add tui setting
467        let mut user_partial = SettingsPartial::default();
468        user_partial.general.log_level = Some("info".to_string());
469        user_partial.tui.refresh_rate = Some("1s".to_string());
470        settings.apply_partial(&user_partial);
471
472        // Project config: override log_level to debug, enable web
473        let mut project_partial = SettingsPartial::default();
474        project_partial.general.log_level = Some("debug".to_string());
475        project_partial.web.auto_start = Some(true);
476        settings.apply_partial(&project_partial);
477
478        // Verify final merged state
479        assert_eq!(settings.general.log_level, "debug"); // from project
480        assert_eq!(settings.web.bind_address, "0.0.0.0"); // from system (not overridden)
481        assert_eq!(settings.tui.refresh_rate, "1s"); // from user
482        assert!(settings.web.auto_start); // from project
483
484        // Also verify Bug 5 fix: explicitly setting a value equal to the default
485        // correctly overrides a prior non-default value.
486        // system set log_level = "warn", then user explicitly sets it back to "info" (the default)
487        // - old broken merge_from would have skipped it because "info" == default
488        // - new apply_partial correctly sets it because the partial has Some("info")
489        // (then project overrides to "debug", but the intermediate step passed)
490        let mut s2 = Settings::default();
491        let mut p1 = SettingsPartial::default();
492        p1.general.log_level = Some("warn".to_string());
493        s2.apply_partial(&p1);
494        assert_eq!(s2.general.log_level, "warn");
495
496        // Now explicitly reset to default value "info" - must work
497        let mut p2 = SettingsPartial::default();
498        p2.general.log_level = Some("info".to_string());
499        s2.apply_partial(&p2);
500        assert_eq!(s2.general.log_level, "info"); // Bug 5 would have left this as "warn"
501    }
502
503    #[test]
504    fn test_settings_in_pitchfork_toml() {
505        // Test parsing settings from pitchfork.toml format via SettingsPartial
506        let toml_content = r#"
507[daemons.myapp]
508run = "node server.js"
509
510[settings.general]
511autostop_delay = "5m"
512log_level = "debug"
513
514[settings.web]
515auto_start = true
516bind_port = 8080
517
518[settings.supervisor]
519auto_start = false
520user = "postgres"
521"#;
522
523        // Parse the [settings] section as SettingsPartial
524        let table: toml::Table = toml::from_str(toml_content).unwrap();
525        let settings_table = table.get("settings").unwrap();
526        let partial: SettingsPartial = settings_table.clone().try_into().unwrap();
527
528        // Apply onto defaults to get resolved Settings
529        let mut settings = Settings::default();
530        settings.apply_partial(&partial);
531
532        assert_eq!(settings.general.autostop_delay, "5m");
533        assert_eq!(settings.general.log_level, "debug");
534        assert!(settings.web.auto_start);
535        assert_eq!(settings.web.bind_port, 8080);
536        assert!(!settings.supervisor.auto_start);
537        assert_eq!(settings.supervisor.user, "postgres");
538        assert_eq!(settings.general.interval, "10s");
539        assert_eq!(settings.ipc.connect_attempts, 5);
540
541        // Unset fields in partial must be None
542        assert!(partial.general.interval.is_none());
543        assert!(partial.ipc.connect_attempts.is_none());
544    }
545}