1use std::path::PathBuf;
2
3#[derive(Debug, Clone)]
6pub struct SshConfigFile {
7 pub elements: Vec<ConfigElement>,
8 pub path: PathBuf,
9 pub crlf: bool,
11}
12
13#[derive(Debug, Clone)]
15#[allow(dead_code)]
16pub struct IncludeDirective {
17 pub raw_line: String,
18 pub pattern: String,
19 pub resolved_files: Vec<IncludedFile>,
20}
21
22#[derive(Debug, Clone)]
24pub struct IncludedFile {
25 pub path: PathBuf,
26 pub elements: Vec<ConfigElement>,
27}
28
29#[derive(Debug, Clone)]
31pub enum ConfigElement {
32 HostBlock(HostBlock),
34 GlobalLine(String),
36 Include(IncludeDirective),
38}
39
40#[derive(Debug, Clone)]
42pub struct HostBlock {
43 pub host_pattern: String,
45 pub raw_host_line: String,
47 pub directives: Vec<Directive>,
49}
50
51#[derive(Debug, Clone)]
53pub struct Directive {
54 pub key: String,
56 pub value: String,
58 pub raw_line: String,
60 pub is_non_directive: bool,
62}
63
64#[derive(Debug, Clone)]
66pub struct HostEntry {
67 pub alias: String,
68 pub hostname: String,
69 pub user: String,
70 pub port: u16,
71 pub identity_file: String,
72 pub proxy_jump: String,
73 pub source_file: Option<PathBuf>,
75 pub tags: Vec<String>,
77 pub provider: Option<String>,
79}
80
81impl Default for HostEntry {
82 fn default() -> Self {
83 Self {
84 alias: String::new(),
85 hostname: String::new(),
86 user: String::new(),
87 port: 22,
88 identity_file: String::new(),
89 proxy_jump: String::new(),
90 source_file: None,
91 tags: Vec::new(),
92 provider: None,
93 }
94 }
95}
96
97impl HostEntry {
98 pub fn ssh_command(&self) -> String {
101 let escaped = self.alias.replace('\'', "'\\''");
102 format!("ssh -- '{}'", escaped)
103 }
104}
105
106pub fn is_host_pattern(pattern: &str) -> bool {
110 pattern.contains('*')
111 || pattern.contains('?')
112 || pattern.contains('[')
113 || pattern.starts_with('!')
114 || pattern.contains(' ')
115 || pattern.contains('\t')
116}
117
118impl HostBlock {
119 fn content_end(&self) -> usize {
121 let mut pos = self.directives.len();
122 while pos > 0 {
123 if self.directives[pos - 1].is_non_directive
124 && self.directives[pos - 1].raw_line.trim().is_empty()
125 {
126 pos -= 1;
127 } else {
128 break;
129 }
130 }
131 pos
132 }
133
134 fn pop_trailing_blanks(&mut self) -> Vec<Directive> {
136 let end = self.content_end();
137 self.directives.drain(end..).collect()
138 }
139
140 fn ensure_trailing_blank(&mut self) {
142 self.pop_trailing_blanks();
143 self.directives.push(Directive {
144 key: String::new(),
145 value: String::new(),
146 raw_line: String::new(),
147 is_non_directive: true,
148 });
149 }
150
151 fn detect_indent(&self) -> String {
153 for d in &self.directives {
154 if !d.is_non_directive && !d.raw_line.is_empty() {
155 let trimmed = d.raw_line.trim_start();
156 let indent_len = d.raw_line.len() - trimmed.len();
157 if indent_len > 0 {
158 return d.raw_line[..indent_len].to_string();
159 }
160 }
161 }
162 " ".to_string()
163 }
164
165 pub fn tags(&self) -> Vec<String> {
167 for d in &self.directives {
168 if d.is_non_directive {
169 let trimmed = d.raw_line.trim();
170 if let Some(rest) = trimmed.strip_prefix("# purple:tags ") {
171 return rest
172 .split(',')
173 .map(|t| t.trim().to_string())
174 .filter(|t| !t.is_empty())
175 .collect();
176 }
177 }
178 }
179 Vec::new()
180 }
181
182 pub fn provider(&self) -> Option<(String, String)> {
185 for d in &self.directives {
186 if d.is_non_directive {
187 let trimmed = d.raw_line.trim();
188 if let Some(rest) = trimmed.strip_prefix("# purple:provider ") {
189 if let Some((name, id)) = rest.split_once(':') {
190 return Some((name.trim().to_string(), id.trim().to_string()));
191 }
192 }
193 }
194 }
195 None
196 }
197
198 pub fn set_provider(&mut self, provider_name: &str, server_id: &str) {
200 let indent = self.detect_indent();
201 self.directives.retain(|d| {
202 !(d.is_non_directive && d.raw_line.trim().starts_with("# purple:provider"))
203 });
204 let pos = self.content_end();
205 self.directives.insert(
206 pos,
207 Directive {
208 key: String::new(),
209 value: String::new(),
210 raw_line: format!("{}# purple:provider {}:{}", indent, provider_name, server_id),
211 is_non_directive: true,
212 },
213 );
214 }
215
216 pub fn set_tags(&mut self, tags: &[String]) {
218 let indent = self.detect_indent();
219 self.directives.retain(|d| {
220 !(d.is_non_directive && d.raw_line.trim().starts_with("# purple:tags"))
221 });
222 if !tags.is_empty() {
223 let pos = self.content_end();
224 self.directives.insert(
225 pos,
226 Directive {
227 key: String::new(),
228 value: String::new(),
229 raw_line: format!("{}# purple:tags {}", indent, tags.join(",")),
230 is_non_directive: true,
231 },
232 );
233 }
234 }
235
236 pub fn to_host_entry(&self) -> HostEntry {
238 let mut entry = HostEntry {
239 alias: self.host_pattern.clone(),
240 port: 22,
241 ..Default::default()
242 };
243 for d in &self.directives {
244 if d.is_non_directive {
245 continue;
246 }
247 if d.key.eq_ignore_ascii_case("hostname") {
248 entry.hostname = d.value.clone();
249 } else if d.key.eq_ignore_ascii_case("user") {
250 entry.user = d.value.clone();
251 } else if d.key.eq_ignore_ascii_case("port") {
252 entry.port = d.value.parse().unwrap_or(22);
253 } else if d.key.eq_ignore_ascii_case("identityfile") {
254 if entry.identity_file.is_empty() {
255 entry.identity_file = d.value.clone();
256 }
257 } else if d.key.eq_ignore_ascii_case("proxyjump") {
258 entry.proxy_jump = d.value.clone();
259 }
260 }
261 entry.tags = self.tags();
262 entry.provider = self.provider().map(|(name, _)| name);
263 entry
264 }
265}
266
267impl SshConfigFile {
268 pub fn host_entries(&self) -> Vec<HostEntry> {
270 let mut entries = Vec::new();
271 Self::collect_host_entries(&self.elements, &mut entries);
272 entries
273 }
274
275 pub fn include_paths(&self) -> Vec<PathBuf> {
277 let mut paths = Vec::new();
278 Self::collect_include_paths(&self.elements, &mut paths);
279 paths
280 }
281
282 fn collect_include_paths(elements: &[ConfigElement], paths: &mut Vec<PathBuf>) {
283 for e in elements {
284 if let ConfigElement::Include(include) = e {
285 for file in &include.resolved_files {
286 paths.push(file.path.clone());
287 Self::collect_include_paths(&file.elements, paths);
288 }
289 }
290 }
291 }
292
293 pub fn include_glob_dirs(&self) -> Vec<PathBuf> {
296 let config_dir = self.path.parent();
297 let mut seen = std::collections::HashSet::new();
298 let mut dirs = Vec::new();
299 Self::collect_include_glob_dirs(&self.elements, config_dir, &mut seen, &mut dirs);
300 dirs
301 }
302
303 fn collect_include_glob_dirs(
304 elements: &[ConfigElement],
305 config_dir: Option<&std::path::Path>,
306 seen: &mut std::collections::HashSet<PathBuf>,
307 dirs: &mut Vec<PathBuf>,
308 ) {
309 for e in elements {
310 if let ConfigElement::Include(include) = e {
311 for single in include.pattern.split_whitespace() {
314 let expanded = Self::expand_tilde(single);
315 let resolved = if expanded.starts_with('/') {
316 PathBuf::from(&expanded)
317 } else if let Some(dir) = config_dir {
318 dir.join(&expanded)
319 } else {
320 continue;
321 };
322 if let Some(parent) = resolved.parent() {
323 let parent = parent.to_path_buf();
324 if seen.insert(parent.clone()) {
325 dirs.push(parent);
326 }
327 }
328 }
329 for file in &include.resolved_files {
331 Self::collect_include_glob_dirs(
332 &file.elements,
333 file.path.parent(),
334 seen,
335 dirs,
336 );
337 }
338 }
339 }
340 }
341
342
343 fn collect_host_entries(elements: &[ConfigElement], entries: &mut Vec<HostEntry>) {
345 for e in elements {
346 match e {
347 ConfigElement::HostBlock(block) => {
348 if is_host_pattern(&block.host_pattern) {
349 continue;
350 }
351 entries.push(block.to_host_entry());
352 }
353 ConfigElement::Include(include) => {
354 for file in &include.resolved_files {
355 let start = entries.len();
356 Self::collect_host_entries(&file.elements, entries);
357 for entry in &mut entries[start..] {
358 if entry.source_file.is_none() {
359 entry.source_file = Some(file.path.clone());
360 }
361 }
362 }
363 }
364 ConfigElement::GlobalLine(_) => {}
365 }
366 }
367 }
368
369 pub fn has_host(&self, alias: &str) -> bool {
372 Self::has_host_in_elements(&self.elements, alias)
373 }
374
375 fn has_host_in_elements(elements: &[ConfigElement], alias: &str) -> bool {
376 for e in elements {
377 match e {
378 ConfigElement::HostBlock(block) => {
379 if block.host_pattern.split_whitespace().any(|p| p == alias) {
380 return true;
381 }
382 }
383 ConfigElement::Include(include) => {
384 for file in &include.resolved_files {
385 if Self::has_host_in_elements(&file.elements, alias) {
386 return true;
387 }
388 }
389 }
390 ConfigElement::GlobalLine(_) => {}
391 }
392 }
393 false
394 }
395
396 pub fn add_host(&mut self, entry: &HostEntry) {
398 let block = Self::entry_to_block(entry);
399 if !self.elements.is_empty() && !self.last_element_has_trailing_blank() {
401 self.elements
402 .push(ConfigElement::GlobalLine(String::new()));
403 }
404 self.elements.push(ConfigElement::HostBlock(block));
405 }
406
407 pub fn last_element_has_trailing_blank(&self) -> bool {
409 match self.elements.last() {
410 Some(ConfigElement::HostBlock(block)) => block
411 .directives
412 .last()
413 .is_some_and(|d| d.is_non_directive && d.raw_line.trim().is_empty()),
414 Some(ConfigElement::GlobalLine(line)) => line.trim().is_empty(),
415 _ => false,
416 }
417 }
418
419 pub fn update_host(&mut self, old_alias: &str, entry: &HostEntry) {
422 for element in &mut self.elements {
423 if let ConfigElement::HostBlock(block) = element {
424 if block.host_pattern == old_alias {
425 if entry.alias != block.host_pattern {
427 block.host_pattern = entry.alias.clone();
428 block.raw_host_line = format!("Host {}", entry.alias);
429 }
430
431 Self::upsert_directive(block, "HostName", &entry.hostname);
433 Self::upsert_directive(block, "User", &entry.user);
434 if entry.port != 22 {
435 Self::upsert_directive(block, "Port", &entry.port.to_string());
436 } else {
437 block
439 .directives
440 .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case("port"));
441 }
442 Self::upsert_directive(block, "IdentityFile", &entry.identity_file);
443 Self::upsert_directive(block, "ProxyJump", &entry.proxy_jump);
444 return;
445 }
446 }
447 }
448 }
449
450 fn upsert_directive(block: &mut HostBlock, key: &str, value: &str) {
452 if value.is_empty() {
453 block
454 .directives
455 .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case(key));
456 return;
457 }
458 let indent = block.detect_indent();
459 for d in &mut block.directives {
460 if !d.is_non_directive && d.key.eq_ignore_ascii_case(key) {
461 if d.value != value {
463 d.value = value.to_string();
464 let trimmed = d.raw_line.trim_start();
466 let after_key = &trimmed[d.key.len()..];
467 let sep = if after_key.starts_with('=') { "=" } else { " " };
468 d.raw_line = format!("{}{}{}{}", indent, d.key, sep, value);
469 }
470 return;
471 }
472 }
473 let pos = block.content_end();
475 block.directives.insert(
476 pos,
477 Directive {
478 key: key.to_string(),
479 value: value.to_string(),
480 raw_line: format!("{}{} {}", indent, key, value),
481 is_non_directive: false,
482 },
483 );
484 }
485
486 pub fn set_host_provider(&mut self, alias: &str, provider_name: &str, server_id: &str) {
488 for element in &mut self.elements {
489 if let ConfigElement::HostBlock(block) = element {
490 if block.host_pattern == alias {
491 block.set_provider(provider_name, server_id);
492 return;
493 }
494 }
495 }
496 }
497
498 pub fn find_hosts_by_provider(&self, provider_name: &str) -> Vec<(String, String)> {
502 let mut results = Vec::new();
503 Self::collect_provider_hosts(&self.elements, provider_name, &mut results);
504 results
505 }
506
507 fn collect_provider_hosts(
508 elements: &[ConfigElement],
509 provider_name: &str,
510 results: &mut Vec<(String, String)>,
511 ) {
512 for element in elements {
513 match element {
514 ConfigElement::HostBlock(block) => {
515 if let Some((name, id)) = block.provider() {
516 if name == provider_name {
517 results.push((block.host_pattern.clone(), id));
518 }
519 }
520 }
521 ConfigElement::Include(include) => {
522 for file in &include.resolved_files {
523 Self::collect_provider_hosts(&file.elements, provider_name, results);
524 }
525 }
526 ConfigElement::GlobalLine(_) => {}
527 }
528 }
529 }
530
531 pub fn deduplicate_alias(&self, base: &str) -> String {
533 if !self.has_host(base) {
534 return base.to_string();
535 }
536 for n in 2..=9999 {
537 let candidate = format!("{}-{}", base, n);
538 if !self.has_host(&candidate) {
539 return candidate;
540 }
541 }
542 format!("{}-{}", base, std::process::id())
544 }
545
546 pub fn set_host_tags(&mut self, alias: &str, tags: &[String]) {
548 for element in &mut self.elements {
549 if let ConfigElement::HostBlock(block) = element {
550 if block.host_pattern == alias {
551 block.set_tags(tags);
552 return;
553 }
554 }
555 }
556 }
557
558 #[allow(dead_code)]
560 pub fn delete_host(&mut self, alias: &str) {
561 self.elements.retain(|e| match e {
562 ConfigElement::HostBlock(block) => block.host_pattern != alias,
563 _ => true,
564 });
565 self.elements.dedup_by(|a, b| {
567 matches!(
568 (&*a, &*b),
569 (ConfigElement::GlobalLine(x), ConfigElement::GlobalLine(y))
570 if x.trim().is_empty() && y.trim().is_empty()
571 )
572 });
573 }
574
575 pub fn delete_host_undoable(&mut self, alias: &str) -> Option<(ConfigElement, usize)> {
578 let pos = self.elements.iter().position(|e| {
579 matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias)
580 })?;
581 let element = self.elements.remove(pos);
582 Some((element, pos))
583 }
584
585 pub fn insert_host_at(&mut self, element: ConfigElement, position: usize) {
587 let pos = position.min(self.elements.len());
588 self.elements.insert(pos, element);
589 }
590
591 #[allow(dead_code)]
593 pub fn swap_hosts(&mut self, alias_a: &str, alias_b: &str) -> bool {
594 let pos_a = self.elements.iter().position(|e| {
595 matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_a)
596 });
597 let pos_b = self.elements.iter().position(|e| {
598 matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_b)
599 });
600 if let (Some(a), Some(b)) = (pos_a, pos_b) {
601 if a == b {
602 return false;
603 }
604 let (first, second) = (a.min(b), a.max(b));
605
606 if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
608 block.pop_trailing_blanks();
609 }
610 if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
611 block.pop_trailing_blanks();
612 }
613
614 self.elements.swap(first, second);
616
617 if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
619 block.ensure_trailing_blank();
620 }
621
622 if second < self.elements.len() - 1 {
624 if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
625 block.ensure_trailing_blank();
626 }
627 }
628
629 return true;
630 }
631 false
632 }
633
634 pub(crate) fn entry_to_block(entry: &HostEntry) -> HostBlock {
636 let mut directives = Vec::new();
637
638 if !entry.hostname.is_empty() {
639 directives.push(Directive {
640 key: "HostName".to_string(),
641 value: entry.hostname.clone(),
642 raw_line: format!(" HostName {}", entry.hostname),
643 is_non_directive: false,
644 });
645 }
646 if !entry.user.is_empty() {
647 directives.push(Directive {
648 key: "User".to_string(),
649 value: entry.user.clone(),
650 raw_line: format!(" User {}", entry.user),
651 is_non_directive: false,
652 });
653 }
654 if entry.port != 22 {
655 directives.push(Directive {
656 key: "Port".to_string(),
657 value: entry.port.to_string(),
658 raw_line: format!(" Port {}", entry.port),
659 is_non_directive: false,
660 });
661 }
662 if !entry.identity_file.is_empty() {
663 directives.push(Directive {
664 key: "IdentityFile".to_string(),
665 value: entry.identity_file.clone(),
666 raw_line: format!(" IdentityFile {}", entry.identity_file),
667 is_non_directive: false,
668 });
669 }
670 if !entry.proxy_jump.is_empty() {
671 directives.push(Directive {
672 key: "ProxyJump".to_string(),
673 value: entry.proxy_jump.clone(),
674 raw_line: format!(" ProxyJump {}", entry.proxy_jump),
675 is_non_directive: false,
676 });
677 }
678
679 HostBlock {
680 host_pattern: entry.alias.clone(),
681 raw_host_line: format!("Host {}", entry.alias),
682 directives,
683 }
684 }
685}