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