1use std::path::PathBuf;
2
3use crate::error::{Error, Result};
4
5#[derive(Debug, Clone, PartialEq, Eq, Default)]
7#[non_exhaustive]
8pub struct AppInfo {
9 pub name: String,
11 pub bundle_id: Option<String>,
14 pub pid: Option<u32>,
16 pub path: Option<PathBuf>,
18 pub is_running: bool,
20 pub is_active: bool,
22 pub window_title: Option<String>,
24 pub needs_restart: bool,
30}
31
32impl AppInfo {
33 #[must_use]
35 pub fn new(name: impl Into<String>) -> Self {
36 Self { name: name.into(), ..Self::default() }
37 }
38
39 #[must_use]
41 pub fn with_bundle_id(mut self, bundle_id: impl Into<String>) -> Self {
42 self.bundle_id = Some(bundle_id.into());
43 self
44 }
45
46 #[must_use]
48 pub const fn with_pid(mut self, pid: u32) -> Self {
49 self.pid = Some(pid);
50 self.is_running = true;
51 self
52 }
53
54 #[must_use]
56 pub fn with_path(mut self, path: impl Into<PathBuf>) -> Self {
57 self.path = Some(path.into());
58 self
59 }
60
61 #[must_use]
63 pub const fn with_active(mut self, active: bool) -> Self {
64 self.is_active = active;
65 self
66 }
67
68 #[must_use]
70 pub const fn with_needs_restart(mut self, needs_restart: bool) -> Self {
71 self.needs_restart = needs_restart;
72 self
73 }
74
75 #[must_use]
77 pub fn with_window_title(mut self, title: impl Into<String>) -> Self {
78 self.window_title = Some(title.into()).filter(|t: &String| !t.is_empty());
79 self
80 }
81
82 #[must_use]
84 pub fn reference(&self) -> &str {
85 self.bundle_id.as_deref().unwrap_or(&self.name)
86 }
87
88 #[must_use]
92 pub fn match_score(&self, query: &str) -> Option<u8> {
93 let query = query.trim();
94 if query.is_empty() {
95 return None;
96 }
97 if let Some(pid) = query.strip_prefix("pid:") {
98 return (pid.trim().parse::<u32>().ok() == self.pid && self.pid.is_some()).then_some(0);
99 }
100 let q = query.to_lowercase();
101 let q_stem = q.strip_suffix(".app").or_else(|| q.strip_suffix(".exe")).unwrap_or(&q);
102 let name = self.name.to_lowercase();
103 let bundle = self.bundle_id.as_deref().map(str::to_lowercase);
104
105 if name == q || name == q_stem || bundle.as_deref() == Some(q.as_str()) {
106 return Some(0);
107 }
108 if let Some(path) = &self.path {
109 let path_str = path.to_string_lossy().to_lowercase();
110 if path_str == q {
111 return Some(1);
112 }
113 let stem = path.file_stem().map(|s| s.to_string_lossy().to_lowercase());
114 if stem.as_deref() == Some(q_stem) {
115 return Some(2);
116 }
117 }
118 if bundle.as_deref().and_then(|b| b.rsplit('.').next()).is_some_and(|last| last == q_stem) {
119 return Some(3);
120 }
121 if name.starts_with(q_stem) || name.split_whitespace().any(|word| word == q_stem) {
122 return Some(4);
123 }
124 let title = self.window_title.as_deref().map(str::to_lowercase);
125 if title.as_deref() == Some(q.as_str()) {
126 return Some(5);
127 }
128 if title.is_some_and(|t| t.contains(q.as_str())) {
129 return Some(6);
130 }
131 None
132 }
133}
134
135pub(crate) fn resolve(apps: &[AppInfo], query: &str) -> Result<AppInfo> {
137 let mut matches: Vec<_> = apps.iter().filter_map(|app| Some((app.match_score(query)?, app))).collect();
138 matches.sort_by_key(|(score, app)| (*score, !app.is_running, !app.is_active, app.pid));
139 let Some(&(best, first)) = matches.first() else {
140 return Err(Error::AppNotFound(query.to_owned()));
141 };
142 let tied: Vec<AppInfo> =
143 matches.iter().filter(|(score, app)| *score == best && app.is_running).map(|(_, app)| (*app).clone()).collect();
144 if tied.len() > 1 {
145 return Err(Error::AmbiguousApp { query: query.to_owned(), candidates: tied });
146 }
147 Ok(first.clone())
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153
154 fn apps() -> Vec<AppInfo> {
155 vec![
156 AppInfo::new("TextEdit")
157 .with_bundle_id("com.apple.TextEdit")
158 .with_path("/System/Applications/TextEdit.app"),
159 AppInfo::new("TextEdit").with_bundle_id("com.apple.TextEdit").with_pid(42),
160 AppInfo::new("Safari").with_bundle_id("com.apple.Safari").with_pid(7),
161 ]
162 }
163
164 fn best_match<'a>(apps: &'a [AppInfo], query: &str) -> Option<&'a AppInfo> {
165 apps.iter().find(|app| resolve(apps, query).is_ok_and(|best| best == **app))
166 }
167
168 #[test]
169 fn same_named_running_apps_are_ambiguous() {
170 let apps = vec![AppInfo::new("Code").with_pid(2), AppInfo::new("Code").with_pid(1).with_window_title("a.rs")];
171 let Err(Error::AmbiguousApp { candidates, .. }) = resolve(&apps, "code") else { panic!("expected ambiguity") };
172 assert_eq!(candidates.iter().filter_map(|a| a.pid).collect::<Vec<_>>(), vec![1, 2]);
173 assert_eq!(resolve(&apps, "pid:2").ok().and_then(|a| a.pid), Some(2));
174 assert_eq!(resolve(&apps, "a.rs").ok().and_then(|a| a.pid), Some(1));
175 }
176
177 #[test]
178 fn matches_name_bundle_and_pid() {
179 let apps = apps();
180 assert_eq!(best_match(&apps, "textedit").and_then(|a| a.pid), Some(42));
181 assert_eq!(best_match(&apps, "com.apple.Safari").map(|a| a.name.as_str()), Some("Safari"));
182 assert_eq!(best_match(&apps, "pid:7").map(|a| a.name.as_str()), Some("Safari"));
183 assert_eq!(best_match(&apps, "Safari.app").map(|a| a.name.as_str()), Some("Safari"));
184 assert!(best_match(&apps, "Xcode").is_none());
185 }
186
187 #[test]
188 fn matches_window_titles_last() {
189 let apps = vec![
190 AppInfo::new("notepad").with_pid(1).with_window_title("todo.txt - Notepad"),
191 AppInfo::new("todo").with_pid(2),
192 ];
193 assert_eq!(best_match(&apps, "todo.txt - notepad").and_then(|a| a.pid), Some(1));
194 assert_eq!(best_match(&apps, "todo").and_then(|a| a.pid), Some(2));
195 assert_eq!(best_match(&apps, "todo.txt").and_then(|a| a.pid), Some(1));
196 }
197
198 #[test]
199 fn prefix_match_is_weaker_than_exact() {
200 let apps = vec![AppInfo::new("Notes Helper").with_pid(1), AppInfo::new("Notes").with_pid(2)];
201 assert_eq!(best_match(&apps, "notes").and_then(|a| a.pid), Some(2));
202 }
203}