1use std::{
2 cmp::Ordering,
3 collections::BTreeMap,
4 path::{Path, PathBuf},
5};
6
7use crate::{CandidateAction, PreviewKey};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
10pub enum CandidateKind {
11 TmuxSession,
12 TmuxWindow,
13 Directory,
14 Project,
15 Worktree,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
19pub enum CandidateId {
20 Session(String),
21 Window { session: String, index: u32 },
22 Directory(PathBuf),
23 Worktree(PathBuf),
24 Project(String),
25}
26
27#[derive(Debug, Clone, PartialEq)]
28pub struct Candidate {
29 pub id: CandidateId,
30 pub kind: CandidateKind,
31 pub primary_text: String,
32 pub secondary_text: Option<String>,
33 pub search_terms: Vec<String>,
34 pub preview_key: PreviewKey,
35 pub score_hints: ScoreHints,
36 pub action: CandidateAction,
37 pub metadata: CandidateMetadata,
38}
39
40impl Candidate {
41 #[must_use]
42 pub fn session(metadata: SessionMetadata) -> Self {
43 let primary_text = metadata.session_name.clone();
44 let secondary_text = Some(format!("{} windows", metadata.window_count));
45
46 Self {
47 id: CandidateId::Session(metadata.session_name.clone()),
48 kind: CandidateKind::TmuxSession,
49 search_terms: vec![metadata.session_name.clone()],
50 preview_key: PreviewKey::Session(metadata.session_name.clone()),
51 score_hints: ScoreHints {
52 recency: metadata.last_activity,
53 source_score: Some(i64::from(metadata.window_count as i32)),
54 is_current: metadata.current,
55 is_attached: metadata.attached,
56 },
57 action: CandidateAction::SwitchSession,
58 metadata: CandidateMetadata::Session(metadata),
59 primary_text,
60 secondary_text,
61 }
62 }
63
64 #[must_use]
65 pub fn directory(metadata: DirectoryMetadata) -> Self {
66 let primary_text = metadata.display_path.clone();
67
68 Self {
69 id: CandidateId::Directory(metadata.full_path.clone()),
70 kind: CandidateKind::Directory,
71 search_terms: vec![
72 metadata.display_path.clone(),
73 metadata.full_path.display().to_string(),
74 ],
75 preview_key: PreviewKey::Directory(metadata.full_path.clone()),
76 score_hints: ScoreHints {
77 source_score: metadata.zoxide_score.map(|score| score.round() as i64),
78 ..ScoreHints::default()
79 },
80 action: CandidateAction::CreateOrSwitchSession,
81 metadata: CandidateMetadata::Directory(metadata),
82 primary_text,
83 secondary_text: Some("directory".to_string()),
84 }
85 }
86
87 #[must_use]
88 pub fn worktree(metadata: WorktreeMetadata) -> Self {
89 let primary_text = metadata.display_path.clone();
90 let mut search_terms = vec![
91 metadata.display_path.clone(),
92 metadata.full_path.display().to_string(),
93 ];
94 if let Some(branch) = &metadata.branch {
95 search_terms.push(branch.clone());
96 }
97
98 Self {
99 id: CandidateId::Worktree(metadata.full_path.clone()),
100 kind: CandidateKind::Worktree,
101 search_terms,
102 preview_key: PreviewKey::Directory(metadata.full_path.clone()),
103 score_hints: ScoreHints::default(),
104 action: CandidateAction::CreateOrSwitchSession,
105 metadata: CandidateMetadata::Worktree(metadata),
106 primary_text,
107 secondary_text: Some("worktree".to_string()),
108 }
109 }
110
111 #[must_use]
112 pub fn searchable_text(&self) -> String {
113 self.search_terms.join(" ")
114 }
115
116 #[must_use]
117 pub fn matches_query(&self, query: &str) -> bool {
118 let normalized_query = normalize_text(query);
119 if normalized_query.is_empty() {
120 return true;
121 }
122
123 let haystack = normalize_text(&self.searchable_text());
124 normalized_query
125 .split_whitespace()
126 .all(|needle| haystack.contains(needle))
127 }
128}
129
130#[derive(Debug, Clone, PartialEq)]
131pub enum CandidateMetadata {
132 Session(SessionMetadata),
133 Window(WindowMetadata),
134 Directory(DirectoryMetadata),
135 Project(ProjectMetadata),
136 Worktree(WorktreeMetadata),
137}
138
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct SessionMetadata {
141 pub session_name: String,
142 pub attached: bool,
143 pub current: bool,
144 pub window_count: usize,
145 pub last_activity: Option<u64>,
146}
147
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct WindowMetadata {
150 pub session_name: String,
151 pub index: u32,
152 pub name: String,
153 pub active: bool,
154}
155
156#[derive(Debug, Clone, PartialEq)]
157pub struct DirectoryMetadata {
158 pub full_path: PathBuf,
159 pub display_path: String,
160 pub zoxide_score: Option<f64>,
161 pub git_root_hint: Option<PathBuf>,
162 pub exists: bool,
163}
164
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub struct ProjectMetadata {
167 pub name: String,
168 pub root: PathBuf,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct WorktreeMetadata {
173 pub full_path: PathBuf,
174 pub display_path: String,
175 pub branch: Option<String>,
176}
177
178#[derive(Debug, Clone, Default, PartialEq, Eq)]
179pub struct ScoreHints {
180 pub recency: Option<u64>,
181 pub source_score: Option<i64>,
182 pub is_current: bool,
183 pub is_attached: bool,
184}
185
186impl ScoreHints {
187 fn priority_tuple(&self) -> (bool, bool, i64, u64) {
188 (
189 self.is_current,
190 self.is_attached,
191 self.source_score.unwrap_or_default(),
192 self.recency.unwrap_or_default(),
193 )
194 }
195}
196
197#[must_use]
198pub fn normalize_display_path(path: &Path, home: Option<&Path>) -> String {
199 if let Some(home_path) = home
200 && let Ok(relative) = path.strip_prefix(home_path)
201 {
202 let suffix = relative.display().to_string();
203 return if suffix.is_empty() {
204 "~".to_string()
205 } else {
206 format!("~/{suffix}")
207 };
208 }
209
210 path.display().to_string()
211}
212
213#[must_use]
214pub fn deduplicate_candidates(candidates: impl IntoIterator<Item = Candidate>) -> Vec<Candidate> {
215 let mut deduplicated: BTreeMap<CandidateId, Candidate> = BTreeMap::new();
216
217 for candidate in candidates {
218 match deduplicated.get(&candidate.id) {
219 Some(existing) if candidate_priority(&candidate) <= candidate_priority(existing) => {}
220 _ => {
221 deduplicated.insert(candidate.id.clone(), candidate);
222 }
223 }
224 }
225
226 deduplicated.into_values().collect()
227}
228
229pub fn sort_candidates(candidates: &mut [Candidate]) {
230 candidates.sort_by(candidate_cmp);
231}
232
233fn candidate_cmp(left: &Candidate, right: &Candidate) -> Ordering {
234 candidate_priority(left)
235 .cmp(&candidate_priority(right))
236 .reverse()
237 .then_with(|| left.kind.cmp(&right.kind))
238 .then_with(|| left.primary_text.cmp(&right.primary_text))
239 .then_with(|| left.secondary_text.cmp(&right.secondary_text))
240}
241
242fn candidate_priority(candidate: &Candidate) -> (bool, bool, i64, u64) {
243 candidate.score_hints.priority_tuple()
244}
245
246fn normalize_text(input: &str) -> String {
247 input
248 .split_whitespace()
249 .map(str::to_ascii_lowercase)
250 .collect::<Vec<_>>()
251 .join(" ")
252}