Skip to main content

purple_ssh/messages/
cli.rs

1// ── Add host validation ─────────────────────────────────────────
2
3pub const ALIAS_EMPTY: &str = "Alias can't be empty. Use --alias to specify one.";
4pub const ALIAS_WHITESPACE: &str =
5    "Alias can't contain whitespace. Use --alias to pick a simpler name.";
6pub const ALIAS_PATTERN_CHARS: &str =
7    "Alias can't contain pattern characters. Use --alias to pick a different name.";
8pub const HOSTNAME_WHITESPACE: &str = "Hostname can't contain whitespace.";
9pub const USER_WHITESPACE: &str = "User can't contain whitespace.";
10pub const PASSWORD_EMPTY: &str = "Password can't be empty.";
11pub const CANCELLED: &str = "Cancelled.";
12pub const DESCRIPTION_CONTROL_CHARS: &str = "Description contains control characters.";
13
14pub use super::contains_control_chars as control_chars;
15
16pub fn welcome(alias: &str) -> String {
17    format!("Welcome aboard, {}!", alias)
18}
19
20// ── Import ──────────────────────────────────────────────────────
21
22pub const IMPORT_NO_FILE: &str =
23    "Provide a file or use --known-hosts. Run 'purple import --help' for details.";
24
25// ── Provider CLI ────────────────────────────────────────────────
26
27pub const NO_PROVIDERS: &str = "No providers configured. Run 'purple provider add' to set one up.";
28
29/// All supported provider slugs as a comma-separated string. Surfaced in
30/// `unknown_provider` and `skipping_unknown_provider`. Kept as a single
31/// const so adding a new provider only updates one place.
32pub const PROVIDER_LIST: &str = "digitalocean, vultr, linode, hetzner, upcloud, proxmox, aws, \
33     scaleway, gcp, azure, tailscale, oracle, ovh, leaseweb, i3d, transip";
34
35/// Stderr line when the user passed `--provider X` for an unknown slug.
36/// Different from `skipping_unknown_provider` so each call site can
37/// evolve its wording without breaking the other.
38pub fn unknown_provider(name: &str) -> String {
39    format!("Never heard of '{}'. Try: {}.", name, PROVIDER_LIST)
40}
41
42/// Stderr line when iterating configured providers and one is unknown
43/// (e.g. config file references a since-removed provider). The sync
44/// continues with the remaining providers.
45pub fn skipping_unknown_provider(name: &str) -> String {
46    format!(
47        "Skipping unknown provider '{}'. Try: {}.",
48        name, PROVIDER_LIST
49    )
50}
51
52/// Stderr line printed by `purple add` when the alias already exists.
53/// Tells the user the exact flag to fix it instead of just complaining.
54pub fn alias_already_exists(alias: &str) -> String {
55    format!(
56        "'{}' already exists. Use --alias to pick a different name.",
57        alias
58    )
59}
60
61/// Stderr lines printed after `purple import` when some lines failed
62/// to parse or read. Use the singular/plural form via the count.
63pub fn import_parse_failures(count: usize) -> String {
64    let s = if count == 1 { "" } else { "s" };
65    format!(
66        "! {} line{} could not be parsed (invalid format).",
67        count, s
68    )
69}
70
71pub fn import_read_errors(count: usize) -> String {
72    let s = if count == 1 { "" } else { "s" };
73    format!("! {} line{} could not be read (encoding error).", count, s)
74}
75
76pub fn no_config_for(provider: &str) -> String {
77    format!(
78        "No configuration for {}. Run 'purple provider add {}' first.",
79        provider, provider
80    )
81}
82
83pub fn saved_config(provider: &str) -> String {
84    format!("Saved {} configuration.", provider)
85}
86
87pub fn no_config_to_remove(provider: &str) -> String {
88    format!("No configuration for '{}'. Nothing to remove.", provider)
89}
90
91pub fn removed_config(provider: &str) -> String {
92    format!("Removed {} configuration.", provider)
93}
94
95pub fn removed_configs(provider: &str, count: usize) -> String {
96    format!("Removed {} {} configurations.", count, provider)
97}
98
99pub fn invalid_label_flag(reason: &str) -> String {
100    format!("Invalid --label: {}", reason)
101}
102
103pub fn add_requires_label(provider: &str) -> String {
104    format!(
105        "Provider '{}' already has labeled configs. Pass --label to add another.",
106        provider
107    )
108}
109
110pub fn add_label_collides_with_bare(provider: &str) -> String {
111    format!(
112        "Provider '{}' has a bare config. Remove it first or use the TUI add flow which prompts for labels.",
113        provider
114    )
115}
116
117// ── Tunnel CLI ──────────────────────────────────────────────────
118
119pub fn no_tunnels_for(alias: &str) -> String {
120    format!("No tunnels configured for {}.", alias)
121}
122
123pub fn tunnels_for(alias: &str) -> String {
124    format!("Tunnels for {}:", alias)
125}
126
127pub const NO_TUNNELS: &str = "No tunnels configured.";
128
129pub fn starting_tunnel(alias: &str) -> String {
130    format!("Starting tunnel for {}... (Ctrl+C to stop)", alias)
131}
132
133pub fn host_not_found(alias: &str) -> String {
134    format!("No host '{}' found.", alias)
135}
136
137pub fn added_forward(forward: &str, alias: &str) -> String {
138    format!("Added {} to {}.", forward, alias)
139}
140
141pub fn forward_exists(forward: &str, alias: &str) -> String {
142    format!("Forward {} already exists on {}.", forward, alias)
143}
144
145pub fn forward_not_found(forward: &str, alias: &str) -> String {
146    format!("No matching forward {} found on {}.", forward, alias)
147}
148
149pub fn removed_forward(forward: &str, alias: &str) -> String {
150    format!("Removed {} from {}.", forward, alias)
151}
152
153pub fn no_forwards(alias: &str) -> String {
154    format!("No forwarding directives configured for '{}'.", alias)
155}
156
157pub fn save_config_failed(e: &impl std::fmt::Display) -> String {
158    format!("Failed to save config: {}", e)
159}
160
161pub fn included_host_read_only(alias: &str) -> String {
162    format!(
163        "Host '{}' is from an included file and cannot be modified.",
164        alias
165    )
166}
167
168pub fn operation_failed(e: &impl std::fmt::Display) -> String {
169    format!("Failed: {}", e)
170}
171
172// ── Snippet CLI ─────────────────────────────────────────────────
173
174pub const NO_SNIPPETS: &str = "No snippets configured. Use 'purple snippet add' to create one.";
175
176pub use super::snippet_added;
177pub use super::snippet_removed;
178pub use super::snippet_updated;
179
180pub fn snippet_not_found(name: &str) -> String {
181    format!("No snippet '{}' found.", name)
182}
183
184pub fn no_hosts_with_tag(tag: &str) -> String {
185    format!("No hosts found with tag '{}'.", tag)
186}
187
188pub const SPECIFY_TARGET: &str = "Specify a host alias, --tag or --all.";
189
190// ── Run/exec output ─────────────────────────────────────────────
191
192pub fn beaming_up(alias: &str) -> String {
193    format!("Beaming you up to {}...\n", alias)
194}
195
196pub fn running_snippet_on(name: &str, alias: &str) -> String {
197    format!("Running '{}' on {}...\n", name, alias)
198}
199
200pub fn host_separator(alias: &str) -> String {
201    format!("── {} ──", alias)
202}
203
204pub fn exited_with_code(code: i32) -> String {
205    format!("Exited with code {}.", code)
206}
207
208pub const DONE: &str = "Done.";
209
210pub fn done_multi(name: &str, count: usize) -> String {
211    format!("Done. Ran '{}' on {} hosts.", name, count)
212}
213
214pub const PRESS_ENTER: &str = "Press Enter to continue...";
215
216pub fn host_failed(alias: &str, e: &impl std::fmt::Display) -> String {
217    format!("[{}] Failed: {}", alias, e)
218}
219
220pub fn skipping_host(alias: &str, e: &impl std::fmt::Display) -> String {
221    format!("Skipping {}: {}", alias, e)
222}
223
224// ── Password CLI ────────────────────────────────────────────────
225
226pub fn password_removed(alias: &str) -> String {
227    format!("Password removed for {}.", alias)
228}
229
230// ── Log CLI ─────────────────────────────────────────────────────
231
232pub fn log_deleted(path: &impl std::fmt::Display) -> String {
233    format!("Log file deleted: {}", path)
234}
235
236pub fn no_log_file(path: &impl std::fmt::Display) -> String {
237    format!("No log file found at {}", path)
238}
239
240// ── Theme CLI ───────────────────────────────────────────────────
241
242pub const BUILTIN_THEMES: &str = "Built-in themes:";
243pub const CUSTOM_THEMES: &str = "\nCustom themes:";
244
245pub fn theme_set(name: &str) -> String {
246    format!("Theme set to: {}", name)
247}
248
249// ── Sync output ─────────────────────────────────────────────────
250
251pub fn syncing(name: &str, summary: &str) -> String {
252    format!("\x1b[2K\rSyncing {}... {}", name, summary)
253}
254
255/// One-shot "Syncing X... " prefix without the cursor-clear/CR escapes.
256/// Used before progress callbacks start emitting overwrite-style updates,
257/// so the user sees something happening even if the provider is slow to
258/// produce the first progress event.
259pub fn syncing_start(name: &str) -> String {
260    format!("Syncing {}... ", name)
261}
262
263/// Rendered before the dot-progress (`\u{2713}` or error) on each
264/// per-host vault sign in the CLI bulk path. The trailing space is
265/// intentional — the success/fail glyph follows on the same line.
266pub fn vault_signing_host(alias: &str) -> String {
267    format!("Signing {}... ", alias)
268}
269
270/// Stderr line emitted by `purple vault sign --all` when a host block
271/// disappeared between the moment we enumerated it and the moment we
272/// tried to write its CertificateFile (rename, delete, race with another
273/// process). The cert is on disk; only the wiring is missing.
274pub fn vault_sign_host_block_gone(alias: &str) -> String {
275    format!(
276        "  warning: {} no longer in ssh config; CertificateFile not written (cert saved on disk)",
277        alias
278    )
279}
280
281/// Single-word "failed." status the CLI sync output appends when a
282/// provider fetch hit a hard error. Mirrors the trailing-status pattern
283/// used by `syncing_start` so the line reads `Syncing X... failed.`.
284pub const SYNC_FAILED: &str = "failed.";
285
286pub fn servers_found_with_failures(count: usize, failures: usize, total: usize) -> String {
287    format!(
288        "{} servers found ({} of {} failed to fetch).",
289        count, failures, total
290    )
291}
292
293pub fn servers_found(count: usize) -> String {
294    format!("{} servers found.", count)
295}
296
297pub fn sync_result(prefix: &str, added: usize, updated: usize, unchanged: usize) -> String {
298    format!(
299        "{}Added {}, updated {}, unchanged {}.",
300        prefix, added, updated, unchanged
301    )
302}
303
304pub fn sync_removed(count: usize) -> String {
305    format!("  Removed {}.", count)
306}
307
308pub fn sync_stale(count: usize) -> String {
309    format!("  Marked {} stale.", count)
310}
311
312pub fn sync_skip_remove(display_name: &str) -> String {
313    format!(
314        "! {}: skipping --remove due to partial failures.",
315        display_name
316    )
317}
318
319pub fn sync_error(display_name: &str, e: &impl std::fmt::Display) -> String {
320    format!("! {}: {}", display_name, e)
321}
322
323pub const SYNC_SKIP_WRITE: &str =
324    "! Skipping config write due to sync failures. Fix the errors and re-run.";
325
326// ── Provider validation (CLI) ───────────────────────────────────
327
328pub const PROXMOX_URL_REQUIRED: &str =
329    "Proxmox requires --url (e.g. --url https://pve.example.com:8006).";
330pub const AWS_REGIONS_REQUIRED: &str =
331    "AWS requires --regions (e.g. --regions us-east-1,eu-west-1).";
332pub const AZURE_REGIONS_REQUIRED: &str =
333    "Azure requires --regions with one or more subscription IDs.";
334pub const GCP_PROJECT_REQUIRED: &str = "GCP requires --project (e.g. --project my-gcp-project-id).";
335pub use super::ALIAS_PREFIX_INVALID;
336
337pub const WARN_URL_NOT_USED: &str =
338    "Warning: --url is only used by the Proxmox provider. Ignoring.";
339pub const WARN_PROFILE_NOT_USED: &str =
340    "Warning: --profile is only used by the AWS provider. Ignoring.";
341pub const WARN_PROJECT_NOT_USED: &str =
342    "Warning: --project is only used by the GCP provider. Ignoring.";
343pub const WARN_COMPARTMENT_NOT_USED: &str =
344    "Warning: --compartment is only used by the Oracle provider. Ignoring.";
345pub const WARN_NO_VERIFY_TLS_NOT_USED: &str =
346    "Warning: --no-verify-tls is only used by the Proxmox provider. Ignoring.";
347pub const WARN_VERIFY_TLS_NOT_USED: &str =
348    "Warning: --verify-tls is only used by the Proxmox provider. Ignoring.";
349pub const WARN_REGIONS_NOT_USED: &str = "Warning: --regions is only used by the AWS, Scaleway, GCP, Azure and Oracle providers. \
350     Ignoring.";
351
352/// Per-host status prefixes for `purple sync` output. Indented two
353/// spaces so the result line aligns under the `Syncing X...` header.
354/// `--dry-run` mode prepends "Would have:" so the user knows nothing
355/// was written.
356pub const SYNC_RESULT_PREFIX_LIVE: &str = "  ";
357pub const SYNC_RESULT_PREFIX_DRY_RUN: &str = "  Would have: ";
358
359/// Stderr line printed by `purple provider add` when the user-supplied
360/// URL does not start with `https://`. CLI-flavoured: tells the user to
361/// use the `--no-verify-tls` flag, distinct from the TUI variant which
362/// references a Verify TLS toggle.
363pub const PROVIDER_URL_REQUIRES_HTTPS: &str =
364    "URL must start with https://. For self-signed certificates use --no-verify-tls.";
365
366/// Reuse the TUI form's `Token can't be empty...` lines verbatim — the
367/// remediation steps (paste a JSON file path, paste an OCI config path,
368/// grab a token from the dashboard) are identical between CLI and TUI.
369pub use super::PROVIDER_TOKEN_REQUIRED_GCP;
370pub use super::PROVIDER_TOKEN_REQUIRED_ORACLE;
371pub use super::azure_subscription_id_invalid;
372pub use super::provider_token_required;
373
374/// Stderr line printed when `purple provider add scaleway` is missing
375/// `--regions`. Mirrors the Azure/GCP/Oracle pattern of including the
376/// concrete flag form in the message.
377pub const SCALEWAY_REGIONS_REQUIRED: &str = "Scaleway requires --regions with one or more zones \
378     (e.g. --regions fr-par-1,nl-ams-1).";
379
380pub const ORACLE_COMPARTMENT_REQUIRED: &str =
381    "Oracle requires --compartment (e.g. --compartment ocid1.compartment.oc1..aaa...).";
382
383// ── Vault CLI ───────────────────────────────────────────────────
384
385pub fn vault_no_role(alias: &str) -> String {
386    format!(
387        "No Vault SSH role configured for '{}'. Set it in the host form \
388         (Vault SSH Role field) or in the provider config (vault_role).",
389        alias
390    )
391}
392
393pub fn vault_cert_signed(path: &impl std::fmt::Display) -> String {
394    format!("Certificate signed: {}", path)
395}
396
397pub fn vault_sign_failed(e: &impl std::fmt::Display) -> String {
398    format!("failed: {}", e)
399}
400
401pub fn vault_config_update_warning(e: &impl std::fmt::Display) -> String {
402    format!("Warning: Failed to update SSH config: {}", e)
403}
404
405// ── List hosts ──────────────────────────────────────────────────
406
407pub const NO_HOSTS: &str = "No hosts configured. Run 'purple' to add some!";
408
409// ── Token ───────────────────────────────────────────────────────
410
411pub const NO_TOKEN: &str =
412    "No token provided. Use --token, --token-stdin, or set PURPLE_TOKEN env var.";
413
414// ── What's new (CLI) ────────────────────────────────────────────
415
416pub mod whats_new {
417    pub const HEADER: &str = "purple release notes";
418}