1use std::path::PathBuf;
7
8use crate::fs_util::atomic_write;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum SourceKind {
15 Host,
16 Tunnel,
17 Container,
18 Snippet,
19 Action,
20}
21
22impl SourceKind {
23 pub fn section_label(self) -> &'static str {
24 match self {
25 Self::Host => "HOSTS",
26 Self::Tunnel => "TUNNELS",
27 Self::Container => "CONTAINERS",
28 Self::Snippet => "SNIPPETS",
29 Self::Action => "ACTIONS",
30 }
31 }
32
33 pub fn render_order() -> [Self; 5] {
36 [
37 Self::Host,
38 Self::Tunnel,
39 Self::Container,
40 Self::Snippet,
41 Self::Action,
42 ]
43 }
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum JumpHit {
50 Action(JumpAction),
51 Host(HostHit),
52 Tunnel(TunnelHit),
53 Container(ContainerHit),
54 Snippet(SnippetHit),
55}
56
57impl JumpHit {
58 pub fn kind(&self) -> SourceKind {
59 match self {
60 Self::Action(_) => SourceKind::Action,
61 Self::Host(_) => SourceKind::Host,
62 Self::Tunnel(_) => SourceKind::Tunnel,
63 Self::Container(_) => SourceKind::Container,
64 Self::Snippet(_) => SourceKind::Snippet,
65 }
66 }
67
68 pub fn haystacks(&self) -> Vec<&str> {
74 match self {
75 Self::Action(a) => {
76 let mut v = Vec::with_capacity(2 + a.aliases.len());
77 v.push(a.label);
78 v.push(a.key_str);
79 for alias in a.aliases {
80 v.push(*alias);
81 }
82 v
83 }
84 Self::Host(h) => {
85 let mut v = Vec::with_capacity(7 + h.tags.len());
86 v.push(h.alias.as_str());
87 v.push(h.hostname.as_str());
88 if let Some(p) = &h.provider {
89 v.push(p.as_str());
90 }
91 for t in &h.tags {
92 v.push(t.as_str());
93 }
94 if !h.user.is_empty() {
95 v.push(h.user.as_str());
96 }
97 if !h.identity_file.is_empty() {
98 v.push(h.identity_file.as_str());
99 }
100 if !h.proxy_jump.is_empty() {
101 v.push(h.proxy_jump.as_str());
102 }
103 if let Some(role) = &h.vault_ssh {
104 v.push(role.as_str());
105 }
106 v
107 }
108 Self::Tunnel(t) => vec![t.alias.as_str(), t.destination.as_str(), &t.bind_port_str],
109 Self::Container(c) => vec![
110 c.container_name.as_str(),
111 c.alias.as_str(),
112 c.container_id.as_str(),
113 ],
114 Self::Snippet(s) => vec![s.name.as_str(), s.command_preview.as_str()],
115 }
116 }
117
118 pub fn identity(&self) -> RecentRef {
120 match self {
121 Self::Action(a) => RecentRef::new(SourceKind::Action, a.key.to_string()),
122 Self::Host(h) => RecentRef::new(SourceKind::Host, h.alias.clone()),
123 Self::Tunnel(t) => {
124 RecentRef::new(SourceKind::Tunnel, format!("{}:{}", t.alias, t.bind_port))
125 }
126 Self::Container(c) => RecentRef::new(
127 SourceKind::Container,
128 format!("{}/{}", c.alias, c.container_name),
129 ),
130 Self::Snippet(s) => RecentRef::new(SourceKind::Snippet, s.name.clone()),
131 }
132 }
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub struct JumpAction {
137 pub key: char,
138 pub key_str: &'static str,
142 pub label: &'static str,
143 pub aliases: &'static [&'static str],
144 pub target: JumpActionTarget,
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum JumpActionTarget {
153 Hosts,
154 Tunnels,
155 Containers,
156 Keys,
157}
158
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct HostHit {
161 pub alias: String,
162 pub hostname: String,
163 pub tags: Vec<String>,
164 pub provider: Option<String>,
165 pub user: String,
166 pub identity_file: String,
167 pub proxy_jump: String,
168 pub vault_ssh: Option<String>,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct TunnelHit {
173 pub alias: String,
174 pub bind_port: u16,
175 pub bind_port_str: String,
179 pub destination: String,
180 pub active: bool,
181}
182
183#[derive(Debug, Clone, PartialEq, Eq)]
184pub struct ContainerHit {
185 pub alias: String,
186 pub container_name: String,
187 pub container_id: String,
188 pub state: String,
189}
190
191#[derive(Debug, Clone, PartialEq, Eq)]
192pub struct SnippetHit {
193 pub name: String,
194 pub command_preview: String,
195}
196
197#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
200pub struct RecentRef {
201 pub kind: SourceKind,
202 pub key: String,
203}
204
205impl RecentRef {
206 pub fn new(kind: SourceKind, key: String) -> Self {
207 Self { kind, key }
208 }
209}
210
211#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
212pub struct RecentEntry {
213 #[serde(flatten)]
214 pub target: RecentRef,
215 pub last_used_unix: i64,
216}
217
218#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
221pub struct RecentsFile {
222 pub version: u32,
223 pub entries: Vec<RecentEntry>,
224}
225
226impl Default for RecentsFile {
227 fn default() -> Self {
228 Self {
229 version: 1,
230 entries: Vec::new(),
231 }
232 }
233}
234
235const RECENTS_VERSION: u32 = 1;
236const RECENTS_CAP: usize = 50;
237
238pub fn recents_path() -> Option<PathBuf> {
241 if let Some(p) = recents_path_override() {
242 return Some(p);
243 }
244 let home = dirs::home_dir()?;
245 Some(home.join(".purple").join("recents.json"))
246}
247
248#[cfg(test)]
254pub mod test_path {
255 use std::cell::RefCell;
256 use std::path::PathBuf;
257
258 thread_local! {
259 static OVERRIDE: RefCell<Option<PathBuf>> = const { RefCell::new(None) };
260 }
261
262 pub fn set(path: PathBuf) {
263 OVERRIDE.with(|cell| *cell.borrow_mut() = Some(path));
264 }
265
266 pub fn clear() {
267 OVERRIDE.with(|cell| *cell.borrow_mut() = None);
268 }
269
270 pub fn get() -> Option<PathBuf> {
271 OVERRIDE.with(|cell| cell.borrow().clone())
272 }
273}
274
275#[cfg(test)]
276fn recents_path_override() -> Option<PathBuf> {
277 test_path::get()
278}
279
280#[cfg(not(test))]
281fn recents_path_override() -> Option<PathBuf> {
282 None
283}
284
285pub fn load_recents() -> RecentsFile {
286 #[cfg(test)]
287 {
288 if test_path::get().is_none() {
291 return RecentsFile::default();
292 }
293 }
294 let Some(path) = recents_path() else {
295 return RecentsFile::default();
296 };
297 let bytes = match std::fs::read(&path) {
298 Ok(b) => b,
299 Err(_) => return RecentsFile::default(),
300 };
301 serde_json::from_slice(&bytes).unwrap_or_default()
302}
303
304pub fn save_recents(file: &RecentsFile) -> std::io::Result<()> {
305 #[cfg(test)]
311 {
312 if test_path::get().is_none() {
313 return Ok(());
314 }
315 }
316 let Some(path) = recents_path() else {
317 return Ok(());
318 };
319 if let Some(parent) = path.parent() {
320 std::fs::create_dir_all(parent)?;
321 }
322 let bytes = serde_json::to_vec_pretty(file).map_err(std::io::Error::other)?;
323 atomic_write(&path, &bytes)
324}
325
326pub fn rename_host_recent(file: &mut RecentsFile, old_alias: &str, new_alias: &str) -> bool {
333 if old_alias == new_alias {
334 return false;
335 }
336 let old_idx = file
337 .entries
338 .iter()
339 .position(|e| e.target.kind == SourceKind::Host && e.target.key == old_alias);
340 let Some(old_idx) = old_idx else {
341 return false;
342 };
343 let new_idx = file
344 .entries
345 .iter()
346 .position(|e| e.target.kind == SourceKind::Host && e.target.key == new_alias);
347 if let Some(new_idx) = new_idx {
348 let drop_idx =
349 if file.entries[old_idx].last_used_unix >= file.entries[new_idx].last_used_unix {
350 new_idx
351 } else {
352 old_idx
353 };
354 let keep_idx = if drop_idx == new_idx {
355 old_idx
356 } else {
357 new_idx
358 };
359 file.entries[keep_idx].target.key = new_alias.to_string();
360 file.entries.remove(drop_idx);
361 } else {
362 file.entries[old_idx].target.key = new_alias.to_string();
363 }
364 file.version = RECENTS_VERSION;
365 true
366}
367
368pub fn touch_recent(file: &mut RecentsFile, target: RecentRef) {
370 file.version = RECENTS_VERSION;
371 file.entries.retain(|e| e.target != target);
372 let now = current_unix_ts();
373 file.entries.insert(
374 0,
375 RecentEntry {
376 target,
377 last_used_unix: now,
378 },
379 );
380 if file.entries.len() > RECENTS_CAP {
381 file.entries.truncate(RECENTS_CAP);
382 }
383}
384
385fn current_unix_ts() -> i64 {
386 std::time::SystemTime::now()
387 .duration_since(std::time::UNIX_EPOCH)
388 .map(|d| d.as_secs() as i64)
389 .unwrap_or(0)
390}
391
392#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
396pub enum JumpMode {
397 #[default]
398 Hosts,
399 Tunnels,
400 Containers,
401 Keys,
402}
403
404pub const JUMP_EMPTY_STATE_ACTIONS_CAP: usize = 6;
409
410const EMPTY_STATE_TAB_BIAS: usize = 3;
416
417const CATEGORY_PRIORITY: &[&str] = &[
423 "Hosts",
424 "Tunnels",
425 "Containers",
426 "Files",
427 "Vault",
428 "Keys",
429 "Providers",
430 "Snippets",
431 "Clipboard",
432 "Settings",
433 "Help",
434];
435
436pub(crate) const PALETTE_ACTION_FLOOR: u32 = 30;
439
440fn round_robin_actions_by_category(actions: impl Iterator<Item = JumpAction>) -> Vec<JumpHit> {
448 let mut buckets: Vec<(String, Vec<JumpAction>)> = Vec::new();
449 for action in actions {
450 let category = action
451 .label
452 .split_once(':')
453 .map(|(c, _)| c.trim().to_string())
454 .unwrap_or_else(|| "Other".to_string());
455 if let Some(slot) = buckets.iter_mut().find(|(c, _)| c == &category) {
456 slot.1.push(action);
457 } else {
458 buckets.push((category, vec![action]));
459 }
460 }
461 let priority_index = |cat: &str| -> usize {
462 CATEGORY_PRIORITY
463 .iter()
464 .position(|p| *p == cat)
465 .unwrap_or(usize::MAX)
466 };
467 buckets.sort_by_key(|(c, _)| priority_index(c));
468 let mut out: Vec<JumpHit> = Vec::new();
469 let mut depth = 0usize;
470 let max_depth = buckets.iter().map(|(_, v)| v.len()).max().unwrap_or(0);
471 while depth < max_depth {
472 for (_, bucket) in &buckets {
473 if let Some(action) = bucket.get(depth) {
474 out.push(JumpHit::Action(*action));
475 }
476 }
477 depth += 1;
478 }
479 out
480}
481
482fn round_robin_actions_with_bias(
493 actions: impl Iterator<Item = JumpAction>,
494 preferred: JumpActionTarget,
495 bump: usize,
496) -> Vec<JumpHit> {
497 let collected: Vec<JumpAction> = actions.collect();
498 let biased: Vec<JumpAction> = collected
499 .iter()
500 .filter(|a| a.target == preferred)
501 .take(bump)
502 .copied()
503 .collect();
504 let biased_keys: std::collections::HashSet<char> = biased.iter().map(|a| a.key).collect();
505 let rest: Vec<JumpAction> = collected
506 .into_iter()
507 .filter(|a| !(biased_keys.contains(&a.key) && a.target == preferred))
508 .collect();
509 let mut out: Vec<JumpHit> = biased.into_iter().map(JumpHit::Action).collect();
510 out.extend(round_robin_actions_by_category(rest.into_iter()));
511 out
512}
513
514#[derive(Debug, Default)]
515pub struct JumpState {
516 pub(in crate::app) query: String,
517 pub(in crate::app) selected: usize,
518 pub(in crate::app) mode: JumpMode,
519 pub(in crate::app) hits: Vec<JumpHit>,
522 pub(in crate::app) recents: Vec<JumpHit>,
524 pub(in crate::app) cursor_revealed: bool,
530 pub(in crate::app) matcher: Option<nucleo_matcher::Matcher>,
534}
535
536impl Clone for JumpState {
541 fn clone(&self) -> Self {
542 Self {
543 query: self.query.clone(),
544 selected: self.selected,
545 mode: self.mode,
546 hits: self.hits.clone(),
547 recents: self.recents.clone(),
548 cursor_revealed: self.cursor_revealed,
549 matcher: None,
550 }
551 }
552}
553
554impl JumpState {
555 pub fn for_mode(mode: JumpMode) -> Self {
556 Self {
557 mode,
558 ..Self::default()
559 }
560 }
561
562 pub fn query(&self) -> &str {
563 &self.query
564 }
565
566 pub fn selected(&self) -> usize {
567 self.selected
568 }
569
570 pub fn mode(&self) -> JumpMode {
571 self.mode
572 }
573
574 pub fn cursor_revealed(&self) -> bool {
575 self.cursor_revealed
576 }
577
578 pub fn hits(&self) -> &[JumpHit] {
579 &self.hits
580 }
581
582 pub fn recents(&self) -> &[JumpHit] {
583 &self.recents
584 }
585
586 pub fn set_selected(&mut self, n: usize) {
587 self.selected = n;
588 }
589
590 pub fn set_hits(&mut self, hits: Vec<JumpHit>) {
591 self.hits = hits;
592 }
593
594 pub fn set_recents(&mut self, recents: Vec<JumpHit>) {
595 self.recents = recents;
596 }
597
598 pub fn move_down(&mut self) {
601 let count = self.visible_hits().len();
602 if count == 0 {
603 return;
604 }
605 if !self.cursor_revealed {
606 self.cursor_revealed = true;
607 self.selected = 0;
608 } else {
609 self.selected = (self.selected + 1).min(count - 1);
610 }
611 }
612
613 pub fn move_up(&mut self) {
616 if !self.cursor_revealed {
617 self.cursor_revealed = true;
618 self.selected = 0;
619 } else {
620 self.selected = self.selected.saturating_sub(1);
621 }
622 }
623
624 pub fn reveal_cursor(&mut self) {
625 self.cursor_revealed = true;
626 }
627
628 pub fn reset_after_clear_query(&mut self) {
631 self.cursor_revealed = false;
632 self.selected = 0;
633 }
634
635 pub fn push_query(&mut self, c: char) {
636 if self.query.len() < 64 {
637 self.query.push(c);
638 }
639 }
644
645 pub fn pop_query(&mut self) {
646 self.query.pop();
647 }
648
649 pub fn visible_hits(&self) -> Vec<JumpHit> {
658 if self.query.is_empty() {
659 let mut out: Vec<JumpHit> = self.recents.clone();
660 out.extend(self.empty_state_actions());
661 out
662 } else {
663 let mut out: Vec<JumpHit> = Vec::with_capacity(self.hits.len());
669 for kind in SourceKind::render_order() {
670 out.extend(self.hits.iter().filter(|h| h.kind() == kind).cloned());
671 }
672 out
673 }
674 }
675
676 fn filtered_actions_for_empty_state(&self) -> Vec<JumpAction> {
683 let recent_keys: std::collections::HashSet<RecentRef> =
684 self.recents.iter().map(|h| h.identity()).collect();
685 JumpAction::for_mode(self.mode)
686 .iter()
687 .filter(|a| {
688 let id = RecentRef::new(SourceKind::Action, a.key.to_string());
689 !recent_keys.contains(&id)
690 })
691 .copied()
692 .collect()
693 }
694
695 fn empty_state_actions(&self) -> Vec<JumpHit> {
701 let filtered = self.filtered_actions_for_empty_state();
702 let preferred_target = match self.mode {
703 JumpMode::Hosts => None,
704 JumpMode::Tunnels => Some(JumpActionTarget::Tunnels),
705 JumpMode::Containers => Some(JumpActionTarget::Containers),
706 JumpMode::Keys => Some(JumpActionTarget::Keys),
707 };
708 let actions = match preferred_target {
709 Some(t) => round_robin_actions_with_bias(filtered.into_iter(), t, EMPTY_STATE_TAB_BIAS),
710 None => round_robin_actions_by_category(filtered.into_iter()),
711 };
712 actions
713 .into_iter()
714 .take(JUMP_EMPTY_STATE_ACTIONS_CAP)
715 .collect()
716 }
717
718 pub fn empty_state_actions_total(&self) -> usize {
722 self.filtered_actions_for_empty_state().len()
723 }
724
725 pub fn grouped_hits(&self) -> Vec<(SourceKind, Vec<JumpHit>)> {
729 let visible = self.visible_hits();
730 let mut out = Vec::with_capacity(SourceKind::render_order().len());
731 for kind in SourceKind::render_order() {
732 let group: Vec<JumpHit> = visible
733 .iter()
734 .filter(|h| h.kind() == kind)
735 .cloned()
736 .collect();
737 if !group.is_empty() {
738 out.push((kind, group));
739 }
740 }
741 out
742 }
743
744 pub fn empty_state_groups(&self) -> Vec<(&'static str, Vec<JumpHit>)> {
749 let mut out: Vec<(&'static str, Vec<JumpHit>)> = Vec::new();
750 if !self.recents.is_empty() {
751 out.push(("RECENT", self.recents.clone()));
752 }
753 let actions = self.empty_state_actions();
756 if !actions.is_empty() {
757 out.push(("ACTIONS", actions));
758 }
759 out
760 }
761
762 pub fn selected_section(&self) -> Option<SourceKind> {
765 self.visible_hits().get(self.selected).map(|h| h.kind())
766 }
767
768 #[cfg(test)]
772 pub fn filtered_commands(&self) -> Vec<JumpAction> {
773 let all = JumpAction::for_mode(self.mode);
774 if self.query.is_empty() {
775 return all.to_vec();
776 }
777 let q = self.query.to_lowercase();
778 all.iter()
779 .filter(|cmd| {
780 cmd.label.to_lowercase().contains(&q)
781 || cmd.aliases.iter().any(|a| a.to_lowercase().contains(&q))
782 })
783 .copied()
784 .collect()
785 }
786
787 pub fn jump_next_section(&mut self) {
789 let visible = self.visible_hits();
790 if visible.is_empty() {
791 return;
792 }
793 if self.query.is_empty() {
794 let n_recent = self.recents.len();
802 if n_recent == 0 || n_recent >= visible.len() {
803 return;
804 }
805 if self.selected < n_recent {
806 self.selected = n_recent; } else {
808 self.selected = 0; }
810 return;
811 }
812 let groups = self.grouped_hits();
813 if groups.len() < 2 {
814 return;
815 }
816 let cur_kind = match self.selected_section() {
817 Some(k) => k,
818 None => {
819 self.selected = 0;
820 return;
821 }
822 };
823 let cur_idx = groups.iter().position(|(k, _)| *k == cur_kind).unwrap_or(0);
824 let next_idx = (cur_idx + 1) % groups.len();
825 let next_kind = groups[next_idx].0;
826 if let Some(pos) = visible.iter().position(|h| h.kind() == next_kind) {
827 self.selected = pos;
828 }
829 }
830}
831
832#[cfg(test)]
833pub mod tests {
834 use super::*;
835 use std::sync::Mutex;
836
837 pub(crate) static ENV_LOCK: Mutex<()> = Mutex::new(());
838
839 fn with_temp<F: FnOnce(&std::path::Path)>(f: F) {
840 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
841 let dir = tempfile::tempdir().unwrap();
842 let path = dir.path().join("recents.json");
843 test_path::set(path.clone());
844 f(&path);
845 test_path::clear();
846 }
847
848 #[test]
849 fn visible_hits_matches_grouped_render_order_with_active_query() {
850 let action = JumpAction::all()[0];
857 let host = HostHit {
858 alias: "proxy-vm".into(),
859 hostname: "proxy-vm.example.com".into(),
860 tags: Vec::new(),
861 provider: None,
862 user: String::new(),
863 identity_file: String::new(),
864 proxy_jump: String::new(),
865 vault_ssh: None,
866 };
867 let state = JumpState {
870 query: "prov".into(),
871 hits: vec![JumpHit::Action(action), JumpHit::Host(host)],
872 ..Default::default()
873 };
874
875 let visible = state.visible_hits();
876 let flattened: Vec<JumpHit> = state
877 .grouped_hits()
878 .into_iter()
879 .flat_map(|(_, hits)| hits)
880 .collect();
881 assert_eq!(
882 visible, flattened,
883 "visible_hits() must equal the flattened grouped order so the \
884 highlighted row and the dispatched hit reference the same item"
885 );
886 assert!(
889 matches!(visible[0], JumpHit::Host(_)),
890 "first visible row must follow render order (HOSTS first)"
891 );
892 }
893
894 #[test]
895 fn section_labels_are_uppercase() {
896 for k in SourceKind::render_order() {
897 let label = k.section_label();
898 assert_eq!(label, label.to_uppercase(), "{:?} not uppercase", k);
899 }
900 }
901
902 #[test]
903 fn render_order_starts_with_hosts() {
904 assert_eq!(SourceKind::render_order()[0], SourceKind::Host);
905 assert_eq!(SourceKind::render_order()[4], SourceKind::Action);
906 }
907
908 #[test]
909 fn touch_moves_existing_to_front_and_caps() {
910 let mut f = RecentsFile::default();
911 for i in 0..(RECENTS_CAP + 5) {
912 touch_recent(&mut f, RecentRef::new(SourceKind::Host, format!("h{i}")));
913 }
914 assert_eq!(f.entries.len(), RECENTS_CAP);
915 let target = RecentRef::new(SourceKind::Host, format!("h{}", RECENTS_CAP + 2));
917 touch_recent(&mut f, target.clone());
918 assert_eq!(f.entries[0].target, target);
919 assert_eq!(f.entries.len(), RECENTS_CAP);
920 }
921
922 #[test]
923 fn save_then_load_roundtrip() {
924 with_temp(|_path| {
925 let mut f = RecentsFile::default();
926 touch_recent(&mut f, RecentRef::new(SourceKind::Action, "F".into()));
927 touch_recent(&mut f, RecentRef::new(SourceKind::Host, "web-01".into()));
928 save_recents(&f).expect("save");
929 let loaded = load_recents();
930 assert_eq!(loaded.version, RECENTS_VERSION);
931 assert_eq!(loaded.entries.len(), 2);
932 assert_eq!(loaded.entries[0].target.key, "web-01");
933 assert_eq!(loaded.entries[1].target.key, "F");
934 });
935 }
936
937 #[test]
938 fn missing_file_loads_empty() {
939 with_temp(|_path| {
940 let loaded = load_recents();
941 assert!(loaded.entries.is_empty());
942 });
943 }
944
945 #[test]
946 fn corrupt_file_loads_empty() {
947 with_temp(|path| {
948 std::fs::write(path, b"not json").unwrap();
949 let loaded = load_recents();
950 assert!(loaded.entries.is_empty());
951 });
952 }
953
954 fn host_entry(alias: &str, ts: i64) -> RecentEntry {
955 RecentEntry {
956 target: RecentRef::new(SourceKind::Host, alias.to_string()),
957 last_used_unix: ts,
958 }
959 }
960
961 #[test]
962 fn rename_host_recent_rewrites_key() {
963 let mut file = RecentsFile::default();
964 file.entries.push(host_entry("web-old", 100));
965 file.entries.push(RecentEntry {
966 target: RecentRef::new(SourceKind::Tunnel, "web-old:5432".to_string()),
967 last_used_unix: 90,
968 });
969
970 assert!(rename_host_recent(&mut file, "web-old", "web-new"));
971 assert_eq!(file.entries[0].target.kind, SourceKind::Host);
972 assert_eq!(file.entries[0].target.key, "web-new");
973 assert_eq!(file.entries[1].target.kind, SourceKind::Tunnel);
975 assert_eq!(file.entries[1].target.key, "web-old:5432");
976 }
977
978 #[test]
979 fn rename_host_recent_dedups_on_collision_keeping_most_recent() {
980 let mut file = RecentsFile::default();
981 file.entries.push(host_entry("a", 200));
984 file.entries.push(host_entry("b", 100));
985
986 assert!(rename_host_recent(&mut file, "a", "b"));
987 assert_eq!(file.entries.len(), 1);
988 assert_eq!(file.entries[0].target.key, "b");
989 assert_eq!(file.entries[0].last_used_unix, 200);
990 }
991
992 #[test]
993 fn rename_host_recent_dedups_when_new_key_is_newer() {
994 let mut file = RecentsFile::default();
995 file.entries.push(host_entry("a", 100));
996 file.entries.push(host_entry("b", 200));
997
998 assert!(rename_host_recent(&mut file, "a", "b"));
999 assert_eq!(file.entries.len(), 1);
1000 assert_eq!(file.entries[0].target.key, "b");
1001 assert_eq!(file.entries[0].last_used_unix, 200);
1002 }
1003
1004 #[test]
1005 fn rename_host_recent_noop_when_same() {
1006 let mut file = RecentsFile::default();
1007 file.entries.push(host_entry("a", 10));
1008 assert!(!rename_host_recent(&mut file, "a", "a"));
1009 assert_eq!(file.entries.len(), 1);
1010 }
1011
1012 #[test]
1013 fn rename_host_recent_noop_when_absent() {
1014 let mut file = RecentsFile::default();
1015 assert!(!rename_host_recent(&mut file, "ghost", "phantom"));
1016 assert!(file.entries.is_empty());
1017 }
1018}