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_eq!(settings.supervisor.ready_check_interval, "500ms");
136        assert_eq!(settings.supervisor.file_watch_debounce, "1s");
137        assert_eq!(settings.supervisor.user, "");
138    }
139
140    #[test]
141    fn test_parse_duration() {
142        assert_eq!(Settings::parse_duration("1s"), Some(Duration::from_secs(1)));
143        assert_eq!(
144            Settings::parse_duration("500ms"),
145            Some(Duration::from_millis(500))
146        );
147        assert_eq!(
148            Settings::parse_duration("1m"),
149            Some(Duration::from_secs(60))
150        );
151        assert_eq!(
152            Settings::parse_duration("2h"),
153            Some(Duration::from_secs(7200))
154        );
155        assert_eq!(Settings::parse_duration("invalid"), None);
156    }
157
158    #[test]
159    fn test_convenience_methods() {
160        let settings = Settings::default();
161
162        assert_eq!(settings.general_autostop_delay(), Duration::from_secs(60));
163        assert_eq!(settings.general_interval(), Duration::from_secs(10));
164    }
165
166    #[test]
167    fn test_load_from_toml_string() {
168        // Test loading from a complete TOML string
169        let toml_content = r#"
170[general]
171autostop_delay = "5m"
172interval = "30s"
173log_level = "debug"
174
175[ipc]
176connect_attempts = 10
177request_timeout = "10s"
178
179[web]
180auto_start = true
181bind_port = 8080
182"#;
183
184        let settings: Settings = toml::from_str(toml_content).unwrap();
185
186        // Explicitly set values
187        assert_eq!(settings.general.autostop_delay, "5m");
188        assert_eq!(settings.general.interval, "30s");
189        assert_eq!(settings.general.log_level, "debug");
190        assert_eq!(settings.ipc.connect_attempts, 10);
191        assert_eq!(settings.ipc.request_timeout, "10s");
192        assert!(settings.web.auto_start);
193        assert_eq!(settings.web.bind_port, 8080);
194        assert_eq!(settings.general.log_file_level, "info");
195        assert_eq!(settings.ipc.rate_limit, 100);
196        assert_eq!(settings.web.bind_address, "127.0.0.1");
197        assert_eq!(settings.tui.refresh_rate, "2s");
198    }
199
200    #[test]
201    fn test_partial_config_uses_defaults() {
202        // Test that missing sections use defaults
203        let toml_content = r#"
204[general]
205log_level = "warn"
206"#;
207
208        let settings: Settings = toml::from_str(toml_content).unwrap();
209
210        // Explicitly set value
211        assert_eq!(settings.general.log_level, "warn");
212
213        // All other values should be defaults
214        assert_eq!(settings.general.autostop_delay, "1m");
215        assert_eq!(settings.general.interval, "10s");
216        assert_eq!(settings.ipc.connect_attempts, 5);
217        assert!(!settings.web.auto_start);
218        assert_eq!(settings.tui.stat_history, 60);
219        assert_eq!(settings.supervisor.stop_timeout, "5s");
220    }
221
222    #[test]
223    fn test_empty_config_uses_all_defaults() {
224        // Empty TOML should result in all defaults
225        let settings: Settings = toml::from_str("").unwrap();
226
227        assert_eq!(settings.general.autostop_delay, "1m");
228        assert_eq!(settings.general.interval, "10s");
229        assert_eq!(settings.general.log_level, "info");
230        assert_eq!(settings.ipc.connect_attempts, 5);
231        assert!(!settings.web.auto_start);
232        assert_eq!(settings.tui.refresh_rate, "2s");
233    }
234
235    #[test]
236    fn test_env_override() {
237        // Test load_from_env() directly on a fresh Settings instance.
238        // NOTE: We deliberately do NOT use settings() here. settings() is a
239        // process-wide OnceLock singleton that is initialized exactly once, so
240        // any env-var changes made after first access would be invisible to it.
241        // By calling Settings::default() + load_from_env() directly we get a
242        // proper unit test of the env-reading code path.
243        //
244        // Cargo runs tests in the same process on multiple threads. Mutating
245        // env vars from concurrent threads is a data race (UB in Rust's memory
246        // model). We therefore hold a process-wide mutex for the entire
247        // set/test/unset sequence so that at most one test touches the env at
248        // a time.
249        use std::sync::{LazyLock, Mutex};
250        static ENV_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
251        let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
252
253        // SAFETY: we hold ENV_LOCK so no other thread in this process is
254        // concurrently reading or writing these variables.
255        unsafe {
256            std::env::set_var("PITCHFORK_AUTOSTOP_DELAY", "10m");
257            std::env::set_var("PITCHFORK_INTERVAL", "5s");
258            std::env::set_var("PITCHFORK_IPC_CONNECT_ATTEMPTS", "20");
259            std::env::set_var("PITCHFORK_WEB_AUTO_START", "true");
260        }
261
262        let mut settings = Settings::default();
263        settings.load_from_env();
264
265        // Verify the env vars were picked up
266        assert_eq!(settings.general.autostop_delay, "10m");
267        assert_eq!(settings.general.interval, "5s");
268        assert_eq!(settings.ipc.connect_attempts, 20);
269        assert!(settings.web.auto_start);
270
271        // Fields with no corresponding env var set remain at defaults
272        assert_eq!(settings.general.log_level, "info");
273        assert_eq!(settings.ipc.rate_limit, 100);
274
275        // Clean up to avoid polluting other tests.
276        // SAFETY: same guarantee as above – we still hold ENV_LOCK.
277        unsafe {
278            std::env::remove_var("PITCHFORK_AUTOSTOP_DELAY");
279            std::env::remove_var("PITCHFORK_INTERVAL");
280            std::env::remove_var("PITCHFORK_IPC_CONNECT_ATTEMPTS");
281            std::env::remove_var("PITCHFORK_WEB_AUTO_START");
282        }
283    }
284
285    #[test]
286    fn test_invalid_duration_fallback() {
287        let mut settings = Settings::default();
288
289        // Set invalid duration values
290        settings.general.autostop_delay = "invalid".to_string();
291        settings.general.interval = "not_a_duration".to_string();
292
293        // Convenience methods should fallback to default values
294        assert_eq!(settings.general_autostop_delay(), Duration::from_secs(60)); // default "1m"
295        assert_eq!(settings.general_interval(), Duration::from_secs(10)); // default "10s"
296    }
297
298    #[test]
299    fn test_duration_methods_all_fields() {
300        let settings = Settings::default();
301
302        // Test all Duration convenience methods return expected defaults
303        assert_eq!(settings.general_autostop_delay(), Duration::from_secs(60));
304        assert_eq!(settings.general_interval(), Duration::from_secs(10));
305        assert_eq!(settings.ipc_connect_min_delay(), Duration::from_millis(100));
306        assert_eq!(settings.ipc_connect_max_delay(), Duration::from_secs(1));
307        assert_eq!(settings.ipc_request_timeout(), Duration::from_secs(5));
308        assert_eq!(settings.ipc_rate_limit_window(), Duration::from_secs(1));
309        assert_eq!(settings.web_sse_poll_interval(), Duration::from_millis(500));
310        assert_eq!(settings.tui_refresh_rate(), Duration::from_secs(2));
311        assert_eq!(settings.tui_tick_rate(), Duration::from_millis(100));
312        assert_eq!(settings.tui_message_duration(), Duration::from_secs(3));
313        assert_eq!(
314            settings.supervisor_ready_check_interval(),
315            Duration::from_millis(500)
316        );
317        assert_eq!(
318            settings.supervisor_file_watch_debounce(),
319            Duration::from_secs(1)
320        );
321        assert_eq!(
322            settings.supervisor_log_flush_interval(),
323            Duration::from_millis(500)
324        );
325        assert_eq!(settings.supervisor_stop_timeout(), Duration::from_secs(5));
326        assert_eq!(
327            settings.supervisor_restart_delay(),
328            Duration::from_millis(100)
329        );
330        assert_eq!(
331            settings.supervisor_cron_check_interval(),
332            Duration::from_secs(10)
333        );
334        assert_eq!(
335            settings.supervisor_http_client_timeout(),
336            Duration::from_secs(5)
337        );
338    }
339
340    #[test]
341    fn test_unknown_fields_ignored() {
342        // serde's default behaviour (without #[serde(deny_unknown_fields)]) is to
343        // silently discard unrecognised keys.  Our generated structs rely on this
344        // so that future pitchfork versions with new settings don't break older
345        // configs – and so that users can add comments/custom keys without errors.
346        let toml_content = r#"
347[general]
348log_level = "debug"
349unknown_field = "should be ignored"
350
351[unknown_section]
352foo = "bar"
353"#;
354
355        let result: Result<Settings, _> = toml::from_str(toml_content);
356        // Must succeed: our structs do NOT use deny_unknown_fields.
357        let settings = result.expect("unknown fields should be silently ignored by serde");
358        assert_eq!(settings.general.log_level, "debug");
359        // Known fields in unrecognised sections (unknown_section) are dropped;
360        // all other fields fall back to their defaults.
361        assert_eq!(settings.general.autostop_delay, "1m");
362    }
363
364    #[test]
365    fn test_serialize_roundtrip() {
366        let settings = Settings::default();
367
368        // Serialize to TOML
369        let toml_str = toml::to_string_pretty(&settings).unwrap();
370
371        // Parse back
372        let parsed: Settings = toml::from_str(&toml_str).unwrap();
373
374        // Verify roundtrip
375        assert_eq!(
376            settings.general.autostop_delay,
377            parsed.general.autostop_delay
378        );
379        assert_eq!(settings.general.interval, parsed.general.interval);
380        assert_eq!(settings.ipc.connect_attempts, parsed.ipc.connect_attempts);
381        assert_eq!(settings.web.auto_start, parsed.web.auto_start);
382        assert_eq!(settings.tui.stat_history, parsed.tui.stat_history);
383    }
384
385    #[test]
386    fn test_type_coercion() {
387        // Test that integer and boolean values are correctly parsed
388        let toml_content = r#"
389[ipc]
390connect_attempts = 3
391rate_limit = 50
392
393[web]
394auto_start = true
395bind_port = 9000
396log_lines = 200
397
398[tui]
399stat_history = 120
400"#;
401
402        let settings: Settings = toml::from_str(toml_content).unwrap();
403
404        assert_eq!(settings.ipc.connect_attempts, 3);
405        assert_eq!(settings.ipc.rate_limit, 50);
406        assert!(settings.web.auto_start);
407        assert_eq!(settings.web.bind_port, 9000);
408        assert_eq!(settings.web.log_lines, 200);
409        assert_eq!(settings.tui.stat_history, 120);
410    }
411
412    #[test]
413    fn test_merge_from_non_default_values() {
414        let mut base = Settings::default();
415
416        // Build a partial with only the values we want to override
417        let mut partial = SettingsPartial::default();
418        partial.general.autostop_delay = Some("5m".to_string());
419        partial.general.log_level = Some("debug".to_string());
420        partial.ipc.connect_attempts = Some(10);
421        partial.web.auto_start = Some(true);
422
423        // Apply
424        base.apply_partial(&partial);
425
426        // Explicitly set values should be applied
427        assert_eq!(base.general.autostop_delay, "5m");
428        assert_eq!(base.general.log_level, "debug");
429        assert_eq!(base.ipc.connect_attempts, 10);
430        assert!(base.web.auto_start);
431
432        // Unset fields in partial remain at base defaults
433        assert_eq!(base.general.interval, "10s");
434        assert_eq!(base.ipc.rate_limit, 100);
435    }
436
437    #[test]
438    fn test_merge_from_preserves_existing() {
439        let mut base = Settings::default();
440        base.general.autostop_delay = "2m".to_string();
441        base.web.bind_port = 8080;
442
443        // An empty partial has all-None fields - nothing should change
444        let empty_partial = SettingsPartial::default();
445        base.apply_partial(&empty_partial);
446
447        assert_eq!(base.general.autostop_delay, "2m"); // preserved
448        assert_eq!(base.web.bind_port, 8080); // preserved
449    }
450
451    #[test]
452    fn test_merge_chain() {
453        // Simulate system -> user -> project merge chain
454        let mut settings = Settings::default();
455
456        // System config: set some values
457        let mut system_partial = SettingsPartial::default();
458        system_partial.general.log_level = Some("warn".to_string());
459        system_partial.web.bind_address = Some("0.0.0.0".to_string());
460        settings.apply_partial(&system_partial);
461
462        // User config: override log_level back to info, add tui setting
463        let mut user_partial = SettingsPartial::default();
464        user_partial.general.log_level = Some("info".to_string());
465        user_partial.tui.refresh_rate = Some("1s".to_string());
466        settings.apply_partial(&user_partial);
467
468        // Project config: override log_level to debug, enable web
469        let mut project_partial = SettingsPartial::default();
470        project_partial.general.log_level = Some("debug".to_string());
471        project_partial.web.auto_start = Some(true);
472        settings.apply_partial(&project_partial);
473
474        // Verify final merged state
475        assert_eq!(settings.general.log_level, "debug"); // from project
476        assert_eq!(settings.web.bind_address, "0.0.0.0"); // from system (not overridden)
477        assert_eq!(settings.tui.refresh_rate, "1s"); // from user
478        assert!(settings.web.auto_start); // from project
479
480        // Also verify Bug 5 fix: explicitly setting a value equal to the default
481        // correctly overrides a prior non-default value.
482        // system set log_level = "warn", then user explicitly sets it back to "info" (the default)
483        // - old broken merge_from would have skipped it because "info" == default
484        // - new apply_partial correctly sets it because the partial has Some("info")
485        // (then project overrides to "debug", but the intermediate step passed)
486        let mut s2 = Settings::default();
487        let mut p1 = SettingsPartial::default();
488        p1.general.log_level = Some("warn".to_string());
489        s2.apply_partial(&p1);
490        assert_eq!(s2.general.log_level, "warn");
491
492        // Now explicitly reset to default value "info" - must work
493        let mut p2 = SettingsPartial::default();
494        p2.general.log_level = Some("info".to_string());
495        s2.apply_partial(&p2);
496        assert_eq!(s2.general.log_level, "info"); // Bug 5 would have left this as "warn"
497    }
498
499    #[test]
500    fn test_settings_in_pitchfork_toml() {
501        // Test parsing settings from pitchfork.toml format via SettingsPartial
502        let toml_content = r#"
503[daemons.myapp]
504run = "node server.js"
505
506[settings.general]
507autostop_delay = "5m"
508log_level = "debug"
509
510[settings.web]
511auto_start = true
512bind_port = 8080
513
514[settings.supervisor]
515user = "postgres"
516"#;
517
518        // Parse the [settings] section as SettingsPartial
519        let table: toml::Table = toml::from_str(toml_content).unwrap();
520        let settings_table = table.get("settings").unwrap();
521        let partial: SettingsPartial = settings_table.clone().try_into().unwrap();
522
523        // Apply onto defaults to get resolved Settings
524        let mut settings = Settings::default();
525        settings.apply_partial(&partial);
526
527        assert_eq!(settings.general.autostop_delay, "5m");
528        assert_eq!(settings.general.log_level, "debug");
529        assert!(settings.web.auto_start);
530        assert_eq!(settings.web.bind_port, 8080);
531        assert_eq!(settings.supervisor.user, "postgres");
532        assert_eq!(settings.general.interval, "10s");
533        assert_eq!(settings.ipc.connect_attempts, 5);
534
535        // Unset fields in partial must be None
536        assert!(partial.general.interval.is_none());
537        assert!(partial.ipc.connect_attempts.is_none());
538    }
539}