1use std::collections::HashSet;
18use std::path::{Path, PathBuf};
19
20use consortium::node_set::NodeSet;
21use consortium::node_utils::GroupResolverConfig;
22
23pub fn user_config_dir() -> Option<PathBuf> {
26 if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME").filter(|v| !v.is_empty()) {
27 return Some(PathBuf::from(xdg).join("clustershell"));
28 }
29 std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config/clustershell"))
30}
31
32pub fn find_groups_conf() -> Vec<PathBuf> {
34 let mut paths = Vec::new();
35
36 if let Some(dir) = user_config_dir() {
37 let user_conf = dir.join("groups.conf");
38 if user_conf.exists() {
39 paths.push(user_conf);
40 }
41 }
42
43 let sys_conf = PathBuf::from("/etc/clustershell/groups.conf");
45 if sys_conf.exists() {
46 paths.push(sys_conf);
47 }
48
49 paths
50}
51
52pub fn load_group_resolver() -> anyhow::Result<GroupResolverConfig> {
54 let paths = find_groups_conf();
55 if paths.is_empty() {
56 anyhow::bail!(
57 "no groups.conf found (checked ~/.config/clustershell/ and /etc/clustershell/)"
58 );
59 }
60 Ok(GroupResolverConfig::new(paths, HashSet::new()))
61}
62
63pub fn groups_d_dirs() -> Vec<PathBuf> {
65 let mut dirs = Vec::new();
66 if let Some(dir) = user_config_dir() {
67 dirs.push(dir.join("groups.d"));
68 }
69 dirs.push(PathBuf::from("/etc/clustershell/groups.d"));
70 dirs
71}
72
73pub fn parse_flat_groups(content: &str) -> Vec<(String, String)> {
76 content
77 .lines()
78 .map(str::trim)
79 .filter(|l| !l.is_empty() && !l.starts_with('#'))
80 .filter_map(|l| {
81 let (name, nodes) = l.split_once(':')?;
82 let name = name.trim();
83 if name.is_empty() {
84 return None;
85 }
86 Some((name.to_string(), nodes.trim().to_string()))
87 })
88 .collect()
89}
90
91pub fn lookup_flat_group(dirs: &[PathBuf], source: Option<&str>, group: &str) -> Option<String> {
94 for dir in dirs {
95 let Ok(entries) = std::fs::read_dir(dir) else {
96 continue;
97 };
98 let mut files: Vec<PathBuf> = entries
99 .filter_map(|e| e.ok().map(|e| e.path()))
100 .filter(|p| p.is_file() && !is_yaml(p))
101 .filter(|p| {
102 source.map_or(true, |s| {
103 p.file_stem().and_then(|st| st.to_str()) == Some(s)
104 })
105 })
106 .collect();
107 files.sort();
108 for file in files {
109 let Ok(content) = std::fs::read_to_string(&file) else {
110 continue;
111 };
112 if let Some((_, nodes)) = parse_flat_groups(&content)
113 .into_iter()
114 .find(|(g, _)| g == group)
115 {
116 return Some(nodes);
117 }
118 }
119 }
120 None
121}
122
123fn is_yaml(path: &Path) -> bool {
124 matches!(
125 path.extension().and_then(|e| e.to_str()),
126 Some("yaml") | Some("yml")
127 )
128}
129
130pub fn group_nodes(spec: &str) -> anyhow::Result<Vec<String>> {
133 let (source, group) = match spec.split_once(':') {
134 Some((s, g)) => (Some(s), g),
135 None => (None, spec),
136 };
137 if group.is_empty() {
138 anyhow::bail!("empty group name in '@{}'", spec);
139 }
140
141 if let Ok(mut config) = load_group_resolver() {
142 if let Ok(resolver) = config.resolver() {
143 if let Ok(nodes) = resolver.group_nodes(group, source) {
144 if !nodes.is_empty() {
145 return Ok(nodes);
146 }
147 }
148 }
149 }
150
151 let dirs = groups_d_dirs();
152 if let Some(nodes) = lookup_flat_group(&dirs, source, group) {
153 return Ok(split_patterns(&nodes));
154 }
155
156 let mut checked: Vec<String> = find_groups_conf()
157 .iter()
158 .map(|p| p.display().to_string())
159 .collect();
160 checked.extend(dirs.iter().map(|d| format!("{}/*", d.display())));
161 anyhow::bail!(
162 "group '@{}' not found (checked: {})",
163 spec,
164 checked.join(", ")
165 )
166}
167
168fn split_patterns(nodes: &str) -> Vec<String> {
170 nodes
171 .split(|c: char| c == ',' || c.is_whitespace())
172 .filter(|s| !s.is_empty())
173 .map(str::to_string)
174 .collect()
175}
176
177pub fn expand_pattern(
181 pattern: &str,
182 lookup: &dyn Fn(&str) -> anyhow::Result<Vec<String>>,
183) -> anyhow::Result<NodeSet> {
184 let mut ns = NodeSet::new();
185 let mut plain: Vec<&str> = Vec::new();
186 for token in split_top_level(pattern) {
187 if let Some(group) = token.strip_prefix('@') {
188 for node_pattern in lookup(group)? {
189 let parsed = NodeSet::parse(&node_pattern).map_err(|e| {
190 anyhow::anyhow!(
191 "invalid node pattern '{}' in group '@{}': {}",
192 node_pattern,
193 group,
194 e
195 )
196 })?;
197 ns.update(&parsed);
198 }
199 } else if !token.is_empty() {
200 plain.push(token);
201 }
202 }
203 if !plain.is_empty() {
204 let joined = plain.join(",");
205 let parsed = NodeSet::parse(&joined)
206 .map_err(|e| anyhow::anyhow!("invalid node pattern '{}': {}", joined, e))?;
207 ns.update(&parsed);
208 }
209 Ok(ns)
210}
211
212fn split_top_level(pattern: &str) -> Vec<&str> {
214 let mut out = Vec::new();
215 let mut depth = 0usize;
216 let mut start = 0;
217 for (i, c) in pattern.char_indices() {
218 match c {
219 '[' => depth += 1,
220 ']' => depth = depth.saturating_sub(1),
221 ',' if depth == 0 => {
222 out.push(pattern[start..i].trim());
223 start = i + 1;
224 }
225 _ => {}
226 }
227 }
228 out.push(pattern[start..].trim());
229 out
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235
236 #[test]
237 fn parses_flat_group_lines_and_skips_comments() {
238 let groups = parse_flat_groups(
239 "# fleet\n\ndarwin: mac01.local mac02.local\nlinux: box[01-03],gpu01\nbad line\n",
240 );
241 assert_eq!(
242 groups,
243 vec![
244 ("darwin".to_string(), "mac01.local mac02.local".to_string()),
245 ("linux".to_string(), "box[01-03],gpu01".to_string()),
246 ]
247 );
248 }
249
250 #[test]
251 fn flat_lookup_prefers_first_dir_and_honours_source_stem() {
252 let user = tempfile::tempdir().unwrap();
253 let sys = tempfile::tempdir().unwrap();
254 std::fs::write(user.path().join("cluster.cfg"), "web: u[1-2]\n").unwrap();
255 std::fs::write(user.path().join("lab.cfg"), "web: lab1\ndb: lab-db\n").unwrap();
256 std::fs::write(user.path().join("cluster.yaml"), "web: yaml-only\n").unwrap();
257 std::fs::write(sys.path().join("cluster.cfg"), "web: s[1-2]\nsys: s9\n").unwrap();
258 let dirs = vec![user.path().to_path_buf(), sys.path().to_path_buf()];
259
260 assert_eq!(
261 lookup_flat_group(&dirs, None, "web").as_deref(),
262 Some("u[1-2]")
263 );
264 assert_eq!(
265 lookup_flat_group(&dirs, Some("lab"), "web").as_deref(),
266 Some("lab1")
267 );
268 assert_eq!(
269 lookup_flat_group(&dirs, None, "db").as_deref(),
270 Some("lab-db")
271 );
272 assert_eq!(lookup_flat_group(&dirs, None, "sys").as_deref(), Some("s9"));
273 assert_eq!(lookup_flat_group(&dirs, None, "nope"), None);
274 }
275
276 #[test]
277 fn expand_pattern_mixes_groups_and_bracket_ranges() {
278 let lookup = |g: &str| -> anyhow::Result<Vec<String>> {
279 match g {
280 "darwin" => Ok(vec!["mac01.local".into(), "mac02.local".into()]),
281 "gpu" => Ok(vec!["gpu[1-2]".into()]),
282 other => anyhow::bail!("group '@{}' not found", other),
283 }
284 };
285 let ns = expand_pattern("@darwin,box[01-02],@gpu", &lookup).unwrap();
286 let mut nodes: Vec<String> = ns.iter().collect();
287 nodes.sort();
288 assert_eq!(
289 nodes,
290 vec![
291 "box01",
292 "box02",
293 "gpu1",
294 "gpu2",
295 "mac01.local",
296 "mac02.local"
297 ]
298 );
299
300 let err = expand_pattern("@nope", &lookup).unwrap_err();
301 assert!(err.to_string().contains("'@nope' not found"), "{err}");
302 }
303
304 #[test]
305 fn split_top_level_keeps_bracket_commas() {
306 assert_eq!(split_top_level("a[1,3],@g, b"), vec!["a[1,3]", "@g", "b"]);
307 }
308}