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 #[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#[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
73pub 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 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}