use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum AppCapability {
Chat,
Email,
Calendar,
CodeEditor,
Browser,
Terminal,
FileManager,
Custom(String),
}
#[derive(Debug, Clone)]
pub struct AppProfile {
pub name: String,
pub app_id: String,
pub cdp_port: Option<u16>,
pub capabilities: Vec<AppCapability>,
pub selectors: HashMap<String, String>,
pub shortcuts: HashMap<String, String>,
}
impl AppProfile {
#[must_use]
pub fn builder(name: impl Into<String>, app_id: impl Into<String>) -> AppProfileBuilder {
AppProfileBuilder::new(name, app_id)
}
#[must_use]
pub fn has_capability(&self, cap: &AppCapability) -> bool {
self.capabilities.contains(cap)
}
}
#[derive(Debug)]
pub struct AppProfileBuilder {
profile: AppProfile,
}
impl AppProfileBuilder {
fn new(name: impl Into<String>, app_id: impl Into<String>) -> Self {
Self {
profile: AppProfile {
name: name.into(),
app_id: app_id.into(),
cdp_port: None,
capabilities: Vec::new(),
selectors: HashMap::new(),
shortcuts: HashMap::new(),
},
}
}
#[must_use]
pub fn cdp_port(mut self, port: u16) -> Self {
self.profile.cdp_port = Some(port);
self
}
#[must_use]
pub fn capability(mut self, cap: AppCapability) -> Self {
self.profile.capabilities.push(cap);
self
}
#[must_use]
pub fn selector(mut self, name: impl Into<String>, css: impl Into<String>) -> Self {
self.profile.selectors.insert(name.into(), css.into());
self
}
#[must_use]
pub fn shortcut(mut self, action: impl Into<String>, keys: impl Into<String>) -> Self {
self.profile.shortcuts.insert(action.into(), keys.into());
self
}
#[must_use]
pub fn build(self) -> AppProfile {
self.profile
}
}
#[derive(Debug, Default)]
pub struct ProfileRegistry {
profiles: HashMap<String, AppProfile>,
}
impl ProfileRegistry {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_builtins() -> Self {
let mut reg = Self::new();
for profile in builtin_profiles() {
reg.register(profile);
}
reg
}
pub fn register(&mut self, profile: AppProfile) {
let key = normalise_name(&profile.name);
self.profiles.insert(key, profile);
}
#[must_use]
pub fn detect(&self, app_name: &str) -> Option<&AppProfile> {
self.profiles.get(&normalise_name(app_name))
}
#[must_use]
pub fn find_by_capability(&self, cap: &AppCapability) -> Vec<&AppProfile> {
self.profiles
.values()
.filter(|p| p.has_capability(cap))
.collect()
}
#[must_use]
pub fn get_selector(&self, app_name: &str, semantic_name: &str) -> Option<&str> {
self.detect(app_name)
.and_then(|p| p.selectors.get(semantic_name))
.map(String::as_str)
}
#[must_use]
pub fn get_shortcut(&self, app_name: &str, action: &str) -> Option<&str> {
self.detect(app_name)
.and_then(|p| p.shortcuts.get(action))
.map(String::as_str)
}
#[must_use]
pub fn len(&self) -> usize {
self.profiles.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.profiles.is_empty()
}
}
#[must_use]
pub fn builtin_profiles() -> Vec<AppProfile> {
vec![
vscode_profile(),
slack_profile(),
chrome_profile(),
terminal_profile(),
finder_profile(),
]
}
fn vscode_profile() -> AppProfile {
AppProfile::builder("VS Code", "com.microsoft.VSCode")
.cdp_port(9222)
.capability(AppCapability::CodeEditor)
.capability(AppCapability::Terminal)
.selector("editor_tab", ".tab.active")
.selector("editor_input", ".monaco-editor .inputarea")
.selector("problems_panel", "#workbench.panel.markers")
.selector("terminal_input", ".xterm-helper-textarea")
.selector("sidebar_explorer", ".explorer-viewlet")
.selector("quick_open_input", ".quick-input-widget .input")
.selector("status_bar", "#workbench.parts.statusbar")
.shortcut("command_palette", "Meta+Shift+P")
.shortcut("quick_open", "Meta+P")
.shortcut("new_terminal", "Ctrl+`")
.shortcut("toggle_sidebar", "Meta+B")
.shortcut("find_in_files", "Meta+Shift+F")
.shortcut("go_to_line", "Ctrl+G")
.shortcut("save", "Meta+S")
.shortcut("close_editor", "Meta+W")
.build()
}
fn slack_profile() -> AppProfile {
AppProfile::builder("Slack", "com.tinyspeck.slackmacgap")
.capability(AppCapability::Chat)
.selector("message_input", "[data-qa='message_input']")
.selector("send_button", "[data-qa='texty_send_btn']")
.selector("channel_list", "[data-qa='channel_sidebar']")
.selector("search_input", "[data-qa='search_input']")
.selector("channel_header", "[data-qa='channel_name']")
.selector("message_list", "[data-qa='message_list']")
.selector("thread_input", "[data-qa='thread_input']")
.shortcut("new_message", "Meta+K")
.shortcut("search", "Meta+G")
.shortcut("mark_all_read", "Shift+Esc")
.shortcut("next_unread", "Alt+Shift+Down")
.build()
}
fn chrome_profile() -> AppProfile {
AppProfile::builder("Chrome", "com.google.Chrome")
.capability(AppCapability::Browser)
.selector("address_bar", "#omnibox-input")
.selector("new_tab_button", ".new-tab-button")
.selector("tab_strip", ".tab-strip")
.selector("active_tab", ".tab.active")
.selector("back_button", "#back-button")
.selector("forward_button", "#forward-button")
.selector("reload_button", "#reload-button")
.shortcut("new_tab", "Meta+T")
.shortcut("close_tab", "Meta+W")
.shortcut("reopen_tab", "Meta+Shift+T")
.shortcut("address_bar", "Meta+L")
.shortcut("find_in_page", "Meta+F")
.shortcut("developer_tools", "Meta+Alt+I")
.shortcut("downloads", "Meta+Shift+J")
.build()
}
fn terminal_profile() -> AppProfile {
AppProfile::builder("Terminal", "com.apple.Terminal")
.capability(AppCapability::Terminal)
.selector("terminal_view", ".xterm-screen")
.selector("terminal_input", ".xterm-helper-textarea")
.shortcut("new_window", "Meta+N")
.shortcut("new_tab", "Meta+T")
.shortcut("close_tab", "Meta+W")
.shortcut("clear", "Meta+K")
.shortcut("find", "Meta+F")
.build()
}
fn finder_profile() -> AppProfile {
AppProfile::builder("Finder", "com.apple.finder")
.capability(AppCapability::FileManager)
.selector("sidebar", ".sidebar")
.selector("file_list", ".file-list")
.selector("search_input", "[role='searchbox']")
.selector("path_bar", ".path-bar")
.shortcut("new_window", "Meta+N")
.shortcut("new_folder", "Meta+Shift+N")
.shortcut("search", "Meta+F")
.shortcut("go_home", "Meta+Shift+H")
.shortcut("go_to_folder", "Meta+Shift+G")
.shortcut("show_info", "Meta+I")
.build()
}
fn normalise_name(name: &str) -> String {
name.chars()
.filter(|c| !matches!(c, '-' | '_' | ' ' | '\t'))
.collect::<String>()
.to_lowercase()
}
#[cfg(test)]
mod tests {
use super::*;
fn minimal_profile(name: &str) -> AppProfile {
AppProfile::builder(name, format!("com.example.{}", name.to_lowercase())).build()
}
#[test]
fn normalise_name_lowercases_and_strips_spaces() {
assert_eq!(normalise_name("VS Code"), "vscode");
}
#[test]
fn normalise_name_strips_hyphens() {
assert_eq!(normalise_name("vs-code"), "vscode");
}
#[test]
fn normalise_name_strips_underscores() {
assert_eq!(normalise_name("vs_code"), "vscode");
}
#[test]
fn normalise_name_strips_leading_trailing_whitespace() {
assert_eq!(normalise_name(" Slack "), "slack");
}
#[test]
fn register_and_detect_profile_by_exact_name() {
let mut registry = ProfileRegistry::new();
registry.register(minimal_profile("Notion"));
let found = registry.detect("Notion");
assert!(found.is_some());
assert_eq!(found.unwrap().name, "Notion");
}
#[test]
fn detect_is_case_insensitive() {
let mut registry = ProfileRegistry::new();
registry.register(minimal_profile("Slack"));
let found = registry.detect("SLACK");
assert!(found.is_some());
}
#[test]
fn detect_unknown_app_returns_none() {
let registry = ProfileRegistry::new();
let found = registry.detect("NonExistentApp");
assert!(found.is_none());
}
#[test]
fn register_overwrites_existing_profile() {
let mut registry = ProfileRegistry::new();
registry.register(minimal_profile("App"));
let updated = AppProfile::builder("App", "com.example.updated").build();
registry.register(updated);
let found = registry.detect("App").unwrap();
assert_eq!(found.app_id, "com.example.updated");
}
#[test]
fn builtin_profiles_includes_vscode() {
let reg = ProfileRegistry::with_builtins();
assert!(reg.detect("VS Code").is_some());
assert!(reg.detect("vscode").is_some());
assert!(reg.detect("vs-code").is_some());
}
#[test]
fn builtin_profiles_includes_slack() {
let reg = ProfileRegistry::with_builtins();
assert!(reg.detect("Slack").is_some());
}
#[test]
fn builtin_profiles_includes_chrome() {
let reg = ProfileRegistry::with_builtins();
assert!(reg.detect("Chrome").is_some());
}
#[test]
fn builtin_profiles_includes_terminal_and_finder() {
let reg = ProfileRegistry::with_builtins();
assert!(reg.detect("Terminal").is_some());
assert!(reg.detect("Finder").is_some());
}
#[test]
fn builtin_profiles_five_entries_total() {
let reg = ProfileRegistry::with_builtins();
assert_eq!(reg.len(), 5);
}
#[test]
fn vscode_has_code_editor_and_terminal_capabilities() {
let reg = ProfileRegistry::with_builtins();
let vscode = reg.detect("vscode").unwrap();
assert!(vscode.has_capability(&AppCapability::CodeEditor));
assert!(vscode.has_capability(&AppCapability::Terminal));
}
#[test]
fn vscode_does_not_have_chat_capability() {
let reg = ProfileRegistry::with_builtins();
let vscode = reg.detect("vscode").unwrap();
assert!(!vscode.has_capability(&AppCapability::Chat));
}
#[test]
fn slack_has_chat_capability() {
let reg = ProfileRegistry::with_builtins();
let slack = reg.detect("slack").unwrap();
assert!(slack.has_capability(&AppCapability::Chat));
}
#[test]
fn chrome_has_browser_capability() {
let reg = ProfileRegistry::with_builtins();
let chrome = reg.detect("chrome").unwrap();
assert!(chrome.has_capability(&AppCapability::Browser));
}
#[test]
fn vscode_profile_has_cdp_port_9222() {
let reg = ProfileRegistry::with_builtins();
let vscode = reg.detect("vscode").unwrap();
assert_eq!(vscode.cdp_port, Some(9222));
}
#[test]
fn profile_without_cdp_port_is_none() {
let reg = ProfileRegistry::with_builtins();
let slack = reg.detect("slack").unwrap();
assert!(slack.cdp_port.is_none());
}
#[test]
fn get_selector_returns_css_for_known_name() {
let reg = ProfileRegistry::with_builtins();
let sel = reg.get_selector("slack", "message_input");
assert_eq!(sel, Some("[data-qa='message_input']"));
}
#[test]
fn get_selector_returns_none_for_unknown_semantic_name() {
let reg = ProfileRegistry::with_builtins();
let sel = reg.get_selector("vscode", "nonexistent_selector");
assert!(sel.is_none());
}
#[test]
fn get_selector_returns_none_for_unknown_app() {
let reg = ProfileRegistry::with_builtins();
let sel = reg.get_selector("nonexistent_app", "anything");
assert!(sel.is_none());
}
#[test]
fn get_selector_vscode_editor_tab() {
let reg = ProfileRegistry::with_builtins();
let sel = reg.get_selector("vscode", "editor_tab");
assert_eq!(sel, Some(".tab.active"));
}
#[test]
fn get_shortcut_returns_keybinding_for_known_action() {
let reg = ProfileRegistry::with_builtins();
let sc = reg.get_shortcut("vscode", "command_palette");
assert_eq!(sc, Some("Meta+Shift+P"));
}
#[test]
fn get_shortcut_returns_none_for_unknown_action() {
let reg = ProfileRegistry::with_builtins();
let sc = reg.get_shortcut("vscode", "nonexistent_action");
assert!(sc.is_none());
}
#[test]
fn get_shortcut_returns_none_for_unknown_app() {
let reg = ProfileRegistry::with_builtins();
let sc = reg.get_shortcut("not_an_app", "save");
assert!(sc.is_none());
}
#[test]
fn get_shortcut_slack_new_message() {
let reg = ProfileRegistry::with_builtins();
let sc = reg.get_shortcut("slack", "new_message");
assert_eq!(sc, Some("Meta+K"));
}
#[test]
fn find_by_capability_chat_returns_slack() {
let reg = ProfileRegistry::with_builtins();
let apps = reg.find_by_capability(&AppCapability::Chat);
assert!(apps.iter().any(|p| p.name == "Slack"));
}
#[test]
fn find_by_capability_code_editor_returns_vscode() {
let reg = ProfileRegistry::with_builtins();
let apps = reg.find_by_capability(&AppCapability::CodeEditor);
assert!(apps.iter().any(|p| p.name == "VS Code"));
}
#[test]
fn find_by_capability_file_manager_returns_finder() {
let reg = ProfileRegistry::with_builtins();
let apps = reg.find_by_capability(&AppCapability::FileManager);
assert!(apps.iter().any(|p| p.name == "Finder"));
}
#[test]
fn find_by_capability_email_returns_empty_for_builtins() {
let reg = ProfileRegistry::with_builtins();
let apps = reg.find_by_capability(&AppCapability::Email);
assert!(apps.is_empty());
}
#[test]
fn custom_capability_survives_round_trip() {
let mut registry = ProfileRegistry::new();
let profile = AppProfile::builder("Figma", "com.figma.Desktop")
.capability(AppCapability::Custom("Design".into()))
.build();
registry.register(profile);
let found = registry.detect("figma").unwrap();
assert!(found.has_capability(&AppCapability::Custom("Design".into())));
assert!(!found.has_capability(&AppCapability::Custom("Other".into())));
}
#[test]
fn builder_constructs_profile_with_all_fields() {
let profile = AppProfile::builder("TestApp", "com.test.App")
.cdp_port(8888)
.capability(AppCapability::Browser)
.selector("nav", "nav.main")
.shortcut("quit", "Meta+Q")
.build();
assert_eq!(profile.name, "TestApp");
assert_eq!(profile.app_id, "com.test.App");
assert_eq!(profile.cdp_port, Some(8888));
assert!(profile.has_capability(&AppCapability::Browser));
assert_eq!(
profile.selectors.get("nav").map(String::as_str),
Some("nav.main")
);
assert_eq!(
profile.shortcuts.get("quit").map(String::as_str),
Some("Meta+Q")
);
}
#[test]
fn empty_registry_is_empty_and_len_zero() {
let reg = ProfileRegistry::new();
assert!(reg.is_empty());
assert_eq!(reg.len(), 0);
}
#[test]
fn registry_len_increments_on_register() {
let mut reg = ProfileRegistry::new();
assert_eq!(reg.len(), 0);
reg.register(minimal_profile("A"));
reg.register(minimal_profile("B"));
assert_eq!(reg.len(), 2);
assert!(!reg.is_empty());
}
}