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    /// Provenance of this selection, for the `target_source` wire field.
41    ///
42    /// Transfers have no active-marker path — `scp` and `sftp` never read the file
43    /// `connect` writes — so the mapping is total: a lone name came from a positional
44    /// (`argv`), and every plural form came from a selector flag.
45    #[must_use]
46    pub fn target_source(&self) -> crate::json_wire::TargetSource {
47        match self {
48            Self::Single(_) => crate::json_wire::TargetSource::Argv,
49            Self::All | Self::Named(_) | Self::Tagged(_) => {
50                crate::json_wire::TargetSource::Selector
51            }
52        }
53    }
54}
55
56/// Deduplicate host names preserving first-seen order (trim empty segments).
57#[must_use]
58pub fn dedupe_host_names(names: Vec<String>) -> Vec<String> {
59    let mut out = Vec::with_capacity(names.len());
60    let mut seen = std::collections::HashSet::with_capacity(names.len());
61    for n in names {
62        let t = n.trim().to_string();
63        if t.is_empty() {
64            continue;
65        }
66        if seen.insert(t.clone()) {
67            out.push(t);
68        }
69    }
70    out
71}
72
73/// Resolve a [`HostSelection`] into owned jobs for bounded multi-host fan-out.
74///
75/// # Errors
76/// - Empty registry for `--all` / `--hosts`
77/// - Unknown names in `--hosts` (fail-closed)
78/// - Missing single host (`VpsNotFound`)
79pub fn resolve_host_jobs(
80    selection: &HostSelection,
81    file: &ConfigFile,
82) -> SshCliResult<Vec<(String, VpsRecord)>> {
83    match selection {
84        HostSelection::All => {
85            if file.hosts.is_empty() {
86                return Err(SshCliError::InvalidArgument(
87                    "no hosts registered for --all".into(),
88                ));
89            }
90            Ok(file
91                .hosts
92                .iter()
93                .map(|(n, r)| (n.clone(), r.clone()))
94                .collect())
95        }
96        HostSelection::Named(names) => {
97            if names.is_empty() {
98                return Err(SshCliError::InvalidArgument(
99                    "--hosts requires at least one host name".into(),
100                ));
101            }
102            if file.hosts.is_empty() {
103                return Err(SshCliError::InvalidArgument(
104                    "no hosts registered for --hosts".into(),
105                ));
106            }
107            let mut jobs = Vec::with_capacity(names.len());
108            let mut missing = Vec::new();
109            for n in names {
110                match file.hosts.get(n.as_str()) {
111                    Some(r) => jobs.push((n.as_str().to_owned(), r.clone())),
112                    None => missing.push(n.as_str().to_owned()),
113                }
114            }
115            if !missing.is_empty() {
116                return Err(SshCliError::InvalidArgument(format!(
117                    "unknown host(s) for --hosts: {}",
118                    missing.join(", ")
119                )));
120            }
121            Ok(jobs)
122        }
123        HostSelection::Tagged(tags) => {
124            if tags.is_empty() {
125                return Err(SshCliError::InvalidArgument(
126                    "--tags requires at least one tag".into(),
127                ));
128            }
129            if file.hosts.is_empty() {
130                return Err(SshCliError::InvalidArgument(
131                    "no hosts registered for --tags".into(),
132                ));
133            }
134            // G-TYPE-09: tags already refined as HostTag — no second try_tags pass.
135            let jobs: Vec<_> = file
136                .hosts
137                .iter()
138                .filter(|(_, r)| r.has_any_tag(tags))
139                .map(|(n, r)| (n.clone(), r.clone()))
140                .collect();
141            if jobs.is_empty() {
142                return Err(SshCliError::InvalidArgument(format!(
143                    "no hosts match tag(s): {}",
144                    tags.iter()
145                        .map(HostTag::as_str)
146                        .collect::<Vec<_>>()
147                        .join(", ")
148                )));
149            }
150            Ok(jobs)
151        }
152        HostSelection::Single(name) => {
153            let key = name.as_str();
154            let r = file
155                .hosts
156                .get(key)
157                .ok_or_else(|| SshCliError::VpsNotFound(key.to_owned()))?
158                .clone();
159            Ok(vec![(key.to_owned(), r)])
160        }
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use crate::vps::model::VpsRecord;
168    use secrecy::SecretString;
169    use std::collections::BTreeMap;
170
171    fn file_with(names: &[&str]) -> ConfigFile {
172        let mut hosts = BTreeMap::new();
173        for n in names {
174            hosts.insert(
175                (*n).to_string(),
176                VpsRecord::test_new(
177                    *n,
178                    "h",
179                    22,
180                    "u",
181                    SecretString::from(String::new()),
182                    Some("/k"),
183                    None,
184                    None,
185                    None,
186                    None,
187                    None,
188                    None,
189                    false,
190                ),
191            );
192        }
193        ConfigFile {
194            schema_version: 3,
195            hosts,
196        }
197    }
198
199    fn vps(name: &str) -> VpsName {
200        VpsName::try_new(name).expect("valid test VpsName")
201    }
202
203    #[test]
204    fn is_batch_named_and_all() {
205        assert!(!HostSelection::Single(vps("a")).is_batch());
206        assert!(HostSelection::All.is_batch());
207        assert!(HostSelection::Named(vec![vps("a")]).is_batch());
208    }
209
210    #[test]
211    fn dedupe_preserves_order() {
212        assert_eq!(
213            dedupe_host_names(vec!["b".into(), "a".into(), "b".into(), " ".into()]),
214            vec!["b".to_string(), "a".to_string()]
215        );
216    }
217
218    #[test]
219    fn resolve_named_unknown_fails() {
220        let f = file_with(&["a"]);
221        let err = resolve_host_jobs(&HostSelection::Named(vec![vps("x")]), &f).unwrap_err();
222        assert!(matches!(err, SshCliError::InvalidArgument(_)));
223    }
224}