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