use crate::context::Context;
use crate::process::Process;
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Holder {
pub kind: &'static str,
pub noun: Noun,
pub location: Location,
pub remedy: Remedy,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Noun {
Counted {
one: &'static str,
many: &'static str,
},
One(&'static str),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Location {
Within(&'static str),
WithinPrefixed(&'static str),
Anywhere,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Remedy {
Restart,
ReopenApp {
bundle_id: &'static str,
name: &'static str,
},
Run(&'static str),
Do(&'static str),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Holding {
pub holder: Holder,
pub pids: Vec<u32>,
}
impl Location {
fn holds(self, path: &Path) -> bool {
let mut directories = path.ancestors().skip(1).filter_map(Path::file_name);
match self {
Location::Anywhere => true,
Location::Within(name) => directories.any(|dir| dir == name),
Location::WithinPrefixed(prefix) => {
directories.any(|dir| dir.to_string_lossy().starts_with(prefix))
}
}
}
}
pub fn classify(processes: &[Process], holders: &[Holder]) -> Vec<Holding> {
let mut holding: Vec<Holding> = holders
.iter()
.map(|&holder| Holding {
holder,
pids: Vec::new(),
})
.collect();
for process in processes {
if let Some(found) = holding
.iter_mut()
.find(|h| h.holder.location.holds(&process.path))
{
found.pids.push(process.pid);
}
}
holding.retain(|h| !h.pids.is_empty());
holding
}
pub(crate) fn find(ctx: &Context, program: &str, holders: &[Holder]) -> Option<Vec<Holding>> {
ctx.host()
.processes(program)
.map(|processes| classify(&processes, holders))
}
impl Holding {
fn things(&self) -> usize {
match self.holder.noun {
Noun::Counted { .. } => self.pids.len(),
Noun::One(_) => 1,
}
}
pub fn phrase(&self) -> String {
match self.holder.noun {
Noun::Counted { one, many } => match self.pids.len() {
1 => format!("1 {one}"),
n => format!("{n} {many}"),
},
Noun::One(name) => name.to_string(),
}
}
fn clause(&self, alone: bool) -> String {
let them = if self.things() == 1 { "it" } else { "them" };
match self.holder.remedy {
Remedy::Restart if alone => format!("quit {them} and start again"),
Remedy::Restart => {
let what = match self.holder.noun {
Noun::Counted { one, many } => {
format!("the {}", if self.things() == 1 { one } else { many })
}
Noun::One(name) => name.to_string(),
};
format!("quit {what} and start {them} again")
}
Remedy::ReopenApp { name, .. } => {
format!("quit {name} with Command-Q and open it again")
}
Remedy::Run(command) => format!("run `{command}`"),
Remedy::Do(instruction) => instruction.to_string(),
}
}
}
pub fn described(holding: &[Holding]) -> String {
listed(holding.iter().map(Holding::phrase).collect())
}
pub fn described_with_pids(holding: &[Holding]) -> String {
listed(
holding
.iter()
.map(|h| format!("{} (pid {})", h.phrase(), some_of(&h.pids)))
.collect(),
)
}
pub fn plural(holding: &[Holding]) -> bool {
holding.iter().map(Holding::things).sum::<usize>() > 1
}
pub fn remedies(holding: &[Holding], purpose: &str) -> String {
match holding {
[] => String::new(),
[one] => format!("{} {purpose}.", capitalised(&one.clause(true))),
many => format!(
"{}: {}.",
capitalised(purpose),
many.iter()
.map(|h| h.clause(false))
.collect::<Vec<_>>()
.join("; ")
),
}
}
fn listed(mut items: Vec<String>) -> String {
match items.len() {
0 => String::new(),
1 => items.remove(0),
_ => {
let last = items.pop().unwrap_or_default();
format!("{} and {last}", items.join(", "))
}
}
}
pub(crate) fn capitalised(text: &str) -> String {
let mut chars = text.chars();
chars.next().map_or_else(String::new, |first| {
first.to_uppercase().chain(chars).collect()
})
}
pub(crate) fn some_of(pids: &[u32]) -> String {
const SHOWN: usize = 3;
let named: Vec<String> = pids.iter().take(SHOWN).map(u32::to_string).collect();
match pids.len().saturating_sub(SHOWN) {
0 => named.join(", "),
more => format!("{} and {more} more", named.join(", ")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
const APP: Holder = Holder {
kind: "app",
noun: Noun::One("the App"),
location: Location::Within("App.app"),
remedy: Remedy::ReopenApp {
bundle_id: "com.example.app",
name: "App",
},
};
const EXTENSION: Holder = Holder {
kind: "extension",
noun: Noun::One("the extension"),
location: Location::WithinPrefixed("vendor.tool-"),
remedy: Remedy::Do("reload the editor's window"),
};
const SESSION: Holder = Holder {
kind: "session",
noun: Noun::Counted {
one: "`tool` session",
many: "`tool` sessions",
},
location: Location::Anywhere,
remedy: Remedy::Restart,
};
const HOLDERS: &[Holder] = &[APP, EXTENSION, SESSION];
fn at(pid: u32, path: &str) -> Process {
Process {
pid,
path: PathBuf::from(path),
}
}
fn kinds(holding: &[Holding]) -> Vec<(&str, Vec<u32>)> {
holding
.iter()
.map(|h| (h.holder.kind, h.pids.clone()))
.collect()
}
#[test]
fn each_process_is_the_first_kind_whose_place_holds_it() {
let holding = classify(
&[
at(1, "tool"),
at(
2,
"/Applications/App.app/Contents/Helpers/Tool.app/Contents/MacOS/tool",
),
at(3, "/home/a/.editor/extensions/vendor.tool-1.2.3/bin/tool"),
at(4, "/usr/local/bin/tool"),
],
HOLDERS,
);
assert_eq!(
kinds(&holding),
[
("app", vec![2]),
("extension", vec![3]),
("session", vec![1, 4])
]
);
}
#[test]
fn only_the_directories_a_program_is_in_say_where_it_runs() {
let holding = classify(&[at(1, "/usr/bin/App.app")], HOLDERS);
assert_eq!(kinds(&holding), [("session", vec![1])]);
assert!(!Location::WithinPrefixed("vendor.tool-").holds(Path::new("vendor.tool-9")));
}
#[test]
fn nothing_running_is_nothing_held() {
assert!(classify(&[], HOLDERS).is_empty());
assert!(
classify(&[at(1, "tool")], &[APP]).is_empty(),
"and a process no kind claims is left out"
);
}
#[test]
fn an_app_is_one_thing_however_many_processes_it_runs() {
let holding = classify(
&[
at(7, "/Applications/App.app/x/tool"),
at(8, "/Applications/App.app/y/tool"),
],
HOLDERS,
);
assert_eq!(described(&holding), "the App");
assert!(!plural(&holding));
assert_eq!(described_with_pids(&holding), "the App (pid 7, 8)");
}
#[test]
fn what_to_do_is_said_once_for_each_kind() {
let sessions = classify(&[at(1, "tool"), at(2, "tool")], HOLDERS);
assert_eq!(described(&sessions), "2 `tool` sessions");
assert!(plural(&sessions));
assert_eq!(
remedies(&sessions, "to use the new account"),
"Quit them and start again to use the new account."
);
let one = classify(&[at(1, "tool")], HOLDERS);
assert_eq!(described(&one), "1 `tool` session");
assert_eq!(
remedies(&one, "to go on"),
"Quit it and start again to go on."
);
let all = classify(
&[
at(1, "tool"),
at(2, "/Applications/App.app/x/tool"),
at(3, "/e/vendor.tool-1/bin/tool"),
],
HOLDERS,
);
assert_eq!(
described(&all),
"the App, the extension and 1 `tool` session"
);
assert_eq!(
remedies(&all, "to use the new account"),
"To use the new account: quit App with Command-Q and open it again; reload the \
editor's window; quit the `tool` session and start it again."
);
}
#[test]
fn a_command_is_said_as_one() {
let holding = classify(
&[at(1, "/x/daemon/tool")],
&[Holder {
kind: "daemon",
noun: Noun::One("the daemon"),
location: Location::Within("daemon"),
remedy: Remedy::Run("tool daemon restart"),
}],
);
assert_eq!(
remedies(&holding, "to take a switch"),
"Run `tool daemon restart` to take a switch."
);
}
#[test]
fn some_pids_stand_for_many() {
assert_eq!(some_of(&[4321, 99]), "4321, 99");
let many: Vec<u32> = (1..=23).collect();
assert_eq!(some_of(&many), "1, 2, 3 and 20 more");
}
}