use crate::constants::{
CONFIG_DIR_NAME, CONFIG_FILE_NAME, CURRENT_CONFIG_VERSION, DEFAULT_PROFILE_NAME,
};
use crate::error::{AppError, Result};
use crate::monitor::bluetooth::list_bluetooth_devices;
use crate::monitor::usb::list_usb_devices;
use crate::monitor::{DeviceIdentifier, KeyboardMapping};
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::{self, Write};
use std::path::PathBuf;
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct LegacyConfig {
pub keyboard_id: u16,
pub product_profile: String,
pub default_profile: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct Config {
#[serde(default = "default_version")]
pub version: u8,
pub default_profile: String,
pub keyboards: Vec<KeyboardMapping>,
}
fn default_version() -> u8 {
CURRENT_CONFIG_VERSION
}
impl Config {
pub fn new(default_profile: impl Into<String>, keyboards: Vec<KeyboardMapping>) -> Self {
Config {
version: CURRENT_CONFIG_VERSION,
default_profile: default_profile.into(),
keyboards,
}
}
pub fn validate(&self) -> Vec<String> {
let mut warnings = Vec::new();
for (i, mapping1) in self.keyboards.iter().enumerate() {
for mapping2 in self.keyboards.iter().skip(i + 1) {
if mapping1.device.matches(&mapping2.device) {
warnings.push(format!(
"Duplicate device mapping detected: '{}' and '{}' match the same device",
mapping1.name, mapping2.name
));
}
}
}
if self.keyboards.is_empty() {
warnings.push("No keyboard mappings configured".to_string());
}
if self.default_profile.trim().is_empty() {
warnings.push("Default profile name is empty".to_string());
}
for mapping in &self.keyboards {
if mapping.profile.trim().is_empty() {
warnings.push(format!(
"Keyboard '{}' has an empty profile name",
mapping.name
));
}
if mapping.name.trim().is_empty() {
warnings.push("A keyboard mapping has an empty name".to_string());
}
if let DeviceIdentifier::Bluetooth { device_name } = &mapping.device
&& device_name.trim().is_empty()
{
warnings.push(format!(
"Keyboard '{}' has an empty Bluetooth device name (would match every Bluetooth device)",
mapping.name
));
}
}
warnings
}
fn parse_config_str(contents: &str) -> Result<Self> {
if let Ok(config) = serde_yaml::from_str::<Config>(contents) {
if config.version > CURRENT_CONFIG_VERSION {
return Err(AppError::Config(format!(
"Unsupported config version {} (this build supports up to v{}). Please upgrade kaps.",
config.version, CURRENT_CONFIG_VERSION
)));
}
return Ok(config);
}
if let Ok(legacy) = serde_yaml::from_str::<LegacyConfig>(contents) {
return Ok(Self::from_legacy(legacy));
}
Err(AppError::Config(
"Failed to parse config file. Invalid format.".to_string(),
))
}
pub fn load() -> Result<Self> {
let config_path = Self::get_config_path()?;
let contents = fs::read_to_string(&config_path)
.map_err(|e| AppError::Config(format!("Failed to read config file: {}", e)))?;
Self::parse_config_str(&contents)
}
pub fn from_legacy(legacy: LegacyConfig) -> Self {
let mapping = KeyboardMapping::new(
"USB Keyboard",
DeviceIdentifier::usb(legacy.keyboard_id),
legacy.product_profile,
);
Config {
version: CURRENT_CONFIG_VERSION,
default_profile: legacy.default_profile,
keyboards: vec![mapping],
}
}
pub fn save(&self) -> Result<()> {
let config_path = Self::get_config_path()?;
let yaml_content = serde_yaml::to_string(self)?;
fs::write(config_path, yaml_content)
.map_err(|e| AppError::Config(format!("Failed to write config file: {}", e)))?;
Ok(())
}
pub fn get_config_path() -> Result<PathBuf> {
let home_dir = dirs::home_dir().ok_or(AppError::HomeDirectoryNotFound)?;
let mut path = home_dir.join(".config").join(CONFIG_DIR_NAME);
fs::create_dir_all(&path)
.map_err(|e| AppError::Config(format!("Failed to create config directory: {}", e)))?;
path.push(CONFIG_FILE_NAME);
Ok(path)
}
pub fn create_interactively() -> Result<Self> {
println!("Configuration file not found. Let's create one!");
println!();
println!("=== Available USB devices ===");
list_usb_devices();
println!();
println!("=== Available Bluetooth devices ===");
if let Err(e) = list_bluetooth_devices() {
println!(" Could not list Bluetooth devices: {}", e);
}
println!();
let default_profile = Self::prompt_for_default_profile()?;
let mut keyboards = Vec::new();
loop {
println!();
let mapping = Self::prompt_for_keyboard_mapping()?;
keyboards.push(mapping);
let answer = Self::prompt_line("Add another keyboard? (y/N): ")?;
if !answer.to_lowercase().starts_with('y') {
break;
}
}
let config = Config::new(default_profile, keyboards);
config.save()?;
println!();
println!("Configuration saved successfully!");
println!("Config file location: {:?}", Self::get_config_path()?);
println!();
Ok(config)
}
fn prompt_line(prompt: &str) -> Result<String> {
print!("{}", prompt);
io::stdout().flush().map_err(AppError::Io)?;
let mut input = String::new();
io::stdin().read_line(&mut input).map_err(AppError::Io)?;
Ok(input.trim().to_string())
}
fn prompt_nonempty(prompt: &str, empty_message: &str) -> Result<String> {
loop {
let value = Self::prompt_line(prompt)?;
if !value.is_empty() {
return Ok(value);
}
println!("{}", empty_message);
}
}
fn prompt_for_keyboard_mapping() -> Result<KeyboardMapping> {
println!("Device type:");
println!(" 1. USB keyboard");
println!(" 2. Bluetooth keyboard");
let device = match Self::prompt_line("Select (1 or 2): ")?.as_str() {
"1" => DeviceIdentifier::usb(Self::prompt_for_usb_product_id()?),
"2" => DeviceIdentifier::bluetooth(Self::prompt_for_bluetooth_name()?),
_ => {
println!("Invalid selection, defaulting to USB.");
DeviceIdentifier::usb(Self::prompt_for_usb_product_id()?)
}
};
let name_input =
Self::prompt_line("Enter a name for this keyboard (e.g., 'Work Keyboard'): ")?;
let name = if name_input.is_empty() {
"Keyboard".to_string()
} else {
name_input
};
let profile = Self::prompt_for_product_profile()?;
Ok(KeyboardMapping::new(name, device, profile))
}
fn prompt_for_usb_product_id() -> Result<u16> {
loop {
let input = Self::prompt_line("Enter the USB keyboard product ID: ")?;
match input.parse::<u16>() {
Ok(id) => return Ok(id),
Err(_) => println!("Please enter a valid number."),
}
}
}
fn prompt_for_bluetooth_name() -> Result<String> {
Self::prompt_nonempty(
"Enter the Bluetooth device name: ",
"Device name cannot be empty.",
)
}
fn prompt_for_product_profile() -> Result<String> {
Self::prompt_nonempty(
"Enter the Karabiner-Elements profile name for this keyboard: ",
"Profile name cannot be empty.",
)
}
fn prompt_for_default_profile() -> Result<String> {
let input = Self::prompt_line(&format!(
"Enter the default Karabiner-Elements profile name [{}]: ",
DEFAULT_PROFILE_NAME
))?;
Ok(if input.is_empty() {
DEFAULT_PROFILE_NAME.to_string()
} else {
input
})
}
pub fn from_cli_args(
keyboard_id: u16,
product_profile: String,
default_profile: Option<String>,
) -> Self {
let mapping = KeyboardMapping::new(
"USB Keyboard",
DeviceIdentifier::usb(keyboard_id),
product_profile,
);
Config::new(
default_profile.unwrap_or_else(|| DEFAULT_PROFILE_NAME.to_string()),
vec![mapping],
)
}
#[cfg(test)]
pub fn load_from_path(path: &PathBuf) -> Result<Self> {
let contents = fs::read_to_string(path)
.map_err(|e| AppError::Config(format!("Failed to read config file: {}", e)))?;
Self::parse_config_str(&contents)
}
#[cfg(test)]
pub fn save_to_path(&self, path: &PathBuf) -> Result<()> {
let yaml_content = serde_yaml::to_string(self)?;
fs::write(path, yaml_content)
.map_err(|e| AppError::Config(format!("Failed to write config file: {}", e)))?;
Ok(())
}
}
pub fn resolve_config(
keyboard_id: Option<u16>,
product_profile: Option<String>,
default_profile: Option<String>,
) -> Result<Config> {
let config_path = Config::get_config_path()?;
if config_path.exists() {
return Config::load();
}
if let (Some(id), Some(profile)) = (keyboard_id, product_profile) {
Ok(Config::from_cli_args(id, profile, default_profile))
} else {
Config::create_interactively()
}
}
#[cfg(test)]
mod tests;