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//! Domain submodules (host, tunnel, provider, vault, snippet, container,
9//! picker) own their own slice of the surface and are glob-re-exported
10//! below so callers continue to write `crate::messages::X` unchanged.
11
12pub mod container;
13pub mod host;
14pub mod picker;
15pub mod provider;
16pub mod snippet;
17pub mod tunnel;
18pub mod vault;
19
20pub use container::*;
21pub use host::*;
22pub use picker::*;
23pub use provider::*;
24pub use snippet::*;
25pub use tunnel::*;
26pub use vault::*;
27
28// ── General / shared ────────────────────────────────────────────────
29
30pub const FAILED_TO_SAVE: &str = "Failed to save";
31pub fn failed_to_save(e: &impl std::fmt::Display) -> String {
32    format!("{}: {}", FAILED_TO_SAVE, e)
33}
34
35pub const CONFIG_CHANGED_EXTERNALLY: &str =
36    "Config changed externally. Press Esc and re-open to pick up changes.";
37
38// ── Demo mode ───────────────────────────────────────────────────────
39
40pub const DEMO_CONNECTION_DISABLED: &str = "Demo mode. Connection disabled.";
41pub const DEMO_SYNC_DISABLED: &str = "Demo mode. Sync disabled.";
42pub const DEMO_TUNNELS_DISABLED: &str = "Demo mode. Tunnels disabled.";
43pub const DEMO_VAULT_SIGNING_DISABLED: &str = "Demo mode. Vault SSH signing disabled.";
44pub const DEMO_FILE_BROWSER_DISABLED: &str = "Demo mode. File browser disabled.";
45pub const DEMO_CONTAINER_REFRESH_DISABLED: &str = "Demo mode. Container refresh disabled.";
46pub const DEMO_CONTAINER_ACTIONS_DISABLED: &str = "Demo mode. Container actions disabled.";
47pub const DEMO_EXECUTION_DISABLED: &str = "Demo mode. Execution disabled.";
48pub const DEMO_PROVIDER_CHANGES_DISABLED: &str = "Demo mode. Provider config changes disabled.";
49
50// ── Ping ────────────────────────────────────────────────────────────
51
52pub fn pinging_host(alias: &str, show_hint: bool) -> String {
53    if show_hint {
54        format!("Pinging {}... (Shift+P pings all)", alias)
55    } else {
56        format!("Pinging {}...", alias)
57    }
58}
59
60pub fn bastion_not_found(alias: &str) -> String {
61    format!("Bastion {} not found in config.", alias)
62}
63
64pub fn ping_skipped_proxied(alias: &str) -> String {
65    format!("{} connects through a ProxyCommand. Ping skipped.", alias)
66}
67
68// ── Clipboard subprocess errors ─────────────────────────────────────
69//
70// Surfaced when `pbcopy`/`xclip`/`wl-copy` fails to spawn, write to its
71// stdin, or be reaped. The cmd name is the binary the platform picked.
72
73pub fn clipboard_run_failed(cmd: &str) -> String {
74    format!("Failed to run {}.", cmd)
75}
76
77pub fn clipboard_write_failed(cmd: &str) -> String {
78    format!("Failed to write to {}.", cmd)
79}
80
81pub fn clipboard_wait_failed(cmd: &str) -> String {
82    format!("Failed to wait for {}.", cmd)
83}
84
85pub fn clipboard_exited_error(cmd: &str) -> String {
86    format!("{} exited with error.", cmd)
87}
88
89// ── Import errors ───────────────────────────────────────────────────
90//
91// Bubble up to the CLI via `eprintln!("{}", e)` when the user runs
92// `purple import` against a missing or unreadable file.
93
94pub fn import_open_failed(path: &impl std::fmt::Display, e: &impl std::fmt::Display) -> String {
95    format!("Can't open {}: {}", path, e)
96}
97
98pub fn import_known_hosts_open_failed(e: &impl std::fmt::Display) -> String {
99    format!("Can't open known_hosts: {}", e)
100}
101
102pub const IMPORT_HOME_DIR_UNKNOWN: &str = "Could not determine home directory.";
103pub const IMPORT_KNOWN_HOSTS_MISSING: &str = "~/.ssh/known_hosts not found.";
104
105// ── Import ──────────────────────────────────────────────────────────
106
107pub fn imported_hosts(imported: usize, skipped: usize) -> String {
108    format!(
109        "Imported {} host{}, skipped {} duplicate{}.",
110        imported,
111        if imported == 1 { "" } else { "s" },
112        skipped,
113        if skipped == 1 { "" } else { "s" }
114    )
115}
116
117pub fn all_hosts_exist(skipped: usize) -> String {
118    if skipped == 1 {
119        "Host already exists.".to_string()
120    } else {
121        format!("All {} hosts already exist.", skipped)
122    }
123}
124
125// ── Connection ──────────────────────────────────────────────────────
126
127pub fn opened_in_tmux(alias: &str) -> String {
128    format!("Opened {} in new tmux window.", alias)
129}
130
131pub fn tmux_error(e: &impl std::fmt::Display) -> String {
132    format!("tmux: {}", e)
133}
134
135pub fn connection_failed(alias: &str) -> String {
136    format!("Connection to {} failed.", alias)
137}
138
139/// Stderr line printed when the ssh subprocess itself failed to spawn or
140/// wait (e.g. binary missing, signal interrupted), distinct from a
141/// non-zero exit code which the user sees via the toast.
142pub fn connection_spawn_failed(e: &impl std::fmt::Display) -> String {
143    format!("Connection failed: {}", e)
144}
145
146/// Toast shown when ssh exited non-zero with a captured stderr line we
147/// can show. The reason is the trimmed last meaningful line of ssh stderr.
148pub fn ssh_failed_with_reason(alias: &str, reason: &str) -> String {
149    format!("SSH to {} failed. {}", alias, reason)
150}
151
152/// Toast shown when ssh exited non-zero with no captured stderr to relay.
153/// The exit code is the only signal we have left.
154pub fn ssh_exited_with_code(alias: &str, code: i32) -> String {
155    format!("SSH to {} exited with code {}.", alias, code)
156}
157
158// ── Transfer ────────────────────────────────────────────────────────
159
160pub const TRANSFER_COMPLETE: &str = "Transfer complete.";
161
162// ── Background / event loop ─────────────────────────────────────────
163
164/// Per-provider sync progress line with a leading spinner frame so
165/// `event_loop::handle_tick` animates the prefix while the message is
166/// on screen. Format: `⠋ Proxmox VE: Resolving IPs (1/5)...`. Mirrors
167/// the spinner contract used by `synced_progress` so the footer keeps
168/// animating even when granular per-provider progress overrides the
169/// batch summary mid-sync.
170pub fn provider_progress(spinner: &str, name: &str, message: &str) -> String {
171    format!("{} {}: {}", spinner, name, message)
172}
173
174// ── Relative age (detail panel "checked" suffix) ────────────────────
175
176pub const AGE_JUST_NOW: &str = "just now";
177
178/// Compact relative age: "just now", "12s ago", "3m ago", "2h ago",
179/// "2d ago". Used in the detail panel so the reader can tell stale
180/// data from fresh.
181pub fn relative_age(elapsed: std::time::Duration) -> String {
182    let secs = elapsed.as_secs();
183    if secs < 5 {
184        AGE_JUST_NOW.to_string()
185    } else if secs < 60 {
186        format!("{}s ago", secs)
187    } else if secs < 3600 {
188        format!("{}m ago", secs / 60)
189    } else if secs < 86400 {
190        format!("{}h ago", secs / 3600)
191    } else {
192        format!("{}d ago", secs / 86400)
193    }
194}
195
196// ── Config reload ───────────────────────────────────────────────────
197
198pub fn config_reloaded(count: usize) -> String {
199    format!("Config reloaded. {} hosts.", count)
200}
201
202// ── Sync background ─────────────────────────────────────────────────
203
204/// In-progress sync line for the footer. Format:
205/// `⠋ Syncing AWS, Hetzner · 1/3 (+12 ~3 -1)`.
206/// Active provider names lead so the user immediately sees which provider
207/// is currently in flight (especially relevant when one provider is slow).
208/// `done/total` follows as a counter. The leading character is a braille
209/// spinner frame rotated on every tick. The `(+a ~u -s)` suffix is omitted
210/// when all counts are zero.
211///
212/// Callers MUST only invoke this when `active_names` is non-empty (i.e.
213/// at least one provider is still in flight). The only call site is
214/// `main::set_sync_summary`, which enters this branch via `still_syncing`,
215/// itself gated on `!providers.syncing.is_empty()` — so `active_names`
216/// (built from `syncing.keys()`) is guaranteed non-empty.
217pub fn synced_progress(
218    spinner: &str,
219    active_names: &str,
220    done: usize,
221    total: usize,
222    added: usize,
223    updated: usize,
224    stale: usize,
225) -> String {
226    debug_assert!(
227        !active_names.is_empty(),
228        "synced_progress must only be called while a provider is still in flight"
229    );
230    let diff = sync_diff_suffix(added, updated, stale);
231    format!(
232        "{} Syncing {} \u{00B7} {}/{}{}",
233        spinner, active_names, done, total, diff
234    )
235}
236
237/// Final sync summary for the footer once all providers in the batch have
238/// completed. Format: `Synced 5/5 · AWS, DO, Vultr, Hetzner, Linode (+12 ~3 -1)`.
239/// No spinner prefix, no auto-tick: the message expires by length-proportional
240/// timeout once the batch is done.
241pub fn synced_done(
242    done: usize,
243    total: usize,
244    names: &str,
245    added: usize,
246    updated: usize,
247    stale: usize,
248) -> String {
249    let diff = sync_diff_suffix(added, updated, stale);
250    format!("Synced {}/{} \u{00B7} {}{}", done, total, names, diff)
251}
252
253fn sync_diff_suffix(added: usize, updated: usize, stale: usize) -> String {
254    let parts: Vec<String> = [(added, '+'), (updated, '~'), (stale, '-')]
255        .iter()
256        .filter(|(n, _)| *n > 0)
257        .map(|(n, sign)| format!("{}{}", sign, n))
258        .collect();
259    if parts.is_empty() {
260        String::new()
261    } else {
262        format!(" ({})", parts.join(" "))
263    }
264}
265
266pub const SYNC_THREAD_SPAWN_FAILED: &str = "Failed to start sync thread.";
267
268pub const SYNC_UNKNOWN_PROVIDER: &str = "Unknown provider.";
269
270pub fn sync_skipped_external_change() -> &'static str {
271    "Config changed on disk during sync. Re-run sync after reviewing your edits."
272}
273
274// ── Clipboard ───────────────────────────────────────────────────────
275
276pub const NO_CLIPBOARD_TOOL: &str =
277    "No clipboard tool found. Install pbcopy (macOS), wl-copy (Wayland), or xclip/xsel (X11).";
278
279// ── MCP server ──────────────────────────────────────────────────────
280
281pub const MCP_TOOL_DENIED_READ_ONLY: &str = "Tool denied. Server started with --read-only. Restart without --read-only to enable state-changing tools.";
282
283/// Bare message body. Callers add the `[purple]` fault-domain prefix at
284/// their `warn!` / `error!` site; the `eprintln!` startup diagnostic emits
285/// this body directly without the tag.
286pub fn mcp_audit_init_failed(path: &impl std::fmt::Display, e: &impl std::fmt::Display) -> String {
287    format!(
288        "Failed to initialise MCP audit log at {}: {}. Continuing without audit logging.",
289        path, e
290    )
291}
292
293/// Bare message body. Callers add `[purple]` at the log macro site.
294pub fn mcp_audit_write_failed(e: &impl std::fmt::Display) -> String {
295    format!("Failed to write MCP audit entry: {}", e)
296}
297
298/// Returned to the MCP client as `isError` content when the SSH config path
299/// does not point to an existing file. Surfaces the bug class where a
300/// missing-file silently yields an empty host list.
301pub fn mcp_config_file_not_found(path: &impl std::fmt::Display) -> String {
302    format!("SSH config file not found: {}", path)
303}
304
305/// Logged when `dirs::home_dir()` cannot resolve a home for the audit log
306/// default. Auditing is silently disabled in this state, so the operator
307/// needs an explicit cue.
308pub const MCP_AUDIT_HOME_DIR_UNAVAILABLE: &str = "Could not determine home directory; MCP audit log disabled. Set --audit-log <PATH> explicitly to enable auditing.";
309
310// ── Jump ─────────────────────────────────────────────────
311
312/// Placeholder shown in the jump bar input when the query is empty.
313pub const PALETTE_PLACEHOLDER: &str = "Find anything";
314/// Empty-state copy when the current query has no matches.
315pub const PALETTE_NO_RESULTS: &str = "No matches.";
316/// Toast shown when the user dispatches a snippet from the jump bar while
317/// no host is selected (the snippet picker needs at least one target).
318pub const PALETTE_SNIPPET_NEEDS_HOST: &str =
319    "Pick a host first, then run a snippet from the jump bar.";
320/// Suffix appended to the truncated row list when the visible window is
321/// smaller than the result list.
322pub fn jump_more_rows(n: usize) -> String {
323    format!("+{n} more (scroll down)")
324}
325
326// ── CLI messages ────────────────────────────────────────────────────
327
328#[path = "messages/cli.rs"]
329pub mod cli;
330pub mod footer;
331
332// ── Update messages ─────────────────────────────────────────────────
333
334pub mod update {
335    pub const WHATS_NEW_HINT: &str = "Press n inside purple to see what's new.";
336    pub const DONE: &str = "done.";
337    pub const CHECKSUM_OK: &str = "ok.";
338    pub const SUDO_WARNING: &str =
339        "Running via sudo. Consider fixing directory permissions instead.";
340
341    /// Two-space-indented progress prefixes printed before each step.
342    /// Trailing space is intentional so the success/fail glyph or
343    /// `DONE` constant follows on the same line, matching the visual
344    /// rhythm of the updater output.
345    pub const STEP_CHECKING: &str = "  Checking for updates... ";
346    pub const STEP_VERIFYING_CHECKSUM: &str = "  Verifying checksum... ";
347    pub const STEP_INSTALLING: &str = "  Installing... ";
348
349    pub fn already_on(current: &str) -> String {
350        format!("already on v{} (latest).", current)
351    }
352
353    pub fn available(latest: &str, current: &str) -> String {
354        format!("v{} available (current: v{}).", latest, current)
355    }
356
357    /// Two-space-indented progress prefix for the download step. Matches
358    /// the trailing-space convention of the other STEP_* constants so
359    /// the next print resumes on the same line.
360    pub fn step_downloading(version: &str) -> String {
361        format!("  Downloading v{}... ", version)
362    }
363
364    /// Indented sudo warning rendered before the download step. The
365    /// caller passes a pre-bolded bang (`!`) so the line reads
366    /// `  ! Running via sudo. ...` with the `!` emphasized.
367    pub fn sudo_warning_line(bold_bang: &str) -> String {
368        format!("  {} {}", bold_bang, SUDO_WARNING)
369    }
370
371    pub fn header(bold_name: &str) -> String {
372        format!("\n  {} updater\n", bold_name)
373    }
374
375    pub fn binary_path(path: &std::path::Path) -> String {
376        format!("  Binary: {}", path.display())
377    }
378
379    pub fn installed_at(bold_version: &str, path: &std::path::Path) -> String {
380        format!("\n  {} installed at {}.", bold_version, path.display())
381    }
382
383    pub fn whats_new_hint_indented() -> String {
384        format!("\n  {}", WHATS_NEW_HINT)
385    }
386}
387
388// ── Askpass / password prompts ───────────────────────────────────────
389
390pub mod askpass {
391    pub const BW_NOT_FOUND: &str = "Bitwarden CLI (bw) not found. SSH will prompt for password.";
392    pub const BW_NOT_LOGGED_IN: &str = "Bitwarden vault not logged in. Run 'bw login' first.";
393    pub const EMPTY_PASSWORD: &str = "Empty password. SSH will prompt for password.";
394    pub const PASSWORD_IN_KEYCHAIN: &str = "Password stored in keychain.";
395
396    pub fn read_failed(e: &impl std::fmt::Display) -> String {
397        format!("Failed to read password: {}", e)
398    }
399
400    pub fn unlock_failed_retry(e: &impl std::fmt::Display) -> String {
401        format!("Unlock failed: {}. Try again.", e)
402    }
403
404    pub fn unlock_failed_prompt(e: &impl std::fmt::Display) -> String {
405        format!("Unlock failed: {}. SSH will prompt for password.", e)
406    }
407
408    /// CLI prompt shown by the inline askpass path when the user has no
409    /// stored credential yet. The trailing space is intentional — the
410    /// reader echoes user input directly after.
411    pub fn password_prompt(alias: &str) -> String {
412        format!("Password for {}: ", alias)
413    }
414
415    /// CLI prompt shown when keychain storage is the sink. Reminds the
416    /// user that the entry will be persisted, not just used once.
417    pub fn keychain_password_prompt(alias: &str) -> String {
418        format!("Password for {} (stored in keychain): ", alias)
419    }
420
421    /// Stderr line emitted when the keychain `add-generic-password` call
422    /// failed. The user falls back to ssh's own prompt on the next try.
423    pub fn keychain_store_failed(e: &impl std::fmt::Display) -> String {
424        format!(
425            "Failed to store in keychain: {}. SSH will prompt for password.",
426            e
427        )
428    }
429
430    pub const PROTON_NOT_FOUND: &str =
431        "Proton Pass CLI (pass-cli) not found. SSH will prompt for password.";
432
433    pub const PROTON_LOGIN_PROMPT: &str = "Proton Pass PAT: ";
434
435    pub const PROTON_LOGIN_SUCCESS: &str = "Logged in to Proton Pass.";
436
437    pub fn proton_login_failed_retry(e: &impl std::fmt::Display) -> String {
438        format!("Proton Pass login failed: {}. Try again.", e)
439    }
440
441    pub fn proton_login_failed_prompt(e: &impl std::fmt::Display) -> String {
442        format!(
443            "Proton Pass login failed: {}. SSH will prompt for password.",
444            e
445        )
446    }
447}
448
449// ── Logging ─────────────────────────────────────────────────────────
450
451pub mod logging {
452    pub fn init_failed(e: &impl std::fmt::Display) -> String {
453        format!("[purple] Failed to initialize logger: {}", e)
454    }
455
456    pub const SSH_VERSION_FAILED: &str = "[purple] Failed to detect SSH version. Is ssh installed?";
457}
458
459// ── Form field hints / placeholders ─────────────────────────────────
460//
461// Dimmed placeholder text shown in empty form fields. Centralized here
462// so every user-visible string lives in one place and is auditable.
463
464pub mod hints {
465    // ── Shared ──────────────────────────────────────────────────────
466    // Picker hints mention "Space" because per the design system keyboard
467    // invariants, Enter always submits a form; pickers open on Space.
468    // Keep these strings in sync with scripts/check-keybindings.sh.
469    pub const IDENTITY_FILE_PICK: &str = "Space to pick a key";
470    pub const DEFAULT_SSH_USER: &str = "root";
471
472    // ── Host form ───────────────────────────────────────────────────
473    pub const HOST_ALIAS: &str = "e.g. prod or db-01";
474    pub const HOST_ALIAS_PATTERN: &str = "10.0.0.* or *.example.com";
475    pub const HOST_HOSTNAME: &str = "192.168.1.1 or example.com";
476    pub const HOST_PORT: &str = "22";
477    pub const HOST_PROXY_JUMP: &str = "Space to pick a host";
478    pub const HOST_VAULT_SSH: &str = "e.g. ssh-client-signer/sign/my-role (auth via vault login)";
479    pub const HOST_VAULT_SSH_PICKER: &str = "Space to pick a role or type one";
480    pub const HOST_VAULT_ADDR: &str =
481        "e.g. http://127.0.0.1:8200 (inherits from provider or env when empty)";
482    pub const HOST_TAGS: &str = "e.g. prod, staging, us-east (comma-separated)";
483    pub const HOST_ASKPASS_PICK: &str = "Space to pick a source";
484
485    pub fn askpass_default(default: &str) -> String {
486        format!("default: {}", default)
487    }
488
489    pub fn inherits_from(value: &str, provider: &str) -> String {
490        format!("inherits {} from {}", value, provider)
491    }
492
493    // ── Tunnel form ─────────────────────────────────────────────────
494    pub const TUNNEL_BIND_PORT: &str = "8080";
495    pub const TUNNEL_REMOTE_HOST: &str = "localhost";
496    pub const TUNNEL_REMOTE_PORT: &str = "80";
497
498    // ── Snippet form ────────────────────────────────────────────────
499    pub const SNIPPET_NAME: &str = "check-disk";
500    pub const SNIPPET_COMMAND: &str = "df -h";
501    pub const SNIPPET_OPTIONAL: &str = "(optional)";
502    /// Default-hosts field placeholder (the field opens a picker on Space).
503    pub const SNIPPET_DEFAULT_HOSTS: &str = "Space to pick hosts";
504
505    // ── Provider form ───────────────────────────────────────────────
506    pub const PROVIDER_URL: &str = "https://pve.example.com:8006";
507    pub const PROVIDER_TOKEN_DEFAULT: &str = "your-api-token";
508    pub const PROVIDER_TOKEN_PROXMOX: &str = "user@pam!token=secret";
509    pub const PROVIDER_TOKEN_AWS: &str = "AccessKeyId:Secret[:SessionToken] (or use Profile)";
510    pub const PROVIDER_TOKEN_GCP: &str = "/path/to/service-account.json (or access token)";
511    pub const PROVIDER_TOKEN_AZURE: &str = "/path/to/service-principal.json (or access token)";
512    pub const PROVIDER_TOKEN_TAILSCALE: &str = "API key (leave empty for local CLI)";
513    pub const PROVIDER_TOKEN_TELEPORT: &str = "not used (tsh login holds the session)";
514    pub const PROVIDER_TOKEN_ORACLE: &str = "~/.oci/config";
515    pub const PROVIDER_TOKEN_OVH: &str = "app_key:app_secret:consumer_key";
516    pub const PROVIDER_PROFILE: &str = "Name from ~/.aws/credentials (or use Token)";
517    pub const PROVIDER_PROJECT_DEFAULT: &str = "my-gcp-project-id";
518    pub const PROVIDER_PROJECT_OVH: &str = "Public Cloud project ID";
519    pub const PROVIDER_COMPARTMENT: &str = "ocid1.compartment.oc1..aaaa...";
520    pub const PROVIDER_REGIONS_DEFAULT: &str = "Space to select regions";
521    pub const PROVIDER_REGIONS_GCP: &str = "Space to select zones (empty = all)";
522    pub const PROVIDER_REGIONS_SCALEWAY: &str = "Space to select zones";
523    // Azure regions is a text input (not a picker), so no key is mentioned.
524    pub const PROVIDER_REGIONS_AZURE: &str = "comma-separated subscription IDs";
525    pub const PROVIDER_REGIONS_OVH: &str = "Space to select endpoint (default: EU)";
526    pub const PROVIDER_USER_AWS: &str = "ec2-user";
527    pub const PROVIDER_USER_GCP: &str = "ubuntu";
528    pub const PROVIDER_USER_AZURE: &str = "azureuser";
529    pub const PROVIDER_USER_ORACLE: &str = "opc";
530    pub const PROVIDER_USER_OVH: &str = "ubuntu";
531    pub const PROVIDER_USER_TELEPORT: &str = "Teleport login, e.g. root (empty = local user)";
532    pub const PROVIDER_VAULT_ROLE: &str =
533        "e.g. ssh-client-signer/sign/my-role (vault login; inherited)";
534    pub const PROVIDER_VAULT_ADDR: &str = "e.g. http://127.0.0.1:8200 (inherited by all hosts)";
535    pub const PROVIDER_ALIAS_PREFIX_DEFAULT: &str = "prefix";
536    pub const PROVIDER_LABEL: &str = "short name, e.g. server1 or work";
537}
538
539#[cfg(test)]
540mod hints_tests {
541    use super::hints;
542
543    #[test]
544    fn askpass_default_formats() {
545        assert_eq!(hints::askpass_default("keychain"), "default: keychain");
546    }
547
548    #[test]
549    fn askpass_default_formats_empty() {
550        assert_eq!(hints::askpass_default(""), "default: ");
551    }
552
553    #[test]
554    fn inherits_from_formats() {
555        assert_eq!(
556            hints::inherits_from("role/x", "aws"),
557            "inherits role/x from aws"
558        );
559    }
560
561    #[test]
562    fn picker_hints_mention_space_not_enter() {
563        // Per the keyboard invariants, pickers open on Space.
564        // If these assertions fail, audit scripts/check-keybindings.sh too.
565        for s in [
566            hints::IDENTITY_FILE_PICK,
567            hints::HOST_PROXY_JUMP,
568            hints::HOST_VAULT_SSH_PICKER,
569            hints::HOST_ASKPASS_PICK,
570            hints::PROVIDER_REGIONS_DEFAULT,
571            hints::PROVIDER_REGIONS_GCP,
572            hints::PROVIDER_REGIONS_SCALEWAY,
573            hints::PROVIDER_REGIONS_OVH,
574        ] {
575            assert!(
576                s.starts_with("Space "),
577                "picker hint must mention Space: {s}"
578            );
579            assert!(!s.contains("Enter "), "picker hint must not say Enter: {s}");
580        }
581    }
582}
583
584#[path = "messages/whats_new.rs"]
585pub mod whats_new;
586
587#[path = "messages/whats_new_toast.rs"]
588pub mod whats_new_toast;
589
590#[cfg(test)]
591mod relative_age_tests {
592    use super::relative_age;
593    use std::time::Duration;
594
595    #[test]
596    fn relative_age_boundaries() {
597        assert_eq!(relative_age(Duration::from_secs(0)), "just now");
598        assert_eq!(relative_age(Duration::from_secs(4)), "just now");
599        assert_eq!(relative_age(Duration::from_secs(5)), "5s ago");
600        assert_eq!(relative_age(Duration::from_secs(59)), "59s ago");
601        assert_eq!(relative_age(Duration::from_secs(60)), "1m ago");
602        assert_eq!(relative_age(Duration::from_secs(3599)), "59m ago");
603        assert_eq!(relative_age(Duration::from_secs(3600)), "1h ago");
604        assert_eq!(relative_age(Duration::from_secs(86399)), "23h ago");
605        assert_eq!(relative_age(Duration::from_secs(86400)), "1d ago");
606        assert_eq!(relative_age(Duration::from_secs(86400 * 7)), "7d ago");
607    }
608}