1pub const FAILED_TO_SAVE: &str = "Failed to save";
11pub fn failed_to_save(e: &impl std::fmt::Display) -> String {
12 format!("{}: {}", FAILED_TO_SAVE, e)
13}
14
15pub const CONFIG_CHANGED_EXTERNALLY: &str =
16 "Config changed externally. Press Esc and re-open to pick up changes.";
17
18pub const DEMO_CONNECTION_DISABLED: &str = "Demo mode. Connection disabled.";
21pub const DEMO_SYNC_DISABLED: &str = "Demo mode. Sync disabled.";
22pub const DEMO_TUNNELS_DISABLED: &str = "Demo mode. Tunnels disabled.";
23pub const DEMO_VAULT_SIGNING_DISABLED: &str = "Demo mode. Vault SSH signing disabled.";
24pub const DEMO_FILE_BROWSER_DISABLED: &str = "Demo mode. File browser disabled.";
25pub const DEMO_CONTAINER_REFRESH_DISABLED: &str = "Demo mode. Container refresh disabled.";
26pub const DEMO_CONTAINER_ACTIONS_DISABLED: &str = "Demo mode. Container actions disabled.";
27pub const DEMO_EXECUTION_DISABLED: &str = "Demo mode. Execution disabled.";
28pub const DEMO_PROVIDER_CHANGES_DISABLED: &str = "Demo mode. Provider config changes disabled.";
29
30pub fn stale_host(hint: &str) -> String {
33 format!("Stale host.{}", hint)
34}
35
36pub fn copied_ssh_command(alias: &str) -> String {
39 format!("Copied SSH command for {}.", alias)
40}
41
42pub fn copied_config_block(alias: &str) -> String {
43 format!("Copied config block for {}.", alias)
44}
45
46pub fn showing_unreachable(count: usize) -> String {
47 format!(
48 "Showing {} unreachable host{}.",
49 count,
50 if count == 1 { "" } else { "s" }
51 )
52}
53
54pub fn sorted_by(label: &str) -> String {
55 format!("Sorted by {}.", label)
56}
57
58pub fn sorted_by_save_failed(label: &str, e: &impl std::fmt::Display) -> String {
59 format!("Sorted by {}. (save failed: {})", label, e)
60}
61
62pub fn grouped_by(label: &str) -> String {
63 format!("Grouped by {}.", label)
64}
65
66pub fn grouped_by_save_failed(label: &str, e: &impl std::fmt::Display) -> String {
67 format!("Grouped by {}. (save failed: {})", label, e)
68}
69
70pub const UNGROUPED: &str = "Ungrouped.";
71
72pub fn ungrouped_save_failed(e: &impl std::fmt::Display) -> String {
73 format!("Ungrouped. (save failed: {})", e)
74}
75
76pub const GROUPED_BY_TAG: &str = "Grouped by tag.";
77
78pub fn grouped_by_tag_save_failed(e: &impl std::fmt::Display) -> String {
79 format!("Grouped by tag. (save failed: {})", e)
80}
81
82pub fn host_restored(alias: &str) -> String {
83 format!("{} is back from the dead.", alias)
84}
85
86pub fn restored_tags(count: usize) -> String {
87 format!(
88 "Restored tags on {} host{}.",
89 count,
90 if count == 1 { "" } else { "s" }
91 )
92}
93
94pub const NOTHING_TO_UNDO: &str = "Nothing to undo.";
95pub const NO_IMPORTABLE_HOSTS: &str = "No importable hosts in known_hosts.";
96pub const NO_STALE_HOSTS: &str = "No stale hosts.";
97pub const NO_HOST_SELECTED: &str = "No host selected.";
98pub const NO_HOSTS_TO_RUN: &str = "No hosts to run on.";
99pub const NO_HOSTS_TO_TAG: &str = "No hosts to tag.";
100pub const PING_FIRST: &str = "Ping first (p/P), then filter with !.";
101pub const PINGING_ALL: &str = "Pinging all the things...";
102
103pub fn included_file_edit(name: &str) -> String {
104 format!("{} is in an included file. Edit it there.", name)
105}
106
107pub fn included_file_delete(name: &str) -> String {
108 format!("{} is in an included file. Delete it there.", name)
109}
110
111pub fn included_file_clone(name: &str) -> String {
112 format!("{} is in an included file. Clone it there.", name)
113}
114
115pub fn included_host_lives_in(alias: &str, path: &impl std::fmt::Display) -> String {
116 format!("{} lives in {}. Edit it there.", alias, path)
117}
118
119pub fn included_host_clone_there(alias: &str, path: &impl std::fmt::Display) -> String {
120 format!("{} lives in {}. Clone it there.", alias, path)
121}
122
123pub fn included_host_tag_there(alias: &str, path: &impl std::fmt::Display) -> String {
124 format!("{} is included from {}. Tag it there.", alias, path)
125}
126
127pub const HOST_NOT_FOUND_IN_CONFIG: &str = "Host not found in config.";
128
129pub const SMART_PARSED: &str = "Smart-parsed that for you. Check the fields.";
132pub const LOOKS_LIKE_ADDRESS: &str = "Looks like an address. Suggested as Host.";
133
134pub fn goodbye_host(alias: &str) -> String {
137 format!("Goodbye, {}. We barely knew ye. (u to undo)", alias)
138}
139
140pub fn host_not_found(alias: &str) -> String {
141 format!("Host '{}' not found.", alias)
142}
143
144pub fn siblings_stripped(alias: &str, sibling_count: usize) -> String {
148 if sibling_count == 1 {
149 format!(
150 "Stripped {}. 1 sibling alias kept its shared config.",
151 alias
152 )
153 } else {
154 format!(
155 "Stripped {}. {} sibling aliases kept their shared config.",
156 alias, sibling_count
157 )
158 }
159}
160
161pub fn confirm_delete_siblings_note(siblings: &[String]) -> String {
165 let shown: Vec<&str> = siblings.iter().take(3).map(String::as_str).collect();
166 let tail = if siblings.len() > shown.len() {
167 format!(" +{} more", siblings.len() - shown.len())
168 } else {
169 String::new()
170 };
171 format!("Siblings kept: {}{}", shown.join(", "), tail)
172}
173
174pub fn cert_cleanup_warning(path: &impl std::fmt::Display, e: &impl std::fmt::Display) -> String {
175 format!("Warning: failed to clean up Vault SSH cert {}: {}", path, e)
176}
177
178pub const CLONED_VAULT_CLEARED: &str = "Cloned. Vault SSH role cleared on copy.";
181
182pub const TUNNEL_REMOVED: &str = "Tunnel removed.";
185pub const TUNNEL_SAVED: &str = "Tunnel saved.";
186pub const TUNNEL_NOT_FOUND: &str = "Tunnel not found in config.";
187pub const TUNNEL_INCLUDED_READ_ONLY: &str = "Included host. Tunnels are read-only.";
188pub const TUNNEL_ORIGINAL_NOT_FOUND: &str = "Original tunnel not found in config.";
189pub const TUNNEL_LIST_CHANGED: &str = "Tunnel list changed externally. Press Esc and re-open.";
190pub const TUNNEL_DUPLICATE: &str = "Duplicate tunnel already configured.";
191pub const TUNNEL_NO_EDITABLE_HOSTS: &str = "No editable hosts. Add a host first.";
192pub const TUNNEL_HOST_PICKER_NO_MATCH: &str = "No matches.";
193
194pub fn tunnel_stopped(alias: &str) -> String {
195 format!("Tunnel for {} stopped.", alias)
196}
197
198pub fn tunnel_started(alias: &str) -> String {
199 format!("Tunnel for {} started.", alias)
200}
201
202pub fn tunnel_start_failed(e: &impl std::fmt::Display) -> String {
203 format!("Failed to start tunnel: {}", e)
204}
205
206pub fn pinging_host(alias: &str, show_hint: bool) -> String {
209 if show_hint {
210 format!("Pinging {}... (Shift+P pings all)", alias)
211 } else {
212 format!("Pinging {}...", alias)
213 }
214}
215
216pub fn bastion_not_found(alias: &str) -> String {
217 format!("Bastion {} not found in config.", alias)
218}
219
220pub fn provider_removed(display_name: &str) -> String {
223 format!(
224 "Removed {} configuration. Synced hosts remain in your SSH config.",
225 display_name
226 )
227}
228
229pub fn provider_not_configured(display_name: &str) -> String {
230 format!("{} is not configured. Nothing to remove.", display_name)
231}
232
233pub fn provider_configure_first(display_name: &str) -> String {
234 format!("Configure {} first. Press Enter to set up.", display_name)
235}
236
237pub fn provider_saved_syncing(display_name: &str) -> String {
238 format!("Saved {} configuration. Syncing...", display_name)
239}
240
241pub fn provider_saved(display_name: &str) -> String {
242 format!("Saved {} configuration.", display_name)
243}
244
245pub fn no_stale_hosts_for(display_name: &str) -> String {
246 format!("No stale hosts for {}.", display_name)
247}
248
249pub fn contains_control_chars(name: &str) -> String {
250 format!("{} contains control characters.", name)
251}
252
253pub const TOKEN_FORMAT_AWS: &str = "Token format: AccessKeyId:SecretAccessKey";
254pub const URL_REQUIRED_PROXMOX: &str = "URL is required for Proxmox VE.";
255pub const PROJECT_REQUIRED_GCP: &str = "Project ID can't be empty. Set your GCP project ID.";
256pub const COMPARTMENT_REQUIRED_OCI: &str =
257 "Compartment can't be empty. Set your OCI compartment OCID.";
258pub const REGIONS_REQUIRED_AWS: &str = "Select at least one AWS region.";
259pub const ZONES_REQUIRED_SCALEWAY: &str = "Select at least one Scaleway zone.";
260pub const SUBSCRIPTIONS_REQUIRED_AZURE: &str = "Enter at least one Azure subscription ID.";
261pub const ALIAS_PREFIX_INVALID: &str =
262 "Alias prefix can't contain spaces or pattern characters (*, ?, [, !).";
263pub const USER_NO_WHITESPACE: &str = "User can't contain whitespace.";
264pub const VAULT_ROLE_FORMAT: &str = "Vault SSH role must be in the form <mount>/sign/<role>.";
265
266pub const PROVIDER_CONFIG_CHANGED_EXTERNALLY: &str =
267 "Provider config changed externally. Press Esc and re-open to pick up changes.";
268pub const PROVIDER_URL_REQUIRES_HTTPS: &str =
269 "URL must start with https://. Toggle Verify TLS off for self-signed certificates.";
270pub const PROVIDER_TOKEN_REQUIRED_GCP: &str =
271 "Token can't be empty. Provide a service account JSON key file path or access token.";
272pub const PROVIDER_TOKEN_REQUIRED_ORACLE: &str =
273 "Token can't be empty. Provide the path to your OCI config file (e.g. ~/.oci/config).";
274
275pub fn provider_token_required(display_name: &str) -> String {
276 format!(
277 "Token can't be empty. Grab one from your {} dashboard.",
278 display_name
279 )
280}
281
282pub fn azure_subscription_id_invalid(sub: &str) -> String {
283 format!(
284 "Invalid subscription ID '{}'. Expected UUID format \
285 (e.g. 12345678-1234-1234-1234-123456789012).",
286 sub
287 )
288}
289
290pub const VAULT_SIGNING_CANCELLED: &str = "Vault SSH signing cancelled.";
293
294pub fn vault_signing_aborted(failed: u32, last_error: Option<&str>) -> String {
299 format!(
300 "Vault SSH signing aborted after {} consecutive failures. Press V to retry. Last error: {}",
301 failed,
302 last_error.unwrap_or("unknown")
303 )
304}
305
306pub fn bulk_tag_apply_status(
314 changed_hosts: usize,
315 added: usize,
316 removed: usize,
317 skipped_included: usize,
318) -> String {
319 let mut parts: Vec<String> = Vec::new();
320 if changed_hosts > 0 {
321 let host_word = if changed_hosts == 1 { "" } else { "s" };
322 let mut head = format!("Updated {} host{}", changed_hosts, host_word);
323 let mut delta = Vec::new();
324 if added > 0 {
325 delta.push(format!("+{}", added));
326 }
327 if removed > 0 {
328 delta.push(format!("-{}", removed));
329 }
330 if !delta.is_empty() {
331 head = format!("{} ({})", head, delta.join(" "));
332 }
333 parts.push(head);
334 }
335 if skipped_included > 0 {
336 let file_word = if skipped_included == 1 { "" } else { "s" };
337 parts.push(format!(
338 "skipped {} in include file{}",
339 skipped_included, file_word
340 ));
341 }
342 parts.join(". ")
343}
344
345pub fn vault_sign_summary(
346 signed: u32,
347 failed: u32,
348 skipped: u32,
349 first_error: Option<&str>,
350) -> String {
351 let total = signed + failed + skipped;
352 let cert_word = if total == 1 {
353 "certificate"
354 } else {
355 "certificates"
356 };
357 if failed > 0 {
358 if let Some(err) = first_error {
359 if total == 1 {
360 return err.to_string();
361 }
362 format!(
363 "Signed {} of {} {}. {} failed: {}",
364 signed, total, cert_word, failed, err
365 )
366 } else {
367 format!(
368 "Signed {} of {} {}. {} failed",
369 signed, total, cert_word, failed
370 )
371 }
372 } else if skipped > 0 && signed == 0 {
373 format!(
374 "All {} {} already valid. Nothing to sign.",
375 total, cert_word
376 )
377 } else if skipped > 0 {
378 format!(
379 "Signed {} of {} {}. {} already valid.",
380 signed, total, cert_word, skipped
381 )
382 } else {
383 format!("Signed {} of {} {}.", signed, total, cert_word)
384 }
385}
386pub const VAULT_NO_ROLE_CONFIGURED: &str = "No Vault SSH role configured. Set one in the host form \
387 (Vault SSH role field) or on a provider for shared defaults.";
388pub const VAULT_NO_HOSTS_WITH_ROLE: &str = "No hosts with a Vault SSH role configured.";
389pub const VAULT_ALL_CERTS_VALID: &str = "All Vault SSH certificates are still valid.";
390pub const VAULT_NO_ADDRESS: &str = "No Vault address set. Edit the host (e) or provider \
391 and fill in the Vault SSH Address field.";
392
393pub fn vault_error(msg: &str) -> String {
394 format!("Vault SSH: {}", msg)
395}
396
397pub fn vault_signed(alias: &str) -> String {
398 format!("Signed Vault SSH cert for {}", alias)
399}
400
401pub fn vault_sign_failed(alias: &str, message: &str) -> String {
402 format!("Vault SSH: failed to sign {}: {}", alias, message)
403}
404
405pub fn vault_signing_progress(spinner: &str, done: usize, total: usize, alias: &str) -> String {
406 format!(
407 "{} Signing {}/{}: {} (V to cancel)",
408 spinner, done, total, alias
409 )
410}
411
412pub fn vault_cert_saved_host_gone(alias: &str) -> String {
413 format!(
414 "Vault SSH cert saved for {} but host no longer in config \
415 (renamed or deleted). CertificateFile NOT written.",
416 alias
417 )
418}
419
420pub fn vault_spawn_failed(e: &impl std::fmt::Display) -> String {
421 format!("Vault SSH: failed to spawn signing thread: {}", e)
422}
423
424pub fn vault_cert_check_failed(alias: &str, message: &str) -> String {
425 format!("Cert check failed for {}: {}", alias, message)
426}
427
428pub fn vault_role_set(role: &str) -> String {
429 format!("Vault SSH role set to {}.", role)
430}
431
432pub fn vault_signed_pre_connect(alias: &str) -> String {
436 format!("Signed Vault SSH cert for {}.", alias)
437}
438
439pub fn vault_signed_pre_connect_chain(target: &str, count: usize) -> String {
444 if count <= 1 {
445 format!("Signed Vault SSH cert for {}.", target)
446 } else {
447 format!("Signed Vault SSH certs for {} ({} hosts).", target, count)
448 }
449}
450
451pub fn vault_sign_failed_pre_connect(alias: &str, message: &str) -> String {
456 format!("Vault SSH signing failed for {}: {}", alias, message)
457}
458
459pub fn vault_cert_pubkey_resolve_failed(e: &impl std::fmt::Display) -> String {
463 format!("Vault SSH cert failed: {}", e)
464}
465
466pub fn vault_cert_host_block_missing(alias: &str, cert_path: &std::path::Path) -> String {
471 format!(
472 "Warning: signed cert for {} but host block is no longer in ssh config; \
473 CertificateFile not written (cert saved to {})",
474 alias,
475 cert_path.display()
476 )
477}
478
479pub fn vault_cert_config_write_failed(alias: &str, e: &impl std::fmt::Display) -> String {
482 format!(
483 "Warning: signed cert for {} but failed to update SSH config CertificateFile: {}",
484 alias, e
485 )
486}
487
488pub fn snippet_removed(name: &str) -> String {
491 format!("Removed snippet '{}'.", name)
492}
493
494pub fn snippet_added(name: &str) -> String {
495 format!("Added snippet '{}'.", name)
496}
497
498pub fn snippet_updated(name: &str) -> String {
499 format!("Updated snippet '{}'.", name)
500}
501
502pub fn snippet_exists(name: &str) -> String {
503 format!("'{}' already exists.", name)
504}
505
506pub const OUTPUT_COPIED: &str = "Output copied.";
507
508pub fn copy_failed(e: &impl std::fmt::Display) -> String {
509 format!("Copy failed: {}", e)
510}
511
512pub fn clipboard_run_failed(cmd: &str) -> String {
518 format!("Failed to run {}.", cmd)
519}
520
521pub fn clipboard_write_failed(cmd: &str) -> String {
522 format!("Failed to write to {}.", cmd)
523}
524
525pub fn clipboard_wait_failed(cmd: &str) -> String {
526 format!("Failed to wait for {}.", cmd)
527}
528
529pub fn clipboard_exited_error(cmd: &str) -> String {
530 format!("{} exited with error.", cmd)
531}
532
533pub fn import_open_failed(path: &impl std::fmt::Display, e: &impl std::fmt::Display) -> String {
539 format!("Can't open {}: {}", path, e)
540}
541
542pub fn import_known_hosts_open_failed(e: &impl std::fmt::Display) -> String {
543 format!("Can't open known_hosts: {}", e)
544}
545
546pub const IMPORT_HOME_DIR_UNKNOWN: &str = "Could not determine home directory.";
547pub const IMPORT_KNOWN_HOSTS_MISSING: &str = "~/.ssh/known_hosts not found.";
548
549pub fn snippet_ssh_launch_failed(e: &impl std::fmt::Display) -> String {
552 format!("Failed to launch ssh: {}", e)
553}
554
555pub fn vault_create_dir_failed(path: &impl std::fmt::Display) -> String {
562 format!("Failed to create {}", path)
563}
564
565pub fn vault_write_cert_failed(path: &impl std::fmt::Display) -> String {
566 format!("Failed to write certificate to {}", path)
567}
568
569pub fn vault_ssh_keygen_run_failed(e: &impl std::fmt::Display) -> String {
570 format!("Failed to run ssh-keygen: {}", e)
571}
572
573pub const CONTAINER_ID_EMPTY: &str = "Container ID must not be empty.";
580pub const CONTAINER_RUNTIME_MISSING: &str = "No container runtime found. Install Docker or Podman.";
581
582pub fn container_id_invalid_char(c: char) -> String {
583 format!("Container ID contains invalid character: '{c}'")
584}
585
586pub fn container_unknown_sentinel(s: &str) -> String {
587 format!("Unknown sentinel: {s}")
588}
589
590pub fn scp_copying_one(source: &str) -> String {
593 format!("Copying {}...", source)
594}
595
596pub fn scp_copying_many(count: usize) -> String {
599 format!("Copying {} files...", count)
600}
601
602pub fn scp_failed_exit_code(code: i32) -> String {
605 format!("Copy failed (exit code {}).", code)
606}
607
608pub fn scp_spawn_failed(e: &impl std::fmt::Display) -> String {
612 format!("scp failed: {}", e)
613}
614
615pub const GLOBAL_DEFAULT_CLEARED: &str = "Global default cleared.";
618pub const PASSWORD_SOURCE_CLEARED: &str = "Password source cleared.";
619pub const ASKPASS_CUSTOM_COMMAND_HINT: &str =
620 "Type your command. Use %a (alias) and %h (hostname) as placeholders.";
621
622pub fn global_default_set(label: &str) -> String {
623 format!("Global default set to {}.", label)
624}
625
626pub fn password_source_set(label: &str) -> String {
627 format!("Password source set to {}.", label)
628}
629
630pub fn complete_path(label: &str) -> String {
631 format!("Complete the {} path.", label)
632}
633
634pub fn key_selected(name: &str) -> String {
635 format!("Locked and loaded with {}.", name)
636}
637
638pub fn proxy_jump_set(alias: &str) -> String {
639 format!("Jumping through {}.", alias)
640}
641
642pub fn save_default_failed(e: &impl std::fmt::Display) -> String {
643 format!("Failed to save default: {}", e)
644}
645
646pub fn container_action_complete(action: &str) -> String {
649 format!("Container {} complete.", action)
650}
651
652pub const HOST_KEY_UNKNOWN: &str = "Host key unknown. Connect first (Enter) to trust the host.";
653pub const HOST_KEY_CHANGED: &str =
654 "Host key changed. Possible tampering or server re-install. Clear with ssh-keygen -R.";
655
656pub const CONTAINER_RUNTIME_NOT_FOUND: &str = "Docker or Podman not found on remote host.";
660pub const CONTAINER_PERMISSION_DENIED: &str =
661 "Permission denied. Is your user in the docker group?";
662pub const CONTAINER_DAEMON_NOT_RUNNING: &str = "Container daemon is not running.";
663pub const CONTAINER_CONNECTION_REFUSED: &str = "Connection refused.";
664pub const CONTAINER_HOST_UNREACHABLE: &str = "Host unreachable.";
665
666pub fn container_command_failed(code: i32) -> String {
670 format!("Command failed with code {}.", code)
671}
672
673pub fn imported_hosts(imported: usize, skipped: usize) -> String {
676 format!(
677 "Imported {} host{}, skipped {} duplicate{}.",
678 imported,
679 if imported == 1 { "" } else { "s" },
680 skipped,
681 if skipped == 1 { "" } else { "s" }
682 )
683}
684
685pub fn all_hosts_exist(skipped: usize) -> String {
686 if skipped == 1 {
687 "Host already exists.".to_string()
688 } else {
689 format!("All {} hosts already exist.", skipped)
690 }
691}
692
693pub fn config_repaired(groups: usize, orphaned: usize) -> String {
696 format!(
697 "Repaired SSH config ({} absorbed, {} orphaned group headers).",
698 groups, orphaned
699 )
700}
701
702pub fn no_exact_match(alias: &str) -> String {
703 format!("No exact match for '{}'. Here's what we found.", alias)
704}
705
706pub fn group_pref_reset_failed(e: &impl std::fmt::Display) -> String {
707 format!("Group preference reset. (save failed: {})", e)
708}
709
710pub fn opened_in_tmux(alias: &str) -> String {
713 format!("Opened {} in new tmux window.", alias)
714}
715
716pub fn tmux_error(e: &impl std::fmt::Display) -> String {
717 format!("tmux: {}", e)
718}
719
720pub fn connection_failed(alias: &str) -> String {
721 format!("Connection to {} failed.", alias)
722}
723
724pub fn connection_spawn_failed(e: &impl std::fmt::Display) -> String {
728 format!("Connection failed: {}", e)
729}
730
731pub fn ssh_failed_with_reason(alias: &str, reason: &str) -> String {
734 format!("SSH to {} failed. {}", alias, reason)
735}
736
737pub fn ssh_exited_with_code(alias: &str, code: i32) -> String {
740 format!("SSH to {} exited with code {}.", alias, code)
741}
742
743pub fn host_key_remove_failed(stderr: &str) -> String {
746 format!("Failed to remove host key: {}", stderr)
747}
748
749pub fn ssh_keygen_failed(e: &impl std::fmt::Display) -> String {
750 format!("Failed to run ssh-keygen: {}", e)
751}
752
753pub const TRANSFER_COMPLETE: &str = "Transfer complete.";
756
757pub fn provider_progress(spinner: &str, name: &str, message: &str) -> String {
766 format!("{} {}: {}", spinner, name, message)
767}
768
769pub const AGE_JUST_NOW: &str = "just now";
772
773pub fn relative_age(elapsed: std::time::Duration) -> String {
777 let secs = elapsed.as_secs();
778 if secs < 5 {
779 AGE_JUST_NOW.to_string()
780 } else if secs < 60 {
781 format!("{}s ago", secs)
782 } else if secs < 3600 {
783 format!("{}m ago", secs / 60)
784 } else if secs < 86400 {
785 format!("{}h ago", secs / 3600)
786 } else {
787 format!("{}d ago", secs / 86400)
788 }
789}
790
791pub fn vault_config_reapply_failed(signed: usize, e: &impl std::fmt::Display) -> String {
794 format!(
795 "External edits detected; signed {} certs but failed to re-apply CertificateFile: {}",
796 signed, e
797 )
798}
799
800pub fn vault_external_edits_merged(summary: &str, reapplied: usize) -> String {
801 format!(
802 "{} External ssh config edits detected, merged {} CertificateFile directives.",
803 summary, reapplied
804 )
805}
806
807pub fn vault_external_edits_no_write(summary: &str) -> String {
808 format!(
809 "{} External ssh config edits detected; certs on disk, no CertificateFile written.",
810 summary
811 )
812}
813
814pub fn vault_reparse_failed(signed: usize, e: &impl std::fmt::Display) -> String {
815 format!(
816 "Signed {} certs but cannot re-parse ssh config after external edit: {}. \
817 Certs are on disk under ~/.purple/certs/.",
818 signed, e
819 )
820}
821
822pub fn vault_config_update_failed(signed: usize, e: &impl std::fmt::Display) -> String {
823 format!(
824 "Signed {} certs but failed to update SSH config: {}",
825 signed, e
826 )
827}
828
829pub fn vault_config_write_after_sign(e: &impl std::fmt::Display) -> String {
830 format!("Failed to update config after vault signing: {}", e)
831}
832
833pub fn removed_host_key(hostname: &str) -> String {
838 format!("Removed host key for {}. Reconnecting...", hostname)
839}
840
841pub fn tagged_host(alias: &str, count: usize) -> String {
844 format!(
845 "Tagged {} with {} label{}.",
846 alias,
847 count,
848 if count == 1 { "" } else { "s" }
849 )
850}
851
852pub fn config_reloaded(count: usize) -> String {
855 format!("Config reloaded. {} hosts.", count)
856}
857
858pub fn synced_progress(
874 spinner: &str,
875 active_names: &str,
876 done: usize,
877 total: usize,
878 added: usize,
879 updated: usize,
880 stale: usize,
881) -> String {
882 debug_assert!(
883 !active_names.is_empty(),
884 "synced_progress must only be called while a provider is still in flight"
885 );
886 let diff = sync_diff_suffix(added, updated, stale);
887 format!(
888 "{} Syncing {} \u{00B7} {}/{}{}",
889 spinner, active_names, done, total, diff
890 )
891}
892
893pub fn synced_done(
898 done: usize,
899 total: usize,
900 names: &str,
901 added: usize,
902 updated: usize,
903 stale: usize,
904) -> String {
905 let diff = sync_diff_suffix(added, updated, stale);
906 format!("Synced {}/{} \u{00B7} {}{}", done, total, names, diff)
907}
908
909fn sync_diff_suffix(added: usize, updated: usize, stale: usize) -> String {
910 let parts: Vec<String> = [(added, '+'), (updated, '~'), (stale, '-')]
911 .iter()
912 .filter(|(n, _)| *n > 0)
913 .map(|(n, sign)| format!("{}{}", sign, n))
914 .collect();
915 if parts.is_empty() {
916 String::new()
917 } else {
918 format!(" ({})", parts.join(" "))
919 }
920}
921
922pub const SYNC_THREAD_SPAWN_FAILED: &str = "Failed to start sync thread.";
923
924pub const SYNC_UNKNOWN_PROVIDER: &str = "Unknown provider.";
925
926pub fn vault_signing_cancelled_summary(
929 signed: u32,
930 failed: u32,
931 first_error: Option<&str>,
932) -> String {
933 let mut msg = format!(
934 "Vault SSH signing cancelled ({} signed, {} failed)",
935 signed, failed
936 );
937 if let Some(err) = first_error {
938 msg.push_str(": ");
939 msg.push_str(err);
940 }
941 msg
942}
943
944pub fn regions_selected_count(count: usize, label: &str) -> String {
947 let s = if count == 1 { "" } else { "s" };
948 format!("{} {}{} selected.", count, label, s)
949}
950
951pub const NO_CLIPBOARD_TOOL: &str =
956 "No clipboard tool found. Install pbcopy (macOS), wl-copy (Wayland), or xclip/xsel (X11).";
957
958pub const MCP_TOOL_DENIED_READ_ONLY: &str = "Tool denied. Server started with --read-only. Restart without --read-only to enable state-changing tools.";
961
962pub fn mcp_audit_init_failed(path: &impl std::fmt::Display, e: &impl std::fmt::Display) -> String {
966 format!(
967 "Failed to initialise MCP audit log at {}: {}. Continuing without audit logging.",
968 path, e
969 )
970}
971
972pub fn mcp_audit_write_failed(e: &impl std::fmt::Display) -> String {
974 format!("Failed to write MCP audit entry: {}", e)
975}
976
977pub fn mcp_config_file_not_found(path: &impl std::fmt::Display) -> String {
981 format!("SSH config file not found: {}", path)
982}
983
984pub const MCP_AUDIT_HOME_DIR_UNAVAILABLE: &str = "Could not determine home directory; MCP audit log disabled. Set --audit-log <PATH> explicitly to enable auditing.";
988
989pub const PALETTE_PLACEHOLDER: &str = "Find anything";
993pub const PALETTE_NO_RESULTS: &str = "No matches.";
995pub const PALETTE_SNIPPET_NEEDS_HOST: &str =
998 "Pick a host first, then run a snippet from the jump bar.";
999pub fn jump_more_rows(n: usize) -> String {
1002 format!("+{n} more (scroll down)")
1003}
1004
1005#[path = "messages/cli.rs"]
1008pub mod cli;
1009
1010pub mod update {
1013 pub const WHATS_NEW_HINT: &str = "Press n inside purple to see what's new.";
1014 pub const DONE: &str = "done.";
1015 pub const CHECKSUM_OK: &str = "ok.";
1016 pub const SUDO_WARNING: &str =
1017 "Running via sudo. Consider fixing directory permissions instead.";
1018
1019 pub const STEP_CHECKING: &str = " Checking for updates... ";
1024 pub const STEP_VERIFYING_CHECKSUM: &str = " Verifying checksum... ";
1025 pub const STEP_INSTALLING: &str = " Installing... ";
1026
1027 pub fn already_on(current: &str) -> String {
1028 format!("already on v{} (latest).", current)
1029 }
1030
1031 pub fn available(latest: &str, current: &str) -> String {
1032 format!("v{} available (current: v{}).", latest, current)
1033 }
1034
1035 pub fn step_downloading(version: &str) -> String {
1039 format!(" Downloading v{}... ", version)
1040 }
1041
1042 pub fn sudo_warning_line(bold_bang: &str) -> String {
1046 format!(" {} {}", bold_bang, SUDO_WARNING)
1047 }
1048
1049 pub fn header(bold_name: &str) -> String {
1050 format!("\n {} updater\n", bold_name)
1051 }
1052
1053 pub fn binary_path(path: &std::path::Path) -> String {
1054 format!(" Binary: {}", path.display())
1055 }
1056
1057 pub fn installed_at(bold_version: &str, path: &std::path::Path) -> String {
1058 format!("\n {} installed at {}.", bold_version, path.display())
1059 }
1060
1061 pub fn whats_new_hint_indented() -> String {
1062 format!("\n {}", WHATS_NEW_HINT)
1063 }
1064}
1065
1066pub mod askpass {
1069 pub const BW_NOT_FOUND: &str = "Bitwarden CLI (bw) not found. SSH will prompt for password.";
1070 pub const BW_NOT_LOGGED_IN: &str = "Bitwarden vault not logged in. Run 'bw login' first.";
1071 pub const EMPTY_PASSWORD: &str = "Empty password. SSH will prompt for password.";
1072 pub const PASSWORD_IN_KEYCHAIN: &str = "Password stored in keychain.";
1073
1074 pub fn read_failed(e: &impl std::fmt::Display) -> String {
1075 format!("Failed to read password: {}", e)
1076 }
1077
1078 pub fn unlock_failed_retry(e: &impl std::fmt::Display) -> String {
1079 format!("Unlock failed: {}. Try again.", e)
1080 }
1081
1082 pub fn unlock_failed_prompt(e: &impl std::fmt::Display) -> String {
1083 format!("Unlock failed: {}. SSH will prompt for password.", e)
1084 }
1085
1086 pub fn password_prompt(alias: &str) -> String {
1090 format!("Password for {}: ", alias)
1091 }
1092
1093 pub fn keychain_password_prompt(alias: &str) -> String {
1096 format!("Password for {} (stored in keychain): ", alias)
1097 }
1098
1099 pub fn keychain_store_failed(e: &impl std::fmt::Display) -> String {
1102 format!(
1103 "Failed to store in keychain: {}. SSH will prompt for password.",
1104 e
1105 )
1106 }
1107}
1108
1109pub mod logging {
1112 pub fn init_failed(e: &impl std::fmt::Display) -> String {
1113 format!("[purple] Failed to initialize logger: {}", e)
1114 }
1115
1116 pub const SSH_VERSION_FAILED: &str = "[purple] Failed to detect SSH version. Is ssh installed?";
1117}
1118
1119pub mod hints {
1125 pub const IDENTITY_FILE_PICK: &str = "Space to pick a key";
1130 pub const DEFAULT_SSH_USER: &str = "root";
1131
1132 pub const HOST_ALIAS: &str = "e.g. prod or db-01";
1134 pub const HOST_ALIAS_PATTERN: &str = "10.0.0.* or *.example.com";
1135 pub const HOST_HOSTNAME: &str = "192.168.1.1 or example.com";
1136 pub const HOST_PORT: &str = "22";
1137 pub const HOST_PROXY_JUMP: &str = "Space to pick a host";
1138 pub const HOST_VAULT_SSH: &str = "e.g. ssh-client-signer/sign/my-role (auth via vault login)";
1139 pub const HOST_VAULT_SSH_PICKER: &str = "Space to pick a role or type one";
1140 pub const HOST_VAULT_ADDR: &str =
1141 "e.g. http://127.0.0.1:8200 (inherits from provider or env when empty)";
1142 pub const HOST_TAGS: &str = "e.g. prod, staging, us-east (comma-separated)";
1143 pub const HOST_ASKPASS_PICK: &str = "Space to pick a source";
1144
1145 pub fn askpass_default(default: &str) -> String {
1146 format!("default: {}", default)
1147 }
1148
1149 pub fn inherits_from(value: &str, provider: &str) -> String {
1150 format!("inherits {} from {}", value, provider)
1151 }
1152
1153 pub const TUNNEL_BIND_PORT: &str = "8080";
1155 pub const TUNNEL_REMOTE_HOST: &str = "localhost";
1156 pub const TUNNEL_REMOTE_PORT: &str = "80";
1157
1158 pub const SNIPPET_NAME: &str = "check-disk";
1160 pub const SNIPPET_COMMAND: &str = "df -h";
1161 pub const SNIPPET_OPTIONAL: &str = "(optional)";
1162
1163 pub const PROVIDER_URL: &str = "https://pve.example.com:8006";
1165 pub const PROVIDER_TOKEN_DEFAULT: &str = "your-api-token";
1166 pub const PROVIDER_TOKEN_PROXMOX: &str = "user@pam!token=secret";
1167 pub const PROVIDER_TOKEN_AWS: &str = "AccessKeyId:Secret (or use Profile)";
1168 pub const PROVIDER_TOKEN_GCP: &str = "/path/to/service-account.json (or access token)";
1169 pub const PROVIDER_TOKEN_AZURE: &str = "/path/to/service-principal.json (or access token)";
1170 pub const PROVIDER_TOKEN_TAILSCALE: &str = "API key (leave empty for local CLI)";
1171 pub const PROVIDER_TOKEN_ORACLE: &str = "~/.oci/config";
1172 pub const PROVIDER_TOKEN_OVH: &str = "app_key:app_secret:consumer_key";
1173 pub const PROVIDER_PROFILE: &str = "Name from ~/.aws/credentials (or use Token)";
1174 pub const PROVIDER_PROJECT_DEFAULT: &str = "my-gcp-project-id";
1175 pub const PROVIDER_PROJECT_OVH: &str = "Public Cloud project ID";
1176 pub const PROVIDER_COMPARTMENT: &str = "ocid1.compartment.oc1..aaaa...";
1177 pub const PROVIDER_REGIONS_DEFAULT: &str = "Space to select regions";
1178 pub const PROVIDER_REGIONS_GCP: &str = "Space to select zones (empty = all)";
1179 pub const PROVIDER_REGIONS_SCALEWAY: &str = "Space to select zones";
1180 pub const PROVIDER_REGIONS_AZURE: &str = "comma-separated subscription IDs";
1182 pub const PROVIDER_REGIONS_OVH: &str = "Space to select endpoint (default: EU)";
1183 pub const PROVIDER_USER_AWS: &str = "ec2-user";
1184 pub const PROVIDER_USER_GCP: &str = "ubuntu";
1185 pub const PROVIDER_USER_AZURE: &str = "azureuser";
1186 pub const PROVIDER_USER_ORACLE: &str = "opc";
1187 pub const PROVIDER_USER_OVH: &str = "ubuntu";
1188 pub const PROVIDER_VAULT_ROLE: &str =
1189 "e.g. ssh-client-signer/sign/my-role (vault login; inherited)";
1190 pub const PROVIDER_VAULT_ADDR: &str = "e.g. http://127.0.0.1:8200 (inherited by all hosts)";
1191 pub const PROVIDER_ALIAS_PREFIX_DEFAULT: &str = "prefix";
1192}
1193
1194#[cfg(test)]
1195mod hints_tests {
1196 use super::hints;
1197
1198 #[test]
1199 fn askpass_default_formats() {
1200 assert_eq!(hints::askpass_default("keychain"), "default: keychain");
1201 }
1202
1203 #[test]
1204 fn askpass_default_formats_empty() {
1205 assert_eq!(hints::askpass_default(""), "default: ");
1206 }
1207
1208 #[test]
1209 fn inherits_from_formats() {
1210 assert_eq!(
1211 hints::inherits_from("role/x", "aws"),
1212 "inherits role/x from aws"
1213 );
1214 }
1215
1216 #[test]
1217 fn picker_hints_mention_space_not_enter() {
1218 for s in [
1221 hints::IDENTITY_FILE_PICK,
1222 hints::HOST_PROXY_JUMP,
1223 hints::HOST_VAULT_SSH_PICKER,
1224 hints::HOST_ASKPASS_PICK,
1225 hints::PROVIDER_REGIONS_DEFAULT,
1226 hints::PROVIDER_REGIONS_GCP,
1227 hints::PROVIDER_REGIONS_SCALEWAY,
1228 hints::PROVIDER_REGIONS_OVH,
1229 ] {
1230 assert!(
1231 s.starts_with("Space "),
1232 "picker hint must mention Space: {s}"
1233 );
1234 assert!(!s.contains("Enter "), "picker hint must not say Enter: {s}");
1235 }
1236 }
1237}
1238
1239#[path = "messages/whats_new.rs"]
1240pub mod whats_new;
1241
1242#[path = "messages/whats_new_toast.rs"]
1243pub mod whats_new_toast;
1244
1245#[cfg(test)]
1246mod relative_age_tests {
1247 use super::relative_age;
1248 use std::time::Duration;
1249
1250 #[test]
1251 fn relative_age_boundaries() {
1252 assert_eq!(relative_age(Duration::from_secs(0)), "just now");
1253 assert_eq!(relative_age(Duration::from_secs(4)), "just now");
1254 assert_eq!(relative_age(Duration::from_secs(5)), "5s ago");
1255 assert_eq!(relative_age(Duration::from_secs(59)), "59s ago");
1256 assert_eq!(relative_age(Duration::from_secs(60)), "1m ago");
1257 assert_eq!(relative_age(Duration::from_secs(3599)), "59m ago");
1258 assert_eq!(relative_age(Duration::from_secs(3600)), "1h ago");
1259 assert_eq!(relative_age(Duration::from_secs(86399)), "23h ago");
1260 assert_eq!(relative_age(Duration::from_secs(86400)), "1d ago");
1261 assert_eq!(relative_age(Duration::from_secs(86400 * 7)), "7d ago");
1262 }
1263}