Skip to main content

purple_ssh/ssh_config/
model.rs

1use std::path::PathBuf;
2
3/// Represents the entire SSH config file as a sequence of elements.
4/// Preserves the original structure for round-trip fidelity.
5#[derive(Debug, Clone)]
6pub struct SshConfigFile {
7    pub elements: Vec<ConfigElement>,
8    pub path: PathBuf,
9    /// Whether the original file used CRLF line endings.
10    pub crlf: bool,
11    /// Whether the original file started with a UTF-8 BOM.
12    pub bom: bool,
13}
14
15/// An Include directive that references other config files.
16#[derive(Debug, Clone)]
17pub struct IncludeDirective {
18    pub raw_line: String,
19    pub pattern: String,
20    pub resolved_files: Vec<IncludedFile>,
21}
22
23/// A file resolved from an Include directive.
24#[derive(Debug, Clone)]
25pub struct IncludedFile {
26    pub path: PathBuf,
27    pub elements: Vec<ConfigElement>,
28}
29
30/// A single element in the config file.
31#[derive(Debug, Clone)]
32pub enum ConfigElement {
33    /// A Host block: the `Host <pattern>` line plus all indented directives.
34    HostBlock(HostBlock),
35    /// A comment, blank line, or global directive not inside a Host block.
36    GlobalLine(String),
37    /// An Include directive referencing other config files (read-only).
38    Include(IncludeDirective),
39}
40
41/// A parsed Host block with its directives.
42#[derive(Debug, Clone)]
43pub struct HostBlock {
44    /// The host alias/pattern (the value after "Host").
45    pub host_pattern: String,
46    /// The original raw "Host ..." line for faithful reproduction.
47    pub raw_host_line: String,
48    /// Parsed directives inside this block.
49    pub directives: Vec<Directive>,
50}
51
52/// A directive line inside a Host block.
53#[derive(Debug, Clone)]
54pub struct Directive {
55    /// The directive key (e.g., "HostName", "User", "Port").
56    pub key: String,
57    /// The directive value.
58    pub value: String,
59    /// The original raw line (preserves indentation, inline comments).
60    pub raw_line: String,
61    /// Whether this is a comment-only or blank line inside a host block.
62    pub is_non_directive: bool,
63}
64
65/// Convenience view for the TUI — extracted from a HostBlock.
66#[derive(Debug, Clone)]
67pub struct HostEntry {
68    pub alias: String,
69    pub hostname: String,
70    pub user: String,
71    pub port: u16,
72    pub identity_file: String,
73    pub proxy_jump: String,
74    /// If this host comes from an included file, the file path.
75    pub source_file: Option<PathBuf>,
76    /// User-added tags from purple:tags comment.
77    pub tags: Vec<String>,
78    /// Provider-synced tags from purple:provider_tags comment.
79    pub provider_tags: Vec<String>,
80    /// Whether a purple:provider_tags comment exists (distinguishes "never migrated" from "empty").
81    pub has_provider_tags: bool,
82    /// Cloud provider label from purple:provider comment (e.g. "do", "vultr").
83    pub provider: Option<String>,
84    /// Provider config label from a 3-segment purple:provider marker
85    /// (`provider:label:server_id`). None for legacy 2-segment markers.
86    /// Used together with `provider` to resolve which labeled config a host
87    /// belongs to in multi-config setups.
88    pub provider_label: Option<String>,
89    /// Number of tunnel forwarding directives.
90    pub tunnel_count: u16,
91    /// Password source from purple:askpass comment (e.g. "keychain", "op://...", "pass:...").
92    pub askpass: Option<String>,
93    /// Vault SSH certificate signing role from purple:vault-ssh comment.
94    pub vault_ssh: Option<String>,
95    /// Optional Vault HTTP endpoint from purple:vault-addr comment. When
96    /// set, purple passes it as `VAULT_ADDR` to the `vault` subprocess for
97    /// this host's signing, overriding the parent shell. Empty = inherit env.
98    pub vault_addr: Option<String>,
99    /// CertificateFile directive value (e.g. "~/.ssh/my-cert.pub").
100    pub certificate_file: String,
101    /// Provider metadata from purple:meta comment (region, plan, etc.).
102    pub provider_meta: Vec<(String, String)>,
103    /// Unix timestamp when the host was marked stale (disappeared from provider sync).
104    pub stale: Option<u64>,
105}
106
107impl Default for HostEntry {
108    fn default() -> Self {
109        Self {
110            alias: String::new(),
111            hostname: String::new(),
112            user: String::new(),
113            port: 22,
114            identity_file: String::new(),
115            proxy_jump: String::new(),
116            source_file: None,
117            tags: Vec::new(),
118            provider_tags: Vec::new(),
119            has_provider_tags: false,
120            provider: None,
121            provider_label: None,
122            tunnel_count: 0,
123            askpass: None,
124            vault_ssh: None,
125            vault_addr: None,
126            certificate_file: String::new(),
127            provider_meta: Vec::new(),
128            stale: None,
129        }
130    }
131}
132
133impl HostEntry {
134    /// Build the SSH command string for this host.
135    /// Includes `-F <config_path>` when the config is non-default so the alias
136    /// resolves correctly when pasted into a terminal.
137    /// Shell-quotes both the config path and alias to prevent injection.
138    pub fn ssh_command(&self, config_path: &std::path::Path) -> String {
139        let escaped = self.alias.replace('\'', "'\\''");
140        let default = dirs::home_dir()
141            .map(|h| h.join(".ssh/config"))
142            .unwrap_or_default();
143        if config_path == default {
144            format!("ssh -- '{}'", escaped)
145        } else {
146            let config_escaped = config_path.display().to_string().replace('\'', "'\\''");
147            format!("ssh -F '{}' -- '{}'", config_escaped, escaped)
148        }
149    }
150}
151
152/// Convenience view for pattern Host blocks in the TUI.
153#[derive(Debug, Clone, Default)]
154pub struct PatternEntry {
155    pub pattern: String,
156    pub hostname: String,
157    pub user: String,
158    pub port: u16,
159    pub identity_file: String,
160    pub proxy_jump: String,
161    pub tags: Vec<String>,
162    pub askpass: Option<String>,
163    pub source_file: Option<PathBuf>,
164    /// All non-comment directives as key-value pairs for display.
165    pub directives: Vec<(String, String)>,
166}
167
168/// Inherited field hints from matching patterns. Each field is `Some((value,
169/// source_pattern))` when a pattern provides that directive, `None` otherwise.
170#[derive(Debug, Clone, Default)]
171pub struct InheritedHints {
172    pub proxy_jump: Option<(String, String)>,
173    pub user: Option<(String, String)>,
174    pub identity_file: Option<(String, String)>,
175}
176
177use super::pattern::apply_first_match_fields;
178/// Returns true if the host pattern contains wildcards, character classes,
179/// negation or whitespace-separated multi-patterns (*, ?, [], !, space/tab).
180/// These are SSH match patterns, not concrete hosts.
181// Pattern-matching lives in `ssh_config::pattern`. These re-exports preserve
182// the old `ssh_config::model::*` import paths used across the codebase and in
183// the model_tests file mounted below.
184#[allow(unused_imports)]
185pub use super::pattern::{
186    host_pattern_matches, is_host_pattern, proxy_jump_contains_self, ssh_pattern_match,
187};
188// Re-exported so the test file mounted below keeps working.
189#[allow(unused_imports)]
190pub(super) use super::repair::provider_group_display_name;
191
192impl SshConfigFile {
193    /// Get all host entries as convenience views (including from Include files).
194    /// Pattern-inherited directives (ProxyJump, User, IdentityFile) are merged
195    /// using SSH-faithful alias-only matching so indicators like ↗ reflect what
196    /// SSH will actually apply when connecting via `ssh <alias>`.
197    pub fn host_entries(&self) -> Vec<HostEntry> {
198        let mut entries = Vec::new();
199        Self::collect_host_entries(&self.elements, &mut entries);
200        self.apply_pattern_inheritance(&mut entries);
201        entries
202    }
203
204    /// Get a single host entry by alias without pattern inheritance applied.
205    /// Returns the raw directives from the host's own block only. Used by the
206    /// edit form so inherited values can be shown as dimmed placeholders.
207    pub fn raw_host_entry(&self, alias: &str) -> Option<HostEntry> {
208        Self::find_raw_host_entry(&self.elements, alias)
209    }
210
211    fn find_raw_host_entry(elements: &[ConfigElement], alias: &str) -> Option<HostEntry> {
212        for e in elements {
213            match e {
214                ConfigElement::HostBlock(block)
215                    if !is_host_pattern(&block.host_pattern) && block.host_pattern == alias =>
216                {
217                    return Some(block.to_host_entry());
218                }
219                ConfigElement::Include(inc) => {
220                    for file in &inc.resolved_files {
221                        if let Some(mut found) = Self::find_raw_host_entry(&file.elements, alias) {
222                            if found.source_file.is_none() {
223                                found.source_file = Some(file.path.clone());
224                            }
225                            return Some(found);
226                        }
227                    }
228                }
229                _ => {}
230            }
231        }
232        None
233    }
234
235    /// Apply SSH first-match-wins pattern inheritance to host entries.
236    /// Matches patterns against the alias only (SSH-faithful: `Host` patterns
237    /// match the token typed on the command line, not the resolved `Hostname`).
238    fn apply_pattern_inheritance(&self, entries: &mut [HostEntry]) {
239        // Patterns are pre-collected once. Host entries never contain pattern
240        // aliases — collect_host_entries skips is_host_pattern blocks.
241        let all_patterns = self.pattern_entries();
242        for entry in entries.iter_mut() {
243            if !entry.proxy_jump.is_empty()
244                && !entry.user.is_empty()
245                && !entry.identity_file.is_empty()
246            {
247                continue;
248            }
249            for p in &all_patterns {
250                if !host_pattern_matches(&p.pattern, &entry.alias) {
251                    continue;
252                }
253                apply_first_match_fields(
254                    &mut entry.proxy_jump,
255                    &mut entry.user,
256                    &mut entry.identity_file,
257                    p,
258                );
259                if !entry.proxy_jump.is_empty()
260                    && !entry.user.is_empty()
261                    && !entry.identity_file.is_empty()
262                {
263                    break;
264                }
265            }
266        }
267    }
268
269    /// Compute pattern-provided field hints for a host alias. Returns first-match
270    /// values and their source patterns for ProxyJump, User and IdentityFile.
271    /// These are returned regardless of whether the host has its own values for
272    /// those fields. The caller (form rendering) decides visibility based on
273    /// whether the field is empty. Matches by alias only (SSH-faithful).
274    pub fn inherited_hints(&self, alias: &str) -> InheritedHints {
275        let patterns = self.matching_patterns(alias);
276        let mut hints = InheritedHints::default();
277        for p in &patterns {
278            if hints.proxy_jump.is_none() && !p.proxy_jump.is_empty() {
279                hints.proxy_jump = Some((p.proxy_jump.clone(), p.pattern.clone()));
280            }
281            if hints.user.is_none() && !p.user.is_empty() {
282                hints.user = Some((p.user.clone(), p.pattern.clone()));
283            }
284            if hints.identity_file.is_none() && !p.identity_file.is_empty() {
285                hints.identity_file = Some((p.identity_file.clone(), p.pattern.clone()));
286            }
287            if hints.proxy_jump.is_some() && hints.user.is_some() && hints.identity_file.is_some() {
288                break;
289            }
290        }
291        hints
292    }
293
294    /// Get all pattern entries as convenience views (including from Include files).
295    pub fn pattern_entries(&self) -> Vec<PatternEntry> {
296        let mut entries = Vec::new();
297        Self::collect_pattern_entries(&self.elements, &mut entries);
298        entries
299    }
300
301    fn collect_pattern_entries(elements: &[ConfigElement], entries: &mut Vec<PatternEntry>) {
302        for e in elements {
303            match e {
304                ConfigElement::HostBlock(block) => {
305                    if !is_host_pattern(&block.host_pattern) {
306                        continue;
307                    }
308                    entries.push(block.to_pattern_entry());
309                }
310                ConfigElement::Include(include) => {
311                    for file in &include.resolved_files {
312                        let start = entries.len();
313                        Self::collect_pattern_entries(&file.elements, entries);
314                        for entry in &mut entries[start..] {
315                            if entry.source_file.is_none() {
316                                entry.source_file = Some(file.path.clone());
317                            }
318                        }
319                    }
320                }
321                ConfigElement::GlobalLine(_) => {}
322            }
323        }
324    }
325
326    /// Find all pattern blocks that match a given host alias and hostname.
327    /// Returns entries in config order (first match first).
328    pub fn matching_patterns(&self, alias: &str) -> Vec<PatternEntry> {
329        let mut matches = Vec::new();
330        Self::collect_matching_patterns(&self.elements, alias, &mut matches);
331        matches
332    }
333
334    fn collect_matching_patterns(
335        elements: &[ConfigElement],
336        alias: &str,
337        matches: &mut Vec<PatternEntry>,
338    ) {
339        for e in elements {
340            match e {
341                ConfigElement::HostBlock(block) => {
342                    if !is_host_pattern(&block.host_pattern) {
343                        continue;
344                    }
345                    if host_pattern_matches(&block.host_pattern, alias) {
346                        matches.push(block.to_pattern_entry());
347                    }
348                }
349                ConfigElement::Include(include) => {
350                    for file in &include.resolved_files {
351                        let start = matches.len();
352                        Self::collect_matching_patterns(&file.elements, alias, matches);
353                        for entry in &mut matches[start..] {
354                            if entry.source_file.is_none() {
355                                entry.source_file = Some(file.path.clone());
356                            }
357                        }
358                    }
359                }
360                ConfigElement::GlobalLine(_) => {}
361            }
362        }
363    }
364
365    /// Collect all resolved Include file paths (recursively).
366    pub fn include_paths(&self) -> Vec<PathBuf> {
367        let mut paths = Vec::new();
368        Self::collect_include_paths(&self.elements, &mut paths);
369        paths
370    }
371
372    fn collect_include_paths(elements: &[ConfigElement], paths: &mut Vec<PathBuf>) {
373        for e in elements {
374            if let ConfigElement::Include(include) = e {
375                for file in &include.resolved_files {
376                    paths.push(file.path.clone());
377                    Self::collect_include_paths(&file.elements, paths);
378                }
379            }
380        }
381    }
382
383    /// Collect parent directories of Include glob patterns.
384    /// When a file is added/removed under a glob dir, the directory's mtime changes.
385    pub fn include_glob_dirs(&self) -> Vec<PathBuf> {
386        let config_dir = self.path.parent();
387        let mut seen = std::collections::HashSet::new();
388        let mut dirs = Vec::new();
389        Self::collect_include_glob_dirs(&self.elements, config_dir, &mut seen, &mut dirs);
390        dirs
391    }
392
393    fn collect_include_glob_dirs(
394        elements: &[ConfigElement],
395        config_dir: Option<&std::path::Path>,
396        seen: &mut std::collections::HashSet<PathBuf>,
397        dirs: &mut Vec<PathBuf>,
398    ) {
399        for e in elements {
400            if let ConfigElement::Include(include) = e {
401                // Split respecting quoted paths (same as resolve_include does)
402                for single in Self::split_include_patterns(&include.pattern) {
403                    let expanded = Self::expand_env_vars(&Self::expand_tilde(single));
404                    let resolved = if expanded.starts_with('/') {
405                        PathBuf::from(&expanded)
406                    } else if let Some(dir) = config_dir {
407                        dir.join(&expanded)
408                    } else {
409                        continue;
410                    };
411                    if let Some(parent) = resolved.parent() {
412                        let parent = parent.to_path_buf();
413                        if seen.insert(parent.clone()) {
414                            dirs.push(parent);
415                        }
416                    }
417                }
418                // Recurse into resolved files
419                for file in &include.resolved_files {
420                    Self::collect_include_glob_dirs(&file.elements, file.path.parent(), seen, dirs);
421                }
422            }
423        }
424    }
425
426    /// Remove `# purple:group <Name>` headers that have no corresponding
427    /// provider hosts. Returns the number of headers removed.
428    /// Recursively collect host entries from a list of elements.
429    fn collect_host_entries(elements: &[ConfigElement], entries: &mut Vec<HostEntry>) {
430        for e in elements {
431            match e {
432                ConfigElement::HostBlock(block) => {
433                    if is_host_pattern(&block.host_pattern) {
434                        continue;
435                    }
436                    entries.push(block.to_host_entry());
437                }
438                ConfigElement::Include(include) => {
439                    for file in &include.resolved_files {
440                        let start = entries.len();
441                        Self::collect_host_entries(&file.elements, entries);
442                        for entry in &mut entries[start..] {
443                            if entry.source_file.is_none() {
444                                entry.source_file = Some(file.path.clone());
445                            }
446                        }
447                    }
448                }
449                ConfigElement::GlobalLine(_) => {}
450            }
451        }
452    }
453
454    /// Check if a host alias already exists (including in Include files).
455    /// Walks the element tree directly without building HostEntry structs.
456    pub fn has_host(&self, alias: &str) -> bool {
457        Self::has_host_in_elements(&self.elements, alias)
458    }
459
460    fn has_host_in_elements(elements: &[ConfigElement], alias: &str) -> bool {
461        for e in elements {
462            match e {
463                ConfigElement::HostBlock(block) => {
464                    if block.host_pattern.split_whitespace().any(|p| p == alias) {
465                        return true;
466                    }
467                }
468                ConfigElement::Include(include) => {
469                    for file in &include.resolved_files {
470                        if Self::has_host_in_elements(&file.elements, alias) {
471                            return true;
472                        }
473                    }
474                }
475                ConfigElement::GlobalLine(_) => {}
476            }
477        }
478        false
479    }
480
481    /// Return the sibling aliases that share a `Host` block with `alias`.
482    ///
483    /// An empty vector means `alias` lives in its own single-alias block (or
484    /// is not present). A non-empty vector lists the other tokens in the
485    /// block in source order, so the UI can render indicators like `+N` or
486    /// spell the aliases out in a confirm dialog before a destructive
487    /// action. Does not recurse into `Include`d files: those are read-only
488    /// and their hosts cannot be edited from purple anyway.
489    pub fn siblings_of(&self, alias: &str) -> Vec<String> {
490        if alias.is_empty() {
491            return Vec::new();
492        }
493        self.elements
494            .iter()
495            .find_map(|el| match el {
496                ConfigElement::HostBlock(b) => {
497                    // Full-pattern match means the caller is acting on the
498                    // whole block (e.g. pattern browser delete of
499                    // `web-01 web-01.prod`). All tokens are the target, so
500                    // there are no "siblings" to preserve.
501                    if b.host_pattern == alias {
502                        return Some(Vec::new());
503                    }
504                    let tokens: Vec<String> = b
505                        .host_pattern
506                        .split_whitespace()
507                        .map(String::from)
508                        .collect();
509                    if tokens.iter().any(|t| t == alias) {
510                        Some(tokens.into_iter().filter(|t| t != alias).collect())
511                    } else {
512                        None
513                    }
514                }
515                _ => None,
516            })
517            .unwrap_or_default()
518    }
519
520    /// Find a mutable top-level `HostBlock` whose `host_pattern` contains
521    /// `alias` as one of its whitespace-separated tokens.
522    ///
523    /// Mirrors the matching used by read-path helpers like `has_host` and
524    /// `find_tunnel_directives`, so that any host visible in the TUI is also
525    /// addressable from write paths (`update_host`, `delete_host`,
526    /// `set_host_*`). Prior to this helper, writers compared the full
527    /// `host_pattern` for exact equality, which silently no-op'd on
528    /// multi-alias blocks like `Host web-01 web-01.prod 10.0.1.5` and
529    /// resulted in on-disk drift between the in-memory view and the config
530    /// file.
531    ///
532    /// Does not recurse into `Include`d files: those are read-only.
533    ///
534    /// A block matches when either (a) its full `host_pattern` equals
535    /// `alias` (used by the pattern browser for blocks like `web-* db-*`
536    /// or `web-01 web-01.prod` whose full pattern is the caller's key) or
537    /// (b) `alias` appears as one of the whitespace-separated tokens (used
538    /// by the host list for multi-alias blocks). The full-pattern match is
539    /// tried first so callers that pass a pattern string do not
540    /// accidentally trigger the token-strip path.
541    fn find_host_block_mut(&mut self, alias: &str) -> Option<&mut HostBlock> {
542        if alias.is_empty() {
543            return None;
544        }
545        self.elements.iter_mut().find_map(|el| match el {
546            ConfigElement::HostBlock(b)
547                if b.host_pattern == alias
548                    || b.host_pattern.split_whitespace().any(|t| t == alias) =>
549            {
550                Some(b)
551            }
552            _ => None,
553        })
554    }
555
556    /// Check if a host block with exactly this host_pattern exists (top-level only).
557    /// Unlike `has_host` which splits multi-host patterns and checks individual parts,
558    /// this matches the full `Host` line pattern string (e.g. "web-* db-*").
559    /// Does not search Include files (patterns from includes are read-only).
560    pub fn has_host_block(&self, pattern: &str) -> bool {
561        self.elements
562            .iter()
563            .any(|e| matches!(e, ConfigElement::HostBlock(block) if block.host_pattern == pattern))
564    }
565
566    /// Check if a host alias is from an included file (read-only).
567    /// Handles multi-pattern Host lines by splitting on whitespace.
568    pub fn is_included_host(&self, alias: &str) -> bool {
569        // Not in top-level elements → must be in an Include
570        for e in &self.elements {
571            match e {
572                ConfigElement::HostBlock(block) => {
573                    if block.host_pattern.split_whitespace().any(|p| p == alias) {
574                        return false;
575                    }
576                }
577                ConfigElement::Include(include) => {
578                    for file in &include.resolved_files {
579                        if Self::has_host_in_elements(&file.elements, alias) {
580                            return true;
581                        }
582                    }
583                }
584                ConfigElement::GlobalLine(_) => {}
585            }
586        }
587        false
588    }
589
590    /// Add a new host entry to the config.
591    /// Inserts before any trailing wildcard/pattern Host blocks (e.g. `Host *`)
592    /// so that SSH "first match wins" semantics are preserved. If wildcards are
593    /// only at the top of the file (acting as global defaults), appends at end.
594    pub fn add_host(&mut self, entry: &HostEntry) {
595        let block = Self::entry_to_block(entry);
596        let insert_pos = self.find_trailing_pattern_start();
597
598        if let Some(pos) = insert_pos {
599            // Insert before the trailing pattern group, with blank separators
600            let needs_blank_before = pos > 0
601                && !matches!(
602                    self.elements.get(pos - 1),
603                    Some(ConfigElement::GlobalLine(line)) if line.trim().is_empty()
604                );
605            let mut idx = pos;
606            if needs_blank_before {
607                self.elements
608                    .insert(idx, ConfigElement::GlobalLine(String::new()));
609                idx += 1;
610            }
611            self.elements.insert(idx, ConfigElement::HostBlock(block));
612            // Ensure a blank separator after the new block (before the wildcard group)
613            let after = idx + 1;
614            if after < self.elements.len()
615                && !matches!(
616                    self.elements.get(after),
617                    Some(ConfigElement::GlobalLine(line)) if line.trim().is_empty()
618                )
619            {
620                self.elements
621                    .insert(after, ConfigElement::GlobalLine(String::new()));
622            }
623        } else {
624            // No trailing patterns: append at end
625            if !self.elements.is_empty() && !self.last_element_has_trailing_blank() {
626                self.elements.push(ConfigElement::GlobalLine(String::new()));
627            }
628            self.elements.push(ConfigElement::HostBlock(block));
629        }
630    }
631
632    /// Find the start of a trailing group of wildcard/pattern Host blocks.
633    /// Scans backwards from the end, skipping GlobalLines (blanks/comments/Match).
634    /// Returns `None` if no trailing patterns exist (or if ALL hosts are patterns,
635    /// i.e. patterns start at position 0 — in that case we append at end).
636    fn find_trailing_pattern_start(&self) -> Option<usize> {
637        let mut first_pattern_pos = None;
638        for i in (0..self.elements.len()).rev() {
639            match &self.elements[i] {
640                ConfigElement::HostBlock(block) => {
641                    if is_host_pattern(&block.host_pattern) {
642                        first_pattern_pos = Some(i);
643                    } else {
644                        // Found a concrete host: the trailing group starts after this
645                        break;
646                    }
647                }
648                ConfigElement::GlobalLine(_) => {
649                    // Blank lines, comments, Match blocks between patterns: keep scanning
650                    if first_pattern_pos.is_some() {
651                        first_pattern_pos = Some(i);
652                    }
653                }
654                ConfigElement::Include(_) => break,
655            }
656        }
657        // Don't return position 0 — that means everything is patterns (or patterns at top)
658        first_pattern_pos.filter(|&pos| pos > 0)
659    }
660
661    /// Check if the last element already ends with a blank line.
662    pub fn last_element_has_trailing_blank(&self) -> bool {
663        match self.elements.last() {
664            Some(ConfigElement::HostBlock(block)) => block
665                .directives
666                .last()
667                .is_some_and(|d| d.is_non_directive && d.raw_line.trim().is_empty()),
668            Some(ConfigElement::GlobalLine(line)) => line.trim().is_empty(),
669            _ => false,
670        }
671    }
672
673    /// Update an existing host entry by alias.
674    /// Merges changes into the existing block, preserving unknown directives.
675    ///
676    /// Alias matching uses whitespace-tokenized equality, so a host visible
677    /// under a multi-alias block like `Host web-01 web-01.prod` is reachable
678    /// from any of its aliases. Directives are shared across all tokens in
679    /// the block (per SSH semantics): updating `User` on `web-01.prod`
680    /// therefore also affects `web-01`.
681    ///
682    /// On rename of a multi-alias block only the matching token is replaced
683    /// in the `Host` line; sibling aliases are preserved verbatim.
684    pub fn update_host(&mut self, old_alias: &str, entry: &HostEntry) {
685        let Some(block) = self.find_host_block_mut(old_alias) else {
686            return;
687        };
688
689        if entry.alias != old_alias {
690            // Full-pattern match (pattern browser rename) replaces the whole
691            // `host_pattern` verbatim. Token match (host list rename on a
692            // multi-alias block) replaces only the selected token so
693            // siblings survive. Single-alias blocks are covered by the
694            // token path because `tokens == [old_alias]`.
695            let is_full_pattern_match = block.host_pattern == old_alias;
696            let new_pattern: String = if is_full_pattern_match {
697                entry.alias.clone()
698            } else {
699                block
700                    .host_pattern
701                    .split_whitespace()
702                    .map(|t| {
703                        if t == old_alias {
704                            entry.alias.as_str()
705                        } else {
706                            t
707                        }
708                    })
709                    .collect::<Vec<_>>()
710                    .join(" ")
711            };
712            block.host_pattern = new_pattern.clone();
713            block.raw_host_line = format!("Host {}", new_pattern);
714        }
715
716        // Merge known directives (update existing, add missing, remove empty)
717        Self::upsert_directive(block, "HostName", &entry.hostname);
718        Self::upsert_directive(block, "User", &entry.user);
719        if entry.port != 22 {
720            Self::upsert_directive(block, "Port", &entry.port.to_string());
721        } else {
722            // Remove explicit Port 22 (it's the default)
723            block
724                .directives
725                .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case("port"));
726        }
727        Self::upsert_directive(block, "IdentityFile", &entry.identity_file);
728        Self::upsert_directive(block, "ProxyJump", &entry.proxy_jump);
729    }
730
731    /// Update a directive in-place, add it if missing, or remove it if value is empty.
732    fn upsert_directive(block: &mut HostBlock, key: &str, value: &str) {
733        if value.is_empty() {
734            block
735                .directives
736                .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case(key));
737            return;
738        }
739        let indent = block.detect_indent();
740        for d in &mut block.directives {
741            if !d.is_non_directive && d.key.eq_ignore_ascii_case(key) {
742                // Only rebuild raw_line when value actually changed (preserves inline comments)
743                if d.value != value {
744                    d.value = value.to_string();
745                    // Detect separator style from original raw_line and preserve it.
746                    // Handles: "Key value", "Key=value", "Key = value", "Key =value"
747                    // Only considers '=' as separator if it appears before any
748                    // non-whitespace content (avoids matching '=' inside values
749                    // like "IdentityFile ~/.ssh/id=prod").
750                    let trimmed = d.raw_line.trim_start();
751                    let after_key = &trimmed[d.key.len()..];
752                    let sep = if after_key.trim_start().starts_with('=') {
753                        let eq_pos = after_key.find('=').unwrap();
754                        let after_eq = &after_key[eq_pos + 1..];
755                        let trailing_ws = after_eq.len() - after_eq.trim_start().len();
756                        after_key[..eq_pos + 1 + trailing_ws].to_string()
757                    } else {
758                        " ".to_string()
759                    };
760                    // Preserve inline comment from original raw_line (e.g. "# production")
761                    let comment_suffix = Self::extract_inline_comment(&d.raw_line, &d.key);
762                    d.raw_line = format!("{}{}{}{}{}", indent, d.key, sep, value, comment_suffix);
763                }
764                return;
765            }
766        }
767        // Not found — insert before trailing blanks
768        let pos = block.content_end();
769        block.directives.insert(
770            pos,
771            Directive {
772                key: key.to_string(),
773                value: value.to_string(),
774                raw_line: format!("{}{} {}", indent, key, value),
775                is_non_directive: false,
776            },
777        );
778    }
779
780    /// Extract the inline comment suffix from a directive's raw line.
781    /// Returns the trailing portion (e.g. " # production") or empty string.
782    /// Respects double-quoted strings so that `#` inside quotes is not a comment.
783    fn extract_inline_comment(raw_line: &str, key: &str) -> String {
784        let trimmed = raw_line.trim_start();
785        if trimmed.len() <= key.len() {
786            return String::new();
787        }
788        // Skip past key and separator to reach the value portion
789        let after_key = &trimmed[key.len()..];
790        let rest = after_key.trim_start();
791        let rest = rest.strip_prefix('=').unwrap_or(rest).trim_start();
792        // Scan for inline comment (# preceded by whitespace, outside quotes)
793        let bytes = rest.as_bytes();
794        let mut in_quote = false;
795        for i in 0..bytes.len() {
796            if bytes[i] == b'"' {
797                in_quote = !in_quote;
798            } else if !in_quote
799                && bytes[i] == b'#'
800                && i > 0
801                && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t')
802            {
803                // Found comment start. The clean value ends before the whitespace preceding #.
804                let clean_end = rest[..i].trim_end().len();
805                return rest[clean_end..].to_string();
806            }
807        }
808        String::new()
809    }
810
811    /// Set provider on a host block by alias using a full ProviderConfigId.
812    /// Emits a 3-segment marker when the id has a label, 2-segment otherwise.
813    pub fn set_host_provider_id(
814        &mut self,
815        alias: &str,
816        id: &crate::providers::config::ProviderConfigId,
817        server_id: &str,
818    ) {
819        if let Some(block) = self.find_host_block_mut(alias) {
820            block.set_provider_id(id, server_id);
821        }
822    }
823
824    /// Rewrite every 2-segment legacy marker for `provider_name` to a
825    /// 3-segment marker keyed to `(provider_name, label)`. Used by the
826    /// lazy-migration flow so existing hosts of a now-labeled config stay
827    /// owned (and don't get re-claimed or stale-marked) on the next sync.
828    ///
829    /// Only top-level host blocks are rewritten; Include files are read-only
830    /// per the project's invariant. Returns the count of host blocks touched.
831    pub fn rewrite_legacy_markers_to_label(&mut self, provider_name: &str, label: &str) -> usize {
832        let new_id = crate::providers::config::ProviderConfigId::labeled(provider_name, label);
833        let mut rewritten = 0usize;
834        for element in &mut self.elements {
835            if let ConfigElement::HostBlock(block) = element {
836                let Some((id, server_id)) = block.provider_id() else {
837                    continue;
838                };
839                if id.provider == provider_name && id.label.is_none() {
840                    block.set_provider_id(&new_id, &server_id);
841                    rewritten += 1;
842                }
843            }
844        }
845        rewritten
846    }
847
848    /// Find all hosts with a specific provider, returning (alias, server_id) pairs.
849    /// Searches both top-level elements and Include files so that provider hosts
850    /// in included configs are recognized during sync (prevents duplicate additions).
851    pub fn find_hosts_by_provider(&self, provider_name: &str) -> Vec<(String, String)> {
852        let mut results = Vec::new();
853        Self::collect_provider_hosts(&self.elements, provider_name, &mut results);
854        results
855    }
856
857    /// Find hosts owned by an exact `ProviderConfigId`. Used during multi-config sync
858    /// so two labeled configs of the same provider don't claim each other's hosts.
859    /// Legacy 2-segment markers match a bare id (label=None) for backward compatibility.
860    pub fn find_hosts_by_id(
861        &self,
862        id: &crate::providers::config::ProviderConfigId,
863    ) -> Vec<(String, String)> {
864        let mut results = Vec::new();
865        Self::collect_provider_hosts_by_id(&self.elements, id, &mut results);
866        results
867    }
868
869    /// Like `find_hosts_by_provider`, but returns the FULL server_id from the
870    /// raw marker (everything after the first colon), without trying to
871    /// interpret the middle segment as a label. Used by sync of BARE configs
872    /// so server_ids containing colons (Proxmox `qemu:300`) are matched
873    /// against the API response one-to-one instead of being mis-parsed as
874    /// labeled markers.
875    pub fn find_hosts_by_provider_raw(&self, provider_name: &str) -> Vec<(String, String)> {
876        let mut results = Vec::new();
877        Self::collect_provider_hosts_raw(&self.elements, provider_name, &mut results);
878        results
879    }
880
881    fn collect_provider_hosts_raw(
882        elements: &[ConfigElement],
883        provider_name: &str,
884        results: &mut Vec<(String, String)>,
885    ) {
886        for element in elements {
887            match element {
888                ConfigElement::HostBlock(block) => {
889                    if let Some((name, server_id)) = block.provider_raw() {
890                        if name == provider_name {
891                            results.push((block.host_pattern.clone(), server_id));
892                        }
893                    }
894                }
895                ConfigElement::Include(include) => {
896                    for file in &include.resolved_files {
897                        Self::collect_provider_hosts_raw(&file.elements, provider_name, results);
898                    }
899                }
900                ConfigElement::GlobalLine(_) => {}
901            }
902        }
903    }
904
905    fn collect_provider_hosts(
906        elements: &[ConfigElement],
907        provider_name: &str,
908        results: &mut Vec<(String, String)>,
909    ) {
910        for element in elements {
911            match element {
912                ConfigElement::HostBlock(block) => {
913                    if let Some((name, id)) = block.provider() {
914                        if name == provider_name {
915                            results.push((block.host_pattern.clone(), id));
916                        }
917                    }
918                }
919                ConfigElement::Include(include) => {
920                    for file in &include.resolved_files {
921                        Self::collect_provider_hosts(&file.elements, provider_name, results);
922                    }
923                }
924                ConfigElement::GlobalLine(_) => {}
925            }
926        }
927    }
928
929    fn collect_provider_hosts_by_id(
930        elements: &[ConfigElement],
931        id: &crate::providers::config::ProviderConfigId,
932        results: &mut Vec<(String, String)>,
933    ) {
934        for element in elements {
935            match element {
936                ConfigElement::HostBlock(block) => {
937                    if let Some((host_id, server_id)) = block.provider_id() {
938                        if &host_id == id {
939                            results.push((block.host_pattern.clone(), server_id));
940                        }
941                    }
942                }
943                ConfigElement::Include(include) => {
944                    for file in &include.resolved_files {
945                        Self::collect_provider_hosts_by_id(&file.elements, id, results);
946                    }
947                }
948                ConfigElement::GlobalLine(_) => {}
949            }
950        }
951    }
952
953    /// Compare two directive values with whitespace normalization.
954    /// Handles hand-edited configs with tabs or multiple spaces.
955    fn values_match(a: &str, b: &str) -> bool {
956        a.split_whitespace().eq(b.split_whitespace())
957    }
958
959    /// Add a forwarding directive to a host block.
960    /// Inserts at `content_end()` (before trailing blanks), using detected indentation.
961    /// Uses split_whitespace matching for multi-pattern Host lines.
962    pub fn add_forward(&mut self, alias: &str, directive_key: &str, value: &str) {
963        for element in &mut self.elements {
964            if let ConfigElement::HostBlock(block) = element {
965                if block.host_pattern.split_whitespace().any(|p| p == alias) {
966                    let indent = block.detect_indent();
967                    let pos = block.content_end();
968                    block.directives.insert(
969                        pos,
970                        Directive {
971                            key: directive_key.to_string(),
972                            value: value.to_string(),
973                            raw_line: format!("{}{} {}", indent, directive_key, value),
974                            is_non_directive: false,
975                        },
976                    );
977                    return;
978                }
979            }
980        }
981    }
982
983    /// Remove a specific forwarding directive from a host block.
984    /// Matches key (case-insensitive) and value (whitespace-normalized).
985    /// Uses split_whitespace matching for multi-pattern Host lines.
986    /// Returns true if a directive was actually removed.
987    pub fn remove_forward(&mut self, alias: &str, directive_key: &str, value: &str) -> bool {
988        for element in &mut self.elements {
989            if let ConfigElement::HostBlock(block) = element {
990                if block.host_pattern.split_whitespace().any(|p| p == alias) {
991                    if let Some(pos) = block.directives.iter().position(|d| {
992                        !d.is_non_directive
993                            && d.key.eq_ignore_ascii_case(directive_key)
994                            && Self::values_match(&d.value, value)
995                    }) {
996                        block.directives.remove(pos);
997                        return true;
998                    }
999                    return false;
1000                }
1001            }
1002        }
1003        false
1004    }
1005
1006    /// Check if a host block has a specific forwarding directive.
1007    /// Uses whitespace-normalized value comparison and split_whitespace host matching.
1008    pub fn has_forward(&self, alias: &str, directive_key: &str, value: &str) -> bool {
1009        for element in &self.elements {
1010            if let ConfigElement::HostBlock(block) = element {
1011                if block.host_pattern.split_whitespace().any(|p| p == alias) {
1012                    return block.directives.iter().any(|d| {
1013                        !d.is_non_directive
1014                            && d.key.eq_ignore_ascii_case(directive_key)
1015                            && Self::values_match(&d.value, value)
1016                    });
1017                }
1018            }
1019        }
1020        false
1021    }
1022
1023    /// Find tunnel directives for a host alias, searching all elements including
1024    /// Include files. Uses split_whitespace matching like has_host() for multi-pattern
1025    /// Host lines.
1026    pub fn find_tunnel_directives(&self, alias: &str) -> Vec<crate::tunnel::TunnelRule> {
1027        Self::find_tunnel_directives_in(&self.elements, alias)
1028    }
1029
1030    fn find_tunnel_directives_in(
1031        elements: &[ConfigElement],
1032        alias: &str,
1033    ) -> Vec<crate::tunnel::TunnelRule> {
1034        for element in elements {
1035            match element {
1036                ConfigElement::HostBlock(block) => {
1037                    if block.host_pattern.split_whitespace().any(|p| p == alias) {
1038                        return block.tunnel_directives();
1039                    }
1040                }
1041                ConfigElement::Include(include) => {
1042                    for file in &include.resolved_files {
1043                        let rules = Self::find_tunnel_directives_in(&file.elements, alias);
1044                        if !rules.is_empty() {
1045                            return rules;
1046                        }
1047                    }
1048                }
1049                ConfigElement::GlobalLine(_) => {}
1050            }
1051        }
1052        Vec::new()
1053    }
1054
1055    /// Generate a unique alias by appending -2, -3, etc. if the base alias is taken.
1056    pub fn deduplicate_alias(&self, base: &str) -> String {
1057        self.deduplicate_alias_excluding(base, None)
1058    }
1059
1060    /// Generate a unique alias, optionally excluding one alias from collision detection.
1061    /// Used during rename so the host being renamed doesn't collide with itself.
1062    pub fn deduplicate_alias_excluding(&self, base: &str, exclude: Option<&str>) -> String {
1063        let is_taken = |alias: &str| {
1064            if exclude == Some(alias) {
1065                return false;
1066            }
1067            self.has_host(alias)
1068        };
1069        if !is_taken(base) {
1070            return base.to_string();
1071        }
1072        for n in 2..=9999 {
1073            let candidate = format!("{}-{}", base, n);
1074            if !is_taken(&candidate) {
1075                return candidate;
1076            }
1077        }
1078        // Practically unreachable: fall back to PID-based suffix
1079        format!("{}-{}", base, std::process::id())
1080    }
1081
1082    /// Set tags on a host block by alias.
1083    pub fn set_host_tags(&mut self, alias: &str, tags: &[String]) {
1084        if let Some(block) = self.find_host_block_mut(alias) {
1085            block.set_tags(tags);
1086        }
1087    }
1088
1089    /// Set provider-synced tags on a host block by alias.
1090    pub fn set_host_provider_tags(&mut self, alias: &str, tags: &[String]) {
1091        if let Some(block) = self.find_host_block_mut(alias) {
1092            block.set_provider_tags(tags);
1093        }
1094    }
1095
1096    /// Set askpass source on a host block by alias.
1097    pub fn set_host_askpass(&mut self, alias: &str, source: &str) {
1098        if let Some(block) = self.find_host_block_mut(alias) {
1099            block.set_askpass(source);
1100        }
1101    }
1102
1103    /// Set vault-ssh role on a host block by alias.
1104    pub fn set_host_vault_ssh(&mut self, alias: &str, role: &str) {
1105        if let Some(block) = self.find_host_block_mut(alias) {
1106            block.set_vault_ssh(role);
1107        }
1108    }
1109
1110    /// Set or remove the Vault SSH endpoint comment on a host block by alias.
1111    /// Empty `url` removes the comment.
1112    ///
1113    /// Mirrors the safety invariants of `set_host_certificate_file`: wildcard
1114    /// aliases are refused to avoid accidentally applying a vault address to
1115    /// every host resolved through a pattern, and Match blocks are not
1116    /// touched (they live as inert `GlobalLines`). Returns `true` on a
1117    /// successful mutation, `false` when the alias is invalid or the block
1118    /// is not found.
1119    ///
1120    /// Callers that run asynchronously (e.g. form submit handlers that
1121    /// resolve the alias before writing) MUST check the return value so a
1122    /// silent config mutation failure is surfaced instead of pretending the
1123    /// vault address was wired up.
1124    #[must_use = "check the return value to detect silently-skipped mutations (renamed or deleted hosts)"]
1125    pub fn set_host_vault_addr(&mut self, alias: &str, url: &str) -> bool {
1126        // Same guard as `set_host_certificate_file`: refuse empty aliases
1127        // and any SSH pattern shape. `is_host_pattern` already covers
1128        // wildcards, negation and whitespace-separated multi-host forms.
1129        if alias.is_empty() || is_host_pattern(alias) {
1130            return false;
1131        }
1132        let Some(block) = self.find_host_block_mut(alias) else {
1133            return false;
1134        };
1135        // Defense in depth: refuse to mutate a block that is itself a
1136        // pattern or a multi-alias block (ExactAliasOnly policy). Writing a
1137        // vault endpoint onto such a block would apply to every sibling
1138        // alias and every host resolving through the pattern, which is
1139        // almost certainly not what the caller intends.
1140        if is_host_pattern(&block.host_pattern) {
1141            return false;
1142        }
1143        block.set_vault_addr(url);
1144        true
1145    }
1146
1147    /// Set or remove the CertificateFile directive on a host block by alias.
1148    /// Empty path removes the directive.
1149    /// Set the `CertificateFile` directive on the host block that matches
1150    /// `alias` exactly. Returns `true` if a matching block was found and
1151    /// updated, `false` if no top-level `HostBlock` matched (alias was
1152    /// renamed, deleted or lives only inside an `Include`d file).
1153    ///
1154    /// Callers that run asynchronously (e.g. the Vault SSH bulk-sign worker)
1155    /// MUST check the return value so a silent config mutation failure is
1156    /// surfaced to the user instead of pretending the cert was wired up.
1157    #[must_use = "check the return value to detect silently-skipped mutations (renamed or deleted hosts)"]
1158    pub fn set_host_certificate_file(&mut self, alias: &str, path: &str) -> bool {
1159        // Defense in depth: refuse to mutate a host block when the requested
1160        // alias is empty or matches any SSH pattern shape (`*`, `?`, `[`,
1161        // leading `!`, or whitespace-separated multi-host form like
1162        // `Host web-* db-*`). Writing `CertificateFile` onto a pattern
1163        // block is almost never what a user intends and would affect every
1164        // host that resolves through that pattern. Reusing `is_host_pattern`
1165        // keeps this check in sync with the form-level pattern detection.
1166        if alias.is_empty() || is_host_pattern(alias) {
1167            return false;
1168        }
1169        let Some(block) = self.find_host_block_mut(alias) else {
1170            return false;
1171        };
1172        // Additionally refuse when the matched block is itself a pattern or
1173        // multi-alias block (ExactAliasOnly policy). The input `alias` may
1174        // be a plain token yet resolve into a block like `Host web-01
1175        // web-01.prod`, where writing `CertificateFile` would silently
1176        // affect sibling aliases.
1177        if is_host_pattern(&block.host_pattern) {
1178            return false;
1179        }
1180        Self::upsert_directive(block, "CertificateFile", path);
1181        true
1182    }
1183
1184    /// Set provider metadata on a host block by alias.
1185    pub fn set_host_meta(&mut self, alias: &str, meta: &[(String, String)]) {
1186        if let Some(block) = self.find_host_block_mut(alias) {
1187            block.set_meta(meta);
1188        }
1189    }
1190
1191    /// Mark a host as stale by alias.
1192    pub fn set_host_stale(&mut self, alias: &str, timestamp: u64) {
1193        if let Some(block) = self.find_host_block_mut(alias) {
1194            block.set_stale(timestamp);
1195        }
1196    }
1197
1198    /// Clear stale marking from a host by alias.
1199    pub fn clear_host_stale(&mut self, alias: &str) {
1200        if let Some(block) = self.find_host_block_mut(alias) {
1201            block.clear_stale();
1202        }
1203    }
1204
1205    /// Collect all stale hosts with their timestamps.
1206    pub fn stale_hosts(&self) -> Vec<(String, u64)> {
1207        let mut result = Vec::new();
1208        for element in &self.elements {
1209            if let ConfigElement::HostBlock(block) = element {
1210                if let Some(ts) = block.stale() {
1211                    result.push((block.host_pattern.clone(), ts));
1212                }
1213            }
1214        }
1215        result
1216    }
1217
1218    /// Delete a host entry by alias.
1219    ///
1220    /// For a single-alias block this removes the whole block (and cleans up
1221    /// any orphaned `# purple:group` header left behind). For a multi-alias
1222    /// block like `Host web-01 web-01.prod 10.0.1.5` only the matching
1223    /// alias token is stripped from the `Host` line; sibling aliases and
1224    /// all directives are preserved so that `delete_host("web-01.prod")`
1225    /// does not silently wipe configuration for `web-01` and `10.0.1.5`.
1226    ///
1227    /// Callers that want to remove the entire block regardless of sibling
1228    /// aliases should surface an explicit confirmation in the UI and then
1229    /// delete each sibling alias in turn.
1230    pub fn delete_host(&mut self, alias: &str) {
1231        // Two matching modes:
1232        //   - Full-pattern match: block.host_pattern == alias. Removes the
1233        //     entire block (plus duplicates). Used by the pattern browser,
1234        //     where `alias` is a full pattern string like `web-* db-*` or
1235        //     `web-01 web-01.prod`.
1236        //   - Token match: alias appears as one of the whitespace-separated
1237        //     tokens. Strips just that token from a multi-alias block and
1238        //     removes single-alias blocks outright. Used by the host list.
1239        // Full-pattern is checked first so pattern-browser deletes never
1240        // degenerate into partial token strips.
1241        let has_full_match = self
1242            .elements
1243            .iter()
1244            .any(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias));
1245
1246        // Capture the provider for orphaned-group cleanup before mutation.
1247        let provider_name = self.elements.iter().find_map(|e| match e {
1248            ConfigElement::HostBlock(b)
1249                if (has_full_match && b.host_pattern == alias)
1250                    || (!has_full_match
1251                        && b.host_pattern.split_whitespace().any(|t| t == alias)) =>
1252            {
1253                b.provider().map(|(name, _)| name)
1254            }
1255            _ => None,
1256        });
1257
1258        if has_full_match {
1259            // Remove every block whose full host_pattern equals the input
1260            // (duplicate-block invariant preserved, matches pre-refactor).
1261            self.elements.retain(|e| match e {
1262                ConfigElement::HostBlock(block) => block.host_pattern != alias,
1263                _ => true,
1264            });
1265        } else {
1266            // Token-aware: strip the alias from multi-alias blocks first,
1267            // then drop single-alias blocks whose sole token equals alias.
1268            for el in &mut self.elements {
1269                if let ConfigElement::HostBlock(block) = el {
1270                    let tokens: Vec<&str> = block.host_pattern.split_whitespace().collect();
1271                    if tokens.len() > 1 && tokens.contains(&alias) {
1272                        let new_pattern = tokens
1273                            .iter()
1274                            .filter(|t| **t != alias)
1275                            .copied()
1276                            .collect::<Vec<_>>()
1277                            .join(" ");
1278                        block.host_pattern = new_pattern.clone();
1279                        block.raw_host_line = format!("Host {}", new_pattern);
1280                    }
1281                }
1282            }
1283            self.elements.retain(|e| match e {
1284                ConfigElement::HostBlock(block) => {
1285                    let mut tokens = block.host_pattern.split_whitespace();
1286                    !matches!(
1287                        (tokens.next(), tokens.next()),
1288                        (Some(first), None) if first == alias
1289                    )
1290                }
1291                _ => true,
1292            });
1293        }
1294
1295        if let Some(name) = provider_name {
1296            self.remove_orphaned_group_header(&name);
1297        }
1298
1299        // Collapse consecutive blank lines left by deletion
1300        self.elements.dedup_by(|a, b| {
1301            matches!(
1302                (&*a, &*b),
1303                (ConfigElement::GlobalLine(x), ConfigElement::GlobalLine(y))
1304                if x.trim().is_empty() && y.trim().is_empty()
1305            )
1306        });
1307    }
1308
1309    /// Delete a host and return the removed element and its position for undo.
1310    /// Does NOT collapse blank lines or remove group headers so the position
1311    /// stays valid for re-insertion via `insert_host_at()`.
1312    /// Orphaned group headers (if any) are cleaned up at next startup.
1313    ///
1314    /// For multi-alias blocks this returns `None`: undoable-delete of a
1315    /// single alias out of a shared `Host` line cannot be round-tripped via
1316    /// `insert_host_at` because sibling aliases would be lost. Callers
1317    /// should fall back to `delete_host` in that case (which strips only
1318    /// the requested token).
1319    pub fn delete_host_undoable(&mut self, alias: &str) -> Option<(ConfigElement, usize)> {
1320        // Two-mode match mirroring `delete_host`: full-pattern first (for
1321        // pattern-browser deletes where `alias` is the whole pattern
1322        // string), then token match. Undoable delete is only safe when
1323        // removing the entire block; token-strip on a multi-alias block is
1324        // therefore refused (returns `None`) because re-inserting the
1325        // whole element would not reverse a token strip.
1326        let full_pos = self
1327            .elements
1328            .iter()
1329            .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias));
1330        let pos = if let Some(p) = full_pos {
1331            p
1332        } else {
1333            let token_pos = self.elements.iter().position(|e| match e {
1334                ConfigElement::HostBlock(b) => {
1335                    b.host_pattern.split_whitespace().any(|t| t == alias)
1336                }
1337                _ => false,
1338            })?;
1339            if let ConfigElement::HostBlock(b) = &self.elements[token_pos] {
1340                if b.host_pattern.split_whitespace().count() > 1 {
1341                    return None;
1342                }
1343            }
1344            token_pos
1345        };
1346        let element = self.elements.remove(pos);
1347        Some((element, pos))
1348    }
1349
1350    /// Insert a host block at a specific position (for undo).
1351    pub fn insert_host_at(&mut self, element: ConfigElement, position: usize) {
1352        let pos = position.min(self.elements.len());
1353        self.elements.insert(pos, element);
1354    }
1355
1356    /// Find the position after the last HostBlock that belongs to a provider.
1357    /// Returns `None` if no hosts for this provider exist in the config.
1358    /// Used by the sync engine to insert new hosts adjacent to existing provider hosts.
1359    pub fn find_provider_insert_position(&self, provider_name: &str) -> Option<usize> {
1360        let mut last_pos = None;
1361        for (i, element) in self.elements.iter().enumerate() {
1362            if let ConfigElement::HostBlock(block) = element {
1363                if let Some((name, _)) = block.provider() {
1364                    if name == provider_name {
1365                        last_pos = Some(i);
1366                    }
1367                }
1368            }
1369        }
1370        // Return position after the last provider host
1371        last_pos.map(|p| p + 1)
1372    }
1373
1374    /// Swap two host blocks in the config by alias. Returns true if swap was performed.
1375    #[allow(dead_code)]
1376    pub fn swap_hosts(&mut self, alias_a: &str, alias_b: &str) -> bool {
1377        let pos_a = self
1378            .elements
1379            .iter()
1380            .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_a));
1381        let pos_b = self
1382            .elements
1383            .iter()
1384            .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_b));
1385        if let (Some(a), Some(b)) = (pos_a, pos_b) {
1386            if a == b {
1387                return false;
1388            }
1389            let (first, second) = (a.min(b), a.max(b));
1390
1391            // Strip trailing blanks from both blocks before swap
1392            if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
1393                block.pop_trailing_blanks();
1394            }
1395            if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
1396                block.pop_trailing_blanks();
1397            }
1398
1399            // Swap
1400            self.elements.swap(first, second);
1401
1402            // Add trailing blank to first block (separator between the two)
1403            if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
1404                block.ensure_trailing_blank();
1405            }
1406
1407            // Add trailing blank to second only if not the last element
1408            if second < self.elements.len() - 1 {
1409                if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
1410                    block.ensure_trailing_blank();
1411                }
1412            }
1413
1414            return true;
1415        }
1416        false
1417    }
1418
1419    /// Convert a HostEntry into a new HostBlock with clean formatting.
1420    pub(crate) fn entry_to_block(entry: &HostEntry) -> HostBlock {
1421        // Defense-in-depth: callers must validate before reaching here.
1422        // Newlines in values would inject extra SSH config directives.
1423        debug_assert!(
1424            !entry.alias.contains('\n') && !entry.alias.contains('\r'),
1425            "entry_to_block: alias contains newline"
1426        );
1427        debug_assert!(
1428            !entry.hostname.contains('\n') && !entry.hostname.contains('\r'),
1429            "entry_to_block: hostname contains newline"
1430        );
1431        debug_assert!(
1432            !entry.user.contains('\n') && !entry.user.contains('\r'),
1433            "entry_to_block: user contains newline"
1434        );
1435
1436        let mut directives = Vec::new();
1437
1438        if !entry.hostname.is_empty() {
1439            directives.push(Directive {
1440                key: "HostName".to_string(),
1441                value: entry.hostname.clone(),
1442                raw_line: format!("  HostName {}", entry.hostname),
1443                is_non_directive: false,
1444            });
1445        }
1446        if !entry.user.is_empty() {
1447            directives.push(Directive {
1448                key: "User".to_string(),
1449                value: entry.user.clone(),
1450                raw_line: format!("  User {}", entry.user),
1451                is_non_directive: false,
1452            });
1453        }
1454        if entry.port != 22 {
1455            directives.push(Directive {
1456                key: "Port".to_string(),
1457                value: entry.port.to_string(),
1458                raw_line: format!("  Port {}", entry.port),
1459                is_non_directive: false,
1460            });
1461        }
1462        if !entry.identity_file.is_empty() {
1463            directives.push(Directive {
1464                key: "IdentityFile".to_string(),
1465                value: entry.identity_file.clone(),
1466                raw_line: format!("  IdentityFile {}", entry.identity_file),
1467                is_non_directive: false,
1468            });
1469        }
1470        if !entry.proxy_jump.is_empty() {
1471            directives.push(Directive {
1472                key: "ProxyJump".to_string(),
1473                value: entry.proxy_jump.clone(),
1474                raw_line: format!("  ProxyJump {}", entry.proxy_jump),
1475                is_non_directive: false,
1476            });
1477        }
1478
1479        HostBlock {
1480            host_pattern: entry.alias.clone(),
1481            raw_host_line: format!("Host {}", entry.alias),
1482            directives,
1483        }
1484    }
1485}
1486
1487#[cfg(test)]
1488#[path = "model_tests.rs"]
1489mod tests;