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 pub modifiers: crossterm::event::KeyModifiers,
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158pub enum JumpActionTarget {
159 Hosts,
160 Tunnels,
161 Containers,
162 Keys,
163}
164
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub struct HostHit {
167 pub alias: String,
168 pub hostname: String,
169 pub tags: Vec<String>,
170 pub provider: Option<String>,
171 pub user: String,
172 pub identity_file: String,
173 pub proxy_jump: String,
174 pub vault_ssh: Option<String>,
175}
176
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct TunnelHit {
179 pub alias: String,
180 pub bind_port: u16,
181 pub bind_port_str: String,
185 pub destination: String,
186 pub active: bool,
187}
188
189#[derive(Debug, Clone, PartialEq, Eq)]
190pub struct ContainerHit {
191 pub alias: String,
192 pub container_name: String,
193 pub container_id: String,
194 pub state: String,
195}
196
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct SnippetHit {
199 pub name: String,
200 pub command_preview: String,
201}
202
203#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
206pub struct RecentRef {
207 pub kind: SourceKind,
208 pub key: String,
209}
210
211impl RecentRef {
212 pub fn new(kind: SourceKind, key: String) -> Self {
213 Self { kind, key }
214 }
215}
216
217#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
218pub struct RecentEntry {
219 #[serde(flatten)]
220 pub target: RecentRef,
221 pub last_used_unix: i64,
222}
223
224#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
227pub struct RecentsFile {
228 pub version: u32,
229 pub entries: Vec<RecentEntry>,
230}
231
232impl Default for RecentsFile {
233 fn default() -> Self {
234 Self {
235 version: 1,
236 entries: Vec::new(),
237 }
238 }
239}
240
241const RECENTS_VERSION: u32 = 1;
242const RECENTS_CAP: usize = 50;
243
244pub fn recents_path(paths: Option<&Paths>) -> Option<PathBuf> {
247 paths.map(Paths::recents)
248}
249
250pub fn load_recents(paths: Option<&Paths>) -> RecentsFile {
251 let Some(path) = recents_path(paths) else {
252 return RecentsFile::default();
253 };
254 let bytes = match std::fs::read(&path) {
255 Ok(b) => b,
256 Err(_) => return RecentsFile::default(),
257 };
258 serde_json::from_slice(&bytes).unwrap_or_default()
259}
260
261pub fn save_recents(file: &RecentsFile, paths: Option<&Paths>) -> std::io::Result<()> {
262 let Some(path) = recents_path(paths) else {
263 return Ok(());
264 };
265 if let Some(parent) = path.parent() {
266 std::fs::create_dir_all(parent)?;
267 }
268 let bytes = serde_json::to_vec_pretty(file).map_err(std::io::Error::other)?;
269 atomic_write(&path, &bytes)
270}
271
272pub fn rename_host_recent(file: &mut RecentsFile, old_alias: &str, new_alias: &str) -> bool {
279 if old_alias == new_alias {
280 return false;
281 }
282 let old_idx = file
283 .entries
284 .iter()
285 .position(|e| e.target.kind == SourceKind::Host && e.target.key == old_alias);
286 let Some(old_idx) = old_idx else {
287 return false;
288 };
289 let new_idx = file
290 .entries
291 .iter()
292 .position(|e| e.target.kind == SourceKind::Host && e.target.key == new_alias);
293 if let Some(new_idx) = new_idx {
294 let drop_idx =
295 if file.entries[old_idx].last_used_unix >= file.entries[new_idx].last_used_unix {
296 new_idx
297 } else {
298 old_idx
299 };
300 let keep_idx = if drop_idx == new_idx {
301 old_idx
302 } else {
303 new_idx
304 };
305 file.entries[keep_idx].target.key = new_alias.to_string();
306 file.entries.remove(drop_idx);
307 } else {
308 file.entries[old_idx].target.key = new_alias.to_string();
309 }
310 file.version = RECENTS_VERSION;
311 true
312}
313
314pub fn touch_recent(file: &mut RecentsFile, target: RecentRef) {
316 file.version = RECENTS_VERSION;
317 file.entries.retain(|e| e.target != target);
318 let now = current_unix_ts();
319 file.entries.insert(
320 0,
321 RecentEntry {
322 target,
323 last_used_unix: now,
324 },
325 );
326 if file.entries.len() > RECENTS_CAP {
327 file.entries.truncate(RECENTS_CAP);
328 }
329}
330
331fn current_unix_ts() -> i64 {
332 std::time::SystemTime::now()
333 .duration_since(std::time::UNIX_EPOCH)
334 .map(|d| d.as_secs() as i64)
335 .unwrap_or(0)
336}
337
338#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
342pub enum JumpMode {
343 #[default]
344 Hosts,
345 Tunnels,
346 Containers,
347 Snippets,
348 Keys,
349}
350
351pub const JUMP_EMPTY_STATE_ACTIONS_CAP: usize = 6;
356
357const EMPTY_STATE_TAB_BIAS: usize = 3;
363
364const CATEGORY_PRIORITY: &[&str] = &[
370 "Hosts",
371 "Tunnels",
372 "Containers",
373 "Files",
374 "Vault",
375 "Keys",
376 "Providers",
377 "Snippets",
378 "Clipboard",
379 "Settings",
380 "Help",
381];
382
383pub(crate) const PALETTE_ACTION_FLOOR: u32 = 30;
386
387fn round_robin_actions_by_category(actions: impl Iterator<Item = JumpAction>) -> Vec<JumpHit> {
395 let mut buckets: Vec<(String, Vec<JumpAction>)> = Vec::new();
396 for action in actions {
397 let category = action
398 .label
399 .split_once(':')
400 .map(|(c, _)| c.trim().to_string())
401 .unwrap_or_else(|| "Other".to_string());
402 if let Some(slot) = buckets.iter_mut().find(|(c, _)| c == &category) {
403 slot.1.push(action);
404 } else {
405 buckets.push((category, vec![action]));
406 }
407 }
408 let priority_index = |cat: &str| -> usize {
409 CATEGORY_PRIORITY
410 .iter()
411 .position(|p| *p == cat)
412 .unwrap_or(usize::MAX)
413 };
414 buckets.sort_by_key(|(c, _)| priority_index(c));
415 let mut out: Vec<JumpHit> = Vec::new();
416 let mut depth = 0usize;
417 let max_depth = buckets.iter().map(|(_, v)| v.len()).max().unwrap_or(0);
418 while depth < max_depth {
419 for (_, bucket) in &buckets {
420 if let Some(action) = bucket.get(depth) {
421 out.push(JumpHit::Action(*action));
422 }
423 }
424 depth += 1;
425 }
426 out
427}
428
429fn round_robin_actions_with_bias(
440 actions: impl Iterator<Item = JumpAction>,
441 preferred: JumpActionTarget,
442 bump: usize,
443) -> Vec<JumpHit> {
444 let collected: Vec<JumpAction> = actions.collect();
445 let biased: Vec<JumpAction> = collected
446 .iter()
447 .filter(|a| a.target == preferred)
448 .take(bump)
449 .copied()
450 .collect();
451 let biased_keys: std::collections::HashSet<char> = biased.iter().map(|a| a.key).collect();
452 let rest: Vec<JumpAction> = collected
453 .into_iter()
454 .filter(|a| !(biased_keys.contains(&a.key) && a.target == preferred))
455 .collect();
456 let mut out: Vec<JumpHit> = biased.into_iter().map(JumpHit::Action).collect();
457 out.extend(round_robin_actions_by_category(rest.into_iter()));
458 out
459}
460
461fn round_robin_actions_with_category_bias(
466 actions: impl Iterator<Item = JumpAction>,
467 category: &str,
468 bump: usize,
469) -> Vec<JumpHit> {
470 let collected: Vec<JumpAction> = actions.collect();
471 let cat_of = |a: &JumpAction| a.label.split_once(':').map(|(c, _)| c.trim()).unwrap_or("");
472 let biased: Vec<JumpAction> = collected
473 .iter()
474 .filter(|a| cat_of(a) == category)
475 .take(bump)
476 .copied()
477 .collect();
478 let biased_keys: std::collections::HashSet<char> = biased.iter().map(|a| a.key).collect();
479 let rest: Vec<JumpAction> = collected
480 .into_iter()
481 .filter(|a| !(biased_keys.contains(&a.key) && cat_of(a) == category))
482 .collect();
483 let mut out: Vec<JumpHit> = biased.into_iter().map(JumpHit::Action).collect();
484 out.extend(round_robin_actions_by_category(rest.into_iter()));
485 out
486}
487
488#[derive(Debug, Default)]
489pub struct JumpState {
490 pub(in crate::app) query: String,
491 pub(in crate::app) selected: usize,
492 pub(in crate::app) mode: JumpMode,
493 pub(in crate::app) hits: Vec<JumpHit>,
496 pub(in crate::app) recents: Vec<JumpHit>,
498 pub(in crate::app) cursor_revealed: bool,
504 pub(in crate::app) matcher: Option<nucleo_matcher::Matcher>,
508}
509
510impl Clone for JumpState {
515 fn clone(&self) -> Self {
516 Self {
517 query: self.query.clone(),
518 selected: self.selected,
519 mode: self.mode,
520 hits: self.hits.clone(),
521 recents: self.recents.clone(),
522 cursor_revealed: self.cursor_revealed,
523 matcher: None,
524 }
525 }
526}
527
528impl JumpState {
529 pub fn for_mode(mode: JumpMode) -> Self {
530 Self {
531 mode,
532 ..Self::default()
533 }
534 }
535
536 pub fn query(&self) -> &str {
537 &self.query
538 }
539
540 pub fn selected(&self) -> usize {
541 self.selected
542 }
543
544 pub fn mode(&self) -> JumpMode {
545 self.mode
546 }
547
548 pub fn cursor_revealed(&self) -> bool {
549 self.cursor_revealed
550 }
551
552 pub fn hits(&self) -> &[JumpHit] {
553 &self.hits
554 }
555
556 pub fn recents(&self) -> &[JumpHit] {
557 &self.recents
558 }
559
560 pub fn set_selected(&mut self, n: usize) {
561 self.selected = n;
562 }
563
564 pub fn set_hits(&mut self, hits: Vec<JumpHit>) {
565 self.hits = hits;
566 }
567
568 pub fn set_recents(&mut self, recents: Vec<JumpHit>) {
569 self.recents = recents;
570 }
571
572 pub fn move_down(&mut self) {
575 let count = self.visible_hits().len();
576 if count == 0 {
577 return;
578 }
579 if !self.cursor_revealed {
580 self.cursor_revealed = true;
581 self.selected = 0;
582 } else {
583 self.selected = (self.selected + 1).min(count - 1);
584 }
585 }
586
587 pub fn move_up(&mut self) {
590 if !self.cursor_revealed {
591 self.cursor_revealed = true;
592 self.selected = 0;
593 } else {
594 self.selected = self.selected.saturating_sub(1);
595 }
596 }
597
598 pub fn reveal_cursor(&mut self) {
599 self.cursor_revealed = true;
600 }
601
602 pub fn reset_after_clear_query(&mut self) {
605 self.cursor_revealed = false;
606 self.selected = 0;
607 }
608
609 pub fn push_query(&mut self, c: char) {
610 if self.query.len() < 64 {
611 self.query.push(c);
612 }
613 }
618
619 pub fn pop_query(&mut self) {
620 self.query.pop();
621 }
622
623 pub fn visible_hits(&self) -> Vec<JumpHit> {
632 if self.query.is_empty() {
633 let mut out: Vec<JumpHit> = self.recents.clone();
634 out.extend(self.empty_state_actions());
635 out
636 } else {
637 let mut out: Vec<JumpHit> = Vec::with_capacity(self.hits.len());
643 for kind in SourceKind::render_order() {
644 out.extend(self.hits.iter().filter(|h| h.kind() == kind).cloned());
645 }
646 out
647 }
648 }
649
650 fn filtered_actions_for_empty_state(&self) -> Vec<JumpAction> {
657 let recent_keys: std::collections::HashSet<RecentRef> =
658 self.recents.iter().map(|h| h.identity()).collect();
659 JumpAction::for_mode(self.mode)
660 .iter()
661 .filter(|a| {
662 let id = RecentRef::new(SourceKind::Action, a.key.to_string());
663 !recent_keys.contains(&id)
664 })
665 .copied()
666 .collect()
667 }
668
669 fn empty_state_actions(&self) -> Vec<JumpHit> {
675 let filtered = self.filtered_actions_for_empty_state();
676 let preferred_target = match self.mode {
677 JumpMode::Hosts => None,
678 JumpMode::Tunnels => Some(JumpActionTarget::Tunnels),
679 JumpMode::Containers => Some(JumpActionTarget::Containers),
680 JumpMode::Keys => Some(JumpActionTarget::Keys),
681 JumpMode::Snippets => None,
684 };
685 let actions = match self.mode {
686 JumpMode::Snippets => round_robin_actions_with_category_bias(
687 filtered.into_iter(),
688 "Snippets",
689 EMPTY_STATE_TAB_BIAS,
690 ),
691 _ => match preferred_target {
692 Some(t) => {
693 round_robin_actions_with_bias(filtered.into_iter(), t, EMPTY_STATE_TAB_BIAS)
694 }
695 None => round_robin_actions_by_category(filtered.into_iter()),
696 },
697 };
698 actions
699 .into_iter()
700 .take(JUMP_EMPTY_STATE_ACTIONS_CAP)
701 .collect()
702 }
703
704 pub fn empty_state_actions_total(&self) -> usize {
708 self.filtered_actions_for_empty_state().len()
709 }
710
711 pub fn grouped_hits(&self) -> Vec<(SourceKind, Vec<JumpHit>)> {
715 let visible = self.visible_hits();
716 let mut out = Vec::with_capacity(SourceKind::render_order().len());
717 for kind in SourceKind::render_order() {
718 let group: Vec<JumpHit> = visible
719 .iter()
720 .filter(|h| h.kind() == kind)
721 .cloned()
722 .collect();
723 if !group.is_empty() {
724 out.push((kind, group));
725 }
726 }
727 out
728 }
729
730 pub fn empty_state_groups(&self) -> Vec<(&'static str, Vec<JumpHit>)> {
735 let mut out: Vec<(&'static str, Vec<JumpHit>)> = Vec::new();
736 if !self.recents.is_empty() {
737 out.push(("RECENT", self.recents.clone()));
738 }
739 let actions = self.empty_state_actions();
742 if !actions.is_empty() {
743 out.push(("ACTIONS", actions));
744 }
745 out
746 }
747
748 pub fn selected_section(&self) -> Option<SourceKind> {
751 self.visible_hits().get(self.selected).map(|h| h.kind())
752 }
753
754 #[cfg(test)]
758 pub fn filtered_commands(&self) -> Vec<JumpAction> {
759 let all = JumpAction::for_mode(self.mode);
760 if self.query.is_empty() {
761 return all.to_vec();
762 }
763 let q = self.query.to_lowercase();
764 all.iter()
765 .filter(|cmd| {
766 cmd.label.to_lowercase().contains(&q)
767 || cmd.aliases.iter().any(|a| a.to_lowercase().contains(&q))
768 })
769 .copied()
770 .collect()
771 }
772
773 pub fn jump_next_section(&mut self) {
775 let visible = self.visible_hits();
776 if visible.is_empty() {
777 return;
778 }
779 if self.query.is_empty() {
780 let n_recent = self.recents.len();
788 if n_recent == 0 || n_recent >= visible.len() {
789 return;
790 }
791 if self.selected < n_recent {
792 self.selected = n_recent; } else {
794 self.selected = 0; }
796 return;
797 }
798 let groups = self.grouped_hits();
799 if groups.len() < 2 {
800 return;
801 }
802 let cur_kind = match self.selected_section() {
803 Some(k) => k,
804 None => {
805 self.selected = 0;
806 return;
807 }
808 };
809 let cur_idx = groups.iter().position(|(k, _)| *k == cur_kind).unwrap_or(0);
810 let next_idx = (cur_idx + 1) % groups.len();
811 let next_kind = groups[next_idx].0;
812 if let Some(pos) = visible.iter().position(|h| h.kind() == next_kind) {
813 self.selected = pos;
814 }
815 }
816}
817
818#[cfg(test)]
819pub mod tests {
820 use super::*;
821
822 fn with_temp<F: FnOnce(&Paths)>(f: F) {
825 let dir = tempfile::tempdir().unwrap();
826 let paths = Paths::new(dir.path());
827 f(&paths);
828 }
829
830 #[test]
831 fn snippets_mode_empty_state_leads_with_snippet_actions() {
832 let state = JumpState::for_mode(JumpMode::Snippets);
836 let hits = state.visible_hits();
837 match hits.first().expect("at least one action") {
838 JumpHit::Action(a) => assert!(
839 a.label.starts_with("Snippets:"),
840 "expected a Snippets action first, got {:?}",
841 a.label
842 ),
843 other => panic!("expected an action first, got {other:?}"),
844 }
845 }
846
847 #[test]
848 fn hosts_mode_empty_state_does_not_lead_with_snippet_actions() {
849 let state = JumpState::for_mode(JumpMode::Hosts);
852 let hits = state.visible_hits();
853 if let Some(JumpHit::Action(a)) = hits.first() {
854 assert!(
855 !a.label.starts_with("Snippets:"),
856 "Hosts mode should not lead with a Snippets action, got {:?}",
857 a.label
858 );
859 }
860 }
861
862 #[test]
863 fn visible_hits_matches_grouped_render_order_with_active_query() {
864 let action = JumpAction::all()[0];
871 let host = HostHit {
872 alias: "proxy-vm".into(),
873 hostname: "proxy-vm.example.com".into(),
874 tags: Vec::new(),
875 provider: None,
876 user: String::new(),
877 identity_file: String::new(),
878 proxy_jump: String::new(),
879 vault_ssh: None,
880 };
881 let state = JumpState {
884 query: "prov".into(),
885 hits: vec![JumpHit::Action(action), JumpHit::Host(host)],
886 ..Default::default()
887 };
888
889 let visible = state.visible_hits();
890 let flattened: Vec<JumpHit> = state
891 .grouped_hits()
892 .into_iter()
893 .flat_map(|(_, hits)| hits)
894 .collect();
895 assert_eq!(
896 visible, flattened,
897 "visible_hits() must equal the flattened grouped order so the \
898 highlighted row and the dispatched hit reference the same item"
899 );
900 assert!(
903 matches!(visible[0], JumpHit::Host(_)),
904 "first visible row must follow render order (HOSTS first)"
905 );
906 }
907
908 #[test]
909 fn section_labels_are_uppercase() {
910 for k in SourceKind::render_order() {
911 let label = k.section_label();
912 assert_eq!(label, label.to_uppercase(), "{:?} not uppercase", k);
913 }
914 }
915
916 #[test]
917 fn render_order_starts_with_hosts() {
918 assert_eq!(SourceKind::render_order()[0], SourceKind::Host);
919 assert_eq!(SourceKind::render_order()[4], SourceKind::Action);
920 }
921
922 #[test]
923 fn touch_moves_existing_to_front_and_caps() {
924 let mut f = RecentsFile::default();
925 for i in 0..(RECENTS_CAP + 5) {
926 touch_recent(&mut f, RecentRef::new(SourceKind::Host, format!("h{i}")));
927 }
928 assert_eq!(f.entries.len(), RECENTS_CAP);
929 let target = RecentRef::new(SourceKind::Host, format!("h{}", RECENTS_CAP + 2));
931 touch_recent(&mut f, target.clone());
932 assert_eq!(f.entries[0].target, target);
933 assert_eq!(f.entries.len(), RECENTS_CAP);
934 }
935
936 #[test]
937 fn save_then_load_roundtrip() {
938 with_temp(|paths| {
939 let mut f = RecentsFile::default();
940 touch_recent(&mut f, RecentRef::new(SourceKind::Action, "F".into()));
941 touch_recent(&mut f, RecentRef::new(SourceKind::Host, "web-01".into()));
942 save_recents(&f, Some(paths)).expect("save");
943 let loaded = load_recents(Some(paths));
944 assert_eq!(loaded.version, RECENTS_VERSION);
945 assert_eq!(loaded.entries.len(), 2);
946 assert_eq!(loaded.entries[0].target.key, "web-01");
947 assert_eq!(loaded.entries[1].target.key, "F");
948 });
949 }
950
951 #[test]
952 fn missing_file_loads_empty() {
953 with_temp(|paths| {
954 let loaded = load_recents(Some(paths));
955 assert!(loaded.entries.is_empty());
956 });
957 }
958
959 #[test]
960 fn corrupt_file_loads_empty() {
961 with_temp(|paths| {
962 let path = paths.recents();
963 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
964 std::fs::write(&path, b"not json").unwrap();
965 let loaded = load_recents(Some(paths));
966 assert!(loaded.entries.is_empty());
967 });
968 }
969
970 fn host_entry(alias: &str, ts: i64) -> RecentEntry {
971 RecentEntry {
972 target: RecentRef::new(SourceKind::Host, alias.to_string()),
973 last_used_unix: ts,
974 }
975 }
976
977 #[test]
978 fn rename_host_recent_rewrites_key() {
979 let mut file = RecentsFile::default();
980 file.entries.push(host_entry("web-old", 100));
981 file.entries.push(RecentEntry {
982 target: RecentRef::new(SourceKind::Tunnel, "web-old:5432".to_string()),
983 last_used_unix: 90,
984 });
985
986 assert!(rename_host_recent(&mut file, "web-old", "web-new"));
987 assert_eq!(file.entries[0].target.kind, SourceKind::Host);
988 assert_eq!(file.entries[0].target.key, "web-new");
989 assert_eq!(file.entries[1].target.kind, SourceKind::Tunnel);
991 assert_eq!(file.entries[1].target.key, "web-old:5432");
992 }
993
994 #[test]
995 fn rename_host_recent_dedups_on_collision_keeping_most_recent() {
996 let mut file = RecentsFile::default();
997 file.entries.push(host_entry("a", 200));
1000 file.entries.push(host_entry("b", 100));
1001
1002 assert!(rename_host_recent(&mut file, "a", "b"));
1003 assert_eq!(file.entries.len(), 1);
1004 assert_eq!(file.entries[0].target.key, "b");
1005 assert_eq!(file.entries[0].last_used_unix, 200);
1006 }
1007
1008 #[test]
1009 fn rename_host_recent_dedups_when_new_key_is_newer() {
1010 let mut file = RecentsFile::default();
1011 file.entries.push(host_entry("a", 100));
1012 file.entries.push(host_entry("b", 200));
1013
1014 assert!(rename_host_recent(&mut file, "a", "b"));
1015 assert_eq!(file.entries.len(), 1);
1016 assert_eq!(file.entries[0].target.key, "b");
1017 assert_eq!(file.entries[0].last_used_unix, 200);
1018 }
1019
1020 #[test]
1021 fn rename_host_recent_noop_when_same() {
1022 let mut file = RecentsFile::default();
1023 file.entries.push(host_entry("a", 10));
1024 assert!(!rename_host_recent(&mut file, "a", "a"));
1025 assert_eq!(file.entries.len(), 1);
1026 }
1027
1028 #[test]
1029 fn rename_host_recent_noop_when_absent() {
1030 let mut file = RecentsFile::default();
1031 assert!(!rename_host_recent(&mut file, "ghost", "phantom"));
1032 assert!(file.entries.is_empty());
1033 }
1034}