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