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