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 provider_not_configured(display_name: &str) -> String {
230    format!("{} is not configured. Nothing to remove.", display_name)
231}
232
233pub fn provider_configure_first(display_name: &str) -> String {
234    format!("Configure {} first. Press Enter to set up.", display_name)
235}
236
237pub fn provider_saved_syncing(display_name: &str) -> String {
238    format!("Saved {} configuration. Syncing...", display_name)
239}
240
241pub fn provider_saved(display_name: &str) -> String {
242    format!("Saved {} configuration.", display_name)
243}
244
245pub fn no_stale_hosts_for(display_name: &str) -> String {
246    format!("No stale hosts for {}.", display_name)
247}
248
249pub fn contains_control_chars(name: &str) -> String {
250    format!("{} contains control characters.", name)
251}
252
253pub const TOKEN_FORMAT_AWS: &str = "Token format: AccessKeyId:SecretAccessKey";
254pub const URL_REQUIRED_PROXMOX: &str = "URL is required for Proxmox VE.";
255pub const PROJECT_REQUIRED_GCP: &str = "Project ID can't be empty. Set your GCP project ID.";
256pub const COMPARTMENT_REQUIRED_OCI: &str =
257    "Compartment can't be empty. Set your OCI compartment OCID.";
258pub const REGIONS_REQUIRED_AWS: &str = "Select at least one AWS region.";
259pub const ZONES_REQUIRED_SCALEWAY: &str = "Select at least one Scaleway zone.";
260pub const SUBSCRIPTIONS_REQUIRED_AZURE: &str = "Enter at least one Azure subscription ID.";
261pub const ALIAS_PREFIX_INVALID: &str =
262    "Alias prefix can't contain spaces or pattern characters (*, ?, [, !).";
263pub const USER_NO_WHITESPACE: &str = "User can't contain whitespace.";
264pub const VAULT_ROLE_FORMAT: &str = "Vault SSH role must be in the form <mount>/sign/<role>.";
265
266// ── Vault SSH ───────────────────────────────────────────────────────
267
268pub const VAULT_SIGNING_CANCELLED: &str = "Vault SSH signing cancelled.";
269pub const VAULT_NO_ROLE_CONFIGURED: &str = "No Vault SSH role configured. Set one in the host form \
270     (Vault SSH role field) or on a provider for shared defaults.";
271pub const VAULT_NO_HOSTS_WITH_ROLE: &str = "No hosts with a Vault SSH role configured.";
272pub const VAULT_ALL_CERTS_VALID: &str = "All Vault SSH certificates are still valid.";
273pub const VAULT_NO_ADDRESS: &str = "No Vault address set. Edit the host (e) or provider \
274     and fill in the Vault SSH Address field.";
275
276pub fn vault_error(msg: &str) -> String {
277    format!("Vault SSH: {}", msg)
278}
279
280pub fn vault_signed(alias: &str) -> String {
281    format!("Signed Vault SSH cert for {}", alias)
282}
283
284pub fn vault_sign_failed(alias: &str, message: &str) -> String {
285    format!("Vault SSH: failed to sign {}: {}", alias, message)
286}
287
288pub fn vault_signing_progress(spinner: &str, done: usize, total: usize, alias: &str) -> String {
289    format!(
290        "{} Signing {}/{}: {} (V to cancel)",
291        spinner, done, total, alias
292    )
293}
294
295pub fn vault_cert_saved_host_gone(alias: &str) -> String {
296    format!(
297        "Vault SSH cert saved for {} but host no longer in config \
298         (renamed or deleted). CertificateFile NOT written.",
299        alias
300    )
301}
302
303pub fn vault_spawn_failed(e: &impl std::fmt::Display) -> String {
304    format!("Vault SSH: failed to spawn signing thread: {}", e)
305}
306
307pub fn vault_cert_check_failed(alias: &str, message: &str) -> String {
308    format!("Cert check failed for {}: {}", alias, message)
309}
310
311pub fn vault_role_set(role: &str) -> String {
312    format!("Vault SSH role set to {}.", role)
313}
314
315// ── Snippets ────────────────────────────────────────────────────────
316
317pub fn snippet_removed(name: &str) -> String {
318    format!("Removed snippet '{}'.", name)
319}
320
321pub fn snippet_added(name: &str) -> String {
322    format!("Added snippet '{}'.", name)
323}
324
325pub fn snippet_updated(name: &str) -> String {
326    format!("Updated snippet '{}'.", name)
327}
328
329pub fn snippet_exists(name: &str) -> String {
330    format!("'{}' already exists.", name)
331}
332
333pub const OUTPUT_COPIED: &str = "Output copied.";
334
335pub fn copy_failed(e: &impl std::fmt::Display) -> String {
336    format!("Copy failed: {}", e)
337}
338
339// ── Picker (password source, key, proxy) ────────────────────────────
340
341pub const GLOBAL_DEFAULT_CLEARED: &str = "Global default cleared.";
342pub const PASSWORD_SOURCE_CLEARED: &str = "Password source cleared.";
343
344pub fn global_default_set(label: &str) -> String {
345    format!("Global default set to {}.", label)
346}
347
348pub fn password_source_set(label: &str) -> String {
349    format!("Password source set to {}.", label)
350}
351
352pub fn complete_path(label: &str) -> String {
353    format!("Complete the {} path.", label)
354}
355
356pub fn key_selected(name: &str) -> String {
357    format!("Locked and loaded with {}.", name)
358}
359
360pub fn proxy_jump_set(alias: &str) -> String {
361    format!("Jumping through {}.", alias)
362}
363
364pub fn save_default_failed(e: &impl std::fmt::Display) -> String {
365    format!("Failed to save default: {}", e)
366}
367
368// ── Containers ──────────────────────────────────────────────────────
369
370pub fn container_action_complete(action: &str) -> String {
371    format!("Container {} complete.", action)
372}
373
374pub const HOST_KEY_UNKNOWN: &str = "Host key unknown. Connect first (Enter) to trust the host.";
375pub const HOST_KEY_CHANGED: &str =
376    "Host key changed. Possible tampering or server re-install. Clear with ssh-keygen -R.";
377
378// ── Import ──────────────────────────────────────────────────────────
379
380pub fn imported_hosts(imported: usize, skipped: usize) -> String {
381    format!(
382        "Imported {} host{}, skipped {} duplicate{}.",
383        imported,
384        if imported == 1 { "" } else { "s" },
385        skipped,
386        if skipped == 1 { "" } else { "s" }
387    )
388}
389
390pub fn all_hosts_exist(skipped: usize) -> String {
391    if skipped == 1 {
392        "Host already exists.".to_string()
393    } else {
394        format!("All {} hosts already exist.", skipped)
395    }
396}
397
398// ── SSH config repair ───────────────────────────────────────────────
399
400pub fn config_repaired(groups: usize, orphaned: usize) -> String {
401    format!(
402        "Repaired SSH config ({} absorbed, {} orphaned group headers).",
403        groups, orphaned
404    )
405}
406
407pub fn no_exact_match(alias: &str) -> String {
408    format!("No exact match for '{}'. Here's what we found.", alias)
409}
410
411pub fn group_pref_reset_failed(e: &impl std::fmt::Display) -> String {
412    format!("Group preference reset. (save failed: {})", e)
413}
414
415// ── Connection ──────────────────────────────────────────────────────
416
417pub fn opened_in_tmux(alias: &str) -> String {
418    format!("Opened {} in new tmux window.", alias)
419}
420
421pub fn tmux_error(e: &impl std::fmt::Display) -> String {
422    format!("tmux: {}", e)
423}
424
425pub fn connection_failed(alias: &str) -> String {
426    format!("Connection to {} failed.", alias)
427}
428
429// ── Host key reset ──────────────────────────────────────────────────
430
431pub fn host_key_remove_failed(stderr: &str) -> String {
432    format!("Failed to remove host key: {}", stderr)
433}
434
435pub fn ssh_keygen_failed(e: &impl std::fmt::Display) -> String {
436    format!("Failed to run ssh-keygen: {}", e)
437}
438
439// ── Transfer ────────────────────────────────────────────────────────
440
441pub const TRANSFER_COMPLETE: &str = "Transfer complete.";
442
443// ── Background / event loop ─────────────────────────────────────────
444
445/// Per-provider sync progress line with a leading spinner frame so
446/// `event_loop::handle_tick` animates the prefix while the message is
447/// on screen. Format: `⠋ Proxmox VE: Resolving IPs (1/5)...`. Mirrors
448/// the spinner contract used by `synced_progress` so the footer keeps
449/// animating even when granular per-provider progress overrides the
450/// batch summary mid-sync.
451pub fn provider_progress(spinner: &str, name: &str, message: &str) -> String {
452    format!("{} {}: {}", spinner, name, message)
453}
454
455// ── Relative age (detail panel "checked" suffix) ────────────────────
456
457pub const AGE_JUST_NOW: &str = "just now";
458
459/// Compact relative age: "just now", "12s ago", "3m ago", "2h ago",
460/// "2d ago". Used in the detail panel so the reader can tell stale
461/// data from fresh.
462pub fn relative_age(elapsed: std::time::Duration) -> String {
463    let secs = elapsed.as_secs();
464    if secs < 5 {
465        AGE_JUST_NOW.to_string()
466    } else if secs < 60 {
467        format!("{}s ago", secs)
468    } else if secs < 3600 {
469        format!("{}m ago", secs / 60)
470    } else if secs < 86400 {
471        format!("{}h ago", secs / 3600)
472    } else {
473        format!("{}d ago", secs / 86400)
474    }
475}
476
477// ── Vault SSH bulk signing summaries (event_loop.rs) ────────────────
478
479pub fn vault_config_reapply_failed(signed: usize, e: &impl std::fmt::Display) -> String {
480    format!(
481        "External edits detected; signed {} certs but failed to re-apply CertificateFile: {}",
482        signed, e
483    )
484}
485
486pub fn vault_external_edits_merged(summary: &str, reapplied: usize) -> String {
487    format!(
488        "{} External ssh config edits detected, merged {} CertificateFile directives.",
489        summary, reapplied
490    )
491}
492
493pub fn vault_external_edits_no_write(summary: &str) -> String {
494    format!(
495        "{} External ssh config edits detected; certs on disk, no CertificateFile written.",
496        summary
497    )
498}
499
500pub fn vault_reparse_failed(signed: usize, e: &impl std::fmt::Display) -> String {
501    format!(
502        "Signed {} certs but cannot re-parse ssh config after external edit: {}. \
503         Certs are on disk under ~/.purple/certs/.",
504        signed, e
505    )
506}
507
508pub fn vault_config_update_failed(signed: usize, e: &impl std::fmt::Display) -> String {
509    format!(
510        "Signed {} certs but failed to update SSH config: {}",
511        signed, e
512    )
513}
514
515pub fn vault_config_write_after_sign(e: &impl std::fmt::Display) -> String {
516    format!("Failed to update config after vault signing: {}", e)
517}
518
519// ── File browser ────────────────────────────────────────────────────
520
521// ── Confirm / host key ──────────────────────────────────────────────
522
523pub fn removed_host_key(hostname: &str) -> String {
524    format!("Removed host key for {}. Reconnecting...", hostname)
525}
526
527// ── Host detail (tags) ──────────────────────────────────────────────
528
529pub fn tagged_host(alias: &str, count: usize) -> String {
530    format!(
531        "Tagged {} with {} label{}.",
532        alias,
533        count,
534        if count == 1 { "" } else { "s" }
535    )
536}
537
538// ── Config reload ───────────────────────────────────────────────────
539
540pub fn config_reloaded(count: usize) -> String {
541    format!("Config reloaded. {} hosts.", count)
542}
543
544// ── Sync background ─────────────────────────────────────────────────
545
546/// In-progress sync line for the footer. Format:
547/// `⠋ Syncing AWS, Hetzner · 1/3 (+12 ~3 -1)`.
548/// Active provider names lead so the user immediately sees which provider
549/// is currently in flight (especially relevant when one provider is slow).
550/// `done/total` follows as a counter. The leading character is a braille
551/// spinner frame rotated on every tick. The `(+a ~u -s)` suffix is omitted
552/// when all counts are zero.
553///
554/// Callers MUST only invoke this when `active_names` is non-empty (i.e.
555/// at least one provider is still in flight). The only call site is
556/// `main::set_sync_summary`, which enters this branch via `still_syncing`,
557/// itself gated on `!providers.syncing.is_empty()` — so `active_names`
558/// (built from `syncing.keys()`) is guaranteed non-empty.
559pub fn synced_progress(
560    spinner: &str,
561    active_names: &str,
562    done: usize,
563    total: usize,
564    added: usize,
565    updated: usize,
566    stale: usize,
567) -> String {
568    debug_assert!(
569        !active_names.is_empty(),
570        "synced_progress must only be called while a provider is still in flight"
571    );
572    let diff = sync_diff_suffix(added, updated, stale);
573    format!(
574        "{} Syncing {} \u{00B7} {}/{}{}",
575        spinner, active_names, done, total, diff
576    )
577}
578
579/// Final sync summary for the footer once all providers in the batch have
580/// completed. Format: `Synced 5/5 · AWS, DO, Vultr, Hetzner, Linode (+12 ~3 -1)`.
581/// No spinner prefix, no auto-tick: the message expires by length-proportional
582/// timeout once the batch is done.
583pub fn synced_done(
584    done: usize,
585    total: usize,
586    names: &str,
587    added: usize,
588    updated: usize,
589    stale: usize,
590) -> String {
591    let diff = sync_diff_suffix(added, updated, stale);
592    format!("Synced {}/{} \u{00B7} {}{}", done, total, names, diff)
593}
594
595fn sync_diff_suffix(added: usize, updated: usize, stale: usize) -> String {
596    let parts: Vec<String> = [(added, '+'), (updated, '~'), (stale, '-')]
597        .iter()
598        .filter(|(n, _)| *n > 0)
599        .map(|(n, sign)| format!("{}{}", sign, n))
600        .collect();
601    if parts.is_empty() {
602        String::new()
603    } else {
604        format!(" ({})", parts.join(" "))
605    }
606}
607
608pub const SYNC_THREAD_SPAWN_FAILED: &str = "Failed to start sync thread.";
609
610pub const SYNC_UNKNOWN_PROVIDER: &str = "Unknown provider.";
611
612// ── Vault signing cancelled summary ─────────────────────────────────
613
614pub fn vault_signing_cancelled_summary(
615    signed: u32,
616    failed: u32,
617    first_error: Option<&str>,
618) -> String {
619    let mut msg = format!(
620        "Vault SSH signing cancelled ({} signed, {} failed)",
621        signed, failed
622    );
623    if let Some(err) = first_error {
624        msg.push_str(": ");
625        msg.push_str(err);
626    }
627    msg
628}
629
630// ── Region picker ───────────────────────────────────────────────────
631
632pub fn regions_selected_count(count: usize, label: &str) -> String {
633    let s = if count == 1 { "" } else { "s" };
634    format!("{} {}{} selected.", count, label, s)
635}
636
637// ── Purge stale ─────────────────────────────────────────────────────
638
639// ── Clipboard ───────────────────────────────────────────────────────
640
641pub const NO_CLIPBOARD_TOOL: &str =
642    "No clipboard tool found. Install pbcopy (macOS), wl-copy (Wayland), or xclip/xsel (X11).";
643
644// ── MCP server ──────────────────────────────────────────────────────
645
646pub const MCP_TOOL_DENIED_READ_ONLY: &str = "Tool denied. Server started with --read-only. Restart without --read-only to enable state-changing tools.";
647
648/// Bare message body. Callers add the `[purple]` fault-domain prefix at
649/// their `warn!` / `error!` site; the `eprintln!` startup diagnostic emits
650/// this body directly without the tag.
651pub fn mcp_audit_init_failed(path: &impl std::fmt::Display, e: &impl std::fmt::Display) -> String {
652    format!(
653        "Failed to initialise MCP audit log at {}: {}. Continuing without audit logging.",
654        path, e
655    )
656}
657
658/// Bare message body. Callers add `[purple]` at the log macro site.
659pub fn mcp_audit_write_failed(e: &impl std::fmt::Display) -> String {
660    format!("Failed to write MCP audit entry: {}", e)
661}
662
663/// Returned to the MCP client as `isError` content when the SSH config path
664/// does not point to an existing file. Surfaces the bug class where a
665/// missing-file silently yields an empty host list.
666pub fn mcp_config_file_not_found(path: &impl std::fmt::Display) -> String {
667    format!("SSH config file not found: {}", path)
668}
669
670/// Logged when `dirs::home_dir()` cannot resolve a home for the audit log
671/// default. Auditing is silently disabled in this state, so the operator
672/// needs an explicit cue.
673pub const MCP_AUDIT_HOME_DIR_UNAVAILABLE: &str = "Could not determine home directory; MCP audit log disabled. Set --audit-log <PATH> explicitly to enable auditing.";
674
675// ── CLI messages ────────────────────────────────────────────────────
676
677#[path = "messages/cli.rs"]
678pub mod cli;
679
680// ── Update messages ─────────────────────────────────────────────────
681
682pub mod update {
683    pub const WHATS_NEW_HINT: &str = "Press n inside purple to see what's new.";
684    pub const DONE: &str = "done.";
685    pub const CHECKSUM_OK: &str = "ok.";
686    pub const SUDO_WARNING: &str =
687        "Running via sudo. Consider fixing directory permissions instead.";
688
689    pub fn already_on(current: &str) -> String {
690        format!("already on v{} (latest).", current)
691    }
692
693    pub fn available(latest: &str, current: &str) -> String {
694        format!("v{} available (current: v{}).", latest, current)
695    }
696
697    pub fn header(bold_name: &str) -> String {
698        format!("\n  {} updater\n", bold_name)
699    }
700
701    pub fn binary_path(path: &std::path::Path) -> String {
702        format!("  Binary: {}", path.display())
703    }
704
705    pub fn installed_at(bold_version: &str, path: &std::path::Path) -> String {
706        format!("\n  {} installed at {}.", bold_version, path.display())
707    }
708
709    pub fn whats_new_hint_indented() -> String {
710        format!("\n  {}", WHATS_NEW_HINT)
711    }
712}
713
714// ── Askpass / password prompts ───────────────────────────────────────
715
716pub mod askpass {
717    pub const BW_NOT_FOUND: &str = "Bitwarden CLI (bw) not found. SSH will prompt for password.";
718    pub const BW_NOT_LOGGED_IN: &str = "Bitwarden vault not logged in. Run 'bw login' first.";
719    pub const EMPTY_PASSWORD: &str = "Empty password. SSH will prompt for password.";
720    pub const PASSWORD_IN_KEYCHAIN: &str = "Password stored in keychain.";
721
722    pub fn read_failed(e: &impl std::fmt::Display) -> String {
723        format!("Failed to read password: {}", e)
724    }
725
726    pub fn unlock_failed_retry(e: &impl std::fmt::Display) -> String {
727        format!("Unlock failed: {}. Try again.", e)
728    }
729
730    pub fn unlock_failed_prompt(e: &impl std::fmt::Display) -> String {
731        format!("Unlock failed: {}. SSH will prompt for password.", e)
732    }
733}
734
735// ── Logging ─────────────────────────────────────────────────────────
736
737pub mod logging {
738    pub fn init_failed(e: &impl std::fmt::Display) -> String {
739        format!("[purple] Failed to initialize logger: {}", e)
740    }
741
742    pub const SSH_VERSION_FAILED: &str = "[purple] Failed to detect SSH version. Is ssh installed?";
743}
744
745// ── Form field hints / placeholders ─────────────────────────────────
746//
747// Dimmed placeholder text shown in empty form fields. Centralized here
748// so every user-visible string lives in one place and is auditable.
749
750pub mod hints {
751    // ── Shared ──────────────────────────────────────────────────────
752    // Picker hints mention "Space" because per the design system keyboard
753    // invariants, Enter always submits a form; pickers open on Space.
754    // Keep these strings in sync with scripts/check-keybindings.sh.
755    pub const IDENTITY_FILE_PICK: &str = "Space to pick a key";
756    pub const DEFAULT_SSH_USER: &str = "root";
757
758    // ── Host form ───────────────────────────────────────────────────
759    pub const HOST_ALIAS: &str = "e.g. prod or db-01";
760    pub const HOST_ALIAS_PATTERN: &str = "10.0.0.* or *.example.com";
761    pub const HOST_HOSTNAME: &str = "192.168.1.1 or example.com";
762    pub const HOST_PORT: &str = "22";
763    pub const HOST_PROXY_JUMP: &str = "Space to pick a host";
764    pub const HOST_VAULT_SSH: &str = "e.g. ssh-client-signer/sign/my-role (auth via vault login)";
765    pub const HOST_VAULT_SSH_PICKER: &str = "Space to pick a role or type one";
766    pub const HOST_VAULT_ADDR: &str =
767        "e.g. http://127.0.0.1:8200 (inherits from provider or env when empty)";
768    pub const HOST_TAGS: &str = "e.g. prod, staging, us-east (comma-separated)";
769    pub const HOST_ASKPASS_PICK: &str = "Space to pick a source";
770
771    pub fn askpass_default(default: &str) -> String {
772        format!("default: {}", default)
773    }
774
775    pub fn inherits_from(value: &str, provider: &str) -> String {
776        format!("inherits {} from {}", value, provider)
777    }
778
779    // ── Tunnel form ─────────────────────────────────────────────────
780    pub const TUNNEL_BIND_PORT: &str = "8080";
781    pub const TUNNEL_REMOTE_HOST: &str = "localhost";
782    pub const TUNNEL_REMOTE_PORT: &str = "80";
783
784    // ── Snippet form ────────────────────────────────────────────────
785    pub const SNIPPET_NAME: &str = "check-disk";
786    pub const SNIPPET_COMMAND: &str = "df -h";
787    pub const SNIPPET_OPTIONAL: &str = "(optional)";
788
789    // ── Provider form ───────────────────────────────────────────────
790    pub const PROVIDER_URL: &str = "https://pve.example.com:8006";
791    pub const PROVIDER_TOKEN_DEFAULT: &str = "your-api-token";
792    pub const PROVIDER_TOKEN_PROXMOX: &str = "user@pam!token=secret";
793    pub const PROVIDER_TOKEN_AWS: &str = "AccessKeyId:Secret (or use Profile)";
794    pub const PROVIDER_TOKEN_GCP: &str = "/path/to/service-account.json (or access token)";
795    pub const PROVIDER_TOKEN_AZURE: &str = "/path/to/service-principal.json (or access token)";
796    pub const PROVIDER_TOKEN_TAILSCALE: &str = "API key (leave empty for local CLI)";
797    pub const PROVIDER_TOKEN_ORACLE: &str = "~/.oci/config";
798    pub const PROVIDER_TOKEN_OVH: &str = "app_key:app_secret:consumer_key";
799    pub const PROVIDER_PROFILE: &str = "Name from ~/.aws/credentials (or use Token)";
800    pub const PROVIDER_PROJECT_DEFAULT: &str = "my-gcp-project-id";
801    pub const PROVIDER_PROJECT_OVH: &str = "Public Cloud project ID";
802    pub const PROVIDER_COMPARTMENT: &str = "ocid1.compartment.oc1..aaaa...";
803    pub const PROVIDER_REGIONS_DEFAULT: &str = "Space to select regions";
804    pub const PROVIDER_REGIONS_GCP: &str = "Space to select zones (empty = all)";
805    pub const PROVIDER_REGIONS_SCALEWAY: &str = "Space to select zones";
806    // Azure regions is a text input (not a picker), so no key is mentioned.
807    pub const PROVIDER_REGIONS_AZURE: &str = "comma-separated subscription IDs";
808    pub const PROVIDER_REGIONS_OVH: &str = "Space to select endpoint (default: EU)";
809    pub const PROVIDER_USER_AWS: &str = "ec2-user";
810    pub const PROVIDER_USER_GCP: &str = "ubuntu";
811    pub const PROVIDER_USER_AZURE: &str = "azureuser";
812    pub const PROVIDER_USER_ORACLE: &str = "opc";
813    pub const PROVIDER_USER_OVH: &str = "ubuntu";
814    pub const PROVIDER_VAULT_ROLE: &str =
815        "e.g. ssh-client-signer/sign/my-role (vault login; inherited)";
816    pub const PROVIDER_VAULT_ADDR: &str = "e.g. http://127.0.0.1:8200 (inherited by all hosts)";
817    pub const PROVIDER_ALIAS_PREFIX_DEFAULT: &str = "prefix";
818}
819
820#[cfg(test)]
821mod hints_tests {
822    use super::hints;
823
824    #[test]
825    fn askpass_default_formats() {
826        assert_eq!(hints::askpass_default("keychain"), "default: keychain");
827    }
828
829    #[test]
830    fn askpass_default_formats_empty() {
831        assert_eq!(hints::askpass_default(""), "default: ");
832    }
833
834    #[test]
835    fn inherits_from_formats() {
836        assert_eq!(
837            hints::inherits_from("role/x", "aws"),
838            "inherits role/x from aws"
839        );
840    }
841
842    #[test]
843    fn picker_hints_mention_space_not_enter() {
844        // Per the keyboard invariants, pickers open on Space.
845        // If these assertions fail, audit scripts/check-keybindings.sh too.
846        for s in [
847            hints::IDENTITY_FILE_PICK,
848            hints::HOST_PROXY_JUMP,
849            hints::HOST_VAULT_SSH_PICKER,
850            hints::HOST_ASKPASS_PICK,
851            hints::PROVIDER_REGIONS_DEFAULT,
852            hints::PROVIDER_REGIONS_GCP,
853            hints::PROVIDER_REGIONS_SCALEWAY,
854            hints::PROVIDER_REGIONS_OVH,
855        ] {
856            assert!(
857                s.starts_with("Space "),
858                "picker hint must mention Space: {s}"
859            );
860            assert!(!s.contains("Enter "), "picker hint must not say Enter: {s}");
861        }
862    }
863}
864
865#[path = "messages/whats_new.rs"]
866pub mod whats_new;
867
868#[path = "messages/whats_new_toast.rs"]
869pub mod whats_new_toast;
870
871#[cfg(test)]
872mod relative_age_tests {
873    use super::relative_age;
874    use std::time::Duration;
875
876    #[test]
877    fn relative_age_boundaries() {
878        assert_eq!(relative_age(Duration::from_secs(0)), "just now");
879        assert_eq!(relative_age(Duration::from_secs(4)), "just now");
880        assert_eq!(relative_age(Duration::from_secs(5)), "5s ago");
881        assert_eq!(relative_age(Duration::from_secs(59)), "59s ago");
882        assert_eq!(relative_age(Duration::from_secs(60)), "1m ago");
883        assert_eq!(relative_age(Duration::from_secs(3599)), "59m ago");
884        assert_eq!(relative_age(Duration::from_secs(3600)), "1h ago");
885        assert_eq!(relative_age(Duration::from_secs(86399)), "23h ago");
886        assert_eq!(relative_age(Duration::from_secs(86400)), "1d ago");
887        assert_eq!(relative_age(Duration::from_secs(86400 * 7)), "7d ago");
888    }
889}