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 {
273 continue;
274 }
275 entries.push(block.to_host_entry());
276 }
277 ConfigElement::Include(include) => {
278 for file in &include.resolved_files {
279 let mut file_entries = Self::collect_host_entries(&file.elements);
280 for entry in &mut file_entries {
281 if entry.source_file.is_none() {
282 entry.source_file = Some(file.path.clone());
283 }
284 }
285 entries.extend(file_entries);
286 }
287 }
288 ConfigElement::GlobalLine(_) => {}
289 }
290 }
291 entries
292 }
293
294 pub fn has_host(&self, alias: &str) -> bool {
297 Self::has_host_in_elements(&self.elements, alias)
298 }
299
300 fn has_host_in_elements(elements: &[ConfigElement], alias: &str) -> bool {
301 for e in elements {
302 match e {
303 ConfigElement::HostBlock(block) => {
304 if block.host_pattern == alias {
305 return true;
306 }
307 }
308 ConfigElement::Include(include) => {
309 for file in &include.resolved_files {
310 if Self::has_host_in_elements(&file.elements, alias) {
311 return true;
312 }
313 }
314 }
315 ConfigElement::GlobalLine(_) => {}
316 }
317 }
318 false
319 }
320
321 pub fn add_host(&mut self, entry: &HostEntry) {
323 let block = Self::entry_to_block(entry);
324 if !self.elements.is_empty() && !self.last_element_has_trailing_blank() {
326 self.elements
327 .push(ConfigElement::GlobalLine(String::new()));
328 }
329 self.elements.push(ConfigElement::HostBlock(block));
330 }
331
332 fn last_element_has_trailing_blank(&self) -> bool {
334 match self.elements.last() {
335 Some(ConfigElement::HostBlock(block)) => block
336 .directives
337 .last()
338 .is_some_and(|d| d.is_non_directive && d.raw_line.trim().is_empty()),
339 Some(ConfigElement::GlobalLine(line)) => line.trim().is_empty(),
340 _ => false,
341 }
342 }
343
344 pub fn update_host(&mut self, old_alias: &str, entry: &HostEntry) {
347 for element in &mut self.elements {
348 if let ConfigElement::HostBlock(block) = element {
349 if block.host_pattern == old_alias {
350 block.host_pattern = entry.alias.clone();
352 block.raw_host_line = format!("Host {}", entry.alias);
353
354 Self::upsert_directive(block, "HostName", &entry.hostname);
356 Self::upsert_directive(block, "User", &entry.user);
357 if entry.port != 22 {
358 Self::upsert_directive(block, "Port", &entry.port.to_string());
359 } else {
360 block
362 .directives
363 .retain(|d| d.is_non_directive || d.key.to_lowercase() != "port");
364 }
365 Self::upsert_directive(block, "IdentityFile", &entry.identity_file);
366 Self::upsert_directive(block, "ProxyJump", &entry.proxy_jump);
367 return;
368 }
369 }
370 }
371 }
372
373 fn upsert_directive(block: &mut HostBlock, key: &str, value: &str) {
375 if value.is_empty() {
376 block
377 .directives
378 .retain(|d| d.is_non_directive || d.key.to_lowercase() != key.to_lowercase());
379 return;
380 }
381 let indent = block.detect_indent();
382 for d in &mut block.directives {
383 if !d.is_non_directive && d.key.to_lowercase() == key.to_lowercase() {
384 d.value = value.to_string();
385 d.raw_line = format!("{}{} {}", indent, d.key, value);
387 return;
388 }
389 }
390 let pos = block.content_end();
392 block.directives.insert(
393 pos,
394 Directive {
395 key: key.to_string(),
396 value: value.to_string(),
397 raw_line: format!("{}{} {}", indent, key, value),
398 is_non_directive: false,
399 },
400 );
401 }
402
403 pub fn set_host_tags(&mut self, alias: &str, tags: &[String]) {
405 for element in &mut self.elements {
406 if let ConfigElement::HostBlock(block) = element {
407 if block.host_pattern == alias {
408 block.set_tags(tags);
409 return;
410 }
411 }
412 }
413 }
414
415 #[allow(dead_code)]
417 pub fn delete_host(&mut self, alias: &str) {
418 self.elements.retain(|e| match e {
419 ConfigElement::HostBlock(block) => block.host_pattern != alias,
420 _ => true,
421 });
422 self.elements.dedup_by(|a, b| {
424 matches!(
425 (&*a, &*b),
426 (ConfigElement::GlobalLine(x), ConfigElement::GlobalLine(y))
427 if x.trim().is_empty() && y.trim().is_empty()
428 )
429 });
430 }
431
432 pub fn delete_host_undoable(&mut self, alias: &str) -> Option<(ConfigElement, usize)> {
435 let pos = self.elements.iter().position(|e| {
436 matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias)
437 })?;
438 let element = self.elements.remove(pos);
439 Some((element, pos))
440 }
441
442 pub fn insert_host_at(&mut self, element: ConfigElement, position: usize) {
444 let pos = position.min(self.elements.len());
445 self.elements.insert(pos, element);
446 }
447
448 #[allow(dead_code)]
450 pub fn swap_hosts(&mut self, alias_a: &str, alias_b: &str) -> bool {
451 let pos_a = self.elements.iter().position(|e| {
452 matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_a)
453 });
454 let pos_b = self.elements.iter().position(|e| {
455 matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_b)
456 });
457 if let (Some(a), Some(b)) = (pos_a, pos_b) {
458 let (first, second) = (a.min(b), a.max(b));
459
460 if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
462 block.pop_trailing_blanks();
463 }
464 if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
465 block.pop_trailing_blanks();
466 }
467
468 self.elements.swap(first, second);
470
471 if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
473 block.ensure_trailing_blank();
474 }
475
476 if second < self.elements.len() - 1 {
478 if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
479 block.ensure_trailing_blank();
480 }
481 }
482
483 return true;
484 }
485 false
486 }
487
488 fn entry_to_block(entry: &HostEntry) -> HostBlock {
490 let mut directives = Vec::new();
491
492 if !entry.hostname.is_empty() {
493 directives.push(Directive {
494 key: "HostName".to_string(),
495 value: entry.hostname.clone(),
496 raw_line: format!(" HostName {}", entry.hostname),
497 is_non_directive: false,
498 });
499 }
500 if !entry.user.is_empty() {
501 directives.push(Directive {
502 key: "User".to_string(),
503 value: entry.user.clone(),
504 raw_line: format!(" User {}", entry.user),
505 is_non_directive: false,
506 });
507 }
508 if entry.port != 22 {
509 directives.push(Directive {
510 key: "Port".to_string(),
511 value: entry.port.to_string(),
512 raw_line: format!(" Port {}", entry.port),
513 is_non_directive: false,
514 });
515 }
516 if !entry.identity_file.is_empty() {
517 directives.push(Directive {
518 key: "IdentityFile".to_string(),
519 value: entry.identity_file.clone(),
520 raw_line: format!(" IdentityFile {}", entry.identity_file),
521 is_non_directive: false,
522 });
523 }
524 if !entry.proxy_jump.is_empty() {
525 directives.push(Directive {
526 key: "ProxyJump".to_string(),
527 value: entry.proxy_jump.clone(),
528 raw_line: format!(" ProxyJump {}", entry.proxy_jump),
529 is_non_directive: false,
530 });
531 }
532
533 HostBlock {
534 host_pattern: entry.alias.clone(),
535 raw_host_line: format!("Host {}", entry.alias),
536 directives,
537 }
538 }
539}