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
32/// Compose a "Stale host." warning with an optional hint clause.
33/// Trims the hint, drops a trailing period to avoid doubling, and uses
34/// a space separator so the result reads as one sentence. With an empty
35/// hint the bare "Stale host." remains.
36pub fn stale_host(hint: &str) -> String {
37    let trimmed = hint.trim().trim_end_matches('.');
38    if trimmed.is_empty() {
39        "Stale host.".to_string()
40    } else {
41        format!("Stale host. {}.", trimmed)
42    }
43}
44
45// ── Host list ───────────────────────────────────────────────────────
46
47pub fn copied_ssh_command(alias: &str) -> String {
48    format!("Copied SSH command for {}.", alias)
49}
50
51pub fn copied_config_block(alias: &str) -> String {
52    format!("Copied config block for {}.", alias)
53}
54
55pub fn showing_unreachable(count: usize) -> String {
56    format!(
57        "Showing {} unreachable host{}.",
58        count,
59        if count == 1 { "" } else { "s" }
60    )
61}
62
63pub fn sorted_by(label: &str) -> String {
64    format!("Sorted by {}.", label)
65}
66
67pub fn sorted_by_save_failed(label: &str, e: &impl std::fmt::Display) -> String {
68    format!("Sorted by {}. (save failed: {})", label, e)
69}
70
71pub fn grouped_by(label: &str) -> String {
72    format!("Grouped by {}.", label)
73}
74
75pub fn grouped_by_save_failed(label: &str, e: &impl std::fmt::Display) -> String {
76    format!("Grouped by {}. (save failed: {})", label, e)
77}
78
79pub const UNGROUPED: &str = "Ungrouped.";
80
81pub fn ungrouped_save_failed(e: &impl std::fmt::Display) -> String {
82    format!("Ungrouped. (save failed: {})", e)
83}
84
85pub const GROUPED_BY_TAG: &str = "Grouped by tag.";
86
87pub fn grouped_by_tag_save_failed(e: &impl std::fmt::Display) -> String {
88    format!("Grouped by tag. (save failed: {})", e)
89}
90
91pub fn host_restored(alias: &str) -> String {
92    format!("{} is back from the dead.", alias)
93}
94
95pub fn restored_tags(count: usize) -> String {
96    format!(
97        "Restored tags on {} host{}.",
98        count,
99        if count == 1 { "" } else { "s" }
100    )
101}
102
103pub const NOTHING_TO_UNDO: &str = "Nothing to undo.";
104pub const NO_IMPORTABLE_HOSTS: &str = "No importable hosts in known_hosts.";
105pub const NO_STALE_HOSTS: &str = "No stale hosts.";
106pub const NO_HOST_SELECTED: &str = "No host selected.";
107pub const NO_HOSTS_TO_RUN: &str = "No hosts to run on.";
108pub const NO_HOSTS_TO_TAG: &str = "No hosts to tag.";
109pub const PING_FIRST: &str = "Ping first (p/P), then filter with !.";
110pub const PINGING_ALL: &str = "Pinging all the things...";
111pub const ESC_QUIT_HINT: &str = "Nothing to cancel. Press q to quit.";
112
113pub fn included_file_edit(name: &str) -> String {
114    format!("{} is in an included file. Edit it there.", name)
115}
116
117pub fn included_file_delete(name: &str) -> String {
118    format!("{} is in an included file. Delete it there.", name)
119}
120
121pub fn included_file_clone(name: &str) -> String {
122    format!("{} is in an included file. Clone it there.", name)
123}
124
125pub fn included_host_lives_in(alias: &str, path: &impl std::fmt::Display) -> String {
126    format!("{} lives in {}. Edit it there.", alias, path)
127}
128
129pub fn included_host_clone_there(alias: &str, path: &impl std::fmt::Display) -> String {
130    format!("{} lives in {}. Clone it there.", alias, path)
131}
132
133pub fn included_host_tag_there(alias: &str, path: &impl std::fmt::Display) -> String {
134    format!("{} is included from {}. Tag it there.", alias, path)
135}
136
137pub const HOST_NOT_FOUND_IN_CONFIG: &str = "Host not found in config.";
138
139// ── Host form ───────────────────────────────────────────────────────
140
141pub const SMART_PARSED: &str = "Smart-parsed that for you. Check the fields.";
142pub const LOOKS_LIKE_ADDRESS: &str = "Looks like an address. Suggested as Host.";
143
144// ── Form validation (HostForm) ──────────────────────────────────────
145//
146// Surfaced via `notify_error(msg)` after `HostForm::validate()`. All
147// strings live here so the central message audit (`check-messages.sh`)
148// covers them and so the wording stays consistent with the rest of the
149// TUI copy.
150
151pub const HOST_ALIAS_EMPTY: &str = "Alias can't be empty. Every host needs a name!";
152pub const HOST_PATTERN_EMPTY: &str = "Pattern can't be empty.";
153pub const HOST_PATTERN_NEEDS_WILDCARD: &str =
154    "Pattern needs a wildcard (*, ?, [) or multiple hosts.";
155pub const HOST_ALIAS_WHITESPACE: &str = "Alias can't contain whitespace. Keep it simple.";
156pub const HOST_ALIAS_HASH: &str =
157    "Alias can't contain '#'. That's a comment character in SSH config.";
158pub const HOST_ALIAS_PATTERN_CHARS: &str =
159    "Alias can't contain pattern characters. That creates a match pattern, not a host.";
160pub const HOST_HOSTNAME_EMPTY: &str = "Hostname can't be empty. Where should we connect to?";
161pub const HOST_HOSTNAME_WHITESPACE: &str = "Hostname can't contain whitespace.";
162pub const HOST_PORT_INVALID: &str = "That's not a port number. Ports are 1-65535, not poetry.";
163pub const HOST_PORT_ZERO: &str = "Port 0? Bold choice, but no. Try 1-65535.";
164pub const HOST_VAULT_ROLE_INVALID: &str = "Vault SSH role: only letters, digits, /, _ and - \
165     are allowed (e.g. ssh-client-signer/sign/my-role).";
166pub const HOST_VAULT_ADDR_INVALID: &str = "Vault SSH address: must be a non-empty URL \
167     without spaces or control characters (e.g. http://127.0.0.1:8200).";
168
169/// Long-form "{} contains control characters." used by `HostForm::validate`
170/// where the toast doubles as guidance ("that's not going to work").
171pub fn field_control_chars(name: &str) -> String {
172    format!(
173        "{} contains control characters. That's not going to work.",
174        name
175    )
176}
177
178// ── Form validation (TunnelForm) ────────────────────────────────────
179
180pub const TUNNEL_BIND_PORT_INVALID: &str = "Bind port must be 1-65535.";
181pub const TUNNEL_BIND_PORT_ZERO: &str = "Bind port can't be 0.";
182pub const TUNNEL_REMOTE_HOST_EMPTY: &str = "Remote host can't be empty.";
183pub const TUNNEL_REMOTE_HOST_SPACES: &str = "Remote host can't contain spaces.";
184pub const TUNNEL_REMOTE_PORT_INVALID: &str = "Remote port must be 1-65535.";
185pub const TUNNEL_REMOTE_PORT_ZERO: &str = "Remote port can't be 0.";
186
187/// Short form of `field_control_chars` used by TunnelForm where the
188/// toast is purely informational and does not need the guidance suffix.
189pub fn field_control_chars_short(name: &str) -> String {
190    format!("{} contains control characters.", name)
191}
192
193// ── Form validation (SnippetForm + snippet store) ───────────────────
194
195pub const SNIPPET_NAME_EMPTY: &str = "Snippet name cannot be empty.";
196pub const SNIPPET_NAME_WHITESPACE: &str =
197    "Snippet name cannot have leading or trailing whitespace.";
198pub const SNIPPET_NAME_INVALID_CHARS: &str = "Snippet name cannot contain #, [ or ].";
199pub const SNIPPET_NAME_CONTROL_CHARS: &str = "Snippet name cannot contain control characters.";
200pub const SNIPPET_COMMAND_EMPTY: &str = "Command cannot be empty.";
201pub const SNIPPET_COMMAND_CONTROL_CHARS: &str = "Command cannot contain control characters.";
202pub const SNIPPET_DESCRIPTION_CONTROL_CHARS: &str = "Description contains control characters.";
203
204// ── Host CRUD (add / edit) ──────────────────────────────────────────
205
206pub fn pattern_already_exists(alias: &str) -> String {
207    format!("Pattern '{}' already exists.", alias)
208}
209
210pub fn host_alias_already_exists(alias: &str) -> String {
211    format!("'{}' already exists. Aliases must be unique.", alias)
212}
213
214pub const PATTERN_NO_LONGER_EXISTS: &str = "Pattern no longer exists.";
215pub const HOST_NO_LONGER_EXISTS: &str = "Host no longer exists.";
216
217pub fn cert_path_resolve_failed(e: &impl std::fmt::Display) -> String {
218    format!("Failed to resolve cert path: {}", e)
219}
220
221/// Toast shown after a host is added through the TUI form. The CLI
222/// `purple add` flow shares this string via `messages::cli::welcome`.
223pub fn welcome_aboard(alias: &str) -> String {
224    format!("Welcome aboard, {}!", alias)
225}
226
227// ── Bulk tag editor ─────────────────────────────────────────────────
228
229pub const BULK_TAG_NO_HOSTS_SELECTED: &str = "No hosts selected.";
230
231// ── Confirm delete ──────────────────────────────────────────────────
232
233pub fn goodbye_host(alias: &str) -> String {
234    format!("Goodbye, {}. We barely knew ye. (u to undo)", alias)
235}
236
237pub fn host_not_found(alias: &str) -> String {
238    format!("Host '{}' not found.", alias)
239}
240
241/// Toast after stripping an alias token from a shared `Host` line. Undo is
242/// not offered because re-inserting a whole block would not reverse a token
243/// strip (sibling aliases and their directives stay in place).
244pub fn siblings_stripped(alias: &str, sibling_count: usize) -> String {
245    if sibling_count == 1 {
246        format!(
247            "Stripped {}. 1 sibling alias kept its shared config.",
248            alias
249        )
250    } else {
251        format!(
252            "Stripped {}. {} sibling aliases kept their shared config.",
253            alias, sibling_count
254        )
255    }
256}
257
258/// One-line note rendered inside the confirm-delete dialog when the target
259/// alias shares its `Host` block with siblings. Explains that the other
260/// tokens survive.
261pub fn confirm_delete_siblings_note(siblings: &[String]) -> String {
262    let shown: Vec<&str> = siblings.iter().take(3).map(String::as_str).collect();
263    let tail = if siblings.len() > shown.len() {
264        format!(" +{} more", siblings.len() - shown.len())
265    } else {
266        String::new()
267    };
268    format!("Siblings kept: {}{}", shown.join(", "), tail)
269}
270
271pub fn cert_cleanup_warning(path: &impl std::fmt::Display, e: &impl std::fmt::Display) -> String {
272    format!("Warning: failed to clean up Vault SSH cert {}: {}", path, e)
273}
274
275// ── Clone ───────────────────────────────────────────────────────────
276
277pub const CLONED_VAULT_CLEARED: &str = "Cloned. Vault SSH role cleared on copy.";
278
279// ── Tunnels ─────────────────────────────────────────────────────────
280
281pub const TUNNEL_REMOVED: &str = "Tunnel removed.";
282pub const TUNNEL_SAVED: &str = "Tunnel saved.";
283pub const TUNNEL_NOT_FOUND: &str = "Tunnel not found in config.";
284pub const TUNNEL_INCLUDED_READ_ONLY: &str = "Included host. Tunnels are read-only.";
285pub const TUNNEL_ORIGINAL_NOT_FOUND: &str = "Original tunnel not found in config.";
286pub const TUNNEL_LIST_CHANGED: &str = "Tunnel list changed externally. Press Esc and re-open.";
287pub const TUNNEL_DUPLICATE: &str = "Duplicate tunnel already configured.";
288pub const TUNNEL_NO_EDITABLE_HOSTS: &str = "No editable hosts. Add a host first.";
289pub const TUNNEL_HOST_PICKER_NO_MATCH: &str = "No matches.";
290
291pub fn tunnel_stopped(alias: &str) -> String {
292    format!("Tunnel for {} stopped.", alias)
293}
294
295pub fn tunnel_started(alias: &str) -> String {
296    format!("Tunnel for {} started.", alias)
297}
298
299pub fn tunnel_start_failed(e: &impl std::fmt::Display) -> String {
300    format!("Failed to start tunnel: {}", e)
301}
302
303// ── Ping ────────────────────────────────────────────────────────────
304
305pub fn pinging_host(alias: &str, show_hint: bool) -> String {
306    if show_hint {
307        format!("Pinging {}... (Shift+P pings all)", alias)
308    } else {
309        format!("Pinging {}...", alias)
310    }
311}
312
313pub fn bastion_not_found(alias: &str) -> String {
314    format!("Bastion {} not found in config.", alias)
315}
316
317// ── Providers ───────────────────────────────────────────────────────
318
319pub fn provider_removed(display_name: &str) -> String {
320    format!(
321        "Removed {} configuration. Synced hosts remain in your SSH config.",
322        display_name
323    )
324}
325
326pub fn label_invalid(reason: &str) -> String {
327    format!("Invalid name: {}", reason)
328}
329
330pub const LABEL_MUST_DIFFER: &str = "The two names must be different.";
331
332pub const LABEL_MIGRATION_FIELD_CURRENT: &str = " Name for your current config ";
333pub const LABEL_MIGRATION_FIELD_NEW: &str = " Name for the new config ";
334
335pub fn confirm_remove_provider(display: &str) -> String {
336    format!(" Remove {}? ", display)
337}
338
339pub fn confirm_remove_labeled_config(display: &str, label: &str) -> String {
340    format!(" Remove {} ({})? ", display, label)
341}
342
343pub const EXPAND_TO_REMOVE_CONFIG: &str =
344    "Expand the provider and pick a specific config to remove.";
345
346pub fn provider_not_configured(display_name: &str) -> String {
347    format!("{} is not configured. Nothing to remove.", display_name)
348}
349
350pub fn provider_configure_first(display_name: &str) -> String {
351    format!("Configure {} first. Press Enter to set up.", display_name)
352}
353
354pub fn provider_saved_syncing(display_name: &str) -> String {
355    format!("Saved {} configuration. Syncing...", display_name)
356}
357
358pub fn provider_saved(display_name: &str) -> String {
359    format!("Saved {} configuration.", display_name)
360}
361
362pub fn no_stale_hosts_for(display_name: &str) -> String {
363    format!("No stale hosts for {}.", display_name)
364}
365
366pub fn contains_control_chars(name: &str) -> String {
367    format!("{} contains control characters.", name)
368}
369
370pub const TOKEN_FORMAT_AWS: &str = "Token format: AccessKeyId:SecretAccessKey";
371pub const URL_REQUIRED_PROXMOX: &str = "URL is required for Proxmox VE.";
372pub const PROJECT_REQUIRED_GCP: &str = "Project ID can't be empty. Set your GCP project ID.";
373pub const COMPARTMENT_REQUIRED_OCI: &str =
374    "Compartment can't be empty. Set your OCI compartment OCID.";
375pub const REGIONS_REQUIRED_AWS: &str = "Select at least one AWS region.";
376pub const ZONES_REQUIRED_SCALEWAY: &str = "Select at least one Scaleway zone.";
377pub const SUBSCRIPTIONS_REQUIRED_AZURE: &str = "Enter at least one Azure subscription ID.";
378pub const ALIAS_PREFIX_INVALID: &str =
379    "Alias prefix can't contain spaces or pattern characters (*, ?, [, !).";
380pub const USER_NO_WHITESPACE: &str = "User can't contain whitespace.";
381pub const VAULT_ROLE_FORMAT: &str = "Vault SSH role must be in the form <mount>/sign/<role>.";
382
383pub const PROVIDER_CONFIG_CHANGED_EXTERNALLY: &str =
384    "Provider config changed externally. Press Esc and re-open to pick up changes.";
385pub const PROVIDER_URL_REQUIRES_HTTPS: &str =
386    "URL must start with https://. Toggle Verify TLS off for self-signed certificates.";
387pub const PROVIDER_TOKEN_REQUIRED_GCP: &str =
388    "Token can't be empty. Provide a service account JSON key file path or access token.";
389pub const PROVIDER_TOKEN_REQUIRED_ORACLE: &str =
390    "Token can't be empty. Provide the path to your OCI config file (e.g. ~/.oci/config).";
391
392pub fn provider_token_required(display_name: &str) -> String {
393    format!(
394        "Token can't be empty. Grab one from your {} dashboard.",
395        display_name
396    )
397}
398
399pub fn azure_subscription_id_invalid(sub: &str) -> String {
400    format!(
401        "Invalid subscription ID '{}'. Expected UUID format \
402         (e.g. 12345678-1234-1234-1234-123456789012).",
403        sub
404    )
405}
406
407// ── Vault SSH ───────────────────────────────────────────────────────
408
409pub const VAULT_SIGNING_CANCELLED: &str = "Vault SSH signing cancelled.";
410
411/// Sticky error shown when bulk signing hits 3 consecutive failures and
412/// gives up. `failed` is the running failure count; `last_error` carries
413/// the scrubbed Vault stderr so the user can act (run `vault login`,
414/// fix the address, etc.).
415pub fn vault_signing_aborted(failed: u32, last_error: Option<&str>) -> String {
416    format!(
417        "Vault SSH signing aborted after {} consecutive failures. Press V to retry. Last error: {}",
418        failed,
419        last_error.unwrap_or("unknown")
420    )
421}
422
423/// Status line shown after a bulk Vault SSH sign run completes. Combines
424/// signed/failed/skipped counters into one line, with the first error
425/// inlined when there's room. Single-host sign runs show only the error
426/// (no stats prefix) because the counter would just be noise.
427/// Status string shown after a successful bulk tag apply. Returns an
428/// empty string when nothing was changed and nothing was skipped, so the
429/// caller can detect a no-op and skip setting a status.
430pub fn bulk_tag_apply_status(
431    changed_hosts: usize,
432    added: usize,
433    removed: usize,
434    skipped_included: usize,
435) -> String {
436    let mut parts: Vec<String> = Vec::new();
437    if changed_hosts > 0 {
438        let host_word = if changed_hosts == 1 { "" } else { "s" };
439        let mut head = format!("Updated {} host{}", changed_hosts, host_word);
440        let mut delta = Vec::new();
441        if added > 0 {
442            delta.push(format!("+{}", added));
443        }
444        if removed > 0 {
445            delta.push(format!("-{}", removed));
446        }
447        if !delta.is_empty() {
448            head = format!("{} ({})", head, delta.join(" "));
449        }
450        parts.push(head);
451    }
452    if skipped_included > 0 {
453        let file_word = if skipped_included == 1 { "" } else { "s" };
454        parts.push(format!(
455            "skipped {} in include file{}",
456            skipped_included, file_word
457        ));
458    }
459    parts.join(". ")
460}
461
462pub fn vault_sign_summary(
463    signed: u32,
464    failed: u32,
465    skipped: u32,
466    first_error: Option<&str>,
467) -> String {
468    let total = signed + failed + skipped;
469    let cert_word = if total == 1 {
470        "certificate"
471    } else {
472        "certificates"
473    };
474    if failed > 0 {
475        if let Some(err) = first_error {
476            if total == 1 {
477                return err.to_string();
478            }
479            format!(
480                "Signed {} of {} {}. {} failed: {}",
481                signed, total, cert_word, failed, err
482            )
483        } else {
484            format!(
485                "Signed {} of {} {}. {} failed",
486                signed, total, cert_word, failed
487            )
488        }
489    } else if skipped > 0 && signed == 0 {
490        format!(
491            "All {} {} already valid. Nothing to sign.",
492            total, cert_word
493        )
494    } else if skipped > 0 {
495        format!(
496            "Signed {} of {} {}. {} already valid.",
497            signed, total, cert_word, skipped
498        )
499    } else {
500        format!("Signed {} of {} {}.", signed, total, cert_word)
501    }
502}
503pub const VAULT_NO_ROLE_CONFIGURED: &str = "No Vault SSH role configured. Set one in the host form \
504     (Vault SSH role field) or on a provider for shared defaults.";
505pub const VAULT_NO_HOSTS_WITH_ROLE: &str = "No hosts with a Vault SSH role configured.";
506pub const VAULT_ALL_CERTS_VALID: &str = "All Vault SSH certificates are still valid.";
507pub const VAULT_NO_ADDRESS: &str = "No Vault address set. Edit the host (e) or provider \
508     and fill in the Vault SSH Address field.";
509
510pub fn vault_error(msg: &str) -> String {
511    format!("Vault SSH: {}", msg)
512}
513
514pub fn vault_signed(alias: &str) -> String {
515    format!("Signed Vault SSH cert for {}", alias)
516}
517
518pub fn vault_sign_failed(alias: &str, message: &str) -> String {
519    format!("Vault SSH: failed to sign {}: {}", alias, message)
520}
521
522pub fn vault_signing_progress(spinner: &str, done: usize, total: usize, alias: &str) -> String {
523    format!(
524        "{} Signing {}/{}: {} (V to cancel)",
525        spinner, done, total, alias
526    )
527}
528
529pub fn vault_cert_saved_host_gone(alias: &str) -> String {
530    format!(
531        "Vault SSH cert saved for {} but host no longer in config \
532         (renamed or deleted). CertificateFile NOT written.",
533        alias
534    )
535}
536
537pub fn vault_spawn_failed(e: &impl std::fmt::Display) -> String {
538    format!("Vault SSH: failed to spawn signing thread: {}", e)
539}
540
541pub fn vault_cert_check_failed(alias: &str, message: &str) -> String {
542    format!("Cert check failed for {}: {}", alias, message)
543}
544
545pub fn vault_role_set(role: &str) -> String {
546    format!("Vault SSH role set to {}.", role)
547}
548
549/// Toast shown after a successful pre-connect signing for a single host.
550/// Distinct from `vault_signed` (used by bulk sign and form-submit) so the
551/// connect path can mention that the cert was signed *as part of* connecting.
552pub fn vault_signed_pre_connect(alias: &str) -> String {
553    format!("Signed Vault SSH cert for {}.", alias)
554}
555
556/// Toast shown after a successful pre-connect signing covered multiple
557/// chained hosts (target + ProxyJump hops). The `count` includes only hosts
558/// that actually got a fresh cert; hosts whose cert was already valid are
559/// excluded.
560pub fn vault_signed_pre_connect_chain(target: &str, count: usize) -> String {
561    if count <= 1 {
562        format!("Signed Vault SSH cert for {}.", target)
563    } else {
564        format!("Signed Vault SSH certs for {} ({} hosts).", target, count)
565    }
566}
567
568/// Toast shown when pre-connect signing failed for a host. Includes the
569/// scrubbed Vault error so the user can act (run `vault login`, fix the
570/// address, etc.). Distinct from `vault_sign_failed` so the wording can
571/// reflect the connect context without breaking bulk-sign callers.
572pub fn vault_sign_failed_pre_connect(alias: &str, message: &str) -> String {
573    format!("Vault SSH signing failed for {}: {}", alias, message)
574}
575
576/// Toast shown when resolving the public key path for a Vault sign call
577/// failed (missing pubkey, non-UTF8 path, etc.). Surfaced at the connect
578/// step before any Vault round-trip happens.
579pub fn vault_cert_pubkey_resolve_failed(e: &impl std::fmt::Display) -> String {
580    format!("Vault SSH cert failed: {}", e)
581}
582
583/// Stderr warning emitted when a cert was signed but the matching host
584/// block is no longer present (renamed or deleted between the connect
585/// keypress and the signing call). The cert is still written to disk;
586/// the user just has no `CertificateFile` directive pointing at it.
587pub fn vault_cert_host_block_missing(alias: &str, cert_path: &std::path::Path) -> String {
588    format!(
589        "Warning: signed cert for {} but host block is no longer in ssh config; \
590         CertificateFile not written (cert saved to {})",
591        alias,
592        cert_path.display()
593    )
594}
595
596/// Stderr warning emitted when the cert was signed but writing the
597/// updated SSH config back to disk failed.
598pub fn vault_cert_config_write_failed(alias: &str, e: &impl std::fmt::Display) -> String {
599    format!(
600        "Warning: signed cert for {} but failed to update SSH config CertificateFile: {}",
601        alias, e
602    )
603}
604
605// ── Snippets ────────────────────────────────────────────────────────
606
607pub fn snippet_removed(name: &str) -> String {
608    format!("Removed snippet '{}'.", name)
609}
610
611pub fn snippet_added(name: &str) -> String {
612    format!("Added snippet '{}'.", name)
613}
614
615pub fn snippet_updated(name: &str) -> String {
616    format!("Updated snippet '{}'.", name)
617}
618
619pub fn snippet_exists(name: &str) -> String {
620    format!("'{}' already exists.", name)
621}
622
623pub const OUTPUT_COPIED: &str = "Output copied.";
624
625pub fn copy_failed(e: &impl std::fmt::Display) -> String {
626    format!("Copy failed: {}", e)
627}
628
629// ── Clipboard subprocess errors ─────────────────────────────────────
630//
631// Surfaced when `pbcopy`/`xclip`/`wl-copy` fails to spawn, write to its
632// stdin, or be reaped. The cmd name is the binary the platform picked.
633
634pub fn clipboard_run_failed(cmd: &str) -> String {
635    format!("Failed to run {}.", cmd)
636}
637
638pub fn clipboard_write_failed(cmd: &str) -> String {
639    format!("Failed to write to {}.", cmd)
640}
641
642pub fn clipboard_wait_failed(cmd: &str) -> String {
643    format!("Failed to wait for {}.", cmd)
644}
645
646pub fn clipboard_exited_error(cmd: &str) -> String {
647    format!("{} exited with error.", cmd)
648}
649
650// ── Import errors ───────────────────────────────────────────────────
651//
652// Bubble up to the CLI via `eprintln!("{}", e)` when the user runs
653// `purple import` against a missing or unreadable file.
654
655pub fn import_open_failed(path: &impl std::fmt::Display, e: &impl std::fmt::Display) -> String {
656    format!("Can't open {}: {}", path, e)
657}
658
659pub fn import_known_hosts_open_failed(e: &impl std::fmt::Display) -> String {
660    format!("Can't open known_hosts: {}", e)
661}
662
663pub const IMPORT_HOME_DIR_UNKNOWN: &str = "Could not determine home directory.";
664pub const IMPORT_KNOWN_HOSTS_MISSING: &str = "~/.ssh/known_hosts not found.";
665
666// ── Snippet runner errors ───────────────────────────────────────────
667
668pub fn snippet_ssh_launch_failed(e: &impl std::fmt::Display) -> String {
669    format!("Failed to launch ssh: {}", e)
670}
671
672// ── Vault SSH library errors ────────────────────────────────────────
673//
674// Reach the user via the anyhow chain that `ensure_vault_ssh_chain_if_needed`
675// turns into a toast. `vault_create_dir_failed` and `vault_write_cert_failed`
676// are with_context strings, so they appear after a colon in the error chain.
677
678pub fn vault_create_dir_failed(path: &impl std::fmt::Display) -> String {
679    format!("Failed to create {}", path)
680}
681
682pub fn vault_write_cert_failed(path: &impl std::fmt::Display) -> String {
683    format!("Failed to write certificate to {}", path)
684}
685
686pub fn vault_ssh_keygen_run_failed(e: &impl std::fmt::Display) -> String {
687    format!("Failed to run ssh-keygen: {}", e)
688}
689
690// ── Container library errors ────────────────────────────────────────
691//
692// Validation (`validate_container_id`) errors propagate via the
693// `ContainerActionComplete` event and become toasts. The "no runtime"
694// and "unknown sentinel" lines surface in the same path.
695
696pub const CONTAINER_ID_EMPTY: &str = "Container ID must not be empty.";
697pub const CONTAINER_RUNTIME_MISSING: &str = "No container runtime found. Install Docker or Podman.";
698
699pub fn container_id_invalid_char(c: char) -> String {
700    format!("Container ID contains invalid character: '{c}'")
701}
702
703pub fn container_unknown_sentinel(s: &str) -> String {
704    format!("Unknown sentinel: {s}")
705}
706
707pub fn container_invalid_id(reason: &str) -> String {
708    format!("Container exec blocked: {reason}")
709}
710
711/// Transient label shown on the file browser overlay while an scp transfer
712/// is running. Singular form for a single source.
713pub fn scp_copying_one(source: &str) -> String {
714    format!("Copying {}...", source)
715}
716
717/// Transient label shown on the file browser overlay while an scp transfer
718/// is running. Plural form when multiple files were selected at once.
719pub fn scp_copying_many(count: usize) -> String {
720    format!("Copying {} files...", count)
721}
722
723/// Toast shown when scp exited non-zero with no captured stderr to relay.
724/// The exit code is the only signal we have left.
725pub fn scp_failed_exit_code(code: i32) -> String {
726    format!("Copy failed (exit code {}).", code)
727}
728
729/// Toast shown when the scp subprocess itself failed to spawn or wait
730/// (e.g. binary missing, signal interrupted), distinct from a non-zero
731/// exit which uses `scp_failed_exit_code`.
732pub fn scp_spawn_failed(e: &impl std::fmt::Display) -> String {
733    format!("scp failed: {}", e)
734}
735
736// ── Picker (password source, key, proxy) ────────────────────────────
737
738pub const GLOBAL_DEFAULT_CLEARED: &str = "Global default cleared.";
739pub const PASSWORD_SOURCE_CLEARED: &str = "Password source cleared.";
740pub const ASKPASS_CUSTOM_COMMAND_HINT: &str =
741    "Type your command. Use %a (alias) and %h (hostname) as placeholders.";
742
743pub fn global_default_set(label: &str) -> String {
744    format!("Global default set to {}.", label)
745}
746
747pub fn password_source_set(label: &str) -> String {
748    format!("Password source set to {}.", label)
749}
750
751pub fn complete_path(label: &str) -> String {
752    format!("Complete the {} path.", label)
753}
754
755pub fn key_selected(name: &str) -> String {
756    format!("Locked and loaded with {}.", name)
757}
758
759pub fn proxy_jump_set(alias: &str) -> String {
760    format!("Jumping through {}.", alias)
761}
762
763pub fn save_default_failed(e: &impl std::fmt::Display) -> String {
764    format!("Failed to save default: {}", e)
765}
766
767// ── Containers ──────────────────────────────────────────────────────
768
769pub fn container_action_complete(action: &str) -> String {
770    format!("Container {} complete.", action)
771}
772
773pub const HOST_KEY_UNKNOWN: &str = "Host key unknown. Connect first (Enter) to trust the host.";
774pub const HOST_KEY_CHANGED: &str =
775    "Host key changed. Possible tampering or server re-install. Clear with ssh-keygen -R.";
776
777// User-friendly classifications of stderr from a remote `docker ps` /
778// `podman ps`. The raw stderr is too technical and varies across
779// distros; these phrasings give the user the actionable next step.
780pub const CONTAINER_RUNTIME_NOT_FOUND: &str = "Docker or Podman not found on remote host.";
781pub const CONTAINER_PERMISSION_DENIED: &str =
782    "Permission denied. Is your user in the docker group?";
783pub const CONTAINER_DAEMON_NOT_RUNNING: &str = "Container daemon is not running.";
784pub const CONTAINER_CONNECTION_REFUSED: &str = "Connection refused.";
785pub const CONTAINER_HOST_UNREACHABLE: &str = "Host unreachable.";
786
787/// Generic fallback when none of the container error classifiers
788/// matched. The exit code is the only signal we can show without
789/// leaking unfiltered remote stderr.
790pub fn container_command_failed(code: i32) -> String {
791    format!("Command failed with code {}.", code)
792}
793
794/// `docker inspect` returned no JSON (empty array or empty stdout).
795pub const CONTAINER_INSPECT_EMPTY: &str = "Inspect returned no data.";
796
797/// `docker inspect` stdout was not valid JSON.
798pub fn container_inspect_parse_failed(reason: &str) -> String {
799    format!("Inspect parse failed: {}", reason)
800}
801
802// ── Container exec (Enter on containers overview) ──────────────────
803
804/// User pressed Enter on a non-running container.
805pub fn container_not_running(name: &str) -> String {
806    format!("{} is not running. Cannot exec.", name)
807}
808
809/// Demo mode interactive guard.
810pub const DEMO_CONTAINER_EXEC_DISABLED: &str = "Demo mode: container exec disabled.";
811
812/// Tmux mode opened a new window for the exec session.
813pub fn container_exec_opened_in_tmux(name: &str, alias: &str) -> String {
814    format!("Opened {} on {} in tmux window.", name, alias)
815}
816
817/// Interactive shell exited cleanly.
818pub fn container_exec_ended(name: &str) -> String {
819    format!("Container shell ended: {}.", name)
820}
821
822/// Interactive shell failed with a parsed stderr reason.
823pub fn container_exec_failed_with_reason(name: &str, reason: &str) -> String {
824    format!("Container exec failed for {}: {}", name, reason)
825}
826
827/// Interactive shell exited non-zero with no stderr reason.
828pub fn container_exec_exited_with_code(name: &str, code: i32) -> String {
829    format!("Container exec for {} exited with code {}.", name, code)
830}
831
832/// `Command::new("ssh").spawn()` failed.
833pub fn container_exec_spawn_failed(name: &str) -> String {
834    format!("Failed to launch ssh for container {}.", name)
835}
836
837/// Exec prompt rejected the typed command (control chars, newline).
838pub const CONTAINER_EXEC_INVALID_COMMAND: &str =
839    "Command rejected: control characters not allowed.";
840
841// ── Container logs (l) ─────────────────────────────────────────────
842
843/// Title shown in the logs overlay border for "logs are loading".
844pub const CONTAINER_LOGS_LOADING: &str = "fetching logs…";
845
846/// Title for "logs are ready". Uses the short relative-time format
847/// (12s, 5m, 2h) so the badge stays compact regardless of staleness.
848pub fn container_logs_fetched(secs_ago: u64) -> String {
849    format!(
850        "fetched {} ago",
851        crate::containers::format_uptime_short(secs_ago)
852    )
853}
854
855/// Title for "logs fetch failed".
856pub fn container_logs_failed(reason: &str) -> String {
857    format!("logs fetch failed: {}", reason)
858}
859
860/// Search position badge for the logs overlay: `3 of 12` while the
861/// user navigates `/foo` matches with n/N.
862pub fn container_logs_search_position(current: usize, total: usize) -> String {
863    format!("{} of {}", current, total)
864}
865
866/// Search badge when the query has no hits in the current body.
867pub const CONTAINER_LOGS_SEARCH_NO_MATCHES: &str = "no matches";
868
869// ── Container restart/stop (K / S) ─────────────────────────────────
870
871/// Confirm body line that summarises a destructive action's mechanics.
872pub const CONTAINER_RESTART_BODY: &str =
873    "Sends SIGTERM, waits 10s, then SIGKILL. Live connections will drop.";
874pub const CONTAINER_STOP_BODY: &str = "Sends SIGTERM, waits 10s, then SIGKILL. Container will not restart unless its policy reschedules it.";
875
876// ── Container stack restart (Ctrl-K) ───────────────────────────────
877
878pub fn container_stack_unknown(name: &str) -> String {
879    format!("Stack unknown for {}: open the detail panel first.", name)
880}
881
882pub fn container_stack_no_running(project: &str) -> String {
883    format!("Stack {} has no running members to restart.", project)
884}
885
886pub const CONTAINER_STACK_RESTART_BODY: &str = "Restart cycles every running member one by one. Exited members are not touched. Live connections will drop.";
887
888// ── Container host-wide bulk actions (K / S on a divider) ──────────
889
890/// Body line on the bulk-restart-host confirm dialog. Same mechanics
891/// as a single restart but spelled out so the user knows it walks the
892/// host one container at a time.
893pub const CONTAINER_HOST_RESTART_ALL_BODY: &str = "Restart cycles every running container on the host one by one. Exited containers are not touched. Live connections will drop.";
894
895/// Body line on the bulk-stop-host confirm dialog.
896pub const CONTAINER_HOST_STOP_ALL_BODY: &str = "Stops every running container on the host one by one. Exited containers are not touched. Restart policies may reschedule them.";
897
898/// Footer toast when the user presses a single-target action key (l, e)
899/// while the cursor is parked on a host-divider row. Steers the user
900/// back to a container row instead of silently no-op'ing. `action` is
901/// lowercased for sentence-case readability ("logs needs..." reads
902/// better than "Logs applies...").
903pub fn container_action_needs_single(action: &str) -> String {
904    format!(
905        "{} need a single container. Place the cursor on a container row.",
906        action.to_lowercase()
907    )
908}
909
910/// Toast when bulk K/S on a divider finds no running containers.
911pub fn container_host_no_running(alias: &str) -> String {
912    format!("No running containers on {}.", alias)
913}
914
915// ── Container refresh (r / R / a) ──────────────────────────────────
916
917/// `r` keypress: single-host refresh started.
918pub fn container_refreshing(alias: &str) -> String {
919    format!("Refreshing {}…", alias)
920}
921
922/// `R` keypress while a previous batch is still in flight.
923pub const REFRESH_BATCH_ALREADY_RUNNING: &str = "Refresh already in progress.";
924
925/// `R` keypress on an empty container cache.
926pub const REFRESH_NOTHING_TO_REFRESH: &str = "No cached hosts to refresh. Press 'a' to add a host.";
927
928/// Batch progress readout shown in the status footer.
929pub fn container_refresh_progress(done: usize, total: usize) -> String {
930    format!("Refreshing {}/{} hosts…", done, total)
931}
932
933/// Batch completed.
934pub fn container_refresh_complete(total: usize) -> String {
935    format!(
936        "Refreshed {} host{}.",
937        total,
938        if total == 1 { "" } else { "s" }
939    )
940}
941
942/// Host picker: no hosts match the live query.
943pub const CONTAINER_HOST_PICKER_NO_MATCH: &str = "No hosts match.";
944
945/// Host picker: every host already has a cache entry.
946pub const CONTAINER_HOST_PICKER_NOTHING_TO_ADD: &str =
947    "All hosts already cached. Use 'r' or 'R' to refresh.";
948
949// ── Import ──────────────────────────────────────────────────────────
950
951pub fn imported_hosts(imported: usize, skipped: usize) -> String {
952    format!(
953        "Imported {} host{}, skipped {} duplicate{}.",
954        imported,
955        if imported == 1 { "" } else { "s" },
956        skipped,
957        if skipped == 1 { "" } else { "s" }
958    )
959}
960
961pub fn all_hosts_exist(skipped: usize) -> String {
962    if skipped == 1 {
963        "Host already exists.".to_string()
964    } else {
965        format!("All {} hosts already exist.", skipped)
966    }
967}
968
969// ── SSH config repair ───────────────────────────────────────────────
970
971pub fn config_repaired(groups: usize, orphaned: usize) -> String {
972    format!(
973        "Repaired SSH config ({} absorbed, {} orphaned group headers).",
974        groups, orphaned
975    )
976}
977
978pub fn no_exact_match(alias: &str) -> String {
979    format!("No exact match for '{}'. Here's what we found.", alias)
980}
981
982pub fn group_pref_reset_failed(e: &impl std::fmt::Display) -> String {
983    format!("Group preference reset. (save failed: {})", e)
984}
985
986// ── Connection ──────────────────────────────────────────────────────
987
988pub fn opened_in_tmux(alias: &str) -> String {
989    format!("Opened {} in new tmux window.", alias)
990}
991
992pub fn tmux_error(e: &impl std::fmt::Display) -> String {
993    format!("tmux: {}", e)
994}
995
996pub fn connection_failed(alias: &str) -> String {
997    format!("Connection to {} failed.", alias)
998}
999
1000/// Stderr line printed when the ssh subprocess itself failed to spawn or
1001/// wait (e.g. binary missing, signal interrupted), distinct from a
1002/// non-zero exit code which the user sees via the toast.
1003pub fn connection_spawn_failed(e: &impl std::fmt::Display) -> String {
1004    format!("Connection failed: {}", e)
1005}
1006
1007/// Toast shown when ssh exited non-zero with a captured stderr line we
1008/// can show. The reason is the trimmed last meaningful line of ssh stderr.
1009pub fn ssh_failed_with_reason(alias: &str, reason: &str) -> String {
1010    format!("SSH to {} failed. {}", alias, reason)
1011}
1012
1013/// Toast shown when ssh exited non-zero with no captured stderr to relay.
1014/// The exit code is the only signal we have left.
1015pub fn ssh_exited_with_code(alias: &str, code: i32) -> String {
1016    format!("SSH to {} exited with code {}.", alias, code)
1017}
1018
1019// ── Host key reset ──────────────────────────────────────────────────
1020
1021pub fn host_key_remove_failed(stderr: &str) -> String {
1022    format!("Failed to remove host key: {}", stderr)
1023}
1024
1025pub fn ssh_keygen_failed(e: &impl std::fmt::Display) -> String {
1026    format!("Failed to run ssh-keygen: {}", e)
1027}
1028
1029// ── Transfer ────────────────────────────────────────────────────────
1030
1031pub const TRANSFER_COMPLETE: &str = "Transfer complete.";
1032
1033// ── Background / event loop ─────────────────────────────────────────
1034
1035/// Per-provider sync progress line with a leading spinner frame so
1036/// `event_loop::handle_tick` animates the prefix while the message is
1037/// on screen. Format: `⠋ Proxmox VE: Resolving IPs (1/5)...`. Mirrors
1038/// the spinner contract used by `synced_progress` so the footer keeps
1039/// animating even when granular per-provider progress overrides the
1040/// batch summary mid-sync.
1041pub fn provider_progress(spinner: &str, name: &str, message: &str) -> String {
1042    format!("{} {}: {}", spinner, name, message)
1043}
1044
1045// ── Relative age (detail panel "checked" suffix) ────────────────────
1046
1047pub const AGE_JUST_NOW: &str = "just now";
1048
1049/// Compact relative age: "just now", "12s ago", "3m ago", "2h ago",
1050/// "2d ago". Used in the detail panel so the reader can tell stale
1051/// data from fresh.
1052pub fn relative_age(elapsed: std::time::Duration) -> String {
1053    let secs = elapsed.as_secs();
1054    if secs < 5 {
1055        AGE_JUST_NOW.to_string()
1056    } else if secs < 60 {
1057        format!("{}s ago", secs)
1058    } else if secs < 3600 {
1059        format!("{}m ago", secs / 60)
1060    } else if secs < 86400 {
1061        format!("{}h ago", secs / 3600)
1062    } else {
1063        format!("{}d ago", secs / 86400)
1064    }
1065}
1066
1067// ── Vault SSH bulk signing summaries (event_loop.rs) ────────────────
1068
1069pub fn vault_config_reapply_failed(signed: usize, e: &impl std::fmt::Display) -> String {
1070    format!(
1071        "External edits detected; signed {} certs but failed to re-apply CertificateFile: {}",
1072        signed, e
1073    )
1074}
1075
1076pub fn vault_external_edits_merged(summary: &str, reapplied: usize) -> String {
1077    format!(
1078        "{} External ssh config edits detected, merged {} CertificateFile directives.",
1079        summary, reapplied
1080    )
1081}
1082
1083pub fn vault_external_edits_no_write(summary: &str) -> String {
1084    format!(
1085        "{} External ssh config edits detected; certs on disk, no CertificateFile written.",
1086        summary
1087    )
1088}
1089
1090pub fn vault_reparse_failed(signed: usize, e: &impl std::fmt::Display) -> String {
1091    format!(
1092        "Signed {} certs but cannot re-parse ssh config after external edit: {}. \
1093         Certs are on disk under ~/.purple/certs/.",
1094        signed, e
1095    )
1096}
1097
1098pub fn vault_config_update_failed(signed: usize, e: &impl std::fmt::Display) -> String {
1099    format!(
1100        "Signed {} certs but failed to update SSH config: {}",
1101        signed, e
1102    )
1103}
1104
1105pub fn vault_config_write_after_sign(e: &impl std::fmt::Display) -> String {
1106    format!("Failed to update config after vault signing: {}", e)
1107}
1108
1109// ── File browser ────────────────────────────────────────────────────
1110
1111// ── Confirm / host key ──────────────────────────────────────────────
1112
1113pub fn removed_host_key(hostname: &str) -> String {
1114    format!("Removed host key for {}. Reconnecting...", hostname)
1115}
1116
1117// ── Host detail (tags) ──────────────────────────────────────────────
1118
1119pub fn tagged_host(alias: &str, count: usize) -> String {
1120    format!(
1121        "Tagged {} with {} label{}.",
1122        alias,
1123        count,
1124        if count == 1 { "" } else { "s" }
1125    )
1126}
1127
1128// ── Config reload ───────────────────────────────────────────────────
1129
1130pub fn config_reloaded(count: usize) -> String {
1131    format!("Config reloaded. {} hosts.", count)
1132}
1133
1134// ── Sync background ─────────────────────────────────────────────────
1135
1136/// In-progress sync line for the footer. Format:
1137/// `⠋ Syncing AWS, Hetzner · 1/3 (+12 ~3 -1)`.
1138/// Active provider names lead so the user immediately sees which provider
1139/// is currently in flight (especially relevant when one provider is slow).
1140/// `done/total` follows as a counter. The leading character is a braille
1141/// spinner frame rotated on every tick. The `(+a ~u -s)` suffix is omitted
1142/// when all counts are zero.
1143///
1144/// Callers MUST only invoke this when `active_names` is non-empty (i.e.
1145/// at least one provider is still in flight). The only call site is
1146/// `main::set_sync_summary`, which enters this branch via `still_syncing`,
1147/// itself gated on `!providers.syncing.is_empty()` — so `active_names`
1148/// (built from `syncing.keys()`) is guaranteed non-empty.
1149pub fn synced_progress(
1150    spinner: &str,
1151    active_names: &str,
1152    done: usize,
1153    total: usize,
1154    added: usize,
1155    updated: usize,
1156    stale: usize,
1157) -> String {
1158    debug_assert!(
1159        !active_names.is_empty(),
1160        "synced_progress must only be called while a provider is still in flight"
1161    );
1162    let diff = sync_diff_suffix(added, updated, stale);
1163    format!(
1164        "{} Syncing {} \u{00B7} {}/{}{}",
1165        spinner, active_names, done, total, diff
1166    )
1167}
1168
1169/// Final sync summary for the footer once all providers in the batch have
1170/// completed. Format: `Synced 5/5 · AWS, DO, Vultr, Hetzner, Linode (+12 ~3 -1)`.
1171/// No spinner prefix, no auto-tick: the message expires by length-proportional
1172/// timeout once the batch is done.
1173pub fn synced_done(
1174    done: usize,
1175    total: usize,
1176    names: &str,
1177    added: usize,
1178    updated: usize,
1179    stale: usize,
1180) -> String {
1181    let diff = sync_diff_suffix(added, updated, stale);
1182    format!("Synced {}/{} \u{00B7} {}{}", done, total, names, diff)
1183}
1184
1185fn sync_diff_suffix(added: usize, updated: usize, stale: usize) -> String {
1186    let parts: Vec<String> = [(added, '+'), (updated, '~'), (stale, '-')]
1187        .iter()
1188        .filter(|(n, _)| *n > 0)
1189        .map(|(n, sign)| format!("{}{}", sign, n))
1190        .collect();
1191    if parts.is_empty() {
1192        String::new()
1193    } else {
1194        format!(" ({})", parts.join(" "))
1195    }
1196}
1197
1198pub const SYNC_THREAD_SPAWN_FAILED: &str = "Failed to start sync thread.";
1199
1200pub const SYNC_UNKNOWN_PROVIDER: &str = "Unknown provider.";
1201
1202// ── Vault signing cancelled summary ─────────────────────────────────
1203
1204pub fn vault_signing_cancelled_summary(
1205    signed: u32,
1206    failed: u32,
1207    first_error: Option<&str>,
1208) -> String {
1209    let mut msg = format!(
1210        "Vault SSH signing cancelled ({} signed, {} failed)",
1211        signed, failed
1212    );
1213    if let Some(err) = first_error {
1214        msg.push_str(": ");
1215        msg.push_str(err);
1216    }
1217    msg
1218}
1219
1220// ── Region picker ───────────────────────────────────────────────────
1221
1222pub fn regions_selected_count(count: usize, label: &str) -> String {
1223    let s = if count == 1 { "" } else { "s" };
1224    format!("{} {}{} selected.", count, label, s)
1225}
1226
1227// ── Purge stale ─────────────────────────────────────────────────────
1228
1229// ── Clipboard ───────────────────────────────────────────────────────
1230
1231pub const NO_CLIPBOARD_TOOL: &str =
1232    "No clipboard tool found. Install pbcopy (macOS), wl-copy (Wayland), or xclip/xsel (X11).";
1233
1234// ── MCP server ──────────────────────────────────────────────────────
1235
1236pub const MCP_TOOL_DENIED_READ_ONLY: &str = "Tool denied. Server started with --read-only. Restart without --read-only to enable state-changing tools.";
1237
1238/// Bare message body. Callers add the `[purple]` fault-domain prefix at
1239/// their `warn!` / `error!` site; the `eprintln!` startup diagnostic emits
1240/// this body directly without the tag.
1241pub fn mcp_audit_init_failed(path: &impl std::fmt::Display, e: &impl std::fmt::Display) -> String {
1242    format!(
1243        "Failed to initialise MCP audit log at {}: {}. Continuing without audit logging.",
1244        path, e
1245    )
1246}
1247
1248/// Bare message body. Callers add `[purple]` at the log macro site.
1249pub fn mcp_audit_write_failed(e: &impl std::fmt::Display) -> String {
1250    format!("Failed to write MCP audit entry: {}", e)
1251}
1252
1253/// Returned to the MCP client as `isError` content when the SSH config path
1254/// does not point to an existing file. Surfaces the bug class where a
1255/// missing-file silently yields an empty host list.
1256pub fn mcp_config_file_not_found(path: &impl std::fmt::Display) -> String {
1257    format!("SSH config file not found: {}", path)
1258}
1259
1260/// Logged when `dirs::home_dir()` cannot resolve a home for the audit log
1261/// default. Auditing is silently disabled in this state, so the operator
1262/// needs an explicit cue.
1263pub const MCP_AUDIT_HOME_DIR_UNAVAILABLE: &str = "Could not determine home directory; MCP audit log disabled. Set --audit-log <PATH> explicitly to enable auditing.";
1264
1265// ── Jump ─────────────────────────────────────────────────
1266
1267/// Placeholder shown in the jump bar input when the query is empty.
1268pub const PALETTE_PLACEHOLDER: &str = "Find anything";
1269/// Empty-state copy when the current query has no matches.
1270pub const PALETTE_NO_RESULTS: &str = "No matches.";
1271/// Toast shown when the user dispatches a snippet from the jump bar while
1272/// no host is selected (the snippet picker needs at least one target).
1273pub const PALETTE_SNIPPET_NEEDS_HOST: &str =
1274    "Pick a host first, then run a snippet from the jump bar.";
1275/// Suffix appended to the truncated row list when the visible window is
1276/// smaller than the result list.
1277pub fn jump_more_rows(n: usize) -> String {
1278    format!("+{n} more (scroll down)")
1279}
1280
1281// ── CLI messages ────────────────────────────────────────────────────
1282
1283#[path = "messages/cli.rs"]
1284pub mod cli;
1285pub mod footer;
1286
1287// ── Update messages ─────────────────────────────────────────────────
1288
1289pub mod update {
1290    pub const WHATS_NEW_HINT: &str = "Press n inside purple to see what's new.";
1291    pub const DONE: &str = "done.";
1292    pub const CHECKSUM_OK: &str = "ok.";
1293    pub const SUDO_WARNING: &str =
1294        "Running via sudo. Consider fixing directory permissions instead.";
1295
1296    /// Two-space-indented progress prefixes printed before each step.
1297    /// Trailing space is intentional so the success/fail glyph or
1298    /// `DONE` constant follows on the same line, matching the visual
1299    /// rhythm of the updater output.
1300    pub const STEP_CHECKING: &str = "  Checking for updates... ";
1301    pub const STEP_VERIFYING_CHECKSUM: &str = "  Verifying checksum... ";
1302    pub const STEP_INSTALLING: &str = "  Installing... ";
1303
1304    pub fn already_on(current: &str) -> String {
1305        format!("already on v{} (latest).", current)
1306    }
1307
1308    pub fn available(latest: &str, current: &str) -> String {
1309        format!("v{} available (current: v{}).", latest, current)
1310    }
1311
1312    /// Two-space-indented progress prefix for the download step. Matches
1313    /// the trailing-space convention of the other STEP_* constants so
1314    /// the next print resumes on the same line.
1315    pub fn step_downloading(version: &str) -> String {
1316        format!("  Downloading v{}... ", version)
1317    }
1318
1319    /// Indented sudo warning rendered before the download step. The
1320    /// caller passes a pre-bolded bang (`!`) so the line reads
1321    /// `  ! Running via sudo. ...` with the `!` emphasized.
1322    pub fn sudo_warning_line(bold_bang: &str) -> String {
1323        format!("  {} {}", bold_bang, SUDO_WARNING)
1324    }
1325
1326    pub fn header(bold_name: &str) -> String {
1327        format!("\n  {} updater\n", bold_name)
1328    }
1329
1330    pub fn binary_path(path: &std::path::Path) -> String {
1331        format!("  Binary: {}", path.display())
1332    }
1333
1334    pub fn installed_at(bold_version: &str, path: &std::path::Path) -> String {
1335        format!("\n  {} installed at {}.", bold_version, path.display())
1336    }
1337
1338    pub fn whats_new_hint_indented() -> String {
1339        format!("\n  {}", WHATS_NEW_HINT)
1340    }
1341}
1342
1343// ── Askpass / password prompts ───────────────────────────────────────
1344
1345pub mod askpass {
1346    pub const BW_NOT_FOUND: &str = "Bitwarden CLI (bw) not found. SSH will prompt for password.";
1347    pub const BW_NOT_LOGGED_IN: &str = "Bitwarden vault not logged in. Run 'bw login' first.";
1348    pub const EMPTY_PASSWORD: &str = "Empty password. SSH will prompt for password.";
1349    pub const PASSWORD_IN_KEYCHAIN: &str = "Password stored in keychain.";
1350
1351    pub fn read_failed(e: &impl std::fmt::Display) -> String {
1352        format!("Failed to read password: {}", e)
1353    }
1354
1355    pub fn unlock_failed_retry(e: &impl std::fmt::Display) -> String {
1356        format!("Unlock failed: {}. Try again.", e)
1357    }
1358
1359    pub fn unlock_failed_prompt(e: &impl std::fmt::Display) -> String {
1360        format!("Unlock failed: {}. SSH will prompt for password.", e)
1361    }
1362
1363    /// CLI prompt shown by the inline askpass path when the user has no
1364    /// stored credential yet. The trailing space is intentional — the
1365    /// reader echoes user input directly after.
1366    pub fn password_prompt(alias: &str) -> String {
1367        format!("Password for {}: ", alias)
1368    }
1369
1370    /// CLI prompt shown when keychain storage is the sink. Reminds the
1371    /// user that the entry will be persisted, not just used once.
1372    pub fn keychain_password_prompt(alias: &str) -> String {
1373        format!("Password for {} (stored in keychain): ", alias)
1374    }
1375
1376    /// Stderr line emitted when the keychain `add-generic-password` call
1377    /// failed. The user falls back to ssh's own prompt on the next try.
1378    pub fn keychain_store_failed(e: &impl std::fmt::Display) -> String {
1379        format!(
1380            "Failed to store in keychain: {}. SSH will prompt for password.",
1381            e
1382        )
1383    }
1384}
1385
1386// ── Logging ─────────────────────────────────────────────────────────
1387
1388pub mod logging {
1389    pub fn init_failed(e: &impl std::fmt::Display) -> String {
1390        format!("[purple] Failed to initialize logger: {}", e)
1391    }
1392
1393    pub const SSH_VERSION_FAILED: &str = "[purple] Failed to detect SSH version. Is ssh installed?";
1394}
1395
1396// ── Form field hints / placeholders ─────────────────────────────────
1397//
1398// Dimmed placeholder text shown in empty form fields. Centralized here
1399// so every user-visible string lives in one place and is auditable.
1400
1401pub mod hints {
1402    // ── Shared ──────────────────────────────────────────────────────
1403    // Picker hints mention "Space" because per the design system keyboard
1404    // invariants, Enter always submits a form; pickers open on Space.
1405    // Keep these strings in sync with scripts/check-keybindings.sh.
1406    pub const IDENTITY_FILE_PICK: &str = "Space to pick a key";
1407    pub const DEFAULT_SSH_USER: &str = "root";
1408
1409    // ── Host form ───────────────────────────────────────────────────
1410    pub const HOST_ALIAS: &str = "e.g. prod or db-01";
1411    pub const HOST_ALIAS_PATTERN: &str = "10.0.0.* or *.example.com";
1412    pub const HOST_HOSTNAME: &str = "192.168.1.1 or example.com";
1413    pub const HOST_PORT: &str = "22";
1414    pub const HOST_PROXY_JUMP: &str = "Space to pick a host";
1415    pub const HOST_VAULT_SSH: &str = "e.g. ssh-client-signer/sign/my-role (auth via vault login)";
1416    pub const HOST_VAULT_SSH_PICKER: &str = "Space to pick a role or type one";
1417    pub const HOST_VAULT_ADDR: &str =
1418        "e.g. http://127.0.0.1:8200 (inherits from provider or env when empty)";
1419    pub const HOST_TAGS: &str = "e.g. prod, staging, us-east (comma-separated)";
1420    pub const HOST_ASKPASS_PICK: &str = "Space to pick a source";
1421
1422    pub fn askpass_default(default: &str) -> String {
1423        format!("default: {}", default)
1424    }
1425
1426    pub fn inherits_from(value: &str, provider: &str) -> String {
1427        format!("inherits {} from {}", value, provider)
1428    }
1429
1430    // ── Tunnel form ─────────────────────────────────────────────────
1431    pub const TUNNEL_BIND_PORT: &str = "8080";
1432    pub const TUNNEL_REMOTE_HOST: &str = "localhost";
1433    pub const TUNNEL_REMOTE_PORT: &str = "80";
1434
1435    // ── Snippet form ────────────────────────────────────────────────
1436    pub const SNIPPET_NAME: &str = "check-disk";
1437    pub const SNIPPET_COMMAND: &str = "df -h";
1438    pub const SNIPPET_OPTIONAL: &str = "(optional)";
1439
1440    // ── Provider form ───────────────────────────────────────────────
1441    pub const PROVIDER_URL: &str = "https://pve.example.com:8006";
1442    pub const PROVIDER_TOKEN_DEFAULT: &str = "your-api-token";
1443    pub const PROVIDER_TOKEN_PROXMOX: &str = "user@pam!token=secret";
1444    pub const PROVIDER_TOKEN_AWS: &str = "AccessKeyId:Secret (or use Profile)";
1445    pub const PROVIDER_TOKEN_GCP: &str = "/path/to/service-account.json (or access token)";
1446    pub const PROVIDER_TOKEN_AZURE: &str = "/path/to/service-principal.json (or access token)";
1447    pub const PROVIDER_TOKEN_TAILSCALE: &str = "API key (leave empty for local CLI)";
1448    pub const PROVIDER_TOKEN_ORACLE: &str = "~/.oci/config";
1449    pub const PROVIDER_TOKEN_OVH: &str = "app_key:app_secret:consumer_key";
1450    pub const PROVIDER_PROFILE: &str = "Name from ~/.aws/credentials (or use Token)";
1451    pub const PROVIDER_PROJECT_DEFAULT: &str = "my-gcp-project-id";
1452    pub const PROVIDER_PROJECT_OVH: &str = "Public Cloud project ID";
1453    pub const PROVIDER_COMPARTMENT: &str = "ocid1.compartment.oc1..aaaa...";
1454    pub const PROVIDER_REGIONS_DEFAULT: &str = "Space to select regions";
1455    pub const PROVIDER_REGIONS_GCP: &str = "Space to select zones (empty = all)";
1456    pub const PROVIDER_REGIONS_SCALEWAY: &str = "Space to select zones";
1457    // Azure regions is a text input (not a picker), so no key is mentioned.
1458    pub const PROVIDER_REGIONS_AZURE: &str = "comma-separated subscription IDs";
1459    pub const PROVIDER_REGIONS_OVH: &str = "Space to select endpoint (default: EU)";
1460    pub const PROVIDER_USER_AWS: &str = "ec2-user";
1461    pub const PROVIDER_USER_GCP: &str = "ubuntu";
1462    pub const PROVIDER_USER_AZURE: &str = "azureuser";
1463    pub const PROVIDER_USER_ORACLE: &str = "opc";
1464    pub const PROVIDER_USER_OVH: &str = "ubuntu";
1465    pub const PROVIDER_VAULT_ROLE: &str =
1466        "e.g. ssh-client-signer/sign/my-role (vault login; inherited)";
1467    pub const PROVIDER_VAULT_ADDR: &str = "e.g. http://127.0.0.1:8200 (inherited by all hosts)";
1468    pub const PROVIDER_ALIAS_PREFIX_DEFAULT: &str = "prefix";
1469}
1470
1471#[cfg(test)]
1472mod hints_tests {
1473    use super::hints;
1474
1475    #[test]
1476    fn askpass_default_formats() {
1477        assert_eq!(hints::askpass_default("keychain"), "default: keychain");
1478    }
1479
1480    #[test]
1481    fn askpass_default_formats_empty() {
1482        assert_eq!(hints::askpass_default(""), "default: ");
1483    }
1484
1485    #[test]
1486    fn inherits_from_formats() {
1487        assert_eq!(
1488            hints::inherits_from("role/x", "aws"),
1489            "inherits role/x from aws"
1490        );
1491    }
1492
1493    #[test]
1494    fn picker_hints_mention_space_not_enter() {
1495        // Per the keyboard invariants, pickers open on Space.
1496        // If these assertions fail, audit scripts/check-keybindings.sh too.
1497        for s in [
1498            hints::IDENTITY_FILE_PICK,
1499            hints::HOST_PROXY_JUMP,
1500            hints::HOST_VAULT_SSH_PICKER,
1501            hints::HOST_ASKPASS_PICK,
1502            hints::PROVIDER_REGIONS_DEFAULT,
1503            hints::PROVIDER_REGIONS_GCP,
1504            hints::PROVIDER_REGIONS_SCALEWAY,
1505            hints::PROVIDER_REGIONS_OVH,
1506        ] {
1507            assert!(
1508                s.starts_with("Space "),
1509                "picker hint must mention Space: {s}"
1510            );
1511            assert!(!s.contains("Enter "), "picker hint must not say Enter: {s}");
1512        }
1513    }
1514}
1515
1516#[path = "messages/whats_new.rs"]
1517pub mod whats_new;
1518
1519#[path = "messages/whats_new_toast.rs"]
1520pub mod whats_new_toast;
1521
1522#[cfg(test)]
1523mod stale_host_tests {
1524    use super::stale_host;
1525
1526    #[test]
1527    fn empty_hint_returns_bare_sentence() {
1528        assert_eq!(stale_host(""), "Stale host.");
1529    }
1530
1531    #[test]
1532    fn empty_after_trim_returns_bare_sentence() {
1533        assert_eq!(stale_host("   "), "Stale host.");
1534    }
1535
1536    #[test]
1537    fn provider_hint_is_appended_with_space_and_period() {
1538        assert_eq!(
1539            stale_host("Gone from DigitalOcean"),
1540            "Stale host. Gone from DigitalOcean."
1541        );
1542    }
1543
1544    #[test]
1545    fn trailing_period_in_hint_is_not_doubled() {
1546        assert_eq!(
1547            stale_host("Gone from DigitalOcean."),
1548            "Stale host. Gone from DigitalOcean."
1549        );
1550    }
1551
1552    #[test]
1553    fn leading_space_in_hint_is_trimmed() {
1554        assert_eq!(stale_host(" Gone from AWS"), "Stale host. Gone from AWS.");
1555    }
1556}
1557
1558#[cfg(test)]
1559mod relative_age_tests {
1560    use super::relative_age;
1561    use std::time::Duration;
1562
1563    #[test]
1564    fn relative_age_boundaries() {
1565        assert_eq!(relative_age(Duration::from_secs(0)), "just now");
1566        assert_eq!(relative_age(Duration::from_secs(4)), "just now");
1567        assert_eq!(relative_age(Duration::from_secs(5)), "5s ago");
1568        assert_eq!(relative_age(Duration::from_secs(59)), "59s ago");
1569        assert_eq!(relative_age(Duration::from_secs(60)), "1m ago");
1570        assert_eq!(relative_age(Duration::from_secs(3599)), "59m ago");
1571        assert_eq!(relative_age(Duration::from_secs(3600)), "1h ago");
1572        assert_eq!(relative_age(Duration::from_secs(86399)), "23h ago");
1573        assert_eq!(relative_age(Duration::from_secs(86400)), "1d ago");
1574        assert_eq!(relative_age(Duration::from_secs(86400 * 7)), "7d ago");
1575    }
1576}