use crate::context::Context;
use crate::host::{OS, 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 {
app: AppId,
name: &'static str,
},
Run(&'static str),
Do(&'static str),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum AppId {
MacBundle(&'static str),
}
impl AppId {
pub fn as_str(self) -> &'static str {
match self {
AppId::MacBundle(id) => id,
}
}
fn reopen(self, name: &str) -> String {
match self {
AppId::MacBundle(_) => format!("quit {name} with Command-Q and open it again"),
}
}
}
#[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| OS.same_path(Path::new(dir), Path::new(name)))
}
Location::WithinPrefixed(prefix) => directories.any(|dir| {
let head: String = dir
.to_string_lossy()
.chars()
.take(prefix.chars().count())
.collect();
OS.same_path(Path::new(&head), Path::new(prefix))
}),
}
}
}
pub(crate) 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 { app, name } => app.reopen(name),
Remedy::Run(command) => format!("run `{command}`"),
Remedy::Do(instruction) => instruction.to_string(),
}
}
}
pub(crate) fn described(holding: &[Holding]) -> String {
crate::words::listed(holding.iter().map(Holding::phrase).collect())
}
pub(crate) fn described_with_pids(holding: &[Holding]) -> String {
crate::words::listed(
holding
.iter()
.map(|h| format!("{} (pid {})", h.phrase(), some_of(&h.pids)))
.collect(),
)
}
pub(crate) fn plural(holding: &[Holding]) -> bool {
holding.iter().map(Holding::things).sum::<usize>() > 1
}
pub(crate) 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("; ")
),
}
}
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 {
app: AppId::MacBundle("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 a_place_is_named_as_this_system_compares_paths() {
let holding = classify(
&[
at(1, "/Applications/app.app/x/tool"),
at(2, "/e/Vendor.Tool-1/bin/tool"),
],
HOLDERS,
);
match OS {
crate::host::Os::MacOs | crate::host::Os::Linux => {
assert_eq!(kinds(&holding), [("session", vec![1, 2])]);
}
crate::host::Os::Windows => {
assert_eq!(kinds(&holding), [("app", vec![1]), ("extension", vec![2])]);
}
}
assert!(Location::WithinPrefixed("vendor.tool-").holds(Path::new("/e/vendor.tool-1/t")));
assert!(
!Location::WithinPrefixed("vendor.tool-").holds(Path::new("/e/vendor.tool/t")),
"a name shorter than the prefix does not start with it"
);
}
#[test]
fn an_app_is_named_and_quit_its_own_system_s_way() {
let mac = AppId::MacBundle("com.example.app");
assert_eq!(mac.as_str(), "com.example.app");
assert_eq!(
mac.reopen("App"),
"quit App with Command-Q and open it again"
);
let app = classify(&[at(1, "/Applications/App.app/x/tool")], HOLDERS);
assert_eq!(
app.first().map(|h| h.holder.remedy),
Some(Remedy::ReopenApp {
app: mac,
name: "App"
})
);
}
#[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");
}
}