Skip to main content

consortium/
node_set.rs

1//! Node name sets with bracket-based range expansion.
2//!
3//! Rust implementation of `ClusterShell.NodeSet`.
4//!
5//! Supports bracket notation like `node[1-10,15]`, set operations
6//! (union `,`, difference `!`, intersection `&`, symmetric difference `^`),
7//! and automatic folding for compact display.
8
9use crate::range_set::RangeSet;
10use std::collections::HashMap;
11use std::fmt;
12use std::str::FromStr;
13use thiserror::Error;
14
15// ============================================================================
16// Errors
17// ============================================================================
18
19#[derive(Debug, Error)]
20pub enum NodeSetError {
21    #[error("parse error: {msg} (in \"{part}\")")]
22    ParseError { part: String, msg: String },
23
24    #[error("range error: {0}")]
25    RangeError(#[from] crate::range_set::RangeSetError),
26
27    #[error("external error: {0}")]
28    ExternalError(String),
29
30    /// Mirrors Python's `IndexError`/`ValueError` raised by `__getitem__`
31    /// slice handling (eg. illegal slice parameters).
32    #[error("index error: {0}")]
33    IndexError(String),
34}
35
36pub type Result<T> = std::result::Result<T, NodeSetError>;
37
38// ============================================================================
39// Internal: NodeSetBase
40// ============================================================================
41
42/// Internal representation: maps pattern templates (with `%s` placeholders for
43/// range indices) to their `RangeSet` (or `None` for non-indexed nodes).
44///
45/// Examples:
46///  - `"node%s"` → `Some(RangeSet{1,2,3})` represents `node1`, `node2`, `node3`
47///  - `"switch"` → `None` represents a single non-indexed node `switch`
48#[derive(Debug, Clone)]
49struct NodeSetBase {
50    patterns: HashMap<String, Option<RangeSet>>,
51    autostep: Option<u32>,
52}
53
54impl NodeSetBase {
55    fn new() -> Self {
56        Self {
57            patterns: HashMap::new(),
58            autostep: None,
59        }
60    }
61
62    fn with_autostep(autostep: Option<u32>) -> Self {
63        Self {
64            patterns: HashMap::new(),
65            autostep,
66        }
67    }
68
69    /// Add a pattern with its rangeset. If the pattern already exists, merge.
70    fn add(&mut self, pat: &str, rangeset: Option<RangeSet>) {
71        if let Some(existing) = self.patterns.get_mut(pat) {
72            if let (Some(existing_rs), Some(new_rs)) = (existing.as_mut(), &rangeset) {
73                existing_rs.update(new_rs);
74            }
75            // If existing is None (non-indexed) and we add None, nothing to do.
76            // If mixed indexed/non-indexed, keep existing behavior.
77        } else {
78            self.patterns.insert(pat.to_string(), rangeset);
79        }
80    }
81
82    fn len(&self) -> usize {
83        self.patterns
84            .values()
85            .map(|rs| match rs {
86                Some(rs) => rs.len(),
87                None => 1, // non-indexed node
88            })
89            .sum()
90    }
91
92    fn is_empty(&self) -> bool {
93        self.patterns.is_empty()
94    }
95
96    fn clear(&mut self) {
97        self.patterns.clear();
98    }
99
100    /// Update (union) with another NodeSetBase.
101    fn update(&mut self, other: &NodeSetBase) {
102        for (pat, rangeset) in &other.patterns {
103            self.add(pat, rangeset.clone());
104        }
105    }
106
107    /// Intersection update: keep only elements in both.
108    fn intersection_update(&mut self, other: &NodeSetBase) {
109        let mut result = HashMap::new();
110        for (pat, other_rs) in &other.patterns {
111            if let Some(self_rs) = self.patterns.get(pat) {
112                match (self_rs, other_rs) {
113                    (Some(s), Some(o)) => {
114                        let intersection = s.intersection(o);
115                        if intersection.len() > 0 {
116                            result.insert(pat.clone(), Some(intersection));
117                        }
118                    }
119                    (None, None) => {
120                        // Both are non-indexed, keep it
121                        result.insert(pat.clone(), None);
122                    }
123                    _ => {} // mixed indexed/non-indexed: no intersection
124                }
125            }
126        }
127        self.patterns = result;
128    }
129
130    /// Difference update: remove elements in other from self.
131    fn difference_update(&mut self, other: &NodeSetBase) {
132        let mut purge = Vec::new();
133        for (pat, other_rs) in &other.patterns {
134            if let Some(self_rs) = self.patterns.get_mut(pat) {
135                match (self_rs, other_rs) {
136                    (Some(s), Some(o)) => {
137                        s.difference_update(o);
138                        if s.len() == 0 {
139                            purge.push(pat.clone());
140                        }
141                    }
142                    (None, None) => {
143                        purge.push(pat.clone());
144                    }
145                    _ => {} // mixed: no removal
146                }
147            }
148        }
149        for pat in purge {
150            self.patterns.remove(&pat);
151        }
152    }
153
154    /// Symmetric difference update.
155    fn symmetric_difference_update(&mut self, other: &NodeSetBase) {
156        let mut purge = Vec::new();
157
158        // Process patterns in self against other
159        for (pat, self_rs) in self.patterns.iter_mut() {
160            if let Some(other_rs) = other.patterns.get(pat) {
161                match (self_rs.as_mut(), other_rs) {
162                    (Some(s), Some(o)) => {
163                        s.symmetric_difference_update(o);
164                        if s.len() == 0 {
165                            purge.push(pat.clone());
166                        }
167                    }
168                    (None, None) => {
169                        // Both non-indexed: xor removes
170                        purge.push(pat.clone());
171                    }
172                    _ => {}
173                }
174            }
175        }
176
177        // Add patterns in other but not in self
178        for (pat, other_rs) in &other.patterns {
179            if !self.patterns.contains_key(pat) {
180                self.add(pat, other_rs.clone());
181            }
182        }
183
184        for pat in purge {
185            self.patterns.remove(&pat);
186        }
187    }
188
189    /// Check if a node is contained.
190    ///
191    /// Strategy: parse the node into (pattern, digit_strings), then look
192    /// up by matching stored patterns. For multi-dimensional stored patterns
193    /// (expanded), we also try the fully-expanded form.
194    fn contains(&self, node: &str) -> bool {
195        let (pat, indices) = parse_node_indices(node);
196
197        // Try exact pattern match first
198        if let Some(rs_opt) = self.patterns.get(&pat) {
199            return match (rs_opt, indices.as_slice()) {
200                (Some(rs), &[(_, _)]) => {
201                    // Use string-based lookup to handle padding correctly
202                    let digit_str = extract_last_digit_str(node);
203                    rs.contains_str(&digit_str)
204                }
205                (None, &[]) => true,
206                _ => false,
207            };
208        }
209
210        // For multi-dimensional patterns that were expanded during parsing,
211        // the stored patterns have fewer %s placeholders. Try substituting
212        // leading indices to find a match.
213        if indices.len() > 1 {
214            // Extract digit strings from the node
215            let digit_strs = extract_all_digit_strs(node);
216            // Try substituting first N-1 indices into the pattern to match stored keys
217            for n_sub in 1..indices.len() {
218                let mut candidate_pat = pat.clone();
219                for i in 0..n_sub {
220                    candidate_pat = candidate_pat.replacen("%s", &digit_strs[i], 1);
221                }
222                if let Some(Some(rs)) = self.patterns.get(&candidate_pat) {
223                    // Check last digit against the rangeset
224                    let last_digit = &digit_strs[digit_strs.len() - 1];
225                    if rs.contains_str(last_digit) {
226                        return true;
227                    }
228                }
229            }
230        }
231
232        false
233    }
234
235    /// Patterns in iteration order: sorted by pattern string, matching
236    /// Python's `sorted(self._patterns.items())`.
237    fn sorted_patterns(&self) -> Vec<(&String, &Option<RangeSet>)> {
238        let mut sorted_pats: Vec<_> = self.patterns.iter().collect();
239        sorted_pats.sort_by(|a, b| a.0.cmp(b.0));
240        sorted_pats
241    }
242
243    /// Iterate all individual node names in sorted order.
244    ///
245    /// Matches Python's `NodeSet.__iter__`: patterns sorted by pattern
246    /// string, each expanded in `RangeSet` order (numeric-aware, so eg.
247    /// `cde9` comes before `cde11`). The whole list is NOT re-sorted
248    /// lexicographically.
249    fn iter(&self) -> Vec<String> {
250        let mut nodes = Vec::new();
251        for (pat, rs_opt) in self.sorted_patterns() {
252            match rs_opt {
253                Some(rs) => {
254                    for s in rs.striter() {
255                        nodes.push(pat.replacen("%s", &s, 1));
256                    }
257                }
258                None => {
259                    nodes.push(pat.clone());
260                }
261            }
262        }
263        nodes
264    }
265
266    /// Position of the single node `name` in iteration order, or `None` if
267    /// it is not present (or if `name` does not resolve to exactly one
268    /// node). Mirrors `NodeSetBase.index()`: the argument is parsed like a
269    /// nodeset (it must hold exactly one node), then the node is matched
270    /// against the stored pattern shapes with zero-padding significant.
271    fn position_of(&self, name: &str) -> Option<usize> {
272        let searched = parse_nodeset_string(name, self.autostep).ok()?;
273        let nodes = searched.iter();
274        if nodes.len() != 1 {
275            // like list.index(), the argument must be a single node
276            return None;
277        }
278        let node = &nodes[0];
279
280        let mut base = 0usize;
281        for (pat, rs_opt) in self.sorted_patterns() {
282            match rs_opt {
283                Some(rs) => {
284                    if let Some(digits) = match_pattern_index(pat, node) {
285                        // pattern shape matched: the position of this
286                        // (possibly zero-padded) index decides
287                        if let Some(offset) = rs.sorted().iter().position(|s| s == &digits) {
288                            return Some(base + offset);
289                        }
290                    }
291                }
292                // unnumbered node (eg. 'server')
293                None => {
294                    if pat.as_str() == node {
295                        return Some(base);
296                    }
297                }
298            }
299            base += match rs_opt {
300                Some(rs) => rs.len(),
301                None => 1,
302            };
303        }
304        None
305    }
306
307    /// Build the sub-nodeset selected by a Python-style slice
308    /// `start:stop:step` over iteration order. Mirrors
309    /// `NodeSet.__getitem__` with a slice argument and `_extractslice()`
310    /// normalization, including the stepped-slice walk across multiple
311    /// patterns (upstream fix 1b2509b: the next pick after each pattern is
312    /// `sl_start + k*sl_step`, computed with floor division).
313    fn slice_base(&self, start: Option<i64>, stop: Option<i64>, step: i64) -> Result<NodeSetBase> {
314        if step == 0 {
315            return Err(NodeSetError::IndexError(
316                "slice step cannot be zero".to_string(),
317            ));
318        }
319        let length = self.len() as i64;
320        // _extractslice(): start/stop normalization
321        let mut sl_start = match start {
322            None => 0,
323            Some(s) if s < 0 => (length + s).max(0),
324            Some(s) => s,
325        };
326        let sl_stop = match stop {
327            None => i64::MAX,
328            Some(s) if s < 0 => (length + s).max(0),
329            Some(s) => s,
330        };
331        let sl_step = if step < 0 {
332            // We support negative step slicing with no start/stop, ie. ns[::-n].
333            if start.is_some() || stop.is_some() {
334                return Err(NodeSetError::IndexError(
335                    "illegal start and stop when negative step is used".to_string(),
336                ));
337            }
338            // As elements are ordered internally, adjust sl_start to fake
339            // backward stepping in case of negative slice step.
340            let abs_step = -step;
341            let stepmod = (length + abs_step - 1) % abs_step;
342            if stepmod > 0 {
343                sl_start += stepmod;
344            }
345            abs_step
346        } else {
347            step
348        };
349
350        let mut inst = NodeSetBase::with_autostep(self.autostep);
351        if sl_stop <= sl_start {
352            return Ok(inst);
353        }
354
355        // Global indices to pick are exactly sl_start, sl_start+sl_step, ...
356        // below sl_stop; walk patterns in iteration order and, for each one,
357        // collect the elements whose global index is picked (this is the
358        // observable result of Python's pattern walk with floor division).
359        let mut next_pick = sl_start;
360        let mut base = 0i64;
361        for (pat, rs_opt) in self.sorted_patterns() {
362            let cnt = match rs_opt {
363                Some(rs) => rs.len() as i64,
364                None => 1,
365            };
366            match rs_opt {
367                Some(rs) => {
368                    if next_pick < base + cnt {
369                        // sub-rangeset preserving zero-padding
370                        let mut sub = RangeSet::new();
371                        sub.set_autostep(self.autostep);
372                        let sorted = rs.sorted();
373                        while next_pick < base + cnt && next_pick < sl_stop {
374                            sub.add_str(&sorted[(next_pick - base) as usize]);
375                            next_pick += sl_step;
376                        }
377                        inst.add(pat, Some(sub));
378                    }
379                }
380                None => {
381                    if next_pick == base && next_pick < sl_stop {
382                        inst.add(pat, None);
383                        next_pick += sl_step;
384                    }
385                }
386            }
387            if next_pick >= sl_stop {
388                break;
389            }
390            base += cnt;
391        }
392        Ok(inst)
393    }
394}
395
396impl fmt::Display for NodeSetBase {
397    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
398        let mut parts: Vec<String> = Vec::new();
399        // Sort patterns for deterministic output (same order as iteration)
400        for (pat, rs_opt) in self.sorted_patterns() {
401            match rs_opt {
402                Some(rs) => {
403                    let rs_str = format!("{}", rs);
404                    if rs.len() == 1 {
405                        // Single element: no brackets
406                        parts.push(pat.replacen("%s", &rs_str, 1));
407                    } else {
408                        // Multiple elements: brackets
409                        let bracketed = format!("[{}]", rs_str);
410                        parts.push(pat.replacen("%s", &bracketed, 1));
411                    }
412                }
413                None => {
414                    parts.push(pat.clone());
415                }
416            }
417        }
418        write!(f, "{}", parts.join(","))
419    }
420}
421
422// ============================================================================
423// Parsing helpers
424// ============================================================================
425
426/// Scan a single node name like `node003` into (pattern, optional int value).
427/// Returns (pattern_with_%s, Option<integer_value>).
428#[allow(dead_code)]
429fn scan_node_single(node: &str) -> (String, Option<i64>) {
430    // Split into alternating non-digit/digit segments
431    // e.g., "node003" -> [("node", "003")]
432    // e.g., "rack2-node05" -> [("rack", "2"), ("-node", "05")]
433    // e.g., "switch" -> [("switch", "")]
434
435    let mut pat = String::new();
436    let mut has_index = false;
437    let mut last_value: Option<i64> = None;
438
439    let chars: Vec<char> = node.chars().collect();
440    let len = chars.len();
441    let mut i = 0;
442
443    while i < len {
444        // Consume non-digit prefix
445        let prefix_start = i;
446        while i < len && !chars[i].is_ascii_digit() {
447            i += 1;
448        }
449        let prefix = &node[prefix_start..i];
450
451        // Consume digit suffix
452        let digit_start = i;
453        while i < len && chars[i].is_ascii_digit() {
454            i += 1;
455        }
456        let digits = &node[digit_start..i];
457
458        if digits.is_empty() {
459            pat.push_str(prefix);
460        } else {
461            pat.push_str(prefix);
462            pat.push_str("%s");
463            has_index = true;
464            last_value = Some(digits.parse().unwrap_or(0));
465        }
466    }
467
468    if has_index {
469        (pat, last_value)
470    } else {
471        (pat, None)
472    }
473}
474
475/// Parse a single node into (pattern, Vec<(value, pad)>).
476/// Handles multi-dimensional indices like "rack2-node05".
477fn parse_node_indices(node: &str) -> (String, Vec<(i64, u32)>) {
478    let mut pat = String::new();
479    let mut indices = Vec::new();
480
481    let chars: Vec<char> = node.chars().collect();
482    let len = chars.len();
483    let mut i = 0;
484
485    while i < len {
486        let prefix_start = i;
487        while i < len && !chars[i].is_ascii_digit() {
488            i += 1;
489        }
490        pat.push_str(&node[prefix_start..i]);
491
492        let digit_start = i;
493        while i < len && chars[i].is_ascii_digit() {
494            i += 1;
495        }
496        let digits = &node[digit_start..i];
497
498        if !digits.is_empty() {
499            pat.push_str("%s");
500            let value: i64 = digits.parse().unwrap_or(0);
501            let pad = if value != 0 {
502                let stripped = digits.trim_start_matches('0');
503                if digits.len() > stripped.len() {
504                    digits.len() as u32
505                } else {
506                    0
507                }
508            } else if digits.len() > 1 {
509                digits.len() as u32
510            } else {
511                0
512            };
513            indices.push((value, pad));
514        }
515    }
516
517    (pat, indices)
518}
519
520/// Parse a nodeset string, handling brackets and operators.
521///
522/// Supports:
523///  - Simple nodes: `node1`, `switch`
524///  - Bracket ranges: `node[1-10]`, `node[1,3,5-8]`
525///  - Multiple patterns: `web[1-3],db[1-2]`
526///  - Operators: `!` (difference), `&` (intersection), `^` (xor)
527///  - Leading/trailing digit amendment around brackets
528fn parse_nodeset_string(input: &str, autostep: Option<u32>) -> Result<NodeSetBase> {
529    let input = input.trim();
530    if input.is_empty() {
531        return Ok(NodeSetBase::with_autostep(autostep));
532    }
533
534    let mut result = NodeSetBase::with_autostep(autostep);
535    let mut current_op = Op::Update; // Default operation is union
536
537    // Tokenize into segments separated by operators (, ! & ^)
538    // But operators inside brackets should be ignored
539    let tokens = tokenize(input)?;
540
541    for token in tokens {
542        match token {
543            Token::Operator(op) => {
544                current_op = op;
545            }
546            Token::Pattern(s) => {
547                let parsed = parse_single_pattern(&s, autostep)?;
548                match current_op {
549                    Op::Update => result.update(&parsed),
550                    Op::Difference => result.difference_update(&parsed),
551                    Op::Intersection => result.intersection_update(&parsed),
552                    Op::SymmetricDifference => result.symmetric_difference_update(&parsed),
553                }
554            }
555        }
556    }
557
558    Ok(result)
559}
560
561#[derive(Debug, Clone, Copy)]
562enum Op {
563    Update,
564    Difference,
565    Intersection,
566    SymmetricDifference,
567}
568
569#[derive(Debug)]
570enum Token {
571    Operator(Op),
572    Pattern(String),
573}
574
575/// Tokenize a nodeset string, respecting brackets.
576fn tokenize(input: &str) -> Result<Vec<Token>> {
577    let mut tokens = Vec::new();
578    let mut current = String::new();
579    let mut bracket_depth = 0;
580    let chars: Vec<char> = input.chars().collect();
581    let mut i = 0;
582
583    while i < chars.len() {
584        let ch = chars[i];
585        match ch {
586            '[' => {
587                bracket_depth += 1;
588                current.push(ch);
589            }
590            ']' => {
591                if bracket_depth == 0 {
592                    return Err(NodeSetError::ParseError {
593                        part: input.to_string(),
594                        msg: "illegal closing bracket".to_string(),
595                    });
596                }
597                bracket_depth -= 1;
598                current.push(ch);
599            }
600            ',' | '!' | '&' | '^' if bracket_depth == 0 => {
601                // This is an operator
602                if !current.is_empty() {
603                    tokens.push(Token::Pattern(current.trim().to_string()));
604                    current = String::new();
605                }
606                let op = match ch {
607                    ',' => Op::Update,
608                    '!' => Op::Difference,
609                    '&' => Op::Intersection,
610                    '^' => Op::SymmetricDifference,
611                    _ => unreachable!(),
612                };
613                tokens.push(Token::Operator(op));
614            }
615            _ => {
616                current.push(ch);
617            }
618        }
619        i += 1;
620    }
621
622    if bracket_depth != 0 {
623        return Err(NodeSetError::ParseError {
624            part: input.to_string(),
625            msg: "missing bracket".to_string(),
626        });
627    }
628
629    if !current.is_empty() {
630        tokens.push(Token::Pattern(current.trim().to_string()));
631    }
632
633    // Validate: first token should be a pattern (or empty input)
634    // Insert an implicit Update at the beginning if first is a pattern
635    let mut result = Vec::new();
636    let mut expect_pattern = true;
637    for token in tokens {
638        match &token {
639            Token::Pattern(_) => {
640                if expect_pattern {
641                    if result.is_empty() {
642                        // First pattern: implicit Update
643                        result.push(Token::Operator(Op::Update));
644                    }
645                    result.push(token);
646                    expect_pattern = false;
647                } else {
648                    // Two patterns in a row without operator — shouldn't happen
649                    // with our tokenizer since comma is an operator
650                    result.push(Token::Operator(Op::Update));
651                    result.push(token);
652                    expect_pattern = false;
653                }
654            }
655            Token::Operator(_) => {
656                result.push(token);
657                expect_pattern = true;
658            }
659        }
660    }
661
662    Ok(result)
663}
664
665/// Parse a single pattern segment (no operators at this level).
666/// Handles brackets and plain node names.
667fn parse_single_pattern(pat: &str, autostep: Option<u32>) -> Result<NodeSetBase> {
668    let mut base = NodeSetBase::with_autostep(autostep);
669
670    if pat.contains('[') {
671        // Bracket notation: e.g., "node[1-10]", "rack[1-2]-node[1-5]"
672        parse_bracketed(pat, autostep, &mut base)?;
673    } else {
674        // Plain node name like "node1" or "switch"
675        let (pattern, indices) = parse_node_indices(pat);
676        if indices.is_empty() {
677            base.add(&pattern, None);
678        } else {
679            // Build a rangeset from the single index values
680            let mut rs = RangeSet::new();
681            rs.set_autostep(autostep);
682            // For single-dimensional simple nodes
683            if indices.len() == 1 {
684                let (val, pad) = indices[0];
685                rs.add_int(val, pad);
686                base.add(&pattern, Some(rs));
687            } else {
688                // Multi-dimensional: expand leading indices into the pattern,
689                // keeping only the last dimension as the rangeset.
690                // e.g., "192.168.1.29" → pattern "%s.%s.%s.%s", indices [(192,0),(168,0),(1,0),(29,0)]
691                //   → expanded pattern "192.168.1.%s", rangeset {29}
692                let digit_strs = extract_all_digit_strs(pat);
693                let mut expanded_pat = pattern.clone();
694                for ds in &digit_strs[..digit_strs.len() - 1] {
695                    expanded_pat = expanded_pat.replacen("%s", ds, 1);
696                }
697                let (val, pad) = indices[indices.len() - 1];
698                rs.add_int(val, pad);
699                base.add(&expanded_pat, Some(rs));
700            }
701        }
702    }
703
704    Ok(base)
705}
706
707/// Parse bracketed notation: "node[1-10,15]", "rack[1-2]-node[1-5]"
708fn parse_bracketed(pat: &str, autostep: Option<u32>, base: &mut NodeSetBase) -> Result<()> {
709    // Split into prefix[range]suffix segments
710    let mut remaining = pat;
711    let mut template = String::new();
712    let mut rangesets: Vec<RangeSet> = Vec::new();
713
714    while let Some(bracket_start) = remaining.find('[') {
715        let prefix = &remaining[..bracket_start];
716
717        // Handle leading digits amendment:
718        // "node10[1-3]" → prefix="node10", amend range to "101-103"
719        let (clean_prefix, leading_digits) = split_trailing_digits(prefix);
720        template.push_str(clean_prefix);
721        template.push_str("%s");
722
723        let after_bracket = &remaining[bracket_start + 1..];
724        let bracket_end = after_bracket
725            .find(']')
726            .ok_or_else(|| NodeSetError::ParseError {
727                part: pat.to_string(),
728                msg: "missing bracket".to_string(),
729            })?;
730
731        let range_str = &after_bracket[..bracket_end];
732        remaining = &after_bracket[bracket_end + 1..];
733
734        // Handle trailing digits amendment:
735        // "node[1-3]5" → suffix starts with "5", amend range
736        let (trailing_digits, clean_remaining) = split_leading_digits(remaining);
737
738        // Amend the range string if we have leading/trailing digits
739        let amended_range = amend_range(range_str, &leading_digits, &trailing_digits)?;
740
741        let rs = RangeSet::parse(&amended_range, autostep)?;
742        rangesets.push(rs);
743
744        remaining = clean_remaining;
745    }
746
747    // Append any remaining suffix
748    if !remaining.is_empty() {
749        // Check for trailing node-like content (non-digit)
750        template.push_str(remaining);
751    }
752
753    // For single-dimensional case
754    if rangesets.len() == 1 {
755        base.add(&template, Some(rangesets.into_iter().next().unwrap()));
756    } else if rangesets.is_empty() {
757        base.add(&template, None);
758    } else {
759        // Multi-dimensional: expand all combinations
760        // e.g., rack[1-2]-node[1-3] → 6 nodes
761        expand_multi_dim(&template, &rangesets, base);
762    }
763
764    Ok(())
765}
766
767/// Expand multi-dimensional bracket notation into individual patterns.
768fn expand_multi_dim(template: &str, rangesets: &[RangeSet], base: &mut NodeSetBase) {
769    // For multi-dimensional, we expand all but the last dimension
770    // and create individual patterns with the last dimension as the rangeset.
771    if rangesets.is_empty() {
772        return;
773    }
774
775    if rangesets.len() == 1 {
776        base.add(template, Some(rangesets[0].clone()));
777        return;
778    }
779
780    // Collect all values from each dimension except the last
781    let last_rs = &rangesets[rangesets.len() - 1];
782    let prefixes = expand_dimensions(&rangesets[..rangesets.len() - 1]);
783
784    // For each combination of prefix dimensions, create a pattern
785    for prefix_vals in &prefixes {
786        let mut pat = template.to_string();
787        for val in prefix_vals {
788            pat = pat.replacen("%s", val, 1);
789        }
790        // pat now has one %s left for the last dimension
791        base.add(&pat, Some(last_rs.clone()));
792    }
793}
794
795/// Expand multiple RangeSets into all combinations of their string values.
796fn expand_dimensions(rangesets: &[RangeSet]) -> Vec<Vec<String>> {
797    if rangesets.is_empty() {
798        return vec![vec![]];
799    }
800
801    let first_vals: Vec<String> = rangesets[0].striter().collect();
802    let rest = expand_dimensions(&rangesets[1..]);
803
804    let mut result = Vec::new();
805    for val in &first_vals {
806        for suffix in &rest {
807            let mut combined = vec![val.clone()];
808            combined.extend(suffix.iter().cloned());
809            result.push(combined);
810        }
811    }
812    result
813}
814
815/// Match a node name against a stored pattern shape holding exactly one
816/// `%s` placeholder. Returns the index digit-string substituted for `%s`
817/// (zero-padding preserved) when the node matches, `None` otherwise.
818///
819/// Stored patterns hold at most one `%s` (multi-dimensional inputs are
820/// expanded at parse time), but the placeholder may sit anywhere in the
821/// pattern (eg. `da%sp2` for `da[70-77]p2`), so matching is done by
822/// prefix/suffix anchoring rather than by re-parsing the node.
823fn match_pattern_index(pat: &str, node: &str) -> Option<String> {
824    let (prefix, suffix) = pat.split_once("%s")?;
825    if suffix.contains("%s") {
826        return None; // defensive: stored patterns hold at most one %s
827    }
828    let middle = node.strip_prefix(prefix)?.strip_suffix(suffix)?;
829    if middle.is_empty() || !middle.bytes().all(|b| b.is_ascii_digit()) {
830        return None;
831    }
832    Some(middle.to_string())
833}
834
835/// Extract the last run of digits from a node string, preserving padding.
836/// e.g., "node001" → "001", "rack2-node05" → "05"
837fn extract_last_digit_str(node: &str) -> String {
838    let bytes = node.as_bytes();
839    let mut end = bytes.len();
840    while end > 0 && !bytes[end - 1].is_ascii_digit() {
841        end -= 1;
842    }
843    let mut start = end;
844    while start > 0 && bytes[start - 1].is_ascii_digit() {
845        start -= 1;
846    }
847    node[start..end].to_string()
848}
849
850/// Extract all runs of digits from a node string, preserving padding.
851/// e.g., "rack2-node05" → ["2", "05"]
852fn extract_all_digit_strs(node: &str) -> Vec<String> {
853    let mut result = Vec::new();
854    let chars: Vec<char> = node.chars().collect();
855    let len = chars.len();
856    let mut i = 0;
857
858    while i < len {
859        // Skip non-digits
860        while i < len && !chars[i].is_ascii_digit() {
861            i += 1;
862        }
863        let start = i;
864        while i < len && chars[i].is_ascii_digit() {
865            i += 1;
866        }
867        if i > start {
868            result.push(node[start..i].to_string());
869        }
870    }
871    result
872}
873
874/// Split trailing digits from a prefix: "node10" → ("node", "10")
875fn split_trailing_digits(s: &str) -> (&str, String) {
876    let bytes = s.as_bytes();
877    let mut i = bytes.len();
878    while i > 0 && bytes[i - 1].is_ascii_digit() {
879        i -= 1;
880    }
881    (&s[..i], s[i..].to_string())
882}
883
884/// Split leading digits from remaining: "5-ilo" → ("5", "-ilo")
885fn split_leading_digits(s: &str) -> (String, &str) {
886    let bytes = s.as_bytes();
887    let mut i = 0;
888    while i < bytes.len() && bytes[i].is_ascii_digit() {
889        i += 1;
890    }
891    (s[..i].to_string(), &s[i..])
892}
893
894/// Amend a range string with leading/trailing digits.
895///
896/// Leading digits: "10" + "[1-3]" → "101-103"
897/// Trailing digits: "[1-3]" + "5" → "15-35/10"
898fn amend_range(range: &str, leading: &str, trailing: &str) -> Result<String> {
899    let mut result = range.to_string();
900
901    if !leading.is_empty() {
902        // Prepend leading digits to each bound
903        // "1-3,5-7" with leading "10" → "101-103,105-107"
904        let rs = RangeSet::parse(&result, None)?;
905        let mut parts = Vec::new();
906        // Get contiguous sub-ranges
907        for val_str in rs.striter() {
908            let combined = format!("{}{}", leading, val_str);
909            parts.push(combined);
910        }
911        // Re-parse the expanded list
912        result = parts.join(",");
913    }
914
915    if !trailing.is_empty() {
916        // Append trailing digits to each bound
917        let rs = RangeSet::parse(&result, None)?;
918        let mut parts = Vec::new();
919        let trail_len = trailing.len();
920        let power = 10i64.pow(trail_len as u32);
921        let trail_val: i64 = trailing.parse().unwrap_or(0);
922
923        for val in rs.intiter() {
924            let combined = val * power + trail_val;
925            parts.push(format!("{}", combined));
926        }
927        result = parts.join(",");
928    }
929
930    Ok(result)
931}
932
933// ============================================================================
934// Public API: NodeSet
935// ============================================================================
936
937/// A set of cluster node names supporting range notation like `node[1-100]`.
938///
939/// # Examples
940///
941/// ```
942/// use consortium::node_set::NodeSet;
943///
944/// let ns = NodeSet::parse("node[1-5,10]").unwrap();
945/// assert_eq!(ns.len(), 6);
946/// assert!(ns.contains("node3"));
947/// assert!(!ns.contains("node7"));
948/// ```
949#[derive(Debug, Clone)]
950pub struct NodeSet {
951    base: NodeSetBase,
952}
953
954impl NodeSet {
955    /// Create an empty NodeSet.
956    pub fn new() -> Self {
957        Self {
958            base: NodeSetBase::new(),
959        }
960    }
961
962    /// Parse a node set from a string like `"node[1-10,15],other[001-050]"`.
963    pub fn parse(pattern: &str) -> Result<Self> {
964        let base = parse_nodeset_string(pattern, None)?;
965        Ok(Self { base })
966    }
967
968    /// Parse with explicit autostep setting.
969    pub fn parse_with_autostep(pattern: &str, autostep: Option<u32>) -> Result<Self> {
970        let base = parse_nodeset_string(pattern, autostep)?;
971        Ok(Self { base })
972    }
973
974    /// Number of individual nodes in the set.
975    pub fn len(&self) -> usize {
976        self.base.len()
977    }
978
979    /// Whether the set is empty.
980    pub fn is_empty(&self) -> bool {
981        self.base.is_empty()
982    }
983
984    /// Check if a specific node name is in the set.
985    pub fn contains(&self, node: &str) -> bool {
986        self.base.contains(node)
987    }
988
989    /// Update (union) this nodeset with another.
990    pub fn update(&mut self, other: &NodeSet) {
991        self.base.update(&other.base);
992    }
993
994    /// Update from a string pattern.
995    pub fn update_str(&mut self, pattern: &str) -> Result<()> {
996        let other = parse_nodeset_string(pattern, self.base.autostep)?;
997        self.base.update(&other);
998        Ok(())
999    }
1000
1001    /// Return the union of this nodeset and another.
1002    pub fn union(&self, other: &NodeSet) -> NodeSet {
1003        let mut copy = self.clone();
1004        copy.update(other);
1005        copy
1006    }
1007
1008    /// Intersection update: keep only elements in both.
1009    pub fn intersection_update(&mut self, other: &NodeSet) {
1010        self.base.intersection_update(&other.base);
1011    }
1012
1013    /// Return the intersection of this nodeset and another.
1014    pub fn intersection(&self, other: &NodeSet) -> NodeSet {
1015        let mut copy = self.clone();
1016        copy.intersection_update(other);
1017        copy
1018    }
1019
1020    /// Difference update: remove elements of other from self.
1021    pub fn difference_update(&mut self, other: &NodeSet) {
1022        self.base.difference_update(&other.base);
1023    }
1024
1025    /// Return the difference of this nodeset and another.
1026    pub fn difference(&self, other: &NodeSet) -> NodeSet {
1027        let mut copy = self.clone();
1028        copy.difference_update(other);
1029        copy
1030    }
1031
1032    /// Symmetric difference update.
1033    pub fn symmetric_difference_update(&mut self, other: &NodeSet) {
1034        self.base.symmetric_difference_update(&other.base);
1035    }
1036
1037    /// Return the symmetric difference of this nodeset and another.
1038    pub fn symmetric_difference(&self, other: &NodeSet) -> NodeSet {
1039        let mut copy = self.clone();
1040        copy.symmetric_difference_update(other);
1041        copy
1042    }
1043
1044    /// Clear all nodes from this set.
1045    pub fn clear(&mut self) {
1046        self.base.clear();
1047    }
1048
1049    /// Iterate all individual node names in sorted order.
1050    pub fn iter(&self) -> impl Iterator<Item = String> + '_ {
1051        self.base.iter().into_iter()
1052    }
1053
1054    /// Return the zero-based position of the node `name` when iterating
1055    /// over this nodeset, or `None` if the node is not present.
1056    ///
1057    /// This is the list-like `index()` method (upstream commit 484d230):
1058    /// the reverse operation of [`NodeSet::get`]. Zero-padding is
1059    /// significant (`prune003` matches, `prune3` does not).
1060    ///
1061    /// ```
1062    /// use consortium::node_set::NodeSet;
1063    ///
1064    /// let ns = NodeSet::parse("node[0-9,20]").unwrap();
1065    /// assert_eq!(ns.index("node20"), Some(10));
1066    /// assert_eq!(ns.index("node42"), None);
1067    /// ```
1068    pub fn index(&self, name: &str) -> Option<usize> {
1069        self.index_in(name, 0, None)
1070    }
1071
1072    /// `list.index()`-like search restricted to the `[start, stop)` window
1073    /// of iteration order. Negative `start`/`stop` count from the end, like
1074    /// Python. Returns `None` when the node is absent or its position falls
1075    /// outside the window.
1076    fn index_in(&self, name: &str, start: i64, stop: Option<i64>) -> Option<usize> {
1077        let found = self.base.position_of(name)? as i64;
1078        if start != 0 || stop.is_some() {
1079            let length = self.len() as i64;
1080            let start = if start < 0 {
1081                (length + start).max(0)
1082            } else {
1083                start
1084            };
1085            let stop = match stop {
1086                None => length,
1087                Some(s) if s < 0 => (length + s).max(0),
1088                Some(s) => s,
1089            };
1090            if !(start <= found && found < stop) {
1091                return None;
1092            }
1093        }
1094        Some(found as usize)
1095    }
1096
1097    /// Return the node at position `index` in iteration order. Negative
1098    /// `index` counts from the end, like Python's `nodeset[index]`.
1099    /// Returns `None` when out of range (Python raises `IndexError`).
1100    pub fn get(&self, index: i64) -> Option<String> {
1101        let length = self.len() as i64;
1102        let idx = if index < 0 {
1103            if index >= -length {
1104                length + index
1105            } else {
1106                return None;
1107            }
1108        } else {
1109            index
1110        };
1111        if idx >= length {
1112            return None;
1113        }
1114        self.base.iter().into_iter().nth(idx as usize)
1115    }
1116
1117    /// Python-style slice `nodeset[start:stop:step]` over iteration order,
1118    /// returned as a new nodeset. `start`/`stop` may be negative (counted
1119    /// from the end); a negative `step` is only allowed with no `start` and
1120    /// no `stop` (mirroring `NodeSet._extractslice`). Stepped slices span
1121    /// multiple patterns exactly like upstream (commit 1b2509b).
1122    ///
1123    /// ```
1124    /// use consortium::node_set::NodeSet;
1125    ///
1126    /// let ns = NodeSet::parse("stone[1-9],wood[1-9]").unwrap();
1127    /// assert_eq!(ns.slice(None, None, 2).unwrap().to_string(),
1128    ///            "stone[1,3,5,7,9],wood[2,4,6,8]");
1129    /// ```
1130    pub fn slice(&self, start: Option<i64>, stop: Option<i64>, step: i64) -> Result<NodeSet> {
1131        Ok(NodeSet {
1132            base: self.base.slice_base(start, stop, step)?,
1133        })
1134    }
1135
1136    /// Split the nodeset into `nbr` sub-nodesets (at most).
1137    pub fn split(&self, nbr: usize) -> Vec<NodeSet> {
1138        assert!(nbr > 0);
1139        let all_nodes: Vec<String> = self.iter().collect();
1140        let total = all_nodes.len();
1141        let slice_size = total / nbr;
1142        let leftover = total % nbr;
1143
1144        let mut result = Vec::new();
1145        let mut begin = 0;
1146
1147        for i in 0..nbr.min(total) {
1148            let length = slice_size + if i < leftover { 1 } else { 0 };
1149            let slice = &all_nodes[begin..begin + length];
1150            let pattern = slice.join(",");
1151            if let Ok(ns) = NodeSet::parse(&pattern) {
1152                result.push(ns);
1153            }
1154            begin += length;
1155        }
1156
1157        result
1158    }
1159
1160    /// Check if this nodeset is a subset of another.
1161    pub fn is_subset(&self, other: &NodeSet) -> bool {
1162        self.difference(other).is_empty()
1163    }
1164
1165    /// Check if this nodeset is a superset of another.
1166    pub fn is_superset(&self, other: &NodeSet) -> bool {
1167        other.is_subset(self)
1168    }
1169}
1170
1171impl Default for NodeSet {
1172    fn default() -> Self {
1173        Self::new()
1174    }
1175}
1176
1177impl fmt::Display for NodeSet {
1178    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1179        write!(f, "{}", self.base)
1180    }
1181}
1182
1183impl FromStr for NodeSet {
1184    type Err = NodeSetError;
1185
1186    fn from_str(s: &str) -> Result<Self> {
1187        NodeSet::parse(s)
1188    }
1189}
1190
1191// ============================================================================
1192// Convenience functions
1193// ============================================================================
1194
1195/// Expand a nodeset pattern into a list of individual node names.
1196///
1197/// ```
1198/// use consortium::node_set::expand;
1199/// let nodes = expand("node[1-3]").unwrap();
1200/// assert_eq!(nodes, vec!["node1", "node2", "node3"]);
1201/// ```
1202pub fn expand(pat: &str) -> Result<Vec<String>> {
1203    let ns = NodeSet::parse(pat)?;
1204    Ok(ns.iter().collect())
1205}
1206
1207/// Fold a nodeset pattern: remove duplicates and compress ranges.
1208///
1209/// ```
1210/// use consortium::node_set::fold;
1211/// let s = fold("node3,node1,node2").unwrap();
1212/// assert_eq!(s, "node[1-3]");
1213/// ```
1214pub fn fold(pat: &str) -> Result<String> {
1215    let ns = NodeSet::parse(pat)?;
1216    Ok(format!("{}", ns))
1217}
1218
1219// ============================================================================
1220// Tests
1221// ============================================================================
1222
1223#[cfg(test)]
1224mod tests {
1225    use super::*;
1226
1227    #[test]
1228    fn test_new_is_empty() {
1229        let ns = NodeSet::new();
1230        assert!(ns.is_empty());
1231        assert_eq!(ns.len(), 0);
1232    }
1233
1234    #[test]
1235    fn test_parse_simple_node() {
1236        let ns = NodeSet::parse("node1").unwrap();
1237        assert_eq!(ns.len(), 1);
1238        assert!(ns.contains("node1"));
1239        assert!(!ns.contains("node2"));
1240    }
1241
1242    #[test]
1243    fn test_parse_range() {
1244        let ns = NodeSet::parse("node[1-5]").unwrap();
1245        assert_eq!(ns.len(), 5);
1246        for i in 1..=5 {
1247            assert!(ns.contains(&format!("node{}", i)));
1248        }
1249        assert!(!ns.contains("node0"));
1250        assert!(!ns.contains("node6"));
1251    }
1252
1253    #[test]
1254    fn test_parse_multiple_ranges() {
1255        let ns = NodeSet::parse("node[1-3,7-9]").unwrap();
1256        assert_eq!(ns.len(), 6);
1257        assert!(ns.contains("node1"));
1258        assert!(ns.contains("node3"));
1259        assert!(!ns.contains("node4"));
1260        assert!(ns.contains("node7"));
1261        assert!(ns.contains("node9"));
1262    }
1263
1264    #[test]
1265    fn test_parse_padded() {
1266        let ns = NodeSet::parse("node[001-003]").unwrap();
1267        assert_eq!(ns.len(), 3);
1268        assert!(ns.contains("node001"));
1269        assert!(ns.contains("node002"));
1270        assert!(ns.contains("node003"));
1271        let display = format!("{}", ns);
1272        assert!(display.contains("001"));
1273    }
1274
1275    #[test]
1276    fn test_parse_comma_separated() {
1277        let ns = NodeSet::parse("node1,node2,node3").unwrap();
1278        assert_eq!(ns.len(), 3);
1279        assert!(ns.contains("node1"));
1280        assert!(ns.contains("node2"));
1281        assert!(ns.contains("node3"));
1282    }
1283
1284    #[test]
1285    fn test_parse_difference_operator() {
1286        let ns = NodeSet::parse("node[1-10]!node[5-7]").unwrap();
1287        assert_eq!(ns.len(), 7);
1288        assert!(ns.contains("node1"));
1289        assert!(ns.contains("node4"));
1290        assert!(!ns.contains("node5"));
1291        assert!(!ns.contains("node6"));
1292        assert!(!ns.contains("node7"));
1293        assert!(ns.contains("node8"));
1294        assert!(ns.contains("node10"));
1295    }
1296
1297    #[test]
1298    fn test_parse_intersection_operator() {
1299        let ns = NodeSet::parse("node[1-10]&node[5-15]").unwrap();
1300        assert_eq!(ns.len(), 6);
1301        for i in 5..=10 {
1302            assert!(ns.contains(&format!("node{}", i)));
1303        }
1304        assert!(!ns.contains("node1"));
1305        assert!(!ns.contains("node11"));
1306    }
1307
1308    #[test]
1309    fn test_parse_xor_operator() {
1310        let ns = NodeSet::parse("node[1-5]^node[3-7]").unwrap();
1311        assert_eq!(ns.len(), 4);
1312        assert!(ns.contains("node1"));
1313        assert!(ns.contains("node2"));
1314        assert!(!ns.contains("node3"));
1315        assert!(!ns.contains("node5"));
1316        assert!(ns.contains("node6"));
1317        assert!(ns.contains("node7"));
1318    }
1319
1320    #[test]
1321    fn test_multi_pattern() {
1322        let ns = NodeSet::parse("web[1-3],db[1-2]").unwrap();
1323        assert_eq!(ns.len(), 5);
1324        assert!(ns.contains("web1"));
1325        assert!(ns.contains("web3"));
1326        assert!(ns.contains("db1"));
1327        assert!(ns.contains("db2"));
1328    }
1329
1330    #[test]
1331    fn test_display_fold() {
1332        let ns = NodeSet::parse("node3,node1,node2").unwrap();
1333        let display = format!("{}", ns);
1334        assert_eq!(display, "node[1-3]");
1335    }
1336
1337    #[test]
1338    fn test_set_union() {
1339        let a = NodeSet::parse("node[1-3]").unwrap();
1340        let b = NodeSet::parse("node[5-7]").unwrap();
1341        let c = a.union(&b);
1342        assert_eq!(c.len(), 6);
1343    }
1344
1345    #[test]
1346    fn test_set_intersection() {
1347        let a = NodeSet::parse("node[1-10]").unwrap();
1348        let b = NodeSet::parse("node[5-15]").unwrap();
1349        let c = a.intersection(&b);
1350        assert_eq!(c.len(), 6);
1351    }
1352
1353    #[test]
1354    fn test_set_difference() {
1355        let a = NodeSet::parse("node[1-10]").unwrap();
1356        let b = NodeSet::parse("node[5-7]").unwrap();
1357        let c = a.difference(&b);
1358        assert_eq!(c.len(), 7);
1359    }
1360
1361    #[test]
1362    fn test_set_symmetric_difference() {
1363        let a = NodeSet::parse("node[1-5]").unwrap();
1364        let b = NodeSet::parse("node[3-7]").unwrap();
1365        let c = a.symmetric_difference(&b);
1366        assert_eq!(c.len(), 4);
1367    }
1368
1369    #[test]
1370    fn test_iter_sorted() {
1371        let ns = NodeSet::parse("node[3,1,2]").unwrap();
1372        let nodes: Vec<String> = ns.iter().collect();
1373        assert_eq!(nodes, vec!["node1", "node2", "node3"]);
1374    }
1375
1376    #[test]
1377    fn test_non_indexed_node() {
1378        let ns = NodeSet::parse("switch").unwrap();
1379        assert_eq!(ns.len(), 1);
1380        assert!(ns.contains("switch"));
1381    }
1382
1383    #[test]
1384    fn test_expand_function() {
1385        let nodes = expand("node[1-3]").unwrap();
1386        assert_eq!(nodes, vec!["node1", "node2", "node3"]);
1387    }
1388
1389    #[test]
1390    fn test_fold_function() {
1391        let s = fold("node3,node1,node2").unwrap();
1392        assert_eq!(s, "node[1-3]");
1393    }
1394
1395    #[test]
1396    fn test_from_str() {
1397        let ns: NodeSet = "node[1-3]".parse().unwrap();
1398        assert_eq!(ns.len(), 3);
1399    }
1400
1401    #[test]
1402    fn test_update_str() {
1403        let mut ns = NodeSet::parse("node[1-3]").unwrap();
1404        ns.update_str("node[5-7]").unwrap();
1405        assert_eq!(ns.len(), 6);
1406    }
1407
1408    #[test]
1409    fn test_split() {
1410        let ns = NodeSet::parse("node[1-6]").unwrap();
1411        let parts = ns.split(3);
1412        assert_eq!(parts.len(), 3);
1413        assert_eq!(parts[0].len(), 2);
1414        assert_eq!(parts[1].len(), 2);
1415        assert_eq!(parts[2].len(), 2);
1416    }
1417
1418    #[test]
1419    fn test_subset_superset() {
1420        let a = NodeSet::parse("node[1-5]").unwrap();
1421        let b = NodeSet::parse("node[1-10]").unwrap();
1422        assert!(a.is_subset(&b));
1423        assert!(b.is_superset(&a));
1424        assert!(!b.is_subset(&a));
1425    }
1426
1427    #[test]
1428    fn test_multi_dimensional() {
1429        let ns = NodeSet::parse("rack[1-2]-node[1-3]").unwrap();
1430        assert_eq!(ns.len(), 6);
1431        assert!(ns.contains("rack1-node1"));
1432        assert!(ns.contains("rack1-node3"));
1433        assert!(ns.contains("rack2-node1"));
1434        assert!(ns.contains("rack2-node3"));
1435        assert!(!ns.contains("rack3-node1"));
1436    }
1437
1438    #[test]
1439    fn test_empty_parse() {
1440        let ns = NodeSet::parse("").unwrap();
1441        assert!(ns.is_empty());
1442    }
1443
1444    #[test]
1445    fn test_missing_bracket_error() {
1446        let result = NodeSet::parse("node[1-3");
1447        assert!(result.is_err());
1448    }
1449
1450    #[test]
1451    fn test_clear() {
1452        let mut ns = NodeSet::parse("node[1-5]").unwrap();
1453        assert!(!ns.is_empty());
1454        ns.clear();
1455        assert!(ns.is_empty());
1456    }
1457
1458    // ── Dotted hostname / IP address tests ──────────────────────────────
1459
1460    #[test]
1461    fn test_dotted_hostname_single() {
1462        let ns = NodeSet::parse("server1.example.com").unwrap();
1463        assert_eq!(ns.len(), 1);
1464        let nodes: Vec<String> = ns.iter().collect();
1465        assert_eq!(nodes, vec!["server1.example.com"]);
1466        assert_eq!(format!("{ns}"), "server1.example.com");
1467    }
1468
1469    #[test]
1470    fn test_dotted_hostname_multiple() {
1471        let ns = NodeSet::parse("server1.local,server2.local").unwrap();
1472        assert_eq!(ns.len(), 2);
1473        let nodes: Vec<String> = ns.iter().collect();
1474        assert_eq!(nodes, vec!["server1.local", "server2.local"]);
1475    }
1476
1477    #[test]
1478    fn test_ip_address_single() {
1479        let ns = NodeSet::parse("192.168.1.29").unwrap();
1480        assert_eq!(ns.len(), 1);
1481        let nodes: Vec<String> = ns.iter().collect();
1482        assert_eq!(nodes, vec!["192.168.1.29"]);
1483        assert_eq!(format!("{ns}"), "192.168.1.29");
1484    }
1485
1486    #[test]
1487    fn test_ip_address_multiple_same_subnet() {
1488        let ns = NodeSet::parse("192.168.1.3,192.168.1.29").unwrap();
1489        assert_eq!(ns.len(), 2);
1490        let mut nodes: Vec<String> = ns.iter().collect();
1491        nodes.sort();
1492        assert_eq!(nodes, vec!["192.168.1.29", "192.168.1.3"]);
1493        assert_eq!(format!("{ns}"), "192.168.1.[3,29]");
1494    }
1495
1496    #[test]
1497    fn test_ip_address_different_subnets() {
1498        let ns = NodeSet::parse("10.0.0.1,192.168.1.1").unwrap();
1499        assert_eq!(ns.len(), 2);
1500        let mut nodes: Vec<String> = ns.iter().collect();
1501        nodes.sort();
1502        assert_eq!(nodes, vec!["10.0.0.1", "192.168.1.1"]);
1503    }
1504
1505    #[test]
1506    fn test_ip_address_contains() {
1507        let ns = NodeSet::parse("192.168.1.3,192.168.1.29").unwrap();
1508        assert!(ns.contains("192.168.1.3"));
1509        assert!(ns.contains("192.168.1.29"));
1510        assert!(!ns.contains("192.168.1.4"));
1511        assert!(!ns.contains("10.0.0.1"));
1512    }
1513
1514    #[test]
1515    fn test_dotted_hostname_with_numbers() {
1516        // Hostname like "node1.rack2.dc3" has numbers in each component
1517        let ns = NodeSet::parse("node1.rack2.dc3").unwrap();
1518        assert_eq!(ns.len(), 1);
1519        let nodes: Vec<String> = ns.iter().collect();
1520        assert_eq!(nodes, vec!["node1.rack2.dc3"]);
1521    }
1522
1523    #[test]
1524    fn test_mdns_hostname() {
1525        let ns = NodeSet::parse("GN9CFLM92K-MBP.local").unwrap();
1526        assert_eq!(ns.len(), 1);
1527        let nodes: Vec<String> = ns.iter().collect();
1528        assert_eq!(nodes, vec!["GN9CFLM92K-MBP.local"]);
1529    }
1530
1531    #[test]
1532    fn test_mdns_hostname_comma_list() {
1533        let ns = NodeSet::parse("CK2Q9LN7PM-MBA.local,GJHC5VVN49-MBP.local,GN9CFLM92K-MBP.local")
1534            .unwrap();
1535        assert_eq!(ns.len(), 3);
1536        let nodes: Vec<String> = ns.iter().collect();
1537        assert!(nodes.contains(&"CK2Q9LN7PM-MBA.local".to_string()));
1538        assert!(nodes.contains(&"GJHC5VVN49-MBP.local".to_string()));
1539        assert!(nodes.contains(&"GN9CFLM92K-MBP.local".to_string()));
1540    }
1541
1542    #[test]
1543    fn test_thunderbolt_hostname() {
1544        let ns = NodeSet::parse("CK2Q9LN7PM-MBA.tb").unwrap();
1545        assert_eq!(ns.len(), 1);
1546        let nodes: Vec<String> = ns.iter().collect();
1547        assert_eq!(nodes, vec!["CK2Q9LN7PM-MBA.tb"]);
1548    }
1549
1550    // ── list-like index() and slicing ────────────────────────────────────
1551    // Mirrors tests/NodeSetTest.py methods added upstream by commits
1552    // 484d230 (index) and 1b2509b (stepped slice across multiple patterns).
1553
1554    #[test]
1555    fn test_index() {
1556        // mirror NodeSetTest.py::NodeSetTest::test_index
1557        let ns = NodeSet::parse("yeti[30,34-51,59-60]").unwrap();
1558        // index() is the reverse of iteration for every node
1559        for (i, node) in ns.iter().enumerate() {
1560            assert_eq!(ns.index(&node), Some(i));
1561        }
1562        assert_eq!(ns.index("yeti30"), Some(0));
1563        assert_eq!(ns.index("yeti34"), Some(1));
1564        assert_eq!(ns.index("yeti51"), Some(18));
1565        assert_eq!(ns.index("yeti60"), Some(20));
1566        // missing node -> None (Python raises ValueError, like list.index())
1567        assert_eq!(ns.index("yeti31"), None);
1568        assert_eq!(ns.index("foo1"), None);
1569        // a single node is required
1570        assert_eq!(ns.index("yeti[30,34]"), None);
1571
1572        // several patterns, including unindexed nodes
1573        let ns = NodeSet::parse("abc,cde[3-9,11],fgh").unwrap();
1574        for (i, node) in ns.iter().enumerate() {
1575            assert_eq!(ns.index(&node), Some(i));
1576        }
1577        assert_eq!(ns.index("abc"), Some(0));
1578        assert_eq!(ns.index("cde3"), Some(1));
1579        assert_eq!(ns.index("cde11"), Some(8));
1580        assert_eq!(ns.index("fgh"), Some(9));
1581
1582        // zero-padding must match exactly
1583        let ns = NodeSet::parse("prune[003-034,349-353/2]").unwrap();
1584        assert_eq!(ns.index("prune003"), Some(0));
1585        assert_eq!(ns.index("prune034"), Some(31));
1586        assert_eq!(ns.index("prune349"), Some(32));
1587        assert_eq!(ns.index("prune353"), Some(34));
1588        assert_eq!(ns.index("prune3"), None);
1589        assert_eq!(ns.index("prune35"), None);
1590
1591        // optional start/stop search window (list.index() semantics)
1592        let ns = NodeSet::parse("node[0-9]").unwrap();
1593        assert_eq!(ns.index_in("node5", 3, None), Some(5));
1594        assert_eq!(ns.index_in("node5", 5, None), Some(5));
1595        assert_eq!(ns.index_in("node5", 6, None), None);
1596        assert_eq!(ns.index_in("node8", -3, None), Some(8));
1597        assert_eq!(ns.index_in("node5", 0, Some(6)), Some(5));
1598        assert_eq!(ns.index_in("node5", 0, Some(5)), None);
1599        assert_eq!(ns.index_in("node9", 0, Some(-1)), None);
1600    }
1601
1602    #[test]
1603    fn test_nd_index() {
1604        // mirror NodeSetTest.py::NodeSetTest::test_nd_index
1605        let ns = NodeSet::parse("da[30,34-51,59-60]p[1-2]").unwrap();
1606        for (i, node) in ns.iter().enumerate() {
1607            assert_eq!(ns.index(&node), Some(i));
1608        }
1609        assert_eq!(ns.index("da30p1"), Some(0));
1610        assert_eq!(ns.index("da30p2"), Some(1));
1611        assert_eq!(ns.index("da34p1"), Some(2));
1612        assert_eq!(ns.index("da30p3"), None);
1613
1614        let ns = NodeSet::parse("da[30,34-51,59-60]p[1-2],da[70-77]p2").unwrap();
1615        for (i, node) in ns.iter().enumerate() {
1616            assert_eq!(ns.index(&node), Some(i));
1617        }
1618    }
1619
1620    #[test]
1621    fn test_get_int() {
1622        // int indexing: reverse relation with index() (Python __getitem__)
1623        let ns = NodeSet::parse("node[0-9]").unwrap();
1624        assert_eq!(ns.get(0).as_deref(), Some("node0"));
1625        assert_eq!(ns.get(9).as_deref(), Some("node9"));
1626        assert_eq!(ns.get(-1).as_deref(), Some("node9"));
1627        assert_eq!(ns.get(-10).as_deref(), Some("node0"));
1628        assert_eq!(ns.get(10), None);
1629        assert_eq!(ns.get(-11), None);
1630
1631        let ns = NodeSet::parse("yeti[30,34-51,59-60]").unwrap();
1632        for i in 0..ns.len() as i64 {
1633            let node = ns.get(i).unwrap();
1634            assert_eq!(ns.index(&node), Some(i as usize));
1635        }
1636    }
1637
1638    /// Slice `ns[start:stop:step]` and return the folded string.
1639    fn sl(ns: &NodeSet, start: Option<i64>, stop: Option<i64>, step: i64) -> String {
1640        ns.slice(start, stop, step).unwrap().to_string()
1641    }
1642
1643    #[test]
1644    fn test_getslice() {
1645        // mirror NodeSetTest.py::NodeSetTest::test_getslice (including the
1646        // stepped multi-pattern assertions added by upstream 1b2509b)
1647        let ns = NodeSet::parse("yeti[30,34-51,59-60]").unwrap();
1648        assert_eq!(ns.len(), 21);
1649        assert_eq!(ns.slice(Some(0), Some(2), 1).unwrap().len(), 2);
1650        assert_eq!(sl(&ns, Some(0), Some(2), 1), "yeti[30,34]");
1651        assert_eq!(ns.slice(Some(1), Some(3), 1).unwrap().len(), 2);
1652        assert_eq!(sl(&ns, Some(1), Some(3), 1), "yeti[34-35]");
1653        assert_eq!(ns.slice(Some(19), Some(21), 1).unwrap().len(), 2);
1654        assert_eq!(sl(&ns, Some(19), Some(21), 1), "yeti[59-60]");
1655        assert_eq!(ns.slice(Some(20), Some(22), 1).unwrap().len(), 1);
1656        assert_eq!(sl(&ns, Some(20), Some(22), 1), "yeti60");
1657        assert_eq!(ns.slice(Some(21), Some(24), 1).unwrap().len(), 0);
1658        assert_eq!(sl(&ns, Some(21), Some(24), 1), "");
1659        // negative indices
1660        assert_eq!(sl(&ns, None, Some(-1), 1), "yeti[30,34-51,59]");
1661        assert_eq!(sl(&ns, None, Some(-2), 1), "yeti[30,34-51]");
1662        assert_eq!(sl(&ns, Some(1), Some(-2), 1), "yeti[34-51]");
1663        assert_eq!(sl(&ns, Some(2), Some(-2), 1), "yeti[35-51]");
1664        assert_eq!(sl(&ns, Some(9), Some(-3), 1), "yeti[42-50]");
1665        assert_eq!(sl(&ns, Some(10), Some(-9), 1), "yeti[43-44]");
1666        assert_eq!(sl(&ns, Some(10), Some(-10), 1), "yeti43");
1667        assert_eq!(sl(&ns, Some(11), Some(-10), 1), "");
1668        assert_eq!(sl(&ns, Some(11), Some(-11), 1), "");
1669        assert_eq!(
1670            sl(&ns, None, None, -2),
1671            "yeti[30,35,37,39,41,43,45,47,49,51,60]"
1672        );
1673        assert_eq!(sl(&ns, None, None, -3), "yeti[35,38,41,44,47,50,60]");
1674        // advanced
1675        assert_eq!(sl(&ns, Some(0), Some(10), 2), "yeti[30,35,37,39,41]");
1676        assert_eq!(sl(&ns, Some(1), Some(11), 2), "yeti[34,36,38,40,42]");
1677        assert_eq!(sl(&ns, None, Some(11), 3), "yeti[30,36,39,42]");
1678        assert_eq!(sl(&ns, Some(11), None, 4), "yeti[44,48,59]");
1679        assert_eq!(sl(&ns, Some(14), None, 1), "yeti[47-51,59-60]");
1680        assert_eq!(sl(&ns, None, None, 1), "yeti[30,34-51,59-60]");
1681        assert_eq!(sl(&ns, None, None, 5), "yeti[30,38,43,48,60]");
1682        // with unindexed nodes
1683        let ns = NodeSet::parse("foo,bar,bur").unwrap();
1684        assert_eq!(ns.len(), 3);
1685        assert_eq!(ns.slice(Some(0), Some(2), 1).unwrap().len(), 2);
1686        assert_eq!(sl(&ns, Some(0), Some(2), 1), "bar,bur");
1687        assert_eq!(sl(&ns, Some(1), Some(2), 1), "bur");
1688        assert_eq!(sl(&ns, Some(1), Some(3), 1), "bur,foo");
1689        assert_eq!(sl(&ns, Some(2), Some(4), 1), "foo");
1690        let ns = NodeSet::parse("foo,bar,bur3,bur1").unwrap();
1691        assert_eq!(ns.len(), 4);
1692        assert_eq!(ns.slice(Some(0), Some(2), 1).unwrap().len(), 2);
1693        assert_eq!(ns.slice(Some(1), Some(3), 1).unwrap().len(), 2);
1694        assert_eq!(ns.slice(Some(2), Some(4), 1).unwrap().len(), 2);
1695        assert_eq!(ns.slice(Some(3), Some(5), 1).unwrap().len(), 1);
1696        assert_eq!(sl(&ns, Some(2), Some(3), 1), "bur3");
1697        assert_eq!(sl(&ns, Some(3), Some(4), 1), "foo");
1698        assert_eq!(sl(&ns, Some(0), Some(2), 1), "bar,bur1");
1699        assert_eq!(sl(&ns, Some(1), Some(3), 1), "bur[1,3]");
1700        // using range step
1701        let ns = NodeSet::parse("yeti[10-98/2]").unwrap();
1702        assert_eq!(sl(&ns, Some(1), Some(9), 3), "yeti[12,18,24]");
1703        assert_eq!(sl(&ns, None, None, 17), "yeti[10,44,78]");
1704        let ns = NodeSet::parse_with_autostep("yeti[10-98/2]", Some(2)).unwrap();
1705        assert_eq!(sl(&ns, Some(22), Some(29), 1), "yeti[54-66/2]");
1706        // stepping scalability
1707        let ns = NodeSet::parse_with_autostep("yeti[10-9800/2]", Some(2)).unwrap();
1708        assert_eq!(sl(&ns, Some(22), Some(2900), 1), "yeti[54-5808/2]");
1709        assert_eq!(sl(&ns, Some(22), Some(2900), 3), "yeti[54-5808/6]");
1710        let ns = NodeSet::parse("yeti[10-14,20-26,30-33]").unwrap();
1711        assert_eq!(sl(&ns, Some(2), Some(6), 1), "yeti[12-14,20]");
1712        // multiple patterns
1713        let ns = NodeSet::parse("stone[1-9],wood[1-9]").unwrap();
1714        assert_eq!(sl(&ns, None, None, 1), "stone[1-9],wood[1-9]");
1715        assert_eq!(sl(&ns, Some(1), Some(2), 1), "stone2");
1716        assert_eq!(sl(&ns, Some(8), Some(9), 1), "stone9");
1717        assert_eq!(sl(&ns, Some(8), Some(10), 1), "stone9,wood1");
1718        assert_eq!(sl(&ns, Some(9), Some(10), 1), "wood1");
1719        assert_eq!(sl(&ns, Some(9), None, 1), "wood[1-9]");
1720        // multiple patterns with stepped slices (upstream 1b2509b)
1721        assert_eq!(sl(&ns, None, None, 2), "stone[1,3,5,7,9],wood[2,4,6,8]");
1722        assert_eq!(sl(&ns, Some(1), None, 2), "stone[2,4,6,8],wood[1,3,5,7,9]");
1723        assert_eq!(sl(&ns, Some(2), Some(15), 4), "stone[3,7],wood[2,6]");
1724        assert_eq!(sl(&ns, None, None, -2), "stone[2,4,6,8],wood[1,3,5,7,9]");
1725        let ns = NodeSet::parse("stone[1-9],water[10-12],wood[1-9]").unwrap();
1726        assert_eq!(sl(&ns, Some(8), Some(10), 1), "stone9,water10");
1727        assert_eq!(sl(&ns, Some(11), Some(15), 1), "water12,wood[1-3]");
1728        let ns = NodeSet::parse("stone[1-9],water,wood[1-9]").unwrap();
1729        assert_eq!(sl(&ns, Some(8), Some(10), 1), "stone9,water");
1730        assert_eq!(sl(&ns, Some(8), Some(11), 1), "stone9,water,wood1");
1731        assert_eq!(sl(&ns, Some(9), Some(11), 1), "water,wood1");
1732        assert_eq!(sl(&ns, Some(9), Some(12), 1), "water,wood[1-2]");
1733        assert_eq!(sl(&ns, Some(8), None, 2), "stone9,wood[1,3,5,7,9]");
1734    }
1735
1736    #[test]
1737    fn test_slice_bad_params() {
1738        // mirror the error conditions of NodeSet._extractslice (Python
1739        // raises IndexError/ValueError; Rust returns Err)
1740        let ns = NodeSet::parse("cluster[1-30]c[1-2]").unwrap();
1741        // slice step cannot be zero
1742        assert!(ns.slice(None, None, 0).is_err());
1743        // illegal start and stop when negative step is used
1744        assert!(ns.slice(Some(1), None, -1).is_err());
1745        assert!(ns.slice(None, Some(2), -2).is_err());
1746        // negative step without start/stop is supported
1747        assert!(ns.slice(None, None, -1).is_ok());
1748    }
1749}