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 #[test]
451 fn test_config_paths_with_cfgdir() {
452 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 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 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 let mut d = Defaults::new();
553 d.task_default
555 .insert("port_qlimit".into(), ConfigValue::Int(50));
556 assert_eq!(d.port_qlimit(), 50);
558
559 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 assert_eq!(d.fanout(), 256);
612 assert_eq!(d.connect_timeout(), 5.0);
614
615 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}