Skip to main content

ssh_cli/vps/
selection.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// G-CLOSE-05 / G-COMP-01: host selection extracted from `vps/mod` (SRP + componentization).
3#![forbid(unsafe_code)]
4//! Multi-host selection resolution for bounded fan-out (G-PAR-27 / G-PAR-31).
5//!
6//! Pure local map lookup — parallelism starts only after callers pass jobs to
7//! [`crate::concurrency::map_bounded`].
8
9use super::ConfigFile;
10use crate::domain::{HostTag, VpsName};
11use crate::errors::{SshCliError, SshCliResult};
12use crate::vps::model::VpsRecord;
13
14/// How multi-host SSH ops select target hosts (G-PAR-27 / G-PAR-31 / G-TYPE-09).
15///
16/// Workload: selection is pure local map lookup (tiny). Parallelism starts only
17/// after [`resolve_host_jobs`] when callers fan out via [`crate::concurrency::map_bounded`].
18///
19/// Names/tags are refined at the CLI boundary (`VpsName` / `HostTag`); map keys stay
20/// owned `String` for wire/storage compatibility.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum HostSelection {
23    /// One host by name (positional). Classic single-host JSON wire.
24    Single(VpsName),
25    /// Every registered host. Batch JSON wire.
26    All,
27    /// Explicit subset (`--hosts`). Batch JSON wire even if `len == 1` (G-PAR-36).
28    Named(Vec<VpsName>),
29    /// Hosts matching **any** of the given tags (OR). Batch JSON wire (G-O2).
30    Tagged(Vec<HostTag>),
31}
32
33impl HostSelection {
34    /// True when output must use the multi-host batch envelope.
35    #[must_use]
36    pub fn is_batch(&self) -> bool {
37        matches!(self, Self::All | Self::Named(_) | Self::Tagged(_))
38    }
39}
40
41/// Deduplicate host names preserving first-seen order (trim empty segments).
42#[must_use]
43pub fn dedupe_host_names(names: Vec<String>) -> Vec<String> {
44    let mut out = Vec::with_capacity(names.len());
45    let mut seen = std::collections::HashSet::with_capacity(names.len());
46    for n in names {
47        let t = n.trim().to_string();
48        if t.is_empty() {
49            continue;
50        }
51        if seen.insert(t.clone()) {
52            out.push(t);
53        }
54    }
55    out
56}
57
58/// Resolve a [`HostSelection`] into owned jobs for bounded multi-host fan-out.
59///
60/// # Errors
61/// - Empty registry for `--all` / `--hosts`
62/// - Unknown names in `--hosts` (fail-closed)
63/// - Missing single host (`VpsNotFound`)
64pub fn resolve_host_jobs(
65    selection: &HostSelection,
66    file: &ConfigFile,
67) -> SshCliResult<Vec<(String, VpsRecord)>> {
68    match selection {
69        HostSelection::All => {
70            if file.hosts.is_empty() {
71                return Err(SshCliError::InvalidArgument(
72                    "no hosts registered for --all".into(),
73                ));
74            }
75            Ok(file
76                .hosts
77                .iter()
78                .map(|(n, r)| (n.clone(), r.clone()))
79                .collect())
80        }
81        HostSelection::Named(names) => {
82            if names.is_empty() {
83                return Err(SshCliError::InvalidArgument(
84                    "--hosts requires at least one host name".into(),
85                ));
86            }
87            if file.hosts.is_empty() {
88                return Err(SshCliError::InvalidArgument(
89                    "no hosts registered for --hosts".into(),
90                ));
91            }
92            let mut jobs = Vec::with_capacity(names.len());
93            let mut missing = Vec::new();
94            for n in names {
95                match file.hosts.get(n.as_str()) {
96                    Some(r) => jobs.push((n.as_str().to_owned(), r.clone())),
97                    None => missing.push(n.as_str().to_owned()),
98                }
99            }
100            if !missing.is_empty() {
101                return Err(SshCliError::InvalidArgument(format!(
102                    "unknown host(s) for --hosts: {}",
103                    missing.join(", ")
104                )));
105            }
106            Ok(jobs)
107        }
108        HostSelection::Tagged(tags) => {
109            if tags.is_empty() {
110                return Err(SshCliError::InvalidArgument(
111                    "--tags requires at least one tag".into(),
112                ));
113            }
114            if file.hosts.is_empty() {
115                return Err(SshCliError::InvalidArgument(
116                    "no hosts registered for --tags".into(),
117                ));
118            }
119            // G-TYPE-09: tags already refined as HostTag — no second try_tags pass.
120            let jobs: Vec<_> = file
121                .hosts
122                .iter()
123                .filter(|(_, r)| r.has_any_tag(tags))
124                .map(|(n, r)| (n.clone(), r.clone()))
125                .collect();
126            if jobs.is_empty() {
127                return Err(SshCliError::InvalidArgument(format!(
128                    "no hosts match tag(s): {}",
129                    tags.iter()
130                        .map(HostTag::as_str)
131                        .collect::<Vec<_>>()
132                        .join(", ")
133                )));
134            }
135            Ok(jobs)
136        }
137        HostSelection::Single(name) => {
138            let key = name.as_str();
139            let r = file
140                .hosts
141                .get(key)
142                .ok_or_else(|| SshCliError::VpsNotFound(key.to_owned()))?
143                .clone();
144            Ok(vec![(key.to_owned(), r)])
145        }
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use crate::vps::model::VpsRecord;
153    use secrecy::SecretString;
154    use std::collections::BTreeMap;
155
156    fn file_with(names: &[&str]) -> ConfigFile {
157        let mut hosts = BTreeMap::new();
158        for n in names {
159            hosts.insert(
160                (*n).to_string(),
161                VpsRecord::test_new(
162                    *n,
163                    "h",
164                    22,
165                    "u",
166                    SecretString::from(String::new()),
167                    Some("/k"),
168                    None,
169                    None,
170                    None,
171                    None,
172                    None,
173                    None,
174                    false,
175                ),
176            );
177        }
178        ConfigFile {
179            schema_version: 3,
180            hosts,
181        }
182    }
183
184    fn vps(name: &str) -> VpsName {
185        VpsName::try_new(name).expect("valid test VpsName")
186    }
187
188    #[test]
189    fn is_batch_named_and_all() {
190        assert!(!HostSelection::Single(vps("a")).is_batch());
191        assert!(HostSelection::All.is_batch());
192        assert!(HostSelection::Named(vec![vps("a")]).is_batch());
193    }
194
195    #[test]
196    fn dedupe_preserves_order() {
197        assert_eq!(
198            dedupe_host_names(vec!["b".into(), "a".into(), "b".into(), " ".into()]),
199            vec!["b".to_string(), "a".to_string()]
200        );
201    }
202
203    #[test]
204    fn resolve_named_unknown_fails() {
205        let f = file_with(&["a"]);
206        let err = resolve_host_jobs(&HostSelection::Named(vec![vps("x")]), &f).unwrap_err();
207        assert!(matches!(err, SshCliError::InvalidArgument(_)));
208    }
209}