1use std::path::PathBuf;
2
3#[derive(Debug, Clone, PartialEq, Eq, Default)]
5#[non_exhaustive]
6pub struct AppInfo {
7 pub name: String,
9 pub bundle_id: Option<String>,
12 pub pid: Option<u32>,
14 pub path: Option<PathBuf>,
16 pub is_running: bool,
18 pub is_active: bool,
20 pub window_title: Option<String>,
22 pub needs_restart: bool,
28}
29
30impl AppInfo {
31 #[must_use]
33 pub fn new(name: impl Into<String>) -> Self {
34 Self { name: name.into(), ..Self::default() }
35 }
36
37 #[must_use]
39 pub fn with_bundle_id(mut self, bundle_id: impl Into<String>) -> Self {
40 self.bundle_id = Some(bundle_id.into());
41 self
42 }
43
44 #[must_use]
46 pub const fn with_pid(mut self, pid: u32) -> Self {
47 self.pid = Some(pid);
48 self.is_running = true;
49 self
50 }
51
52 #[must_use]
54 pub fn with_path(mut self, path: impl Into<PathBuf>) -> Self {
55 self.path = Some(path.into());
56 self
57 }
58
59 #[must_use]
61 pub const fn with_active(mut self, active: bool) -> Self {
62 self.is_active = active;
63 self
64 }
65
66 #[must_use]
68 pub const fn with_needs_restart(mut self, needs_restart: bool) -> Self {
69 self.needs_restart = needs_restart;
70 self
71 }
72
73 #[must_use]
75 pub fn with_window_title(mut self, title: impl Into<String>) -> Self {
76 self.window_title = Some(title.into()).filter(|t: &String| !t.is_empty());
77 self
78 }
79
80 #[must_use]
82 pub fn reference(&self) -> &str {
83 self.bundle_id.as_deref().unwrap_or(&self.name)
84 }
85
86 #[must_use]
90 pub fn match_score(&self, query: &str) -> Option<u8> {
91 let query = query.trim();
92 if query.is_empty() {
93 return None;
94 }
95 if let Some(pid) = query.strip_prefix("pid:") {
96 return (pid.trim().parse::<u32>().ok() == self.pid && self.pid.is_some()).then_some(0);
97 }
98 let q = query.to_lowercase();
99 let q_stem = q.strip_suffix(".app").or_else(|| q.strip_suffix(".exe")).unwrap_or(&q);
100 let name = self.name.to_lowercase();
101 let bundle = self.bundle_id.as_deref().map(str::to_lowercase);
102
103 if name == q || name == q_stem || bundle.as_deref() == Some(q.as_str()) {
104 return Some(0);
105 }
106 if let Some(path) = &self.path {
107 let path_str = path.to_string_lossy().to_lowercase();
108 if path_str == q {
109 return Some(1);
110 }
111 let stem = path.file_stem().map(|s| s.to_string_lossy().to_lowercase());
112 if stem.as_deref() == Some(q_stem) {
113 return Some(2);
114 }
115 }
116 if bundle.as_deref().and_then(|b| b.rsplit('.').next()).is_some_and(|last| last == q_stem) {
117 return Some(3);
118 }
119 if name.starts_with(q_stem) || name.split_whitespace().any(|word| word == q_stem) {
120 return Some(4);
121 }
122 let title = self.window_title.as_deref().map(str::to_lowercase);
123 if title.as_deref() == Some(q.as_str()) {
124 return Some(5);
125 }
126 if title.is_some_and(|t| t.contains(q.as_str())) {
127 return Some(6);
128 }
129 None
130 }
131}
132
133pub(crate) fn best_match<'a>(apps: &'a [AppInfo], query: &str) -> Option<&'a AppInfo> {
134 apps.iter()
135 .filter_map(|app| app.match_score(query).map(|score| (score, !app.is_running, app)))
136 .min_by_key(|(score, not_running, _)| (*score, *not_running))
137 .map(|(_, _, app)| app)
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143
144 fn apps() -> Vec<AppInfo> {
145 vec![
146 AppInfo::new("TextEdit")
147 .with_bundle_id("com.apple.TextEdit")
148 .with_path("/System/Applications/TextEdit.app"),
149 AppInfo::new("TextEdit").with_bundle_id("com.apple.TextEdit").with_pid(42),
150 AppInfo::new("Safari").with_bundle_id("com.apple.Safari").with_pid(7),
151 ]
152 }
153
154 #[test]
155 fn matches_name_bundle_and_pid() {
156 let apps = apps();
157 assert_eq!(best_match(&apps, "textedit").and_then(|a| a.pid), Some(42));
158 assert_eq!(best_match(&apps, "com.apple.Safari").map(|a| a.name.as_str()), Some("Safari"));
159 assert_eq!(best_match(&apps, "pid:7").map(|a| a.name.as_str()), Some("Safari"));
160 assert_eq!(best_match(&apps, "Safari.app").map(|a| a.name.as_str()), Some("Safari"));
161 assert!(best_match(&apps, "Xcode").is_none());
162 }
163
164 #[test]
165 fn matches_window_titles_last() {
166 let apps = vec![
167 AppInfo::new("notepad").with_pid(1).with_window_title("todo.txt - Notepad"),
168 AppInfo::new("todo").with_pid(2),
169 ];
170 assert_eq!(best_match(&apps, "todo.txt - notepad").and_then(|a| a.pid), Some(1));
171 assert_eq!(best_match(&apps, "todo").and_then(|a| a.pid), Some(2));
172 assert_eq!(best_match(&apps, "todo.txt").and_then(|a| a.pid), Some(1));
173 }
174
175 #[test]
176 fn prefix_match_is_weaker_than_exact() {
177 let apps = vec![AppInfo::new("Notes Helper").with_pid(1), AppInfo::new("Notes").with_pid(2)];
178 assert_eq!(best_match(&apps, "notes").and_then(|a| a.pid), Some(2));
179 }
180}