1use std::path::PathBuf;
2
3#[derive(Debug, Clone)]
6pub struct SshConfigFile {
7 pub elements: Vec<ConfigElement>,
8 pub path: PathBuf,
9}
10
11#[derive(Debug, Clone)]
13#[allow(dead_code)]
14pub struct IncludeDirective {
15 pub raw_line: String,
16 pub pattern: String,
17 pub resolved_files: Vec<IncludedFile>,
18}
19
20#[derive(Debug, Clone)]
22pub struct IncludedFile {
23 pub path: PathBuf,
24 pub elements: Vec<ConfigElement>,
25}
26
27#[derive(Debug, Clone)]
29pub enum ConfigElement {
30 HostBlock(HostBlock),
32 GlobalLine(String),
34 Include(IncludeDirective),
36}
37
38#[derive(Debug, Clone)]
40pub struct HostBlock {
41 pub host_pattern: String,
43 pub raw_host_line: String,
45 pub directives: Vec<Directive>,
47}
48
49#[derive(Debug, Clone)]
51pub struct Directive {
52 pub key: String,
54 pub value: String,
56 pub raw_line: String,
58 pub is_non_directive: bool,
60}
61
62#[derive(Debug, Clone, Default)]
64pub struct HostEntry {
65 pub alias: String,
66 pub hostname: String,
67 pub user: String,
68 pub port: u16,
69 pub identity_file: String,
70 pub proxy_jump: String,
71 pub source_file: Option<PathBuf>,
73 pub tags: Vec<String>,
75}
76
77impl HostEntry {
78 pub fn ssh_command(&self) -> String {
80 format!("ssh {}", self.alias)
81 }
82}
83
84impl HostBlock {
85 fn content_end(&self) -> usize {
87 let mut pos = self.directives.len();
88 while pos > 0 {
89 if self.directives[pos - 1].is_non_directive
90 && self.directives[pos - 1].raw_line.trim().is_empty()
91 {
92 pos -= 1;
93 } else {
94 break;
95 }
96 }
97 pos
98 }
99
100 fn pop_trailing_blanks(&mut self) -> Vec<Directive> {
102 let end = self.content_end();
103 self.directives.drain(end..).collect()
104 }
105
106 fn ensure_trailing_blank(&mut self) {
108 self.pop_trailing_blanks();
109 self.directives.push(Directive {
110 key: String::new(),
111 value: String::new(),
112 raw_line: String::new(),
113 is_non_directive: true,
114 });
115 }
116
117 fn detect_indent(&self) -> String {
119 for d in &self.directives {
120 if !d.is_non_directive && !d.raw_line.is_empty() {
121 let trimmed = d.raw_line.trim_start();
122 let indent_len = d.raw_line.len() - trimmed.len();
123 if indent_len > 0 {
124 return d.raw_line[..indent_len].to_string();
125 }
126 }
127 }
128 " ".to_string()
129 }
130
131 pub fn tags(&self) -> Vec<String> {
133 for d in &self.directives {
134 if d.is_non_directive {
135 let trimmed = d.raw_line.trim();
136 if let Some(rest) = trimmed.strip_prefix("# purple:tags ") {
137 return rest
138 .split(',')
139 .map(|t| t.trim().to_string())
140 .filter(|t| !t.is_empty())
141 .collect();
142 }
143 }
144 }
145 Vec::new()
146 }
147
148 pub fn set_tags(&mut self, tags: &[String]) {
150 let indent = self.detect_indent();
151 self.directives.retain(|d| {
152 !(d.is_non_directive && d.raw_line.trim().starts_with("# purple:tags"))
153 });
154 if !tags.is_empty() {
155 let pos = self.content_end();
156 self.directives.insert(
157 pos,
158 Directive {
159 key: String::new(),
160 value: String::new(),
161 raw_line: format!("{}# purple:tags {}", indent, tags.join(",")),
162 is_non_directive: true,
163 },
164 );
165 }
166 }
167
168 pub fn to_host_entry(&self) -> HostEntry {
170 let mut entry = HostEntry {
171 alias: self.host_pattern.clone(),
172 port: 22,
173 ..Default::default()
174 };
175 for d in &self.directives {
176 if d.is_non_directive {
177 continue;
178 }
179 match d.key.to_lowercase().as_str() {
180 "hostname" => entry.hostname = d.value.clone(),
181 "user" => entry.user = d.value.clone(),
182 "port" => entry.port = d.value.parse().unwrap_or(22),
183 "identityfile" => entry.identity_file = d.value.clone(),
184 "proxyjump" => entry.proxy_jump = d.value.clone(),
185 _ => {}
186 }
187 }
188 entry.tags = self.tags();
189 entry
190 }
191}
192
193impl SshConfigFile {
194 pub fn host_entries(&self) -> Vec<HostEntry> {
196 Self::collect_host_entries(&self.elements)
197 }
198
199 fn collect_host_entries(elements: &[ConfigElement]) -> Vec<HostEntry> {
201 let mut entries = Vec::new();
202 for e in elements {
203 match e {
204 ConfigElement::HostBlock(block) => {
205 if block.host_pattern.contains('*')
207 || block.host_pattern.contains('?')
208 || block.host_pattern.contains(' ')
209 {
210 continue;
211 }
212 entries.push(block.to_host_entry());
213 }
214 ConfigElement::Include(include) => {
215 for file in &include.resolved_files {
216 let mut file_entries = Self::collect_host_entries(&file.elements);
217 for entry in &mut file_entries {
218 if entry.source_file.is_none() {
219 entry.source_file = Some(file.path.clone());
220 }
221 }
222 entries.extend(file_entries);
223 }
224 }
225 ConfigElement::GlobalLine(_) => {}
226 }
227 }
228 entries
229 }
230
231 pub fn has_host(&self, alias: &str) -> bool {
234 Self::has_host_in_elements(&self.elements, alias)
235 }
236
237 fn has_host_in_elements(elements: &[ConfigElement], alias: &str) -> bool {
238 for e in elements {
239 match e {
240 ConfigElement::HostBlock(block) => {
241 if block.host_pattern == alias {
242 return true;
243 }
244 }
245 ConfigElement::Include(include) => {
246 for file in &include.resolved_files {
247 if Self::has_host_in_elements(&file.elements, alias) {
248 return true;
249 }
250 }
251 }
252 ConfigElement::GlobalLine(_) => {}
253 }
254 }
255 false
256 }
257
258 pub fn add_host(&mut self, entry: &HostEntry) {
260 let block = Self::entry_to_block(entry);
261 if !self.elements.is_empty() && !self.last_element_has_trailing_blank() {
263 self.elements
264 .push(ConfigElement::GlobalLine(String::new()));
265 }
266 self.elements.push(ConfigElement::HostBlock(block));
267 }
268
269 fn last_element_has_trailing_blank(&self) -> bool {
271 match self.elements.last() {
272 Some(ConfigElement::HostBlock(block)) => block
273 .directives
274 .last()
275 .is_some_and(|d| d.is_non_directive && d.raw_line.trim().is_empty()),
276 Some(ConfigElement::GlobalLine(line)) => line.trim().is_empty(),
277 _ => false,
278 }
279 }
280
281 pub fn update_host(&mut self, old_alias: &str, entry: &HostEntry) {
284 for element in &mut self.elements {
285 if let ConfigElement::HostBlock(block) = element {
286 if block.host_pattern == old_alias {
287 block.host_pattern = entry.alias.clone();
289 block.raw_host_line = format!("Host {}", entry.alias);
290
291 Self::upsert_directive(block, "HostName", &entry.hostname);
293 Self::upsert_directive(block, "User", &entry.user);
294 if entry.port != 22 {
295 Self::upsert_directive(block, "Port", &entry.port.to_string());
296 } else {
297 block
299 .directives
300 .retain(|d| d.is_non_directive || d.key.to_lowercase() != "port");
301 }
302 Self::upsert_directive(block, "IdentityFile", &entry.identity_file);
303 Self::upsert_directive(block, "ProxyJump", &entry.proxy_jump);
304 return;
305 }
306 }
307 }
308 }
309
310 fn upsert_directive(block: &mut HostBlock, key: &str, value: &str) {
312 if value.is_empty() {
313 block
314 .directives
315 .retain(|d| d.is_non_directive || d.key.to_lowercase() != key.to_lowercase());
316 return;
317 }
318 let indent = block.detect_indent();
319 for d in &mut block.directives {
320 if !d.is_non_directive && d.key.to_lowercase() == key.to_lowercase() {
321 d.value = value.to_string();
322 d.raw_line = format!("{}{} {}", indent, d.key, value);
324 return;
325 }
326 }
327 let pos = block.content_end();
329 block.directives.insert(
330 pos,
331 Directive {
332 key: key.to_string(),
333 value: value.to_string(),
334 raw_line: format!("{}{} {}", indent, key, value),
335 is_non_directive: false,
336 },
337 );
338 }
339
340 pub fn set_host_tags(&mut self, alias: &str, tags: &[String]) {
342 for element in &mut self.elements {
343 if let ConfigElement::HostBlock(block) = element {
344 if block.host_pattern == alias {
345 block.set_tags(tags);
346 return;
347 }
348 }
349 }
350 }
351
352 #[allow(dead_code)]
354 pub fn delete_host(&mut self, alias: &str) {
355 self.elements.retain(|e| match e {
356 ConfigElement::HostBlock(block) => block.host_pattern != alias,
357 _ => true,
358 });
359 self.elements.dedup_by(|a, b| {
361 matches!(
362 (&*a, &*b),
363 (ConfigElement::GlobalLine(x), ConfigElement::GlobalLine(y))
364 if x.trim().is_empty() && y.trim().is_empty()
365 )
366 });
367 }
368
369 pub fn delete_host_undoable(&mut self, alias: &str) -> Option<(ConfigElement, usize)> {
372 let pos = self.elements.iter().position(|e| {
373 matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias)
374 })?;
375 let element = self.elements.remove(pos);
376 Some((element, pos))
377 }
378
379 pub fn insert_host_at(&mut self, element: ConfigElement, position: usize) {
381 let pos = position.min(self.elements.len());
382 self.elements.insert(pos, element);
383 }
384
385 #[allow(dead_code)]
387 pub fn swap_hosts(&mut self, alias_a: &str, alias_b: &str) -> bool {
388 let pos_a = self.elements.iter().position(|e| {
389 matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_a)
390 });
391 let pos_b = self.elements.iter().position(|e| {
392 matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_b)
393 });
394 if let (Some(a), Some(b)) = (pos_a, pos_b) {
395 let (first, second) = (a.min(b), a.max(b));
396
397 if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
399 block.pop_trailing_blanks();
400 }
401 if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
402 block.pop_trailing_blanks();
403 }
404
405 self.elements.swap(first, second);
407
408 if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
410 block.ensure_trailing_blank();
411 }
412
413 if second < self.elements.len() - 1 {
415 if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
416 block.ensure_trailing_blank();
417 }
418 }
419
420 return true;
421 }
422 false
423 }
424
425 fn entry_to_block(entry: &HostEntry) -> HostBlock {
427 let mut directives = Vec::new();
428
429 if !entry.hostname.is_empty() {
430 directives.push(Directive {
431 key: "HostName".to_string(),
432 value: entry.hostname.clone(),
433 raw_line: format!(" HostName {}", entry.hostname),
434 is_non_directive: false,
435 });
436 }
437 if !entry.user.is_empty() {
438 directives.push(Directive {
439 key: "User".to_string(),
440 value: entry.user.clone(),
441 raw_line: format!(" User {}", entry.user),
442 is_non_directive: false,
443 });
444 }
445 if entry.port != 22 {
446 directives.push(Directive {
447 key: "Port".to_string(),
448 value: entry.port.to_string(),
449 raw_line: format!(" Port {}", entry.port),
450 is_non_directive: false,
451 });
452 }
453 if !entry.identity_file.is_empty() {
454 directives.push(Directive {
455 key: "IdentityFile".to_string(),
456 value: entry.identity_file.clone(),
457 raw_line: format!(" IdentityFile {}", entry.identity_file),
458 is_non_directive: false,
459 });
460 }
461 if !entry.proxy_jump.is_empty() {
462 directives.push(Directive {
463 key: "ProxyJump".to_string(),
464 value: entry.proxy_jump.clone(),
465 raw_line: format!(" ProxyJump {}", entry.proxy_jump),
466 is_non_directive: false,
467 });
468 }
469
470 HostBlock {
471 host_pattern: entry.alias.clone(),
472 raw_host_line: format!("Host {}", entry.alias),
473 directives,
474 }
475 }
476}