1pub mod container;
13pub mod host;
14pub mod picker;
15pub mod provider;
16pub mod snippet;
17pub mod tunnel;
18pub mod vault;
19
20pub use container::*;
21pub use host::*;
22pub use picker::*;
23pub use provider::*;
24pub use snippet::*;
25pub use tunnel::*;
26pub use vault::*;
27
28pub const FAILED_TO_SAVE: &str = "Failed to save";
31pub fn failed_to_save(e: &impl std::fmt::Display) -> String {
32 format!("{}: {}", FAILED_TO_SAVE, e)
33}
34
35pub const CONFIG_CHANGED_EXTERNALLY: &str =
36 "Config changed externally. Press Esc and re-open to pick up changes.";
37
38pub const WELCOME_BACKUP_TAKEN: &str = "Your original config has been backed up";
41pub fn welcome_backup_location(path: &str) -> String {
43 format!("to {path}")
44}
45
46pub const DEMO_CONNECTION_DISABLED: &str = "Demo mode. Connection disabled.";
49pub const DEMO_SYNC_DISABLED: &str = "Demo mode. Sync disabled.";
50pub const DEMO_TUNNELS_DISABLED: &str = "Demo mode. Tunnels disabled.";
51pub const DEMO_VAULT_SIGNING_DISABLED: &str = "Demo mode. Vault SSH signing disabled.";
52pub const DEMO_FILE_BROWSER_DISABLED: &str = "Demo mode. File browser disabled.";
53pub const DEMO_CONTAINER_REFRESH_DISABLED: &str = "Demo mode. Container refresh disabled.";
54pub const DEMO_CONTAINER_ACTIONS_DISABLED: &str = "Demo mode. Container actions disabled.";
55pub const DEMO_EXECUTION_DISABLED: &str = "Demo mode. Execution disabled.";
56pub const DEMO_PROVIDER_CHANGES_DISABLED: &str = "Demo mode. Provider config changes disabled.";
57
58pub fn pinging_host(alias: &str, show_hint: bool) -> String {
61 if show_hint {
62 format!("Pinging {}... (Shift+P pings all)", alias)
63 } else {
64 format!("Pinging {}...", alias)
65 }
66}
67
68pub fn bastion_not_found(alias: &str) -> String {
69 format!("Bastion {} not found in config.", alias)
70}
71
72pub fn ping_skipped_proxied(alias: &str) -> String {
73 format!("{} connects through a ProxyCommand. Ping skipped.", alias)
74}
75
76pub fn clipboard_run_failed(cmd: &str) -> String {
82 format!("Failed to run {}.", cmd)
83}
84
85pub fn clipboard_write_failed(cmd: &str) -> String {
86 format!("Failed to write to {}.", cmd)
87}
88
89pub fn clipboard_wait_failed(cmd: &str) -> String {
90 format!("Failed to wait for {}.", cmd)
91}
92
93pub fn clipboard_exited_error(cmd: &str) -> String {
94 format!("{} exited with error.", cmd)
95}
96
97pub fn import_open_failed(path: &impl std::fmt::Display, e: &impl std::fmt::Display) -> String {
103 format!("Can't open {}: {}", path, e)
104}
105
106pub fn import_known_hosts_open_failed(e: &impl std::fmt::Display) -> String {
107 format!("Can't open known_hosts: {}", e)
108}
109
110pub const IMPORT_HOME_DIR_UNKNOWN: &str = "Could not determine home directory.";
111pub const IMPORT_KNOWN_HOSTS_MISSING: &str = "~/.ssh/known_hosts not found.";
112
113pub fn imported_hosts(imported: usize, skipped: usize) -> String {
116 format!(
117 "Imported {} host{}, skipped {} duplicate{}.",
118 imported,
119 if imported == 1 { "" } else { "s" },
120 skipped,
121 if skipped == 1 { "" } else { "s" }
122 )
123}
124
125pub fn all_hosts_exist(skipped: usize) -> String {
126 if skipped == 1 {
127 "Host already exists.".to_string()
128 } else {
129 format!("All {} hosts already exist.", skipped)
130 }
131}
132
133pub fn opened_in_tmux(alias: &str) -> String {
136 format!("Opened {} in new tmux window.", alias)
137}
138
139pub fn tmux_error(e: &impl std::fmt::Display) -> String {
140 format!("tmux: {}", e)
141}
142
143pub fn connection_failed(alias: &str) -> String {
144 format!("Connection to {} failed.", alias)
145}
146
147pub fn connection_spawn_failed(e: &impl std::fmt::Display) -> String {
151 format!("Connection failed: {}", e)
152}
153
154pub fn ssh_failed_with_reason(alias: &str, reason: &str) -> String {
157 format!("SSH to {} failed. {}", alias, reason)
158}
159
160pub fn ssh_exited_with_code(alias: &str, code: i32) -> String {
163 format!("SSH to {} exited with code {}.", alias, code)
164}
165
166pub const TRANSFER_COMPLETE: &str = "Transfer complete.";
169
170pub fn provider_progress(spinner: &str, name: &str, message: &str) -> String {
179 format!("{} {}: {}", spinner, name, message)
180}
181
182pub const AGE_JUST_NOW: &str = "just now";
185
186pub fn relative_age(elapsed: std::time::Duration) -> String {
190 let secs = elapsed.as_secs();
191 if secs < 5 {
192 AGE_JUST_NOW.to_string()
193 } else if secs < 60 {
194 format!("{}s ago", secs)
195 } else if secs < 3600 {
196 format!("{}m ago", secs / 60)
197 } else if secs < 86400 {
198 format!("{}h ago", secs / 3600)
199 } else {
200 format!("{}d ago", secs / 86400)
201 }
202}
203
204pub fn config_reloaded(count: usize) -> String {
207 format!("Config reloaded. {} hosts.", count)
208}
209
210pub fn synced_progress(
226 spinner: &str,
227 active_names: &str,
228 done: usize,
229 total: usize,
230 added: usize,
231 updated: usize,
232 stale: usize,
233) -> String {
234 debug_assert!(
235 !active_names.is_empty(),
236 "synced_progress must only be called while a provider is still in flight"
237 );
238 let diff = sync_diff_suffix(added, updated, stale);
239 format!(
240 "{} Syncing {} \u{00B7} {}/{}{}",
241 spinner, active_names, done, total, diff
242 )
243}
244
245pub fn synced_done(
250 done: usize,
251 total: usize,
252 names: &str,
253 added: usize,
254 updated: usize,
255 stale: usize,
256) -> String {
257 let diff = sync_diff_suffix(added, updated, stale);
258 format!("Synced {}/{} \u{00B7} {}{}", done, total, names, diff)
259}
260
261fn sync_diff_suffix(added: usize, updated: usize, stale: usize) -> String {
262 let parts: Vec<String> = [(added, '+'), (updated, '~'), (stale, '-')]
263 .iter()
264 .filter(|(n, _)| *n > 0)
265 .map(|(n, sign)| format!("{}{}", sign, n))
266 .collect();
267 if parts.is_empty() {
268 String::new()
269 } else {
270 format!(" ({})", parts.join(" "))
271 }
272}
273
274pub const SYNC_THREAD_SPAWN_FAILED: &str = "Failed to start sync thread.";
275
276pub const SYNC_UNKNOWN_PROVIDER: &str = "Unknown provider.";
277
278pub fn sync_skipped_external_change() -> &'static str {
279 "Config changed on disk during sync. Re-run sync after reviewing your edits."
280}
281
282pub const NO_CLIPBOARD_TOOL: &str =
285 "No clipboard tool found. Install pbcopy (macOS), wl-copy (Wayland), or xclip/xsel (X11).";
286
287pub const MCP_TOOL_DENIED_READ_ONLY: &str = "Tool denied. Server started with --read-only. Restart without --read-only to enable state-changing tools.";
290
291pub fn mcp_audit_init_failed(path: &impl std::fmt::Display, e: &impl std::fmt::Display) -> String {
295 format!(
296 "Failed to initialise MCP audit log at {}: {}. Continuing without audit logging.",
297 path, e
298 )
299}
300
301pub fn mcp_audit_write_failed(e: &impl std::fmt::Display) -> String {
303 format!("Failed to write MCP audit entry: {}", e)
304}
305
306pub fn mcp_config_file_not_found(path: &impl std::fmt::Display) -> String {
310 format!("SSH config file not found: {}", path)
311}
312
313pub const MCP_AUDIT_HOME_DIR_UNAVAILABLE: &str = "Could not determine home directory; MCP audit log disabled. Set --audit-log <PATH> explicitly to enable auditing.";
317
318pub const PALETTE_PLACEHOLDER: &str = "Find anything";
322pub const PALETTE_NO_RESULTS: &str = "No matches.";
324pub const PALETTE_SNIPPET_NEEDS_HOST: &str =
327 "Pick a host first, then run a snippet from the jump bar.";
328pub fn jump_more_rows(n: usize) -> String {
331 format!("+{n} more (scroll down)")
332}
333
334#[path = "messages/cli.rs"]
337pub mod cli;
338pub mod footer;
339
340pub mod update {
343 pub const WHATS_NEW_HINT: &str = "Press n inside purple to see what's new.";
344 pub const DONE: &str = "done.";
345 pub const CHECKSUM_OK: &str = "ok.";
346 pub const SUDO_WARNING: &str =
347 "Running via sudo. Consider fixing directory permissions instead.";
348
349 pub const STEP_CHECKING: &str = " Checking for updates... ";
354 pub const STEP_VERIFYING_CHECKSUM: &str = " Verifying checksum... ";
355 pub const STEP_INSTALLING: &str = " Installing... ";
356
357 pub fn already_on(current: &str) -> String {
358 format!("already on v{} (latest).", current)
359 }
360
361 pub fn available(latest: &str, current: &str) -> String {
362 format!("v{} available (current: v{}).", latest, current)
363 }
364
365 pub fn step_downloading(version: &str) -> String {
369 format!(" Downloading v{}... ", version)
370 }
371
372 pub fn sudo_warning_line(bold_bang: &str) -> String {
376 format!(" {} {}", bold_bang, SUDO_WARNING)
377 }
378
379 pub fn header(bold_name: &str) -> String {
380 format!("\n {} updater\n", bold_name)
381 }
382
383 pub fn binary_path(path: &std::path::Path) -> String {
384 format!(" Binary: {}", path.display())
385 }
386
387 pub fn installed_at(bold_version: &str, path: &std::path::Path) -> String {
388 format!("\n {} installed at {}.", bold_version, path.display())
389 }
390
391 pub fn whats_new_hint_indented() -> String {
392 format!("\n {}", WHATS_NEW_HINT)
393 }
394}
395
396pub mod askpass {
399 pub const BW_NOT_FOUND: &str = "Bitwarden CLI (bw) not found. SSH will prompt for password.";
400 pub const BW_NOT_LOGGED_IN: &str = "Bitwarden vault not logged in. Run 'bw login' first.";
401 pub const EMPTY_PASSWORD: &str = "Empty password. SSH will prompt for password.";
402 pub const PASSWORD_IN_KEYCHAIN: &str = "Password stored in keychain.";
403
404 pub fn read_failed(e: &impl std::fmt::Display) -> String {
405 format!("Failed to read password: {}", e)
406 }
407
408 pub fn unlock_failed_retry(e: &impl std::fmt::Display) -> String {
409 format!("Unlock failed: {}. Try again.", e)
410 }
411
412 pub fn unlock_failed_prompt(e: &impl std::fmt::Display) -> String {
413 format!("Unlock failed: {}. SSH will prompt for password.", e)
414 }
415
416 pub fn password_prompt(alias: &str) -> String {
420 format!("Password for {}: ", alias)
421 }
422
423 pub fn keychain_password_prompt(alias: &str) -> String {
426 format!("Password for {} (stored in keychain): ", alias)
427 }
428
429 pub fn keychain_store_failed(e: &impl std::fmt::Display) -> String {
432 format!(
433 "Failed to store in keychain: {}. SSH will prompt for password.",
434 e
435 )
436 }
437
438 pub const PROTON_NOT_FOUND: &str =
439 "Proton Pass CLI (pass-cli) not found. SSH will prompt for password.";
440
441 pub const PROTON_LOGIN_PROMPT: &str = "Proton Pass PAT: ";
442
443 pub const PROTON_LOGIN_SUCCESS: &str = "Logged in to Proton Pass.";
444
445 pub fn proton_login_failed_retry(e: &impl std::fmt::Display) -> String {
446 format!("Proton Pass login failed: {}. Try again.", e)
447 }
448
449 pub fn proton_login_failed_prompt(e: &impl std::fmt::Display) -> String {
450 format!(
451 "Proton Pass login failed: {}. SSH will prompt for password.",
452 e
453 )
454 }
455
456 pub const PROMPT_TITLE: &str = "Password";
463
464 pub const PROMPT_NEEDS_PASSWORD: &str = "needs a password";
466
467 pub const PROMPT_FIELD_PASSWORD: &str = "password";
469 pub const PROMPT_FIELD_REMEMBER: &str = "remember";
470
471 pub const PROMPT_REMEMBER_ON: &str = "in keychain";
474
475 pub const PROMPT_REMEMBER_OFF: &str = "this session only";
478
479 pub fn source_did_not_deliver(alias: &str, source_label: &str) -> String {
483 format!(
484 "{} failed password authentication. {} did not deliver a password. Check the source and try again.",
485 alias, source_label
486 )
487 }
488
489 pub fn password_rejected(alias: &str) -> String {
492 format!("{} rejected the password. Try again.", alias)
493 }
494
495 pub fn keychain_store_failed_session(e: &impl std::fmt::Display) -> String {
498 format!(
499 "Could not store in keychain: {}. Using the password for this session only.",
500 e
501 )
502 }
503
504 pub fn keychain_source_not_written(alias: &str) -> String {
509 format!(
510 "Stored in keychain, but the block for {} is not in the file purple writes, so the password source was not set. Using the password for this session.",
511 alias
512 )
513 }
514
515 pub fn keychain_source_skipped_external_change(alias: &str) -> String {
519 format!(
520 "Stored in keychain. Your SSH config changed on disk, so the password source for {} was left for you to set. Using the password for this session.",
521 alias
522 )
523 }
524
525 pub fn keychain_stored_without_source(e: &impl std::fmt::Display) -> String {
529 format!(
530 "Stored in keychain, but saving the password source failed: {}. Using the password for this session.",
531 e
532 )
533 }
534
535 pub fn prompt_cancelled(alias: &str) -> String {
539 format!("No password given for {}. Skipped.", alias)
540 }
541
542 pub fn hop_refused(hop: &str) -> String {
546 format!(
547 "{} refused the login on the way there. Give that jump host a password source and try again.",
548 hop
549 )
550 }
551
552 pub fn prompt_names_no_host(alias: &str) -> String {
559 format!(
560 "{} goes through a jump host. purple could not tell which machine asked for the password, so it answered neither. A prompt that spells out its host can be placed by giving that host a Host block of its own.",
561 alias
562 )
563 }
564
565 pub const PIN_PROMPT_TITLE: &str = "Security Key";
569
570 pub const PIN_PROMPT_NEEDS_PIN: &str = "needs a security key PIN";
572
573 pub const PIN_FIELD_KEY: &str = "key";
575 pub const PIN_FIELD_PIN: &str = "PIN";
576
577 pub const PIN_TOUCH_HINT: &str = "Touch the key when it blinks.";
579
580 pub const PIN_TOUCH_KEY: &str = "Touch your security key when it blinks.";
582
583 pub fn pin_too_short() -> String {
586 format!(
587 "A security key PIN has at least {} characters. Some keys require more.",
588 crate::security_key::PIN_MIN_CHARS
589 )
590 }
591
592 pub fn pin_refused(key: &str, reason: crate::security_key::RefusalReason) -> String {
594 use crate::security_key::RefusalReason;
595 match reason {
596 RefusalReason::WrongPin => format!(
597 "Wrong PIN for {}. After three in a row the key needs a replug.",
598 key
599 ),
600 RefusalReason::NoAnswer => format!(
601 "The key for {} did not answer. Touch it sooner or replug it.",
602 key
603 ),
604 RefusalReason::NotFound => format!("No security key found for {}. Plug it in.", key),
605 RefusalReason::Other => format!("The security key for {} could not sign.", key),
606 }
607 }
608
609 pub fn pin_refused_but_connected(alias: &str, key: &str) -> String {
612 format!("Wrong PIN for {}. {} let you in another way.", key, alias)
613 }
614
615 pub fn pin_prompt_canceled(alias: &str) -> String {
617 format!("No security key PIN given for {}. Skipped.", alias)
618 }
619
620 pub fn pin_needed_listing(alias: &str, key: &str) -> String {
622 format!(
623 "{} needs the PIN of security key {}. Press R to enter it.",
624 alias, key
625 )
626 }
627
628 pub fn pin_needed_transfer(alias: &str, key: &str) -> String {
630 format!(
631 "{} needs the PIN of security key {}. Refresh the listing with R to enter it.",
632 alias, key
633 )
634 }
635
636 pub fn pin_needed_tunnel(alias: &str, key: &str) -> String {
638 format!(
639 "Tunnel for {} needs the PIN of security key {}. Start it again to enter it.",
640 alias, key
641 )
642 }
643}
644
645pub mod host_key_trust {
648 pub const TITLE: &str = "New Host";
649
650 pub fn question(hostname: &str) -> String {
652 format!("Trust {} on first contact?", hostname)
653 }
654
655 pub fn detail_unknown(alias: &str) -> String {
657 format!("{} is not in known_hosts yet.", alias)
658 }
659
660 pub const DETAIL_RECORDED: &str =
662 "Its key is recorded now and checked on every later connection.";
663
664 pub fn declined(alias: &str) -> String {
666 format!("Host key for {} not trusted. Skipped.", alias)
667 }
668
669 pub fn hop_is_unknown(hop: &str) -> String {
673 format!(
674 "{} is not in known_hosts yet, and it sits on the way there. Connect to that jump host once to record its key.",
675 hop
676 )
677 }
678}
679
680pub mod logging {
683 pub fn init_failed(e: &impl std::fmt::Display) -> String {
684 format!("[purple] Failed to initialize logger: {}", e)
685 }
686
687 pub const SSH_VERSION_FAILED: &str = "[purple] Failed to detect SSH version. Is ssh installed?";
688}
689
690pub mod hints {
696 pub const IDENTITY_FILE_PICK: &str = "Space to pick a key";
701 pub const DEFAULT_SSH_USER: &str = "root";
702
703 pub const HOST_ALIAS: &str = "e.g. prod or db-01";
705 pub const HOST_ALIAS_PATTERN: &str = "10.0.0.* or *.example.com";
706 pub const HOST_HOSTNAME: &str = "192.168.1.1 or example.com";
707 pub const HOST_PORT: &str = "22";
708 pub const HOST_PROXY_JUMP: &str = "Space to pick a host";
709 pub const HOST_VAULT_SSH: &str = "e.g. ssh-client-signer/sign/my-role (auth via vault login)";
710 pub const HOST_VAULT_SSH_PICKER: &str = "Space to pick a role or type one";
711 pub const HOST_VAULT_ADDR: &str =
712 "e.g. http://127.0.0.1:8200 (inherits from provider or env when empty)";
713 pub const HOST_TAGS: &str = "e.g. prod, staging, us-east (comma-separated)";
714 pub const HOST_ASKPASS_PICK: &str = "Space to pick a source";
715
716 pub fn askpass_default(default: &str) -> String {
717 format!("default: {}", default)
718 }
719
720 pub fn inherits_from(value: &str, provider: &str) -> String {
721 format!("inherits {} from {}", value, provider)
722 }
723
724 pub const TUNNEL_BIND_PORT: &str = "8080";
726 pub const TUNNEL_REMOTE_HOST: &str = "localhost";
727 pub const TUNNEL_REMOTE_PORT: &str = "80";
728
729 pub const SNIPPET_NAME: &str = "check-disk";
731 pub const SNIPPET_COMMAND: &str = "df -h";
732 pub const SNIPPET_OPTIONAL: &str = "(optional)";
733 pub const SNIPPET_DEFAULT_HOSTS: &str = "Space to pick hosts";
735
736 pub fn snippet_interactive_value(interactive: bool) -> &'static str {
738 if interactive {
739 "yes (loads ~/.bashrc or ~/.zshrc, like when you log in)"
740 } else {
741 "no (plain ssh)"
742 }
743 }
744
745 pub const PROVIDER_URL: &str = "https://pve.example.com:8006";
747 pub const PROVIDER_URL_NETBOX: &str = "https://netbox.example.com";
748 pub const PROVIDER_TOKEN_DEFAULT: &str = "your-api-token";
749 pub const PROVIDER_TOKEN_PROXMOX: &str = "user@pam!token=secret";
750 pub const PROVIDER_TOKEN_NETBOX: &str = "nbt_... (v2) or classic API token";
751 pub const PROVIDER_TOKEN_AWS: &str = "AccessKeyId:Secret[:SessionToken] (or Profile or env)";
752 pub const PROVIDER_TOKEN_GCP: &str = "/path/to/service-account.json (or access token)";
753 pub const PROVIDER_TOKEN_AZURE: &str = "/path/to/service-principal.json (or access token)";
754 pub const PROVIDER_TOKEN_TAILSCALE: &str = "API key (leave empty for local CLI)";
755 pub const PROVIDER_TOKEN_TELEPORT: &str = "not used (tsh login holds the session)";
756 pub const PROVIDER_TOKEN_ORACLE: &str = "~/.oci/config";
757 pub const PROVIDER_TOKEN_OVH: &str = "app_key:app_secret:consumer_key";
758 pub const PROVIDER_PROFILE: &str = "Space to select a profile (or use Token)";
759 pub const PROVIDER_PROJECT_DEFAULT: &str = "my-gcp-project-id";
760 pub const PROVIDER_PROJECT_OVH: &str = "Public Cloud project ID";
761 pub const PROVIDER_COMPARTMENT: &str = "ocid1.compartment.oc1..aaaa...";
762 pub const PROVIDER_REGIONS_DEFAULT: &str = "Space to select regions";
763 pub const PROVIDER_REGIONS_GCP: &str = "Space to select zones (empty = all)";
764 pub const PROVIDER_REGIONS_SCALEWAY: &str = "Space to select zones";
765 pub const PROVIDER_REGIONS_AZURE: &str = "comma-separated subscription IDs";
767 pub const PROVIDER_REGIONS_OVH: &str = "Space to select endpoint (default: EU)";
768 pub const PROVIDER_FILTER_NETBOX: &str = "status=active&tag=ssh (NetBox query params)";
769
770 pub fn ssm_mode_value(mode: crate::providers::aws_ssm::SsmMode) -> &'static str {
773 use crate::providers::aws_ssm::SsmMode;
774 match mode {
775 SsmMode::Off => "off (connect to the IP address)",
776 SsmMode::Auto => "auto (route the nodes SSM reports online)",
777 SsmMode::Always => "always (route every instance)",
778 }
779 }
780 pub const PROVIDER_USER_AWS: &str = "ec2-user";
781 pub const PROVIDER_USER_GCP: &str = "ubuntu";
782 pub const PROVIDER_USER_AZURE: &str = "azureuser";
783 pub const PROVIDER_USER_ORACLE: &str = "opc";
784 pub const PROVIDER_USER_OVH: &str = "ubuntu";
785 pub const PROVIDER_USER_TELEPORT: &str = "Teleport login, e.g. root (empty = local user)";
786 pub const PROVIDER_VAULT_ROLE: &str =
787 "e.g. ssh-client-signer/sign/my-role (vault login; inherited)";
788 pub const PROVIDER_VAULT_ADDR: &str = "e.g. http://127.0.0.1:8200 (inherited by all hosts)";
789 pub const PROVIDER_ALIAS_PREFIX_DEFAULT: &str = "prefix";
790 pub const PROVIDER_LABEL: &str = "short name, e.g. server1 or work";
791}
792
793#[cfg(test)]
794mod hints_tests {
795 use super::hints;
796
797 #[test]
798 fn askpass_default_formats() {
799 assert_eq!(hints::askpass_default("keychain"), "default: keychain");
800 }
801
802 #[test]
803 fn askpass_default_formats_empty() {
804 assert_eq!(hints::askpass_default(""), "default: ");
805 }
806
807 #[test]
808 fn inherits_from_formats() {
809 assert_eq!(
810 hints::inherits_from("role/x", "aws"),
811 "inherits role/x from aws"
812 );
813 }
814
815 #[test]
816 fn picker_hints_mention_space_not_enter() {
817 for s in [
820 hints::IDENTITY_FILE_PICK,
821 hints::HOST_PROXY_JUMP,
822 hints::HOST_VAULT_SSH_PICKER,
823 hints::HOST_ASKPASS_PICK,
824 hints::PROVIDER_REGIONS_DEFAULT,
825 hints::PROVIDER_REGIONS_GCP,
826 hints::PROVIDER_REGIONS_SCALEWAY,
827 hints::PROVIDER_REGIONS_OVH,
828 hints::PROVIDER_PROFILE,
829 ] {
830 assert!(
831 s.starts_with("Space "),
832 "picker hint must mention Space: {s}"
833 );
834 assert!(!s.contains("Enter "), "picker hint must not say Enter: {s}");
835 }
836 }
837}
838
839#[path = "messages/whats_new.rs"]
840pub mod whats_new;
841
842#[path = "messages/whats_new_toast.rs"]
843pub mod whats_new_toast;
844
845#[cfg(test)]
846mod relative_age_tests {
847 use super::relative_age;
848 use std::time::Duration;
849
850 #[test]
851 fn relative_age_boundaries() {
852 assert_eq!(relative_age(Duration::from_secs(0)), "just now");
853 assert_eq!(relative_age(Duration::from_secs(4)), "just now");
854 assert_eq!(relative_age(Duration::from_secs(5)), "5s ago");
855 assert_eq!(relative_age(Duration::from_secs(59)), "59s ago");
856 assert_eq!(relative_age(Duration::from_secs(60)), "1m ago");
857 assert_eq!(relative_age(Duration::from_secs(3599)), "59m ago");
858 assert_eq!(relative_age(Duration::from_secs(3600)), "1h ago");
859 assert_eq!(relative_age(Duration::from_secs(86399)), "23h ago");
860 assert_eq!(relative_age(Duration::from_secs(86400)), "1d ago");
861 assert_eq!(relative_age(Duration::from_secs(86400 * 7)), "7d ago");
862 }
863}