1use crate::context::Context;
15use crate::process::Process;
16use std::path::Path;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub struct Holder {
21 pub kind: &'static str,
23 pub noun: Noun,
25 pub location: Location,
27 pub remedy: Remedy,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum Noun {
34 Counted {
36 one: &'static str,
37 many: &'static str,
38 },
39 One(&'static str),
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum Location {
47 Within(&'static str),
49 WithinPrefixed(&'static str),
52 Anywhere,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum Remedy {
59 Restart,
61 ReopenApp {
64 bundle_id: &'static str,
65 name: &'static str,
66 },
67 Run(&'static str),
69 Do(&'static str),
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct Holding {
76 pub holder: Holder,
77 pub pids: Vec<u32>,
79}
80
81impl Location {
82 fn holds(self, path: &Path) -> bool {
85 let mut directories = path.ancestors().skip(1).filter_map(Path::file_name);
86 match self {
87 Location::Anywhere => true,
88 Location::Within(name) => directories.any(|dir| dir == name),
89 Location::WithinPrefixed(prefix) => {
90 directories.any(|dir| dir.to_string_lossy().starts_with(prefix))
91 }
92 }
93 }
94}
95
96pub fn classify(processes: &[Process], holders: &[Holder]) -> Vec<Holding> {
100 let mut holding: Vec<Holding> = holders
101 .iter()
102 .map(|&holder| Holding {
103 holder,
104 pids: Vec::new(),
105 })
106 .collect();
107 for process in processes {
108 if let Some(found) = holding
109 .iter_mut()
110 .find(|h| h.holder.location.holds(&process.path))
111 {
112 found.pids.push(process.pid);
113 }
114 }
115 holding.retain(|h| !h.pids.is_empty());
116 holding
117}
118
119pub(crate) fn find(ctx: &Context, program: &str, holders: &[Holder]) -> Option<Vec<Holding>> {
122 ctx.host()
123 .processes(program)
124 .map(|processes| classify(&processes, holders))
125}
126
127impl Holding {
128 fn things(&self) -> usize {
130 match self.holder.noun {
131 Noun::Counted { .. } => self.pids.len(),
132 Noun::One(_) => 1,
133 }
134 }
135
136 pub fn phrase(&self) -> String {
138 match self.holder.noun {
139 Noun::Counted { one, many } => match self.pids.len() {
140 1 => format!("1 {one}"),
141 n => format!("{n} {many}"),
142 },
143 Noun::One(name) => name.to_string(),
144 }
145 }
146
147 fn clause(&self, alone: bool) -> String {
150 let them = if self.things() == 1 { "it" } else { "them" };
151 match self.holder.remedy {
152 Remedy::Restart if alone => format!("quit {them} and start again"),
153 Remedy::Restart => {
154 let what = match self.holder.noun {
155 Noun::Counted { one, many } => {
156 format!("the {}", if self.things() == 1 { one } else { many })
157 }
158 Noun::One(name) => name.to_string(),
159 };
160 format!("quit {what} and start {them} again")
161 }
162 Remedy::ReopenApp { name, .. } => {
163 format!("quit {name} with Command-Q and open it again")
164 }
165 Remedy::Run(command) => format!("run `{command}`"),
166 Remedy::Do(instruction) => instruction.to_string(),
167 }
168 }
169}
170
171pub fn described(holding: &[Holding]) -> String {
173 listed(holding.iter().map(Holding::phrase).collect())
174}
175
176pub fn described_with_pids(holding: &[Holding]) -> String {
178 listed(
179 holding
180 .iter()
181 .map(|h| format!("{} (pid {})", h.phrase(), some_of(&h.pids)))
182 .collect(),
183 )
184}
185
186pub fn plural(holding: &[Holding]) -> bool {
188 holding.iter().map(Holding::things).sum::<usize>() > 1
189}
190
191pub fn remedies(holding: &[Holding], purpose: &str) -> String {
196 match holding {
197 [] => String::new(),
198 [one] => format!("{} {purpose}.", capitalised(&one.clause(true))),
199 many => format!(
200 "{}: {}.",
201 capitalised(purpose),
202 many.iter()
203 .map(|h| h.clause(false))
204 .collect::<Vec<_>>()
205 .join("; ")
206 ),
207 }
208}
209
210fn listed(mut items: Vec<String>) -> String {
212 match items.len() {
213 0 => String::new(),
214 1 => items.remove(0),
215 _ => {
216 let last = items.pop().unwrap_or_default();
217 format!("{} and {last}", items.join(", "))
218 }
219 }
220}
221
222pub(crate) fn capitalised(text: &str) -> String {
224 let mut chars = text.chars();
225 chars.next().map_or_else(String::new, |first| {
226 first.to_uppercase().chain(chars).collect()
227 })
228}
229
230pub(crate) fn some_of(pids: &[u32]) -> String {
233 const SHOWN: usize = 3;
234 let named: Vec<String> = pids.iter().take(SHOWN).map(u32::to_string).collect();
235 match pids.len().saturating_sub(SHOWN) {
236 0 => named.join(", "),
237 more => format!("{} and {more} more", named.join(", ")),
238 }
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244 use std::path::PathBuf;
245
246 const APP: Holder = Holder {
247 kind: "app",
248 noun: Noun::One("the App"),
249 location: Location::Within("App.app"),
250 remedy: Remedy::ReopenApp {
251 bundle_id: "com.example.app",
252 name: "App",
253 },
254 };
255 const EXTENSION: Holder = Holder {
256 kind: "extension",
257 noun: Noun::One("the extension"),
258 location: Location::WithinPrefixed("vendor.tool-"),
259 remedy: Remedy::Do("reload the editor's window"),
260 };
261 const SESSION: Holder = Holder {
262 kind: "session",
263 noun: Noun::Counted {
264 one: "`tool` session",
265 many: "`tool` sessions",
266 },
267 location: Location::Anywhere,
268 remedy: Remedy::Restart,
269 };
270 const HOLDERS: &[Holder] = &[APP, EXTENSION, SESSION];
271
272 fn at(pid: u32, path: &str) -> Process {
273 Process {
274 pid,
275 path: PathBuf::from(path),
276 }
277 }
278
279 fn kinds(holding: &[Holding]) -> Vec<(&str, Vec<u32>)> {
280 holding
281 .iter()
282 .map(|h| (h.holder.kind, h.pids.clone()))
283 .collect()
284 }
285
286 #[test]
287 fn each_process_is_the_first_kind_whose_place_holds_it() {
288 let holding = classify(
289 &[
290 at(1, "tool"),
291 at(
292 2,
293 "/Applications/App.app/Contents/Helpers/Tool.app/Contents/MacOS/tool",
294 ),
295 at(3, "/home/a/.editor/extensions/vendor.tool-1.2.3/bin/tool"),
296 at(4, "/usr/local/bin/tool"),
297 ],
298 HOLDERS,
299 );
300 assert_eq!(
301 kinds(&holding),
302 [
303 ("app", vec![2]),
304 ("extension", vec![3]),
305 ("session", vec![1, 4])
306 ]
307 );
308 }
309
310 #[test]
313 fn only_the_directories_a_program_is_in_say_where_it_runs() {
314 let holding = classify(&[at(1, "/usr/bin/App.app")], HOLDERS);
315 assert_eq!(kinds(&holding), [("session", vec![1])]);
316 assert!(!Location::WithinPrefixed("vendor.tool-").holds(Path::new("vendor.tool-9")));
317 }
318
319 #[test]
320 fn nothing_running_is_nothing_held() {
321 assert!(classify(&[], HOLDERS).is_empty());
322 assert!(
323 classify(&[at(1, "tool")], &[APP]).is_empty(),
324 "and a process no kind claims is left out"
325 );
326 }
327
328 #[test]
329 fn an_app_is_one_thing_however_many_processes_it_runs() {
330 let holding = classify(
331 &[
332 at(7, "/Applications/App.app/x/tool"),
333 at(8, "/Applications/App.app/y/tool"),
334 ],
335 HOLDERS,
336 );
337 assert_eq!(described(&holding), "the App");
338 assert!(!plural(&holding));
339 assert_eq!(described_with_pids(&holding), "the App (pid 7, 8)");
340 }
341
342 #[test]
345 fn what_to_do_is_said_once_for_each_kind() {
346 let sessions = classify(&[at(1, "tool"), at(2, "tool")], HOLDERS);
347 assert_eq!(described(&sessions), "2 `tool` sessions");
348 assert!(plural(&sessions));
349 assert_eq!(
350 remedies(&sessions, "to use the new account"),
351 "Quit them and start again to use the new account."
352 );
353
354 let one = classify(&[at(1, "tool")], HOLDERS);
355 assert_eq!(described(&one), "1 `tool` session");
356 assert_eq!(
357 remedies(&one, "to go on"),
358 "Quit it and start again to go on."
359 );
360
361 let all = classify(
362 &[
363 at(1, "tool"),
364 at(2, "/Applications/App.app/x/tool"),
365 at(3, "/e/vendor.tool-1/bin/tool"),
366 ],
367 HOLDERS,
368 );
369 assert_eq!(
370 described(&all),
371 "the App, the extension and 1 `tool` session"
372 );
373 assert_eq!(
374 remedies(&all, "to use the new account"),
375 "To use the new account: quit App with Command-Q and open it again; reload the \
376 editor's window; quit the `tool` session and start it again."
377 );
378 }
379
380 #[test]
381 fn a_command_is_said_as_one() {
382 let holding = classify(
383 &[at(1, "/x/daemon/tool")],
384 &[Holder {
385 kind: "daemon",
386 noun: Noun::One("the daemon"),
387 location: Location::Within("daemon"),
388 remedy: Remedy::Run("tool daemon restart"),
389 }],
390 );
391 assert_eq!(
392 remedies(&holding, "to take a switch"),
393 "Run `tool daemon restart` to take a switch."
394 );
395 }
396
397 #[test]
398 fn some_pids_stand_for_many() {
399 assert_eq!(some_of(&[4321, 99]), "4321, 99");
400 let many: Vec<u32> = (1..=23).collect();
401 assert_eq!(some_of(&many), "1, 2, 3 and 20 more");
402 }
403}