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 let expanded = Self::expand_tilde(&include.pattern);
279 let resolved = if expanded.starts_with('/') {
280 PathBuf::from(&expanded)
281 } else if let Some(dir) = config_dir {
282 dir.join(&expanded)
283 } else {
284 continue;
285 };
286 if let Some(parent) = resolved.parent() {
287 let parent = parent.to_path_buf();
288 if !dirs.contains(&parent) {
289 dirs.push(parent);
290 }
291 }
292 for file in &include.resolved_files {
294 Self::collect_include_glob_dirs(
295 &file.elements,
296 file.path.parent(),
297 dirs,
298 );
299 }
300 }
301 }
302 }
303
304
305 fn collect_host_entries(elements: &[ConfigElement]) -> Vec<HostEntry> {
307 let mut entries = Vec::new();
308 for e in elements {
309 match e {
310 ConfigElement::HostBlock(block) => {
311 if block.host_pattern.contains('*')
313 || block.host_pattern.contains('?')
314 || block.host_pattern.contains(' ')
315 || block.host_pattern.contains('\t')
316 {
317 continue;
318 }
319 entries.push(block.to_host_entry());
320 }
321 ConfigElement::Include(include) => {
322 for file in &include.resolved_files {
323 let mut file_entries = Self::collect_host_entries(&file.elements);
324 for entry in &mut file_entries {
325 if entry.source_file.is_none() {
326 entry.source_file = Some(file.path.clone());
327 }
328 }
329 entries.extend(file_entries);
330 }
331 }
332 ConfigElement::GlobalLine(_) => {}
333 }
334 }
335 entries
336 }
337
338 pub fn has_host(&self, alias: &str) -> bool {
341 Self::has_host_in_elements(&self.elements, alias)
342 }
343
344 fn has_host_in_elements(elements: &[ConfigElement], alias: &str) -> bool {
345 for e in elements {
346 match e {
347 ConfigElement::HostBlock(block) => {
348 if block.host_pattern.split_whitespace().any(|p| p == alias) {
349 return true;
350 }
351 }
352 ConfigElement::Include(include) => {
353 for file in &include.resolved_files {
354 if Self::has_host_in_elements(&file.elements, alias) {
355 return true;
356 }
357 }
358 }
359 ConfigElement::GlobalLine(_) => {}
360 }
361 }
362 false
363 }
364
365 pub fn add_host(&mut self, entry: &HostEntry) {
367 let block = Self::entry_to_block(entry);
368 if !self.elements.is_empty() && !self.last_element_has_trailing_blank() {
370 self.elements
371 .push(ConfigElement::GlobalLine(String::new()));
372 }
373 self.elements.push(ConfigElement::HostBlock(block));
374 }
375
376 pub fn last_element_has_trailing_blank(&self) -> bool {
378 match self.elements.last() {
379 Some(ConfigElement::HostBlock(block)) => block
380 .directives
381 .last()
382 .is_some_and(|d| d.is_non_directive && d.raw_line.trim().is_empty()),
383 Some(ConfigElement::GlobalLine(line)) => line.trim().is_empty(),
384 _ => false,
385 }
386 }
387
388 pub fn update_host(&mut self, old_alias: &str, entry: &HostEntry) {
391 for element in &mut self.elements {
392 if let ConfigElement::HostBlock(block) = element {
393 if block.host_pattern == old_alias {
394 block.host_pattern = entry.alias.clone();
396 block.raw_host_line = format!("Host {}", entry.alias);
397
398 Self::upsert_directive(block, "HostName", &entry.hostname);
400 Self::upsert_directive(block, "User", &entry.user);
401 if entry.port != 22 {
402 Self::upsert_directive(block, "Port", &entry.port.to_string());
403 } else {
404 block
406 .directives
407 .retain(|d| d.is_non_directive || d.key.to_lowercase() != "port");
408 }
409 Self::upsert_directive(block, "IdentityFile", &entry.identity_file);
410 Self::upsert_directive(block, "ProxyJump", &entry.proxy_jump);
411 return;
412 }
413 }
414 }
415 }
416
417 fn upsert_directive(block: &mut HostBlock, key: &str, value: &str) {
419 if value.is_empty() {
420 block
421 .directives
422 .retain(|d| d.is_non_directive || d.key.to_lowercase() != key.to_lowercase());
423 return;
424 }
425 let indent = block.detect_indent();
426 for d in &mut block.directives {
427 if !d.is_non_directive && d.key.to_lowercase() == key.to_lowercase() {
428 if d.value != value {
430 d.value = value.to_string();
431 d.raw_line = format!("{}{} {}", indent, d.key, value);
432 }
433 return;
434 }
435 }
436 let pos = block.content_end();
438 block.directives.insert(
439 pos,
440 Directive {
441 key: key.to_string(),
442 value: value.to_string(),
443 raw_line: format!("{}{} {}", indent, key, value),
444 is_non_directive: false,
445 },
446 );
447 }
448
449 pub fn set_host_provider(&mut self, alias: &str, provider_name: &str, server_id: &str) {
451 for element in &mut self.elements {
452 if let ConfigElement::HostBlock(block) = element {
453 if block.host_pattern == alias {
454 block.set_provider(provider_name, server_id);
455 return;
456 }
457 }
458 }
459 }
460
461 pub fn find_hosts_by_provider(&self, provider_name: &str) -> Vec<(String, String)> {
465 let mut results = Vec::new();
466 Self::collect_provider_hosts(&self.elements, provider_name, &mut results);
467 results
468 }
469
470 fn collect_provider_hosts(
471 elements: &[ConfigElement],
472 provider_name: &str,
473 results: &mut Vec<(String, String)>,
474 ) {
475 for element in elements {
476 match element {
477 ConfigElement::HostBlock(block) => {
478 if let Some((name, id)) = block.provider() {
479 if name == provider_name {
480 results.push((block.host_pattern.clone(), id));
481 }
482 }
483 }
484 ConfigElement::Include(include) => {
485 for file in &include.resolved_files {
486 Self::collect_provider_hosts(&file.elements, provider_name, results);
487 }
488 }
489 ConfigElement::GlobalLine(_) => {}
490 }
491 }
492 }
493
494 pub fn deduplicate_alias(&self, base: &str) -> String {
496 if !self.has_host(base) {
497 return base.to_string();
498 }
499 for n in 2..=9999 {
500 let candidate = format!("{}-{}", base, n);
501 if !self.has_host(&candidate) {
502 return candidate;
503 }
504 }
505 format!("{}-{}", base, std::process::id())
507 }
508
509 pub fn set_host_tags(&mut self, alias: &str, tags: &[String]) {
511 for element in &mut self.elements {
512 if let ConfigElement::HostBlock(block) = element {
513 if block.host_pattern == alias {
514 block.set_tags(tags);
515 return;
516 }
517 }
518 }
519 }
520
521 #[allow(dead_code)]
523 pub fn delete_host(&mut self, alias: &str) {
524 self.elements.retain(|e| match e {
525 ConfigElement::HostBlock(block) => block.host_pattern != alias,
526 _ => true,
527 });
528 self.elements.dedup_by(|a, b| {
530 matches!(
531 (&*a, &*b),
532 (ConfigElement::GlobalLine(x), ConfigElement::GlobalLine(y))
533 if x.trim().is_empty() && y.trim().is_empty()
534 )
535 });
536 }
537
538 pub fn delete_host_undoable(&mut self, alias: &str) -> Option<(ConfigElement, usize)> {
541 let pos = self.elements.iter().position(|e| {
542 matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias)
543 })?;
544 let element = self.elements.remove(pos);
545 Some((element, pos))
546 }
547
548 pub fn insert_host_at(&mut self, element: ConfigElement, position: usize) {
550 let pos = position.min(self.elements.len());
551 self.elements.insert(pos, element);
552 }
553
554 #[allow(dead_code)]
556 pub fn swap_hosts(&mut self, alias_a: &str, alias_b: &str) -> bool {
557 let pos_a = self.elements.iter().position(|e| {
558 matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_a)
559 });
560 let pos_b = self.elements.iter().position(|e| {
561 matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_b)
562 });
563 if let (Some(a), Some(b)) = (pos_a, pos_b) {
564 let (first, second) = (a.min(b), a.max(b));
565
566 if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
568 block.pop_trailing_blanks();
569 }
570 if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
571 block.pop_trailing_blanks();
572 }
573
574 self.elements.swap(first, second);
576
577 if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
579 block.ensure_trailing_blank();
580 }
581
582 if second < self.elements.len() - 1 {
584 if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
585 block.ensure_trailing_blank();
586 }
587 }
588
589 return true;
590 }
591 false
592 }
593
594 pub(crate) fn entry_to_block(entry: &HostEntry) -> HostBlock {
596 let mut directives = Vec::new();
597
598 if !entry.hostname.is_empty() {
599 directives.push(Directive {
600 key: "HostName".to_string(),
601 value: entry.hostname.clone(),
602 raw_line: format!(" HostName {}", entry.hostname),
603 is_non_directive: false,
604 });
605 }
606 if !entry.user.is_empty() {
607 directives.push(Directive {
608 key: "User".to_string(),
609 value: entry.user.clone(),
610 raw_line: format!(" User {}", entry.user),
611 is_non_directive: false,
612 });
613 }
614 if entry.port != 22 {
615 directives.push(Directive {
616 key: "Port".to_string(),
617 value: entry.port.to_string(),
618 raw_line: format!(" Port {}", entry.port),
619 is_non_directive: false,
620 });
621 }
622 if !entry.identity_file.is_empty() {
623 directives.push(Directive {
624 key: "IdentityFile".to_string(),
625 value: entry.identity_file.clone(),
626 raw_line: format!(" IdentityFile {}", entry.identity_file),
627 is_non_directive: false,
628 });
629 }
630 if !entry.proxy_jump.is_empty() {
631 directives.push(Directive {
632 key: "ProxyJump".to_string(),
633 value: entry.proxy_jump.clone(),
634 raw_line: format!(" ProxyJump {}", entry.proxy_jump),
635 is_non_directive: false,
636 });
637 }
638
639 HostBlock {
640 host_pattern: entry.alias.clone(),
641 raw_host_line: format!("Host {}", entry.alias),
642 directives,
643 }
644 }
645}