Skip to main content

wisp_core/
candidate.rs

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}