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}
78
79impl HostEntry {
80 pub fn ssh_command(&self) -> String {
83 let escaped = self.alias.replace('\'', "'\\''");
84 format!("ssh -- '{}'", escaped)
85 }
86}
87
88impl HostBlock {
89 fn content_end(&self) -> usize {
91 let mut pos = self.directives.len();
92 while pos > 0 {
93 if self.directives[pos - 1].is_non_directive
94 && self.directives[pos - 1].raw_line.trim().is_empty()
95 {
96 pos -= 1;
97 } else {
98 break;
99 }
100 }
101 pos
102 }
103
104 fn pop_trailing_blanks(&mut self) -> Vec<Directive> {
106 let end = self.content_end();
107 self.directives.drain(end..).collect()
108 }
109
110 fn ensure_trailing_blank(&mut self) {
112 self.pop_trailing_blanks();
113 self.directives.push(Directive {
114 key: String::new(),
115 value: String::new(),
116 raw_line: String::new(),
117 is_non_directive: true,
118 });
119 }
120
121 fn detect_indent(&self) -> String {
123 for d in &self.directives {
124 if !d.is_non_directive && !d.raw_line.is_empty() {
125 let trimmed = d.raw_line.trim_start();
126 let indent_len = d.raw_line.len() - trimmed.len();
127 if indent_len > 0 {
128 return d.raw_line[..indent_len].to_string();
129 }
130 }
131 }
132 " ".to_string()
133 }
134
135 pub fn tags(&self) -> Vec<String> {
137 for d in &self.directives {
138 if d.is_non_directive {
139 let trimmed = d.raw_line.trim();
140 if let Some(rest) = trimmed.strip_prefix("# purple:tags ") {
141 return rest
142 .split(',')
143 .map(|t| t.trim().to_string())
144 .filter(|t| !t.is_empty())
145 .collect();
146 }
147 }
148 }
149 Vec::new()
150 }
151
152 pub fn set_tags(&mut self, tags: &[String]) {
154 let indent = self.detect_indent();
155 self.directives.retain(|d| {
156 !(d.is_non_directive && d.raw_line.trim().starts_with("# purple:tags"))
157 });
158 if !tags.is_empty() {
159 let pos = self.content_end();
160 self.directives.insert(
161 pos,
162 Directive {
163 key: String::new(),
164 value: String::new(),
165 raw_line: format!("{}# purple:tags {}", indent, tags.join(",")),
166 is_non_directive: true,
167 },
168 );
169 }
170 }
171
172 pub fn to_host_entry(&self) -> HostEntry {
174 let mut entry = HostEntry {
175 alias: self.host_pattern.clone(),
176 port: 22,
177 ..Default::default()
178 };
179 for d in &self.directives {
180 if d.is_non_directive {
181 continue;
182 }
183 match d.key.to_lowercase().as_str() {
184 "hostname" => entry.hostname = d.value.clone(),
185 "user" => entry.user = d.value.clone(),
186 "port" => entry.port = d.value.parse().unwrap_or(22),
187 "identityfile" => {
188 if entry.identity_file.is_empty() {
189 entry.identity_file = d.value.clone();
190 }
191 }
192 "proxyjump" => entry.proxy_jump = d.value.clone(),
193 _ => {}
194 }
195 }
196 entry.tags = self.tags();
197 entry
198 }
199}
200
201impl SshConfigFile {
202 pub fn host_entries(&self) -> Vec<HostEntry> {
204 Self::collect_host_entries(&self.elements)
205 }
206
207 pub fn include_paths(&self) -> Vec<PathBuf> {
209 let mut paths = Vec::new();
210 Self::collect_include_paths(&self.elements, &mut paths);
211 paths
212 }
213
214 fn collect_include_paths(elements: &[ConfigElement], paths: &mut Vec<PathBuf>) {
215 for e in elements {
216 if let ConfigElement::Include(include) = e {
217 for file in &include.resolved_files {
218 paths.push(file.path.clone());
219 Self::collect_include_paths(&file.elements, paths);
220 }
221 }
222 }
223 }
224
225 pub fn include_glob_dirs(&self) -> Vec<PathBuf> {
228 let config_dir = self.path.parent();
229 let mut dirs = Vec::new();
230 Self::collect_include_glob_dirs(&self.elements, config_dir, &mut dirs);
231 dirs
232 }
233
234 fn collect_include_glob_dirs(
235 elements: &[ConfigElement],
236 config_dir: Option<&std::path::Path>,
237 dirs: &mut Vec<PathBuf>,
238 ) {
239 for e in elements {
240 if let ConfigElement::Include(include) = e {
241 let expanded = Self::expand_tilde(&include.pattern);
242 let resolved = if expanded.starts_with('/') {
243 PathBuf::from(&expanded)
244 } else if let Some(dir) = config_dir {
245 dir.join(&expanded)
246 } else {
247 continue;
248 };
249 if let Some(parent) = resolved.parent() {
250 let parent = parent.to_path_buf();
251 if !dirs.contains(&parent) {
252 dirs.push(parent);
253 }
254 }
255 for file in &include.resolved_files {
257 Self::collect_include_glob_dirs(
258 &file.elements,
259 file.path.parent(),
260 dirs,
261 );
262 }
263 }
264 }
265 }
266
267
268 fn collect_host_entries(elements: &[ConfigElement]) -> Vec<HostEntry> {
270 let mut entries = Vec::new();
271 for e in elements {
272 match e {
273 ConfigElement::HostBlock(block) => {
274 if block.host_pattern.contains('*')
276 || block.host_pattern.contains('?')
277 || block.host_pattern.contains(' ')
278 || block.host_pattern.contains('\t')
279 {
280 continue;
281 }
282 entries.push(block.to_host_entry());
283 }
284 ConfigElement::Include(include) => {
285 for file in &include.resolved_files {
286 let mut file_entries = Self::collect_host_entries(&file.elements);
287 for entry in &mut file_entries {
288 if entry.source_file.is_none() {
289 entry.source_file = Some(file.path.clone());
290 }
291 }
292 entries.extend(file_entries);
293 }
294 }
295 ConfigElement::GlobalLine(_) => {}
296 }
297 }
298 entries
299 }
300
301 pub fn has_host(&self, alias: &str) -> bool {
304 Self::has_host_in_elements(&self.elements, alias)
305 }
306
307 fn has_host_in_elements(elements: &[ConfigElement], alias: &str) -> bool {
308 for e in elements {
309 match e {
310 ConfigElement::HostBlock(block) => {
311 if block.host_pattern.split_whitespace().any(|p| p == alias) {
312 return true;
313 }
314 }
315 ConfigElement::Include(include) => {
316 for file in &include.resolved_files {
317 if Self::has_host_in_elements(&file.elements, alias) {
318 return true;
319 }
320 }
321 }
322 ConfigElement::GlobalLine(_) => {}
323 }
324 }
325 false
326 }
327
328 pub fn add_host(&mut self, entry: &HostEntry) {
330 let block = Self::entry_to_block(entry);
331 if !self.elements.is_empty() && !self.last_element_has_trailing_blank() {
333 self.elements
334 .push(ConfigElement::GlobalLine(String::new()));
335 }
336 self.elements.push(ConfigElement::HostBlock(block));
337 }
338
339 pub fn last_element_has_trailing_blank(&self) -> bool {
341 match self.elements.last() {
342 Some(ConfigElement::HostBlock(block)) => block
343 .directives
344 .last()
345 .is_some_and(|d| d.is_non_directive && d.raw_line.trim().is_empty()),
346 Some(ConfigElement::GlobalLine(line)) => line.trim().is_empty(),
347 _ => false,
348 }
349 }
350
351 pub fn update_host(&mut self, old_alias: &str, entry: &HostEntry) {
354 for element in &mut self.elements {
355 if let ConfigElement::HostBlock(block) = element {
356 if block.host_pattern == old_alias {
357 block.host_pattern = entry.alias.clone();
359 block.raw_host_line = format!("Host {}", entry.alias);
360
361 Self::upsert_directive(block, "HostName", &entry.hostname);
363 Self::upsert_directive(block, "User", &entry.user);
364 if entry.port != 22 {
365 Self::upsert_directive(block, "Port", &entry.port.to_string());
366 } else {
367 block
369 .directives
370 .retain(|d| d.is_non_directive || d.key.to_lowercase() != "port");
371 }
372 Self::upsert_directive(block, "IdentityFile", &entry.identity_file);
373 Self::upsert_directive(block, "ProxyJump", &entry.proxy_jump);
374 return;
375 }
376 }
377 }
378 }
379
380 fn upsert_directive(block: &mut HostBlock, key: &str, value: &str) {
382 if value.is_empty() {
383 block
384 .directives
385 .retain(|d| d.is_non_directive || d.key.to_lowercase() != key.to_lowercase());
386 return;
387 }
388 let indent = block.detect_indent();
389 for d in &mut block.directives {
390 if !d.is_non_directive && d.key.to_lowercase() == key.to_lowercase() {
391 if d.value != value {
393 d.value = value.to_string();
394 d.raw_line = format!("{}{} {}", indent, d.key, value);
395 }
396 return;
397 }
398 }
399 let pos = block.content_end();
401 block.directives.insert(
402 pos,
403 Directive {
404 key: key.to_string(),
405 value: value.to_string(),
406 raw_line: format!("{}{} {}", indent, key, value),
407 is_non_directive: false,
408 },
409 );
410 }
411
412 pub fn set_host_tags(&mut self, alias: &str, tags: &[String]) {
414 for element in &mut self.elements {
415 if let ConfigElement::HostBlock(block) = element {
416 if block.host_pattern == alias {
417 block.set_tags(tags);
418 return;
419 }
420 }
421 }
422 }
423
424 #[allow(dead_code)]
426 pub fn delete_host(&mut self, alias: &str) {
427 self.elements.retain(|e| match e {
428 ConfigElement::HostBlock(block) => block.host_pattern != alias,
429 _ => true,
430 });
431 self.elements.dedup_by(|a, b| {
433 matches!(
434 (&*a, &*b),
435 (ConfigElement::GlobalLine(x), ConfigElement::GlobalLine(y))
436 if x.trim().is_empty() && y.trim().is_empty()
437 )
438 });
439 }
440
441 pub fn delete_host_undoable(&mut self, alias: &str) -> Option<(ConfigElement, usize)> {
444 let pos = self.elements.iter().position(|e| {
445 matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias)
446 })?;
447 let element = self.elements.remove(pos);
448 Some((element, pos))
449 }
450
451 pub fn insert_host_at(&mut self, element: ConfigElement, position: usize) {
453 let pos = position.min(self.elements.len());
454 self.elements.insert(pos, element);
455 }
456
457 #[allow(dead_code)]
459 pub fn swap_hosts(&mut self, alias_a: &str, alias_b: &str) -> bool {
460 let pos_a = self.elements.iter().position(|e| {
461 matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_a)
462 });
463 let pos_b = self.elements.iter().position(|e| {
464 matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_b)
465 });
466 if let (Some(a), Some(b)) = (pos_a, pos_b) {
467 let (first, second) = (a.min(b), a.max(b));
468
469 if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
471 block.pop_trailing_blanks();
472 }
473 if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
474 block.pop_trailing_blanks();
475 }
476
477 self.elements.swap(first, second);
479
480 if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
482 block.ensure_trailing_blank();
483 }
484
485 if second < self.elements.len() - 1 {
487 if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
488 block.ensure_trailing_blank();
489 }
490 }
491
492 return true;
493 }
494 false
495 }
496
497 pub(crate) fn entry_to_block(entry: &HostEntry) -> HostBlock {
499 let mut directives = Vec::new();
500
501 if !entry.hostname.is_empty() {
502 directives.push(Directive {
503 key: "HostName".to_string(),
504 value: entry.hostname.clone(),
505 raw_line: format!(" HostName {}", entry.hostname),
506 is_non_directive: false,
507 });
508 }
509 if !entry.user.is_empty() {
510 directives.push(Directive {
511 key: "User".to_string(),
512 value: entry.user.clone(),
513 raw_line: format!(" User {}", entry.user),
514 is_non_directive: false,
515 });
516 }
517 if entry.port != 22 {
518 directives.push(Directive {
519 key: "Port".to_string(),
520 value: entry.port.to_string(),
521 raw_line: format!(" Port {}", entry.port),
522 is_non_directive: false,
523 });
524 }
525 if !entry.identity_file.is_empty() {
526 directives.push(Directive {
527 key: "IdentityFile".to_string(),
528 value: entry.identity_file.clone(),
529 raw_line: format!(" IdentityFile {}", entry.identity_file),
530 is_non_directive: false,
531 });
532 }
533 if !entry.proxy_jump.is_empty() {
534 directives.push(Directive {
535 key: "ProxyJump".to_string(),
536 value: entry.proxy_jump.clone(),
537 raw_line: format!(" ProxyJump {}", entry.proxy_jump),
538 is_non_directive: false,
539 });
540 }
541
542 HostBlock {
543 host_pattern: entry.alias.clone(),
544 raw_host_line: format!("Host {}", entry.alias),
545 directives,
546 }
547 }
548}