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...";
102pub const ESC_QUIT_HINT: &str = "Nothing to cancel. Press q to quit.";
103
104pub fn included_file_edit(name: &str) -> String {
105 format!("{} is in an included file. Edit it there.", name)
106}
107
108pub fn included_file_delete(name: &str) -> String {
109 format!("{} is in an included file. Delete it there.", name)
110}
111
112pub fn included_file_clone(name: &str) -> String {
113 format!("{} is in an included file. Clone it there.", name)
114}
115
116pub fn included_host_lives_in(alias: &str, path: &impl std::fmt::Display) -> String {
117 format!("{} lives in {}. Edit it there.", alias, path)
118}
119
120pub fn included_host_clone_there(alias: &str, path: &impl std::fmt::Display) -> String {
121 format!("{} lives in {}. Clone it there.", alias, path)
122}
123
124pub fn included_host_tag_there(alias: &str, path: &impl std::fmt::Display) -> String {
125 format!("{} is included from {}. Tag it there.", alias, path)
126}
127
128pub const HOST_NOT_FOUND_IN_CONFIG: &str = "Host not found in config.";
129
130pub const SMART_PARSED: &str = "Smart-parsed that for you. Check the fields.";
133pub const LOOKS_LIKE_ADDRESS: &str = "Looks like an address. Suggested as Host.";
134
135pub fn goodbye_host(alias: &str) -> String {
138 format!("Goodbye, {}. We barely knew ye. (u to undo)", alias)
139}
140
141pub fn host_not_found(alias: &str) -> String {
142 format!("Host '{}' not found.", alias)
143}
144
145pub fn siblings_stripped(alias: &str, sibling_count: usize) -> String {
149 if sibling_count == 1 {
150 format!(
151 "Stripped {}. 1 sibling alias kept its shared config.",
152 alias
153 )
154 } else {
155 format!(
156 "Stripped {}. {} sibling aliases kept their shared config.",
157 alias, sibling_count
158 )
159 }
160}
161
162pub fn confirm_delete_siblings_note(siblings: &[String]) -> String {
166 let shown: Vec<&str> = siblings.iter().take(3).map(String::as_str).collect();
167 let tail = if siblings.len() > shown.len() {
168 format!(" +{} more", siblings.len() - shown.len())
169 } else {
170 String::new()
171 };
172 format!("Siblings kept: {}{}", shown.join(", "), tail)
173}
174
175pub fn cert_cleanup_warning(path: &impl std::fmt::Display, e: &impl std::fmt::Display) -> String {
176 format!("Warning: failed to clean up Vault SSH cert {}: {}", path, e)
177}
178
179pub const CLONED_VAULT_CLEARED: &str = "Cloned. Vault SSH role cleared on copy.";
182
183pub const TUNNEL_REMOVED: &str = "Tunnel removed.";
186pub const TUNNEL_SAVED: &str = "Tunnel saved.";
187pub const TUNNEL_NOT_FOUND: &str = "Tunnel not found in config.";
188pub const TUNNEL_INCLUDED_READ_ONLY: &str = "Included host. Tunnels are read-only.";
189pub const TUNNEL_ORIGINAL_NOT_FOUND: &str = "Original tunnel not found in config.";
190pub const TUNNEL_LIST_CHANGED: &str = "Tunnel list changed externally. Press Esc and re-open.";
191pub const TUNNEL_DUPLICATE: &str = "Duplicate tunnel already configured.";
192pub const TUNNEL_NO_EDITABLE_HOSTS: &str = "No editable hosts. Add a host first.";
193pub const TUNNEL_HOST_PICKER_NO_MATCH: &str = "No matches.";
194
195pub fn tunnel_stopped(alias: &str) -> String {
196 format!("Tunnel for {} stopped.", alias)
197}
198
199pub fn tunnel_started(alias: &str) -> String {
200 format!("Tunnel for {} started.", alias)
201}
202
203pub fn tunnel_start_failed(e: &impl std::fmt::Display) -> String {
204 format!("Failed to start tunnel: {}", e)
205}
206
207pub fn pinging_host(alias: &str, show_hint: bool) -> String {
210 if show_hint {
211 format!("Pinging {}... (Shift+P pings all)", alias)
212 } else {
213 format!("Pinging {}...", alias)
214 }
215}
216
217pub fn bastion_not_found(alias: &str) -> String {
218 format!("Bastion {} not found in config.", alias)
219}
220
221pub fn provider_removed(display_name: &str) -> String {
224 format!(
225 "Removed {} configuration. Synced hosts remain in your SSH config.",
226 display_name
227 )
228}
229
230pub fn label_invalid(reason: &str) -> String {
231 format!("Invalid name: {}", reason)
232}
233
234pub const LABEL_MUST_DIFFER: &str = "The two names must be different.";
235
236pub const LABEL_MIGRATION_FIELD_CURRENT: &str = " Name for your current config ";
237pub const LABEL_MIGRATION_FIELD_NEW: &str = " Name for the new config ";
238
239pub fn confirm_remove_provider(display: &str) -> String {
240 format!(" Remove {}? ", display)
241}
242
243pub fn confirm_remove_labeled_config(display: &str, label: &str) -> String {
244 format!(" Remove {} ({})? ", display, label)
245}
246
247pub const EXPAND_TO_REMOVE_CONFIG: &str =
248 "Expand the provider and pick a specific config to remove.";
249
250pub fn provider_not_configured(display_name: &str) -> String {
251 format!("{} is not configured. Nothing to remove.", display_name)
252}
253
254pub fn provider_configure_first(display_name: &str) -> String {
255 format!("Configure {} first. Press Enter to set up.", display_name)
256}
257
258pub fn provider_saved_syncing(display_name: &str) -> String {
259 format!("Saved {} configuration. Syncing...", display_name)
260}
261
262pub fn provider_saved(display_name: &str) -> String {
263 format!("Saved {} configuration.", display_name)
264}
265
266pub fn no_stale_hosts_for(display_name: &str) -> String {
267 format!("No stale hosts for {}.", display_name)
268}
269
270pub fn contains_control_chars(name: &str) -> String {
271 format!("{} contains control characters.", name)
272}
273
274pub const TOKEN_FORMAT_AWS: &str = "Token format: AccessKeyId:SecretAccessKey";
275pub const URL_REQUIRED_PROXMOX: &str = "URL is required for Proxmox VE.";
276pub const PROJECT_REQUIRED_GCP: &str = "Project ID can't be empty. Set your GCP project ID.";
277pub const COMPARTMENT_REQUIRED_OCI: &str =
278 "Compartment can't be empty. Set your OCI compartment OCID.";
279pub const REGIONS_REQUIRED_AWS: &str = "Select at least one AWS region.";
280pub const ZONES_REQUIRED_SCALEWAY: &str = "Select at least one Scaleway zone.";
281pub const SUBSCRIPTIONS_REQUIRED_AZURE: &str = "Enter at least one Azure subscription ID.";
282pub const ALIAS_PREFIX_INVALID: &str =
283 "Alias prefix can't contain spaces or pattern characters (*, ?, [, !).";
284pub const USER_NO_WHITESPACE: &str = "User can't contain whitespace.";
285pub const VAULT_ROLE_FORMAT: &str = "Vault SSH role must be in the form <mount>/sign/<role>.";
286
287pub const PROVIDER_CONFIG_CHANGED_EXTERNALLY: &str =
288 "Provider config changed externally. Press Esc and re-open to pick up changes.";
289pub const PROVIDER_URL_REQUIRES_HTTPS: &str =
290 "URL must start with https://. Toggle Verify TLS off for self-signed certificates.";
291pub const PROVIDER_TOKEN_REQUIRED_GCP: &str =
292 "Token can't be empty. Provide a service account JSON key file path or access token.";
293pub const PROVIDER_TOKEN_REQUIRED_ORACLE: &str =
294 "Token can't be empty. Provide the path to your OCI config file (e.g. ~/.oci/config).";
295
296pub fn provider_token_required(display_name: &str) -> String {
297 format!(
298 "Token can't be empty. Grab one from your {} dashboard.",
299 display_name
300 )
301}
302
303pub fn azure_subscription_id_invalid(sub: &str) -> String {
304 format!(
305 "Invalid subscription ID '{}'. Expected UUID format \
306 (e.g. 12345678-1234-1234-1234-123456789012).",
307 sub
308 )
309}
310
311pub const VAULT_SIGNING_CANCELLED: &str = "Vault SSH signing cancelled.";
314
315pub fn vault_signing_aborted(failed: u32, last_error: Option<&str>) -> String {
320 format!(
321 "Vault SSH signing aborted after {} consecutive failures. Press V to retry. Last error: {}",
322 failed,
323 last_error.unwrap_or("unknown")
324 )
325}
326
327pub fn bulk_tag_apply_status(
335 changed_hosts: usize,
336 added: usize,
337 removed: usize,
338 skipped_included: usize,
339) -> String {
340 let mut parts: Vec<String> = Vec::new();
341 if changed_hosts > 0 {
342 let host_word = if changed_hosts == 1 { "" } else { "s" };
343 let mut head = format!("Updated {} host{}", changed_hosts, host_word);
344 let mut delta = Vec::new();
345 if added > 0 {
346 delta.push(format!("+{}", added));
347 }
348 if removed > 0 {
349 delta.push(format!("-{}", removed));
350 }
351 if !delta.is_empty() {
352 head = format!("{} ({})", head, delta.join(" "));
353 }
354 parts.push(head);
355 }
356 if skipped_included > 0 {
357 let file_word = if skipped_included == 1 { "" } else { "s" };
358 parts.push(format!(
359 "skipped {} in include file{}",
360 skipped_included, file_word
361 ));
362 }
363 parts.join(". ")
364}
365
366pub fn vault_sign_summary(
367 signed: u32,
368 failed: u32,
369 skipped: u32,
370 first_error: Option<&str>,
371) -> String {
372 let total = signed + failed + skipped;
373 let cert_word = if total == 1 {
374 "certificate"
375 } else {
376 "certificates"
377 };
378 if failed > 0 {
379 if let Some(err) = first_error {
380 if total == 1 {
381 return err.to_string();
382 }
383 format!(
384 "Signed {} of {} {}. {} failed: {}",
385 signed, total, cert_word, failed, err
386 )
387 } else {
388 format!(
389 "Signed {} of {} {}. {} failed",
390 signed, total, cert_word, failed
391 )
392 }
393 } else if skipped > 0 && signed == 0 {
394 format!(
395 "All {} {} already valid. Nothing to sign.",
396 total, cert_word
397 )
398 } else if skipped > 0 {
399 format!(
400 "Signed {} of {} {}. {} already valid.",
401 signed, total, cert_word, skipped
402 )
403 } else {
404 format!("Signed {} of {} {}.", signed, total, cert_word)
405 }
406}
407pub const VAULT_NO_ROLE_CONFIGURED: &str = "No Vault SSH role configured. Set one in the host form \
408 (Vault SSH role field) or on a provider for shared defaults.";
409pub const VAULT_NO_HOSTS_WITH_ROLE: &str = "No hosts with a Vault SSH role configured.";
410pub const VAULT_ALL_CERTS_VALID: &str = "All Vault SSH certificates are still valid.";
411pub const VAULT_NO_ADDRESS: &str = "No Vault address set. Edit the host (e) or provider \
412 and fill in the Vault SSH Address field.";
413
414pub fn vault_error(msg: &str) -> String {
415 format!("Vault SSH: {}", msg)
416}
417
418pub fn vault_signed(alias: &str) -> String {
419 format!("Signed Vault SSH cert for {}", alias)
420}
421
422pub fn vault_sign_failed(alias: &str, message: &str) -> String {
423 format!("Vault SSH: failed to sign {}: {}", alias, message)
424}
425
426pub fn vault_signing_progress(spinner: &str, done: usize, total: usize, alias: &str) -> String {
427 format!(
428 "{} Signing {}/{}: {} (V to cancel)",
429 spinner, done, total, alias
430 )
431}
432
433pub fn vault_cert_saved_host_gone(alias: &str) -> String {
434 format!(
435 "Vault SSH cert saved for {} but host no longer in config \
436 (renamed or deleted). CertificateFile NOT written.",
437 alias
438 )
439}
440
441pub fn vault_spawn_failed(e: &impl std::fmt::Display) -> String {
442 format!("Vault SSH: failed to spawn signing thread: {}", e)
443}
444
445pub fn vault_cert_check_failed(alias: &str, message: &str) -> String {
446 format!("Cert check failed for {}: {}", alias, message)
447}
448
449pub fn vault_role_set(role: &str) -> String {
450 format!("Vault SSH role set to {}.", role)
451}
452
453pub fn vault_signed_pre_connect(alias: &str) -> String {
457 format!("Signed Vault SSH cert for {}.", alias)
458}
459
460pub fn vault_signed_pre_connect_chain(target: &str, count: usize) -> String {
465 if count <= 1 {
466 format!("Signed Vault SSH cert for {}.", target)
467 } else {
468 format!("Signed Vault SSH certs for {} ({} hosts).", target, count)
469 }
470}
471
472pub fn vault_sign_failed_pre_connect(alias: &str, message: &str) -> String {
477 format!("Vault SSH signing failed for {}: {}", alias, message)
478}
479
480pub fn vault_cert_pubkey_resolve_failed(e: &impl std::fmt::Display) -> String {
484 format!("Vault SSH cert failed: {}", e)
485}
486
487pub fn vault_cert_host_block_missing(alias: &str, cert_path: &std::path::Path) -> String {
492 format!(
493 "Warning: signed cert for {} but host block is no longer in ssh config; \
494 CertificateFile not written (cert saved to {})",
495 alias,
496 cert_path.display()
497 )
498}
499
500pub fn vault_cert_config_write_failed(alias: &str, e: &impl std::fmt::Display) -> String {
503 format!(
504 "Warning: signed cert for {} but failed to update SSH config CertificateFile: {}",
505 alias, e
506 )
507}
508
509pub fn snippet_removed(name: &str) -> String {
512 format!("Removed snippet '{}'.", name)
513}
514
515pub fn snippet_added(name: &str) -> String {
516 format!("Added snippet '{}'.", name)
517}
518
519pub fn snippet_updated(name: &str) -> String {
520 format!("Updated snippet '{}'.", name)
521}
522
523pub fn snippet_exists(name: &str) -> String {
524 format!("'{}' already exists.", name)
525}
526
527pub const OUTPUT_COPIED: &str = "Output copied.";
528
529pub fn copy_failed(e: &impl std::fmt::Display) -> String {
530 format!("Copy failed: {}", e)
531}
532
533pub fn clipboard_run_failed(cmd: &str) -> String {
539 format!("Failed to run {}.", cmd)
540}
541
542pub fn clipboard_write_failed(cmd: &str) -> String {
543 format!("Failed to write to {}.", cmd)
544}
545
546pub fn clipboard_wait_failed(cmd: &str) -> String {
547 format!("Failed to wait for {}.", cmd)
548}
549
550pub fn clipboard_exited_error(cmd: &str) -> String {
551 format!("{} exited with error.", cmd)
552}
553
554pub fn import_open_failed(path: &impl std::fmt::Display, e: &impl std::fmt::Display) -> String {
560 format!("Can't open {}: {}", path, e)
561}
562
563pub fn import_known_hosts_open_failed(e: &impl std::fmt::Display) -> String {
564 format!("Can't open known_hosts: {}", e)
565}
566
567pub const IMPORT_HOME_DIR_UNKNOWN: &str = "Could not determine home directory.";
568pub const IMPORT_KNOWN_HOSTS_MISSING: &str = "~/.ssh/known_hosts not found.";
569
570pub fn snippet_ssh_launch_failed(e: &impl std::fmt::Display) -> String {
573 format!("Failed to launch ssh: {}", e)
574}
575
576pub fn vault_create_dir_failed(path: &impl std::fmt::Display) -> String {
583 format!("Failed to create {}", path)
584}
585
586pub fn vault_write_cert_failed(path: &impl std::fmt::Display) -> String {
587 format!("Failed to write certificate to {}", path)
588}
589
590pub fn vault_ssh_keygen_run_failed(e: &impl std::fmt::Display) -> String {
591 format!("Failed to run ssh-keygen: {}", e)
592}
593
594pub const CONTAINER_ID_EMPTY: &str = "Container ID must not be empty.";
601pub const CONTAINER_RUNTIME_MISSING: &str = "No container runtime found. Install Docker or Podman.";
602
603pub fn container_id_invalid_char(c: char) -> String {
604 format!("Container ID contains invalid character: '{c}'")
605}
606
607pub fn container_unknown_sentinel(s: &str) -> String {
608 format!("Unknown sentinel: {s}")
609}
610
611pub fn scp_copying_one(source: &str) -> String {
614 format!("Copying {}...", source)
615}
616
617pub fn scp_copying_many(count: usize) -> String {
620 format!("Copying {} files...", count)
621}
622
623pub fn scp_failed_exit_code(code: i32) -> String {
626 format!("Copy failed (exit code {}).", code)
627}
628
629pub fn scp_spawn_failed(e: &impl std::fmt::Display) -> String {
633 format!("scp failed: {}", e)
634}
635
636pub const GLOBAL_DEFAULT_CLEARED: &str = "Global default cleared.";
639pub const PASSWORD_SOURCE_CLEARED: &str = "Password source cleared.";
640pub const ASKPASS_CUSTOM_COMMAND_HINT: &str =
641 "Type your command. Use %a (alias) and %h (hostname) as placeholders.";
642
643pub fn global_default_set(label: &str) -> String {
644 format!("Global default set to {}.", label)
645}
646
647pub fn password_source_set(label: &str) -> String {
648 format!("Password source set to {}.", label)
649}
650
651pub fn complete_path(label: &str) -> String {
652 format!("Complete the {} path.", label)
653}
654
655pub fn key_selected(name: &str) -> String {
656 format!("Locked and loaded with {}.", name)
657}
658
659pub fn proxy_jump_set(alias: &str) -> String {
660 format!("Jumping through {}.", alias)
661}
662
663pub fn save_default_failed(e: &impl std::fmt::Display) -> String {
664 format!("Failed to save default: {}", e)
665}
666
667pub fn container_action_complete(action: &str) -> String {
670 format!("Container {} complete.", action)
671}
672
673pub const HOST_KEY_UNKNOWN: &str = "Host key unknown. Connect first (Enter) to trust the host.";
674pub const HOST_KEY_CHANGED: &str =
675 "Host key changed. Possible tampering or server re-install. Clear with ssh-keygen -R.";
676
677pub const CONTAINER_RUNTIME_NOT_FOUND: &str = "Docker or Podman not found on remote host.";
681pub const CONTAINER_PERMISSION_DENIED: &str =
682 "Permission denied. Is your user in the docker group?";
683pub const CONTAINER_DAEMON_NOT_RUNNING: &str = "Container daemon is not running.";
684pub const CONTAINER_CONNECTION_REFUSED: &str = "Connection refused.";
685pub const CONTAINER_HOST_UNREACHABLE: &str = "Host unreachable.";
686
687pub fn container_command_failed(code: i32) -> String {
691 format!("Command failed with code {}.", code)
692}
693
694pub const CONTAINER_INSPECT_EMPTY: &str = "Inspect returned no data.";
696
697pub fn container_inspect_parse_failed(reason: &str) -> String {
699 format!("Inspect parse failed: {}", reason)
700}
701
702pub fn container_not_running(name: &str) -> String {
706 format!("{} is not running. Cannot exec.", name)
707}
708
709pub const DEMO_CONTAINER_EXEC_DISABLED: &str = "Demo mode: container exec disabled.";
711
712pub fn container_exec_opened_in_tmux(name: &str, alias: &str) -> String {
714 format!("Opened {} on {} in tmux window.", name, alias)
715}
716
717pub fn container_exec_ended(name: &str) -> String {
719 format!("Container shell ended: {}.", name)
720}
721
722pub fn container_exec_failed_with_reason(name: &str, reason: &str) -> String {
724 format!("Container exec failed for {}: {}", name, reason)
725}
726
727pub fn container_exec_exited_with_code(name: &str, code: i32) -> String {
729 format!("Container exec for {} exited with code {}.", name, code)
730}
731
732pub fn container_exec_spawn_failed(name: &str) -> String {
734 format!("Failed to launch ssh for container {}.", name)
735}
736
737pub const CONTAINER_EXEC_INVALID_COMMAND: &str =
739 "Command rejected: control characters not allowed.";
740
741pub const CONTAINER_LOGS_LOADING: &str = "fetching logs…";
745
746pub fn container_logs_fetched(secs_ago: u64) -> String {
749 format!(
750 "fetched {} ago",
751 crate::containers::format_uptime_short(secs_ago)
752 )
753}
754
755pub fn container_logs_failed(reason: &str) -> String {
757 format!("logs fetch failed: {}", reason)
758}
759
760pub const CONTAINER_RESTART_BODY: &str =
764 "Sends SIGTERM, waits 10s, then SIGKILL. Live connections will drop.";
765pub const CONTAINER_STOP_BODY: &str = "Sends SIGTERM, waits 10s, then SIGKILL. Container will not restart unless its policy reschedules it.";
766
767pub fn container_stack_unknown(name: &str) -> String {
770 format!("Stack unknown for {}: open the detail panel first.", name)
771}
772
773pub fn container_stack_no_running(project: &str) -> String {
774 format!("Stack {} has no running members to restart.", project)
775}
776
777pub const CONTAINER_STACK_RESTART_BODY: &str = "Restart cycles every running member one by one. Exited members are not touched. Live connections will drop.";
778
779pub const CONTAINER_HOST_RESTART_ALL_BODY: &str = "Restart cycles every running container on the host one by one. Exited containers are not touched. Live connections will drop.";
785
786pub const CONTAINER_HOST_STOP_ALL_BODY: &str = "Stops every running container on the host one by one. Exited containers are not touched. Restart policies may reschedule them.";
788
789pub fn container_action_needs_single(action: &str) -> String {
795 format!(
796 "{} need a single container. Place the cursor on a container row.",
797 action.to_lowercase()
798 )
799}
800
801pub fn container_host_no_running(alias: &str) -> String {
803 format!("No running containers on {}.", alias)
804}
805
806pub fn container_refreshing(alias: &str) -> String {
810 format!("Refreshing {}…", alias)
811}
812
813pub const REFRESH_BATCH_ALREADY_RUNNING: &str = "Refresh already in progress.";
815
816pub const REFRESH_NOTHING_TO_REFRESH: &str = "No cached hosts to refresh. Press 'a' to add a host.";
818
819pub fn container_refresh_progress(done: usize, total: usize) -> String {
821 format!("Refreshing {}/{} hosts…", done, total)
822}
823
824pub fn container_refresh_complete(total: usize) -> String {
826 format!(
827 "Refreshed {} host{}.",
828 total,
829 if total == 1 { "" } else { "s" }
830 )
831}
832
833pub const CONTAINER_HOST_PICKER_NO_MATCH: &str = "No hosts match.";
835
836pub const CONTAINER_HOST_PICKER_NOTHING_TO_ADD: &str =
838 "All hosts already cached. Use 'r' or 'R' to refresh.";
839
840pub fn imported_hosts(imported: usize, skipped: usize) -> String {
843 format!(
844 "Imported {} host{}, skipped {} duplicate{}.",
845 imported,
846 if imported == 1 { "" } else { "s" },
847 skipped,
848 if skipped == 1 { "" } else { "s" }
849 )
850}
851
852pub fn all_hosts_exist(skipped: usize) -> String {
853 if skipped == 1 {
854 "Host already exists.".to_string()
855 } else {
856 format!("All {} hosts already exist.", skipped)
857 }
858}
859
860pub fn config_repaired(groups: usize, orphaned: usize) -> String {
863 format!(
864 "Repaired SSH config ({} absorbed, {} orphaned group headers).",
865 groups, orphaned
866 )
867}
868
869pub fn no_exact_match(alias: &str) -> String {
870 format!("No exact match for '{}'. Here's what we found.", alias)
871}
872
873pub fn group_pref_reset_failed(e: &impl std::fmt::Display) -> String {
874 format!("Group preference reset. (save failed: {})", e)
875}
876
877pub fn opened_in_tmux(alias: &str) -> String {
880 format!("Opened {} in new tmux window.", alias)
881}
882
883pub fn tmux_error(e: &impl std::fmt::Display) -> String {
884 format!("tmux: {}", e)
885}
886
887pub fn connection_failed(alias: &str) -> String {
888 format!("Connection to {} failed.", alias)
889}
890
891pub fn connection_spawn_failed(e: &impl std::fmt::Display) -> String {
895 format!("Connection failed: {}", e)
896}
897
898pub fn ssh_failed_with_reason(alias: &str, reason: &str) -> String {
901 format!("SSH to {} failed. {}", alias, reason)
902}
903
904pub fn ssh_exited_with_code(alias: &str, code: i32) -> String {
907 format!("SSH to {} exited with code {}.", alias, code)
908}
909
910pub fn host_key_remove_failed(stderr: &str) -> String {
913 format!("Failed to remove host key: {}", stderr)
914}
915
916pub fn ssh_keygen_failed(e: &impl std::fmt::Display) -> String {
917 format!("Failed to run ssh-keygen: {}", e)
918}
919
920pub const TRANSFER_COMPLETE: &str = "Transfer complete.";
923
924pub fn provider_progress(spinner: &str, name: &str, message: &str) -> String {
933 format!("{} {}: {}", spinner, name, message)
934}
935
936pub const AGE_JUST_NOW: &str = "just now";
939
940pub fn relative_age(elapsed: std::time::Duration) -> String {
944 let secs = elapsed.as_secs();
945 if secs < 5 {
946 AGE_JUST_NOW.to_string()
947 } else if secs < 60 {
948 format!("{}s ago", secs)
949 } else if secs < 3600 {
950 format!("{}m ago", secs / 60)
951 } else if secs < 86400 {
952 format!("{}h ago", secs / 3600)
953 } else {
954 format!("{}d ago", secs / 86400)
955 }
956}
957
958pub fn vault_config_reapply_failed(signed: usize, e: &impl std::fmt::Display) -> String {
961 format!(
962 "External edits detected; signed {} certs but failed to re-apply CertificateFile: {}",
963 signed, e
964 )
965}
966
967pub fn vault_external_edits_merged(summary: &str, reapplied: usize) -> String {
968 format!(
969 "{} External ssh config edits detected, merged {} CertificateFile directives.",
970 summary, reapplied
971 )
972}
973
974pub fn vault_external_edits_no_write(summary: &str) -> String {
975 format!(
976 "{} External ssh config edits detected; certs on disk, no CertificateFile written.",
977 summary
978 )
979}
980
981pub fn vault_reparse_failed(signed: usize, e: &impl std::fmt::Display) -> String {
982 format!(
983 "Signed {} certs but cannot re-parse ssh config after external edit: {}. \
984 Certs are on disk under ~/.purple/certs/.",
985 signed, e
986 )
987}
988
989pub fn vault_config_update_failed(signed: usize, e: &impl std::fmt::Display) -> String {
990 format!(
991 "Signed {} certs but failed to update SSH config: {}",
992 signed, e
993 )
994}
995
996pub fn vault_config_write_after_sign(e: &impl std::fmt::Display) -> String {
997 format!("Failed to update config after vault signing: {}", e)
998}
999
1000pub fn removed_host_key(hostname: &str) -> String {
1005 format!("Removed host key for {}. Reconnecting...", hostname)
1006}
1007
1008pub fn tagged_host(alias: &str, count: usize) -> String {
1011 format!(
1012 "Tagged {} with {} label{}.",
1013 alias,
1014 count,
1015 if count == 1 { "" } else { "s" }
1016 )
1017}
1018
1019pub fn config_reloaded(count: usize) -> String {
1022 format!("Config reloaded. {} hosts.", count)
1023}
1024
1025pub fn synced_progress(
1041 spinner: &str,
1042 active_names: &str,
1043 done: usize,
1044 total: usize,
1045 added: usize,
1046 updated: usize,
1047 stale: usize,
1048) -> String {
1049 debug_assert!(
1050 !active_names.is_empty(),
1051 "synced_progress must only be called while a provider is still in flight"
1052 );
1053 let diff = sync_diff_suffix(added, updated, stale);
1054 format!(
1055 "{} Syncing {} \u{00B7} {}/{}{}",
1056 spinner, active_names, done, total, diff
1057 )
1058}
1059
1060pub fn synced_done(
1065 done: usize,
1066 total: usize,
1067 names: &str,
1068 added: usize,
1069 updated: usize,
1070 stale: usize,
1071) -> String {
1072 let diff = sync_diff_suffix(added, updated, stale);
1073 format!("Synced {}/{} \u{00B7} {}{}", done, total, names, diff)
1074}
1075
1076fn sync_diff_suffix(added: usize, updated: usize, stale: usize) -> String {
1077 let parts: Vec<String> = [(added, '+'), (updated, '~'), (stale, '-')]
1078 .iter()
1079 .filter(|(n, _)| *n > 0)
1080 .map(|(n, sign)| format!("{}{}", sign, n))
1081 .collect();
1082 if parts.is_empty() {
1083 String::new()
1084 } else {
1085 format!(" ({})", parts.join(" "))
1086 }
1087}
1088
1089pub const SYNC_THREAD_SPAWN_FAILED: &str = "Failed to start sync thread.";
1090
1091pub const SYNC_UNKNOWN_PROVIDER: &str = "Unknown provider.";
1092
1093pub fn vault_signing_cancelled_summary(
1096 signed: u32,
1097 failed: u32,
1098 first_error: Option<&str>,
1099) -> String {
1100 let mut msg = format!(
1101 "Vault SSH signing cancelled ({} signed, {} failed)",
1102 signed, failed
1103 );
1104 if let Some(err) = first_error {
1105 msg.push_str(": ");
1106 msg.push_str(err);
1107 }
1108 msg
1109}
1110
1111pub fn regions_selected_count(count: usize, label: &str) -> String {
1114 let s = if count == 1 { "" } else { "s" };
1115 format!("{} {}{} selected.", count, label, s)
1116}
1117
1118pub const NO_CLIPBOARD_TOOL: &str =
1123 "No clipboard tool found. Install pbcopy (macOS), wl-copy (Wayland), or xclip/xsel (X11).";
1124
1125pub const MCP_TOOL_DENIED_READ_ONLY: &str = "Tool denied. Server started with --read-only. Restart without --read-only to enable state-changing tools.";
1128
1129pub fn mcp_audit_init_failed(path: &impl std::fmt::Display, e: &impl std::fmt::Display) -> String {
1133 format!(
1134 "Failed to initialise MCP audit log at {}: {}. Continuing without audit logging.",
1135 path, e
1136 )
1137}
1138
1139pub fn mcp_audit_write_failed(e: &impl std::fmt::Display) -> String {
1141 format!("Failed to write MCP audit entry: {}", e)
1142}
1143
1144pub fn mcp_config_file_not_found(path: &impl std::fmt::Display) -> String {
1148 format!("SSH config file not found: {}", path)
1149}
1150
1151pub const MCP_AUDIT_HOME_DIR_UNAVAILABLE: &str = "Could not determine home directory; MCP audit log disabled. Set --audit-log <PATH> explicitly to enable auditing.";
1155
1156pub const PALETTE_PLACEHOLDER: &str = "Find anything";
1160pub const PALETTE_NO_RESULTS: &str = "No matches.";
1162pub const PALETTE_SNIPPET_NEEDS_HOST: &str =
1165 "Pick a host first, then run a snippet from the jump bar.";
1166pub fn jump_more_rows(n: usize) -> String {
1169 format!("+{n} more (scroll down)")
1170}
1171
1172#[path = "messages/cli.rs"]
1175pub mod cli;
1176pub mod footer;
1177
1178pub mod update {
1181 pub const WHATS_NEW_HINT: &str = "Press n inside purple to see what's new.";
1182 pub const DONE: &str = "done.";
1183 pub const CHECKSUM_OK: &str = "ok.";
1184 pub const SUDO_WARNING: &str =
1185 "Running via sudo. Consider fixing directory permissions instead.";
1186
1187 pub const STEP_CHECKING: &str = " Checking for updates... ";
1192 pub const STEP_VERIFYING_CHECKSUM: &str = " Verifying checksum... ";
1193 pub const STEP_INSTALLING: &str = " Installing... ";
1194
1195 pub fn already_on(current: &str) -> String {
1196 format!("already on v{} (latest).", current)
1197 }
1198
1199 pub fn available(latest: &str, current: &str) -> String {
1200 format!("v{} available (current: v{}).", latest, current)
1201 }
1202
1203 pub fn step_downloading(version: &str) -> String {
1207 format!(" Downloading v{}... ", version)
1208 }
1209
1210 pub fn sudo_warning_line(bold_bang: &str) -> String {
1214 format!(" {} {}", bold_bang, SUDO_WARNING)
1215 }
1216
1217 pub fn header(bold_name: &str) -> String {
1218 format!("\n {} updater\n", bold_name)
1219 }
1220
1221 pub fn binary_path(path: &std::path::Path) -> String {
1222 format!(" Binary: {}", path.display())
1223 }
1224
1225 pub fn installed_at(bold_version: &str, path: &std::path::Path) -> String {
1226 format!("\n {} installed at {}.", bold_version, path.display())
1227 }
1228
1229 pub fn whats_new_hint_indented() -> String {
1230 format!("\n {}", WHATS_NEW_HINT)
1231 }
1232}
1233
1234pub mod askpass {
1237 pub const BW_NOT_FOUND: &str = "Bitwarden CLI (bw) not found. SSH will prompt for password.";
1238 pub const BW_NOT_LOGGED_IN: &str = "Bitwarden vault not logged in. Run 'bw login' first.";
1239 pub const EMPTY_PASSWORD: &str = "Empty password. SSH will prompt for password.";
1240 pub const PASSWORD_IN_KEYCHAIN: &str = "Password stored in keychain.";
1241
1242 pub fn read_failed(e: &impl std::fmt::Display) -> String {
1243 format!("Failed to read password: {}", e)
1244 }
1245
1246 pub fn unlock_failed_retry(e: &impl std::fmt::Display) -> String {
1247 format!("Unlock failed: {}. Try again.", e)
1248 }
1249
1250 pub fn unlock_failed_prompt(e: &impl std::fmt::Display) -> String {
1251 format!("Unlock failed: {}. SSH will prompt for password.", e)
1252 }
1253
1254 pub fn password_prompt(alias: &str) -> String {
1258 format!("Password for {}: ", alias)
1259 }
1260
1261 pub fn keychain_password_prompt(alias: &str) -> String {
1264 format!("Password for {} (stored in keychain): ", alias)
1265 }
1266
1267 pub fn keychain_store_failed(e: &impl std::fmt::Display) -> String {
1270 format!(
1271 "Failed to store in keychain: {}. SSH will prompt for password.",
1272 e
1273 )
1274 }
1275}
1276
1277pub mod logging {
1280 pub fn init_failed(e: &impl std::fmt::Display) -> String {
1281 format!("[purple] Failed to initialize logger: {}", e)
1282 }
1283
1284 pub const SSH_VERSION_FAILED: &str = "[purple] Failed to detect SSH version. Is ssh installed?";
1285}
1286
1287pub mod hints {
1293 pub const IDENTITY_FILE_PICK: &str = "Space to pick a key";
1298 pub const DEFAULT_SSH_USER: &str = "root";
1299
1300 pub const HOST_ALIAS: &str = "e.g. prod or db-01";
1302 pub const HOST_ALIAS_PATTERN: &str = "10.0.0.* or *.example.com";
1303 pub const HOST_HOSTNAME: &str = "192.168.1.1 or example.com";
1304 pub const HOST_PORT: &str = "22";
1305 pub const HOST_PROXY_JUMP: &str = "Space to pick a host";
1306 pub const HOST_VAULT_SSH: &str = "e.g. ssh-client-signer/sign/my-role (auth via vault login)";
1307 pub const HOST_VAULT_SSH_PICKER: &str = "Space to pick a role or type one";
1308 pub const HOST_VAULT_ADDR: &str =
1309 "e.g. http://127.0.0.1:8200 (inherits from provider or env when empty)";
1310 pub const HOST_TAGS: &str = "e.g. prod, staging, us-east (comma-separated)";
1311 pub const HOST_ASKPASS_PICK: &str = "Space to pick a source";
1312
1313 pub fn askpass_default(default: &str) -> String {
1314 format!("default: {}", default)
1315 }
1316
1317 pub fn inherits_from(value: &str, provider: &str) -> String {
1318 format!("inherits {} from {}", value, provider)
1319 }
1320
1321 pub const TUNNEL_BIND_PORT: &str = "8080";
1323 pub const TUNNEL_REMOTE_HOST: &str = "localhost";
1324 pub const TUNNEL_REMOTE_PORT: &str = "80";
1325
1326 pub const SNIPPET_NAME: &str = "check-disk";
1328 pub const SNIPPET_COMMAND: &str = "df -h";
1329 pub const SNIPPET_OPTIONAL: &str = "(optional)";
1330
1331 pub const PROVIDER_URL: &str = "https://pve.example.com:8006";
1333 pub const PROVIDER_TOKEN_DEFAULT: &str = "your-api-token";
1334 pub const PROVIDER_TOKEN_PROXMOX: &str = "user@pam!token=secret";
1335 pub const PROVIDER_TOKEN_AWS: &str = "AccessKeyId:Secret (or use Profile)";
1336 pub const PROVIDER_TOKEN_GCP: &str = "/path/to/service-account.json (or access token)";
1337 pub const PROVIDER_TOKEN_AZURE: &str = "/path/to/service-principal.json (or access token)";
1338 pub const PROVIDER_TOKEN_TAILSCALE: &str = "API key (leave empty for local CLI)";
1339 pub const PROVIDER_TOKEN_ORACLE: &str = "~/.oci/config";
1340 pub const PROVIDER_TOKEN_OVH: &str = "app_key:app_secret:consumer_key";
1341 pub const PROVIDER_PROFILE: &str = "Name from ~/.aws/credentials (or use Token)";
1342 pub const PROVIDER_PROJECT_DEFAULT: &str = "my-gcp-project-id";
1343 pub const PROVIDER_PROJECT_OVH: &str = "Public Cloud project ID";
1344 pub const PROVIDER_COMPARTMENT: &str = "ocid1.compartment.oc1..aaaa...";
1345 pub const PROVIDER_REGIONS_DEFAULT: &str = "Space to select regions";
1346 pub const PROVIDER_REGIONS_GCP: &str = "Space to select zones (empty = all)";
1347 pub const PROVIDER_REGIONS_SCALEWAY: &str = "Space to select zones";
1348 pub const PROVIDER_REGIONS_AZURE: &str = "comma-separated subscription IDs";
1350 pub const PROVIDER_REGIONS_OVH: &str = "Space to select endpoint (default: EU)";
1351 pub const PROVIDER_USER_AWS: &str = "ec2-user";
1352 pub const PROVIDER_USER_GCP: &str = "ubuntu";
1353 pub const PROVIDER_USER_AZURE: &str = "azureuser";
1354 pub const PROVIDER_USER_ORACLE: &str = "opc";
1355 pub const PROVIDER_USER_OVH: &str = "ubuntu";
1356 pub const PROVIDER_VAULT_ROLE: &str =
1357 "e.g. ssh-client-signer/sign/my-role (vault login; inherited)";
1358 pub const PROVIDER_VAULT_ADDR: &str = "e.g. http://127.0.0.1:8200 (inherited by all hosts)";
1359 pub const PROVIDER_ALIAS_PREFIX_DEFAULT: &str = "prefix";
1360}
1361
1362#[cfg(test)]
1363mod hints_tests {
1364 use super::hints;
1365
1366 #[test]
1367 fn askpass_default_formats() {
1368 assert_eq!(hints::askpass_default("keychain"), "default: keychain");
1369 }
1370
1371 #[test]
1372 fn askpass_default_formats_empty() {
1373 assert_eq!(hints::askpass_default(""), "default: ");
1374 }
1375
1376 #[test]
1377 fn inherits_from_formats() {
1378 assert_eq!(
1379 hints::inherits_from("role/x", "aws"),
1380 "inherits role/x from aws"
1381 );
1382 }
1383
1384 #[test]
1385 fn picker_hints_mention_space_not_enter() {
1386 for s in [
1389 hints::IDENTITY_FILE_PICK,
1390 hints::HOST_PROXY_JUMP,
1391 hints::HOST_VAULT_SSH_PICKER,
1392 hints::HOST_ASKPASS_PICK,
1393 hints::PROVIDER_REGIONS_DEFAULT,
1394 hints::PROVIDER_REGIONS_GCP,
1395 hints::PROVIDER_REGIONS_SCALEWAY,
1396 hints::PROVIDER_REGIONS_OVH,
1397 ] {
1398 assert!(
1399 s.starts_with("Space "),
1400 "picker hint must mention Space: {s}"
1401 );
1402 assert!(!s.contains("Enter "), "picker hint must not say Enter: {s}");
1403 }
1404 }
1405}
1406
1407#[path = "messages/whats_new.rs"]
1408pub mod whats_new;
1409
1410#[path = "messages/whats_new_toast.rs"]
1411pub mod whats_new_toast;
1412
1413#[cfg(test)]
1414mod relative_age_tests {
1415 use super::relative_age;
1416 use std::time::Duration;
1417
1418 #[test]
1419 fn relative_age_boundaries() {
1420 assert_eq!(relative_age(Duration::from_secs(0)), "just now");
1421 assert_eq!(relative_age(Duration::from_secs(4)), "just now");
1422 assert_eq!(relative_age(Duration::from_secs(5)), "5s ago");
1423 assert_eq!(relative_age(Duration::from_secs(59)), "59s ago");
1424 assert_eq!(relative_age(Duration::from_secs(60)), "1m ago");
1425 assert_eq!(relative_age(Duration::from_secs(3599)), "59m ago");
1426 assert_eq!(relative_age(Duration::from_secs(3600)), "1h ago");
1427 assert_eq!(relative_age(Duration::from_secs(86399)), "23h ago");
1428 assert_eq!(relative_age(Duration::from_secs(86400)), "1d ago");
1429 assert_eq!(relative_age(Duration::from_secs(86400 * 7)), "7d ago");
1430 }
1431}