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    /// Last-synced provider username from purple:provider_user comment. Lets
106    /// sync detect a manual per-host User override (user != marker) and
107    /// propagate a changed provider user to hosts still on the old value.
108    pub provider_user: Option<String>,
109    /// Last-synced provider identity file from purple:provider_key comment.
110    /// Same override-detection role as provider_user for IdentityFile.
111    pub provider_key: Option<String>,
112}
113
114impl Default for HostEntry {
115    fn default() -> Self {
116        Self {
117            alias: String::new(),
118            hostname: String::new(),
119            user: String::new(),
120            port: 22,
121            identity_file: String::new(),
122            proxy_jump: String::new(),
123            source_file: None,
124            tags: Vec::new(),
125            provider_tags: Vec::new(),
126            has_provider_tags: false,
127            provider: None,
128            provider_label: None,
129            tunnel_count: 0,
130            askpass: None,
131            vault_ssh: None,
132            vault_addr: None,
133            certificate_file: String::new(),
134            provider_meta: Vec::new(),
135            stale: None,
136            provider_user: None,
137            provider_key: None,
138        }
139    }
140}
141
142impl HostEntry {
143    /// Build the SSH command string for this host.
144    /// Includes `-F <config_path>` when the config is non-default so the alias
145    /// resolves correctly when pasted into a terminal.
146    /// Shell-quotes both the config path and alias to prevent injection.
147    pub fn ssh_command(
148        &self,
149        paths: Option<&crate::runtime::env::Paths>,
150        config_path: &std::path::Path,
151    ) -> String {
152        let escaped = self.alias.replace('\'', "'\\''");
153        let default = paths
154            .map(|p| p.ssh_dir().join("config"))
155            .unwrap_or_default();
156        if config_path == default {
157            format!("ssh -- '{}'", escaped)
158        } else {
159            let config_escaped = config_path.display().to_string().replace('\'', "'\\''");
160            format!("ssh -F '{}' -- '{}'", config_escaped, escaped)
161        }
162    }
163}
164
165/// Convenience view for pattern Host blocks in the TUI.
166#[derive(Debug, Clone, Default)]
167pub struct PatternEntry {
168    pub pattern: String,
169    pub hostname: String,
170    pub user: String,
171    pub port: u16,
172    pub identity_file: String,
173    pub proxy_jump: String,
174    pub tags: Vec<String>,
175    pub askpass: Option<String>,
176    pub source_file: Option<PathBuf>,
177    /// All non-comment directives as key-value pairs for display.
178    pub directives: Vec<(String, String)>,
179}
180
181/// Inherited field hints from matching patterns. Each field is `Some((value,
182/// source_pattern))` when a pattern provides that directive, `None` otherwise.
183#[derive(Debug, Clone, Default)]
184pub struct InheritedHints {
185    pub proxy_jump: Option<(String, String)>,
186    pub user: Option<(String, String)>,
187    pub identity_file: Option<(String, String)>,
188}
189
190use super::pattern::apply_first_match_fields;
191/// Returns true if the host pattern contains wildcards, character classes,
192/// negation or whitespace-separated multi-patterns (*, ?, [], !, space/tab).
193/// These are SSH match patterns, not concrete hosts.
194// Pattern-matching lives in `ssh_config::pattern`. These re-exports preserve
195// the old `ssh_config::model::*` import paths used across the codebase and in
196// the model_tests file mounted below.
197#[allow(unused_imports)]
198pub use super::pattern::{
199    host_pattern_matches, is_host_pattern, proxy_jump_contains_self, ssh_pattern_match,
200};
201
202/// True if a `CertificateFile` directive value points at purple's managed
203/// certificate directory. Recognises both tilde-prefixed and absolute paths
204/// (`~/.purple/certs/...`, `/home/user/.purple/certs/...`,
205/// `$HOME/.purple/certs/...`). Used by `set_host_certificate_file` so
206/// user-set custom CertificateFile entries are preserved across vault
207/// sign / unsign cycles.
208pub(super) fn is_purple_managed_cert_value(value: &str) -> bool {
209    let trimmed = value.trim();
210    // Strip surrounding double quotes; OpenSSH treats `"~/.purple/..."` and
211    // `~/.purple/...` as equivalent.
212    let unquoted = trimmed
213        .strip_prefix('"')
214        .and_then(|s| s.strip_suffix('"'))
215        .unwrap_or(trimmed);
216    unquoted.contains(".purple/certs/")
217}
218// Re-exported so the test file mounted below keeps working.
219#[allow(unused_imports)]
220pub(super) use super::repair::provider_group_display_name;
221
222impl SshConfigFile {
223    /// Get all host entries as convenience views (including from Include files).
224    /// Pattern-inherited directives (ProxyJump, User, IdentityFile) are merged
225    /// using SSH-faithful alias-only matching so indicators like ↗ reflect what
226    /// SSH will actually apply when connecting via `ssh <alias>`.
227    pub fn host_entries(&self) -> Vec<HostEntry> {
228        let mut entries = Vec::new();
229        Self::collect_host_entries(&self.elements, &mut entries);
230        self.apply_pattern_inheritance(&mut entries);
231        entries
232    }
233
234    /// Get a single host entry by alias without pattern inheritance applied.
235    /// Returns the raw directives from the host's own block only. Used by the
236    /// edit form so inherited values can be shown as dimmed placeholders.
237    pub fn raw_host_entry(&self, alias: &str) -> Option<HostEntry> {
238        Self::find_raw_host_entry(&self.elements, alias)
239    }
240
241    fn find_raw_host_entry(elements: &[ConfigElement], alias: &str) -> Option<HostEntry> {
242        for e in elements {
243            match e {
244                ConfigElement::HostBlock(block)
245                    if !is_host_pattern(&block.host_pattern) && block.host_pattern == alias =>
246                {
247                    return Some(block.to_host_entry());
248                }
249                ConfigElement::Include(inc) => {
250                    for file in &inc.resolved_files {
251                        if let Some(mut found) = Self::find_raw_host_entry(&file.elements, alias) {
252                            if found.source_file.is_none() {
253                                found.source_file = Some(file.path.clone());
254                            }
255                            return Some(found);
256                        }
257                    }
258                }
259                _ => {}
260            }
261        }
262        None
263    }
264
265    /// Apply SSH first-match-wins pattern inheritance to host entries.
266    /// Matches patterns against the alias only (SSH-faithful: `Host` patterns
267    /// match the token typed on the command line, not the resolved `Hostname`).
268    fn apply_pattern_inheritance(&self, entries: &mut [HostEntry]) {
269        // Patterns are pre-collected once. Host entries never contain pattern
270        // aliases — collect_host_entries skips is_host_pattern blocks.
271        let all_patterns = self.pattern_entries();
272        for entry in entries.iter_mut() {
273            if !entry.proxy_jump.is_empty()
274                && !entry.user.is_empty()
275                && !entry.identity_file.is_empty()
276            {
277                continue;
278            }
279            for p in &all_patterns {
280                if !host_pattern_matches(&p.pattern, &entry.alias) {
281                    continue;
282                }
283                apply_first_match_fields(
284                    &mut entry.proxy_jump,
285                    &mut entry.user,
286                    &mut entry.identity_file,
287                    p,
288                );
289                if !entry.proxy_jump.is_empty()
290                    && !entry.user.is_empty()
291                    && !entry.identity_file.is_empty()
292                {
293                    break;
294                }
295            }
296        }
297    }
298
299    /// Compute pattern-provided field hints for a host alias. Returns first-match
300    /// values and their source patterns for ProxyJump, User and IdentityFile.
301    /// These are returned regardless of whether the host has its own values for
302    /// those fields. The caller (form rendering) decides visibility based on
303    /// whether the field is empty. Matches by alias only (SSH-faithful).
304    pub fn inherited_hints(&self, alias: &str) -> InheritedHints {
305        let patterns = self.matching_patterns(alias);
306        let mut hints = InheritedHints::default();
307        for p in &patterns {
308            if hints.proxy_jump.is_none() && !p.proxy_jump.is_empty() {
309                hints.proxy_jump = Some((p.proxy_jump.clone(), p.pattern.clone()));
310            }
311            if hints.user.is_none() && !p.user.is_empty() {
312                hints.user = Some((p.user.clone(), p.pattern.clone()));
313            }
314            if hints.identity_file.is_none() && !p.identity_file.is_empty() {
315                hints.identity_file = Some((p.identity_file.clone(), p.pattern.clone()));
316            }
317            if hints.proxy_jump.is_some() && hints.user.is_some() && hints.identity_file.is_some() {
318                break;
319            }
320        }
321        hints
322    }
323
324    /// Get all pattern entries as convenience views (including from Include files).
325    pub fn pattern_entries(&self) -> Vec<PatternEntry> {
326        let mut entries = Vec::new();
327        Self::collect_pattern_entries(&self.elements, &mut entries);
328        entries
329    }
330
331    fn collect_pattern_entries(elements: &[ConfigElement], entries: &mut Vec<PatternEntry>) {
332        for e in elements {
333            match e {
334                ConfigElement::HostBlock(block) => {
335                    if !is_host_pattern(&block.host_pattern) {
336                        continue;
337                    }
338                    entries.push(block.to_pattern_entry());
339                }
340                ConfigElement::Include(include) => {
341                    for file in &include.resolved_files {
342                        let start = entries.len();
343                        Self::collect_pattern_entries(&file.elements, entries);
344                        for entry in &mut entries[start..] {
345                            if entry.source_file.is_none() {
346                                entry.source_file = Some(file.path.clone());
347                            }
348                        }
349                    }
350                }
351                ConfigElement::GlobalLine(_) => {}
352            }
353        }
354    }
355
356    /// Find all pattern blocks that match a given host alias and hostname.
357    /// Returns entries in config order (first match first).
358    pub fn matching_patterns(&self, alias: &str) -> Vec<PatternEntry> {
359        let mut matches = Vec::new();
360        Self::collect_matching_patterns(&self.elements, alias, &mut matches);
361        matches
362    }
363
364    fn collect_matching_patterns(
365        elements: &[ConfigElement],
366        alias: &str,
367        matches: &mut Vec<PatternEntry>,
368    ) {
369        for e in elements {
370            match e {
371                ConfigElement::HostBlock(block) => {
372                    if !is_host_pattern(&block.host_pattern) {
373                        continue;
374                    }
375                    if host_pattern_matches(&block.host_pattern, alias) {
376                        matches.push(block.to_pattern_entry());
377                    }
378                }
379                ConfigElement::Include(include) => {
380                    for file in &include.resolved_files {
381                        let start = matches.len();
382                        Self::collect_matching_patterns(&file.elements, alias, matches);
383                        for entry in &mut matches[start..] {
384                            if entry.source_file.is_none() {
385                                entry.source_file = Some(file.path.clone());
386                            }
387                        }
388                    }
389                }
390                ConfigElement::GlobalLine(_) => {}
391            }
392        }
393    }
394
395    /// Collect all resolved Include file paths (recursively).
396    pub fn include_paths(&self) -> Vec<PathBuf> {
397        let mut paths = Vec::new();
398        Self::collect_include_paths(&self.elements, &mut paths);
399        paths
400    }
401
402    fn collect_include_paths(elements: &[ConfigElement], paths: &mut Vec<PathBuf>) {
403        for e in elements {
404            if let ConfigElement::Include(include) = e {
405                for file in &include.resolved_files {
406                    paths.push(file.path.clone());
407                    Self::collect_include_paths(&file.elements, paths);
408                }
409            }
410        }
411    }
412
413    /// Collect parent directories of Include glob patterns.
414    /// When a file is added/removed under a glob dir, the directory's mtime changes.
415    /// Convenience wrapper that captures the process environment once for
416    /// `${VAR}` resolution; production paths call `include_glob_dirs_with`
417    /// with an injected lookup so they stay free of scattered ambient reads.
418    pub fn include_glob_dirs(&self) -> Vec<PathBuf> {
419        let env = crate::runtime::env::Env::from_process();
420        self.include_glob_dirs_with(&|n| env.var(n).map(str::to_string))
421    }
422
423    /// Like `include_glob_dirs` but resolves `${VAR}` from an injected lookup
424    /// instead of the process env, so tests control expansion deterministically.
425    pub fn include_glob_dirs_with(&self, lookup: &dyn Fn(&str) -> Option<String>) -> Vec<PathBuf> {
426        let config_dir = self.path.parent();
427        let mut seen = std::collections::HashSet::new();
428        let mut dirs = Vec::new();
429        Self::collect_include_glob_dirs(&self.elements, config_dir, &mut seen, &mut dirs, lookup);
430        dirs
431    }
432
433    fn collect_include_glob_dirs(
434        elements: &[ConfigElement],
435        config_dir: Option<&std::path::Path>,
436        seen: &mut std::collections::HashSet<PathBuf>,
437        dirs: &mut Vec<PathBuf>,
438        lookup: &dyn Fn(&str) -> Option<String>,
439    ) {
440        for e in elements {
441            if let ConfigElement::Include(include) = e {
442                // Split respecting quoted paths (same as resolve_include does)
443                for single in Self::split_include_patterns(&include.pattern) {
444                    let expanded =
445                        Self::expand_env_vars_with(&Self::expand_tilde(single, lookup), lookup);
446                    let resolved = if expanded.starts_with('/') {
447                        PathBuf::from(&expanded)
448                    } else if let Some(dir) = config_dir {
449                        dir.join(&expanded)
450                    } else {
451                        continue;
452                    };
453                    if let Some(parent) = resolved.parent() {
454                        let parent = parent.to_path_buf();
455                        if seen.insert(parent.clone()) {
456                            dirs.push(parent);
457                        }
458                    }
459                }
460                // Recurse into resolved files
461                for file in &include.resolved_files {
462                    Self::collect_include_glob_dirs(
463                        &file.elements,
464                        file.path.parent(),
465                        seen,
466                        dirs,
467                        lookup,
468                    );
469                }
470            }
471        }
472    }
473
474    /// Remove `# purple:group <Name>` headers that have no corresponding
475    /// provider hosts. Returns the number of headers removed.
476    /// Recursively collect host entries from a list of elements.
477    fn collect_host_entries(elements: &[ConfigElement], entries: &mut Vec<HostEntry>) {
478        for e in elements {
479            match e {
480                ConfigElement::HostBlock(block) => {
481                    if is_host_pattern(&block.host_pattern) {
482                        continue;
483                    }
484                    entries.push(block.to_host_entry());
485                }
486                ConfigElement::Include(include) => {
487                    for file in &include.resolved_files {
488                        let start = entries.len();
489                        Self::collect_host_entries(&file.elements, entries);
490                        for entry in &mut entries[start..] {
491                            if entry.source_file.is_none() {
492                                entry.source_file = Some(file.path.clone());
493                            }
494                        }
495                    }
496                }
497                ConfigElement::GlobalLine(_) => {}
498            }
499        }
500    }
501
502    /// Check if a host alias already exists (including in Include files).
503    /// Walks the element tree directly without building HostEntry structs.
504    pub fn has_host(&self, alias: &str) -> bool {
505        Self::has_host_in_elements(&self.elements, alias)
506    }
507
508    fn has_host_in_elements(elements: &[ConfigElement], alias: &str) -> bool {
509        for e in elements {
510            match e {
511                ConfigElement::HostBlock(block) => {
512                    if pattern_contains_token(&block.host_pattern, alias) {
513                        return true;
514                    }
515                }
516                ConfigElement::Include(include) => {
517                    for file in &include.resolved_files {
518                        if Self::has_host_in_elements(&file.elements, alias) {
519                            return true;
520                        }
521                    }
522                }
523                ConfigElement::GlobalLine(_) => {}
524            }
525        }
526        false
527    }
528
529    /// Return the sibling aliases that share a `Host` block with `alias`.
530    ///
531    /// An empty vector means `alias` lives in its own single-alias block (or
532    /// is not present). A non-empty vector lists the other tokens in the
533    /// block in source order, so the UI can render indicators like `+N` or
534    /// spell the aliases out in a confirm dialog before a destructive
535    /// action. Does not recurse into `Include`d files: those are read-only
536    /// and their hosts cannot be edited from purple anyway.
537    pub fn siblings_of(&self, alias: &str) -> Vec<String> {
538        if alias.is_empty() {
539            return Vec::new();
540        }
541        self.elements
542            .iter()
543            .find_map(|el| match el {
544                ConfigElement::HostBlock(b) => {
545                    // Full-pattern match means the caller is acting on the
546                    // whole block (e.g. pattern browser delete of
547                    // `web-01 web-01.prod`). All tokens are the target, so
548                    // there are no "siblings" to preserve.
549                    if b.host_pattern == alias {
550                        return Some(Vec::new());
551                    }
552                    let tokens: Vec<String> = b
553                        .host_pattern
554                        .split_whitespace()
555                        .map(String::from)
556                        .collect();
557                    if tokens.iter().any(|t| t == alias) {
558                        Some(tokens.into_iter().filter(|t| t != alias).collect())
559                    } else {
560                        None
561                    }
562                }
563                _ => None,
564            })
565            .unwrap_or_default()
566    }
567
568    /// Find a mutable top-level `HostBlock` whose `host_pattern` contains
569    /// `alias` as one of its whitespace-separated tokens.
570    ///
571    /// Mirrors the matching used by read-path helpers like `has_host` and
572    /// `find_tunnel_directives`, so that any host visible in the TUI is also
573    /// addressable from write paths (`update_host`, `delete_host`,
574    /// `set_host_*`). Prior to this helper, writers compared the full
575    /// `host_pattern` for exact equality, which silently no-op'd on
576    /// multi-alias blocks like `Host web-01 web-01.prod 10.0.1.5` and
577    /// resulted in on-disk drift between the in-memory view and the config
578    /// file.
579    ///
580    /// Does not recurse into `Include`d files: those are read-only.
581    ///
582    /// A block matches when either (a) its full `host_pattern` equals
583    /// `alias` (used by the pattern browser for blocks like `web-* db-*`
584    /// or `web-01 web-01.prod` whose full pattern is the caller's key) or
585    /// (b) `alias` appears as one of the whitespace-separated tokens (used
586    /// by the host list for multi-alias blocks). The full-pattern match is
587    /// tried first so callers that pass a pattern string do not
588    /// accidentally trigger the token-strip path.
589    fn find_host_block_mut(&mut self, alias: &str) -> Option<&mut HostBlock> {
590        if alias.is_empty() {
591            return None;
592        }
593        self.elements.iter_mut().find_map(|el| match el {
594            ConfigElement::HostBlock(b)
595                if b.host_pattern == alias || pattern_contains_token(&b.host_pattern, alias) =>
596            {
597                Some(b)
598            }
599            _ => None,
600        })
601    }
602
603    /// Check if a host block with exactly this host_pattern exists (top-level only).
604    /// Unlike `has_host` which splits multi-host patterns and checks individual parts,
605    /// this matches the full `Host` line pattern string (e.g. "web-* db-*").
606    /// Does not search Include files (patterns from includes are read-only).
607    pub fn has_host_block(&self, pattern: &str) -> bool {
608        self.elements
609            .iter()
610            .any(|e| matches!(e, ConfigElement::HostBlock(block) if block.host_pattern == pattern))
611    }
612
613    /// Check if a host alias is from an included file (read-only).
614    /// Handles multi-pattern Host lines by splitting on whitespace.
615    pub fn is_included_host(&self, alias: &str) -> bool {
616        // Not in top-level elements → must be in an Include
617        for e in &self.elements {
618            match e {
619                ConfigElement::HostBlock(block) => {
620                    if pattern_contains_token(&block.host_pattern, alias) {
621                        return false;
622                    }
623                }
624                ConfigElement::Include(include) => {
625                    for file in &include.resolved_files {
626                        if Self::has_host_in_elements(&file.elements, alias) {
627                            return true;
628                        }
629                    }
630                }
631                ConfigElement::GlobalLine(_) => {}
632            }
633        }
634        false
635    }
636
637    /// Add a new host entry to the config.
638    /// Inserts before any trailing wildcard/pattern Host blocks (e.g. `Host *`)
639    /// so that SSH "first match wins" semantics are preserved. If wildcards are
640    /// only at the top of the file (acting as global defaults), appends at end.
641    pub fn add_host(&mut self, entry: &HostEntry) {
642        let block = Self::entry_to_block(entry);
643        let insert_pos = self.find_trailing_pattern_start();
644
645        if let Some(pos) = insert_pos {
646            // Insert before the trailing pattern group, with blank separators
647            let needs_blank_before = pos > 0
648                && !matches!(
649                    self.elements.get(pos - 1),
650                    Some(ConfigElement::GlobalLine(line)) if line.trim().is_empty()
651                );
652            let mut idx = pos;
653            if needs_blank_before {
654                self.elements
655                    .insert(idx, ConfigElement::GlobalLine(String::new()));
656                idx += 1;
657            }
658            self.elements.insert(idx, ConfigElement::HostBlock(block));
659            // Ensure a blank separator after the new block (before the wildcard group)
660            let after = idx + 1;
661            if after < self.elements.len()
662                && !matches!(
663                    self.elements.get(after),
664                    Some(ConfigElement::GlobalLine(line)) if line.trim().is_empty()
665                )
666            {
667                self.elements
668                    .insert(after, ConfigElement::GlobalLine(String::new()));
669            }
670        } else {
671            // No trailing patterns: append at end
672            if !self.elements.is_empty() && !self.last_element_has_trailing_blank() {
673                self.elements.push(ConfigElement::GlobalLine(String::new()));
674            }
675            self.elements.push(ConfigElement::HostBlock(block));
676        }
677    }
678
679    /// Find the start of a trailing group of wildcard/pattern Host blocks.
680    /// Scans backwards from the end, skipping GlobalLines (blanks/comments/Match).
681    /// Returns `None` if no trailing patterns exist (or if ALL hosts are patterns,
682    /// i.e. patterns start at position 0 — in that case we append at end).
683    fn find_trailing_pattern_start(&self) -> Option<usize> {
684        let mut first_pattern_pos = None;
685        for i in (0..self.elements.len()).rev() {
686            match &self.elements[i] {
687                ConfigElement::HostBlock(block) => {
688                    if is_host_pattern(&block.host_pattern) {
689                        first_pattern_pos = Some(i);
690                    } else {
691                        // Found a concrete host: the trailing group starts after this
692                        break;
693                    }
694                }
695                ConfigElement::GlobalLine(_) => {
696                    // Blank lines, comments, Match blocks between patterns: keep scanning
697                    if first_pattern_pos.is_some() {
698                        first_pattern_pos = Some(i);
699                    }
700                }
701                ConfigElement::Include(_) => break,
702            }
703        }
704        // Don't return position 0 — that means everything is patterns (or patterns at top)
705        first_pattern_pos.filter(|&pos| pos > 0)
706    }
707
708    /// Check if the last element already ends with a blank line.
709    pub fn last_element_has_trailing_blank(&self) -> bool {
710        match self.elements.last() {
711            Some(ConfigElement::HostBlock(block)) => block
712                .directives
713                .last()
714                .is_some_and(|d| d.is_non_directive && d.raw_line.trim().is_empty()),
715            Some(ConfigElement::GlobalLine(line)) => line.trim().is_empty(),
716            _ => false,
717        }
718    }
719
720    /// Update an existing host entry by alias.
721    /// Merges changes into the existing block, preserving unknown directives.
722    ///
723    /// Alias matching uses whitespace-tokenized equality, so a host visible
724    /// under a multi-alias block like `Host web-01 web-01.prod` is reachable
725    /// from any of its aliases. Directives are shared across all tokens in
726    /// the block (per SSH semantics): updating `User` on `web-01.prod`
727    /// therefore also affects `web-01`.
728    ///
729    /// On rename of a multi-alias block only the matching token is replaced
730    /// in the `Host` line; sibling aliases are preserved verbatim.
731    pub fn update_host(&mut self, old_alias: &str, entry: &HostEntry) {
732        let Some(block) = self.find_host_block_mut(old_alias) else {
733            return;
734        };
735
736        if entry.alias != old_alias {
737            // Sanitise the new alias before it flows into `raw_host_line`.
738            // A malicious provider response with `\n` in the alias would
739            // otherwise inject extra Host blocks into the user's config.
740            // entry_to_block already sanitises the add-host path; this
741            // mirrors it for the rename path.
742            let safe_alias = HostBlock::sanitize_raw_line_value(&entry.alias);
743            // Full-pattern match (pattern browser rename) replaces the whole
744            // `host_pattern` verbatim. Token match (host list rename on a
745            // multi-alias block) replaces only the selected token so
746            // siblings survive. Single-alias blocks are covered by the
747            // token path because `tokens == [old_alias]`.
748            let is_full_pattern_match = block.host_pattern == old_alias;
749            let new_pattern: String = if is_full_pattern_match {
750                safe_alias.to_string()
751            } else {
752                block
753                    .host_pattern
754                    .split_whitespace()
755                    .map(|t| {
756                        if t == old_alias {
757                            safe_alias.as_ref()
758                        } else {
759                            t
760                        }
761                    })
762                    .collect::<Vec<_>>()
763                    .join(" ")
764            };
765            block.host_pattern = new_pattern.clone();
766            block.raw_host_line = rebuild_host_line(&block.raw_host_line, &new_pattern);
767        }
768
769        // Merge known directives (update existing, add missing, remove empty)
770        Self::upsert_directive(block, "HostName", &entry.hostname);
771        Self::upsert_directive(block, "User", &entry.user);
772        if entry.port != 22 {
773            Self::upsert_directive(block, "Port", &entry.port.to_string());
774        } else {
775            // Port 22 is the SSH default: drop the explicit directive so
776            // the rendered block stays minimal. Route through
777            // `upsert_directive` with an empty value so the first-only
778            // semantics match every other key here; a separate `retain`
779            // would diverge from the cumulative-directive invariant.
780            Self::upsert_directive(block, "Port", "");
781        }
782        Self::upsert_directive(block, "IdentityFile", &entry.identity_file);
783        Self::upsert_directive(block, "ProxyJump", &entry.proxy_jump);
784    }
785
786    /// Update a directive in-place, add it if missing, or remove it if value is empty.
787    ///
788    /// When `value` is empty only the FIRST matching directive is removed.
789    /// OpenSSH treats some directives (`IdentityFile`, `CertificateFile`,
790    /// `LocalForward`, etc.) as cumulative: a host with three `IdentityFile`
791    /// lines is intentionally multi-key. Wiping all matching directives on
792    /// an empty form field would silently delete the user's other keys.
793    /// The form only edits the first occurrence (see `to_host_entry` which
794    /// reads `if entry.identity_file.is_empty()`), so the symmetric remove
795    /// only-first behaviour keeps the per-form-field invariant intact:
796    /// "what the user sees in the field is what the field controls".
797    fn upsert_directive(block: &mut HostBlock, key: &str, value: &str) {
798        // Defence in depth: sanitise the value before interpolation. The
799        // provider-sync update path passes `remote.ip` directly to
800        // `update_host` -&gt; `upsert_directive`, so a self-hosted provider
801        // with TLS verification disabled (Proxmox, OCI) could supply a
802        // hostname containing `\n  ProxyCommand evil` and inject a real
803        // directive. `entry_to_block` (the add-host path) sanitises at
804        // construction; mirroring it here closes the symmetric edit path.
805        let value_owned = HostBlock::sanitize_raw_line_value(value);
806        let value = value_owned.as_ref();
807        if value.is_empty() {
808            if let Some(pos) = block
809                .directives
810                .iter()
811                .position(|d| !d.is_non_directive && d.key.eq_ignore_ascii_case(key))
812            {
813                block.directives.remove(pos);
814            }
815            return;
816        }
817        let indent = block.detect_indent();
818        for d in &mut block.directives {
819            if !d.is_non_directive && d.key.eq_ignore_ascii_case(key) {
820                // Only rebuild raw_line when value actually changed (preserves inline comments)
821                if d.value != value {
822                    d.value = value.to_string();
823                    // Detect separator style from original raw_line and preserve it.
824                    // Handles: "Key value", "Key=value", "Key = value", "Key =value"
825                    // Only considers '=' as separator if it appears before any
826                    // non-whitespace content (avoids matching '=' inside values
827                    // like "IdentityFile ~/.ssh/id=prod").
828                    let trimmed = d.raw_line.trim_start();
829                    let after_key = &trimmed[d.key.len()..];
830                    let sep = if after_key.trim_start().starts_with('=') {
831                        let eq_pos = after_key.find('=').unwrap();
832                        let after_eq = &after_key[eq_pos + 1..];
833                        let trailing_ws = after_eq.len() - after_eq.trim_start().len();
834                        after_key[..eq_pos + 1 + trailing_ws].to_string()
835                    } else {
836                        " ".to_string()
837                    };
838                    // Preserve inline comment from original raw_line (e.g. "# production")
839                    let comment_suffix = Self::extract_inline_comment(&d.raw_line, &d.key);
840                    let rendered = HostBlock::render_value(value);
841                    d.raw_line =
842                        format!("{}{}{}{}{}", indent, d.key, sep, rendered, comment_suffix);
843                }
844                return;
845            }
846        }
847        // Not found — insert before trailing blanks
848        let pos = block.content_end();
849        block.directives.insert(
850            pos,
851            Directive {
852                key: key.to_string(),
853                value: value.to_string(),
854                raw_line: format!("{}{} {}", indent, key, HostBlock::render_value(value)),
855                is_non_directive: false,
856            },
857        );
858    }
859
860    /// Extract the inline comment suffix from a directive's raw line.
861    /// Returns the trailing portion (e.g. " # production") or empty string.
862    /// Respects double-quoted strings so that `#` inside quotes is not a comment.
863    fn extract_inline_comment(raw_line: &str, key: &str) -> String {
864        let trimmed = raw_line.trim_start();
865        if trimmed.len() <= key.len() {
866            return String::new();
867        }
868        // Skip past key and separator to reach the value portion
869        let after_key = &trimmed[key.len()..];
870        let rest = after_key.trim_start();
871        let rest = rest.strip_prefix('=').unwrap_or(rest).trim_start();
872        // Scan for inline comment (# preceded by whitespace, outside quotes)
873        let bytes = rest.as_bytes();
874        let mut in_quote = false;
875        for i in 0..bytes.len() {
876            if bytes[i] == b'"' {
877                in_quote = !in_quote;
878            } else if !in_quote
879                && bytes[i] == b'#'
880                && i > 0
881                && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t')
882            {
883                // Found comment start. The clean value ends before the whitespace preceding #.
884                let clean_end = rest[..i].trim_end().len();
885                return rest[clean_end..].to_string();
886            }
887        }
888        String::new()
889    }
890
891    /// Set provider on a host block by alias using a full ProviderConfigId.
892    /// Emits a 3-segment marker when the id has a label, 2-segment otherwise.
893    ///
894    /// Refuses pattern aliases and multi-alias blocks: claiming a sibling
895    /// alias as provider-owned cascades into stale-marking and bulk-purge,
896    /// which would silently delete the user's hand-curated entries.
897    #[must_use = "check the return value to detect silently-skipped mutations (renamed, deleted or shared-block hosts)"]
898    pub fn set_host_provider_id(
899        &mut self,
900        alias: &str,
901        id: &crate::providers::config::ProviderConfigId,
902        server_id: &str,
903    ) -> bool {
904        if alias.is_empty() || is_host_pattern(alias) {
905            return false;
906        }
907        let Some(block) = self.find_host_block_mut(alias) else {
908            return false;
909        };
910        if is_host_pattern(&block.host_pattern) {
911            return false;
912        }
913        block.set_provider_id(id, server_id);
914        true
915    }
916
917    /// Rewrite every 2-segment legacy marker for `provider_name` to a
918    /// 3-segment marker keyed to `(provider_name, label)`. Used by the
919    /// lazy-migration flow so existing hosts of a now-labeled config stay
920    /// owned (and don't get re-claimed or stale-marked) on the next sync.
921    ///
922    /// Only top-level host blocks are rewritten; Include files are read-only
923    /// per the project's invariant. Returns the count of host blocks touched.
924    pub fn rewrite_legacy_markers_to_label(&mut self, provider_name: &str, label: &str) -> usize {
925        let new_id = crate::providers::config::ProviderConfigId::labeled(provider_name, label);
926        let mut rewritten = 0usize;
927        for element in &mut self.elements {
928            if let ConfigElement::HostBlock(block) = element {
929                let Some((id, server_id)) = block.provider_id() else {
930                    continue;
931                };
932                if id.provider == provider_name && id.label.is_none() {
933                    block.set_provider_id(&new_id, &server_id);
934                    rewritten += 1;
935                }
936            }
937        }
938        rewritten
939    }
940
941    /// Find all hosts with a specific provider, returning (alias, server_id) pairs.
942    /// Searches both top-level elements and Include files so that provider hosts
943    /// in included configs are recognized during sync (prevents duplicate additions).
944    pub fn find_hosts_by_provider(&self, provider_name: &str) -> Vec<(String, String)> {
945        let mut results = Vec::new();
946        Self::collect_provider_hosts(&self.elements, provider_name, &mut results);
947        results
948    }
949
950    /// Find hosts owned by an exact `ProviderConfigId`. Used during multi-config sync
951    /// so two labeled configs of the same provider don't claim each other's hosts.
952    /// Legacy 2-segment markers match a bare id (label=None) for backward compatibility.
953    pub fn find_hosts_by_id(
954        &self,
955        id: &crate::providers::config::ProviderConfigId,
956    ) -> Vec<(String, String)> {
957        let mut results = Vec::new();
958        Self::collect_provider_hosts_by_id(&self.elements, id, &mut results);
959        results
960    }
961
962    /// Like `find_hosts_by_provider`, but returns the FULL server_id from the
963    /// raw marker (everything after the first colon), without trying to
964    /// interpret the middle segment as a label. Used by sync of BARE configs
965    /// so server_ids containing colons (Proxmox `qemu:300`) are matched
966    /// against the API response one-to-one instead of being mis-parsed as
967    /// labeled markers.
968    pub fn find_hosts_by_provider_raw(&self, provider_name: &str) -> Vec<(String, String)> {
969        let mut results = Vec::new();
970        Self::collect_provider_hosts_raw(&self.elements, provider_name, &mut results);
971        results
972    }
973
974    fn collect_provider_hosts_raw(
975        elements: &[ConfigElement],
976        provider_name: &str,
977        results: &mut Vec<(String, String)>,
978    ) {
979        for element in elements {
980            match element {
981                ConfigElement::HostBlock(block) => {
982                    if let Some((name, server_id)) = block.provider_raw() {
983                        if name == provider_name {
984                            results.push((block.host_pattern.clone(), server_id));
985                        }
986                    }
987                }
988                ConfigElement::Include(include) => {
989                    for file in &include.resolved_files {
990                        Self::collect_provider_hosts_raw(&file.elements, provider_name, results);
991                    }
992                }
993                ConfigElement::GlobalLine(_) => {}
994            }
995        }
996    }
997
998    fn collect_provider_hosts(
999        elements: &[ConfigElement],
1000        provider_name: &str,
1001        results: &mut Vec<(String, String)>,
1002    ) {
1003        for element in elements {
1004            match element {
1005                ConfigElement::HostBlock(block) => {
1006                    if let Some((name, id)) = block.provider() {
1007                        if name == provider_name {
1008                            results.push((block.host_pattern.clone(), id));
1009                        }
1010                    }
1011                }
1012                ConfigElement::Include(include) => {
1013                    for file in &include.resolved_files {
1014                        Self::collect_provider_hosts(&file.elements, provider_name, results);
1015                    }
1016                }
1017                ConfigElement::GlobalLine(_) => {}
1018            }
1019        }
1020    }
1021
1022    fn collect_provider_hosts_by_id(
1023        elements: &[ConfigElement],
1024        id: &crate::providers::config::ProviderConfigId,
1025        results: &mut Vec<(String, String)>,
1026    ) {
1027        for element in elements {
1028            match element {
1029                ConfigElement::HostBlock(block) => {
1030                    if let Some((host_id, server_id)) = block.provider_id() {
1031                        if &host_id == id {
1032                            results.push((block.host_pattern.clone(), server_id));
1033                        }
1034                    }
1035                }
1036                ConfigElement::Include(include) => {
1037                    for file in &include.resolved_files {
1038                        Self::collect_provider_hosts_by_id(&file.elements, id, results);
1039                    }
1040                }
1041                ConfigElement::GlobalLine(_) => {}
1042            }
1043        }
1044    }
1045
1046    /// Compare two directive values with whitespace normalization.
1047    /// Handles hand-edited configs with tabs or multiple spaces.
1048    fn values_match(a: &str, b: &str) -> bool {
1049        a.split_whitespace().eq(b.split_whitespace())
1050    }
1051
1052    /// Add a forwarding directive to a host block.
1053    /// Inserts at `content_end()` (before trailing blanks), using detected indentation.
1054    /// Uses split_whitespace matching for multi-pattern Host lines.
1055    pub fn add_forward(&mut self, alias: &str, directive_key: &str, value: &str) {
1056        for element in &mut self.elements {
1057            if let ConfigElement::HostBlock(block) = element {
1058                if pattern_contains_token(&block.host_pattern, alias) {
1059                    let indent = block.detect_indent();
1060                    let pos = block.content_end();
1061                    block.directives.insert(
1062                        pos,
1063                        Directive {
1064                            key: directive_key.to_string(),
1065                            value: value.to_string(),
1066                            raw_line: format!("{}{} {}", indent, directive_key, value),
1067                            is_non_directive: false,
1068                        },
1069                    );
1070                    return;
1071                }
1072            }
1073        }
1074    }
1075
1076    /// Remove a specific forwarding directive from a host block.
1077    /// Matches key (case-insensitive) and value (whitespace-normalized).
1078    /// Uses split_whitespace matching for multi-pattern Host lines.
1079    /// Returns true if a directive was actually removed.
1080    pub fn remove_forward(&mut self, alias: &str, directive_key: &str, value: &str) -> bool {
1081        for element in &mut self.elements {
1082            if let ConfigElement::HostBlock(block) = element {
1083                if pattern_contains_token(&block.host_pattern, alias) {
1084                    if let Some(pos) = block.directives.iter().position(|d| {
1085                        !d.is_non_directive
1086                            && d.key.eq_ignore_ascii_case(directive_key)
1087                            && Self::values_match(&d.value, value)
1088                    }) {
1089                        block.directives.remove(pos);
1090                        return true;
1091                    }
1092                    return false;
1093                }
1094            }
1095        }
1096        false
1097    }
1098
1099    /// Check if a host block has a specific forwarding directive.
1100    /// Uses whitespace-normalized value comparison and split_whitespace host matching.
1101    pub fn has_forward(&self, alias: &str, directive_key: &str, value: &str) -> bool {
1102        for element in &self.elements {
1103            if let ConfigElement::HostBlock(block) = element {
1104                if pattern_contains_token(&block.host_pattern, alias) {
1105                    return block.directives.iter().any(|d| {
1106                        !d.is_non_directive
1107                            && d.key.eq_ignore_ascii_case(directive_key)
1108                            && Self::values_match(&d.value, value)
1109                    });
1110                }
1111            }
1112        }
1113        false
1114    }
1115
1116    /// Find tunnel directives for a host alias, searching all elements including
1117    /// Include files. Uses split_whitespace matching like has_host() for multi-pattern
1118    /// Host lines.
1119    pub fn find_tunnel_directives(&self, alias: &str) -> Vec<crate::tunnel::TunnelRule> {
1120        Self::find_tunnel_directives_in(&self.elements, alias)
1121    }
1122
1123    fn find_tunnel_directives_in(
1124        elements: &[ConfigElement],
1125        alias: &str,
1126    ) -> Vec<crate::tunnel::TunnelRule> {
1127        for element in elements {
1128            match element {
1129                ConfigElement::HostBlock(block) => {
1130                    if pattern_contains_token(&block.host_pattern, alias) {
1131                        return block.tunnel_directives();
1132                    }
1133                }
1134                ConfigElement::Include(include) => {
1135                    for file in &include.resolved_files {
1136                        let rules = Self::find_tunnel_directives_in(&file.elements, alias);
1137                        if !rules.is_empty() {
1138                            return rules;
1139                        }
1140                    }
1141                }
1142                ConfigElement::GlobalLine(_) => {}
1143            }
1144        }
1145        Vec::new()
1146    }
1147
1148    /// Generate a unique alias by appending -2, -3, etc. if the base alias is taken.
1149    pub fn deduplicate_alias(&self, base: &str) -> String {
1150        self.deduplicate_alias_excluding(base, None)
1151    }
1152
1153    /// Generate a unique alias, optionally excluding one alias from collision detection.
1154    /// Used during rename so the host being renamed doesn't collide with itself.
1155    pub fn deduplicate_alias_excluding(&self, base: &str, exclude: Option<&str>) -> String {
1156        let is_taken = |alias: &str| {
1157            if exclude == Some(alias) {
1158                return false;
1159            }
1160            self.has_host(alias)
1161        };
1162        if !is_taken(base) {
1163            return base.to_string();
1164        }
1165        for n in 2..=9999 {
1166            let candidate = format!("{}-{}", base, n);
1167            if !is_taken(&candidate) {
1168                return candidate;
1169            }
1170        }
1171        // Practically unreachable: fall back to PID-based suffix
1172        format!("{}-{}", base, std::process::id())
1173    }
1174
1175    /// Set tags on a host block by alias.
1176    ///
1177    /// Refuses pattern aliases and multi-alias blocks symmetric with the
1178    /// vault/certificate setters: a tag on a shared block silently applies to
1179    /// every sibling alias, which is rarely the user's intent.
1180    #[must_use = "check the return value to detect silently-skipped mutations (renamed, deleted or shared-block hosts)"]
1181    pub fn set_host_tags(&mut self, alias: &str, tags: &[String]) -> bool {
1182        if alias.is_empty() || is_host_pattern(alias) {
1183            return false;
1184        }
1185        let Some(block) = self.find_host_block_mut(alias) else {
1186            return false;
1187        };
1188        if is_host_pattern(&block.host_pattern) {
1189            return false;
1190        }
1191        block.set_tags(tags);
1192        true
1193    }
1194
1195    /// Set provider-synced tags on a host block by alias.
1196    ///
1197    /// Same multi-alias and pattern refusal as the other purple-marker
1198    /// setters. Provider tags drive sync decisions, so a wrong-block mutation
1199    /// can cascade into delete/stale.
1200    #[must_use = "check the return value to detect silently-skipped mutations (renamed, deleted or shared-block hosts)"]
1201    pub fn set_host_provider_tags(&mut self, alias: &str, tags: &[String]) -> bool {
1202        if alias.is_empty() || is_host_pattern(alias) {
1203            return false;
1204        }
1205        let Some(block) = self.find_host_block_mut(alias) else {
1206            return false;
1207        };
1208        if is_host_pattern(&block.host_pattern) {
1209            return false;
1210        }
1211        block.set_provider_tags(tags);
1212        true
1213    }
1214
1215    /// Record the last-synced provider username on a host block by alias.
1216    /// Empty value removes the marker. Same shared-block and pattern refusal
1217    /// as the other purple-marker setters: a wrong-block mutation would
1218    /// mis-track override state and let sync clobber a manual User edit.
1219    #[must_use = "check the return value to detect silently-skipped mutations (renamed, deleted or shared-block hosts)"]
1220    pub fn set_host_provider_user(&mut self, alias: &str, user: &str) -> bool {
1221        if alias.is_empty() || is_host_pattern(alias) {
1222            return false;
1223        }
1224        let Some(block) = self.find_host_block_mut(alias) else {
1225            return false;
1226        };
1227        if is_host_pattern(&block.host_pattern) {
1228            return false;
1229        }
1230        block.set_provider_user(user);
1231        true
1232    }
1233
1234    /// Record the last-synced provider identity file on a host block by alias.
1235    /// Empty value removes the marker. Same safety invariants as
1236    /// `set_host_provider_user`.
1237    #[must_use = "check the return value to detect silently-skipped mutations (renamed, deleted or shared-block hosts)"]
1238    pub fn set_host_provider_key(&mut self, alias: &str, key: &str) -> bool {
1239        if alias.is_empty() || is_host_pattern(alias) {
1240            return false;
1241        }
1242        let Some(block) = self.find_host_block_mut(alias) else {
1243            return false;
1244        };
1245        if is_host_pattern(&block.host_pattern) {
1246            return false;
1247        }
1248        block.set_provider_key(key);
1249        true
1250    }
1251
1252    /// Set askpass source on a host block by alias.
1253    ///
1254    /// Askpass is an authentication credential source; applying it to a
1255    /// sibling alias in a shared block would route the wrong credential.
1256    #[must_use = "check the return value to detect silently-skipped mutations (renamed, deleted or shared-block hosts)"]
1257    pub fn set_host_askpass(&mut self, alias: &str, source: &str) -> bool {
1258        if alias.is_empty() || is_host_pattern(alias) {
1259            return false;
1260        }
1261        let Some(block) = self.find_host_block_mut(alias) else {
1262            return false;
1263        };
1264        if is_host_pattern(&block.host_pattern) {
1265            return false;
1266        }
1267        block.set_askpass(source);
1268        true
1269    }
1270
1271    /// Set or remove the Vault SSH role comment on a host block by alias.
1272    /// Empty `role` removes the comment.
1273    ///
1274    /// Mirrors the safety invariants of `set_host_certificate_file` and
1275    /// `set_host_vault_addr`: wildcard aliases are refused so a `Host *.prod`
1276    /// pattern can never have a Vault role silently assigned to every host
1277    /// it resolves, and multi-alias blocks (`Host web-01 web-01.prod`) are
1278    /// refused so the role is never applied to sibling aliases the user did
1279    /// not authorise. Returns `true` on a successful mutation, `false` when
1280    /// the alias is invalid, missing, or lives in an Include file.
1281    ///
1282    /// Callers that run asynchronously (form submit handlers, sync workers)
1283    /// MUST check the return value so a silent config mutation failure is
1284    /// surfaced instead of pretending the role was wired up.
1285    #[must_use = "check the return value to detect silently-skipped mutations (renamed, deleted or shared-block hosts)"]
1286    pub fn set_host_vault_ssh(&mut self, alias: &str, role: &str) -> bool {
1287        if alias.is_empty() || is_host_pattern(alias) {
1288            return false;
1289        }
1290        let Some(block) = self.find_host_block_mut(alias) else {
1291            return false;
1292        };
1293        if is_host_pattern(&block.host_pattern) {
1294            return false;
1295        }
1296        block.set_vault_ssh(role);
1297        true
1298    }
1299
1300    /// Set or remove the Vault SSH endpoint comment on a host block by alias.
1301    /// Empty `url` removes the comment.
1302    ///
1303    /// Mirrors the safety invariants of `set_host_certificate_file`: wildcard
1304    /// aliases are refused to avoid accidentally applying a vault address to
1305    /// every host resolved through a pattern, and Match blocks are not
1306    /// touched (they live as inert `GlobalLines`). Returns `true` on a
1307    /// successful mutation, `false` when the alias is invalid or the block
1308    /// is not found.
1309    ///
1310    /// Callers that run asynchronously (e.g. form submit handlers that
1311    /// resolve the alias before writing) MUST check the return value so a
1312    /// silent config mutation failure is surfaced instead of pretending the
1313    /// vault address was wired up.
1314    #[must_use = "check the return value to detect silently-skipped mutations (renamed or deleted hosts)"]
1315    pub fn set_host_vault_addr(&mut self, alias: &str, url: &str) -> bool {
1316        // Same guard as `set_host_certificate_file`: refuse empty aliases
1317        // and any SSH pattern shape. `is_host_pattern` already covers
1318        // wildcards, negation and whitespace-separated multi-host forms.
1319        if alias.is_empty() || is_host_pattern(alias) {
1320            return false;
1321        }
1322        let Some(block) = self.find_host_block_mut(alias) else {
1323            return false;
1324        };
1325        // Defense in depth: refuse to mutate a block that is itself a
1326        // pattern or a multi-alias block (ExactAliasOnly policy). Writing a
1327        // vault endpoint onto such a block would apply to every sibling
1328        // alias and every host resolving through the pattern, which is
1329        // almost certainly not what the caller intends.
1330        if is_host_pattern(&block.host_pattern) {
1331            return false;
1332        }
1333        block.set_vault_addr(url);
1334        true
1335    }
1336
1337    /// Set or remove the CertificateFile directive on a host block by alias.
1338    /// Empty path removes the directive.
1339    /// Set the `CertificateFile` directive on the host block that matches
1340    /// `alias` exactly. Returns `true` if a matching block was found and
1341    /// updated, `false` if no top-level `HostBlock` matched (alias was
1342    /// renamed, deleted or lives only inside an `Include`d file).
1343    ///
1344    /// Only touches `CertificateFile` directives that are purple-managed
1345    /// (path contains `.purple/certs/`). User-set custom `CertificateFile`
1346    /// entries (e.g. a corporate or personal cert at `~/.ssh/corp-cert.pub`)
1347    /// are never modified or removed: empty-path clears only the purple
1348    /// managed line; non-empty path updates the purple-managed line in
1349    /// place or inserts a new one if absent. A host with both a corporate
1350    /// cert and a Vault-signed cert ends up with both lines present, in
1351    /// OpenSSH's documented cumulative semantics.
1352    ///
1353    /// Callers that run asynchronously (e.g. the Vault SSH bulk-sign worker)
1354    /// MUST check the return value so a silent config mutation failure is
1355    /// surfaced to the user instead of pretending the cert was wired up.
1356    #[must_use = "check the return value to detect silently-skipped mutations (renamed or deleted hosts)"]
1357    pub fn set_host_certificate_file(&mut self, alias: &str, path: &str) -> bool {
1358        // Defense in depth: refuse to mutate a host block when the requested
1359        // alias is empty or matches any SSH pattern shape (`*`, `?`, `[`,
1360        // leading `!`, or whitespace-separated multi-host form like
1361        // `Host web-* db-*`). Writing `CertificateFile` onto a pattern
1362        // block is almost never what a user intends and would affect every
1363        // host that resolves through that pattern. Reusing `is_host_pattern`
1364        // keeps this check in sync with the form-level pattern detection.
1365        if alias.is_empty() || is_host_pattern(alias) {
1366            return false;
1367        }
1368        let Some(block) = self.find_host_block_mut(alias) else {
1369            return false;
1370        };
1371        // Additionally refuse when the matched block is itself a pattern or
1372        // multi-alias block (ExactAliasOnly policy). The input `alias` may
1373        // be a plain token yet resolve into a block like `Host web-01
1374        // web-01.prod`, where writing `CertificateFile` would silently
1375        // affect sibling aliases.
1376        if is_host_pattern(&block.host_pattern) {
1377            return false;
1378        }
1379
1380        // Find the existing purple-managed CertificateFile entry, if any.
1381        let purple_pos = block.directives.iter().position(|d| {
1382            !d.is_non_directive
1383                && d.key.eq_ignore_ascii_case("CertificateFile")
1384                && is_purple_managed_cert_value(&d.value)
1385        });
1386
1387        if path.is_empty() {
1388            if let Some(pos) = purple_pos {
1389                block.directives.remove(pos);
1390            }
1391            return true;
1392        }
1393
1394        let sanitized = HostBlock::sanitize_raw_line_value(path);
1395        let indent = block.detect_indent();
1396        if let Some(pos) = purple_pos {
1397            let d = &mut block.directives[pos];
1398            if d.value != sanitized.as_ref() {
1399                d.value = sanitized.to_string();
1400                // Preserve separator style + inline comment in the same way
1401                // upsert_directive does for the single-line case.
1402                let trimmed = d.raw_line.trim_start();
1403                let after_key = &trimmed[d.key.len()..];
1404                let sep = if after_key.trim_start().starts_with('=') {
1405                    let eq_pos = after_key.find('=').unwrap();
1406                    let after_eq = &after_key[eq_pos + 1..];
1407                    let trailing_ws = after_eq.len() - after_eq.trim_start().len();
1408                    after_key[..eq_pos + 1 + trailing_ws].to_string()
1409                } else {
1410                    " ".to_string()
1411                };
1412                let comment_suffix = Self::extract_inline_comment(&d.raw_line, &d.key);
1413                let rendered = HostBlock::render_value(&sanitized);
1414                d.raw_line = format!("{}{}{}{}{}", indent, d.key, sep, rendered, comment_suffix);
1415            }
1416        } else if is_purple_managed_cert_value(sanitized.as_ref()) {
1417            // Defensive gate: only insert a NEW CertificateFile line when
1418            // the caller's path is itself purple-managed. The rollback flow
1419            // in `app/hosts.rs` may pass `old_entry.certificate_file` which
1420            // could be a user-set custom path; inserting it here would
1421            // duplicate a user-managed entry. A non-purple-managed path
1422            // with no existing purple-managed line is a no-op.
1423            let pos = block.content_end();
1424            block.directives.insert(
1425                pos,
1426                Directive {
1427                    key: "CertificateFile".to_string(),
1428                    value: sanitized.to_string(),
1429                    raw_line: format!(
1430                        "{}CertificateFile {}",
1431                        indent,
1432                        HostBlock::render_value(&sanitized)
1433                    ),
1434                    is_non_directive: false,
1435                },
1436            );
1437        }
1438        true
1439    }
1440
1441    /// Set provider metadata on a host block by alias.
1442    ///
1443    /// Refuses pattern aliases and multi-alias blocks; same rationale as the
1444    /// other `# purple:*` setters.
1445    #[must_use = "check the return value to detect silently-skipped mutations (renamed, deleted or shared-block hosts)"]
1446    pub fn set_host_meta(&mut self, alias: &str, meta: &[(String, String)]) -> bool {
1447        if alias.is_empty() || is_host_pattern(alias) {
1448            return false;
1449        }
1450        let Some(block) = self.find_host_block_mut(alias) else {
1451            return false;
1452        };
1453        if is_host_pattern(&block.host_pattern) {
1454            return false;
1455        }
1456        block.set_meta(meta);
1457        true
1458    }
1459
1460    /// Mark a host as stale by alias.
1461    ///
1462    /// Stale markers drive the `X` purge flow which deletes the full block,
1463    /// so a wrong-block mutation here cascades into data loss for a sibling
1464    /// alias the user added by hand. Refuse pattern and multi-alias blocks.
1465    #[must_use = "check the return value to detect silently-skipped mutations (renamed, deleted or shared-block hosts)"]
1466    pub fn set_host_stale(&mut self, alias: &str, timestamp: u64) -> bool {
1467        if alias.is_empty() || is_host_pattern(alias) {
1468            return false;
1469        }
1470        let Some(block) = self.find_host_block_mut(alias) else {
1471            return false;
1472        };
1473        if is_host_pattern(&block.host_pattern) {
1474            return false;
1475        }
1476        block.set_stale(timestamp);
1477        true
1478    }
1479
1480    /// Clear stale marking from a host by alias.
1481    ///
1482    /// Symmetric guard with `set_host_stale`. Clearing on a shared block is
1483    /// benign but the asymmetry would be confusing; reject for consistency.
1484    #[must_use = "check the return value to detect silently-skipped mutations (renamed, deleted or shared-block hosts)"]
1485    pub fn clear_host_stale(&mut self, alias: &str) -> bool {
1486        if alias.is_empty() || is_host_pattern(alias) {
1487            return false;
1488        }
1489        let Some(block) = self.find_host_block_mut(alias) else {
1490            return false;
1491        };
1492        if is_host_pattern(&block.host_pattern) {
1493            return false;
1494        }
1495        block.clear_stale();
1496        true
1497    }
1498
1499    /// Collect all stale hosts with their timestamps.
1500    pub fn stale_hosts(&self) -> Vec<(String, u64)> {
1501        let mut result = Vec::new();
1502        for element in &self.elements {
1503            if let ConfigElement::HostBlock(block) = element {
1504                if let Some(ts) = block.stale() {
1505                    result.push((block.host_pattern.clone(), ts));
1506                }
1507            }
1508        }
1509        result
1510    }
1511
1512    /// Delete a host entry by alias.
1513    ///
1514    /// For a single-alias block this removes the whole block (and cleans up
1515    /// any orphaned `# purple:group` header left behind). For a multi-alias
1516    /// block like `Host web-01 web-01.prod 10.0.1.5` only the matching
1517    /// alias token is stripped from the `Host` line; sibling aliases and
1518    /// all directives are preserved so that `delete_host("web-01.prod")`
1519    /// does not silently wipe configuration for `web-01` and `10.0.1.5`.
1520    ///
1521    /// Callers that want to remove the entire block regardless of sibling
1522    /// aliases should surface an explicit confirmation in the UI and then
1523    /// delete each sibling alias in turn.
1524    pub fn delete_host(&mut self, alias: &str) {
1525        // Two matching modes:
1526        //   - Full-pattern match: block.host_pattern == alias. Removes the
1527        //     entire block (plus duplicates). Used by the pattern browser,
1528        //     where `alias` is a full pattern string like `web-* db-*` or
1529        //     `web-01 web-01.prod`.
1530        //   - Token match: alias appears as one of the whitespace-separated
1531        //     tokens. Strips just that token from a multi-alias block and
1532        //     removes single-alias blocks outright. Used by the host list.
1533        // Full-pattern is checked first so pattern-browser deletes never
1534        // degenerate into partial token strips.
1535        let has_full_match = self
1536            .elements
1537            .iter()
1538            .any(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias));
1539
1540        // Capture the provider for orphaned-group cleanup before mutation.
1541        let provider_name = self.elements.iter().find_map(|e| match e {
1542            ConfigElement::HostBlock(b)
1543                if (has_full_match && b.host_pattern == alias)
1544                    || (!has_full_match && pattern_contains_token(&b.host_pattern, alias)) =>
1545            {
1546                b.provider().map(|(name, _)| name)
1547            }
1548            _ => None,
1549        });
1550
1551        if has_full_match {
1552            // Harvest trailing comments (column-0 `#` lines or section
1553            // headers) from each block we're about to delete, so they
1554            // survive the delete and re-attach to whatever follows.
1555            // Skip `# purple:*` metadata — that's bookkeeping owned by the
1556            // block being removed.
1557            let mut salvaged_comments: Vec<String> = Vec::new();
1558            for el in &mut self.elements {
1559                if let ConfigElement::HostBlock(block) = el {
1560                    if block.host_pattern == alias {
1561                        let drain_from = {
1562                            let mut idx = block.directives.len();
1563                            while idx > 0 {
1564                                let d = &block.directives[idx - 1];
1565                                let is_user_comment = d.is_non_directive
1566                                    && (d.raw_line.trim().is_empty()
1567                                        || (d.raw_line.trim().starts_with('#')
1568                                            && !d.raw_line.trim().starts_with("# purple:")));
1569                                if !is_user_comment {
1570                                    break;
1571                                }
1572                                idx -= 1;
1573                            }
1574                            idx
1575                        };
1576                        for d in block.directives.drain(drain_from..) {
1577                            if !d.raw_line.trim().is_empty() {
1578                                salvaged_comments.push(d.raw_line);
1579                            }
1580                        }
1581                    }
1582                }
1583            }
1584            // Remove every block whose full host_pattern equals the input
1585            // (duplicate-block invariant preserved, matches pre-refactor).
1586            self.elements.retain(|e| match e {
1587                ConfigElement::HostBlock(block) => block.host_pattern != alias,
1588                _ => true,
1589            });
1590            // Re-emit salvaged comments as GlobalLines just before the next
1591            // remaining HostBlock, so a section-header lands above what
1592            // follows rather than vanishing with the preceding host.
1593            if !salvaged_comments.is_empty() {
1594                let next_host = self
1595                    .elements
1596                    .iter()
1597                    .position(|e| matches!(e, ConfigElement::HostBlock(_)));
1598                let insert_pos = next_host.unwrap_or(self.elements.len());
1599                for (offset, raw) in salvaged_comments.into_iter().enumerate() {
1600                    self.elements
1601                        .insert(insert_pos + offset, ConfigElement::GlobalLine(raw));
1602                }
1603            }
1604        }
1605        // Always run the token-strip pass too. A config can contain BOTH a
1606        // full-pattern block (`Host web-01`) AND a sibling block that carries
1607        // the same alias as one token of a multi-alias pattern (`Host web-01
1608        // staging`). Without this second pass, `delete_host("web-01")` would
1609        // remove the first block, leave the second untouched, and `ssh web-01`
1610        // would silently re-route to staging's HostName. The strip is a no-op
1611        // when no token-only sibling exists.
1612        for el in &mut self.elements {
1613            if let ConfigElement::HostBlock(block) = el {
1614                let tokens: Vec<&str> = block.host_pattern.split_whitespace().collect();
1615                if tokens.len() > 1 && tokens.contains(&alias) {
1616                    let new_pattern = tokens
1617                        .iter()
1618                        .filter(|t| **t != alias)
1619                        .copied()
1620                        .collect::<Vec<_>>()
1621                        .join(" ");
1622                    block.host_pattern = new_pattern.clone();
1623                    block.raw_host_line = rebuild_host_line(&block.raw_host_line, &new_pattern);
1624                }
1625            }
1626        }
1627        self.elements.retain(|e| match e {
1628            ConfigElement::HostBlock(block) => {
1629                let mut tokens = block.host_pattern.split_whitespace();
1630                !matches!(
1631                    (tokens.next(), tokens.next()),
1632                    (Some(first), None) if first == alias
1633                )
1634            }
1635            _ => true,
1636        });
1637
1638        if let Some(name) = provider_name {
1639            self.remove_orphaned_group_header(&name);
1640        }
1641
1642        // Collapse consecutive blank lines left by deletion
1643        self.elements.dedup_by(|a, b| {
1644            matches!(
1645                (&*a, &*b),
1646                (ConfigElement::GlobalLine(x), ConfigElement::GlobalLine(y))
1647                if x.trim().is_empty() && y.trim().is_empty()
1648            )
1649        });
1650    }
1651
1652    /// Delete a host and return the removed element and its position for undo.
1653    /// Does NOT collapse blank lines or remove group headers so the position
1654    /// stays valid for re-insertion via `insert_host_at()`.
1655    /// Orphaned group headers (if any) are cleaned up at next startup.
1656    ///
1657    /// For multi-alias blocks this returns `None`: undoable-delete of a
1658    /// single alias out of a shared `Host` line cannot be round-tripped via
1659    /// `insert_host_at` because sibling aliases would be lost. Callers
1660    /// should fall back to `delete_host` in that case (which strips only
1661    /// the requested token).
1662    pub fn delete_host_undoable(&mut self, alias: &str) -> Option<(ConfigElement, usize)> {
1663        // Two-mode match mirroring `delete_host`: full-pattern first (for
1664        // pattern-browser deletes where `alias` is the whole pattern
1665        // string), then token match. Undoable delete is only safe when
1666        // removing the entire block; token-strip on a multi-alias block is
1667        // therefore refused (returns `None`) because re-inserting the
1668        // whole element would not reverse a token strip.
1669        let full_pos = self
1670            .elements
1671            .iter()
1672            .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias));
1673        let pos = if let Some(p) = full_pos {
1674            p
1675        } else {
1676            let token_pos = self.elements.iter().position(|e| match e {
1677                ConfigElement::HostBlock(b) => pattern_contains_token(&b.host_pattern, alias),
1678                _ => false,
1679            })?;
1680            if let ConfigElement::HostBlock(b) = &self.elements[token_pos] {
1681                if b.host_pattern.split_whitespace().count() > 1 {
1682                    return None;
1683                }
1684            }
1685            token_pos
1686        };
1687        let element = self.elements.remove(pos);
1688        Some((element, pos))
1689    }
1690
1691    /// Insert a host block at a specific position (for undo).
1692    pub fn insert_host_at(&mut self, element: ConfigElement, position: usize) {
1693        let pos = position.min(self.elements.len());
1694        self.elements.insert(pos, element);
1695    }
1696
1697    /// Find the position after the last HostBlock that belongs to a provider.
1698    /// Returns `None` if no hosts for this provider exist in the config.
1699    /// Used by the sync engine to insert new hosts adjacent to existing provider hosts.
1700    pub fn find_provider_insert_position(&self, provider_name: &str) -> Option<usize> {
1701        let mut last_pos = None;
1702        for (i, element) in self.elements.iter().enumerate() {
1703            if let ConfigElement::HostBlock(block) = element {
1704                if let Some((name, _)) = block.provider() {
1705                    if name == provider_name {
1706                        last_pos = Some(i);
1707                    }
1708                }
1709            }
1710        }
1711        // Return position after the last provider host
1712        last_pos.map(|p| p + 1)
1713    }
1714
1715    /// Swap two host blocks in the config by alias. Returns true if swap was performed.
1716    #[allow(dead_code)]
1717    pub fn swap_hosts(&mut self, alias_a: &str, alias_b: &str) -> bool {
1718        let pos_a = self
1719            .elements
1720            .iter()
1721            .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_a));
1722        let pos_b = self
1723            .elements
1724            .iter()
1725            .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_b));
1726        if let (Some(a), Some(b)) = (pos_a, pos_b) {
1727            if a == b {
1728                return false;
1729            }
1730            let (first, second) = (a.min(b), a.max(b));
1731
1732            // Strip trailing blanks from both blocks before swap
1733            if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
1734                block.pop_trailing_blanks();
1735            }
1736            if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
1737                block.pop_trailing_blanks();
1738            }
1739
1740            // Swap
1741            self.elements.swap(first, second);
1742
1743            // Add trailing blank to first block (separator between the two)
1744            if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
1745                block.ensure_trailing_blank();
1746            }
1747
1748            // Add trailing blank to second only if not the last element
1749            if second < self.elements.len() - 1 {
1750                if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
1751                    block.ensure_trailing_blank();
1752                }
1753            }
1754
1755            return true;
1756        }
1757        false
1758    }
1759
1760    /// Convert a HostEntry into a new HostBlock with clean formatting.
1761    ///
1762    /// Every value that ends up inside a `raw_line` is routed through
1763    /// `HostBlock::sanitize_raw_line_value`. A `\n` or `\r` in `alias`,
1764    /// `hostname`, `user`, `identity_file` or `proxy_jump` would otherwise
1765    /// split the rendered line and inject extra SSH config directives — for
1766    /// example a provider API returning `name = "evil\n  ProxyJump bad"`
1767    /// would land as a real ProxyJump directive in the user's config. The
1768    /// previous `debug_assert!` guards were stripped from release builds,
1769    /// so the sanitiser is the only release-mode defence.
1770    pub(crate) fn entry_to_block(entry: &HostEntry) -> HostBlock {
1771        let alias = HostBlock::sanitize_raw_line_value(&entry.alias);
1772        let hostname = HostBlock::sanitize_raw_line_value(&entry.hostname);
1773        let user = HostBlock::sanitize_raw_line_value(&entry.user);
1774        let identity_file = HostBlock::sanitize_raw_line_value(&entry.identity_file);
1775        let proxy_jump = HostBlock::sanitize_raw_line_value(&entry.proxy_jump);
1776
1777        let mut directives = Vec::new();
1778
1779        if !hostname.is_empty() {
1780            directives.push(Directive {
1781                key: "HostName".to_string(),
1782                value: hostname.to_string(),
1783                raw_line: format!("  HostName {}", HostBlock::render_value(&hostname)),
1784                is_non_directive: false,
1785            });
1786        }
1787        if !user.is_empty() {
1788            directives.push(Directive {
1789                key: "User".to_string(),
1790                value: user.to_string(),
1791                raw_line: format!("  User {}", HostBlock::render_value(&user)),
1792                is_non_directive: false,
1793            });
1794        }
1795        if entry.port != 22 {
1796            directives.push(Directive {
1797                key: "Port".to_string(),
1798                value: entry.port.to_string(),
1799                raw_line: format!("  Port {}", entry.port),
1800                is_non_directive: false,
1801            });
1802        }
1803        if !identity_file.is_empty() {
1804            directives.push(Directive {
1805                key: "IdentityFile".to_string(),
1806                value: identity_file.to_string(),
1807                raw_line: format!("  IdentityFile {}", HostBlock::render_value(&identity_file)),
1808                is_non_directive: false,
1809            });
1810        }
1811        if !proxy_jump.is_empty() {
1812            directives.push(Directive {
1813                key: "ProxyJump".to_string(),
1814                value: proxy_jump.to_string(),
1815                raw_line: format!("  ProxyJump {}", HostBlock::render_value(&proxy_jump)),
1816                is_non_directive: false,
1817            });
1818        }
1819
1820        HostBlock {
1821            host_pattern: alias.to_string(),
1822            raw_host_line: format!("Host {}", alias),
1823            directives,
1824        }
1825    }
1826}
1827
1828/// Check whether `host_pattern` contains `alias` as one of its
1829/// whitespace-separated tokens, with quote-stripping. OpenSSH accepts
1830/// `Host "alpha"` as `Host alpha`; without quote-stripping the stored pattern
1831/// `"alpha"` (with literal quote characters) would never match the typed
1832/// alias `alpha`, leaving the block unreachable to the mutation API.
1833pub(super) fn pattern_contains_token(host_pattern: &str, alias: &str) -> bool {
1834    host_pattern.split_whitespace().any(|t| {
1835        let unquoted = if t.len() >= 2 && t.starts_with('"') && t.ends_with('"') {
1836            &t[1..t.len() - 1]
1837        } else {
1838            t
1839        };
1840        unquoted == alias
1841    })
1842}
1843
1844/// Rebuild a `Host` line with a new pattern, preserving the original line's
1845/// keyword form (`Host` vs `HOST`, with or without `=`), separator (space vs
1846/// tab) and trailing inline comment. Used by delete-token and rename paths
1847/// so that an unrelated edit on a multi-alias block never silently drops the
1848/// inline comment or tab style the user typed.
1849///
1850/// Falls back to `format!("Host {}", new_pattern)` when the original line
1851/// is too short or malformed to deconstruct.
1852pub(super) fn rebuild_host_line(original: &str, new_pattern: &str) -> String {
1853    // Find the position of the inline comment (if any). Inline comments on
1854    // SSH config lines start with a `#` preceded by whitespace, OUTSIDE any
1855    // quoted string. This mirrors `strip_inline_comment` in parser.rs.
1856    let (body, suffix) = {
1857        let bytes = original.as_bytes();
1858        let mut in_quote = false;
1859        let mut comment_start: Option<usize> = None;
1860        for i in 0..bytes.len() {
1861            if bytes[i] == b'"' {
1862                in_quote = !in_quote;
1863            } else if !in_quote
1864                && bytes[i] == b'#'
1865                && i > 0
1866                && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t')
1867            {
1868                comment_start = Some(i - 1); // include the leading whitespace
1869                break;
1870            }
1871        }
1872        match comment_start {
1873            Some(idx) => (
1874                original[..idx].trim_end_matches([' ', '\t']),
1875                &original[idx..],
1876            ),
1877            None => (original.trim_end_matches([' ', '\t']), ""),
1878        }
1879    };
1880
1881    // Split body into keyword + separator + (existing pattern, which we drop).
1882    // Accept tab or space and optional `=`, matching parse_host_line.
1883    let bytes = body.as_bytes();
1884    if bytes.len() < 5 || !bytes[..4].eq_ignore_ascii_case(b"host") {
1885        return format!("Host {}", new_pattern);
1886    }
1887    let sep = bytes[4];
1888    if !sep.is_ascii_whitespace() && sep != b'=' {
1889        return format!("Host {}", new_pattern);
1890    }
1891
1892    // Preserve the original keyword casing (`Host` vs `HOST` vs `host`).
1893    let keyword = &body[..4];
1894
1895    // Capture the original separator span between keyword and pattern so a
1896    // tab-separated `Host\tweb-01` stays tab-separated and `Host=foo` stays
1897    // equals-separated.
1898    let after_keyword = &body[4..];
1899    let pattern_start = after_keyword
1900        .char_indices()
1901        .find(|(_, c)| !c.is_whitespace() && *c != '=')
1902        .map(|(i, _)| i)
1903        .unwrap_or(after_keyword.len());
1904    let separator = &after_keyword[..pattern_start];
1905
1906    format!("{}{}{}{}", keyword, separator, new_pattern, suffix)
1907}
1908
1909#[cfg(test)]
1910#[path = "model_tests.rs"]
1911mod tests;