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// ── Welcome ─────────────────────────────────────────────────────────
39
40pub const WELCOME_BACKUP_TAKEN: &str = "Your original config has been backed up";
41/// Second line under the backup notice, naming where the copy went.
42pub fn welcome_backup_location(path: &str) -> String {
43    format!("to {path}")
44}
45
46// ── Demo mode ───────────────────────────────────────────────────────
47
48pub const DEMO_CONNECTION_DISABLED: &str = "Demo mode. Connection disabled.";
49pub const DEMO_SYNC_DISABLED: &str = "Demo mode. Sync disabled.";
50pub const DEMO_TUNNELS_DISABLED: &str = "Demo mode. Tunnels disabled.";
51pub const DEMO_VAULT_SIGNING_DISABLED: &str = "Demo mode. Vault SSH signing disabled.";
52pub const DEMO_FILE_BROWSER_DISABLED: &str = "Demo mode. File browser disabled.";
53pub const DEMO_CONTAINER_REFRESH_DISABLED: &str = "Demo mode. Container refresh disabled.";
54pub const DEMO_CONTAINER_ACTIONS_DISABLED: &str = "Demo mode. Container actions disabled.";
55pub const DEMO_EXECUTION_DISABLED: &str = "Demo mode. Execution disabled.";
56pub const DEMO_PROVIDER_CHANGES_DISABLED: &str = "Demo mode. Provider config changes disabled.";
57
58// ── Ping ────────────────────────────────────────────────────────────
59
60pub fn pinging_host(alias: &str, show_hint: bool) -> String {
61    if show_hint {
62        format!("Pinging {}... (Shift+P pings all)", alias)
63    } else {
64        format!("Pinging {}...", alias)
65    }
66}
67
68pub fn bastion_not_found(alias: &str) -> String {
69    format!("Bastion {} not found in config.", alias)
70}
71
72pub fn ping_skipped_proxied(alias: &str) -> String {
73    format!("{} connects through a ProxyCommand. Ping skipped.", alias)
74}
75
76// ── Clipboard subprocess errors ─────────────────────────────────────
77//
78// Surfaced when `pbcopy`/`xclip`/`wl-copy` fails to spawn, write to its
79// stdin, or be reaped. The cmd name is the binary the platform picked.
80
81pub fn clipboard_run_failed(cmd: &str) -> String {
82    format!("Failed to run {}.", cmd)
83}
84
85pub fn clipboard_write_failed(cmd: &str) -> String {
86    format!("Failed to write to {}.", cmd)
87}
88
89pub fn clipboard_wait_failed(cmd: &str) -> String {
90    format!("Failed to wait for {}.", cmd)
91}
92
93pub fn clipboard_exited_error(cmd: &str) -> String {
94    format!("{} exited with error.", cmd)
95}
96
97// ── Import errors ───────────────────────────────────────────────────
98//
99// Bubble up to the CLI via `eprintln!("{}", e)` when the user runs
100// `purple import` against a missing or unreadable file.
101
102pub fn import_open_failed(path: &impl std::fmt::Display, e: &impl std::fmt::Display) -> String {
103    format!("Can't open {}: {}", path, e)
104}
105
106pub fn import_known_hosts_open_failed(e: &impl std::fmt::Display) -> String {
107    format!("Can't open known_hosts: {}", e)
108}
109
110pub const IMPORT_HOME_DIR_UNKNOWN: &str = "Could not determine home directory.";
111pub const IMPORT_KNOWN_HOSTS_MISSING: &str = "~/.ssh/known_hosts not found.";
112
113// ── Import ──────────────────────────────────────────────────────────
114
115pub fn imported_hosts(imported: usize, skipped: usize) -> String {
116    format!(
117        "Imported {} host{}, skipped {} duplicate{}.",
118        imported,
119        if imported == 1 { "" } else { "s" },
120        skipped,
121        if skipped == 1 { "" } else { "s" }
122    )
123}
124
125pub fn all_hosts_exist(skipped: usize) -> String {
126    if skipped == 1 {
127        "Host already exists.".to_string()
128    } else {
129        format!("All {} hosts already exist.", skipped)
130    }
131}
132
133// ── Connection ──────────────────────────────────────────────────────
134
135pub fn opened_in_tmux(alias: &str) -> String {
136    format!("Opened {} in new tmux window.", alias)
137}
138
139pub fn tmux_error(e: &impl std::fmt::Display) -> String {
140    format!("tmux: {}", e)
141}
142
143pub fn connection_failed(alias: &str) -> String {
144    format!("Connection to {} failed.", alias)
145}
146
147/// Stderr line printed when the ssh subprocess itself failed to spawn or
148/// wait (e.g. binary missing, signal interrupted), distinct from a
149/// non-zero exit code which the user sees via the toast.
150pub fn connection_spawn_failed(e: &impl std::fmt::Display) -> String {
151    format!("Connection failed: {}", e)
152}
153
154/// Toast shown when ssh exited non-zero with a captured stderr line we
155/// can show. The reason is the trimmed last meaningful line of ssh stderr.
156pub fn ssh_failed_with_reason(alias: &str, reason: &str) -> String {
157    format!("SSH to {} failed. {}", alias, reason)
158}
159
160/// Toast shown when ssh exited non-zero with no captured stderr to relay.
161/// The exit code is the only signal we have left.
162pub fn ssh_exited_with_code(alias: &str, code: i32) -> String {
163    format!("SSH to {} exited with code {}.", alias, code)
164}
165
166// ── Transfer ────────────────────────────────────────────────────────
167
168pub const TRANSFER_COMPLETE: &str = "Transfer complete.";
169
170// ── Background / event loop ─────────────────────────────────────────
171
172/// Per-provider sync progress line with a leading spinner frame so
173/// `event_loop::handle_tick` animates the prefix while the message is
174/// on screen. Format: `⠋ Proxmox VE: Resolving IPs (1/5)...`. Mirrors
175/// the spinner contract used by `synced_progress` so the footer keeps
176/// animating even when granular per-provider progress overrides the
177/// batch summary mid-sync.
178pub fn provider_progress(spinner: &str, name: &str, message: &str) -> String {
179    format!("{} {}: {}", spinner, name, message)
180}
181
182// ── Relative age (detail panel "checked" suffix) ────────────────────
183
184pub const AGE_JUST_NOW: &str = "just now";
185
186/// Compact relative age: "just now", "12s ago", "3m ago", "2h ago",
187/// "2d ago". Used in the detail panel so the reader can tell stale
188/// data from fresh.
189pub fn relative_age(elapsed: std::time::Duration) -> String {
190    let secs = elapsed.as_secs();
191    if secs < 5 {
192        AGE_JUST_NOW.to_string()
193    } else if secs < 60 {
194        format!("{}s ago", secs)
195    } else if secs < 3600 {
196        format!("{}m ago", secs / 60)
197    } else if secs < 86400 {
198        format!("{}h ago", secs / 3600)
199    } else {
200        format!("{}d ago", secs / 86400)
201    }
202}
203
204// ── Config reload ───────────────────────────────────────────────────
205
206pub fn config_reloaded(count: usize) -> String {
207    format!("Config reloaded. {} hosts.", count)
208}
209
210// ── Sync background ─────────────────────────────────────────────────
211
212/// In-progress sync line for the footer. Format:
213/// `⠋ Syncing AWS, Hetzner · 1/3 (+12 ~3 -1)`.
214/// Active provider names lead so the user immediately sees which provider
215/// is currently in flight (especially relevant when one provider is slow).
216/// `done/total` follows as a counter. The leading character is a braille
217/// spinner frame rotated on every tick. The `(+a ~u -s)` suffix is omitted
218/// when all counts are zero.
219///
220/// Callers MUST only invoke this when `active_names` is non-empty (i.e.
221/// at least one provider is still in flight). The only call site is
222/// `main::set_sync_summary`, which enters this branch via `still_syncing`,
223/// itself gated on `!providers.syncing.is_empty()` — so `active_names`
224/// (built from `syncing.keys()`) is guaranteed non-empty.
225pub fn synced_progress(
226    spinner: &str,
227    active_names: &str,
228    done: usize,
229    total: usize,
230    added: usize,
231    updated: usize,
232    stale: usize,
233) -> String {
234    debug_assert!(
235        !active_names.is_empty(),
236        "synced_progress must only be called while a provider is still in flight"
237    );
238    let diff = sync_diff_suffix(added, updated, stale);
239    format!(
240        "{} Syncing {} \u{00B7} {}/{}{}",
241        spinner, active_names, done, total, diff
242    )
243}
244
245/// Final sync summary for the footer once all providers in the batch have
246/// completed. Format: `Synced 5/5 · AWS, DO, Vultr, Hetzner, Linode (+12 ~3 -1)`.
247/// No spinner prefix, no auto-tick: the message expires by length-proportional
248/// timeout once the batch is done.
249pub fn synced_done(
250    done: usize,
251    total: usize,
252    names: &str,
253    added: usize,
254    updated: usize,
255    stale: usize,
256) -> String {
257    let diff = sync_diff_suffix(added, updated, stale);
258    format!("Synced {}/{} \u{00B7} {}{}", done, total, names, diff)
259}
260
261fn sync_diff_suffix(added: usize, updated: usize, stale: usize) -> String {
262    let parts: Vec<String> = [(added, '+'), (updated, '~'), (stale, '-')]
263        .iter()
264        .filter(|(n, _)| *n > 0)
265        .map(|(n, sign)| format!("{}{}", sign, n))
266        .collect();
267    if parts.is_empty() {
268        String::new()
269    } else {
270        format!(" ({})", parts.join(" "))
271    }
272}
273
274pub const SYNC_THREAD_SPAWN_FAILED: &str = "Failed to start sync thread.";
275
276pub const SYNC_UNKNOWN_PROVIDER: &str = "Unknown provider.";
277
278pub fn sync_skipped_external_change() -> &'static str {
279    "Config changed on disk during sync. Re-run sync after reviewing your edits."
280}
281
282// ── Clipboard ───────────────────────────────────────────────────────
283
284pub const NO_CLIPBOARD_TOOL: &str =
285    "No clipboard tool found. Install pbcopy (macOS), wl-copy (Wayland), or xclip/xsel (X11).";
286
287// ── MCP server ──────────────────────────────────────────────────────
288
289pub const MCP_TOOL_DENIED_READ_ONLY: &str = "Tool denied. Server started with --read-only. Restart without --read-only to enable state-changing tools.";
290
291/// Bare message body. Callers add the `[purple]` fault-domain prefix at
292/// their `warn!` / `error!` site; the `eprintln!` startup diagnostic emits
293/// this body directly without the tag.
294pub fn mcp_audit_init_failed(path: &impl std::fmt::Display, e: &impl std::fmt::Display) -> String {
295    format!(
296        "Failed to initialise MCP audit log at {}: {}. Continuing without audit logging.",
297        path, e
298    )
299}
300
301/// Bare message body. Callers add `[purple]` at the log macro site.
302pub fn mcp_audit_write_failed(e: &impl std::fmt::Display) -> String {
303    format!("Failed to write MCP audit entry: {}", e)
304}
305
306/// Returned to the MCP client as `isError` content when the SSH config path
307/// does not point to an existing file. Surfaces the bug class where a
308/// missing-file silently yields an empty host list.
309pub fn mcp_config_file_not_found(path: &impl std::fmt::Display) -> String {
310    format!("SSH config file not found: {}", path)
311}
312
313/// Logged when `dirs::home_dir()` cannot resolve a home for the audit log
314/// default. Auditing is silently disabled in this state, so the operator
315/// needs an explicit cue.
316pub const MCP_AUDIT_HOME_DIR_UNAVAILABLE: &str = "Could not determine home directory; MCP audit log disabled. Set --audit-log <PATH> explicitly to enable auditing.";
317
318// ── Jump ─────────────────────────────────────────────────
319
320/// Placeholder shown in the jump bar input when the query is empty.
321pub const PALETTE_PLACEHOLDER: &str = "Find anything";
322/// Empty-state copy when the current query has no matches.
323pub const PALETTE_NO_RESULTS: &str = "No matches.";
324/// Toast shown when the user dispatches a snippet from the jump bar while
325/// no host is selected (the snippet picker needs at least one target).
326pub const PALETTE_SNIPPET_NEEDS_HOST: &str =
327    "Pick a host first, then run a snippet from the jump bar.";
328/// Suffix appended to the truncated row list when the visible window is
329/// smaller than the result list.
330pub fn jump_more_rows(n: usize) -> String {
331    format!("+{n} more (scroll down)")
332}
333
334// ── CLI messages ────────────────────────────────────────────────────
335
336#[path = "messages/cli.rs"]
337pub mod cli;
338pub mod footer;
339
340// ── Update messages ─────────────────────────────────────────────────
341
342pub mod update {
343    pub const WHATS_NEW_HINT: &str = "Press n inside purple to see what's new.";
344    pub const DONE: &str = "done.";
345    pub const CHECKSUM_OK: &str = "ok.";
346    pub const SUDO_WARNING: &str =
347        "Running via sudo. Consider fixing directory permissions instead.";
348
349    /// Two-space-indented progress prefixes printed before each step.
350    /// Trailing space is intentional so the success/fail glyph or
351    /// `DONE` constant follows on the same line, matching the visual
352    /// rhythm of the updater output.
353    pub const STEP_CHECKING: &str = "  Checking for updates... ";
354    pub const STEP_VERIFYING_CHECKSUM: &str = "  Verifying checksum... ";
355    pub const STEP_INSTALLING: &str = "  Installing... ";
356
357    pub fn already_on(current: &str) -> String {
358        format!("already on v{} (latest).", current)
359    }
360
361    pub fn available(latest: &str, current: &str) -> String {
362        format!("v{} available (current: v{}).", latest, current)
363    }
364
365    /// Two-space-indented progress prefix for the download step. Matches
366    /// the trailing-space convention of the other STEP_* constants so
367    /// the next print resumes on the same line.
368    pub fn step_downloading(version: &str) -> String {
369        format!("  Downloading v{}... ", version)
370    }
371
372    /// Indented sudo warning rendered before the download step. The
373    /// caller passes a pre-bolded bang (`!`) so the line reads
374    /// `  ! Running via sudo. ...` with the `!` emphasized.
375    pub fn sudo_warning_line(bold_bang: &str) -> String {
376        format!("  {} {}", bold_bang, SUDO_WARNING)
377    }
378
379    pub fn header(bold_name: &str) -> String {
380        format!("\n  {} updater\n", bold_name)
381    }
382
383    pub fn binary_path(path: &std::path::Path) -> String {
384        format!("  Binary: {}", path.display())
385    }
386
387    pub fn installed_at(bold_version: &str, path: &std::path::Path) -> String {
388        format!("\n  {} installed at {}.", bold_version, path.display())
389    }
390
391    pub fn whats_new_hint_indented() -> String {
392        format!("\n  {}", WHATS_NEW_HINT)
393    }
394}
395
396// ── Askpass / password prompts ───────────────────────────────────────
397
398pub mod askpass {
399    pub const BW_NOT_FOUND: &str = "Bitwarden CLI (bw) not found. SSH will prompt for password.";
400    pub const BW_NOT_LOGGED_IN: &str = "Bitwarden vault not logged in. Run 'bw login' first.";
401    pub const EMPTY_PASSWORD: &str = "Empty password. SSH will prompt for password.";
402    pub const PASSWORD_IN_KEYCHAIN: &str = "Password stored in keychain.";
403
404    pub fn read_failed(e: &impl std::fmt::Display) -> String {
405        format!("Failed to read password: {}", e)
406    }
407
408    pub fn unlock_failed_retry(e: &impl std::fmt::Display) -> String {
409        format!("Unlock failed: {}. Try again.", e)
410    }
411
412    pub fn unlock_failed_prompt(e: &impl std::fmt::Display) -> String {
413        format!("Unlock failed: {}. SSH will prompt for password.", e)
414    }
415
416    /// CLI prompt shown by the inline askpass path when the user has no
417    /// stored credential yet. The trailing space is intentional — the
418    /// reader echoes user input directly after.
419    pub fn password_prompt(alias: &str) -> String {
420        format!("Password for {}: ", alias)
421    }
422
423    /// CLI prompt shown when keychain storage is the sink. Reminds the
424    /// user that the entry will be persisted, not just used once.
425    pub fn keychain_password_prompt(alias: &str) -> String {
426        format!("Password for {} (stored in keychain): ", alias)
427    }
428
429    /// Stderr line emitted when the keychain `add-generic-password` call
430    /// failed. The user falls back to ssh's own prompt on the next try.
431    pub fn keychain_store_failed(e: &impl std::fmt::Display) -> String {
432        format!(
433            "Failed to store in keychain: {}. SSH will prompt for password.",
434            e
435        )
436    }
437
438    pub const PROTON_NOT_FOUND: &str =
439        "Proton Pass CLI (pass-cli) not found. SSH will prompt for password.";
440
441    pub const PROTON_LOGIN_PROMPT: &str = "Proton Pass PAT: ";
442
443    pub const PROTON_LOGIN_SUCCESS: &str = "Logged in to Proton Pass.";
444
445    pub fn proton_login_failed_retry(e: &impl std::fmt::Display) -> String {
446        format!("Proton Pass login failed: {}. Try again.", e)
447    }
448
449    pub fn proton_login_failed_prompt(e: &impl std::fmt::Display) -> String {
450        format!(
451            "Proton Pass login failed: {}. SSH will prompt for password.",
452            e
453        )
454    }
455
456    // ── TUI password prompt ─────────────────────────────────────────
457    //
458    // Distinct from the CLI prompts above: nothing here may claim that
459    // "SSH will prompt", because the background ssh never gets a tty.
460
461    /// Overlay title of the password prompt.
462    pub const PROMPT_TITLE: &str = "Password";
463
464    /// Muted text after the bold alias on the identity line.
465    pub const PROMPT_NEEDS_PASSWORD: &str = "needs a password";
466
467    /// Field labels, left column of the prompt.
468    pub const PROMPT_FIELD_PASSWORD: &str = "password";
469    pub const PROMPT_FIELD_REMEMBER: &str = "remember";
470
471    /// Value of the remember toggle when on: the password goes to the OS
472    /// keychain and `# purple:askpass keychain` is written on the host.
473    pub const PROMPT_REMEMBER_ON: &str = "in keychain";
474
475    /// Value of the remember toggle when off: the password lives in memory
476    /// until purple exits.
477    pub const PROMPT_REMEMBER_OFF: &str = "this session only";
478
479    /// Toast when a host with a configured source (Bitwarden, 1Password,
480    /// ...) failed password authentication. Typing a password would not
481    /// fix the source, so no prompt opens.
482    pub fn source_did_not_deliver(alias: &str, source_label: &str) -> String {
483        format!(
484            "{} failed password authentication. {} did not deliver a password. Check the source and try again.",
485            alias, source_label
486        )
487    }
488
489    /// Toast when the server rejected the password typed this session. The
490    /// prompt reopens right after.
491    pub fn password_rejected(alias: &str) -> String {
492        format!("{} rejected the password. Try again.", alias)
493    }
494
495    /// Toast when the keychain store failed and the password is kept for
496    /// this session only.
497    pub fn keychain_store_failed_session(e: &impl std::fmt::Display) -> String {
498        format!(
499            "Could not store in keychain: {}. Using the password for this session only.",
500            e
501        )
502    }
503
504    /// Toast when the password landed in the keychain but purple could not
505    /// find a host block to point at it. An Include file is the usual
506    /// reason: purple writes only the file it owns. The session copy keeps
507    /// the current operation going.
508    pub fn keychain_source_not_written(alias: &str) -> String {
509        format!(
510            "Stored in keychain, but the block for {} is not in the file purple writes, so the password source was not set. Using the password for this session.",
511            alias
512        )
513    }
514
515    /// Toast when the config changed on disk while the prompt was open, so
516    /// purple leaves the file alone rather than writing a stale model over
517    /// the edit. The keychain already holds the password.
518    pub fn keychain_source_skipped_external_change(alias: &str) -> String {
519        format!(
520            "Stored in keychain. Your SSH config changed on disk, so the password source for {} was left for you to set. Using the password for this session.",
521            alias
522        )
523    }
524
525    /// Toast when the keychain accepted the password but the config write
526    /// failed. The stored entry stays: purple cannot tell whether it
527    /// replaced one that was already there.
528    pub fn keychain_stored_without_source(e: &impl std::fmt::Display) -> String {
529        format!(
530            "Stored in keychain, but saving the password source failed: {}. Using the password for this session.",
531            e
532        )
533    }
534
535    /// Toast when the user closes the prompt without typing a password.
536    /// The operation is dropped; the next host waiting for an answer opens
537    /// its own dialog.
538    pub fn prompt_cancelled(alias: &str) -> String {
539        format!("No password given for {}. Skipped.", alias)
540    }
541
542    /// Toast when a jump host on the way to the target refused, rather than
543    /// the target itself. Purple asks for the target's password, so a
544    /// password typed here would not reach the hop that wants one.
545    pub fn hop_refused(hop: &str) -> String {
546        format!(
547            "{} refused the login on the way there. Give that jump host a password source and try again.",
548            hop
549        )
550    }
551
552    /// Shown when a password prompt on a proxied connection cannot be placed
553    /// on a hop. Two shapes reach this: a prompt naming no host at all, and
554    /// one naming a host with no `Host` block of its own. Either end of the
555    /// connection could have sent it, so neither gets an answer. The remedy
556    /// covers only the second shape, which is why it is stated with its
557    /// condition rather than as a step to take.
558    pub fn prompt_names_no_host(alias: &str) -> String {
559        format!(
560            "{} goes through a jump host. purple could not tell which machine asked for the password, so it answered neither. A prompt that spells out its host can be placed by giving that host a Host block of its own.",
561            alias
562        )
563    }
564
565    // ── Security key PIN prompt ─────────────────────────────────────
566
567    /// Overlay title of the PIN prompt.
568    pub const PIN_PROMPT_TITLE: &str = "Security Key";
569
570    /// Muted text after the bold alias on the identity line.
571    pub const PIN_PROMPT_NEEDS_PIN: &str = "needs a security key PIN";
572
573    /// Field labels of the PIN prompt.
574    pub const PIN_FIELD_KEY: &str = "key";
575    pub const PIN_FIELD_PIN: &str = "PIN";
576
577    /// Muted line under the PIN field.
578    pub const PIN_TOUCH_HINT: &str = "Touch the key when it blinks.";
579
580    /// Toast once the PIN went out with the operation that asked for it.
581    pub const PIN_TOUCH_KEY: &str = "Touch your security key when it blinks.";
582
583    /// Shown in the TUI and on the terminal when the typed PIN is too short
584    /// to ever be right.
585    pub fn pin_too_short() -> String {
586        format!(
587            "A security key PIN has at least {} characters. Some keys require more.",
588            crate::security_key::PIN_MIN_CHARS
589        )
590    }
591
592    /// Toast when the PIN prompt reopens because the key refused.
593    pub fn pin_refused(key: &str, reason: crate::security_key::RefusalReason) -> String {
594        use crate::security_key::RefusalReason;
595        match reason {
596            RefusalReason::WrongPin => format!(
597                "Wrong PIN for {}. After three in a row the key needs a replug.",
598                key
599            ),
600            RefusalReason::NoAnswer => format!(
601                "The key for {} did not answer. Touch it sooner or replug it.",
602                key
603            ),
604            RefusalReason::NotFound => format!("No security key found for {}. Plug it in.", key),
605            RefusalReason::Other => format!("The security key for {} could not sign.", key),
606        }
607    }
608
609    /// Toast when the key refused the PIN but the host let the user in with
610    /// another method.
611    pub fn pin_refused_but_connected(alias: &str, key: &str) -> String {
612        format!("Wrong PIN for {}. {} let you in another way.", key, alias)
613    }
614
615    /// Toast when the user closes the PIN prompt without a PIN.
616    pub fn pin_prompt_canceled(alias: &str) -> String {
617        format!("No security key PIN given for {}. Skipped.", alias)
618    }
619
620    /// File browser pane when the PIN prompt cannot open right now.
621    pub fn pin_needed_listing(alias: &str, key: &str) -> String {
622        format!(
623            "{} needs the PIN of security key {}. Press R to enter it.",
624            alias, key
625        )
626    }
627
628    /// Transfer error when the host wants a PIN nobody gave yet.
629    pub fn pin_needed_transfer(alias: &str, key: &str) -> String {
630        format!(
631            "{} needs the PIN of security key {}. Refresh the listing with R to enter it.",
632            alias, key
633        )
634    }
635
636    /// Toast when a tunnel wants a PIN and no dialog may open right now.
637    pub fn pin_needed_tunnel(alias: &str, key: &str) -> String {
638        format!(
639            "Tunnel for {} needs the PIN of security key {}. Start it again to enter it.",
640            alias, key
641        )
642    }
643}
644
645/// Host key trust dialog, shown when a background ssh met a host that is
646/// not in `known_hosts` yet.
647pub mod host_key_trust {
648    pub const TITLE: &str = "New Host";
649
650    /// Bold question line.
651    pub fn question(hostname: &str) -> String {
652        format!("Trust {} on first contact?", hostname)
653    }
654
655    /// First muted detail line: which entry this is about.
656    pub fn detail_unknown(alias: &str) -> String {
657        format!("{} is not in known_hosts yet.", alias)
658    }
659
660    /// Second muted detail line: what trusting does.
661    pub const DETAIL_RECORDED: &str =
662        "Its key is recorded now and checked on every later connection.";
663
664    /// Toast when the user declines. The operation is dropped.
665    pub fn declined(alias: &str) -> String {
666        format!("Host key for {} not trusted. Skipped.", alias)
667    }
668
669    /// Toast when the host ssh does not know is a jump host on the way to
670    /// the target. Trusting it under the target's name would record the
671    /// wrong key, so purple names the hop and leaves the choice there.
672    pub fn hop_is_unknown(hop: &str) -> String {
673        format!(
674            "{} is not in known_hosts yet, and it sits on the way there. Connect to that jump host once to record its key.",
675            hop
676        )
677    }
678}
679
680// ── Logging ─────────────────────────────────────────────────────────
681
682pub mod logging {
683    pub fn init_failed(e: &impl std::fmt::Display) -> String {
684        format!("[purple] Failed to initialize logger: {}", e)
685    }
686
687    pub const SSH_VERSION_FAILED: &str = "[purple] Failed to detect SSH version. Is ssh installed?";
688}
689
690// ── Form field hints / placeholders ─────────────────────────────────
691//
692// Dimmed placeholder text shown in empty form fields. Centralized here
693// so every user-visible string lives in one place and is auditable.
694
695pub mod hints {
696    // ── Shared ──────────────────────────────────────────────────────
697    // Picker hints mention "Space" because per the design system keyboard
698    // invariants, Enter always submits a form; pickers open on Space.
699    // Keep these strings in sync with scripts/check-keybindings.sh.
700    pub const IDENTITY_FILE_PICK: &str = "Space to pick a key";
701    pub const DEFAULT_SSH_USER: &str = "root";
702
703    // ── Host form ───────────────────────────────────────────────────
704    pub const HOST_ALIAS: &str = "e.g. prod or db-01";
705    pub const HOST_ALIAS_PATTERN: &str = "10.0.0.* or *.example.com";
706    pub const HOST_HOSTNAME: &str = "192.168.1.1 or example.com";
707    pub const HOST_PORT: &str = "22";
708    pub const HOST_PROXY_JUMP: &str = "Space to pick a host";
709    pub const HOST_VAULT_SSH: &str = "e.g. ssh-client-signer/sign/my-role (auth via vault login)";
710    pub const HOST_VAULT_SSH_PICKER: &str = "Space to pick a role or type one";
711    pub const HOST_VAULT_ADDR: &str =
712        "e.g. http://127.0.0.1:8200 (inherits from provider or env when empty)";
713    pub const HOST_TAGS: &str = "e.g. prod, staging, us-east (comma-separated)";
714    pub const HOST_ASKPASS_PICK: &str = "Space to pick a source";
715
716    pub fn askpass_default(default: &str) -> String {
717        format!("default: {}", default)
718    }
719
720    pub fn inherits_from(value: &str, provider: &str) -> String {
721        format!("inherits {} from {}", value, provider)
722    }
723
724    // ── Tunnel form ─────────────────────────────────────────────────
725    pub const TUNNEL_BIND_PORT: &str = "8080";
726    pub const TUNNEL_REMOTE_HOST: &str = "localhost";
727    pub const TUNNEL_REMOTE_PORT: &str = "80";
728
729    // ── Snippet form ────────────────────────────────────────────────
730    pub const SNIPPET_NAME: &str = "check-disk";
731    pub const SNIPPET_COMMAND: &str = "df -h";
732    pub const SNIPPET_OPTIONAL: &str = "(optional)";
733    /// Default-hosts field placeholder (the field opens a picker on Space).
734    pub const SNIPPET_DEFAULT_HOSTS: &str = "Space to pick hosts";
735
736    /// The Interactive shell toggle as the form shows it, read as the outcome.
737    pub fn snippet_interactive_value(interactive: bool) -> &'static str {
738        if interactive {
739            "yes (loads ~/.bashrc or ~/.zshrc, like when you log in)"
740        } else {
741            "no (plain ssh)"
742        }
743    }
744
745    // ── Provider form ───────────────────────────────────────────────
746    pub const PROVIDER_URL: &str = "https://pve.example.com:8006";
747    pub const PROVIDER_URL_NETBOX: &str = "https://netbox.example.com";
748    pub const PROVIDER_TOKEN_DEFAULT: &str = "your-api-token";
749    pub const PROVIDER_TOKEN_PROXMOX: &str = "user@pam!token=secret";
750    pub const PROVIDER_TOKEN_NETBOX: &str = "nbt_... (v2) or classic API token";
751    pub const PROVIDER_TOKEN_AWS: &str = "AccessKeyId:Secret[:SessionToken] (or Profile or env)";
752    pub const PROVIDER_TOKEN_GCP: &str = "/path/to/service-account.json (or access token)";
753    pub const PROVIDER_TOKEN_AZURE: &str = "/path/to/service-principal.json (or access token)";
754    pub const PROVIDER_TOKEN_TAILSCALE: &str = "API key (leave empty for local CLI)";
755    pub const PROVIDER_TOKEN_TELEPORT: &str = "not used (tsh login holds the session)";
756    pub const PROVIDER_TOKEN_ORACLE: &str = "~/.oci/config";
757    pub const PROVIDER_TOKEN_OVH: &str = "app_key:app_secret:consumer_key";
758    pub const PROVIDER_PROFILE: &str = "Space to select a profile (or use Token)";
759    pub const PROVIDER_PROJECT_DEFAULT: &str = "my-gcp-project-id";
760    pub const PROVIDER_PROJECT_OVH: &str = "Public Cloud project ID";
761    pub const PROVIDER_COMPARTMENT: &str = "ocid1.compartment.oc1..aaaa...";
762    pub const PROVIDER_REGIONS_DEFAULT: &str = "Space to select regions";
763    pub const PROVIDER_REGIONS_GCP: &str = "Space to select zones (empty = all)";
764    pub const PROVIDER_REGIONS_SCALEWAY: &str = "Space to select zones";
765    // Azure regions is a text input (not a picker), so no key is mentioned.
766    pub const PROVIDER_REGIONS_AZURE: &str = "comma-separated subscription IDs";
767    pub const PROVIDER_REGIONS_OVH: &str = "Space to select endpoint (default: EU)";
768    pub const PROVIDER_FILTER_NETBOX: &str = "status=active&tag=ssh (NetBox query params)";
769
770    /// The Session Manager mode as the form shows it. Each reads as the
771    /// outcome rather than the setting name, the way the other toggles do.
772    pub fn ssm_mode_value(mode: crate::providers::aws_ssm::SsmMode) -> &'static str {
773        use crate::providers::aws_ssm::SsmMode;
774        match mode {
775            SsmMode::Off => "off (connect to the IP address)",
776            SsmMode::Auto => "auto (route the nodes SSM reports online)",
777            SsmMode::Always => "always (route every instance)",
778        }
779    }
780    pub const PROVIDER_USER_AWS: &str = "ec2-user";
781    pub const PROVIDER_USER_GCP: &str = "ubuntu";
782    pub const PROVIDER_USER_AZURE: &str = "azureuser";
783    pub const PROVIDER_USER_ORACLE: &str = "opc";
784    pub const PROVIDER_USER_OVH: &str = "ubuntu";
785    pub const PROVIDER_USER_TELEPORT: &str = "Teleport login, e.g. root (empty = local user)";
786    pub const PROVIDER_VAULT_ROLE: &str =
787        "e.g. ssh-client-signer/sign/my-role (vault login; inherited)";
788    pub const PROVIDER_VAULT_ADDR: &str = "e.g. http://127.0.0.1:8200 (inherited by all hosts)";
789    pub const PROVIDER_ALIAS_PREFIX_DEFAULT: &str = "prefix";
790    pub const PROVIDER_LABEL: &str = "short name, e.g. server1 or work";
791}
792
793#[cfg(test)]
794mod hints_tests {
795    use super::hints;
796
797    #[test]
798    fn askpass_default_formats() {
799        assert_eq!(hints::askpass_default("keychain"), "default: keychain");
800    }
801
802    #[test]
803    fn askpass_default_formats_empty() {
804        assert_eq!(hints::askpass_default(""), "default: ");
805    }
806
807    #[test]
808    fn inherits_from_formats() {
809        assert_eq!(
810            hints::inherits_from("role/x", "aws"),
811            "inherits role/x from aws"
812        );
813    }
814
815    #[test]
816    fn picker_hints_mention_space_not_enter() {
817        // Per the keyboard invariants, pickers open on Space.
818        // If these assertions fail, audit scripts/check-keybindings.sh too.
819        for s in [
820            hints::IDENTITY_FILE_PICK,
821            hints::HOST_PROXY_JUMP,
822            hints::HOST_VAULT_SSH_PICKER,
823            hints::HOST_ASKPASS_PICK,
824            hints::PROVIDER_REGIONS_DEFAULT,
825            hints::PROVIDER_REGIONS_GCP,
826            hints::PROVIDER_REGIONS_SCALEWAY,
827            hints::PROVIDER_REGIONS_OVH,
828            hints::PROVIDER_PROFILE,
829        ] {
830            assert!(
831                s.starts_with("Space "),
832                "picker hint must mention Space: {s}"
833            );
834            assert!(!s.contains("Enter "), "picker hint must not say Enter: {s}");
835        }
836    }
837}
838
839#[path = "messages/whats_new.rs"]
840pub mod whats_new;
841
842#[path = "messages/whats_new_toast.rs"]
843pub mod whats_new_toast;
844
845#[cfg(test)]
846mod relative_age_tests {
847    use super::relative_age;
848    use std::time::Duration;
849
850    #[test]
851    fn relative_age_boundaries() {
852        assert_eq!(relative_age(Duration::from_secs(0)), "just now");
853        assert_eq!(relative_age(Duration::from_secs(4)), "just now");
854        assert_eq!(relative_age(Duration::from_secs(5)), "5s ago");
855        assert_eq!(relative_age(Duration::from_secs(59)), "59s ago");
856        assert_eq!(relative_age(Duration::from_secs(60)), "1m ago");
857        assert_eq!(relative_age(Duration::from_secs(3599)), "59m ago");
858        assert_eq!(relative_age(Duration::from_secs(3600)), "1h ago");
859        assert_eq!(relative_age(Duration::from_secs(86399)), "23h ago");
860        assert_eq!(relative_age(Duration::from_secs(86400)), "1d ago");
861        assert_eq!(relative_age(Duration::from_secs(86400 * 7)), "7d ago");
862    }
863}