Skip to main content

pitboard_core/
holder.rs

1//! What keeps a tool's login in memory while it runs, and what makes each kind take a switch.
2//!
3//! A tool whose running sessions never notice a switch (`Adoption::RestartRequired`) names
4//! the kinds of process that run its program. One program can run in very different
5//! places: Codex's runs in a terminal, inside OpenAI's ChatGPT app, as a background app
6//! server and inside an editor's Codex extension. Each takes a switch its own way, and
7//! advice meant for one is wrong for another: quitting a terminal session does nothing for
8//! an app that stays open in the menu bar after its windows close.
9//!
10//! The kinds are told apart by where the program runs from, which the process list says.
11//! Nothing here names a tool; a tool's own list does, most particular kind first and ending
12//! with one that is anywhere, so every process is one kind and none is guessed to be an app.
13
14use crate::context::Context;
15use crate::process::Process;
16use std::path::Path;
17
18/// One kind of process that holds a tool's login.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub struct Holder {
21    /// A stable name in snake case, for a program to tell kinds apart by.
22    pub kind: &'static str,
23    /// How a sentence names what is running.
24    pub noun: Noun,
25    /// Where its program runs from.
26    pub location: Location,
27    /// What makes it take a switch.
28    pub remedy: Remedy,
29}
30
31/// How a sentence names one kind of holder.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum Noun {
34    /// Each process is one of them, counted: "1 `codex` session", "2 `codex` sessions".
35    Counted {
36        one: &'static str,
37        many: &'static str,
38    },
39    /// One thing however many processes it runs: an app that starts two of the program is
40    /// still one app.
41    One(&'static str),
42}
43
44/// Where a kind of holder runs its program from.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum Location {
47    /// Inside a directory of exactly this name, such as an app bundle.
48    Within(&'static str),
49    /// Inside a directory whose name starts with this, such as an editor extension's folder,
50    /// which is named for its version.
51    WithinPrefixed(&'static str),
52    /// Anywhere at all: the kind every process no other kind claims is.
53    Anywhere,
54}
55
56/// What makes a kind of holder take a switch.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum Remedy {
59    /// Quit it and start it again.
60    Restart,
61    /// Quit the app the way Command-Q does, and open it again. Closing its windows is not
62    /// enough. The menu bar app can do both for the person; the command line only says so.
63    ReopenApp {
64        bundle_id: &'static str,
65        name: &'static str,
66    },
67    /// Run this command.
68    Run(&'static str),
69    /// Do this, somewhere pitboard cannot reach.
70    Do(&'static str),
71}
72
73/// The processes of one kind of holder that are running.
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct Holding {
76    pub holder: Holder,
77    /// Their process ids, in the order the process list gave them.
78    pub pids: Vec<u32>,
79}
80
81impl Location {
82    /// Whether a program at `path` runs from here. Only the directories it is in are
83    /// looked at, never the program's own name.
84    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
96/// `processes` sorted into `holders`, each to the first whose location holds it, in the
97/// holders' order. A kind with nothing running is left out, and so is a process no holder
98/// claims, which a list ending in [`Location::Anywhere`] never leaves.
99pub 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
119/// What is running `program` on this machine, by kind. `None` where the process list could
120/// not be read, which is not the same as nothing running.
121pub(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    /// How many things are running: processes for a counted kind, one for the rest.
129    fn things(&self) -> usize {
130        match self.holder.noun {
131            Noun::Counted { .. } => self.pids.len(),
132            Noun::One(_) => 1,
133        }
134    }
135
136    /// "2 `codex` sessions", "the ChatGPT app".
137    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    /// What to do, as a clause. Said alone it can say "it" or "them"; beside others it has
148    /// to name what it is about.
149    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
171/// Everything running, as one noun phrase: "2 `codex` sessions and the ChatGPT app".
172pub fn described(holding: &[Holding]) -> String {
173    listed(holding.iter().map(Holding::phrase).collect())
174}
175
176/// The same, with the pids of each: "2 `codex` sessions (pid 41, 42)".
177pub 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
186/// Whether what is running reads as more than one thing, for "is" or "are".
187pub fn plural(holding: &[Holding]) -> bool {
188    holding.iter().map(Holding::things).sum::<usize>() > 1
189}
190
191/// One sentence saying what makes everything running take a switch, with `purpose`:
192/// "Quit them and start again to use the new account." Several kinds are each said in turn,
193/// apart, since one clause can have an "and" of its own: "To use the new account: quit the
194/// `codex` sessions and start them again; quit ChatGPT with Command-Q and open it again."
195pub 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
210/// "a", "a and b", "a, b and c".
211fn 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
222/// `text` with its first letter a capital, to begin a sentence with.
223pub(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
230/// A few pids and how many more, because somebody with twenty sessions open needs to know
231/// there are twenty, not which twenty.
232pub(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    /// The program's own name is not where it runs from: a program that happens to be
311    /// called like a place is still wherever it is.
312    #[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    /// Alone, a kind's advice is what it always was. Beside others, each names what it is
343    /// about, and the purpose leads.
344    #[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}