Skip to main content

consortium/
defaults.rs

1//! Configuration defaults.
2//!
3//! Rust implementation of `ClusterShell.Defaults`.
4//!
5//! Provides global configuration loaded from INI files, controlling task
6//! defaults, engine settings, and NodeSet fold behavior.
7
8use std::collections::HashMap;
9use std::path::PathBuf;
10use std::sync::LazyLock;
11
12use configparser::ini::Ini;
13
14/// Configuration section names matching the Python implementation.
15const CFG_SECTION_TASK_DEFAULT: &str = "task.default";
16const CFG_SECTION_TASK_INFO: &str = "task.info";
17const CFG_SECTION_NODESET: &str = "nodeset";
18const CFG_SECTION_ENGINE: &str = "engine";
19
20/// Globally accessible Defaults singleton, loaded from standard config paths.
21pub static DEFAULTS: LazyLock<Defaults> = LazyLock::new(|| {
22    let paths = config_paths("defaults.conf");
23    Defaults::from_config(&paths)
24});
25
26/// Return default path list for a ClusterShell config file name.
27///
28/// Search order (later entries have higher priority):
29/// 1. `/etc/clustershell/<name>`
30/// 2. `~/.local/etc/clustershell/<name>`
31/// 3. `$XDG_CONFIG_HOME/clustershell/<name>` (default: `~/.config`)
32/// 4. `$CLUSTERSHELL_CFGDIR/<name>` (highest priority, if set)
33pub fn config_paths(name: &str) -> Vec<PathBuf> {
34    let mut paths = Vec::new();
35
36    // System-wide
37    paths.push(PathBuf::from("/etc/clustershell").join(name));
38
39    // Per-user pip --user style
40    if let Some(home) = home_dir() {
41        paths.push(home.join(".local/etc/clustershell").join(name));
42    }
43
44    // XDG config
45    let xdg_config = std::env::var("XDG_CONFIG_HOME")
46        .map(PathBuf::from)
47        .unwrap_or_else(|_| {
48            home_dir()
49                .map(|h| h.join(".config"))
50                .unwrap_or_else(|| PathBuf::from(".config"))
51        });
52    paths.push(xdg_config.join("clustershell").join(name));
53
54    // $CLUSTERSHELL_CFGDIR has precedence over any other config paths
55    if let Ok(cfgdir) = std::env::var("CLUSTERSHELL_CFGDIR") {
56        paths.push(PathBuf::from(cfgdir).join(name));
57    }
58
59    paths
60}
61
62/// Get the user's home directory.
63fn home_dir() -> Option<PathBuf> {
64    std::env::var("HOME").ok().map(PathBuf::from)
65}
66
67/// A typed config value that can be stored in the defaults dictionaries.
68#[derive(Debug, Clone, PartialEq)]
69pub enum ConfigValue {
70    Bool(bool),
71    Int(i64),
72    Float(f64),
73    Str(String),
74    IntTuple(Vec<i64>),
75}
76
77impl ConfigValue {
78    /// Get as bool, panicking if wrong type.
79    pub fn as_bool(&self) -> bool {
80        match self {
81            ConfigValue::Bool(v) => *v,
82            _ => panic!("expected bool, got {:?}", self),
83        }
84    }
85
86    /// Get as i64, panicking if wrong type.
87    pub fn as_int(&self) -> i64 {
88        match self {
89            ConfigValue::Int(v) => *v,
90            _ => panic!("expected int, got {:?}", self),
91        }
92    }
93
94    /// Get as f64, panicking if wrong type.
95    pub fn as_float(&self) -> f64 {
96        match self {
97            ConfigValue::Float(v) => *v,
98            ConfigValue::Int(v) => *v as f64,
99            _ => panic!("expected float, got {:?}", self),
100        }
101    }
102
103    /// Get as &str, panicking if wrong type.
104    pub fn as_str(&self) -> &str {
105        match self {
106            ConfigValue::Str(v) => v,
107            _ => panic!("expected str, got {:?}", self),
108        }
109    }
110
111    /// Get as tuple of integers.
112    pub fn as_int_tuple(&self) -> &[i64] {
113        match self {
114            ConfigValue::IntTuple(v) => v,
115            _ => panic!("expected int tuple, got {:?}", self),
116        }
117    }
118}
119
120/// Runtime configuration with sane defaults.
121///
122/// Mirrors the four config dictionaries from the Python implementation:
123/// `_task_default`, `_task_info`, `_nodeset`, `_engine`.
124#[derive(Debug, Clone)]
125pub struct Defaults {
126    task_default: HashMap<String, ConfigValue>,
127    task_info: HashMap<String, ConfigValue>,
128    nodeset: HashMap<String, ConfigValue>,
129    engine: HashMap<String, ConfigValue>,
130}
131
132/// Which type a given key should be parsed as.
133#[derive(Clone, Copy)]
134enum ValueType {
135    Bool,
136    Int,
137    Float,
138    Str,
139    IntTuple,
140}
141
142impl Defaults {
143    /// Create a new Defaults with built-in default values.
144    pub fn new() -> Self {
145        let mut task_default = HashMap::new();
146        task_default.insert("stderr".into(), ConfigValue::Bool(false));
147        task_default.insert("stdin".into(), ConfigValue::Bool(true));
148        task_default.insert("stdout_msgtree".into(), ConfigValue::Bool(true));
149        task_default.insert("stderr_msgtree".into(), ConfigValue::Bool(true));
150        task_default.insert("engine".into(), ConfigValue::Str("auto".into()));
151        task_default.insert("port_qlimit".into(), ConfigValue::Int(100)); // 1.8 compat
152        task_default.insert("auto_tree".into(), ConfigValue::Bool(true));
153        task_default.insert("local_workername".into(), ConfigValue::Str("exec".into()));
154        task_default.insert("distant_workername".into(), ConfigValue::Str("ssh".into()));
155
156        let mut task_info = HashMap::new();
157        task_info.insert("debug".into(), ConfigValue::Bool(false));
158        task_info.insert("fanout".into(), ConfigValue::Int(64));
159        task_info.insert("grooming_delay".into(), ConfigValue::Float(0.25));
160        task_info.insert("connect_timeout".into(), ConfigValue::Float(10.0));
161        task_info.insert("command_timeout".into(), ConfigValue::Float(0.0));
162
163        let mut nodeset = HashMap::new();
164        nodeset.insert("fold_axis".into(), ConfigValue::IntTuple(vec![]));
165
166        let mut engine = HashMap::new();
167        engine.insert("port_qlimit".into(), ConfigValue::Int(100));
168
169        Defaults {
170            task_default,
171            task_info,
172            nodeset,
173            engine,
174        }
175    }
176
177    /// Load defaults from config file paths.
178    ///
179    /// Files are read in order; later files override earlier ones.
180    /// Only files that actually exist on disk are parsed.
181    pub fn from_config(paths: &[PathBuf]) -> Self {
182        let mut defaults = Self::new();
183
184        for path in paths {
185            let mut ini = Ini::new();
186            if ini.load(path.to_string_lossy().as_ref()).is_ok() {
187                defaults.parse_ini(&ini);
188            }
189        }
190
191        defaults
192    }
193
194    /// Parse an INI config, overriding current values.
195    fn parse_ini(&mut self, ini: &Ini) {
196        // Task default converters
197        let task_default_types: &[(&str, ValueType)] = &[
198            ("stderr", ValueType::Bool),
199            ("stdin", ValueType::Bool),
200            ("stdout_msgtree", ValueType::Bool),
201            ("stderr_msgtree", ValueType::Bool),
202            ("engine", ValueType::Str),
203            ("port_qlimit", ValueType::Int),
204            ("auto_tree", ValueType::Bool),
205            ("local_workername", ValueType::Str),
206            ("distant_workername", ValueType::Str),
207        ];
208
209        Self::apply_section(
210            ini,
211            CFG_SECTION_TASK_DEFAULT,
212            task_default_types,
213            &mut self.task_default,
214        );
215
216        // Task info converters
217        let task_info_types: &[(&str, ValueType)] = &[
218            ("debug", ValueType::Bool),
219            ("fanout", ValueType::Int),
220            ("grooming_delay", ValueType::Float),
221            ("connect_timeout", ValueType::Float),
222            ("command_timeout", ValueType::Float),
223        ];
224
225        Self::apply_section(
226            ini,
227            CFG_SECTION_TASK_INFO,
228            task_info_types,
229            &mut self.task_info,
230        );
231
232        // NodeSet converters
233        let nodeset_types: &[(&str, ValueType)] = &[("fold_axis", ValueType::IntTuple)];
234
235        Self::apply_section(ini, CFG_SECTION_NODESET, nodeset_types, &mut self.nodeset);
236
237        // Engine converters
238        let engine_types: &[(&str, ValueType)] = &[("port_qlimit", ValueType::Int)];
239
240        Self::apply_section(ini, CFG_SECTION_ENGINE, engine_types, &mut self.engine);
241    }
242
243    /// Apply values from one INI section to a config dictionary.
244    fn apply_section(
245        ini: &Ini,
246        section: &str,
247        types: &[(&str, ValueType)],
248        target: &mut HashMap<String, ConfigValue>,
249    ) {
250        for &(key, vtype) in types {
251            if let Some(raw) = ini.get(section, key) {
252                if let Some(val) = Self::parse_value(&raw, vtype) {
253                    target.insert(key.to_string(), val);
254                }
255            }
256        }
257    }
258
259    /// Parse a raw string value according to the expected type.
260    fn parse_value(raw: &str, vtype: ValueType) -> Option<ConfigValue> {
261        match vtype {
262            ValueType::Bool => {
263                let lower = raw.to_lowercase();
264                match lower.as_str() {
265                    "true" | "yes" | "1" | "on" => Some(ConfigValue::Bool(true)),
266                    "false" | "no" | "0" | "off" => Some(ConfigValue::Bool(false)),
267                    _ => None,
268                }
269            }
270            ValueType::Int => raw.trim().parse::<i64>().ok().map(ConfigValue::Int),
271            ValueType::Float => raw.trim().parse::<f64>().ok().map(ConfigValue::Float),
272            ValueType::Str => Some(ConfigValue::Str(raw.to_string())),
273            ValueType::IntTuple => {
274                let vals: Vec<i64> = raw
275                    .split(',')
276                    .filter_map(|s| {
277                        let trimmed = s.trim();
278                        if trimmed.is_empty() {
279                            None
280                        } else {
281                            trimmed.parse::<i64>().ok()
282                        }
283                    })
284                    .collect();
285                Some(ConfigValue::IntTuple(vals))
286            }
287        }
288    }
289
290    /// Look up a config attribute by name.
291    ///
292    /// Search order matches the Python `__getattr__`:
293    /// engine -> task_default -> task_info -> nodeset.
294    ///
295    /// Special case: `port_qlimit` falls back to task_default if engine
296    /// value is unchanged from the built-in default (1.8 compat).
297    pub fn get(&self, name: &str) -> Option<&ConfigValue> {
298        // 1.8 compat: port_qlimit moved into engine section
299        if name == "port_qlimit" {
300            if let Some(engine_val) = self.engine.get(name) {
301                if *engine_val == ConfigValue::Int(100) {
302                    // Engine still at default, check task_default
303                    if let Some(td_val) = self.task_default.get(name) {
304                        return Some(td_val);
305                    }
306                }
307                return Some(engine_val);
308            }
309        }
310
311        if let Some(v) = self.engine.get(name) {
312            return Some(v);
313        }
314        if let Some(v) = self.task_default.get(name) {
315            return Some(v);
316        }
317        if let Some(v) = self.task_info.get(name) {
318            return Some(v);
319        }
320        if let Some(v) = self.nodeset.get(name) {
321            return Some(v);
322        }
323        None
324    }
325
326    /// Set a config attribute by name.
327    ///
328    /// Searches the same order as get().
329    pub fn set(&mut self, name: &str, value: ConfigValue) -> bool {
330        if self.engine.contains_key(name) {
331            self.engine.insert(name.to_string(), value);
332            return true;
333        }
334        if self.task_default.contains_key(name) {
335            self.task_default.insert(name.to_string(), value);
336            return true;
337        }
338        if self.task_info.contains_key(name) {
339            self.task_info.insert(name.to_string(), value);
340            return true;
341        }
342        if self.nodeset.contains_key(name) {
343            self.nodeset.insert(name.to_string(), value);
344            return true;
345        }
346        false
347    }
348
349    // Convenience accessors for common fields
350
351    /// Get the fanout value.
352    pub fn fanout(&self) -> i64 {
353        self.get("fanout").unwrap().as_int()
354    }
355
356    /// Get the connect timeout.
357    pub fn connect_timeout(&self) -> f64 {
358        self.get("connect_timeout").unwrap().as_float()
359    }
360
361    /// Get the command timeout.
362    pub fn command_timeout(&self) -> f64 {
363        self.get("command_timeout").unwrap().as_float()
364    }
365
366    /// Whether stderr is enabled.
367    pub fn stderr(&self) -> bool {
368        self.get("stderr").unwrap().as_bool()
369    }
370
371    /// Whether stdin is enabled.
372    pub fn stdin(&self) -> bool {
373        self.get("stdin").unwrap().as_bool()
374    }
375
376    /// Get the engine name.
377    pub fn engine(&self) -> &str {
378        self.get("engine").unwrap().as_str()
379    }
380
381    /// Get the local worker name.
382    pub fn local_workername(&self) -> &str {
383        self.get("local_workername").unwrap().as_str()
384    }
385
386    /// Get the distant worker name.
387    pub fn distant_workername(&self) -> &str {
388        self.get("distant_workername").unwrap().as_str()
389    }
390
391    /// Get the port queue limit.
392    pub fn port_qlimit(&self) -> i64 {
393        self.get("port_qlimit").unwrap().as_int()
394    }
395
396    /// Get the fold axis.
397    pub fn fold_axis(&self) -> &[i64] {
398        self.get("fold_axis").unwrap().as_int_tuple()
399    }
400
401    /// Whether debug mode is enabled.
402    pub fn debug(&self) -> bool {
403        self.get("debug").unwrap().as_bool()
404    }
405
406    /// Get the grooming delay.
407    pub fn grooming_delay(&self) -> f64 {
408        self.get("grooming_delay").unwrap().as_float()
409    }
410}
411
412impl Default for Defaults {
413    fn default() -> Self {
414        Self::new()
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421    use std::io::Write;
422
423    #[test]
424    fn test_defaults_have_correct_values() {
425        let d = Defaults::new();
426        assert!(!d.stderr());
427        assert!(d.stdin());
428        assert_eq!(d.fanout(), 64);
429        assert_eq!(d.connect_timeout(), 10.0);
430        assert_eq!(d.command_timeout(), 0.0);
431        assert_eq!(d.engine(), "auto");
432        assert_eq!(d.local_workername(), "exec");
433        assert_eq!(d.distant_workername(), "ssh");
434        assert_eq!(d.port_qlimit(), 100);
435        assert!(d.fold_axis().is_empty());
436        assert!(!d.debug());
437        assert_eq!(d.grooming_delay(), 0.25);
438    }
439
440    #[test]
441    fn test_config_paths_basic() {
442        let paths = config_paths("defaults.conf");
443        assert!(paths.len() >= 3);
444        assert_eq!(paths[0], PathBuf::from("/etc/clustershell/defaults.conf"));
445    }
446
447    // These tests mutate environment variables which is inherently racy
448    // in a multi-threaded test runner. They pass reliably with nextest
449    // (one process per test) but can flake with cargo test.
450    #[test]
451    fn test_config_paths_with_cfgdir() {
452        // SAFETY: env mutation — nextest isolates this in its own process
453        unsafe { std::env::set_var("CLUSTERSHELL_CFGDIR", "/tmp/test_cfg") };
454        let paths = config_paths("defaults.conf");
455        let last = paths.last().unwrap();
456        assert_eq!(*last, PathBuf::from("/tmp/test_cfg/defaults.conf"));
457        unsafe { std::env::remove_var("CLUSTERSHELL_CFGDIR") };
458    }
459
460    #[test]
461    fn test_config_paths_with_xdg() {
462        // SAFETY: env mutation — nextest isolates this in its own process
463        unsafe { std::env::set_var("XDG_CONFIG_HOME", "/tmp/xdg_test") };
464        unsafe { std::env::remove_var("CLUSTERSHELL_CFGDIR") };
465        let paths = config_paths("defaults.conf");
466        assert!(paths.contains(&PathBuf::from("/tmp/xdg_test/clustershell/defaults.conf")));
467        unsafe { std::env::remove_var("XDG_CONFIG_HOME") };
468    }
469
470    #[test]
471    fn test_from_config_empty_paths() {
472        let d = Defaults::from_config(&[]);
473        assert_eq!(d.fanout(), 64);
474        assert_eq!(d.connect_timeout(), 10.0);
475    }
476
477    #[test]
478    fn test_from_config_with_ini_file() {
479        let dir = std::env::temp_dir().join("consortium_test_defaults");
480        std::fs::create_dir_all(&dir).unwrap();
481        let conf_path = dir.join("test_defaults.conf");
482
483        let mut f = std::fs::File::create(&conf_path).unwrap();
484        writeln!(f, "[task.info]").unwrap();
485        writeln!(f, "fanout = 128").unwrap();
486        writeln!(f, "connect_timeout = 30.0").unwrap();
487        writeln!(f, "debug = true").unwrap();
488        writeln!(f, "").unwrap();
489        writeln!(f, "[task.default]").unwrap();
490        writeln!(f, "stderr = true").unwrap();
491        writeln!(f, "engine = select").unwrap();
492        writeln!(f, "distant_workername = rsh").unwrap();
493        writeln!(f, "").unwrap();
494        writeln!(f, "[nodeset]").unwrap();
495        writeln!(f, "fold_axis = 1,2,3").unwrap();
496        writeln!(f, "").unwrap();
497        writeln!(f, "[engine]").unwrap();
498        writeln!(f, "port_qlimit = 200").unwrap();
499        drop(f);
500
501        let d = Defaults::from_config(&[conf_path.clone()]);
502        assert_eq!(d.fanout(), 128);
503        assert_eq!(d.connect_timeout(), 30.0);
504        assert!(d.debug());
505        assert!(d.stderr());
506        assert_eq!(d.engine(), "select");
507        assert_eq!(d.distant_workername(), "rsh");
508        assert_eq!(d.fold_axis(), &[1, 2, 3]);
509        assert_eq!(d.port_qlimit(), 200);
510
511        // Cleanup
512        std::fs::remove_file(&conf_path).ok();
513        std::fs::remove_dir(&dir).ok();
514    }
515
516    #[test]
517    fn test_set_attribute() {
518        let mut d = Defaults::new();
519        assert_eq!(d.fanout(), 64);
520        d.set("fanout", ConfigValue::Int(32));
521        assert_eq!(d.fanout(), 32);
522    }
523
524    #[test]
525    fn test_set_unknown_attribute() {
526        let mut d = Defaults::new();
527        assert!(!d.set("nonexistent", ConfigValue::Bool(true)));
528    }
529
530    #[test]
531    fn test_get_unknown_attribute() {
532        let d = Defaults::new();
533        assert!(d.get("nonexistent").is_none());
534    }
535
536    #[test]
537    fn test_config_value_accessors() {
538        assert_eq!(ConfigValue::Bool(true).as_bool(), true);
539        assert_eq!(ConfigValue::Int(42).as_int(), 42);
540        assert_eq!(ConfigValue::Float(3.14).as_float(), 3.14);
541        assert_eq!(ConfigValue::Int(42).as_float(), 42.0);
542        assert_eq!(ConfigValue::Str("hello".into()).as_str(), "hello");
543        assert_eq!(
544            ConfigValue::IntTuple(vec![1, 2, 3]).as_int_tuple(),
545            &[1, 2, 3]
546        );
547    }
548
549    #[test]
550    fn test_port_qlimit_compat() {
551        // When engine port_qlimit is at default (100), task_default value is used
552        let mut d = Defaults::new();
553        // Change task_default port_qlimit
554        d.task_default
555            .insert("port_qlimit".into(), ConfigValue::Int(50));
556        // Engine is still at default 100, so task_default value should be returned
557        assert_eq!(d.port_qlimit(), 50);
558
559        // Now change engine port_qlimit to non-default
560        d.engine.insert("port_qlimit".into(), ConfigValue::Int(200));
561        assert_eq!(d.port_qlimit(), 200);
562    }
563
564    #[test]
565    fn test_parse_bool_variants() {
566        let check = |s, expected| {
567            let v = Defaults::parse_value(s, ValueType::Bool);
568            assert_eq!(v, Some(ConfigValue::Bool(expected)));
569        };
570        check("true", true);
571        check("True", true);
572        check("yes", true);
573        check("1", true);
574        check("on", true);
575        check("false", false);
576        check("False", false);
577        check("no", false);
578        check("0", false);
579        check("off", false);
580
581        assert_eq!(Defaults::parse_value("maybe", ValueType::Bool), None);
582    }
583
584    #[test]
585    fn test_parse_int_tuple_empty() {
586        let v = Defaults::parse_value("", ValueType::IntTuple);
587        assert_eq!(v, Some(ConfigValue::IntTuple(vec![])));
588    }
589
590    #[test]
591    fn test_multiple_config_files_override() {
592        let dir = std::env::temp_dir().join("consortium_test_multi");
593        std::fs::create_dir_all(&dir).unwrap();
594
595        let conf1 = dir.join("base.conf");
596        let conf2 = dir.join("override.conf");
597
598        let mut f1 = std::fs::File::create(&conf1).unwrap();
599        writeln!(f1, "[task.info]").unwrap();
600        writeln!(f1, "fanout = 32").unwrap();
601        writeln!(f1, "connect_timeout = 5.0").unwrap();
602        drop(f1);
603
604        let mut f2 = std::fs::File::create(&conf2).unwrap();
605        writeln!(f2, "[task.info]").unwrap();
606        writeln!(f2, "fanout = 256").unwrap();
607        drop(f2);
608
609        let d = Defaults::from_config(&[conf1.clone(), conf2.clone()]);
610        // fanout overridden by second file
611        assert_eq!(d.fanout(), 256);
612        // connect_timeout from first file
613        assert_eq!(d.connect_timeout(), 5.0);
614
615        // Cleanup
616        std::fs::remove_file(&conf1).ok();
617        std::fs::remove_file(&conf2).ok();
618        std::fs::remove_dir(&dir).ok();
619    }
620
621    #[test]
622    fn test_defaults_default_trait() {
623        let d = Defaults::default();
624        assert_eq!(d.fanout(), 64);
625    }
626}