1use std::collections::HashMap;
9use std::path::PathBuf;
10use std::sync::LazyLock;
11
12use configparser::ini::Ini;
13
14const 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
20pub static DEFAULTS: LazyLock<Defaults> = LazyLock::new(|| {
22 let paths = config_paths("defaults.conf");
23 Defaults::from_config(&paths)
24});
25
26pub fn config_paths(name: &str) -> Vec<PathBuf> {
34 let mut paths = Vec::new();
35
36 paths.push(PathBuf::from("/etc/clustershell").join(name));
38
39 if let Some(home) = home_dir() {
41 paths.push(home.join(".local/etc/clustershell").join(name));
42 }
43
44 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 if let Ok(cfgdir) = std::env::var("CLUSTERSHELL_CFGDIR") {
56 paths.push(PathBuf::from(cfgdir).join(name));
57 }
58
59 paths
60}
61
62fn home_dir() -> Option<PathBuf> {
64 std::env::var("HOME").ok().map(PathBuf::from)
65}
66
67#[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 pub fn as_bool(&self) -> bool {
80 match self {
81 ConfigValue::Bool(v) => *v,
82 _ => panic!("expected bool, got {:?}", self),
83 }
84 }
85
86 pub fn as_int(&self) -> i64 {
88 match self {
89 ConfigValue::Int(v) => *v,
90 _ => panic!("expected int, got {:?}", self),
91 }
92 }
93
94 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 pub fn as_str(&self) -> &str {
105 match self {
106 ConfigValue::Str(v) => v,
107 _ => panic!("expected str, got {:?}", self),
108 }
109 }
110
111 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#[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#[derive(Clone, Copy)]
134enum ValueType {
135 Bool,
136 Int,
137 Float,
138 Str,
139 IntTuple,
140}
141
142impl Defaults {
143 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)); 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 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 fn parse_ini(&mut self, ini: &Ini) {
196 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 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 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 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 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 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 pub fn get(&self, name: &str) -> Option<&ConfigValue> {
298 if name == "port_qlimit" {
300 if let Some(engine_val) = self.engine.get(name) {
301 if *engine_val == ConfigValue::Int(100) {
302 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 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 pub fn fanout(&self) -> i64 {
353 self.get("fanout").unwrap().as_int()
354 }
355
356 pub fn connect_timeout(&self) -> f64 {
358 self.get("connect_timeout").unwrap().as_float()
359 }
360
361 pub fn command_timeout(&self) -> f64 {
363 self.get("command_timeout").unwrap().as_float()
364 }
365
366 pub fn stderr(&self) -> bool {
368 self.get("stderr").unwrap().as_bool()
369 }
370
371 pub fn stdin(&self) -> bool {
373 self.get("stdin").unwrap().as_bool()
374 }
375
376 pub fn engine(&self) -> &str {
378 self.get("engine").unwrap().as_str()
379 }
380
381 pub fn local_workername(&self) -> &str {
383 self.get("local_workername").unwrap().as_str()
384 }
385
386 pub fn distant_workername(&self) -> &str {
388 self.get("distant_workername").unwrap().as_str()
389 }
390
391 pub fn port_qlimit(&self) -> i64 {
393 self.get("port_qlimit").unwrap().as_int()
394 }
395
396 pub fn fold_axis(&self) -> &[i64] {
398 self.get("fold_axis").unwrap().as_int_tuple()
399 }
400
401 pub fn debug(&self) -> bool {
403 self.get("debug").unwrap().as_bool()
404 }
405
406 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 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
449
450 #[test]
451 fn test_config_paths_with_cfgdir() {
452 let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
453 unsafe { std::env::set_var("CLUSTERSHELL_CFGDIR", "/tmp/test_cfg") };
455 let paths = config_paths("defaults.conf");
456 let last = paths.last().unwrap();
457 assert_eq!(*last, PathBuf::from("/tmp/test_cfg/defaults.conf"));
458 unsafe { std::env::remove_var("CLUSTERSHELL_CFGDIR") };
459 }
460
461 #[test]
462 fn test_config_paths_with_xdg() {
463 let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
464 unsafe { std::env::set_var("XDG_CONFIG_HOME", "/tmp/xdg_test") };
466 unsafe { std::env::remove_var("CLUSTERSHELL_CFGDIR") };
467 let paths = config_paths("defaults.conf");
468 assert!(paths.contains(&PathBuf::from("/tmp/xdg_test/clustershell/defaults.conf")));
469 unsafe { std::env::remove_var("XDG_CONFIG_HOME") };
470 }
471
472 #[test]
473 fn test_from_config_empty_paths() {
474 let d = Defaults::from_config(&[]);
475 assert_eq!(d.fanout(), 64);
476 assert_eq!(d.connect_timeout(), 10.0);
477 }
478
479 #[test]
480 fn test_from_config_with_ini_file() {
481 let dir = std::env::temp_dir().join("consortium_test_defaults");
482 std::fs::create_dir_all(&dir).unwrap();
483 let conf_path = dir.join("test_defaults.conf");
484
485 let mut f = std::fs::File::create(&conf_path).unwrap();
486 writeln!(f, "[task.info]").unwrap();
487 writeln!(f, "fanout = 128").unwrap();
488 writeln!(f, "connect_timeout = 30.0").unwrap();
489 writeln!(f, "debug = true").unwrap();
490 writeln!(f).unwrap();
491 writeln!(f, "[task.default]").unwrap();
492 writeln!(f, "stderr = true").unwrap();
493 writeln!(f, "engine = select").unwrap();
494 writeln!(f, "distant_workername = rsh").unwrap();
495 writeln!(f).unwrap();
496 writeln!(f, "[nodeset]").unwrap();
497 writeln!(f, "fold_axis = 1,2,3").unwrap();
498 writeln!(f).unwrap();
499 writeln!(f, "[engine]").unwrap();
500 writeln!(f, "port_qlimit = 200").unwrap();
501 drop(f);
502
503 let d = Defaults::from_config(std::slice::from_ref(&conf_path));
504 assert_eq!(d.fanout(), 128);
505 assert_eq!(d.connect_timeout(), 30.0);
506 assert!(d.debug());
507 assert!(d.stderr());
508 assert_eq!(d.engine(), "select");
509 assert_eq!(d.distant_workername(), "rsh");
510 assert_eq!(d.fold_axis(), &[1, 2, 3]);
511 assert_eq!(d.port_qlimit(), 200);
512
513 std::fs::remove_file(&conf_path).ok();
515 std::fs::remove_dir(&dir).ok();
516 }
517
518 #[test]
519 fn test_set_attribute() {
520 let mut d = Defaults::new();
521 assert_eq!(d.fanout(), 64);
522 d.set("fanout", ConfigValue::Int(32));
523 assert_eq!(d.fanout(), 32);
524 }
525
526 #[test]
527 fn test_set_unknown_attribute() {
528 let mut d = Defaults::new();
529 assert!(!d.set("nonexistent", ConfigValue::Bool(true)));
530 }
531
532 #[test]
533 fn test_get_unknown_attribute() {
534 let d = Defaults::new();
535 assert!(d.get("nonexistent").is_none());
536 }
537
538 #[test]
539 fn test_config_value_accessors() {
540 assert!(ConfigValue::Bool(true).as_bool());
541 assert_eq!(ConfigValue::Int(42).as_int(), 42);
542 assert_eq!(ConfigValue::Float(2.5).as_float(), 2.5);
543 assert_eq!(ConfigValue::Int(42).as_float(), 42.0);
544 assert_eq!(ConfigValue::Str("hello".into()).as_str(), "hello");
545 assert_eq!(
546 ConfigValue::IntTuple(vec![1, 2, 3]).as_int_tuple(),
547 &[1, 2, 3]
548 );
549 }
550
551 #[test]
552 fn test_port_qlimit_compat() {
553 let mut d = Defaults::new();
555 d.task_default
557 .insert("port_qlimit".into(), ConfigValue::Int(50));
558 assert_eq!(d.port_qlimit(), 50);
560
561 d.engine.insert("port_qlimit".into(), ConfigValue::Int(200));
563 assert_eq!(d.port_qlimit(), 200);
564 }
565
566 #[test]
567 fn test_parse_bool_variants() {
568 let check = |s, expected| {
569 let v = Defaults::parse_value(s, ValueType::Bool);
570 assert_eq!(v, Some(ConfigValue::Bool(expected)));
571 };
572 check("true", true);
573 check("True", true);
574 check("yes", true);
575 check("1", true);
576 check("on", true);
577 check("false", false);
578 check("False", false);
579 check("no", false);
580 check("0", false);
581 check("off", false);
582
583 assert_eq!(Defaults::parse_value("maybe", ValueType::Bool), None);
584 }
585
586 #[test]
587 fn test_parse_int_tuple_empty() {
588 let v = Defaults::parse_value("", ValueType::IntTuple);
589 assert_eq!(v, Some(ConfigValue::IntTuple(vec![])));
590 }
591
592 #[test]
593 fn test_multiple_config_files_override() {
594 let dir = std::env::temp_dir().join("consortium_test_multi");
595 std::fs::create_dir_all(&dir).unwrap();
596
597 let conf1 = dir.join("base.conf");
598 let conf2 = dir.join("override.conf");
599
600 let mut f1 = std::fs::File::create(&conf1).unwrap();
601 writeln!(f1, "[task.info]").unwrap();
602 writeln!(f1, "fanout = 32").unwrap();
603 writeln!(f1, "connect_timeout = 5.0").unwrap();
604 drop(f1);
605
606 let mut f2 = std::fs::File::create(&conf2).unwrap();
607 writeln!(f2, "[task.info]").unwrap();
608 writeln!(f2, "fanout = 256").unwrap();
609 drop(f2);
610
611 let d = Defaults::from_config(&[conf1.clone(), conf2.clone()]);
612 assert_eq!(d.fanout(), 256);
614 assert_eq!(d.connect_timeout(), 5.0);
616
617 std::fs::remove_file(&conf1).ok();
619 std::fs::remove_file(&conf2).ok();
620 std::fs::remove_dir(&dir).ok();
621 }
622
623 #[test]
624 fn test_defaults_default_trait() {
625 let d = Defaults::default();
626 assert_eq!(d.fanout(), 64);
627 }
628}