1use std::path::PathBuf;
7
8use crate::fs_util::atomic_write;
9use crate::runtime::env::Paths;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum SourceKind {
16 Host,
17 Tunnel,
18 Container,
19 Snippet,
20 Action,
21}
22
23impl SourceKind {
24 pub fn section_label(self) -> &'static str {
25 match self {
26 Self::Host => "HOSTS",
27 Self::Tunnel => "TUNNELS",
28 Self::Container => "CONTAINERS",
29 Self::Snippet => "SNIPPETS",
30 Self::Action => "ACTIONS",
31 }
32 }
33
34 pub fn render_order() -> [Self; 5] {
37 [
38 Self::Host,
39 Self::Tunnel,
40 Self::Container,
41 Self::Snippet,
42 Self::Action,
43 ]
44 }
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum JumpHit {
51 Action(JumpAction),
52 Host(HostHit),
53 Tunnel(TunnelHit),
54 Container(ContainerHit),
55 Snippet(SnippetHit),
56}
57
58impl JumpHit {
59 pub fn kind(&self) -> SourceKind {
60 match self {
61 Self::Action(_) => SourceKind::Action,
62 Self::Host(_) => SourceKind::Host,
63 Self::Tunnel(_) => SourceKind::Tunnel,
64 Self::Container(_) => SourceKind::Container,
65 Self::Snippet(_) => SourceKind::Snippet,
66 }
67 }
68
69 pub fn haystacks(&self) -> Vec<&str> {
75 match self {
76 Self::Action(a) => {
77 let mut v = Vec::with_capacity(2 + a.aliases.len());
78 v.push(a.label);
79 v.push(a.key_str);
80 for alias in a.aliases {
81 v.push(*alias);
82 }
83 v
84 }
85 Self::Host(h) => {
86 let mut v = Vec::with_capacity(7 + h.tags.len());
87 v.push(h.alias.as_str());
88 v.push(h.hostname.as_str());
89 if let Some(p) = &h.provider {
90 v.push(p.as_str());
91 }
92 for t in &h.tags {
93 v.push(t.as_str());
94 }
95 if !h.user.is_empty() {
96 v.push(h.user.as_str());
97 }
98 if !h.identity_file.is_empty() {
99 v.push(h.identity_file.as_str());
100 }
101 if !h.proxy_jump.is_empty() {
102 v.push(h.proxy_jump.as_str());
103 }
104 if let Some(role) = &h.vault_ssh {
105 v.push(role.as_str());
106 }
107 v
108 }
109 Self::Tunnel(t) => vec![t.alias.as_str(), t.destination.as_str(), &t.bind_port_str],
110 Self::Container(c) => vec![
111 c.container_name.as_str(),
112 c.alias.as_str(),
113 c.container_id.as_str(),
114 ],
115 Self::Snippet(s) => vec![s.name.as_str(), s.command_preview.as_str()],
116 }
117 }
118
119 pub fn identity(&self) -> RecentRef {
121 match self {
122 Self::Action(a) => RecentRef::new(SourceKind::Action, a.key.to_string()),
123 Self::Host(h) => RecentRef::new(SourceKind::Host, h.alias.clone()),
124 Self::Tunnel(t) => {
125 RecentRef::new(SourceKind::Tunnel, format!("{}:{}", t.alias, t.bind_port))
126 }
127 Self::Container(c) => RecentRef::new(
128 SourceKind::Container,
129 format!("{}/{}", c.alias, c.container_name),
130 ),
131 Self::Snippet(s) => RecentRef::new(SourceKind::Snippet, s.name.clone()),
132 }
133 }
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub struct JumpAction {
138 pub key: char,
139 pub key_str: &'static str,
143 pub label: &'static str,
144 pub aliases: &'static [&'static str],
145 pub target: JumpActionTarget,
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub enum JumpActionTarget {
154 Hosts,
155 Tunnels,
156 Containers,
157 Keys,
158}
159
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct HostHit {
162 pub alias: String,
163 pub hostname: String,
164 pub tags: Vec<String>,
165 pub provider: Option<String>,
166 pub user: String,
167 pub identity_file: String,
168 pub proxy_jump: String,
169 pub vault_ssh: Option<String>,
170}
171
172#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct TunnelHit {
174 pub alias: String,
175 pub bind_port: u16,
176 pub bind_port_str: String,
180 pub destination: String,
181 pub active: bool,
182}
183
184#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct ContainerHit {
186 pub alias: String,
187 pub container_name: String,
188 pub container_id: String,
189 pub state: String,
190}
191
192#[derive(Debug, Clone, PartialEq, Eq)]
193pub struct SnippetHit {
194 pub name: String,
195 pub command_preview: String,
196}
197
198#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
201pub struct RecentRef {
202 pub kind: SourceKind,
203 pub key: String,
204}
205
206impl RecentRef {
207 pub fn new(kind: SourceKind, key: String) -> Self {
208 Self { kind, key }
209 }
210}
211
212#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
213pub struct RecentEntry {
214 #[serde(flatten)]
215 pub target: RecentRef,
216 pub last_used_unix: i64,
217}
218
219#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
222pub struct RecentsFile {
223 pub version: u32,
224 pub entries: Vec<RecentEntry>,
225}
226
227impl Default for RecentsFile {
228 fn default() -> Self {
229 Self {
230 version: 1,
231 entries: Vec::new(),
232 }
233 }
234}
235
236const RECENTS_VERSION: u32 = 1;
237const RECENTS_CAP: usize = 50;
238
239pub fn recents_path(paths: Option<&Paths>) -> Option<PathBuf> {
242 paths.map(Paths::recents)
243}
244
245pub fn load_recents(paths: Option<&Paths>) -> RecentsFile {
246 let Some(path) = recents_path(paths) else {
247 return RecentsFile::default();
248 };
249 let bytes = match std::fs::read(&path) {
250 Ok(b) => b,
251 Err(_) => return RecentsFile::default(),
252 };
253 serde_json::from_slice(&bytes).unwrap_or_default()
254}
255
256pub fn save_recents(file: &RecentsFile, paths: Option<&Paths>) -> std::io::Result<()> {
257 let Some(path) = recents_path(paths) else {
258 return Ok(());
259 };
260 if let Some(parent) = path.parent() {
261 std::fs::create_dir_all(parent)?;
262 }
263 let bytes = serde_json::to_vec_pretty(file).map_err(std::io::Error::other)?;
264 atomic_write(&path, &bytes)
265}
266
267pub fn rename_host_recent(file: &mut RecentsFile, old_alias: &str, new_alias: &str) -> bool {
274 if old_alias == new_alias {
275 return false;
276 }
277 let old_idx = file
278 .entries
279 .iter()
280 .position(|e| e.target.kind == SourceKind::Host && e.target.key == old_alias);
281 let Some(old_idx) = old_idx else {
282 return false;
283 };
284 let new_idx = file
285 .entries
286 .iter()
287 .position(|e| e.target.kind == SourceKind::Host && e.target.key == new_alias);
288 if let Some(new_idx) = new_idx {
289 let drop_idx =
290 if file.entries[old_idx].last_used_unix >= file.entries[new_idx].last_used_unix {
291 new_idx
292 } else {
293 old_idx
294 };
295 let keep_idx = if drop_idx == new_idx {
296 old_idx
297 } else {
298 new_idx
299 };
300 file.entries[keep_idx].target.key = new_alias.to_string();
301 file.entries.remove(drop_idx);
302 } else {
303 file.entries[old_idx].target.key = new_alias.to_string();
304 }
305 file.version = RECENTS_VERSION;
306 true
307}
308
309pub fn touch_recent(file: &mut RecentsFile, target: RecentRef) {
311 file.version = RECENTS_VERSION;
312 file.entries.retain(|e| e.target != target);
313 let now = current_unix_ts();
314 file.entries.insert(
315 0,
316 RecentEntry {
317 target,
318 last_used_unix: now,
319 },
320 );
321 if file.entries.len() > RECENTS_CAP {
322 file.entries.truncate(RECENTS_CAP);
323 }
324}
325
326fn current_unix_ts() -> i64 {
327 std::time::SystemTime::now()
328 .duration_since(std::time::UNIX_EPOCH)
329 .map(|d| d.as_secs() as i64)
330 .unwrap_or(0)
331}
332
333#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
337pub enum JumpMode {
338 #[default]
339 Hosts,
340 Tunnels,
341 Containers,
342 Keys,
343}
344
345pub const JUMP_EMPTY_STATE_ACTIONS_CAP: usize = 6;
350
351const EMPTY_STATE_TAB_BIAS: usize = 3;
357
358const CATEGORY_PRIORITY: &[&str] = &[
364 "Hosts",
365 "Tunnels",
366 "Containers",
367 "Files",
368 "Vault",
369 "Keys",
370 "Providers",
371 "Snippets",
372 "Clipboard",
373 "Settings",
374 "Help",
375];
376
377pub(crate) const PALETTE_ACTION_FLOOR: u32 = 30;
380
381fn round_robin_actions_by_category(actions: impl Iterator<Item = JumpAction>) -> Vec<JumpHit> {
389 let mut buckets: Vec<(String, Vec<JumpAction>)> = Vec::new();
390 for action in actions {
391 let category = action
392 .label
393 .split_once(':')
394 .map(|(c, _)| c.trim().to_string())
395 .unwrap_or_else(|| "Other".to_string());
396 if let Some(slot) = buckets.iter_mut().find(|(c, _)| c == &category) {
397 slot.1.push(action);
398 } else {
399 buckets.push((category, vec![action]));
400 }
401 }
402 let priority_index = |cat: &str| -> usize {
403 CATEGORY_PRIORITY
404 .iter()
405 .position(|p| *p == cat)
406 .unwrap_or(usize::MAX)
407 };
408 buckets.sort_by_key(|(c, _)| priority_index(c));
409 let mut out: Vec<JumpHit> = Vec::new();
410 let mut depth = 0usize;
411 let max_depth = buckets.iter().map(|(_, v)| v.len()).max().unwrap_or(0);
412 while depth < max_depth {
413 for (_, bucket) in &buckets {
414 if let Some(action) = bucket.get(depth) {
415 out.push(JumpHit::Action(*action));
416 }
417 }
418 depth += 1;
419 }
420 out
421}
422
423fn round_robin_actions_with_bias(
434 actions: impl Iterator<Item = JumpAction>,
435 preferred: JumpActionTarget,
436 bump: usize,
437) -> Vec<JumpHit> {
438 let collected: Vec<JumpAction> = actions.collect();
439 let biased: Vec<JumpAction> = collected
440 .iter()
441 .filter(|a| a.target == preferred)
442 .take(bump)
443 .copied()
444 .collect();
445 let biased_keys: std::collections::HashSet<char> = biased.iter().map(|a| a.key).collect();
446 let rest: Vec<JumpAction> = collected
447 .into_iter()
448 .filter(|a| !(biased_keys.contains(&a.key) && a.target == preferred))
449 .collect();
450 let mut out: Vec<JumpHit> = biased.into_iter().map(JumpHit::Action).collect();
451 out.extend(round_robin_actions_by_category(rest.into_iter()));
452 out
453}
454
455#[derive(Debug, Default)]
456pub struct JumpState {
457 pub(in crate::app) query: String,
458 pub(in crate::app) selected: usize,
459 pub(in crate::app) mode: JumpMode,
460 pub(in crate::app) hits: Vec<JumpHit>,
463 pub(in crate::app) recents: Vec<JumpHit>,
465 pub(in crate::app) cursor_revealed: bool,
471 pub(in crate::app) matcher: Option<nucleo_matcher::Matcher>,
475}
476
477impl Clone for JumpState {
482 fn clone(&self) -> Self {
483 Self {
484 query: self.query.clone(),
485 selected: self.selected,
486 mode: self.mode,
487 hits: self.hits.clone(),
488 recents: self.recents.clone(),
489 cursor_revealed: self.cursor_revealed,
490 matcher: None,
491 }
492 }
493}
494
495impl JumpState {
496 pub fn for_mode(mode: JumpMode) -> Self {
497 Self {
498 mode,
499 ..Self::default()
500 }
501 }
502
503 pub fn query(&self) -> &str {
504 &self.query
505 }
506
507 pub fn selected(&self) -> usize {
508 self.selected
509 }
510
511 pub fn mode(&self) -> JumpMode {
512 self.mode
513 }
514
515 pub fn cursor_revealed(&self) -> bool {
516 self.cursor_revealed
517 }
518
519 pub fn hits(&self) -> &[JumpHit] {
520 &self.hits
521 }
522
523 pub fn recents(&self) -> &[JumpHit] {
524 &self.recents
525 }
526
527 pub fn set_selected(&mut self, n: usize) {
528 self.selected = n;
529 }
530
531 pub fn set_hits(&mut self, hits: Vec<JumpHit>) {
532 self.hits = hits;
533 }
534
535 pub fn set_recents(&mut self, recents: Vec<JumpHit>) {
536 self.recents = recents;
537 }
538
539 pub fn move_down(&mut self) {
542 let count = self.visible_hits().len();
543 if count == 0 {
544 return;
545 }
546 if !self.cursor_revealed {
547 self.cursor_revealed = true;
548 self.selected = 0;
549 } else {
550 self.selected = (self.selected + 1).min(count - 1);
551 }
552 }
553
554 pub fn move_up(&mut self) {
557 if !self.cursor_revealed {
558 self.cursor_revealed = true;
559 self.selected = 0;
560 } else {
561 self.selected = self.selected.saturating_sub(1);
562 }
563 }
564
565 pub fn reveal_cursor(&mut self) {
566 self.cursor_revealed = true;
567 }
568
569 pub fn reset_after_clear_query(&mut self) {
572 self.cursor_revealed = false;
573 self.selected = 0;
574 }
575
576 pub fn push_query(&mut self, c: char) {
577 if self.query.len() < 64 {
578 self.query.push(c);
579 }
580 }
585
586 pub fn pop_query(&mut self) {
587 self.query.pop();
588 }
589
590 pub fn visible_hits(&self) -> Vec<JumpHit> {
599 if self.query.is_empty() {
600 let mut out: Vec<JumpHit> = self.recents.clone();
601 out.extend(self.empty_state_actions());
602 out
603 } else {
604 let mut out: Vec<JumpHit> = Vec::with_capacity(self.hits.len());
610 for kind in SourceKind::render_order() {
611 out.extend(self.hits.iter().filter(|h| h.kind() == kind).cloned());
612 }
613 out
614 }
615 }
616
617 fn filtered_actions_for_empty_state(&self) -> Vec<JumpAction> {
624 let recent_keys: std::collections::HashSet<RecentRef> =
625 self.recents.iter().map(|h| h.identity()).collect();
626 JumpAction::for_mode(self.mode)
627 .iter()
628 .filter(|a| {
629 let id = RecentRef::new(SourceKind::Action, a.key.to_string());
630 !recent_keys.contains(&id)
631 })
632 .copied()
633 .collect()
634 }
635
636 fn empty_state_actions(&self) -> Vec<JumpHit> {
642 let filtered = self.filtered_actions_for_empty_state();
643 let preferred_target = match self.mode {
644 JumpMode::Hosts => None,
645 JumpMode::Tunnels => Some(JumpActionTarget::Tunnels),
646 JumpMode::Containers => Some(JumpActionTarget::Containers),
647 JumpMode::Keys => Some(JumpActionTarget::Keys),
648 };
649 let actions = match preferred_target {
650 Some(t) => round_robin_actions_with_bias(filtered.into_iter(), t, EMPTY_STATE_TAB_BIAS),
651 None => round_robin_actions_by_category(filtered.into_iter()),
652 };
653 actions
654 .into_iter()
655 .take(JUMP_EMPTY_STATE_ACTIONS_CAP)
656 .collect()
657 }
658
659 pub fn empty_state_actions_total(&self) -> usize {
663 self.filtered_actions_for_empty_state().len()
664 }
665
666 pub fn grouped_hits(&self) -> Vec<(SourceKind, Vec<JumpHit>)> {
670 let visible = self.visible_hits();
671 let mut out = Vec::with_capacity(SourceKind::render_order().len());
672 for kind in SourceKind::render_order() {
673 let group: Vec<JumpHit> = visible
674 .iter()
675 .filter(|h| h.kind() == kind)
676 .cloned()
677 .collect();
678 if !group.is_empty() {
679 out.push((kind, group));
680 }
681 }
682 out
683 }
684
685 pub fn empty_state_groups(&self) -> Vec<(&'static str, Vec<JumpHit>)> {
690 let mut out: Vec<(&'static str, Vec<JumpHit>)> = Vec::new();
691 if !self.recents.is_empty() {
692 out.push(("RECENT", self.recents.clone()));
693 }
694 let actions = self.empty_state_actions();
697 if !actions.is_empty() {
698 out.push(("ACTIONS", actions));
699 }
700 out
701 }
702
703 pub fn selected_section(&self) -> Option<SourceKind> {
706 self.visible_hits().get(self.selected).map(|h| h.kind())
707 }
708
709 #[cfg(test)]
713 pub fn filtered_commands(&self) -> Vec<JumpAction> {
714 let all = JumpAction::for_mode(self.mode);
715 if self.query.is_empty() {
716 return all.to_vec();
717 }
718 let q = self.query.to_lowercase();
719 all.iter()
720 .filter(|cmd| {
721 cmd.label.to_lowercase().contains(&q)
722 || cmd.aliases.iter().any(|a| a.to_lowercase().contains(&q))
723 })
724 .copied()
725 .collect()
726 }
727
728 pub fn jump_next_section(&mut self) {
730 let visible = self.visible_hits();
731 if visible.is_empty() {
732 return;
733 }
734 if self.query.is_empty() {
735 let n_recent = self.recents.len();
743 if n_recent == 0 || n_recent >= visible.len() {
744 return;
745 }
746 if self.selected < n_recent {
747 self.selected = n_recent; } else {
749 self.selected = 0; }
751 return;
752 }
753 let groups = self.grouped_hits();
754 if groups.len() < 2 {
755 return;
756 }
757 let cur_kind = match self.selected_section() {
758 Some(k) => k,
759 None => {
760 self.selected = 0;
761 return;
762 }
763 };
764 let cur_idx = groups.iter().position(|(k, _)| *k == cur_kind).unwrap_or(0);
765 let next_idx = (cur_idx + 1) % groups.len();
766 let next_kind = groups[next_idx].0;
767 if let Some(pos) = visible.iter().position(|h| h.kind() == next_kind) {
768 self.selected = pos;
769 }
770 }
771}
772
773#[cfg(test)]
774pub mod tests {
775 use super::*;
776
777 fn with_temp<F: FnOnce(&Paths)>(f: F) {
780 let dir = tempfile::tempdir().unwrap();
781 let paths = Paths::new(dir.path());
782 f(&paths);
783 }
784
785 #[test]
786 fn visible_hits_matches_grouped_render_order_with_active_query() {
787 let action = JumpAction::all()[0];
794 let host = HostHit {
795 alias: "proxy-vm".into(),
796 hostname: "proxy-vm.example.com".into(),
797 tags: Vec::new(),
798 provider: None,
799 user: String::new(),
800 identity_file: String::new(),
801 proxy_jump: String::new(),
802 vault_ssh: None,
803 };
804 let state = JumpState {
807 query: "prov".into(),
808 hits: vec![JumpHit::Action(action), JumpHit::Host(host)],
809 ..Default::default()
810 };
811
812 let visible = state.visible_hits();
813 let flattened: Vec<JumpHit> = state
814 .grouped_hits()
815 .into_iter()
816 .flat_map(|(_, hits)| hits)
817 .collect();
818 assert_eq!(
819 visible, flattened,
820 "visible_hits() must equal the flattened grouped order so the \
821 highlighted row and the dispatched hit reference the same item"
822 );
823 assert!(
826 matches!(visible[0], JumpHit::Host(_)),
827 "first visible row must follow render order (HOSTS first)"
828 );
829 }
830
831 #[test]
832 fn section_labels_are_uppercase() {
833 for k in SourceKind::render_order() {
834 let label = k.section_label();
835 assert_eq!(label, label.to_uppercase(), "{:?} not uppercase", k);
836 }
837 }
838
839 #[test]
840 fn render_order_starts_with_hosts() {
841 assert_eq!(SourceKind::render_order()[0], SourceKind::Host);
842 assert_eq!(SourceKind::render_order()[4], SourceKind::Action);
843 }
844
845 #[test]
846 fn touch_moves_existing_to_front_and_caps() {
847 let mut f = RecentsFile::default();
848 for i in 0..(RECENTS_CAP + 5) {
849 touch_recent(&mut f, RecentRef::new(SourceKind::Host, format!("h{i}")));
850 }
851 assert_eq!(f.entries.len(), RECENTS_CAP);
852 let target = RecentRef::new(SourceKind::Host, format!("h{}", RECENTS_CAP + 2));
854 touch_recent(&mut f, target.clone());
855 assert_eq!(f.entries[0].target, target);
856 assert_eq!(f.entries.len(), RECENTS_CAP);
857 }
858
859 #[test]
860 fn save_then_load_roundtrip() {
861 with_temp(|paths| {
862 let mut f = RecentsFile::default();
863 touch_recent(&mut f, RecentRef::new(SourceKind::Action, "F".into()));
864 touch_recent(&mut f, RecentRef::new(SourceKind::Host, "web-01".into()));
865 save_recents(&f, Some(paths)).expect("save");
866 let loaded = load_recents(Some(paths));
867 assert_eq!(loaded.version, RECENTS_VERSION);
868 assert_eq!(loaded.entries.len(), 2);
869 assert_eq!(loaded.entries[0].target.key, "web-01");
870 assert_eq!(loaded.entries[1].target.key, "F");
871 });
872 }
873
874 #[test]
875 fn missing_file_loads_empty() {
876 with_temp(|paths| {
877 let loaded = load_recents(Some(paths));
878 assert!(loaded.entries.is_empty());
879 });
880 }
881
882 #[test]
883 fn corrupt_file_loads_empty() {
884 with_temp(|paths| {
885 let path = paths.recents();
886 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
887 std::fs::write(&path, b"not json").unwrap();
888 let loaded = load_recents(Some(paths));
889 assert!(loaded.entries.is_empty());
890 });
891 }
892
893 fn host_entry(alias: &str, ts: i64) -> RecentEntry {
894 RecentEntry {
895 target: RecentRef::new(SourceKind::Host, alias.to_string()),
896 last_used_unix: ts,
897 }
898 }
899
900 #[test]
901 fn rename_host_recent_rewrites_key() {
902 let mut file = RecentsFile::default();
903 file.entries.push(host_entry("web-old", 100));
904 file.entries.push(RecentEntry {
905 target: RecentRef::new(SourceKind::Tunnel, "web-old:5432".to_string()),
906 last_used_unix: 90,
907 });
908
909 assert!(rename_host_recent(&mut file, "web-old", "web-new"));
910 assert_eq!(file.entries[0].target.kind, SourceKind::Host);
911 assert_eq!(file.entries[0].target.key, "web-new");
912 assert_eq!(file.entries[1].target.kind, SourceKind::Tunnel);
914 assert_eq!(file.entries[1].target.key, "web-old:5432");
915 }
916
917 #[test]
918 fn rename_host_recent_dedups_on_collision_keeping_most_recent() {
919 let mut file = RecentsFile::default();
920 file.entries.push(host_entry("a", 200));
923 file.entries.push(host_entry("b", 100));
924
925 assert!(rename_host_recent(&mut file, "a", "b"));
926 assert_eq!(file.entries.len(), 1);
927 assert_eq!(file.entries[0].target.key, "b");
928 assert_eq!(file.entries[0].last_used_unix, 200);
929 }
930
931 #[test]
932 fn rename_host_recent_dedups_when_new_key_is_newer() {
933 let mut file = RecentsFile::default();
934 file.entries.push(host_entry("a", 100));
935 file.entries.push(host_entry("b", 200));
936
937 assert!(rename_host_recent(&mut file, "a", "b"));
938 assert_eq!(file.entries.len(), 1);
939 assert_eq!(file.entries[0].target.key, "b");
940 assert_eq!(file.entries[0].last_used_unix, 200);
941 }
942
943 #[test]
944 fn rename_host_recent_noop_when_same() {
945 let mut file = RecentsFile::default();
946 file.entries.push(host_entry("a", 10));
947 assert!(!rename_host_recent(&mut file, "a", "a"));
948 assert_eq!(file.entries.len(), 1);
949 }
950
951 #[test]
952 fn rename_host_recent_noop_when_absent() {
953 let mut file = RecentsFile::default();
954 assert!(!rename_host_recent(&mut file, "ghost", "phantom"));
955 assert!(file.entries.is_empty());
956 }
957}