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, Default)]
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 HostEntry {
82 pub fn ssh_command(&self) -> String {
85 let escaped = self.alias.replace('\'', "'\\''");
86 format!("ssh -- '{}'", escaped)
87 }
88}
89
90impl HostBlock {
91 fn content_end(&self) -> usize {
93 let mut pos = self.directives.len();
94 while pos > 0 {
95 if self.directives[pos - 1].is_non_directive
96 && self.directives[pos - 1].raw_line.trim().is_empty()
97 {
98 pos -= 1;
99 } else {
100 break;
101 }
102 }
103 pos
104 }
105
106 fn pop_trailing_blanks(&mut self) -> Vec<Directive> {
108 let end = self.content_end();
109 self.directives.drain(end..).collect()
110 }
111
112 fn ensure_trailing_blank(&mut self) {
114 self.pop_trailing_blanks();
115 self.directives.push(Directive {
116 key: String::new(),
117 value: String::new(),
118 raw_line: String::new(),
119 is_non_directive: true,
120 });
121 }
122
123 fn detect_indent(&self) -> String {
125 for d in &self.directives {
126 if !d.is_non_directive && !d.raw_line.is_empty() {
127 let trimmed = d.raw_line.trim_start();
128 let indent_len = d.raw_line.len() - trimmed.len();
129 if indent_len > 0 {
130 return d.raw_line[..indent_len].to_string();
131 }
132 }
133 }
134 " ".to_string()
135 }
136
137 pub fn tags(&self) -> Vec<String> {
139 for d in &self.directives {
140 if d.is_non_directive {
141 let trimmed = d.raw_line.trim();
142 if let Some(rest) = trimmed.strip_prefix("# purple:tags ") {
143 return rest
144 .split(',')
145 .map(|t| t.trim().to_string())
146 .filter(|t| !t.is_empty())
147 .collect();
148 }
149 }
150 }
151 Vec::new()
152 }
153
154 pub fn provider(&self) -> Option<(String, String)> {
157 for d in &self.directives {
158 if d.is_non_directive {
159 let trimmed = d.raw_line.trim();
160 if let Some(rest) = trimmed.strip_prefix("# purple:provider ") {
161 if let Some((name, id)) = rest.split_once(':') {
162 return Some((name.trim().to_string(), id.trim().to_string()));
163 }
164 }
165 }
166 }
167 None
168 }
169
170 pub fn set_provider(&mut self, provider_name: &str, server_id: &str) {
172 let indent = self.detect_indent();
173 self.directives.retain(|d| {
174 !(d.is_non_directive && d.raw_line.trim().starts_with("# purple:provider"))
175 });
176 let pos = self.content_end();
177 self.directives.insert(
178 pos,
179 Directive {
180 key: String::new(),
181 value: String::new(),
182 raw_line: format!("{}# purple:provider {}:{}", indent, provider_name, server_id),
183 is_non_directive: true,
184 },
185 );
186 }
187
188 pub fn set_tags(&mut self, tags: &[String]) {
190 let indent = self.detect_indent();
191 self.directives.retain(|d| {
192 !(d.is_non_directive && d.raw_line.trim().starts_with("# purple:tags"))
193 });
194 if !tags.is_empty() {
195 let pos = self.content_end();
196 self.directives.insert(
197 pos,
198 Directive {
199 key: String::new(),
200 value: String::new(),
201 raw_line: format!("{}# purple:tags {}", indent, tags.join(",")),
202 is_non_directive: true,
203 },
204 );
205 }
206 }
207
208 pub fn to_host_entry(&self) -> HostEntry {
210 let mut entry = HostEntry {
211 alias: self.host_pattern.clone(),
212 port: 22,
213 ..Default::default()
214 };
215 for d in &self.directives {
216 if d.is_non_directive {
217 continue;
218 }
219 match d.key.to_lowercase().as_str() {
220 "hostname" => entry.hostname = d.value.clone(),
221 "user" => entry.user = d.value.clone(),
222 "port" => entry.port = d.value.parse().unwrap_or(22),
223 "identityfile" => {
224 if entry.identity_file.is_empty() {
225 entry.identity_file = d.value.clone();
226 }
227 }
228 "proxyjump" => entry.proxy_jump = d.value.clone(),
229 _ => {}
230 }
231 }
232 entry.tags = self.tags();
233 entry.provider = self.provider().map(|(name, _)| name);
234 entry
235 }
236}
237
238impl SshConfigFile {
239 pub fn host_entries(&self) -> Vec<HostEntry> {
241 Self::collect_host_entries(&self.elements)
242 }
243
244 pub fn include_paths(&self) -> Vec<PathBuf> {
246 let mut paths = Vec::new();
247 Self::collect_include_paths(&self.elements, &mut paths);
248 paths
249 }
250
251 fn collect_include_paths(elements: &[ConfigElement], paths: &mut Vec<PathBuf>) {
252 for e in elements {
253 if let ConfigElement::Include(include) = e {
254 for file in &include.resolved_files {
255 paths.push(file.path.clone());
256 Self::collect_include_paths(&file.elements, paths);
257 }
258 }
259 }
260 }
261
262 pub fn include_glob_dirs(&self) -> Vec<PathBuf> {
265 let config_dir = self.path.parent();
266 let mut dirs = Vec::new();
267 Self::collect_include_glob_dirs(&self.elements, config_dir, &mut dirs);
268 dirs
269 }
270
271 fn collect_include_glob_dirs(
272 elements: &[ConfigElement],
273 config_dir: Option<&std::path::Path>,
274 dirs: &mut Vec<PathBuf>,
275 ) {
276 for e in elements {
277 if let ConfigElement::Include(include) = e {
278 for single in include.pattern.split_whitespace() {
281 let expanded = Self::expand_tilde(single);
282 let resolved = if expanded.starts_with('/') {
283 PathBuf::from(&expanded)
284 } else if let Some(dir) = config_dir {
285 dir.join(&expanded)
286 } else {
287 continue;
288 };
289 if let Some(parent) = resolved.parent() {
290 let parent = parent.to_path_buf();
291 if !dirs.contains(&parent) {
292 dirs.push(parent);
293 }
294 }
295 }
296 for file in &include.resolved_files {
298 Self::collect_include_glob_dirs(
299 &file.elements,
300 file.path.parent(),
301 dirs,
302 );
303 }
304 }
305 }
306 }
307
308
309 fn collect_host_entries(elements: &[ConfigElement]) -> Vec<HostEntry> {
311 let mut entries = Vec::new();
312 for e in elements {
313 match e {
314 ConfigElement::HostBlock(block) => {
315 if block.host_pattern.contains('*')
317 || block.host_pattern.contains('?')
318 || block.host_pattern.contains('[')
319 || block.host_pattern.starts_with('!')
320 || block.host_pattern.contains(' ')
321 || block.host_pattern.contains('\t')
322 {
323 continue;
324 }
325 entries.push(block.to_host_entry());
326 }
327 ConfigElement::Include(include) => {
328 for file in &include.resolved_files {
329 let mut file_entries = Self::collect_host_entries(&file.elements);
330 for entry in &mut file_entries {
331 if entry.source_file.is_none() {
332 entry.source_file = Some(file.path.clone());
333 }
334 }
335 entries.extend(file_entries);
336 }
337 }
338 ConfigElement::GlobalLine(_) => {}
339 }
340 }
341 entries
342 }
343
344 pub fn has_host(&self, alias: &str) -> bool {
347 Self::has_host_in_elements(&self.elements, alias)
348 }
349
350 fn has_host_in_elements(elements: &[ConfigElement], alias: &str) -> bool {
351 for e in elements {
352 match e {
353 ConfigElement::HostBlock(block) => {
354 if block.host_pattern.split_whitespace().any(|p| p == alias) {
355 return true;
356 }
357 }
358 ConfigElement::Include(include) => {
359 for file in &include.resolved_files {
360 if Self::has_host_in_elements(&file.elements, alias) {
361 return true;
362 }
363 }
364 }
365 ConfigElement::GlobalLine(_) => {}
366 }
367 }
368 false
369 }
370
371 pub fn add_host(&mut self, entry: &HostEntry) {
373 let block = Self::entry_to_block(entry);
374 if !self.elements.is_empty() && !self.last_element_has_trailing_blank() {
376 self.elements
377 .push(ConfigElement::GlobalLine(String::new()));
378 }
379 self.elements.push(ConfigElement::HostBlock(block));
380 }
381
382 pub fn last_element_has_trailing_blank(&self) -> bool {
384 match self.elements.last() {
385 Some(ConfigElement::HostBlock(block)) => block
386 .directives
387 .last()
388 .is_some_and(|d| d.is_non_directive && d.raw_line.trim().is_empty()),
389 Some(ConfigElement::GlobalLine(line)) => line.trim().is_empty(),
390 _ => false,
391 }
392 }
393
394 pub fn update_host(&mut self, old_alias: &str, entry: &HostEntry) {
397 for element in &mut self.elements {
398 if let ConfigElement::HostBlock(block) = element {
399 if block.host_pattern == old_alias {
400 if entry.alias != block.host_pattern {
402 block.host_pattern = entry.alias.clone();
403 block.raw_host_line = format!("Host {}", entry.alias);
404 }
405
406 Self::upsert_directive(block, "HostName", &entry.hostname);
408 Self::upsert_directive(block, "User", &entry.user);
409 if entry.port != 22 {
410 Self::upsert_directive(block, "Port", &entry.port.to_string());
411 } else {
412 block
414 .directives
415 .retain(|d| d.is_non_directive || d.key.to_lowercase() != "port");
416 }
417 Self::upsert_directive(block, "IdentityFile", &entry.identity_file);
418 Self::upsert_directive(block, "ProxyJump", &entry.proxy_jump);
419 return;
420 }
421 }
422 }
423 }
424
425 fn upsert_directive(block: &mut HostBlock, key: &str, value: &str) {
427 if value.is_empty() {
428 block
429 .directives
430 .retain(|d| d.is_non_directive || d.key.to_lowercase() != key.to_lowercase());
431 return;
432 }
433 let indent = block.detect_indent();
434 for d in &mut block.directives {
435 if !d.is_non_directive && d.key.to_lowercase() == key.to_lowercase() {
436 if d.value != value {
438 d.value = value.to_string();
439 d.raw_line = format!("{}{} {}", indent, d.key, value);
440 }
441 return;
442 }
443 }
444 let pos = block.content_end();
446 block.directives.insert(
447 pos,
448 Directive {
449 key: key.to_string(),
450 value: value.to_string(),
451 raw_line: format!("{}{} {}", indent, key, value),
452 is_non_directive: false,
453 },
454 );
455 }
456
457 pub fn set_host_provider(&mut self, alias: &str, provider_name: &str, server_id: &str) {
459 for element in &mut self.elements {
460 if let ConfigElement::HostBlock(block) = element {
461 if block.host_pattern == alias {
462 block.set_provider(provider_name, server_id);
463 return;
464 }
465 }
466 }
467 }
468
469 pub fn find_hosts_by_provider(&self, provider_name: &str) -> Vec<(String, String)> {
473 let mut results = Vec::new();
474 Self::collect_provider_hosts(&self.elements, provider_name, &mut results);
475 results
476 }
477
478 fn collect_provider_hosts(
479 elements: &[ConfigElement],
480 provider_name: &str,
481 results: &mut Vec<(String, String)>,
482 ) {
483 for element in elements {
484 match element {
485 ConfigElement::HostBlock(block) => {
486 if let Some((name, id)) = block.provider() {
487 if name == provider_name {
488 results.push((block.host_pattern.clone(), id));
489 }
490 }
491 }
492 ConfigElement::Include(include) => {
493 for file in &include.resolved_files {
494 Self::collect_provider_hosts(&file.elements, provider_name, results);
495 }
496 }
497 ConfigElement::GlobalLine(_) => {}
498 }
499 }
500 }
501
502 pub fn deduplicate_alias(&self, base: &str) -> String {
504 if !self.has_host(base) {
505 return base.to_string();
506 }
507 for n in 2..=9999 {
508 let candidate = format!("{}-{}", base, n);
509 if !self.has_host(&candidate) {
510 return candidate;
511 }
512 }
513 format!("{}-{}", base, std::process::id())
515 }
516
517 pub fn set_host_tags(&mut self, alias: &str, tags: &[String]) {
519 for element in &mut self.elements {
520 if let ConfigElement::HostBlock(block) = element {
521 if block.host_pattern == alias {
522 block.set_tags(tags);
523 return;
524 }
525 }
526 }
527 }
528
529 #[allow(dead_code)]
531 pub fn delete_host(&mut self, alias: &str) {
532 self.elements.retain(|e| match e {
533 ConfigElement::HostBlock(block) => block.host_pattern != alias,
534 _ => true,
535 });
536 self.elements.dedup_by(|a, b| {
538 matches!(
539 (&*a, &*b),
540 (ConfigElement::GlobalLine(x), ConfigElement::GlobalLine(y))
541 if x.trim().is_empty() && y.trim().is_empty()
542 )
543 });
544 }
545
546 pub fn delete_host_undoable(&mut self, alias: &str) -> Option<(ConfigElement, usize)> {
549 let pos = self.elements.iter().position(|e| {
550 matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias)
551 })?;
552 let element = self.elements.remove(pos);
553 Some((element, pos))
554 }
555
556 pub fn insert_host_at(&mut self, element: ConfigElement, position: usize) {
558 let pos = position.min(self.elements.len());
559 self.elements.insert(pos, element);
560 }
561
562 #[allow(dead_code)]
564 pub fn swap_hosts(&mut self, alias_a: &str, alias_b: &str) -> bool {
565 let pos_a = self.elements.iter().position(|e| {
566 matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_a)
567 });
568 let pos_b = self.elements.iter().position(|e| {
569 matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_b)
570 });
571 if let (Some(a), Some(b)) = (pos_a, pos_b) {
572 if a == b {
573 return false;
574 }
575 let (first, second) = (a.min(b), a.max(b));
576
577 if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
579 block.pop_trailing_blanks();
580 }
581 if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
582 block.pop_trailing_blanks();
583 }
584
585 self.elements.swap(first, second);
587
588 if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
590 block.ensure_trailing_blank();
591 }
592
593 if second < self.elements.len() - 1 {
595 if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
596 block.ensure_trailing_blank();
597 }
598 }
599
600 return true;
601 }
602 false
603 }
604
605 pub(crate) fn entry_to_block(entry: &HostEntry) -> HostBlock {
607 let mut directives = Vec::new();
608
609 if !entry.hostname.is_empty() {
610 directives.push(Directive {
611 key: "HostName".to_string(),
612 value: entry.hostname.clone(),
613 raw_line: format!(" HostName {}", entry.hostname),
614 is_non_directive: false,
615 });
616 }
617 if !entry.user.is_empty() {
618 directives.push(Directive {
619 key: "User".to_string(),
620 value: entry.user.clone(),
621 raw_line: format!(" User {}", entry.user),
622 is_non_directive: false,
623 });
624 }
625 if entry.port != 22 {
626 directives.push(Directive {
627 key: "Port".to_string(),
628 value: entry.port.to_string(),
629 raw_line: format!(" Port {}", entry.port),
630 is_non_directive: false,
631 });
632 }
633 if !entry.identity_file.is_empty() {
634 directives.push(Directive {
635 key: "IdentityFile".to_string(),
636 value: entry.identity_file.clone(),
637 raw_line: format!(" IdentityFile {}", entry.identity_file),
638 is_non_directive: false,
639 });
640 }
641 if !entry.proxy_jump.is_empty() {
642 directives.push(Directive {
643 key: "ProxyJump".to_string(),
644 value: entry.proxy_jump.clone(),
645 raw_line: format!(" ProxyJump {}", entry.proxy_jump),
646 is_non_directive: false,
647 });
648 }
649
650 HostBlock {
651 host_pattern: entry.alias.clone(),
652 raw_host_line: format!("Host {}", entry.alias),
653 directives,
654 }
655 }
656}