Skip to main content

purple_ssh/app/
hosts.rs

1//! Host CRUD operations. Implements `impl App` continuation with host add,
2//! edit, deletion, sync-result application, and the nearby selection helpers
3//! that skip group headers.
4
5use super::{GroupBy, HostListItem};
6use crate::app::App;
7use crate::ssh_config::model::HostEntry;
8
9impl App {
10    pub fn add_host_from_form(&mut self) -> Result<String, String> {
11        let entry = self.forms.host.to_entry();
12        let alias = entry.alias.clone();
13        let duplicate = if self.forms.host.is_pattern {
14            self.hosts_state.ssh_config.has_host_block(&alias)
15        } else {
16            self.hosts_state.ssh_config.has_host(&alias)
17        };
18        if duplicate {
19            return Err(if self.forms.host.is_pattern {
20                crate::messages::pattern_already_exists(&alias)
21            } else {
22                crate::messages::host_alias_already_exists(&alias)
23            });
24        }
25        let len_before = self.hosts_state.ssh_config.elements.len();
26        self.hosts_state.ssh_config.add_host(&entry);
27        if !entry.tags.is_empty() {
28            let tags_wired = self
29                .hosts_state
30                .ssh_config
31                .set_host_tags(&alias, &entry.tags);
32            debug_assert!(
33                tags_wired,
34                "add_host_from_form: alias '{}' missing immediately after add_host (set_host_tags)",
35                alias
36            );
37        }
38        if let Some(ref source) = entry.askpass {
39            let askpass_wired = self.hosts_state.ssh_config.set_host_askpass(&alias, source);
40            debug_assert!(
41                askpass_wired,
42                "add_host_from_form: alias '{}' missing immediately after add_host (set_host_askpass)",
43                alias
44            );
45        }
46        if let Some(ref role) = entry.vault_ssh {
47            // `set_host_vault_ssh` is `#[must_use]` since the multi-alias
48            // refuse-guard was added. The alias was upserted in `add_host`
49            // immediately above, so it MUST exist as a single-alias block
50            // here. Debug-assert the invariant to catch regressions early.
51            let role_wired = self.hosts_state.ssh_config.set_host_vault_ssh(&alias, role);
52            debug_assert!(
53                role_wired,
54                "add_host_from_form: alias '{}' missing immediately after upsert (set_host_vault_ssh)",
55                alias
56            );
57            // Persist the optional Vault address next to the role. `set_host_vault_addr`
58            // is `#[must_use]` but the alias was just upserted above so we only
59            // debug-assert the return value here (matches the CertificateFile pattern).
60            let addr = entry.vault_addr.as_deref().unwrap_or("");
61            let addr_wired = self
62                .hosts_state
63                .ssh_config
64                .set_host_vault_addr(&alias, addr);
65            debug_assert!(
66                addr_wired,
67                "add_host_from_form: alias '{}' missing immediately after upsert (set_host_vault_addr)",
68                alias
69            );
70            // For a brand-new host the only existing CertificateFile value can
71            // come from the form itself (a power user pasting one in). Honor
72            // the same invariant as edit_host_from_form: never overwrite a
73            // user-set custom path.
74            if crate::should_write_certificate_file(&entry.certificate_file) {
75                let cert_path = crate::vault_ssh::cert_path_for(self.env().paths(), &alias)
76                    .map_err(|e| crate::messages::cert_path_resolve_failed(&e))?;
77                // The host block was just upserted above, so the alias MUST
78                // exist. Assert the invariant to catch regressions early.
79                let wired = self
80                    .hosts_state
81                    .ssh_config
82                    .set_host_certificate_file(&alias, &cert_path.to_string_lossy());
83                debug_assert!(
84                    wired,
85                    "add_host_from_form: alias '{}' missing immediately after upsert",
86                    alias
87                );
88            }
89        }
90        if let Err(e) = self.hosts_state.ssh_config.write() {
91            self.hosts_state.ssh_config.elements.truncate(len_before);
92            log::warn!("[config] failed to save new host: alias={alias}: {e}");
93            return Err(crate::messages::failed_to_save(&e));
94        }
95        log::debug!(
96            "[purple] host added: alias={alias} is_pattern={}",
97            self.forms.host.is_pattern
98        );
99        // Form submit writes the full config including any pending vault mutations
100        self.vault.pending_config_write = false;
101        self.update_last_modified();
102        self.reload_hosts();
103        self.select_host_by_alias(&alias);
104        // Refresh the cert cache so the detail panel reflects reality
105        // immediately. No-op when the new host has no vault role or when
106        // running in demo mode.
107        self.refresh_cert_cache(&alias);
108        Ok(crate::messages::welcome_aboard(&alias))
109    }
110
111    /// Edit an existing host from the current form. Returns status message.
112    pub fn edit_host_from_form(&mut self, old_alias: &str) -> Result<String, String> {
113        let entry = self.forms.host.to_entry();
114        let alias = entry.alias.clone();
115        let exists = if self.forms.host.is_pattern {
116            self.hosts_state.ssh_config.has_host_block(old_alias)
117        } else {
118            self.hosts_state.ssh_config.has_host(old_alias)
119        };
120        if !exists {
121            return Err(if self.forms.host.is_pattern {
122                crate::messages::PATTERN_NO_LONGER_EXISTS.to_string()
123            } else {
124                crate::messages::HOST_NO_LONGER_EXISTS.to_string()
125            });
126        }
127        let duplicate = if self.forms.host.is_pattern {
128            alias != old_alias && self.hosts_state.ssh_config.has_host_block(&alias)
129        } else {
130            alias != old_alias && self.hosts_state.ssh_config.has_host(&alias)
131        };
132        if duplicate {
133            return Err(if self.forms.host.is_pattern {
134                crate::messages::pattern_already_exists(&alias)
135            } else {
136                crate::messages::host_alias_already_exists(&alias)
137            });
138        }
139        let old_entry = if self.forms.host.is_pattern {
140            self.hosts_state
141                .patterns
142                .iter()
143                .find(|p| p.pattern == old_alias)
144                .map(|p| HostEntry {
145                    alias: p.pattern.clone(),
146                    hostname: p.hostname.clone(),
147                    user: p.user.clone(),
148                    port: p.port,
149                    identity_file: p.identity_file.clone(),
150                    proxy_jump: p.proxy_jump.clone(),
151                    tags: p.tags.clone(),
152                    askpass: p.askpass.clone(),
153                    ..Default::default()
154                })
155                .unwrap_or_default()
156        } else {
157            self.hosts_state
158                .list
159                .iter()
160                .find(|h| h.alias == old_alias)
161                .cloned()
162                .unwrap_or_default()
163        };
164        self.hosts_state.ssh_config.update_host(old_alias, &entry);
165        // Patterns and concrete hosts both flow through here; tags/askpass
166        // setters refuse pattern blocks (per the symmetric multi-alias guard),
167        // so the boolean return is asserted only for non-pattern edits.
168        if !self.forms.host.is_pattern {
169            let tags_wired = self
170                .hosts_state
171                .ssh_config
172                .set_host_tags(&entry.alias, &entry.tags);
173            debug_assert!(
174                tags_wired,
175                "edit_host_from_form: alias '{}' missing immediately after update_host (set_host_tags)",
176                entry.alias
177            );
178            let askpass_wired = self
179                .hosts_state
180                .ssh_config
181                .set_host_askpass(&entry.alias, entry.askpass.as_deref().unwrap_or(""));
182            debug_assert!(
183                askpass_wired,
184                "edit_host_from_form: alias '{}' missing immediately after update_host (set_host_askpass)",
185                entry.alias
186            );
187        } else {
188            // Pattern blocks refuse purple metadata; this is the documented
189            // ExactAliasOnly policy. Drop the result explicitly.
190            let _ = self
191                .hosts_state
192                .ssh_config
193                .set_host_tags(&entry.alias, &entry.tags);
194            let _ = self
195                .hosts_state
196                .ssh_config
197                .set_host_askpass(&entry.alias, entry.askpass.as_deref().unwrap_or(""));
198        }
199        // `set_host_vault_ssh` refuses patterns and multi-alias blocks
200        // (same invariant as set_host_vault_addr / set_host_certificate_file)
201        // so we only call it for concrete host edits. Patterns never carry a
202        // vault role. For concrete hosts the alias was just updated above so
203        // the #[must_use] return is asserted in debug builds.
204        if !self.forms.host.is_pattern {
205            let role_wired = self
206                .hosts_state
207                .ssh_config
208                .set_host_vault_ssh(&entry.alias, entry.vault_ssh.as_deref().unwrap_or(""));
209            debug_assert!(
210                role_wired,
211                "edit_host_from_form: alias '{}' missing immediately after update_host (set_host_vault_ssh)",
212                entry.alias
213            );
214            let addr_wired = self
215                .hosts_state
216                .ssh_config
217                .set_host_vault_addr(&entry.alias, entry.vault_addr.as_deref().unwrap_or(""));
218            debug_assert!(
219                addr_wired,
220                "edit_host_from_form: alias '{}' missing immediately after update_host (set_host_vault_addr)",
221                entry.alias
222            );
223        }
224        // HostForm does not track CertificateFile, so the source of truth for
225        // the host's existing CertificateFile is `old_entry` (loaded from
226        // disk), not `entry` (rebuilt from the form, which always has it
227        // empty). Both branches below honor that distinction so a user-set
228        // custom CertificateFile is preserved across an edit.
229        if entry.vault_ssh.is_some() {
230            if crate::should_write_certificate_file(&old_entry.certificate_file) {
231                let cert_path = crate::vault_ssh::cert_path_for(self.env().paths(), &entry.alias)
232                    .map_err(|e| crate::messages::cert_path_resolve_failed(&e))?;
233                // Synchronous mutation: the host block was just updated, so
234                // the alias MUST exist. Assert the invariant.
235                let wired = self
236                    .hosts_state
237                    .ssh_config
238                    .set_host_certificate_file(&entry.alias, &cert_path.to_string_lossy());
239                debug_assert!(
240                    wired,
241                    "edit_host_from_form: alias '{}' missing immediately after update_host",
242                    entry.alias
243                );
244            }
245        } else {
246            // Vault SSH role removed: clear the CertificateFile only if it
247            // points at purple's managed cert path. A user-set custom path is
248            // left alone. Compare the expanded form on both sides so a
249            // tilde-relative directive (`~/.purple/certs/...`) and the
250            // absolute path produced by `cert_path_for` match.
251            let purple_managed =
252                crate::vault_ssh::cert_path_for(self.env().paths(), &entry.alias).ok();
253            let existing_resolved = if old_entry.certificate_file.is_empty() {
254                None
255            } else {
256                crate::vault_ssh::resolve_cert_path(
257                    self.env().paths(),
258                    &entry.alias,
259                    &old_entry.certificate_file,
260                )
261                .ok()
262            };
263            if purple_managed.is_some() && purple_managed == existing_resolved {
264                let _ = self
265                    .hosts_state
266                    .ssh_config
267                    .set_host_certificate_file(&entry.alias, "");
268            }
269        }
270        if let Err(e) = self.hosts_state.ssh_config.write() {
271            self.hosts_state
272                .ssh_config
273                .update_host(&entry.alias, &old_entry);
274            let _ = self
275                .hosts_state
276                .ssh_config
277                .set_host_tags(&old_entry.alias, &old_entry.tags);
278            let _ = self
279                .hosts_state
280                .ssh_config
281                .set_host_askpass(&old_entry.alias, old_entry.askpass.as_deref().unwrap_or(""));
282            if !self.forms.host.is_pattern {
283                let _ = self.hosts_state.ssh_config.set_host_vault_ssh(
284                    &old_entry.alias,
285                    old_entry.vault_ssh.as_deref().unwrap_or(""),
286                );
287                let _ = self.hosts_state.ssh_config.set_host_vault_addr(
288                    &old_entry.alias,
289                    old_entry.vault_addr.as_deref().unwrap_or(""),
290                );
291            }
292            if old_entry.vault_ssh.is_some() {
293                // Rollback restores the old host's actual CertificateFile
294                // value (which may be a user-set custom path), not purple's
295                // default. Falling back to the default would silently rewrite
296                // the directive on a write failure.
297                let _ = self
298                    .hosts_state
299                    .ssh_config
300                    .set_host_certificate_file(&old_entry.alias, &old_entry.certificate_file);
301            } else {
302                let _ = self
303                    .hosts_state
304                    .ssh_config
305                    .set_host_certificate_file(&old_entry.alias, "");
306            }
307            log::warn!(
308                "[config] failed to save host edit: alias={alias} old_alias={old_alias}: {e}"
309            );
310            return Err(crate::messages::failed_to_save(&e));
311        }
312        log::debug!(
313            "[purple] host edited: alias={alias} old_alias={old_alias} renamed={}",
314            alias != old_alias
315        );
316        // Form submit writes the full config including any pending vault mutations
317        self.vault.pending_config_write = false;
318        self.update_last_modified();
319        let renames: Vec<(String, String)> = if alias != old_alias {
320            vec![(old_alias.to_string(), alias.clone())]
321        } else {
322            Vec::new()
323        };
324        self.rename_aliases(&renames);
325        // cert_cache is intentionally NOT migrated by rename_aliases; clear
326        // the stale entry under the old alias and refresh under the new one
327        // so the detail panel reflects the freshly-signed cert (or the
328        // absence of a vault role) immediately.
329        if alias != old_alias {
330            self.vault.cert_cache.remove(old_alias);
331        }
332        self.refresh_cert_cache(&alias);
333        Ok(format!("{} got a makeover.", alias))
334    }
335
336    /// Apply a batch of `(old, new)` alias renames after the SSH config
337    /// has been written. Single entry point: orders cache migration,
338    /// stale-cert cleanup, reload and persistent-state migration so
339    /// callers cannot forget a step. Used by `submit_form` (host edit)
340    /// and provider sync. Empty `renames` collapses to a plain reload.
341    pub(crate) fn rename_aliases(&mut self, renames: &[(String, String)]) {
342        self.migrate_alias_keyed_caches(renames);
343        self.cleanup_stale_cert_files_for_renames(renames);
344        self.reload_hosts();
345        self.apply_alias_renames(renames);
346    }
347
348    /// Best-effort: remove on-disk Vault SSH cert files keyed under the
349    /// pre-rename alias. NotFound is fine (no cert was ever signed); any
350    /// other failure surfaces via `vault.cleanup_warning` so the status
351    /// bar shows it. Skipped in demo mode.
352    fn cleanup_stale_cert_files_for_renames(&mut self, renames: &[(String, String)]) {
353        if crate::demo_flag::is_demo() {
354            return;
355        }
356        for (old_alias, new_alias) in renames {
357            if old_alias == new_alias {
358                continue;
359            }
360            let Ok(old_cert) = crate::vault_ssh::cert_path_for(self.env().paths(), old_alias)
361            else {
362                continue;
363            };
364            match std::fs::remove_file(&old_cert) {
365                Ok(()) => {}
366                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
367                Err(e) => {
368                    self.vault.cleanup_warning = Some(format!(
369                        "Warning: failed to clean up old Vault SSH cert {}: {}",
370                        old_cert.display(),
371                        e
372                    ));
373                }
374            }
375        }
376    }
377
378    /// Migrate persistent per-host state (history, jump recents,
379    /// collapsed-fleet preference) and re-sort. Must run AFTER
380    /// `reload_hosts` so `apply_sort` sees the migrated history.
381    /// Production callers go through `rename_aliases`; this is
382    /// `pub(crate)` only to keep whitebox unit tests possible.
383    pub(crate) fn apply_alias_renames(&mut self, renames: &[(String, String)]) {
384        let mut applied = false;
385        let paths = self.env.paths().cloned();
386        for (old_alias, new_alias) in renames {
387            if old_alias == new_alias {
388                continue;
389            }
390            applied = true;
391            log::debug!("[purple] apply_alias_renames: {old_alias} -> {new_alias}");
392            self.history.rename(old_alias, new_alias);
393            let mut recents = crate::app::jump::load_recents(paths.as_ref());
394            if crate::app::jump::rename_host_recent(&mut recents, old_alias, new_alias)
395                && let Err(e) = crate::app::jump::save_recents(&recents, paths.as_ref())
396            {
397                log::warn!("[purple] failed to save recents after rename: {e}");
398            }
399        }
400        if applied {
401            self.apply_sort();
402        }
403    }
404
405    /// Move non-persistent alias-keyed caches and active tunnel handles
406    /// from `old` to `new`. Must run BEFORE `reload_hosts`, whose prune
407    /// step would otherwise drop entries still under the old alias.
408    /// `vault.cert_cache` is excluded: a rename invalidates the prior
409    /// cert path, so the caller refreshes it instead of migrating.
410    /// Production callers go through `rename_aliases`; this is
411    /// `pub(crate)` only to keep whitebox unit tests possible.
412    pub(crate) fn migrate_alias_keyed_caches(&mut self, renames: &[(String, String)]) {
413        let mut container_cache_changed = false;
414        let mut collapsed_hosts_changed = false;
415        for (old_alias, new_alias) in renames {
416            if old_alias == new_alias {
417                continue;
418            }
419            log::debug!("[purple] migrate_alias_keyed_caches: {old_alias} -> {new_alias}");
420            self.ping.migrate_alias(old_alias, new_alias);
421            if self.container_state.migrate_alias(old_alias, new_alias) {
422                container_cache_changed = true;
423            }
424            // collapsed_hosts (inside containers_overview) is persistent so
425            // the returned flag drives the save below. auto_list_in_flight
426            // and refresh_batch.in_flight_aliases are non-persistent and
427            // handled inside the same method.
428            if self.containers_overview.migrate_alias(old_alias, new_alias) {
429                collapsed_hosts_changed = true;
430            }
431            // Vault: cert_checks_in_flight and sign_in_flight move with
432            // the rename. cert_cache is intentionally excluded by
433            // `VaultState::migrate_alias` because the rename invalidates
434            // the prior cert path; the caller refreshes instead.
435            self.vault.migrate_alias(old_alias, new_alias);
436            self.tunnels.migrate_alias(old_alias, new_alias);
437            self.file_browser_state.migrate_alias(old_alias, new_alias);
438            // A password typed this session belongs to the machine, not to
439            // the name. Leaving it behind would hand it to whatever host
440            // takes the old alias next.
441            if let Some(password) = self.session_passwords.remove(old_alias) {
442                self.session_passwords.insert(new_alias.clone(), password);
443            }
444        }
445        if container_cache_changed {
446            crate::containers::save_container_cache(
447                self.env().paths(),
448                self.container_state.cache(),
449            );
450        }
451        if collapsed_hosts_changed
452            && let Err(e) = crate::preferences::save_containers_collapsed_hosts(
453                self.env().paths(),
454                self.containers_overview.collapsed_hosts(),
455            )
456        {
457            log::warn!("[config] failed to save collapsed_hosts after rename: {e}");
458        }
459    }
460
461    /// Select a host in the display list (or filtered list) by alias.
462    pub fn select_host_by_alias(&mut self, alias: &str) {
463        if self.search.query.is_some() {
464            // In search mode, list_state indexes into filtered_indices
465            for (i, &host_idx) in self.search.filtered_indices.iter().enumerate() {
466                if self
467                    .hosts_state
468                    .list
469                    .get(host_idx)
470                    .is_some_and(|h| h.alias == alias)
471                {
472                    self.ui.list_state.select(Some(i));
473                    return;
474                }
475            }
476            // Also check patterns in search results
477            let host_count = self.search.filtered_indices.len();
478            for (i, &pat_idx) in self.search.filtered_pattern_indices.iter().enumerate() {
479                if self
480                    .hosts_state
481                    .patterns
482                    .get(pat_idx)
483                    .is_some_and(|p| p.pattern == alias)
484                {
485                    self.ui.list_state.select(Some(host_count + i));
486                    return;
487                }
488            }
489        } else {
490            for (i, item) in self.hosts_state.display_list.iter().enumerate() {
491                match item {
492                    HostListItem::Host { index } => {
493                        if self
494                            .hosts_state
495                            .list
496                            .get(*index)
497                            .is_some_and(|h| h.alias == alias)
498                        {
499                            self.ui.list_state.select(Some(i));
500                            return;
501                        }
502                    }
503                    HostListItem::Pattern { index } => {
504                        if self
505                            .hosts_state
506                            .patterns
507                            .get(*index)
508                            .is_some_and(|p| p.pattern == alias)
509                        {
510                            self.ui.list_state.select(Some(i));
511                            return;
512                        }
513                    }
514                    HostListItem::GroupHeader(_) => {}
515                }
516            }
517        }
518    }
519
520    /// Apply sync results from a background provider fetch.
521    /// Returns (message, is_error, server_count, added, updated, stale). Caller must remove from syncing_providers.
522    ///
523    /// `provider` is the full ProviderConfigId display string (`do` for bare,
524    /// `do:work` for labeled). We look up by exact id so multi-config
525    /// providers route to the correct section.
526    pub fn apply_sync_result(
527        &mut self,
528        provider: &str,
529        hosts: Vec<crate::providers::ProviderHost>,
530        partial: bool,
531    ) -> (String, bool, usize, usize, usize, usize) {
532        let id: crate::providers::config::ProviderConfigId = match provider.parse() {
533            Ok(id) => id,
534            Err(_) => crate::providers::config::ProviderConfigId::bare(provider),
535        };
536        let section = match self.providers.config.section_by_id(&id).cloned() {
537            Some(s) => s,
538            None => {
539                log::warn!("[config] sync skipped: no config for provider={provider}");
540                return (
541                    format!(
542                        "{} sync skipped: no config.",
543                        crate::providers::provider_display_name(&id.provider)
544                    ),
545                    true,
546                    0,
547                    0,
548                    0,
549                    0,
550                );
551            }
552        };
553        let provider_impl = match crate::providers::get_provider_with_config(&section) {
554            Some(p) => p,
555            None => {
556                log::warn!("[config] sync skipped: unknown provider={provider}");
557                return (
558                    format!(
559                        "Unknown provider: {}.",
560                        crate::providers::provider_display_name(provider)
561                    ),
562                    true,
563                    0,
564                    0,
565                    0,
566                    0,
567                );
568            }
569        };
570        let config_backup = self.hosts_state.ssh_config.clone();
571        let result = crate::providers::sync::sync_provider(
572            &mut self.hosts_state.ssh_config,
573            &*provider_impl,
574            &hosts,
575            &section,
576            false,
577            partial, // suppress stale marking on partial failures
578            false,
579        );
580        let total = result.added + result.updated + result.unchanged;
581        if result.added > 0 || result.updated > 0 || result.stale > 0 {
582            // External-change guard: provider sync runs in the background
583            // (10-30s of network latency) and can race against a user editing
584            // ~/.ssh/config in another process. If the on-disk file changed
585            // since the in-memory model was loaded, refuse the write so we
586            // don't silently overwrite those edits. Roll back the in-memory
587            // sync mutations and surface the conflict; the user can re-run
588            // sync after reviewing their edits.
589            if self.external_config_changed() {
590                self.hosts_state.ssh_config = config_backup;
591                log::warn!(
592                    "[config] sync write refused: external config change for provider={provider}"
593                );
594                return (
595                    crate::messages::sync_skipped_external_change().to_string(),
596                    true,
597                    total,
598                    0,
599                    0,
600                    0,
601                );
602            }
603            if let Err(e) = self.hosts_state.ssh_config.write() {
604                self.hosts_state.ssh_config = config_backup;
605                log::warn!("[purple] sync write failed for provider={provider}, rolled back: {e}");
606                return (format!("Sync failed to save: {}", e), true, total, 0, 0, 0);
607            }
608            self.hosts_state.undo_stack.clear();
609            self.update_last_modified();
610            self.rename_aliases(&result.renames);
611        }
612        log::debug!(
613            "[purple] sync applied: provider={provider} added={} updated={} unchanged={} stale={} renames={}",
614            result.added,
615            result.updated,
616            result.unchanged,
617            result.stale,
618            result.renames.len()
619        );
620        let name = crate::providers::provider_display_name(provider);
621        let mut msg = format!(
622            "Synced {}: added {}, updated {}, unchanged {}",
623            name, result.added, result.updated, result.unchanged
624        );
625        if result.stale > 0 {
626            msg.push_str(&format!(", stale {}", result.stale));
627        }
628        msg.push('.');
629        (
630            msg,
631            false,
632            total,
633            result.added,
634            result.updated,
635            result.stale,
636        )
637    }
638
639    /// Clear group-by-tag if the tag no longer exists in any host.
640    /// Returns true if the tag was cleared.
641    pub fn clear_stale_group_tag(&mut self) -> bool {
642        if let GroupBy::Tag(ref tag) = self.hosts_state.group_by {
643            // Empty tag = "show all tags as tabs" mode, always valid
644            if tag.is_empty() {
645                return false;
646            }
647            let tag_exists = self
648                .hosts_state
649                .list
650                .iter()
651                .any(|h| h.tags.iter().any(|t| t == tag))
652                || self
653                    .hosts_state
654                    .patterns
655                    .iter()
656                    .any(|p| p.tags.iter().any(|t| t == tag));
657            if !tag_exists {
658                self.hosts_state.set_group_by(GroupBy::None);
659                return true;
660            }
661        }
662        false
663    }
664}
665
666/// File-level rename migration for the CLI `purple sync` subcommand,
667/// which writes the SSH config without an `App` in the picture and so
668/// cannot use `App::apply_alias_renames`. Performs the same persistent
669/// migrations: `~/.purple/history.tsv`, `~/.purple/recents.json`, and
670/// the `containers_collapsed_hosts` line in `~/.purple/preferences`.
671///
672/// Pairs where `old == new` are skipped so a caller can hand over the
673/// raw `SyncResult.renames` vec without filtering.
674///
675/// Errors during individual file writes are logged with `[config]` and
676/// the migration continues with the remaining state stores. Losing one
677/// store is a degradation; aborting the whole migration would leave the
678/// SSH config diverged from the on-disk per-host state stores.
679pub fn migrate_renames_persistent_state(
680    paths: Option<&crate::runtime::env::Paths>,
681    renames: &[(String, String)],
682) {
683    for (old_alias, new_alias) in renames {
684        if old_alias == new_alias {
685            continue;
686        }
687        // ConnectionHistory::rename calls save() internally.
688        let mut history = crate::history::ConnectionHistory::load(paths);
689        history.rename(old_alias, new_alias);
690
691        let mut recents = crate::app::jump::load_recents(paths);
692        if crate::app::jump::rename_host_recent(&mut recents, old_alias, new_alias)
693            && let Err(e) = crate::app::jump::save_recents(&recents, paths)
694        {
695            log::warn!("[purple] failed to save recents after cli sync rename: {e}");
696        }
697
698        let mut collapsed = crate::preferences::load_containers_collapsed_hosts(paths);
699        if collapsed.remove(old_alias) {
700            collapsed.insert(new_alias.clone());
701            if let Err(e) = crate::preferences::save_containers_collapsed_hosts(paths, &collapsed) {
702                log::warn!("[config] failed to save collapsed_hosts after cli sync rename: {e}");
703            }
704        }
705    }
706}