1#![forbid(unsafe_code)]
4use super::ConfigFile;
10use crate::domain::{HostTag, VpsName};
11use crate::errors::{SshCliError, SshCliResult};
12use crate::vps::model::VpsRecord;
13
14#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum HostSelection {
23 Single(VpsName),
25 All,
27 Named(Vec<VpsName>),
29 Tagged(Vec<HostTag>),
31}
32
33impl HostSelection {
34 #[must_use]
36 pub fn is_batch(&self) -> bool {
37 matches!(self, Self::All | Self::Named(_) | Self::Tagged(_))
38 }
39}
40
41#[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
58pub 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 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}