Skip to main content

purple_ssh/
messages.rs

1//! Centralized user-facing messages.
2//!
3//! Every string the user can see (toasts, CLI output, error messages) lives
4//! here. Handler, CLI and UI code reference these constants and functions
5//! instead of inlining string literals. This makes copy consistent, auditable
6//! and future-proof for i18n.
7
8// ── General / shared ────────────────────────────────────────────────
9
10pub 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
18// ── Demo mode ───────────────────────────────────────────────────────
19
20pub 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
30// ── Stale host ──────────────────────────────────────────────────────
31
32pub fn stale_host(hint: &str) -> String {
33    format!("Stale host.{}", hint)
34}
35
36// ── Host list ───────────────────────────────────────────────────────
37
38pub 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
129// ── Host form ───────────────────────────────────────────────────────
130
131pub 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
134// ── Confirm delete ──────────────────────────────────────────────────
135
136pub 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
144/// Toast after stripping an alias token from a shared `Host` line. Undo is
145/// not offered because re-inserting a whole block would not reverse a token
146/// strip (sibling aliases and their directives stay in place).
147pub 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
161/// One-line note rendered inside the confirm-delete dialog when the target
162/// alias shares its `Host` block with siblings. Explains that the other
163/// tokens survive.
164pub 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
178// ── Clone ───────────────────────────────────────────────────────────
179
180pub const CLONED_VAULT_CLEARED: &str = "Cloned. Vault SSH role cleared on copy.";
181
182// ── Tunnels ─────────────────────────────────────────────────────────
183
184pub 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
206// ── Ping ────────────────────────────────────────────────────────────
207
208pub 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
220// ── Providers ───────────────────────────────────────────────────────
221
222pub 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 label_invalid(reason: &str) -> String {
230    format!("Invalid name: {}", reason)
231}
232
233pub const LABEL_MUST_DIFFER: &str = "The two names must be different.";
234
235pub const LABEL_MIGRATION_FIELD_CURRENT: &str = " Name for your current config ";
236pub const LABEL_MIGRATION_FIELD_NEW: &str = " Name for the new config ";
237
238pub fn confirm_remove_provider(display: &str) -> String {
239    format!(" Remove {}? ", display)
240}
241
242pub fn confirm_remove_labeled_config(display: &str, label: &str) -> String {
243    format!(" Remove {} ({})? ", display, label)
244}
245
246pub const EXPAND_TO_REMOVE_CONFIG: &str =
247    "Expand the provider and pick a specific config to remove.";
248
249pub fn provider_not_configured(display_name: &str) -> String {
250    format!("{} is not configured. Nothing to remove.", display_name)
251}
252
253pub fn provider_configure_first(display_name: &str) -> String {
254    format!("Configure {} first. Press Enter to set up.", display_name)
255}
256
257pub fn provider_saved_syncing(display_name: &str) -> String {
258    format!("Saved {} configuration. Syncing...", display_name)
259}
260
261pub fn provider_saved(display_name: &str) -> String {
262    format!("Saved {} configuration.", display_name)
263}
264
265pub fn no_stale_hosts_for(display_name: &str) -> String {
266    format!("No stale hosts for {}.", display_name)
267}
268
269pub fn contains_control_chars(name: &str) -> String {
270    format!("{} contains control characters.", name)
271}
272
273pub const TOKEN_FORMAT_AWS: &str = "Token format: AccessKeyId:SecretAccessKey";
274pub const URL_REQUIRED_PROXMOX: &str = "URL is required for Proxmox VE.";
275pub const PROJECT_REQUIRED_GCP: &str = "Project ID can't be empty. Set your GCP project ID.";
276pub const COMPARTMENT_REQUIRED_OCI: &str =
277    "Compartment can't be empty. Set your OCI compartment OCID.";
278pub const REGIONS_REQUIRED_AWS: &str = "Select at least one AWS region.";
279pub const ZONES_REQUIRED_SCALEWAY: &str = "Select at least one Scaleway zone.";
280pub const SUBSCRIPTIONS_REQUIRED_AZURE: &str = "Enter at least one Azure subscription ID.";
281pub const ALIAS_PREFIX_INVALID: &str =
282    "Alias prefix can't contain spaces or pattern characters (*, ?, [, !).";
283pub const USER_NO_WHITESPACE: &str = "User can't contain whitespace.";
284pub const VAULT_ROLE_FORMAT: &str = "Vault SSH role must be in the form <mount>/sign/<role>.";
285
286pub const PROVIDER_CONFIG_CHANGED_EXTERNALLY: &str =
287    "Provider config changed externally. Press Esc and re-open to pick up changes.";
288pub const PROVIDER_URL_REQUIRES_HTTPS: &str =
289    "URL must start with https://. Toggle Verify TLS off for self-signed certificates.";
290pub const PROVIDER_TOKEN_REQUIRED_GCP: &str =
291    "Token can't be empty. Provide a service account JSON key file path or access token.";
292pub const PROVIDER_TOKEN_REQUIRED_ORACLE: &str =
293    "Token can't be empty. Provide the path to your OCI config file (e.g. ~/.oci/config).";
294
295pub fn provider_token_required(display_name: &str) -> String {
296    format!(
297        "Token can't be empty. Grab one from your {} dashboard.",
298        display_name
299    )
300}
301
302pub fn azure_subscription_id_invalid(sub: &str) -> String {
303    format!(
304        "Invalid subscription ID '{}'. Expected UUID format \
305         (e.g. 12345678-1234-1234-1234-123456789012).",
306        sub
307    )
308}
309
310// ── Vault SSH ───────────────────────────────────────────────────────
311
312pub const VAULT_SIGNING_CANCELLED: &str = "Vault SSH signing cancelled.";
313
314/// Sticky error shown when bulk signing hits 3 consecutive failures and
315/// gives up. `failed` is the running failure count; `last_error` carries
316/// the scrubbed Vault stderr so the user can act (run `vault login`,
317/// fix the address, etc.).
318pub fn vault_signing_aborted(failed: u32, last_error: Option<&str>) -> String {
319    format!(
320        "Vault SSH signing aborted after {} consecutive failures. Press V to retry. Last error: {}",
321        failed,
322        last_error.unwrap_or("unknown")
323    )
324}
325
326/// Status line shown after a bulk Vault SSH sign run completes. Combines
327/// signed/failed/skipped counters into one line, with the first error
328/// inlined when there's room. Single-host sign runs show only the error
329/// (no stats prefix) because the counter would just be noise.
330/// Status string shown after a successful bulk tag apply. Returns an
331/// empty string when nothing was changed and nothing was skipped, so the
332/// caller can detect a no-op and skip setting a status.
333pub fn bulk_tag_apply_status(
334    changed_hosts: usize,
335    added: usize,
336    removed: usize,
337    skipped_included: usize,
338) -> String {
339    let mut parts: Vec<String> = Vec::new();
340    if changed_hosts > 0 {
341        let host_word = if changed_hosts == 1 { "" } else { "s" };
342        let mut head = format!("Updated {} host{}", changed_hosts, host_word);
343        let mut delta = Vec::new();
344        if added > 0 {
345            delta.push(format!("+{}", added));
346        }
347        if removed > 0 {
348            delta.push(format!("-{}", removed));
349        }
350        if !delta.is_empty() {
351            head = format!("{} ({})", head, delta.join(" "));
352        }
353        parts.push(head);
354    }
355    if skipped_included > 0 {
356        let file_word = if skipped_included == 1 { "" } else { "s" };
357        parts.push(format!(
358            "skipped {} in include file{}",
359            skipped_included, file_word
360        ));
361    }
362    parts.join(". ")
363}
364
365pub fn vault_sign_summary(
366    signed: u32,
367    failed: u32,
368    skipped: u32,
369    first_error: Option<&str>,
370) -> String {
371    let total = signed + failed + skipped;
372    let cert_word = if total == 1 {
373        "certificate"
374    } else {
375        "certificates"
376    };
377    if failed > 0 {
378        if let Some(err) = first_error {
379            if total == 1 {
380                return err.to_string();
381            }
382            format!(
383                "Signed {} of {} {}. {} failed: {}",
384                signed, total, cert_word, failed, err
385            )
386        } else {
387            format!(
388                "Signed {} of {} {}. {} failed",
389                signed, total, cert_word, failed
390            )
391        }
392    } else if skipped > 0 && signed == 0 {
393        format!(
394            "All {} {} already valid. Nothing to sign.",
395            total, cert_word
396        )
397    } else if skipped > 0 {
398        format!(
399            "Signed {} of {} {}. {} already valid.",
400            signed, total, cert_word, skipped
401        )
402    } else {
403        format!("Signed {} of {} {}.", signed, total, cert_word)
404    }
405}
406pub const VAULT_NO_ROLE_CONFIGURED: &str = "No Vault SSH role configured. Set one in the host form \
407     (Vault SSH role field) or on a provider for shared defaults.";
408pub const VAULT_NO_HOSTS_WITH_ROLE: &str = "No hosts with a Vault SSH role configured.";
409pub const VAULT_ALL_CERTS_VALID: &str = "All Vault SSH certificates are still valid.";
410pub const VAULT_NO_ADDRESS: &str = "No Vault address set. Edit the host (e) or provider \
411     and fill in the Vault SSH Address field.";
412
413pub fn vault_error(msg: &str) -> String {
414    format!("Vault SSH: {}", msg)
415}
416
417pub fn vault_signed(alias: &str) -> String {
418    format!("Signed Vault SSH cert for {}", alias)
419}
420
421pub fn vault_sign_failed(alias: &str, message: &str) -> String {
422    format!("Vault SSH: failed to sign {}: {}", alias, message)
423}
424
425pub fn vault_signing_progress(spinner: &str, done: usize, total: usize, alias: &str) -> String {
426    format!(
427        "{} Signing {}/{}: {} (V to cancel)",
428        spinner, done, total, alias
429    )
430}
431
432pub fn vault_cert_saved_host_gone(alias: &str) -> String {
433    format!(
434        "Vault SSH cert saved for {} but host no longer in config \
435         (renamed or deleted). CertificateFile NOT written.",
436        alias
437    )
438}
439
440pub fn vault_spawn_failed(e: &impl std::fmt::Display) -> String {
441    format!("Vault SSH: failed to spawn signing thread: {}", e)
442}
443
444pub fn vault_cert_check_failed(alias: &str, message: &str) -> String {
445    format!("Cert check failed for {}: {}", alias, message)
446}
447
448pub fn vault_role_set(role: &str) -> String {
449    format!("Vault SSH role set to {}.", role)
450}
451
452/// Toast shown after a successful pre-connect signing for a single host.
453/// Distinct from `vault_signed` (used by bulk sign and form-submit) so the
454/// connect path can mention that the cert was signed *as part of* connecting.
455pub fn vault_signed_pre_connect(alias: &str) -> String {
456    format!("Signed Vault SSH cert for {}.", alias)
457}
458
459/// Toast shown after a successful pre-connect signing covered multiple
460/// chained hosts (target + ProxyJump hops). The `count` includes only hosts
461/// that actually got a fresh cert; hosts whose cert was already valid are
462/// excluded.
463pub fn vault_signed_pre_connect_chain(target: &str, count: usize) -> String {
464    if count <= 1 {
465        format!("Signed Vault SSH cert for {}.", target)
466    } else {
467        format!("Signed Vault SSH certs for {} ({} hosts).", target, count)
468    }
469}
470
471/// Toast shown when pre-connect signing failed for a host. Includes the
472/// scrubbed Vault error so the user can act (run `vault login`, fix the
473/// address, etc.). Distinct from `vault_sign_failed` so the wording can
474/// reflect the connect context without breaking bulk-sign callers.
475pub fn vault_sign_failed_pre_connect(alias: &str, message: &str) -> String {
476    format!("Vault SSH signing failed for {}: {}", alias, message)
477}
478
479/// Toast shown when resolving the public key path for a Vault sign call
480/// failed (missing pubkey, non-UTF8 path, etc.). Surfaced at the connect
481/// step before any Vault round-trip happens.
482pub fn vault_cert_pubkey_resolve_failed(e: &impl std::fmt::Display) -> String {
483    format!("Vault SSH cert failed: {}", e)
484}
485
486/// Stderr warning emitted when a cert was signed but the matching host
487/// block is no longer present (renamed or deleted between the connect
488/// keypress and the signing call). The cert is still written to disk;
489/// the user just has no `CertificateFile` directive pointing at it.
490pub fn vault_cert_host_block_missing(alias: &str, cert_path: &std::path::Path) -> String {
491    format!(
492        "Warning: signed cert for {} but host block is no longer in ssh config; \
493         CertificateFile not written (cert saved to {})",
494        alias,
495        cert_path.display()
496    )
497}
498
499/// Stderr warning emitted when the cert was signed but writing the
500/// updated SSH config back to disk failed.
501pub fn vault_cert_config_write_failed(alias: &str, e: &impl std::fmt::Display) -> String {
502    format!(
503        "Warning: signed cert for {} but failed to update SSH config CertificateFile: {}",
504        alias, e
505    )
506}
507
508// ── Snippets ────────────────────────────────────────────────────────
509
510pub fn snippet_removed(name: &str) -> String {
511    format!("Removed snippet '{}'.", name)
512}
513
514pub fn snippet_added(name: &str) -> String {
515    format!("Added snippet '{}'.", name)
516}
517
518pub fn snippet_updated(name: &str) -> String {
519    format!("Updated snippet '{}'.", name)
520}
521
522pub fn snippet_exists(name: &str) -> String {
523    format!("'{}' already exists.", name)
524}
525
526pub const OUTPUT_COPIED: &str = "Output copied.";
527
528pub fn copy_failed(e: &impl std::fmt::Display) -> String {
529    format!("Copy failed: {}", e)
530}
531
532// ── Clipboard subprocess errors ─────────────────────────────────────
533//
534// Surfaced when `pbcopy`/`xclip`/`wl-copy` fails to spawn, write to its
535// stdin, or be reaped. The cmd name is the binary the platform picked.
536
537pub fn clipboard_run_failed(cmd: &str) -> String {
538    format!("Failed to run {}.", cmd)
539}
540
541pub fn clipboard_write_failed(cmd: &str) -> String {
542    format!("Failed to write to {}.", cmd)
543}
544
545pub fn clipboard_wait_failed(cmd: &str) -> String {
546    format!("Failed to wait for {}.", cmd)
547}
548
549pub fn clipboard_exited_error(cmd: &str) -> String {
550    format!("{} exited with error.", cmd)
551}
552
553// ── Import errors ───────────────────────────────────────────────────
554//
555// Bubble up to the CLI via `eprintln!("{}", e)` when the user runs
556// `purple import` against a missing or unreadable file.
557
558pub fn import_open_failed(path: &impl std::fmt::Display, e: &impl std::fmt::Display) -> String {
559    format!("Can't open {}: {}", path, e)
560}
561
562pub fn import_known_hosts_open_failed(e: &impl std::fmt::Display) -> String {
563    format!("Can't open known_hosts: {}", e)
564}
565
566pub const IMPORT_HOME_DIR_UNKNOWN: &str = "Could not determine home directory.";
567pub const IMPORT_KNOWN_HOSTS_MISSING: &str = "~/.ssh/known_hosts not found.";
568
569// ── Snippet runner errors ───────────────────────────────────────────
570
571pub fn snippet_ssh_launch_failed(e: &impl std::fmt::Display) -> String {
572    format!("Failed to launch ssh: {}", e)
573}
574
575// ── Vault SSH library errors ────────────────────────────────────────
576//
577// Reach the user via the anyhow chain that `ensure_vault_ssh_chain_if_needed`
578// turns into a toast. `vault_create_dir_failed` and `vault_write_cert_failed`
579// are with_context strings, so they appear after a colon in the error chain.
580
581pub fn vault_create_dir_failed(path: &impl std::fmt::Display) -> String {
582    format!("Failed to create {}", path)
583}
584
585pub fn vault_write_cert_failed(path: &impl std::fmt::Display) -> String {
586    format!("Failed to write certificate to {}", path)
587}
588
589pub fn vault_ssh_keygen_run_failed(e: &impl std::fmt::Display) -> String {
590    format!("Failed to run ssh-keygen: {}", e)
591}
592
593// ── Container library errors ────────────────────────────────────────
594//
595// Validation (`validate_container_id`) errors propagate via the
596// `ContainerActionComplete` event and become toasts. The "no runtime"
597// and "unknown sentinel" lines surface in the same path.
598
599pub const CONTAINER_ID_EMPTY: &str = "Container ID must not be empty.";
600pub const CONTAINER_RUNTIME_MISSING: &str = "No container runtime found. Install Docker or Podman.";
601
602pub fn container_id_invalid_char(c: char) -> String {
603    format!("Container ID contains invalid character: '{c}'")
604}
605
606pub fn container_unknown_sentinel(s: &str) -> String {
607    format!("Unknown sentinel: {s}")
608}
609
610/// Transient label shown on the file browser overlay while an scp transfer
611/// is running. Singular form for a single source.
612pub fn scp_copying_one(source: &str) -> String {
613    format!("Copying {}...", source)
614}
615
616/// Transient label shown on the file browser overlay while an scp transfer
617/// is running. Plural form when multiple files were selected at once.
618pub fn scp_copying_many(count: usize) -> String {
619    format!("Copying {} files...", count)
620}
621
622/// Toast shown when scp exited non-zero with no captured stderr to relay.
623/// The exit code is the only signal we have left.
624pub fn scp_failed_exit_code(code: i32) -> String {
625    format!("Copy failed (exit code {}).", code)
626}
627
628/// Toast shown when the scp subprocess itself failed to spawn or wait
629/// (e.g. binary missing, signal interrupted), distinct from a non-zero
630/// exit which uses `scp_failed_exit_code`.
631pub fn scp_spawn_failed(e: &impl std::fmt::Display) -> String {
632    format!("scp failed: {}", e)
633}
634
635// ── Picker (password source, key, proxy) ────────────────────────────
636
637pub const GLOBAL_DEFAULT_CLEARED: &str = "Global default cleared.";
638pub const PASSWORD_SOURCE_CLEARED: &str = "Password source cleared.";
639pub const ASKPASS_CUSTOM_COMMAND_HINT: &str =
640    "Type your command. Use %a (alias) and %h (hostname) as placeholders.";
641
642pub fn global_default_set(label: &str) -> String {
643    format!("Global default set to {}.", label)
644}
645
646pub fn password_source_set(label: &str) -> String {
647    format!("Password source set to {}.", label)
648}
649
650pub fn complete_path(label: &str) -> String {
651    format!("Complete the {} path.", label)
652}
653
654pub fn key_selected(name: &str) -> String {
655    format!("Locked and loaded with {}.", name)
656}
657
658pub fn proxy_jump_set(alias: &str) -> String {
659    format!("Jumping through {}.", alias)
660}
661
662pub fn save_default_failed(e: &impl std::fmt::Display) -> String {
663    format!("Failed to save default: {}", e)
664}
665
666// ── Containers ──────────────────────────────────────────────────────
667
668pub fn container_action_complete(action: &str) -> String {
669    format!("Container {} complete.", action)
670}
671
672pub const HOST_KEY_UNKNOWN: &str = "Host key unknown. Connect first (Enter) to trust the host.";
673pub const HOST_KEY_CHANGED: &str =
674    "Host key changed. Possible tampering or server re-install. Clear with ssh-keygen -R.";
675
676// User-friendly classifications of stderr from a remote `docker ps` /
677// `podman ps`. The raw stderr is too technical and varies across
678// distros; these phrasings give the user the actionable next step.
679pub const CONTAINER_RUNTIME_NOT_FOUND: &str = "Docker or Podman not found on remote host.";
680pub const CONTAINER_PERMISSION_DENIED: &str =
681    "Permission denied. Is your user in the docker group?";
682pub const CONTAINER_DAEMON_NOT_RUNNING: &str = "Container daemon is not running.";
683pub const CONTAINER_CONNECTION_REFUSED: &str = "Connection refused.";
684pub const CONTAINER_HOST_UNREACHABLE: &str = "Host unreachable.";
685
686/// Generic fallback when none of the container error classifiers
687/// matched. The exit code is the only signal we can show without
688/// leaking unfiltered remote stderr.
689pub fn container_command_failed(code: i32) -> String {
690    format!("Command failed with code {}.", code)
691}
692
693// ── Import ──────────────────────────────────────────────────────────
694
695pub fn imported_hosts(imported: usize, skipped: usize) -> String {
696    format!(
697        "Imported {} host{}, skipped {} duplicate{}.",
698        imported,
699        if imported == 1 { "" } else { "s" },
700        skipped,
701        if skipped == 1 { "" } else { "s" }
702    )
703}
704
705pub fn all_hosts_exist(skipped: usize) -> String {
706    if skipped == 1 {
707        "Host already exists.".to_string()
708    } else {
709        format!("All {} hosts already exist.", skipped)
710    }
711}
712
713// ── SSH config repair ───────────────────────────────────────────────
714
715pub fn config_repaired(groups: usize, orphaned: usize) -> String {
716    format!(
717        "Repaired SSH config ({} absorbed, {} orphaned group headers).",
718        groups, orphaned
719    )
720}
721
722pub fn no_exact_match(alias: &str) -> String {
723    format!("No exact match for '{}'. Here's what we found.", alias)
724}
725
726pub fn group_pref_reset_failed(e: &impl std::fmt::Display) -> String {
727    format!("Group preference reset. (save failed: {})", e)
728}
729
730// ── Connection ──────────────────────────────────────────────────────
731
732pub fn opened_in_tmux(alias: &str) -> String {
733    format!("Opened {} in new tmux window.", alias)
734}
735
736pub fn tmux_error(e: &impl std::fmt::Display) -> String {
737    format!("tmux: {}", e)
738}
739
740pub fn connection_failed(alias: &str) -> String {
741    format!("Connection to {} failed.", alias)
742}
743
744/// Stderr line printed when the ssh subprocess itself failed to spawn or
745/// wait (e.g. binary missing, signal interrupted), distinct from a
746/// non-zero exit code which the user sees via the toast.
747pub fn connection_spawn_failed(e: &impl std::fmt::Display) -> String {
748    format!("Connection failed: {}", e)
749}
750
751/// Toast shown when ssh exited non-zero with a captured stderr line we
752/// can show. The reason is the trimmed last meaningful line of ssh stderr.
753pub fn ssh_failed_with_reason(alias: &str, reason: &str) -> String {
754    format!("SSH to {} failed. {}", alias, reason)
755}
756
757/// Toast shown when ssh exited non-zero with no captured stderr to relay.
758/// The exit code is the only signal we have left.
759pub fn ssh_exited_with_code(alias: &str, code: i32) -> String {
760    format!("SSH to {} exited with code {}.", alias, code)
761}
762
763// ── Host key reset ──────────────────────────────────────────────────
764
765pub fn host_key_remove_failed(stderr: &str) -> String {
766    format!("Failed to remove host key: {}", stderr)
767}
768
769pub fn ssh_keygen_failed(e: &impl std::fmt::Display) -> String {
770    format!("Failed to run ssh-keygen: {}", e)
771}
772
773// ── Transfer ────────────────────────────────────────────────────────
774
775pub const TRANSFER_COMPLETE: &str = "Transfer complete.";
776
777// ── Background / event loop ─────────────────────────────────────────
778
779/// Per-provider sync progress line with a leading spinner frame so
780/// `event_loop::handle_tick` animates the prefix while the message is
781/// on screen. Format: `⠋ Proxmox VE: Resolving IPs (1/5)...`. Mirrors
782/// the spinner contract used by `synced_progress` so the footer keeps
783/// animating even when granular per-provider progress overrides the
784/// batch summary mid-sync.
785pub fn provider_progress(spinner: &str, name: &str, message: &str) -> String {
786    format!("{} {}: {}", spinner, name, message)
787}
788
789// ── Relative age (detail panel "checked" suffix) ────────────────────
790
791pub const AGE_JUST_NOW: &str = "just now";
792
793/// Compact relative age: "just now", "12s ago", "3m ago", "2h ago",
794/// "2d ago". Used in the detail panel so the reader can tell stale
795/// data from fresh.
796pub fn relative_age(elapsed: std::time::Duration) -> String {
797    let secs = elapsed.as_secs();
798    if secs < 5 {
799        AGE_JUST_NOW.to_string()
800    } else if secs < 60 {
801        format!("{}s ago", secs)
802    } else if secs < 3600 {
803        format!("{}m ago", secs / 60)
804    } else if secs < 86400 {
805        format!("{}h ago", secs / 3600)
806    } else {
807        format!("{}d ago", secs / 86400)
808    }
809}
810
811// ── Vault SSH bulk signing summaries (event_loop.rs) ────────────────
812
813pub fn vault_config_reapply_failed(signed: usize, e: &impl std::fmt::Display) -> String {
814    format!(
815        "External edits detected; signed {} certs but failed to re-apply CertificateFile: {}",
816        signed, e
817    )
818}
819
820pub fn vault_external_edits_merged(summary: &str, reapplied: usize) -> String {
821    format!(
822        "{} External ssh config edits detected, merged {} CertificateFile directives.",
823        summary, reapplied
824    )
825}
826
827pub fn vault_external_edits_no_write(summary: &str) -> String {
828    format!(
829        "{} External ssh config edits detected; certs on disk, no CertificateFile written.",
830        summary
831    )
832}
833
834pub fn vault_reparse_failed(signed: usize, e: &impl std::fmt::Display) -> String {
835    format!(
836        "Signed {} certs but cannot re-parse ssh config after external edit: {}. \
837         Certs are on disk under ~/.purple/certs/.",
838        signed, e
839    )
840}
841
842pub fn vault_config_update_failed(signed: usize, e: &impl std::fmt::Display) -> String {
843    format!(
844        "Signed {} certs but failed to update SSH config: {}",
845        signed, e
846    )
847}
848
849pub fn vault_config_write_after_sign(e: &impl std::fmt::Display) -> String {
850    format!("Failed to update config after vault signing: {}", e)
851}
852
853// ── File browser ────────────────────────────────────────────────────
854
855// ── Confirm / host key ──────────────────────────────────────────────
856
857pub fn removed_host_key(hostname: &str) -> String {
858    format!("Removed host key for {}. Reconnecting...", hostname)
859}
860
861// ── Host detail (tags) ──────────────────────────────────────────────
862
863pub fn tagged_host(alias: &str, count: usize) -> String {
864    format!(
865        "Tagged {} with {} label{}.",
866        alias,
867        count,
868        if count == 1 { "" } else { "s" }
869    )
870}
871
872// ── Config reload ───────────────────────────────────────────────────
873
874pub fn config_reloaded(count: usize) -> String {
875    format!("Config reloaded. {} hosts.", count)
876}
877
878// ── Sync background ─────────────────────────────────────────────────
879
880/// In-progress sync line for the footer. Format:
881/// `⠋ Syncing AWS, Hetzner · 1/3 (+12 ~3 -1)`.
882/// Active provider names lead so the user immediately sees which provider
883/// is currently in flight (especially relevant when one provider is slow).
884/// `done/total` follows as a counter. The leading character is a braille
885/// spinner frame rotated on every tick. The `(+a ~u -s)` suffix is omitted
886/// when all counts are zero.
887///
888/// Callers MUST only invoke this when `active_names` is non-empty (i.e.
889/// at least one provider is still in flight). The only call site is
890/// `main::set_sync_summary`, which enters this branch via `still_syncing`,
891/// itself gated on `!providers.syncing.is_empty()` — so `active_names`
892/// (built from `syncing.keys()`) is guaranteed non-empty.
893pub fn synced_progress(
894    spinner: &str,
895    active_names: &str,
896    done: usize,
897    total: usize,
898    added: usize,
899    updated: usize,
900    stale: usize,
901) -> String {
902    debug_assert!(
903        !active_names.is_empty(),
904        "synced_progress must only be called while a provider is still in flight"
905    );
906    let diff = sync_diff_suffix(added, updated, stale);
907    format!(
908        "{} Syncing {} \u{00B7} {}/{}{}",
909        spinner, active_names, done, total, diff
910    )
911}
912
913/// Final sync summary for the footer once all providers in the batch have
914/// completed. Format: `Synced 5/5 · AWS, DO, Vultr, Hetzner, Linode (+12 ~3 -1)`.
915/// No spinner prefix, no auto-tick: the message expires by length-proportional
916/// timeout once the batch is done.
917pub fn synced_done(
918    done: usize,
919    total: usize,
920    names: &str,
921    added: usize,
922    updated: usize,
923    stale: usize,
924) -> String {
925    let diff = sync_diff_suffix(added, updated, stale);
926    format!("Synced {}/{} \u{00B7} {}{}", done, total, names, diff)
927}
928
929fn sync_diff_suffix(added: usize, updated: usize, stale: usize) -> String {
930    let parts: Vec<String> = [(added, '+'), (updated, '~'), (stale, '-')]
931        .iter()
932        .filter(|(n, _)| *n > 0)
933        .map(|(n, sign)| format!("{}{}", sign, n))
934        .collect();
935    if parts.is_empty() {
936        String::new()
937    } else {
938        format!(" ({})", parts.join(" "))
939    }
940}
941
942pub const SYNC_THREAD_SPAWN_FAILED: &str = "Failed to start sync thread.";
943
944pub const SYNC_UNKNOWN_PROVIDER: &str = "Unknown provider.";
945
946// ── Vault signing cancelled summary ─────────────────────────────────
947
948pub fn vault_signing_cancelled_summary(
949    signed: u32,
950    failed: u32,
951    first_error: Option<&str>,
952) -> String {
953    let mut msg = format!(
954        "Vault SSH signing cancelled ({} signed, {} failed)",
955        signed, failed
956    );
957    if let Some(err) = first_error {
958        msg.push_str(": ");
959        msg.push_str(err);
960    }
961    msg
962}
963
964// ── Region picker ───────────────────────────────────────────────────
965
966pub fn regions_selected_count(count: usize, label: &str) -> String {
967    let s = if count == 1 { "" } else { "s" };
968    format!("{} {}{} selected.", count, label, s)
969}
970
971// ── Purge stale ─────────────────────────────────────────────────────
972
973// ── Clipboard ───────────────────────────────────────────────────────
974
975pub const NO_CLIPBOARD_TOOL: &str =
976    "No clipboard tool found. Install pbcopy (macOS), wl-copy (Wayland), or xclip/xsel (X11).";
977
978// ── MCP server ──────────────────────────────────────────────────────
979
980pub const MCP_TOOL_DENIED_READ_ONLY: &str = "Tool denied. Server started with --read-only. Restart without --read-only to enable state-changing tools.";
981
982/// Bare message body. Callers add the `[purple]` fault-domain prefix at
983/// their `warn!` / `error!` site; the `eprintln!` startup diagnostic emits
984/// this body directly without the tag.
985pub fn mcp_audit_init_failed(path: &impl std::fmt::Display, e: &impl std::fmt::Display) -> String {
986    format!(
987        "Failed to initialise MCP audit log at {}: {}. Continuing without audit logging.",
988        path, e
989    )
990}
991
992/// Bare message body. Callers add `[purple]` at the log macro site.
993pub fn mcp_audit_write_failed(e: &impl std::fmt::Display) -> String {
994    format!("Failed to write MCP audit entry: {}", e)
995}
996
997/// Returned to the MCP client as `isError` content when the SSH config path
998/// does not point to an existing file. Surfaces the bug class where a
999/// missing-file silently yields an empty host list.
1000pub fn mcp_config_file_not_found(path: &impl std::fmt::Display) -> String {
1001    format!("SSH config file not found: {}", path)
1002}
1003
1004/// Logged when `dirs::home_dir()` cannot resolve a home for the audit log
1005/// default. Auditing is silently disabled in this state, so the operator
1006/// needs an explicit cue.
1007pub const MCP_AUDIT_HOME_DIR_UNAVAILABLE: &str = "Could not determine home directory; MCP audit log disabled. Set --audit-log <PATH> explicitly to enable auditing.";
1008
1009// ── Jump ─────────────────────────────────────────────────
1010
1011/// Placeholder shown in the jump bar input when the query is empty.
1012pub const PALETTE_PLACEHOLDER: &str = "Find anything";
1013/// Empty-state copy when the current query has no matches.
1014pub const PALETTE_NO_RESULTS: &str = "No matches.";
1015/// Toast shown when the user dispatches a snippet from the jump bar while
1016/// no host is selected (the snippet picker needs at least one target).
1017pub const PALETTE_SNIPPET_NEEDS_HOST: &str =
1018    "Pick a host first, then run a snippet from the jump bar.";
1019/// Suffix appended to the truncated row list when the visible window is
1020/// smaller than the result list.
1021pub fn jump_more_rows(n: usize) -> String {
1022    format!("+{n} more (scroll down)")
1023}
1024
1025// ── CLI messages ────────────────────────────────────────────────────
1026
1027#[path = "messages/cli.rs"]
1028pub mod cli;
1029pub mod footer;
1030
1031// ── Update messages ─────────────────────────────────────────────────
1032
1033pub mod update {
1034    pub const WHATS_NEW_HINT: &str = "Press n inside purple to see what's new.";
1035    pub const DONE: &str = "done.";
1036    pub const CHECKSUM_OK: &str = "ok.";
1037    pub const SUDO_WARNING: &str =
1038        "Running via sudo. Consider fixing directory permissions instead.";
1039
1040    /// Two-space-indented progress prefixes printed before each step.
1041    /// Trailing space is intentional so the success/fail glyph or
1042    /// `DONE` constant follows on the same line, matching the visual
1043    /// rhythm of the updater output.
1044    pub const STEP_CHECKING: &str = "  Checking for updates... ";
1045    pub const STEP_VERIFYING_CHECKSUM: &str = "  Verifying checksum... ";
1046    pub const STEP_INSTALLING: &str = "  Installing... ";
1047
1048    pub fn already_on(current: &str) -> String {
1049        format!("already on v{} (latest).", current)
1050    }
1051
1052    pub fn available(latest: &str, current: &str) -> String {
1053        format!("v{} available (current: v{}).", latest, current)
1054    }
1055
1056    /// Two-space-indented progress prefix for the download step. Matches
1057    /// the trailing-space convention of the other STEP_* constants so
1058    /// the next print resumes on the same line.
1059    pub fn step_downloading(version: &str) -> String {
1060        format!("  Downloading v{}... ", version)
1061    }
1062
1063    /// Indented sudo warning rendered before the download step. The
1064    /// caller passes a pre-bolded bang (`!`) so the line reads
1065    /// `  ! Running via sudo. ...` with the `!` emphasized.
1066    pub fn sudo_warning_line(bold_bang: &str) -> String {
1067        format!("  {} {}", bold_bang, SUDO_WARNING)
1068    }
1069
1070    pub fn header(bold_name: &str) -> String {
1071        format!("\n  {} updater\n", bold_name)
1072    }
1073
1074    pub fn binary_path(path: &std::path::Path) -> String {
1075        format!("  Binary: {}", path.display())
1076    }
1077
1078    pub fn installed_at(bold_version: &str, path: &std::path::Path) -> String {
1079        format!("\n  {} installed at {}.", bold_version, path.display())
1080    }
1081
1082    pub fn whats_new_hint_indented() -> String {
1083        format!("\n  {}", WHATS_NEW_HINT)
1084    }
1085}
1086
1087// ── Askpass / password prompts ───────────────────────────────────────
1088
1089pub mod askpass {
1090    pub const BW_NOT_FOUND: &str = "Bitwarden CLI (bw) not found. SSH will prompt for password.";
1091    pub const BW_NOT_LOGGED_IN: &str = "Bitwarden vault not logged in. Run 'bw login' first.";
1092    pub const EMPTY_PASSWORD: &str = "Empty password. SSH will prompt for password.";
1093    pub const PASSWORD_IN_KEYCHAIN: &str = "Password stored in keychain.";
1094
1095    pub fn read_failed(e: &impl std::fmt::Display) -> String {
1096        format!("Failed to read password: {}", e)
1097    }
1098
1099    pub fn unlock_failed_retry(e: &impl std::fmt::Display) -> String {
1100        format!("Unlock failed: {}. Try again.", e)
1101    }
1102
1103    pub fn unlock_failed_prompt(e: &impl std::fmt::Display) -> String {
1104        format!("Unlock failed: {}. SSH will prompt for password.", e)
1105    }
1106
1107    /// CLI prompt shown by the inline askpass path when the user has no
1108    /// stored credential yet. The trailing space is intentional — the
1109    /// reader echoes user input directly after.
1110    pub fn password_prompt(alias: &str) -> String {
1111        format!("Password for {}: ", alias)
1112    }
1113
1114    /// CLI prompt shown when keychain storage is the sink. Reminds the
1115    /// user that the entry will be persisted, not just used once.
1116    pub fn keychain_password_prompt(alias: &str) -> String {
1117        format!("Password for {} (stored in keychain): ", alias)
1118    }
1119
1120    /// Stderr line emitted when the keychain `add-generic-password` call
1121    /// failed. The user falls back to ssh's own prompt on the next try.
1122    pub fn keychain_store_failed(e: &impl std::fmt::Display) -> String {
1123        format!(
1124            "Failed to store in keychain: {}. SSH will prompt for password.",
1125            e
1126        )
1127    }
1128}
1129
1130// ── Logging ─────────────────────────────────────────────────────────
1131
1132pub mod logging {
1133    pub fn init_failed(e: &impl std::fmt::Display) -> String {
1134        format!("[purple] Failed to initialize logger: {}", e)
1135    }
1136
1137    pub const SSH_VERSION_FAILED: &str = "[purple] Failed to detect SSH version. Is ssh installed?";
1138}
1139
1140// ── Form field hints / placeholders ─────────────────────────────────
1141//
1142// Dimmed placeholder text shown in empty form fields. Centralized here
1143// so every user-visible string lives in one place and is auditable.
1144
1145pub mod hints {
1146    // ── Shared ──────────────────────────────────────────────────────
1147    // Picker hints mention "Space" because per the design system keyboard
1148    // invariants, Enter always submits a form; pickers open on Space.
1149    // Keep these strings in sync with scripts/check-keybindings.sh.
1150    pub const IDENTITY_FILE_PICK: &str = "Space to pick a key";
1151    pub const DEFAULT_SSH_USER: &str = "root";
1152
1153    // ── Host form ───────────────────────────────────────────────────
1154    pub const HOST_ALIAS: &str = "e.g. prod or db-01";
1155    pub const HOST_ALIAS_PATTERN: &str = "10.0.0.* or *.example.com";
1156    pub const HOST_HOSTNAME: &str = "192.168.1.1 or example.com";
1157    pub const HOST_PORT: &str = "22";
1158    pub const HOST_PROXY_JUMP: &str = "Space to pick a host";
1159    pub const HOST_VAULT_SSH: &str = "e.g. ssh-client-signer/sign/my-role (auth via vault login)";
1160    pub const HOST_VAULT_SSH_PICKER: &str = "Space to pick a role or type one";
1161    pub const HOST_VAULT_ADDR: &str =
1162        "e.g. http://127.0.0.1:8200 (inherits from provider or env when empty)";
1163    pub const HOST_TAGS: &str = "e.g. prod, staging, us-east (comma-separated)";
1164    pub const HOST_ASKPASS_PICK: &str = "Space to pick a source";
1165
1166    pub fn askpass_default(default: &str) -> String {
1167        format!("default: {}", default)
1168    }
1169
1170    pub fn inherits_from(value: &str, provider: &str) -> String {
1171        format!("inherits {} from {}", value, provider)
1172    }
1173
1174    // ── Tunnel form ─────────────────────────────────────────────────
1175    pub const TUNNEL_BIND_PORT: &str = "8080";
1176    pub const TUNNEL_REMOTE_HOST: &str = "localhost";
1177    pub const TUNNEL_REMOTE_PORT: &str = "80";
1178
1179    // ── Snippet form ────────────────────────────────────────────────
1180    pub const SNIPPET_NAME: &str = "check-disk";
1181    pub const SNIPPET_COMMAND: &str = "df -h";
1182    pub const SNIPPET_OPTIONAL: &str = "(optional)";
1183
1184    // ── Provider form ───────────────────────────────────────────────
1185    pub const PROVIDER_URL: &str = "https://pve.example.com:8006";
1186    pub const PROVIDER_TOKEN_DEFAULT: &str = "your-api-token";
1187    pub const PROVIDER_TOKEN_PROXMOX: &str = "user@pam!token=secret";
1188    pub const PROVIDER_TOKEN_AWS: &str = "AccessKeyId:Secret (or use Profile)";
1189    pub const PROVIDER_TOKEN_GCP: &str = "/path/to/service-account.json (or access token)";
1190    pub const PROVIDER_TOKEN_AZURE: &str = "/path/to/service-principal.json (or access token)";
1191    pub const PROVIDER_TOKEN_TAILSCALE: &str = "API key (leave empty for local CLI)";
1192    pub const PROVIDER_TOKEN_ORACLE: &str = "~/.oci/config";
1193    pub const PROVIDER_TOKEN_OVH: &str = "app_key:app_secret:consumer_key";
1194    pub const PROVIDER_PROFILE: &str = "Name from ~/.aws/credentials (or use Token)";
1195    pub const PROVIDER_PROJECT_DEFAULT: &str = "my-gcp-project-id";
1196    pub const PROVIDER_PROJECT_OVH: &str = "Public Cloud project ID";
1197    pub const PROVIDER_COMPARTMENT: &str = "ocid1.compartment.oc1..aaaa...";
1198    pub const PROVIDER_REGIONS_DEFAULT: &str = "Space to select regions";
1199    pub const PROVIDER_REGIONS_GCP: &str = "Space to select zones (empty = all)";
1200    pub const PROVIDER_REGIONS_SCALEWAY: &str = "Space to select zones";
1201    // Azure regions is a text input (not a picker), so no key is mentioned.
1202    pub const PROVIDER_REGIONS_AZURE: &str = "comma-separated subscription IDs";
1203    pub const PROVIDER_REGIONS_OVH: &str = "Space to select endpoint (default: EU)";
1204    pub const PROVIDER_USER_AWS: &str = "ec2-user";
1205    pub const PROVIDER_USER_GCP: &str = "ubuntu";
1206    pub const PROVIDER_USER_AZURE: &str = "azureuser";
1207    pub const PROVIDER_USER_ORACLE: &str = "opc";
1208    pub const PROVIDER_USER_OVH: &str = "ubuntu";
1209    pub const PROVIDER_VAULT_ROLE: &str =
1210        "e.g. ssh-client-signer/sign/my-role (vault login; inherited)";
1211    pub const PROVIDER_VAULT_ADDR: &str = "e.g. http://127.0.0.1:8200 (inherited by all hosts)";
1212    pub const PROVIDER_ALIAS_PREFIX_DEFAULT: &str = "prefix";
1213}
1214
1215#[cfg(test)]
1216mod hints_tests {
1217    use super::hints;
1218
1219    #[test]
1220    fn askpass_default_formats() {
1221        assert_eq!(hints::askpass_default("keychain"), "default: keychain");
1222    }
1223
1224    #[test]
1225    fn askpass_default_formats_empty() {
1226        assert_eq!(hints::askpass_default(""), "default: ");
1227    }
1228
1229    #[test]
1230    fn inherits_from_formats() {
1231        assert_eq!(
1232            hints::inherits_from("role/x", "aws"),
1233            "inherits role/x from aws"
1234        );
1235    }
1236
1237    #[test]
1238    fn picker_hints_mention_space_not_enter() {
1239        // Per the keyboard invariants, pickers open on Space.
1240        // If these assertions fail, audit scripts/check-keybindings.sh too.
1241        for s in [
1242            hints::IDENTITY_FILE_PICK,
1243            hints::HOST_PROXY_JUMP,
1244            hints::HOST_VAULT_SSH_PICKER,
1245            hints::HOST_ASKPASS_PICK,
1246            hints::PROVIDER_REGIONS_DEFAULT,
1247            hints::PROVIDER_REGIONS_GCP,
1248            hints::PROVIDER_REGIONS_SCALEWAY,
1249            hints::PROVIDER_REGIONS_OVH,
1250        ] {
1251            assert!(
1252                s.starts_with("Space "),
1253                "picker hint must mention Space: {s}"
1254            );
1255            assert!(!s.contains("Enter "), "picker hint must not say Enter: {s}");
1256        }
1257    }
1258}
1259
1260#[path = "messages/whats_new.rs"]
1261pub mod whats_new;
1262
1263#[path = "messages/whats_new_toast.rs"]
1264pub mod whats_new_toast;
1265
1266#[cfg(test)]
1267mod relative_age_tests {
1268    use super::relative_age;
1269    use std::time::Duration;
1270
1271    #[test]
1272    fn relative_age_boundaries() {
1273        assert_eq!(relative_age(Duration::from_secs(0)), "just now");
1274        assert_eq!(relative_age(Duration::from_secs(4)), "just now");
1275        assert_eq!(relative_age(Duration::from_secs(5)), "5s ago");
1276        assert_eq!(relative_age(Duration::from_secs(59)), "59s ago");
1277        assert_eq!(relative_age(Duration::from_secs(60)), "1m ago");
1278        assert_eq!(relative_age(Duration::from_secs(3599)), "59m ago");
1279        assert_eq!(relative_age(Duration::from_secs(3600)), "1h ago");
1280        assert_eq!(relative_age(Duration::from_secs(86399)), "23h ago");
1281        assert_eq!(relative_age(Duration::from_secs(86400)), "1d ago");
1282        assert_eq!(relative_age(Duration::from_secs(86400 * 7)), "7d ago");
1283    }
1284}