use std::{
collections::{HashMap, HashSet},
fmt::Write as _,
fs::{self, File},
io::{Read, Write},
path::{Path, PathBuf},
process::Command,
};
use anyhow::{Context, Result};
use gumdrop::Options;
use schemars::JsonSchema;
use serde::Deserialize;
use serde_yaml::Value;
pub mod browser_host;
pub mod debug;
#[cfg(feature = "wayland")]
pub mod secret;
pub mod terminal;
pub mod ui;
#[derive(Deserialize, JsonSchema, Debug, PartialEq, Clone, Default)]
#[schemars(deny_unknown_fields)]
pub struct RaffiConfig {
pub binary: Option<String>,
pub args: Option<Vec<String>>,
pub icon: Option<String>,
pub description: Option<String>,
pub ifenveq: Option<Vec<String>>,
pub ifenvset: Option<String>,
pub ifenvnotset: Option<String>,
pub ifexist: Option<String>,
pub disabled: Option<bool>,
pub script: Option<String>,
pub show_preview: Option<bool>,
}
#[derive(Deserialize, JsonSchema, Debug, Clone)]
#[schemars(deny_unknown_fields)]
pub struct CurrencyAddonConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub currencies: Option<Vec<String>>,
#[serde(default)]
pub default_currency: Option<String>,
#[serde(default)]
pub trigger: Option<String>,
}
impl Default for CurrencyAddonConfig {
fn default() -> Self {
Self {
enabled: true,
currencies: None,
default_currency: None,
trigger: None,
}
}
}
#[derive(Deserialize, JsonSchema, Debug, Clone)]
#[schemars(deny_unknown_fields)]
pub struct CalculatorAddonConfig {
#[serde(default = "default_true")]
pub enabled: bool,
}
impl Default for CalculatorAddonConfig {
fn default() -> Self {
Self { enabled: true }
}
}
#[derive(Deserialize, JsonSchema, Debug, Clone)]
#[schemars(deny_unknown_fields)]
pub struct UnitsAddonConfig {
#[serde(default = "default_true")]
pub enabled: bool,
}
impl Default for UnitsAddonConfig {
fn default() -> Self {
Self { enabled: true }
}
}
#[derive(Deserialize, JsonSchema, Debug, Clone)]
#[schemars(deny_unknown_fields)]
pub struct ColorAddonConfig {
#[serde(default = "default_true")]
pub enabled: bool,
}
impl Default for ColorAddonConfig {
fn default() -> Self {
Self { enabled: true }
}
}
#[derive(Deserialize, JsonSchema, Debug, Clone)]
#[schemars(deny_unknown_fields)]
pub struct DevtoolsAddonConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub trigger: Option<String>,
#[serde(default)]
#[schemars(range(min = 8, max = 128))]
pub password_length: Option<usize>,
}
impl DevtoolsAddonConfig {
pub fn trigger(&self) -> &str {
self.trigger.as_deref().unwrap_or("dev")
}
pub fn password_length(&self) -> usize {
self.password_length.unwrap_or(20).clamp(8, 128)
}
}
impl Default for DevtoolsAddonConfig {
fn default() -> Self {
Self {
enabled: true,
trigger: None,
password_length: None,
}
}
}
#[derive(Deserialize, JsonSchema, Debug, Clone)]
#[schemars(deny_unknown_fields)]
pub struct WorldClockAddonConfig {
#[serde(default = "default_true")]
pub enabled: bool,
}
impl Default for WorldClockAddonConfig {
fn default() -> Self {
Self { enabled: true }
}
}
#[derive(Deserialize, JsonSchema, Debug, Clone)]
#[schemars(deny_unknown_fields)]
pub struct FileBrowserAddonConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub show_hidden: Option<bool>,
#[serde(default = "default_true")]
pub fido_style: bool,
#[serde(default)]
pub action: Option<String>,
#[serde(default)]
pub secondary_action: Option<String>,
#[serde(default)]
pub file_manager: Option<String>,
#[serde(default)]
pub sort: FileBrowserSort,
#[serde(default)]
pub sort_reverse: bool,
#[serde(default)]
pub show: FileBrowserShow,
#[serde(default)]
pub preview_command: Option<String>,
}
#[derive(Deserialize, JsonSchema, Debug, Clone, Copy, Default, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum FileBrowserSort {
#[default]
Name,
#[serde(alias = "date")]
Modified,
Created,
}
impl FileBrowserSort {
pub fn next(self) -> Self {
match self {
Self::Name => Self::Modified,
Self::Modified => Self::Created,
Self::Created => Self::Name,
}
}
pub fn label(self) -> &'static str {
match self {
Self::Name => "name",
Self::Modified => "modified",
Self::Created => "created",
}
}
}
#[derive(Deserialize, JsonSchema, Debug, Clone, Copy, Default, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum FileBrowserShow {
#[default]
All,
Files,
#[serde(alias = "dirs", alias = "directory")]
#[schemars(transform = accept_directories_aliases)]
Directories,
}
fn accept_directories_aliases(schema: &mut schemars::Schema) {
schema.remove("const");
schema.insert(
"enum".into(),
serde_json::json!(["directories", "dirs", "directory"]),
);
}
impl FileBrowserShow {
pub fn next(self) -> Self {
match self {
Self::All => Self::Files,
Self::Files => Self::Directories,
Self::Directories => Self::All,
}
}
pub fn label(self) -> &'static str {
match self {
Self::All => "all",
Self::Files => "files",
Self::Directories => "directories",
}
}
pub fn includes(self, is_dir: bool) -> bool {
match self {
Self::All => true,
Self::Files => !is_dir,
Self::Directories => is_dir,
}
}
}
impl FileBrowserAddonConfig {
pub fn action(&self) -> &str {
self.action.as_deref().unwrap_or("open")
}
pub fn secondary_action(&self) -> &str {
self.secondary_action.as_deref().unwrap_or("copy")
}
}
impl Default for FileBrowserAddonConfig {
fn default() -> Self {
Self {
enabled: true,
show_hidden: None,
fido_style: true,
action: None,
secondary_action: None,
file_manager: None,
sort: FileBrowserSort::default(),
sort_reverse: false,
show: FileBrowserShow::default(),
preview_command: None,
}
}
}
pub const DEFAULT_EMOJI_FILES: &[&str] = &[
"emojis_smileys_emotion",
"emojis_people_body",
"emojis_animals_nature",
"emojis_food_drink",
"emojis_travel_places",
"emojis_activities",
"emojis_objects",
"emojis_symbols",
"emojis_flags",
"emojis_component",
];
#[derive(Deserialize, JsonSchema, Debug, Clone)]
#[schemars(deny_unknown_fields)]
pub struct EmojiAddonConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub trigger: Option<String>,
#[serde(default)]
pub action: Option<String>,
#[serde(default)]
pub secondary_action: Option<String>,
#[serde(default)]
pub data_files: Option<Vec<String>>,
}
impl Default for EmojiAddonConfig {
fn default() -> Self {
Self {
enabled: true,
trigger: None,
action: None,
secondary_action: None,
data_files: None,
}
}
}
#[derive(Deserialize, JsonSchema, Debug, Clone)]
#[schemars(deny_unknown_fields)]
pub struct ScriptFilterConfig {
pub name: String,
pub command: String,
pub keyword: String,
pub icon: Option<String>,
#[serde(default)]
pub args: Vec<String>,
pub action: Option<String>,
pub secondary_action: Option<String>,
#[serde(default)]
pub env: std::collections::HashMap<String, String>,
pub min_query_length: Option<usize>,
pub dynamic_preview: Option<bool>,
}
#[derive(Deserialize, JsonSchema, Debug, Clone)]
#[schemars(deny_unknown_fields)]
pub struct WebSearchConfig {
pub name: String,
pub keyword: String,
pub url: String,
pub icon: Option<String>,
}
#[derive(Deserialize, JsonSchema, Debug, Clone, PartialEq)]
#[schemars(deny_unknown_fields)]
pub struct TextSnippet {
pub name: String,
pub value: String,
#[serde(default)]
pub icon: Option<String>,
#[serde(default)]
pub keyword: Option<String>,
}
#[derive(Deserialize, JsonSchema, Debug, Clone)]
#[schemars(deny_unknown_fields)]
pub struct TextSnippetSourceConfig {
pub name: String,
pub keyword: String,
#[serde(default)]
pub icon: Option<String>,
#[serde(default)]
pub snippets: Option<Vec<TextSnippet>>,
#[serde(default)]
pub file: Option<String>,
#[serde(default)]
pub command: Option<String>,
#[serde(default)]
pub directory: Option<String>,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub action: Option<String>,
#[serde(default)]
pub secondary_action: Option<String>,
#[serde(default = "default_true")]
pub placeholders: bool,
#[serde(default)]
pub restore_clipboard: bool,
}
#[derive(Deserialize, JsonSchema, Debug, Clone, Copy, Default, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ClipboardBackend {
#[default]
Copyq,
}
#[derive(Deserialize, JsonSchema, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ClipboardAction {
Copy,
HyprPaste,
}
#[derive(Deserialize, JsonSchema, Debug, Clone)]
#[schemars(deny_unknown_fields)]
pub struct ClipboardAddonConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub trigger: Option<String>,
#[serde(default)]
pub image_trigger: Option<String>,
#[serde(default)]
pub min_chars: Option<usize>,
#[serde(default)]
pub backend: ClipboardBackend,
#[serde(default = "default_clipboard_action")]
pub action: ClipboardAction,
#[serde(default = "default_clipboard_secondary_action")]
pub secondary_action: ClipboardAction,
}
fn default_clipboard_action() -> ClipboardAction {
ClipboardAction::Copy
}
fn default_clipboard_secondary_action() -> ClipboardAction {
ClipboardAction::HyprPaste
}
impl ClipboardAddonConfig {
pub fn trigger(&self) -> &str {
self.trigger.as_deref().unwrap_or("clip")
}
pub fn min_chars(&self) -> usize {
self.min_chars.unwrap_or(3)
}
pub fn image_trigger(&self) -> &str {
self.image_trigger.as_deref().unwrap_or("clipimg")
}
pub fn uses_hypr_paste(&self) -> bool {
self.enabled
&& (self.action == ClipboardAction::HyprPaste
|| self.secondary_action == ClipboardAction::HyprPaste)
}
}
impl Default for ClipboardAddonConfig {
fn default() -> Self {
Self {
enabled: false,
trigger: None,
image_trigger: None,
min_chars: None,
backend: ClipboardBackend::default(),
action: default_clipboard_action(),
secondary_action: default_clipboard_secondary_action(),
}
}
}
#[derive(Deserialize, JsonSchema, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SystemCommand {
Lock,
Suspend,
Hibernate,
Logout,
Reboot,
Shutdown,
}
#[derive(Deserialize, JsonSchema, Debug, Clone, Default)]
#[schemars(deny_unknown_fields)]
pub struct SystemCommandsConfig {
#[serde(default)]
pub lock: Option<Vec<String>>,
#[serde(default)]
pub suspend: Option<Vec<String>>,
#[serde(default)]
pub hibernate: Option<Vec<String>>,
#[serde(default)]
pub logout: Option<Vec<String>>,
#[serde(default)]
pub reboot: Option<Vec<String>>,
#[serde(default)]
pub shutdown: Option<Vec<String>>,
}
impl SystemCommandsConfig {
pub fn get(&self, command: SystemCommand) -> Option<&Vec<String>> {
match command {
SystemCommand::Lock => self.lock.as_ref(),
SystemCommand::Suspend => self.suspend.as_ref(),
SystemCommand::Hibernate => self.hibernate.as_ref(),
SystemCommand::Logout => self.logout.as_ref(),
SystemCommand::Reboot => self.reboot.as_ref(),
SystemCommand::Shutdown => self.shutdown.as_ref(),
}
}
}
#[derive(Deserialize, JsonSchema, Debug, Clone)]
#[schemars(deny_unknown_fields)]
pub struct SystemAddonConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub trigger: Option<String>,
#[serde(default)]
pub commands: SystemCommandsConfig,
#[serde(default)]
pub hide: Vec<SystemCommand>,
}
impl SystemAddonConfig {
pub fn trigger(&self) -> &str {
self.trigger.as_deref().unwrap_or("sys")
}
}
impl Default for SystemAddonConfig {
fn default() -> Self {
Self {
enabled: true,
trigger: None,
commands: SystemCommandsConfig::default(),
hide: Vec::new(),
}
}
}
#[derive(Deserialize, JsonSchema, Debug, Clone)]
#[schemars(deny_unknown_fields)]
pub struct WindowsAddonConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub trigger: Option<String>,
}
impl WindowsAddonConfig {
pub fn trigger(&self) -> &str {
self.trigger.as_deref().unwrap_or("win")
}
}
impl Default for WindowsAddonConfig {
fn default() -> Self {
Self {
enabled: true,
trigger: None,
}
}
}
#[derive(Deserialize, JsonSchema, Debug, Clone)]
#[schemars(deny_unknown_fields)]
pub struct BrowserTabsAddonConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub trigger: Option<String>,
#[serde(default)]
pub favicon_service: FaviconService,
}
#[derive(Deserialize, JsonSchema, Debug, Clone, Copy, Default, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum FaviconService {
#[default]
Twenty,
Google,
None,
}
impl BrowserTabsAddonConfig {
pub fn trigger(&self) -> &str {
self.trigger.as_deref().unwrap_or("tabs")
}
}
impl Default for BrowserTabsAddonConfig {
fn default() -> Self {
Self {
enabled: true,
trigger: None,
favicon_service: FaviconService::default(),
}
}
}
#[derive(Deserialize, JsonSchema, Debug, Clone)]
#[schemars(deny_unknown_fields)]
pub struct MediaAddonConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub trigger: Option<String>,
#[serde(default)]
pub close_on_action: bool,
}
impl MediaAddonConfig {
pub fn trigger(&self) -> &str {
self.trigger.as_deref().unwrap_or("media")
}
}
impl Default for MediaAddonConfig {
fn default() -> Self {
Self {
enabled: true,
trigger: None,
close_on_action: false,
}
}
}
#[derive(Deserialize, JsonSchema, Debug, Clone)]
#[schemars(deny_unknown_fields)]
pub struct AudioAddonConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub trigger: Option<String>,
#[serde(default)]
pub close_on_action: bool,
#[serde(default)]
pub volume_step: Option<u32>,
#[serde(default)]
pub max_volume: Option<u32>,
}
impl AudioAddonConfig {
pub fn trigger(&self) -> &str {
self.trigger.as_deref().unwrap_or("audio")
}
pub fn volume_step(&self) -> u32 {
self.volume_step.unwrap_or(5).max(1)
}
pub fn max_volume(&self) -> u32 {
self.max_volume.unwrap_or(100)
}
}
impl Default for AudioAddonConfig {
fn default() -> Self {
Self {
enabled: true,
trigger: None,
close_on_action: false,
volume_step: None,
max_volume: None,
}
}
}
#[derive(Deserialize, JsonSchema, Debug, Clone)]
#[schemars(deny_unknown_fields)]
pub struct AppsAddonConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_true")]
pub actions: bool,
}
impl Default for AppsAddonConfig {
fn default() -> Self {
Self {
enabled: false,
actions: true,
}
}
}
#[derive(Deserialize, JsonSchema, Debug, Clone)]
#[schemars(deny_unknown_fields)]
pub struct TerminalAddonConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub trigger: Option<String>,
#[serde(default)]
pub command: Option<Vec<String>>,
#[serde(default = "default_true")]
pub hold: bool,
#[serde(default)]
pub open_in: TerminalOpenIn,
#[serde(default)]
pub kitty_socket: Option<String>,
}
#[derive(Deserialize, JsonSchema, Debug, Clone, Copy, Default, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum TerminalOpenIn {
#[default]
Tab,
Window,
}
impl TerminalAddonConfig {
pub fn trigger(&self) -> &str {
self.trigger.as_deref().unwrap_or(">")
}
}
impl Default for TerminalAddonConfig {
fn default() -> Self {
Self {
enabled: true,
trigger: None,
command: None,
hold: true,
open_in: TerminalOpenIn::default(),
kitty_socket: None,
}
}
}
#[derive(Deserialize, JsonSchema, Debug, Clone)]
#[schemars(deny_unknown_fields)]
pub struct KillAddonConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub trigger: Option<String>,
}
impl KillAddonConfig {
pub fn trigger(&self) -> &str {
self.trigger.as_deref().unwrap_or("kill")
}
}
impl Default for KillAddonConfig {
fn default() -> Self {
Self {
enabled: true,
trigger: None,
}
}
}
#[derive(Deserialize, JsonSchema, Debug, Clone)]
#[schemars(deny_unknown_fields)]
pub struct BluetoothAddonConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub trigger: Option<String>,
#[serde(default)]
pub close_on_action: bool,
}
impl BluetoothAddonConfig {
pub fn trigger(&self) -> &str {
self.trigger.as_deref().unwrap_or("bt")
}
}
impl Default for BluetoothAddonConfig {
fn default() -> Self {
Self {
enabled: true,
trigger: None,
close_on_action: false,
}
}
}
#[derive(Deserialize, JsonSchema, Debug, Clone, Copy, Default, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum PassPinentry {
#[default]
Raffi,
Agent,
}
#[derive(Deserialize, JsonSchema, Debug, Clone)]
#[schemars(deny_unknown_fields)]
pub struct PassAddonConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub trigger: Option<String>,
#[serde(default)]
pub store_dir: Option<String>,
#[serde(default)]
pub pinentry: PassPinentry,
#[serde(default)]
pub username_fields: Option<Vec<String>>,
#[serde(default)]
pub clipboard_timeout: Option<u64>,
#[serde(default)]
pub type_delay_ms: Option<u64>,
}
impl PassAddonConfig {
pub fn trigger(&self) -> &str {
self.trigger.as_deref().unwrap_or("pass")
}
pub fn username_fields(&self) -> Vec<String> {
self.username_fields
.clone()
.unwrap_or_else(|| ["login", "user", "username"].map(String::from).to_vec())
}
pub fn clipboard_timeout(&self) -> u64 {
self.clipboard_timeout.unwrap_or(45)
}
pub fn type_delay_ms(&self) -> u64 {
self.type_delay_ms.unwrap_or(200)
}
}
impl Default for PassAddonConfig {
fn default() -> Self {
Self {
enabled: true,
trigger: None,
store_dir: None,
pinentry: PassPinentry::default(),
username_fields: None,
clipboard_timeout: None,
type_delay_ms: None,
}
}
}
#[derive(Deserialize, JsonSchema, Debug, Clone)]
#[schemars(deny_unknown_fields)]
pub struct BitwardenAddonConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub trigger: Option<String>,
#[serde(default)]
pub clipboard_timeout: Option<u64>,
#[serde(default)]
pub type_delay_ms: Option<u64>,
}
impl BitwardenAddonConfig {
pub fn trigger(&self) -> &str {
self.trigger.as_deref().unwrap_or("bw")
}
pub fn clipboard_timeout(&self) -> u64 {
self.clipboard_timeout.unwrap_or(45)
}
pub fn type_delay_ms(&self) -> u64 {
self.type_delay_ms.unwrap_or(200)
}
}
impl Default for BitwardenAddonConfig {
fn default() -> Self {
Self {
enabled: true,
trigger: None,
clipboard_timeout: None,
type_delay_ms: None,
}
}
}
#[derive(Deserialize, JsonSchema, Debug, Clone, Copy, Default, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum SlackOpenIn {
#[default]
Desktop,
Browser,
}
#[derive(Deserialize, JsonSchema, Debug, Clone, Default)]
#[schemars(deny_unknown_fields)]
pub struct SlackAddonConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub trigger: Option<String>,
#[serde(default)]
pub token_command: Option<String>,
#[serde(default)]
pub cookie_command: Option<String>,
#[serde(default)]
pub open_in: SlackOpenIn,
#[serde(default)]
pub unread_conversations: Vec<String>,
}
impl SlackAddonConfig {
pub fn trigger(&self) -> &str {
self.trigger.as_deref().unwrap_or("slack")
}
}
#[derive(Deserialize, JsonSchema, Debug, Clone, Default)]
#[schemars(deny_unknown_fields)]
pub struct AddonsConfig {
#[serde(default)]
pub currency: CurrencyAddonConfig,
#[serde(default)]
pub calculator: CalculatorAddonConfig,
#[serde(default)]
pub units: UnitsAddonConfig,
#[serde(default)]
pub color: ColorAddonConfig,
#[serde(default)]
pub world_clock: WorldClockAddonConfig,
#[serde(default)]
pub file_browser: FileBrowserAddonConfig,
#[serde(default)]
pub emoji: EmojiAddonConfig,
#[serde(default)]
pub clipboard: ClipboardAddonConfig,
#[serde(default)]
pub system: SystemAddonConfig,
#[serde(default)]
pub windows: WindowsAddonConfig,
#[serde(default)]
pub browser_tabs: BrowserTabsAddonConfig,
#[serde(default)]
pub media: MediaAddonConfig,
#[serde(default)]
pub audio: AudioAddonConfig,
#[serde(default)]
pub apps: AppsAddonConfig,
#[serde(default)]
pub terminal: TerminalAddonConfig,
#[serde(default)]
pub kill: KillAddonConfig,
#[serde(default)]
pub bluetooth: BluetoothAddonConfig,
#[serde(default)]
pub pass: PassAddonConfig,
#[serde(default)]
pub bitwarden: BitwardenAddonConfig,
#[serde(default)]
pub slack: SlackAddonConfig,
#[serde(default)]
pub devtools: DevtoolsAddonConfig,
#[serde(default)]
pub script_filters: Vec<ScriptFilterConfig>,
#[serde(default)]
pub web_searches: Vec<WebSearchConfig>,
#[serde(default)]
pub text_snippets: Vec<TextSnippetSourceConfig>,
#[serde(default)]
pub keyword_min_chars: Option<usize>,
}
impl AddonsConfig {
pub fn keyword_min_chars(&self) -> usize {
self.keyword_min_chars.unwrap_or(3)
}
}
fn default_true() -> bool {
true
}
#[derive(Deserialize, JsonSchema, Debug, Clone, Default)]
#[schemars(deny_unknown_fields)]
pub struct ThemeColorsConfig {
pub bg_base: Option<String>,
pub bg_input: Option<String>,
pub accent: Option<String>,
pub accent_hover: Option<String>,
pub text_main: Option<String>,
pub text_muted: Option<String>,
pub selection_bg: Option<String>,
pub border: Option<String>,
}
#[derive(Deserialize, JsonSchema, Debug, Clone, Default, PartialEq)]
#[schemars(deny_unknown_fields)]
pub struct PreviewColorsConfig {
pub bg: Option<String>,
pub bg_block: Option<String>,
pub text_main: Option<String>,
pub text_muted: Option<String>,
pub accent: Option<String>,
pub border: Option<String>,
}
#[derive(Deserialize, JsonSchema, Debug, Clone, Default, PartialEq)]
#[schemars(deny_unknown_fields)]
pub struct PreviewConfig {
#[serde(default)]
pub font_size: Option<f32>,
#[serde(default)]
pub font_family: Option<String>,
#[serde(default)]
pub mono_font_family: Option<String>,
#[serde(default)]
pub width_percent: Option<f32>,
#[serde(default)]
pub colors: Option<PreviewColorsConfig>,
}
#[derive(Deserialize, JsonSchema, Debug, Clone, Default, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum SortMode {
Frequency,
Recency,
#[default]
Hybrid,
}
#[derive(Deserialize, JsonSchema, Debug, Clone, Copy, Default, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Renderer {
#[default]
Cpu,
Gpu,
}
#[derive(Deserialize, JsonSchema, Debug, Clone, Copy, Default, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ClipboardHistory {
#[default]
Auto,
Cliphist,
Copyq,
Clipman,
None,
}
#[derive(Deserialize, JsonSchema, Debug, Clone, Default)]
#[schemars(deny_unknown_fields)]
pub struct GeneralConfig {
#[serde(default)]
pub ui_type: Option<String>,
#[serde(default)]
pub default_script_shell: Option<String>,
#[serde(default)]
pub no_icons: Option<bool>,
#[serde(default)]
pub theme: Option<String>,
#[serde(default)]
pub theme_colors: Option<ThemeColorsConfig>,
#[serde(default)]
pub renderer: Renderer,
#[serde(default)]
pub max_history: Option<u32>,
#[serde(default)]
pub font_size: Option<f32>,
#[serde(default)]
pub font_family: Option<String>,
#[serde(default)]
pub window_width: Option<f32>,
#[serde(default)]
pub window_height: Option<f32>,
#[serde(default)]
pub padding: Option<f32>,
#[serde(default)]
pub sort_mode: SortMode,
#[serde(default)]
pub fallbacks: Vec<String>,
#[serde(default)]
pub main_view: Option<Vec<String>>,
#[serde(default)]
pub clipboard_history: ClipboardHistory,
#[serde(default)]
pub terminal_classes: Vec<String>,
#[serde(default)]
pub show_preview: Option<bool>,
#[serde(default)]
pub preview: PreviewConfig,
}
pub struct ParsedConfig {
pub general: GeneralConfig,
pub addons: AddonsConfig,
pub entries: Vec<RaffiConfig>,
}
#[derive(Deserialize, Default)]
struct Config {
#[serde(default)]
#[allow(dead_code)]
version: u32,
#[serde(default)]
general: GeneralConfig,
#[serde(default)]
addons: AddonsConfig,
#[serde(default)]
launchers: serde_yaml::Mapping,
}
#[derive(JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct ConfigSchema {
pub version: u32,
#[serde(default)]
pub general: GeneralConfig,
#[serde(default)]
pub addons: AddonsConfig,
#[serde(default)]
pub launchers: HashMap<String, RaffiConfig>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum UIType {
Fuzzel,
#[cfg(feature = "wayland")]
Native,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ThemeMode {
Dark,
Light,
CatppuccinMocha,
CatppuccinLatte,
TokyoNight,
Nord,
Gruvbox,
Dracula,
RosePine,
Kanagawa,
}
impl ThemeMode {
pub fn is_light(&self) -> bool {
matches!(self, Self::Light | Self::CatppuccinLatte)
}
}
impl std::str::FromStr for ThemeMode {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.trim().to_lowercase().replace('_', "-").as_str() {
"dark" | "obsidian" => Ok(ThemeMode::Dark),
"light" | "rose-pine-dawn" | "rosepinedawn" => Ok(ThemeMode::Light),
"catppuccin" | "catppuccin-mocha" => Ok(ThemeMode::CatppuccinMocha),
"catppuccin-latte" => Ok(ThemeMode::CatppuccinLatte),
"tokyo-night" | "tokyonight" => Ok(ThemeMode::TokyoNight),
"nord" => Ok(ThemeMode::Nord),
"gruvbox" | "gruvbox-dark" => Ok(ThemeMode::Gruvbox),
"dracula" => Ok(ThemeMode::Dracula),
"rose-pine" | "rosepine" => Ok(ThemeMode::RosePine),
"kanagawa" => Ok(ThemeMode::Kanagawa),
_ => Err(format!(
"Invalid theme: {}. Valid options are: dark, light, catppuccin, catppuccin-latte, tokyo-night, nord, gruvbox, dracula, rose-pine, kanagawa",
s
)),
}
}
}
impl std::str::FromStr for UIType {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"fuzzel" => Ok(UIType::Fuzzel),
#[cfg(feature = "wayland")]
"native" | "wayland" | "iced" => Ok(UIType::Native),
#[cfg(not(feature = "wayland"))]
"native" | "wayland" | "iced" => Err(
"Native UI is not available. Build with the 'wayland' feature to enable it."
.to_string(),
),
_ => {
#[cfg(feature = "wayland")]
{
Err(format!(
"Invalid UI type: {}. Valid options are: fuzzel, native",
s
))
}
#[cfg(not(feature = "wayland"))]
{
Err(format!("Invalid UI type: {}. Valid options are: fuzzel", s))
}
}
}
}
}
#[derive(Debug, Options, Clone, Default)]
pub struct Args {
#[options(help = "print help message")]
pub help: bool,
#[options(help = "print version")]
pub version: bool,
#[options(help = "config file location", short = "c")]
pub configfile: Option<String>,
#[options(help = "print command to stdout, do not run it", short = "p")]
pub print_only: bool,
#[options(help = "refresh cache", short = "r")]
pub refresh_cache: bool,
#[options(help = "do not show icons", short = "I")]
pub no_icons: bool,
#[options(help = "default shell when using scripts", short = "P")]
pub default_script_shell: Option<String>,
#[options(
help = "UI type to use: fuzzel, native (default: native; fuzzel-only builds use fuzzel)",
short = "u"
)]
pub ui_type: Option<String>,
#[options(help = "application ID for the native Linux window", no_short)]
pub app_name: Option<String>,
#[options(help = "initial search query (native mode only)", short = "i")]
pub initial_query: Option<String>,
#[options(
help = "activate the only selectable result for a non-empty initial query",
short = "1"
)]
pub select_1: bool,
#[options(
help = "theme: dark, light, catppuccin, catppuccin-latte, tokyo-night, nord, gruvbox, dracula, rose-pine, kanagawa (default: dark)",
short = "t"
)]
pub theme: Option<String>,
#[options(
help = "write both JSON Schemas beside the config file and exit",
no_short
)]
pub generate_schemas: bool,
#[options(
help = "validate the config file against the JSON Schema and exit",
no_short
)]
pub check_config: bool,
#[options(help = "write debug log to FILE")]
pub debug_file: Option<String>,
#[options(
help = "register Raffi as the native host of the Vicinae browser extension and exit",
no_short
)]
pub install_browser_host: bool,
#[options(
help = "remove the browser host manifests pointing to Raffi and exit",
no_short
)]
pub uninstall_browser_host: bool,
#[options(
help = "with --install-browser-host, replace manifests of other hosts such as Vicinae",
no_short
)]
pub force: bool,
}
fn resolve_window_dimensions(general: &GeneralConfig) -> (f32, f32) {
(
general.window_width.unwrap_or(800.0),
general.window_height.unwrap_or(600.0),
)
}
pub trait EnvProvider {
fn var(&self, key: &str) -> Result<String, std::env::VarError>;
}
pub struct DefaultEnvProvider;
impl EnvProvider for DefaultEnvProvider {
fn var(&self, key: &str) -> Result<String, std::env::VarError> {
std::env::var(key)
}
}
pub trait BinaryChecker {
fn exists(&self, binary: &str) -> bool;
}
pub struct DefaultBinaryChecker;
impl BinaryChecker for DefaultBinaryChecker {
fn exists(&self, binary: &str) -> bool {
find_binary(binary)
}
}
impl BinaryChecker for fn(&str) -> bool {
fn exists(&self, binary: &str) -> bool {
self(binary)
}
}
pub trait IconMapProvider {
fn get_icon_map(&self) -> Result<HashMap<String, String>>;
}
pub struct DefaultIconMapProvider;
impl IconMapProvider for DefaultIconMapProvider {
fn get_icon_map(&self) -> Result<HashMap<String, String>> {
read_icon_map()
}
}
fn extract_icon_size(path: &std::path::Path) -> u32 {
for component in path.components() {
if let std::path::Component::Normal(s) = component {
if let Some(s_str) = s.to_str() {
if s_str == "scalable" {
return 512; }
if let Some((w, _)) = s_str.split_once('x') {
if let Ok(size) = w.parse::<u32>() {
return size;
}
}
}
}
}
0
}
fn get_icon_map() -> Result<HashMap<String, String>> {
let mut icon_map: HashMap<String, String> = HashMap::new();
let mut icon_sizes: HashMap<String, u32> = HashMap::new();
let mut data_dirs =
std::env::var("XDG_DATA_DIRS").unwrap_or("/usr/local/share/:/usr/share/".to_string());
let data_home = std::env::var("XDG_DATA_HOME").unwrap_or_else(|_| {
format!(
"{}/.local/share/",
std::env::var("HOME").unwrap_or_default()
)
});
write!(&mut data_dirs, ":{data_home}")?;
for datadir in std::env::split_paths(&data_dirs) {
for subdir in &["icons", "pixmaps"] {
let mut dir = datadir.clone();
dir.push(subdir);
for entry in walkdir::WalkDir::new(dir)
.follow_links(true)
.into_iter()
.filter_map(Result::ok)
{
let fname = entry.file_name().to_string_lossy().to_string();
if let Some(ext) = entry.path().extension().and_then(|s| s.to_str()) {
if ext == "png" || ext == "svg" {
let icon_name = fname.rsplit_once('.').unwrap().0.to_string();
let icon_path = entry.path().to_string_lossy().to_string();
let new_size = extract_icon_size(entry.path());
let current_size = icon_sizes.get(&icon_name).copied().unwrap_or(0);
if new_size >= current_size || (new_size >= 48 && current_size < 48) {
icon_map.insert(icon_name.clone(), icon_path);
icon_sizes.insert(icon_name, new_size);
}
}
}
}
}
}
Ok(icon_map)
}
pub(crate) fn expand_tilde(s: &str) -> String {
if let Some(stripped) = s.strip_prefix("~/") {
format!("{}/{}", std::env::var("HOME").unwrap_or_default(), stripped)
} else {
s.to_string()
}
}
fn expand_env_vars(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '$' && chars.peek() == Some(&'{') {
chars.next(); let var_name: String = chars.by_ref().take_while(|&c| c != '}').collect();
result.push_str(&std::env::var(&var_name).unwrap_or_default());
} else {
result.push(c);
}
}
result
}
pub(crate) fn expand_config_value(s: &str) -> String {
expand_env_vars(&expand_tilde(s))
}
fn migrate_config_v0_in_memory(raw: &mut Value) -> bool {
let mapping = match raw.as_mapping_mut() {
Some(m) => m,
None => return false,
};
if mapping.contains_key(Value::String("version".to_string())) {
return false;
}
let reserved = ["general", "addons", "version"];
let mut launchers = serde_yaml::Mapping::new();
let mut keys_to_move = Vec::new();
for (key, _) in mapping.iter() {
if let Some(k) = key.as_str() {
if !reserved.contains(&k) {
keys_to_move.push(Value::String(k.to_string()));
}
}
}
for key in &keys_to_move {
if let Some(val) = mapping.remove(key) {
launchers.insert(key.clone(), val);
}
}
mapping.insert(
Value::String("version".to_string()),
Value::Number(serde_yaml::Number::from(1u64)),
);
mapping.insert(
Value::String("launchers".to_string()),
Value::Mapping(launchers),
);
true
}
pub fn migrate_config_v0_to_v1(config_path: &str, raw: &mut Value) -> Result<bool> {
if !migrate_config_v0_in_memory(raw) {
return Ok(false);
}
let backup_path = format!("{config_path}.bak");
fs::copy(config_path, &backup_path)
.context(format!("Failed to create backup at {backup_path}"))?;
let migrated_yaml =
serde_yaml::to_string(&raw).context("Failed to serialize migrated config")?;
fs::write(config_path, &migrated_yaml)
.context(format!("Failed to write migrated config to {config_path}"))?;
eprintln!("Config migrated to v1 format. Backup saved as {backup_path}");
Ok(true)
}
#[derive(Debug, Clone, PartialEq)]
pub struct ConfigProblem {
pub path: String,
pub key: Option<String>,
pub message: String,
pub line: Option<usize>,
}
impl ConfigProblem {
fn new(path: &str, message: impl Into<String>) -> Self {
Self {
path: if path.is_empty() { "/" } else { path }.to_string(),
key: None,
message: message.into(),
line: None,
}
}
}
impl std::fmt::Display for ConfigProblem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.path, self.message)
}
}
#[cfg(feature = "config-validation")]
pub fn validate_config_value(raw: &Value) -> Vec<ConfigProblem> {
let instance = match serde_json::to_value(raw) {
Ok(v) => v,
Err(e) => {
return vec![ConfigProblem::new(
"/",
format!("cannot check config against the schema: {e}"),
)]
}
};
let schema = match serde_json::to_value(schemars::schema_for!(ConfigSchema)) {
Ok(v) => v,
Err(e) => {
return vec![ConfigProblem::new(
"/",
format!("cannot serialize the built-in schema: {e}"),
)]
}
};
let validator = match jsonschema::validator_for(&schema) {
Ok(v) => v,
Err(e) => {
return vec![ConfigProblem::new(
"/",
format!("cannot compile the built-in schema: {e}"),
)]
}
};
validator
.iter_errors(&instance)
.map(|e| {
let mut problem = ConfigProblem::new(e.instance_path().as_str(), e.to_string());
if let jsonschema::error::ValidationErrorKind::AdditionalProperties { unexpected } =
e.kind()
{
problem.key = unexpected.first().cloned();
}
problem
})
.collect()
}
#[cfg(not(feature = "config-validation"))]
pub fn validate_config_value(_raw: &Value) -> Vec<ConfigProblem> {
Vec::new()
}
enum YamlTokenKind {
SeqItem,
Key(String),
Other,
}
struct YamlToken {
line: usize,
indent: usize,
kind: YamlTokenKind,
}
fn parse_yaml_key(s: &str) -> Option<String> {
let (key, after) = match s.chars().next()? {
q @ ('"' | '\'') => {
let end = s[1..].find(q)? + 1;
(&s[1..end], &s[end + 1..])
}
'{' | '[' | '?' | '&' | '*' | '!' | '|' | '>' => return None,
_ => {
let end = s.find(": ").or_else(|| s.strip_suffix(':').map(str::len))?;
(s[..end].trim_end(), &s[end..])
}
};
after.starts_with(':').then(|| key.to_string())
}
fn tokenize_yaml(text: &str) -> Vec<YamlToken> {
let mut tokens = Vec::new();
for (i, raw) in text.lines().enumerate() {
let mut rest = raw.trim_start_matches(' ');
let mut indent = raw.len() - rest.len();
if rest.is_empty() || rest.starts_with('#') || rest.starts_with("---") {
continue;
}
while rest == "-" || rest.starts_with("- ") {
tokens.push(YamlToken {
line: i + 1,
indent,
kind: YamlTokenKind::SeqItem,
});
let after = rest[1..].trim_start_matches(' ');
indent += rest.len() - after.len();
rest = after;
}
if rest.is_empty() || rest.starts_with('#') {
continue;
}
tokens.push(YamlToken {
line: i + 1,
indent,
kind: parse_yaml_key(rest).map_or(YamlTokenKind::Other, YamlTokenKind::Key),
});
}
tokens
}
fn find_yaml_line(text: &str, pointer: &str) -> Option<usize> {
let tokens = tokenize_yaml(text);
let (mut start, mut end) = (0, tokens.len());
let mut found = None;
for segment in pointer.split('/').skip(1).filter(|s| !s.is_empty()) {
let segment = segment.replace("~1", "/").replace("~0", "~");
let Some(level) = tokens[start..end].first().map(|t| t.indent) else {
break;
};
let children = || (start..end).filter(|&j| tokens[j].indent == level);
let hit = children()
.find(|&j| matches!(&tokens[j].kind, YamlTokenKind::Key(k) if *k == segment))
.or_else(|| {
let n = segment.parse::<usize>().ok()?;
children()
.filter(|&j| matches!(tokens[j].kind, YamlTokenKind::SeqItem))
.nth(n)
});
let Some(j) = hit else {
break;
};
let parent = &tokens[j];
found = Some(parent.line);
start = j + 1;
end = (start..end)
.find(|&k| {
let t = &tokens[k];
t.indent < parent.indent
|| (t.indent == parent.indent
&& !(matches!(parent.kind, YamlTokenKind::Key(_))
&& matches!(t.kind, YamlTokenKind::SeqItem)))
})
.unwrap_or(end);
}
found
}
fn locate_config_problems(problems: &mut [ConfigProblem], text: &str, v0: bool) {
for problem in problems {
let mut pointer = problem.path.clone();
if let Some(key) = &problem.key {
pointer = format!(
"{}/{}",
pointer.trim_end_matches('/'),
key.replace('~', "~0").replace('/', "~1")
);
}
if v0 {
if let Some(rest) = pointer.strip_prefix("/launchers") {
if rest.is_empty() || rest.starts_with('/') {
pointer = rest.to_string();
}
}
}
problem.line = find_yaml_line(text, &pointer);
}
}
fn format_config_problem(source: &str, problem: &ConfigProblem) -> String {
match problem.line {
Some(line) => format!("{source}:{line}: {problem}"),
None => format!("{source}: {problem}"),
}
}
fn report_config_warnings(raw: &Value, text: &str, v0: bool, source: &str) {
let mut problems = validate_config_value(raw);
locate_config_problems(&mut problems, text, v0);
print_config_warnings(&problems, source);
}
fn print_config_warnings(problems: &[ConfigProblem], source: &str) {
for problem in problems {
let problem = format_config_problem(source, problem);
debug_log!("config warning: {problem}");
eprintln!("raffi: config warning: {problem}");
}
}
fn ensure_config_validation() -> Result<()> {
if !cfg!(feature = "config-validation") {
anyhow::bail!(
"config validation is not available in this build; rebuild with the `config-validation` feature"
);
}
Ok(())
}
pub fn check_config(filename: &str) -> Result<Vec<ConfigProblem>> {
ensure_config_validation()?;
let contents =
fs::read_to_string(filename).context(format!("cannot open config file {filename}"))?;
let mut raw: Value = serde_yaml::from_str(&contents).context("cannot parse config as YAML")?;
let v0 = migrate_config_v0_in_memory(&mut raw);
let mut problems = validate_config_value(&raw);
locate_config_problems(&mut problems, &contents, v0);
if problems.is_empty() {
let result = if v0 {
serde_yaml::from_value::<Config>(raw)
} else {
serde_yaml::from_str::<Config>(&contents)
};
if let Err(e) = result {
let mut problem = ConfigProblem::new("/", e.to_string());
problem.line = e.location().map(|l| l.line());
problems.push(problem);
}
}
Ok(problems)
}
pub fn read_config(filename: &str, args: &Args) -> Result<ParsedConfig> {
let contents =
fs::read_to_string(filename).context(format!("cannot open config file {filename}"))?;
let mut raw: Value = serde_yaml::from_str(&contents).context("cannot parse config as YAML")?;
if migrate_config_v0_to_v1(filename, &mut raw)? {
let migrated = serde_yaml::to_string(&raw).unwrap_or_default();
report_config_warnings(&raw, &migrated, false, filename);
} else {
report_config_warnings(&raw, &contents, false, filename);
}
let mut config: Config = serde_yaml::from_value(raw).context("cannot parse config")?;
let launchers_path = launchers_file_for(filename);
if launchers_path.exists() {
if config.launchers.is_empty() {
config.launchers = read_launchers_file(&launchers_path)?;
} else {
eprintln!(
"raffi: ignoring {} because {filename} already defines launchers",
launchers_path.display()
);
}
}
process_config(config, args)
}
fn launchers_file_for(configfile: &str) -> PathBuf {
Path::new(configfile)
.parent()
.unwrap_or(Path::new("."))
.join("launchers.yaml")
}
fn parse_launchers_file(path: &Path) -> Result<(String, Value)> {
let contents = fs::read_to_string(path)
.context(format!("cannot open launchers file {}", path.display()))?;
let launchers: Value =
serde_yaml::from_str(&contents).context("cannot parse launchers file as YAML")?;
let launchers = if launchers.is_null() {
Value::Mapping(serde_yaml::Mapping::new())
} else {
launchers
};
let mut wrapped = serde_yaml::Mapping::new();
wrapped.insert(Value::String("version".to_string()), Value::from(1u64));
wrapped.insert(Value::String("launchers".to_string()), launchers);
Ok((contents, Value::Mapping(wrapped)))
}
fn launchers_file_problems(contents: &str, wrapped: &Value) -> Vec<ConfigProblem> {
let mut problems = validate_config_value(wrapped);
locate_config_problems(&mut problems, contents, true);
for problem in &mut problems {
if let Some(rest) = problem.path.strip_prefix("/launchers") {
problem.path = if rest.is_empty() { "/" } else { rest }.to_string();
}
}
problems
}
fn read_launchers_file(path: &Path) -> Result<serde_yaml::Mapping> {
let (contents, wrapped) = parse_launchers_file(path)?;
let problems = launchers_file_problems(&contents, &wrapped);
print_config_warnings(&problems, &path.display().to_string());
let config: Config = serde_yaml::from_value(wrapped)
.context(format!("cannot parse launchers file {}", path.display()))?;
Ok(config.launchers)
}
pub fn check_launchers_file(path: &Path) -> Result<Vec<ConfigProblem>> {
ensure_config_validation()?;
let (contents, wrapped) = parse_launchers_file(path)?;
let mut problems = launchers_file_problems(&contents, &wrapped);
if problems.is_empty() {
if let Err(e) = serde_yaml::from_value::<Config>(wrapped) {
problems.push(ConfigProblem::new("/", e.to_string()));
}
}
Ok(problems)
}
pub fn read_config_from_reader<R: Read>(reader: R, args: &Args) -> Result<ParsedConfig> {
let mut contents = String::new();
let mut reader = reader;
reader
.read_to_string(&mut contents)
.context("cannot read config")?;
let mut raw: Value = serde_yaml::from_str(&contents).context("cannot parse config")?;
let v0 = migrate_config_v0_in_memory(&mut raw);
report_config_warnings(&raw, &contents, v0, "config");
let config: Config = serde_yaml::from_value(raw).context("cannot parse config")?;
process_config(config, args)
}
fn process_config(config: Config, args: &Args) -> Result<ParsedConfig> {
let mut rafficonfigs = Vec::new();
for value in config.launchers.values() {
if value.is_mapping() {
let mut mc: RaffiConfig = serde_yaml::from_value(value.clone())
.context("cannot parse config entry".to_string())?;
mc.binary = mc.binary.map(|s| expand_config_value(&s));
mc.icon = mc.icon.map(|s| expand_config_value(&s));
mc.ifexist = mc.ifexist.map(|s| expand_config_value(&s));
mc.args = mc
.args
.map(|v| v.into_iter().map(|s| expand_config_value(&s)).collect());
if mc.disabled.unwrap_or(false)
|| !is_valid_config(&mut mc, args, &DefaultEnvProvider, &DefaultBinaryChecker)
{
continue;
}
rafficonfigs.push(mc);
}
}
let mut addons = config.addons;
let fb = &mut addons.file_browser;
fb.action = fb.action.as_ref().map(|s| expand_config_value(s));
fb.secondary_action = fb.secondary_action.as_ref().map(|s| expand_config_value(s));
fb.file_manager = fb.file_manager.as_ref().map(|s| expand_config_value(s));
for sf in &mut addons.script_filters {
sf.command = expand_config_value(&sf.command);
sf.icon = sf.icon.as_ref().map(|s| expand_config_value(s));
sf.action = sf.action.as_ref().map(|s| expand_config_value(s));
sf.secondary_action = sf.secondary_action.as_ref().map(|s| expand_config_value(s));
}
for ws in &mut addons.web_searches {
ws.url = expand_config_value(&ws.url);
ws.icon = ws.icon.as_ref().map(|s| expand_config_value(s));
}
for ts in &mut addons.text_snippets {
ts.icon = ts.icon.as_ref().map(|s| expand_config_value(s));
ts.file = ts.file.as_ref().map(|s| expand_config_value(s));
ts.command = ts.command.as_ref().map(|s| expand_config_value(s));
ts.directory = ts.directory.as_ref().map(|s| expand_config_value(s));
}
Ok(ParsedConfig {
general: config.general,
addons,
entries: rafficonfigs,
})
}
fn is_valid_config(
mc: &mut RaffiConfig,
args: &Args,
env_provider: &impl EnvProvider,
binary_checker: &impl BinaryChecker,
) -> bool {
if let Some(_script) = &mc.script {
if !binary_checker.exists(
mc.binary
.as_deref()
.unwrap_or(args.default_script_shell.as_deref().unwrap_or("bash")),
) {
return false;
}
} else if let Some(binary) = &mc.binary {
if !binary_checker.exists(binary) {
return false;
}
} else if let Some(description) = &mc.description {
mc.binary = Some(description.clone());
} else {
return false;
}
mc.ifenveq
.as_ref()
.is_none_or(|eq| eq.len() == 2 && env_provider.var(&eq[0]).unwrap_or_default() == eq[1])
&& mc
.ifenvset
.as_ref()
.is_none_or(|var| env_provider.var(var).is_ok())
&& mc
.ifenvnotset
.as_ref()
.is_none_or(|var| env_provider.var(var).is_err())
&& mc
.ifexist
.as_ref()
.is_none_or(|exist| binary_checker.exists(exist))
}
pub(crate) fn find_binary(binary: &str) -> bool {
if binary.contains('/') {
return Path::new(binary).exists();
}
std::env::var("PATH")
.unwrap_or_default()
.split(':')
.any(|path| Path::new(&format!("{path}/{binary}")).exists())
}
#[cfg(feature = "wayland")]
fn default_ui_type(_fuzzel_available: bool, _fallbacks_configured: bool) -> Result<UIType> {
Ok(UIType::Native)
}
#[cfg(not(feature = "wayland"))]
fn default_ui_type(fuzzel_available: bool, fallbacks_configured: bool) -> Result<UIType> {
if fuzzel_available && !fallbacks_configured {
Ok(UIType::Fuzzel)
} else {
Err(anyhow::anyhow!(
"No UI backend available. Install 'fuzzel' or build with the 'wayland' feature."
))
}
}
fn xdg_data_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
if let Ok(data_home) = std::env::var("XDG_DATA_HOME") {
dirs.push(PathBuf::from(data_home));
} else if let Ok(home) = std::env::var("HOME") {
dirs.push(PathBuf::from(home).join(".local/share"));
}
let system_dirs = std::env::var("XDG_DATA_DIRS")
.unwrap_or_else(|_| "/usr/local/share:/usr/share".to_string());
dirs.extend(std::env::split_paths(&system_dirs));
dirs
}
fn desktop_entry_dirs() -> Vec<PathBuf> {
xdg_data_dirs()
.into_iter()
.map(|dir| dir.join("applications"))
.collect()
}
fn parse_bool(value: &str) -> bool {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"true" | "1" | "yes"
)
}
fn parse_desktop_entry_keys(contents: &str) -> HashMap<String, String> {
let mut in_desktop_entry = false;
let mut keys = HashMap::new();
for line in contents.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if trimmed.starts_with('[') && trimmed.ends_with(']') {
if trimmed == "[Desktop Entry]" {
in_desktop_entry = true;
continue;
}
if in_desktop_entry {
break;
}
continue;
}
if !in_desktop_entry {
continue;
}
if let Some((key, value)) = trimmed.split_once('=') {
keys.insert(key.trim().to_string(), value.trim().to_string());
}
}
keys
}
fn split_desktop_exec(exec: &str) -> Vec<String> {
let mut tokens = Vec::new();
let mut current = String::new();
let mut chars = exec.chars().peekable();
let mut in_single = false;
let mut in_double = false;
while let Some(ch) = chars.next() {
match ch {
'\\' => {
if let Some(next) = chars.next() {
current.push(next);
}
}
'\'' if !in_double => in_single = !in_single,
'"' if !in_single => in_double = !in_double,
c if c.is_whitespace() && !in_single && !in_double => {
if !current.is_empty() {
tokens.push(std::mem::take(&mut current));
}
}
_ => current.push(ch),
}
}
if !current.is_empty() {
tokens.push(current);
}
tokens
}
fn strip_desktop_exec_field_codes(token: &str) -> String {
let mut cleaned = String::with_capacity(token.len());
let mut chars = token.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '%' {
match chars.peek().copied() {
Some('%') => {
cleaned.push('%');
chars.next();
}
Some(code) if "fFuUdDnNickvm".contains(code) => {
chars.next();
}
_ => cleaned.push(ch),
}
} else {
cleaned.push(ch);
}
}
cleaned.trim().to_string()
}
fn parse_desktop_exec(exec: &str) -> Option<(String, Vec<String>)> {
let mut tokens = split_desktop_exec(exec)
.into_iter()
.map(|token| strip_desktop_exec_field_codes(&token))
.filter(|token| !token.is_empty())
.collect::<Vec<_>>();
if tokens.is_empty() {
return None;
}
let binary = tokens.remove(0);
Some((binary, tokens))
}
pub(crate) struct DesktopScan {
pub actions: bool,
pub terminal: Option<Vec<String>>,
pub desktops: Vec<String>,
}
impl DesktopScan {
fn from_addons(addons: &AddonsConfig) -> Self {
let terminal = &addons.terminal;
Self {
actions: addons.apps.actions,
terminal: terminal
.enabled
.then(|| terminal::resolve_default(terminal.command.as_deref()))
.flatten(),
desktops: std::env::var("XDG_CURRENT_DESKTOP")
.unwrap_or_default()
.split(':')
.filter(|desktop| !desktop.is_empty())
.map(str::to_string)
.collect(),
}
}
fn shows_on_desktop(&self, keys: &HashMap<String, String>) -> bool {
let listed = |key: &str| {
keys.get(key).map(|value| {
value.split(';').any(|name| {
self.desktops
.iter()
.any(|desktop| !name.is_empty() && desktop.eq_ignore_ascii_case(name))
})
})
};
listed("OnlyShowIn") != Some(false) && listed("NotShowIn") != Some(true)
}
fn command(
&self,
exec: &str,
in_terminal: bool,
binary_checker: &impl BinaryChecker,
) -> Option<(String, Vec<String>)> {
let (binary, args) = parse_desktop_exec(exec)?;
if !binary_checker.exists(&binary) {
return None;
}
if !in_terminal {
return Some((binary, args));
}
let mut argv = terminal::exec_argv(self.terminal.as_deref()?, &binary, &args);
let program = argv.remove(0);
Some((program, argv))
}
}
fn parse_desktop_groups(contents: &str) -> Vec<(String, HashMap<String, String>)> {
let mut groups: Vec<(String, HashMap<String, String>)> = Vec::new();
for line in contents.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if let Some(name) = trimmed
.strip_prefix('[')
.and_then(|rest| rest.strip_suffix(']'))
{
groups.push((name.to_string(), HashMap::new()));
continue;
}
if let (Some((_, keys)), Some((key, value))) = (groups.last_mut(), trimmed.split_once('='))
{
keys.entry(key.trim().to_string())
.or_insert_with(|| value.trim().to_string());
}
}
groups
}
fn parse_desktop_entry_file(
path: &Path,
scan: &DesktopScan,
binary_checker: &impl BinaryChecker,
) -> Vec<RaffiConfig> {
let Ok(contents) = fs::read_to_string(path) else {
return Vec::new();
};
let groups = parse_desktop_groups(&contents);
let group = |name: &str| {
groups
.iter()
.find(|(group, _)| group == name)
.map(|(_, keys)| keys)
};
let Some(keys) = group("Desktop Entry") else {
return Vec::new();
};
if keys.get("Type").map(String::as_str) != Some("Application")
|| keys.get("Hidden").is_some_and(|value| parse_bool(value))
|| keys.get("NoDisplay").is_some_and(|value| parse_bool(value))
|| !scan.shows_on_desktop(keys)
{
return Vec::new();
}
let Some(name) = keys.get("Name") else {
return Vec::new();
};
if keys
.get("TryExec")
.is_some_and(|try_exec| !binary_checker.exists(try_exec))
{
return Vec::new();
}
let in_terminal = keys.get("Terminal").is_some_and(|value| parse_bool(value));
let icon = keys.get("Icon").cloned();
let entry = |description: String, exec: &str, icon: Option<String>| {
let (binary, args) = scan.command(exec, in_terminal, binary_checker)?;
Some(RaffiConfig {
binary: Some(binary),
args: (!args.is_empty()).then_some(args),
icon,
description: Some(description),
..Default::default()
})
};
let mut entries = Vec::new();
entries.extend(
keys.get("Exec")
.and_then(|exec| entry(name.clone(), exec, icon.clone())),
);
if scan.actions {
let actions = keys.get("Actions").map(String::as_str).unwrap_or_default();
for id in actions.split(';').filter(|id| !id.is_empty()) {
let Some(action) = group(&format!("Desktop Action {id}")) else {
continue;
};
let (Some(action_name), Some(exec)) = (action.get("Name"), action.get("Exec")) else {
continue;
};
entries.extend(entry(
format!("{name}: {action_name}"),
exec,
action.get("Icon").cloned().or_else(|| icon.clone()),
));
}
}
entries
}
fn detect_desktop_entries_from_dirs(
dirs: &[PathBuf],
scan: &DesktopScan,
binary_checker: &impl BinaryChecker,
) -> Vec<RaffiConfig> {
let mut seen = HashSet::new();
let mut ids = HashSet::new();
let mut entries = Vec::new();
for dir in dirs {
if !dir.exists() {
continue;
}
for entry in walkdir::WalkDir::new(dir)
.follow_links(true)
.sort_by_file_name()
.into_iter()
.filter_map(Result::ok)
{
if !entry.file_type().is_file() {
continue;
}
if entry.path().extension().and_then(|ext| ext.to_str()) != Some("desktop") {
continue;
}
let id = entry
.path()
.strip_prefix(dir)
.unwrap_or(entry.path())
.to_string_lossy()
.replace('/', "-");
if !ids.insert(id) {
continue;
}
for config in parse_desktop_entry_file(entry.path(), scan, binary_checker) {
let key = format!(
"{}\u{1f}{}\u{1f}{}",
config.description.as_deref().unwrap_or_default(),
config.binary.as_deref().unwrap_or_default(),
config.args.as_deref().unwrap_or(&[]).join("\u{1f}")
);
if seen.insert(key) {
entries.push(config);
}
}
}
}
entries.sort_by(|left, right| {
left.description
.as_deref()
.unwrap_or_default()
.cmp(right.description.as_deref().unwrap_or_default())
});
entries
}
fn detect_desktop_entries(
scan: &DesktopScan,
binary_checker: &impl BinaryChecker,
) -> Vec<RaffiConfig> {
detect_desktop_entries_from_dirs(&desktop_entry_dirs(), scan, binary_checker)
}
fn merge_apps(entries: &mut Vec<RaffiConfig>, apps: Vec<RaffiConfig>) {
let launchers = entries.len();
for app in apps {
let duplicate = entries[..launchers].iter().any(|launcher| {
let same_name = matches!(
(&launcher.description, &app.description),
(Some(left), Some(right)) if left.eq_ignore_ascii_case(right)
);
let same_command = launcher.binary.is_some()
&& launcher.binary == app.binary
&& launcher.args.as_deref().unwrap_or_default()
== app.args.as_deref().unwrap_or_default();
same_name || same_command
});
if !duplicate {
entries.push(app);
}
}
}
const DEFAULT_ICON_PATH: &str = "assets/default_icon.svg";
const DEFAULT_ICON_BYTES: &[u8] = include_bytes!("../assets/default_icon.svg");
const GENERIC_ICON_NAMES: &[&str] = &[
"application-x-executable",
"application-default-icon",
"application-x-desktop",
];
pub(crate) fn icon_cache_dir() -> PathBuf {
PathBuf::from(
std::env::var("XDG_CACHE_HOME")
.unwrap_or_else(|_| format!("{}/.cache", std::env::var("HOME").unwrap_or_default())),
)
.join("raffi")
}
fn is_supported_icon_extension(path: &Path) -> bool {
matches!(
path.extension().and_then(|ext| ext.to_str()),
Some("png" | "svg")
)
}
fn is_icon_file(path: &Path) -> bool {
path.is_file() && is_supported_icon_extension(path)
}
fn push_icon_candidate(candidates: &mut Vec<String>, seen: &mut HashSet<String>, candidate: &str) {
let trimmed = candidate.trim();
if trimmed.is_empty() {
return;
}
let owned = trimmed.to_string();
if seen.insert(owned.clone()) {
candidates.push(owned);
}
let Some(file_name) = Path::new(trimmed)
.file_name()
.and_then(|name| name.to_str())
else {
return;
};
if file_name == trimmed {
return;
}
let basename = file_name.to_string();
if seen.insert(basename.clone()) {
candidates.push(basename);
}
}
fn launcher_icon_candidates(config: &RaffiConfig) -> Vec<String> {
let mut candidates = Vec::new();
let mut seen = HashSet::new();
if let Some(icon) = config.icon.as_deref() {
push_icon_candidate(&mut candidates, &mut seen, icon);
}
if let Some(binary) = config.binary.as_deref() {
push_icon_candidate(&mut candidates, &mut seen, binary);
}
if let Some(args) = config.args.as_deref() {
for arg in args {
if arg.starts_with('-') || arg.contains("://") {
continue;
}
push_icon_candidate(&mut candidates, &mut seen, arg);
}
}
candidates
}
fn is_exec_wrapper_binary(candidate: &str) -> bool {
matches!(
Path::new(candidate)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(candidate),
"env" | "sh" | "bash" | "dash" | "zsh"
)
}
fn desktop_exec_icon_candidates(exec: &str, binary_checker: &impl BinaryChecker) -> Vec<String> {
let mut candidates = Vec::new();
let mut seen = HashSet::new();
for token in split_desktop_exec(exec)
.into_iter()
.map(|token| strip_desktop_exec_field_codes(&token))
.filter(|token| !token.is_empty())
{
if token.starts_with('-') || token.contains('=') || is_exec_wrapper_binary(&token) {
continue;
}
if !binary_checker.exists(&token) {
continue;
}
push_icon_candidate(&mut candidates, &mut seen, &token);
}
candidates
}
fn desktop_entry_icon_map_from_dirs(
dirs: &[PathBuf],
binary_checker: &impl BinaryChecker,
) -> HashMap<String, String> {
let mut icon_map = HashMap::new();
for dir in dirs {
if !dir.exists() {
continue;
}
for entry in walkdir::WalkDir::new(dir)
.max_depth(2)
.follow_links(true)
.into_iter()
.filter_map(Result::ok)
{
if !entry.file_type().is_file() {
continue;
}
if entry.path().extension().and_then(|ext| ext.to_str()) != Some("desktop") {
continue;
}
let Ok(contents) = fs::read_to_string(entry.path()) else {
continue;
};
let keys = parse_desktop_entry_keys(&contents);
if keys.get("Type").map(String::as_str) != Some("Application") {
continue;
}
if keys.get("Hidden").is_some_and(|value| parse_bool(value))
|| keys.get("NoDisplay").is_some_and(|value| parse_bool(value))
|| keys.get("Terminal").is_some_and(|value| parse_bool(value))
{
continue;
}
let Some(icon) = keys.get("Icon").cloned() else {
continue;
};
let mut candidates = keys
.get("Exec")
.map(|exec| desktop_exec_icon_candidates(exec, binary_checker))
.unwrap_or_default();
if let Some(try_exec) = keys.get("TryExec") {
if binary_checker.exists(try_exec) && !is_exec_wrapper_binary(try_exec) {
let mut try_exec_seen = candidates.iter().cloned().collect::<HashSet<_>>();
push_icon_candidate(&mut candidates, &mut try_exec_seen, try_exec);
}
}
for candidate in candidates {
icon_map.entry(candidate).or_insert_with(|| icon.clone());
}
}
}
icon_map
}
pub(crate) fn read_desktop_icon_map() -> HashMap<String, String> {
desktop_entry_icon_map_from_dirs(&desktop_entry_dirs(), &DefaultBinaryChecker)
}
pub(crate) fn resolve_icon_reference(
icon: &str,
icon_map: &HashMap<String, String>,
) -> Option<String> {
let expanded = expand_config_value(icon);
if is_icon_file(Path::new(&expanded)) {
return Some(expanded);
}
icon_map.get(icon).cloned().or_else(|| {
Path::new(icon)
.file_stem()
.and_then(|stem| stem.to_str())
.and_then(|stem| icon_map.get(stem).cloned())
})
}
fn bundled_default_icon_path() -> Option<String> {
let cache_dir = icon_cache_dir();
fs::create_dir_all(&cache_dir).ok()?;
let path = cache_dir.join(Path::new(DEFAULT_ICON_PATH).file_name().unwrap_or_default());
let needs_write = fs::read(&path)
.map(|contents| contents != DEFAULT_ICON_BYTES)
.unwrap_or(true);
if needs_write {
fs::write(&path, DEFAULT_ICON_BYTES).ok()?;
}
Some(path.to_string_lossy().into_owned())
}
pub(crate) fn default_icon_path(icon_map: &HashMap<String, String>) -> Option<String> {
GENERIC_ICON_NAMES
.iter()
.find_map(|icon| icon_map.get(*icon).cloned())
.or_else(bundled_default_icon_path)
}
pub(crate) fn resolve_launcher_icon_path(
config: &RaffiConfig,
icon_map: &HashMap<String, String>,
desktop_icon_map: &HashMap<String, String>,
) -> Option<String> {
let candidates = launcher_icon_candidates(config);
for candidate in &candidates {
if let Some(path) = resolve_icon_reference(candidate, icon_map) {
return Some(path);
}
}
for candidate in &candidates {
if let Some(icon) = desktop_icon_map.get(candidate) {
if let Some(path) = resolve_icon_reference(icon, icon_map) {
return Some(path);
}
}
}
default_icon_path(icon_map)
}
fn no_valid_config_message(configfile: &str) -> String {
let launchers = launchers_file_for(configfile);
format!(
"No valid launchers found.\nChecked: {configfile} and {}\nDefine launchers in launchers.yaml next to raffi.yaml, or in a `launchers:` section of raffi.yaml.\nYou can copy `examples/launchers.yaml` as a starting point, or install desktop applications so Raffi can auto-detect them.",
launchers.display()
)
}
fn save_to_cache_file(map: &HashMap<String, String>) -> Result<()> {
let cache_dir = icon_cache_dir();
fs::create_dir_all(&cache_dir).context("Failed to create cache directory")?;
let cache_file_path = cache_dir.join("icon.cache");
let mut cache_file = File::create(&cache_file_path).context("Failed to create cache file")?;
cache_file
.write_all(
serde_json::to_string(map)
.context("Failed to serialize icon map")?
.as_bytes(),
)
.context("Failed to write to cache file")?;
Ok(())
}
pub fn clear_icon_cache() -> Result<()> {
let cache_path = icon_cache_dir().join("icon.cache");
if Path::new(&cache_path).exists() {
fs::remove_file(&cache_path).context("Failed to remove icon cache file")?;
}
#[cfg(feature = "wayland")]
{
let thumbnails = icon_cache_dir().join(ui::THUMBNAIL_DIR_NAME);
if thumbnails.exists() {
fs::remove_dir_all(&thumbnails).context("Failed to remove icon thumbnails")?;
}
}
Ok(())
}
pub fn clear_emoji_cache() -> Result<()> {
let emoji_dir = icon_cache_dir().join("emoji");
if Path::new(&emoji_dir).exists() {
fs::remove_dir_all(&emoji_dir).context("Failed to remove emoji cache directory")?;
}
Ok(())
}
pub fn read_icon_map() -> Result<HashMap<String, String>> {
let cache_path = icon_cache_dir().join("icon.cache");
if !Path::new(&cache_path).exists() {
let icon_map = get_icon_map()?;
save_to_cache_file(&icon_map)?;
return Ok(icon_map);
}
let mut cache_file = File::open(&cache_path).context("Failed to open cache file")?;
let mut contents = String::new();
cache_file
.read_to_string(&mut contents)
.context("Failed to read cache file")?;
serde_json::from_str(&contents).context("Failed to deserialize cache file")
}
fn shell_quote(value: &str) -> String {
let is_safe = !value.is_empty()
&& value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"_./:=@%+-".contains(&b));
if is_safe {
return value.to_string();
}
format!("'{}'", value.replace('\'', r"'\''"))
}
fn format_command_word(value: &str) -> String {
if value.contains('/') {
return shell_quote(value);
}
if value.contains('=') {
return format!("'{}'", value.replace('\'', r"'\''"));
}
format!("env -- {}", shell_quote(value))
}
fn format_print_only_command(mc: &RaffiConfig, interpreter: &str) -> Result<String> {
let mut parts = Vec::new();
if let Some(script) = &mc.script {
parts.push(format_command_word(interpreter));
parts.push("-c".to_string());
parts.push(shell_quote(script));
if let Some(args) = &mc.args {
parts.push(shell_quote(interpreter));
parts.extend(args.iter().map(|arg| shell_quote(arg)));
}
} else {
let binary = mc.binary.as_deref().context("Binary not found")?;
parts.push(format_command_word(binary));
parts.extend(
mc.args
.as_deref()
.unwrap_or(&[])
.iter()
.map(|arg| shell_quote(arg)),
);
}
Ok(parts.join(" "))
}
pub fn execute_chosen_command(mc: &RaffiConfig, args: &Args, interpreter: &str) -> Result<()> {
if args.print_only {
println!("{}", format_print_only_command(mc, interpreter)?);
return Ok(());
}
if let Some(script) = &mc.script {
let mut command = Command::new(interpreter);
command.arg("-c").arg(script);
if let Some(args) = &mc.args {
command.arg(interpreter);
command.args(args);
}
command.spawn().context("cannot launch script")?;
} else {
Command::new(mc.binary.as_deref().context("Binary not found")?)
.args(mc.args.as_deref().unwrap_or(&[]))
.spawn()
.context("cannot launch command")?;
}
Ok(())
}
pub fn url_encode_query(query: &str) -> String {
let mut encoded = String::with_capacity(query.len() * 3);
for byte in query.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
encoded.push(byte as char);
}
_ => {
write!(encoded, "%{:02X}", byte).unwrap();
}
}
}
encoded
}
pub fn execute_web_search_url(url_template: &str, query: &str) -> Result<()> {
let encoded = url_encode_query(query);
let url = url_template.replace("{query}", &encoded);
Command::new("xdg-open")
.arg(&url)
.spawn()
.context("cannot open web search URL")?;
Ok(())
}
fn generate_schema() -> String {
let schema = schemars::schema_for!(ConfigSchema);
serde_json::to_string_pretty(&schema).expect("Failed to serialize JSON Schema")
}
fn generate_launchers_schema() -> String {
let schema = schemars::schema_for!(HashMap<String, RaffiConfig>);
serde_json::to_string_pretty(&schema).expect("Failed to serialize JSON Schema")
}
fn write_schema_if_changed(schema_path: &Path, schema: &str) -> Result<()> {
if fs::read_to_string(schema_path).is_ok_and(|current| current == schema) {
return Ok(());
}
let dir = schema_path.parent().unwrap_or(Path::new("."));
fs::create_dir_all(dir)
.with_context(|| format!("could not create schema directory {}", dir.display()))?;
fs::write(schema_path, schema)
.with_context(|| format!("could not write schema file {}", schema_path.display()))
}
fn write_schemas_for_config(configfile: &str) -> Result<()> {
let config_dir = Path::new(configfile).parent().unwrap_or(Path::new("."));
write_schema_if_changed(&config_dir.join("raffi-schema.json"), &generate_schema())?;
write_schema_if_changed(
&config_dir.join("launchers-schema.json"),
&generate_launchers_schema(),
)?;
Ok(())
}
pub fn run(args: Args) -> Result<()> {
if let Some(ref path) = args.debug_file {
debug::init(path);
debug_log!("raffi starting, debug log: {path}");
}
if args.version {
println!("raffi version {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
if args.install_browser_host || args.uninstall_browser_host {
return browser_host::manifest_command(args.uninstall_browser_host, args.force);
}
if args.refresh_cache {
clear_icon_cache()?;
clear_emoji_cache()?;
#[cfg(feature = "wayland")]
ui::clear_slack_cache();
}
let default_config_path = format!(
"{}/.config/raffi/raffi.yaml",
std::env::var("HOME").unwrap_or_default()
);
let configfile = args.configfile.as_deref().unwrap_or(&default_config_path);
debug_log!("config: using file {configfile}");
let config_exists = Path::new(configfile).exists();
let launchers_path = launchers_file_for(configfile);
if args.generate_schemas {
write_schemas_for_config(configfile)?;
return Ok(());
}
if args.check_config {
let mut checks = Vec::new();
if config_exists || !launchers_path.exists() {
checks.push((configfile.to_string(), check_config(configfile)?));
}
if launchers_path.exists() {
checks.push((
launchers_path.display().to_string(),
check_launchers_file(&launchers_path)?,
));
}
let mut count = 0;
for (source, problems) in &checks {
if problems.is_empty() {
println!("{source}: config OK");
}
for problem in problems {
eprintln!("{}", format_config_problem(source, problem));
}
count += problems.len();
}
if count > 0 {
anyhow::bail!("{count} problem(s) found");
}
return Ok(());
}
if let Err(e) = write_schemas_for_config(configfile) {
eprintln!("Warning: could not write schema files next to config: {e:#}");
}
let mut parsed_config = if config_exists {
read_config(configfile, &args).context("Failed to read config")?
} else if launchers_path.exists() {
let config = Config {
launchers: read_launchers_file(&launchers_path)?,
..Config::default()
};
process_config(config, &args)?
} else {
ParsedConfig {
general: GeneralConfig::default(),
addons: AddonsConfig::default(),
entries: Vec::new(),
}
};
debug_log!("config: loaded {} entries", parsed_config.entries.len());
if parsed_config.entries.is_empty() {
let scan = DesktopScan::from_addons(&parsed_config.addons);
let detected_entries = detect_desktop_entries(&scan, &DefaultBinaryChecker);
if detected_entries.is_empty() {
eprintln!("{}", no_valid_config_message(configfile));
std::process::exit(1);
}
if !parsed_config.addons.apps.enabled {
eprintln!(
"No valid launchers found in {configfile}; using {} auto-detected desktop applications.",
detected_entries.len()
);
}
parsed_config.entries = detected_entries;
} else if parsed_config.addons.apps.enabled {
let scan = DesktopScan::from_addons(&parsed_config.addons);
let apps = detect_desktop_entries(&scan, &DefaultBinaryChecker);
debug_log!("config: {} installed applications", apps.len());
merge_apps(&mut parsed_config.entries, apps);
}
let general = &parsed_config.general;
let no_icons = args.no_icons || general.no_icons.unwrap_or(false);
let ui_type_str = args.ui_type.as_ref().or(general.ui_type.as_ref());
let default_script_shell = args
.default_script_shell
.as_deref()
.or(general.default_script_shell.as_deref())
.unwrap_or("bash")
.to_string();
let theme_str = args.theme.as_ref().or(general.theme.as_ref());
let theme = if let Some(theme_str) = theme_str {
theme_str
.parse::<ThemeMode>()
.map_err(|e| anyhow::anyhow!(e))?
} else {
ThemeMode::Dark
};
debug_log!("config: theme={theme:?}");
#[cfg(feature = "wayland")]
let fuzzel_available = false;
#[cfg(not(feature = "wayland"))]
let fuzzel_available = find_binary("fuzzel");
let ui_type = if let Some(ui_type_str) = ui_type_str {
ui_type_str
.parse::<UIType>()
.map_err(|e| anyhow::anyhow!(e))?
} else {
default_ui_type(
fuzzel_available,
!general.fallbacks.is_empty() || general.main_view.is_some(),
)?
};
if ui_type == UIType::Fuzzel && !general.fallbacks.is_empty() {
eprintln!("Warning: fallbacks are configured but ui_type is fuzzel — fallbacks require the native UI. Set ui_type: native to use fallbacks.");
}
if ui_type == UIType::Fuzzel && general.main_view.is_some() {
eprintln!("Warning: main_view is configured but ui_type is fuzzel — main_view requires the native UI. Set ui_type: native to use main_view.");
}
debug_log!("config: ui_type={ui_type:?}");
let max_history = general.max_history.unwrap_or(10);
let mut font_sizes = if let Some(base) = general.font_size {
ui::FontSizes::from_base(base)
} else {
ui::FontSizes::default_sizes()
};
if let Some(padding) = general.padding {
font_sizes.outer_padding = padding;
}
let (window_width, window_height) = resolve_window_dimensions(general);
let ui_settings = ui::UISettings {
no_icons,
initial_query: args.initial_query.clone(),
select_1: args.select_1,
app_name: args.app_name.clone(),
theme,
theme_colors: parsed_config.general.theme_colors.clone(),
renderer: general.renderer,
max_history,
font_sizes,
font_family: general.font_family.clone(),
window_width,
window_height,
sort_mode: general.sort_mode.clone(),
fallbacks: general.fallbacks.clone(),
main_view: general.main_view.clone(),
clipboard_history: general.clipboard_history,
terminal_classes: general.terminal_classes.clone(),
show_preview: general.show_preview,
preview: general.preview.clone(),
default_script_shell: default_script_shell.clone(),
};
debug_log!(
"ui: settings: no_icons={} window={}x{} max_history={} sort_mode={:?}",
ui_settings.no_icons,
ui_settings.window_width,
ui_settings.window_height,
ui_settings.max_history,
ui_settings.sort_mode
);
let ui = ui::get_ui(ui_type);
let chosen = ui
.show(&parsed_config.entries, &parsed_config.addons, &ui_settings)
.context("Failed to show UI")?;
let chosen_name = chosen.trim();
debug_log!("ui: chosen={chosen_name:?}");
if chosen_name.is_empty() {
std::process::exit(0);
}
let mc = parsed_config
.entries
.iter()
.find(|mc| {
mc.description.as_deref() == Some(chosen_name)
|| mc.binary.as_deref() == Some(chosen_name)
})
.context("No matching configuration found")?;
let interpreter = if mc.script.is_some() {
mc.binary.as_deref().unwrap_or(&default_script_shell)
} else {
""
};
debug_log!(
"exec: binary={:?} script={} args={:?}",
mc.binary,
mc.script.is_some(),
mc.args
);
execute_chosen_command(mc, &args, interpreter).context("Failed to execute command")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
use std::os::unix::fs::PermissionsExt;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_test_dir(name: &str) -> PathBuf {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::env::temp_dir().join(format!("raffi-{name}-{unique}"));
fs::create_dir_all(&path).unwrap();
path
}
fn script_config(script: &str, args: Option<Vec<&str>>) -> RaffiConfig {
RaffiConfig {
script: Some(script.to_string()),
args: args.map(|args| args.iter().map(|arg| arg.to_string()).collect()),
..Default::default()
}
}
fn binary_config(binary: Option<&str>, args: Option<Vec<&str>>) -> RaffiConfig {
RaffiConfig {
binary: binary.map(str::to_string),
args: args.map(|args| args.iter().map(|arg| arg.to_string()).collect()),
..Default::default()
}
}
#[test]
fn test_print_only_script_without_args_has_no_dollar_zero() {
let config = script_config("echo hello", None);
assert_eq!(
format_print_only_command(&config, "bash").unwrap(),
"env -- bash -c 'echo hello'"
);
}
#[test]
fn test_print_only_script_with_empty_args_keeps_dollar_zero() {
let config = script_config("echo hello", Some(vec![]));
assert_eq!(
format_print_only_command(&config, "bash").unwrap(),
"env -- bash -c 'echo hello' bash"
);
}
#[test]
fn test_print_only_script_quotes_args() {
let config = script_config("echo \"$1\"", Some(vec!["foo", "bar baz"]));
assert_eq!(
format_print_only_command(&config, "bash").unwrap(),
"env -- bash -c 'echo \"$1\"' bash foo 'bar baz'"
);
}
#[test]
fn test_print_only_script_escapes_single_quotes() {
let config = script_config("echo 'hi there'", None);
assert_eq!(
format_print_only_command(&config, "bash").unwrap(),
r#"env -- bash -c 'echo '\''hi there'\'''"#
);
}
#[test]
fn test_print_only_script_preserves_newlines() {
let config = script_config("echo one\necho two", None);
assert_eq!(
format_print_only_command(&config, "sh").unwrap(),
"env -- sh -c 'echo one\necho two'"
);
}
#[test]
fn test_print_only_binary_with_safe_args_is_unquoted() {
let config = binary_config(
Some("/usr/bin/firefox"),
Some(vec!["--new-window", "--width=100"]),
);
assert_eq!(
format_print_only_command(&config, "").unwrap(),
"/usr/bin/firefox --new-window --width=100"
);
}
#[test]
fn test_print_only_binary_quotes_unsafe_args() {
let config = binary_config(Some("kitty"), Some(vec!["hello world", "; rm -rf /tmp/x"]));
assert_eq!(
format_print_only_command(&config, "").unwrap(),
"env -- kitty 'hello world' '; rm -rf /tmp/x'"
);
}
#[test]
fn test_print_only_formats_command_position_words() {
assert_eq!(format_command_word("firefox"), "env -- firefox");
assert_eq!(format_command_word("source"), "env -- source");
assert_eq!(format_command_word("/usr/bin/firefox"), "/usr/bin/firefox");
assert_eq!(format_command_word("./my app"), "'./my app'");
assert_eq!(format_command_word("FOO=bar"), "'FOO=bar'");
assert_eq!(shell_quote("--width=100"), "--width=100");
}
#[test]
fn test_print_only_missing_binary_errors() {
let config = binary_config(None, None);
assert!(format_print_only_command(&config, "").is_err());
}
#[test]
fn test_print_only_script_round_trips_through_shell() {
let config = script_config(
"printf '%s|%s|%s' \"$0\" \"$1\" \"$2\"",
Some(vec!["first arg", "it's second"]),
);
let formatted = format_print_only_command(&config, "sh").unwrap();
let output = Command::new("sh")
.arg("-c")
.arg(&formatted)
.output()
.expect("failed to run formatted command");
assert_eq!(
String::from_utf8_lossy(&output.stdout),
"sh|first arg|it's second"
);
}
#[test]
fn test_print_only_binary_round_trips_through_shell() {
let config = binary_config(Some("printf"), Some(vec!["%s|%s", "hello world", "a'b"]));
let formatted = format_print_only_command(&config, "").unwrap();
let output = Command::new("sh")
.arg("-c")
.arg(&formatted)
.output()
.expect("failed to run formatted command");
assert_eq!(String::from_utf8_lossy(&output.stdout), "hello world|a'b");
}
fn shadowed_path(dir: &Path) -> String {
format!("{}:/usr/bin:/bin", dir.display())
}
fn write_argv_printer(dir: &Path, name: &str) {
let path = dir.join(name);
fs::write(
&path,
"#!/bin/sh\nprintf 'EXTERNAL\\n'\nfor a in \"$@\"; do printf '[%s]\\n' \"$a\"; done\n",
)
.unwrap();
let mut perms = fs::metadata(&path).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&path, perms).unwrap();
}
#[test]
fn test_print_only_binary_shadowing_builtin_runs_external_program() {
let dir = temp_test_dir("builtin-collision");
write_argv_printer(&dir, "printf");
let config = binary_config(Some("printf"), Some(vec!["$(id -u)", "a b; echo pwned"]));
let formatted = format_print_only_command(&config, "").unwrap();
let output = Command::new("/bin/sh")
.arg("-c")
.arg(&formatted)
.env("PATH", shadowed_path(&dir))
.output()
.expect("failed to run formatted command");
let stdout = String::from_utf8_lossy(&output.stdout);
assert_eq!(stdout, "EXTERNAL\n[$(id -u)]\n[a b; echo pwned]\n");
assert!(!stdout.contains("pwned\n[") && !stdout.ends_with("pwned\n"));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_print_only_binary_shadowing_shell_specific_builtin_runs_external_program() {
let dir = temp_test_dir("source-collision");
write_argv_printer(&dir, "source");
let config = binary_config(Some("source"), Some(vec!["/etc/hostname"]));
let formatted = format_print_only_command(&config, "").unwrap();
let output = Command::new("/bin/sh")
.arg("-c")
.arg(&formatted)
.env("PATH", shadowed_path(&dir))
.output()
.expect("failed to run formatted command");
assert_eq!(
String::from_utf8_lossy(&output.stdout),
"EXTERNAL\n[/etc/hostname]\n"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_print_only_binary_shadowed_by_shell_function_runs_external_program() {
let dir = temp_test_dir("function-collision");
write_argv_printer(&dir, "raffifakeapp");
let config = binary_config(Some("raffifakeapp"), Some(vec!["one"]));
let formatted = format_print_only_command(&config, "").unwrap();
let output = Command::new("/bin/sh")
.arg("-c")
.arg(format!(
"raffifakeapp() {{ printf 'FUNCTION\\n'; }}\n{formatted}"
))
.env("PATH", shadowed_path(&dir))
.output()
.expect("failed to run formatted command");
assert_eq!(String::from_utf8_lossy(&output.stdout), "EXTERNAL\n[one]\n");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_print_only_script_interpreter_shadowing_builtin_runs_external_program() {
let dir = temp_test_dir("interpreter-collision");
write_argv_printer(&dir, "test");
let config = script_config("echo hello", None);
let formatted = format_print_only_command(&config, "test").unwrap();
let output = Command::new("/bin/sh")
.arg("-c")
.arg(&formatted)
.env("PATH", shadowed_path(&dir))
.output()
.expect("failed to run formatted command");
assert_eq!(
String::from_utf8_lossy(&output.stdout),
"EXTERNAL\n[-c]\n[echo hello]\n"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_read_config_from_reader() {
let yaml_config = r#"
shell:
binary: sh
description: "Shell"
hello_script:
script: "echo hello"
description: "Hello script"
"#;
let reader = Cursor::new(yaml_config);
let args = Args {
help: false,
version: false,
configfile: None,
print_only: false,
refresh_cache: false,
no_icons: true,
default_script_shell: None,
ui_type: None,
app_name: None,
initial_query: None,
select_1: false,
theme: None,
generate_schemas: false,
check_config: false,
debug_file: None,
install_browser_host: false,
uninstall_browser_host: false,
force: false,
};
let parsed_config = read_config_from_reader(reader, &args).unwrap();
assert_eq!(parsed_config.entries.len(), 2);
assert!(parsed_config.addons.currency.enabled);
assert!(parsed_config.addons.calculator.enabled);
let expected_configs = vec![
RaffiConfig {
binary: Some("sh".to_string()),
description: Some("Shell".to_string()),
..Default::default()
},
RaffiConfig {
description: Some("Hello script".to_string()),
script: Some("echo hello".to_string()),
..Default::default()
},
];
for expected_config in &expected_configs {
assert!(parsed_config.entries.contains(expected_config));
}
}
#[test]
fn test_read_config_keeps_launcher_file_order() {
let names = [
"zulu", "alpha", "mike", "bravo", "yankee", "charlie", "xray", "delta", "whiskey",
"echo",
];
let launchers: String = names
.iter()
.map(|name| format!(" {name}:\n script: \"true\"\n description: \"{name}\"\n"))
.collect();
let yaml_config = format!("version: 1\nlaunchers:\n{launchers}");
let parsed_config =
read_config_from_reader(Cursor::new(yaml_config), &Args::default()).unwrap();
let descriptions: Vec<_> = parsed_config
.entries
.iter()
.map(|entry| entry.description.as_deref().unwrap_or_default())
.collect();
assert_eq!(descriptions, names);
}
#[test]
fn test_addons_config_parsing() {
let yaml_config = r#"
addons:
currency:
enabled: true
currencies: ["USD", "EUR", "GBP"]
calculator:
enabled: false
shell:
binary: sh
description: "Shell"
"#;
let reader = Cursor::new(yaml_config);
let args = Args {
help: false,
version: false,
configfile: None,
print_only: false,
refresh_cache: false,
no_icons: true,
default_script_shell: None,
ui_type: None,
app_name: None,
initial_query: None,
select_1: false,
theme: None,
generate_schemas: false,
check_config: false,
debug_file: None,
install_browser_host: false,
uninstall_browser_host: false,
force: false,
};
let parsed_config = read_config_from_reader(reader, &args).unwrap();
assert!(parsed_config.addons.currency.enabled);
assert!(!parsed_config.addons.calculator.enabled);
assert_eq!(
parsed_config.addons.currency.currencies,
Some(vec![
"USD".to_string(),
"EUR".to_string(),
"GBP".to_string()
])
);
assert_eq!(parsed_config.entries.len(), 1);
}
struct MockEnvProvider {
vars: HashMap<String, String>,
}
impl EnvProvider for MockEnvProvider {
fn var(&self, key: &str) -> Result<String, std::env::VarError> {
self.vars
.get(key)
.cloned()
.ok_or(std::env::VarError::NotPresent)
}
}
struct MockBinaryChecker {
binaries: Vec<String>,
}
impl BinaryChecker for MockBinaryChecker {
fn exists(&self, binary: &str) -> bool {
self.binaries.contains(&binary.to_string())
}
}
#[test]
fn test_is_valid_config() {
let mut config = RaffiConfig {
binary: Some("test-binary".to_string()),
description: Some("Test Description".to_string()),
script: None,
args: None,
icon: None,
ifenveq: Some(vec!["TEST_VAR".to_string(), "true".to_string()]),
ifenvset: Some("ANOTHER_VAR".to_string()),
ifenvnotset: Some("MISSING_VAR".to_string()),
ifexist: Some("another-binary".to_string()),
disabled: None,
show_preview: None,
};
let args = Args {
help: false,
version: false,
configfile: None,
print_only: false,
refresh_cache: false,
no_icons: true,
default_script_shell: None,
ui_type: None,
app_name: None,
initial_query: None,
select_1: false,
theme: None,
generate_schemas: false,
check_config: false,
debug_file: None,
install_browser_host: false,
uninstall_browser_host: false,
force: false,
};
let env_provider = MockEnvProvider {
vars: {
let mut vars = HashMap::new();
vars.insert("TEST_VAR".to_string(), "true".to_string());
vars.insert("ANOTHER_VAR".to_string(), "some_value".to_string());
vars
},
};
let binary_checker = MockBinaryChecker {
binaries: vec!["test-binary".to_string(), "another-binary".to_string()],
};
assert!(is_valid_config(
&mut config,
&args,
&env_provider,
&binary_checker
));
}
#[test]
fn test_script_filter_action_parsing() {
let yaml_config = r#"
addons:
script_filters:
- name: "Bookmarks"
keyword: "bm"
command: "my-bookmark-script"
args: ["-j"]
action: "wl-copy {value}"
secondary_action: "xdg-open {value}"
- name: "Timezones"
keyword: "tz"
command: "batz"
args: ["-j"]
shell:
binary: sh
description: "Shell"
"#;
let reader = Cursor::new(yaml_config);
let args = Args {
help: false,
version: false,
configfile: None,
print_only: false,
refresh_cache: false,
no_icons: true,
default_script_shell: None,
ui_type: None,
app_name: None,
initial_query: None,
select_1: false,
theme: None,
generate_schemas: false,
check_config: false,
debug_file: None,
install_browser_host: false,
uninstall_browser_host: false,
force: false,
};
let parsed_config = read_config_from_reader(reader, &args).unwrap();
assert_eq!(parsed_config.addons.script_filters.len(), 2);
let bm = &parsed_config.addons.script_filters[0];
assert_eq!(bm.name, "Bookmarks");
assert_eq!(bm.action, Some("wl-copy {value}".to_string()));
assert_eq!(bm.secondary_action, Some("xdg-open {value}".to_string()));
let tz = &parsed_config.addons.script_filters[1];
assert_eq!(tz.name, "Timezones");
assert_eq!(tz.action, None);
assert_eq!(tz.secondary_action, None);
}
#[test]
fn test_file_browser_sort_parsing() {
let parse = |s: &str| serde_yaml::from_str::<FileBrowserSort>(s).unwrap();
assert_eq!(parse("name"), FileBrowserSort::Name);
assert_eq!(parse("modified"), FileBrowserSort::Modified);
assert_eq!(parse("date"), FileBrowserSort::Modified);
assert_eq!(parse("created"), FileBrowserSort::Created);
assert!(serde_yaml::from_str::<FileBrowserSort>("size").is_err());
assert_eq!(
FileBrowserAddonConfig::default().sort,
FileBrowserSort::Name
);
assert_eq!(FileBrowserSort::Created.next(), FileBrowserSort::Name);
}
#[test]
fn test_file_browser_show_parsing() {
let parse = |s: &str| serde_yaml::from_str::<FileBrowserShow>(s).unwrap();
assert_eq!(parse("all"), FileBrowserShow::All);
assert_eq!(parse("files"), FileBrowserShow::Files);
assert_eq!(parse("directories"), FileBrowserShow::Directories);
assert_eq!(parse("dirs"), FileBrowserShow::Directories);
assert_eq!(parse("directory"), FileBrowserShow::Directories);
assert!(serde_yaml::from_str::<FileBrowserShow>("links").is_err());
assert_eq!(FileBrowserAddonConfig::default().show, FileBrowserShow::All);
assert_eq!(FileBrowserShow::All.next(), FileBrowserShow::Files);
assert_eq!(FileBrowserShow::Files.next(), FileBrowserShow::Directories);
assert_eq!(FileBrowserShow::Directories.next(), FileBrowserShow::All);
}
#[test]
fn test_text_snippet_action_parsing() {
let yaml_config = r#"
addons:
text_snippets:
- name: "Emails"
keyword: "em"
action: "wl-copy {value}"
secondary_action: "wtype {value}"
snippets:
- name: "Personal"
value: "user@example.com"
- name: "Plain"
keyword: "pl"
snippets:
- name: "Hello"
value: "hello"
shell:
binary: sh
description: "Shell"
"#;
let reader = Cursor::new(yaml_config);
let args = Args {
help: false,
version: false,
configfile: None,
print_only: false,
refresh_cache: false,
no_icons: true,
default_script_shell: None,
ui_type: None,
app_name: None,
initial_query: None,
select_1: false,
theme: None,
generate_schemas: false,
check_config: false,
debug_file: None,
install_browser_host: false,
uninstall_browser_host: false,
force: false,
};
let parsed_config = read_config_from_reader(reader, &args).unwrap();
assert_eq!(parsed_config.addons.text_snippets.len(), 2);
let em = &parsed_config.addons.text_snippets[0];
assert_eq!(em.name, "Emails");
assert_eq!(em.action, Some("wl-copy {value}".to_string()));
assert_eq!(em.secondary_action, Some("wtype {value}".to_string()));
let pl = &parsed_config.addons.text_snippets[1];
assert_eq!(pl.name, "Plain");
assert_eq!(pl.action, None);
assert_eq!(pl.secondary_action, None);
}
#[test]
fn test_expand_tilde() {
let home = std::env::var("HOME").unwrap();
assert_eq!(expand_tilde("~/foo/bar"), format!("{home}/foo/bar"));
}
#[test]
fn test_expand_tilde_no_tilde() {
assert_eq!(expand_tilde("/usr/bin/foo"), "/usr/bin/foo");
assert_eq!(expand_tilde("relative/path"), "relative/path");
}
#[test]
fn test_expand_env_vars() {
let home = std::env::var("HOME").unwrap();
assert_eq!(expand_env_vars("${HOME}/foo"), format!("{home}/foo"));
}
#[test]
fn test_expand_env_vars_unknown() {
assert_eq!(expand_env_vars("${NONEXISTENT_VAR_12345}/foo"), "/foo");
}
#[test]
fn test_expand_env_vars_multiple() {
let home = std::env::var("HOME").unwrap();
let user = std::env::var("USER").unwrap_or_default();
assert_eq!(
expand_env_vars("${HOME}/stuff/${USER}/data"),
format!("{home}/stuff/{user}/data")
);
}
#[test]
fn test_expand_env_vars_no_vars() {
assert_eq!(expand_env_vars("/usr/bin/foo"), "/usr/bin/foo");
assert_eq!(expand_env_vars("plain text"), "plain text");
}
#[test]
fn test_expand_config_value_combined() {
let home = std::env::var("HOME").unwrap();
let user = std::env::var("USER").unwrap_or_default();
assert_eq!(
expand_config_value("~/foo/${USER}/bar"),
format!("{home}/foo/{user}/bar")
);
}
#[test]
fn test_config_expands_env_vars_in_fields() {
let home = std::env::var("HOME").unwrap();
let yaml_config = r#"
version: 1
launchers:
myapp:
binary: "${HOME}/bin/myapp"
description: "My App"
args: ["${HOME}/Downloads/file.txt", "--verbose"]
icon: "${HOME}/icons/myapp.png"
ifexist: "${HOME}/bin/myapp"
"#;
let reader = Cursor::new(yaml_config);
let config: super::Config = serde_yaml::from_reader(reader).expect("cannot parse config");
let mut rafficonfigs = Vec::new();
for value in config.launchers.values() {
if value.is_mapping() {
let mut mc: RaffiConfig = serde_yaml::from_value(value.clone()).unwrap();
mc.binary = mc.binary.map(|s| expand_config_value(&s));
mc.icon = mc.icon.map(|s| expand_config_value(&s));
mc.ifexist = mc.ifexist.map(|s| expand_config_value(&s));
mc.args = mc
.args
.map(|v| v.into_iter().map(|s| expand_config_value(&s)).collect());
rafficonfigs.push(mc);
}
}
assert_eq!(rafficonfigs.len(), 1);
let mc = &rafficonfigs[0];
assert_eq!(mc.binary, Some(format!("{home}/bin/myapp")));
assert_eq!(mc.icon, Some(format!("{home}/icons/myapp.png")));
assert_eq!(mc.ifexist, Some(format!("{home}/bin/myapp")));
assert_eq!(
mc.args,
Some(vec![
format!("{home}/Downloads/file.txt"),
"--verbose".to_string()
])
);
}
#[test]
fn test_config_expands_tilde_in_fields() {
let home = std::env::var("HOME").unwrap();
let yaml_config = r#"
version: 1
launchers:
myapp:
binary: "~/bin/myapp"
description: "My App"
args: ["~/Downloads/file.txt", "--verbose"]
icon: "~/icons/myapp.png"
ifexist: "~/bin/myapp"
"#;
let reader = Cursor::new(yaml_config);
let config: super::Config = serde_yaml::from_reader(reader).expect("cannot parse config");
let mut rafficonfigs = Vec::new();
for value in config.launchers.values() {
if value.is_mapping() {
let mut mc: RaffiConfig = serde_yaml::from_value(value.clone()).unwrap();
mc.binary = mc.binary.map(|s| expand_tilde(&s));
mc.icon = mc.icon.map(|s| expand_tilde(&s));
mc.ifexist = mc.ifexist.map(|s| expand_tilde(&s));
mc.args = mc
.args
.map(|v| v.into_iter().map(|s| expand_tilde(&s)).collect());
rafficonfigs.push(mc);
}
}
assert_eq!(rafficonfigs.len(), 1);
let mc = &rafficonfigs[0];
assert_eq!(mc.binary, Some(format!("{home}/bin/myapp")));
assert_eq!(mc.icon, Some(format!("{home}/icons/myapp.png")));
assert_eq!(mc.ifexist, Some(format!("{home}/bin/myapp")));
assert_eq!(
mc.args,
Some(vec![
format!("{home}/Downloads/file.txt"),
"--verbose".to_string()
])
);
}
#[cfg(feature = "wayland")]
#[test]
fn test_default_ui_type_prefers_native_over_fuzzel() {
assert_eq!(default_ui_type(true, false).unwrap(), UIType::Native);
assert_eq!(default_ui_type(true, true).unwrap(), UIType::Native);
}
#[cfg(not(feature = "wayland"))]
#[test]
fn test_default_ui_type_uses_fuzzel_without_native_support() {
assert_eq!(default_ui_type(true, false).unwrap(), UIType::Fuzzel);
assert!(default_ui_type(false, false).is_err());
assert!(default_ui_type(true, true).is_err());
}
#[test]
fn test_general_config_parsing() {
let yaml_config = r#"
general:
ui_type: native
default_script_shell: zsh
no_icons: true
shell:
binary: sh
description: "Shell"
"#;
let reader = Cursor::new(yaml_config);
let args = Args {
help: false,
version: false,
configfile: None,
print_only: false,
refresh_cache: false,
no_icons: false,
default_script_shell: None,
ui_type: None,
app_name: None,
initial_query: None,
select_1: false,
theme: None,
generate_schemas: false,
check_config: false,
debug_file: None,
install_browser_host: false,
uninstall_browser_host: false,
force: false,
};
let parsed_config = read_config_from_reader(reader, &args).unwrap();
assert_eq!(parsed_config.general.ui_type, Some("native".to_string()));
assert_eq!(
parsed_config.general.default_script_shell,
Some("zsh".to_string())
);
assert_eq!(parsed_config.general.no_icons, Some(true));
assert_eq!(parsed_config.entries.len(), 1);
}
#[test]
fn test_config_without_general_section() {
let yaml_config = r#"
shell:
binary: sh
description: "Shell"
"#;
let reader = Cursor::new(yaml_config);
let args = Args {
help: false,
version: false,
configfile: None,
print_only: false,
refresh_cache: false,
no_icons: false,
default_script_shell: None,
ui_type: None,
app_name: None,
initial_query: None,
select_1: false,
theme: None,
generate_schemas: false,
check_config: false,
debug_file: None,
install_browser_host: false,
uninstall_browser_host: false,
force: false,
};
let parsed_config = read_config_from_reader(reader, &args).unwrap();
assert!(parsed_config.general.ui_type.is_none());
assert!(parsed_config.general.default_script_shell.is_none());
assert!(parsed_config.general.no_icons.is_none());
assert_eq!(parsed_config.entries.len(), 1);
}
#[test]
fn test_general_clipboard_history_parsing() {
let parse =
|yaml: &str| serde_yaml::from_str::<GeneralConfig>(yaml).map(|g| g.clipboard_history);
assert_eq!(parse("{}").unwrap(), ClipboardHistory::Auto);
for (value, expected) in [
("auto", ClipboardHistory::Auto),
("cliphist", ClipboardHistory::Cliphist),
("copyq", ClipboardHistory::Copyq),
("clipman", ClipboardHistory::Clipman),
("none", ClipboardHistory::None),
] {
assert_eq!(
parse(&format!("clipboard_history: {value}")).unwrap(),
expected
);
}
assert!(parse("clipboard_history: gpaste").is_err());
}
#[test]
fn test_partial_general_config() {
let yaml_config = r#"
general:
no_icons: true
shell:
binary: sh
description: "Shell"
"#;
let reader = Cursor::new(yaml_config);
let args = Args {
help: false,
version: false,
configfile: None,
print_only: false,
refresh_cache: false,
no_icons: false,
default_script_shell: None,
ui_type: None,
app_name: None,
initial_query: None,
select_1: false,
theme: None,
generate_schemas: false,
check_config: false,
debug_file: None,
install_browser_host: false,
uninstall_browser_host: false,
force: false,
};
let parsed_config = read_config_from_reader(reader, &args).unwrap();
assert!(parsed_config.general.ui_type.is_none());
assert!(parsed_config.general.default_script_shell.is_none());
assert_eq!(parsed_config.general.no_icons, Some(true));
assert_eq!(parsed_config.entries.len(), 1);
}
#[test]
fn test_general_config_ui_settings_parsing() {
let yaml_config = r#"
general:
font_size: 16
font_family: "Inter"
window_width: 900
window_height: 500
shell:
binary: sh
description: "Shell"
"#;
let reader = Cursor::new(yaml_config);
let args = Args {
help: false,
version: false,
configfile: None,
print_only: false,
refresh_cache: false,
no_icons: false,
default_script_shell: None,
ui_type: None,
app_name: None,
initial_query: None,
select_1: false,
theme: None,
generate_schemas: false,
check_config: false,
debug_file: None,
install_browser_host: false,
uninstall_browser_host: false,
force: false,
};
let parsed_config = read_config_from_reader(reader, &args).unwrap();
assert_eq!(parsed_config.general.font_size, Some(16.0));
assert_eq!(parsed_config.general.font_family, Some("Inter".to_string()));
assert_eq!(parsed_config.general.window_width, Some(900.0));
assert_eq!(parsed_config.general.window_height, Some(500.0));
}
#[test]
fn test_general_config_theme_parsing() {
let yaml_config = r#"
general:
theme: light
shell:
binary: sh
description: "Shell"
"#;
let reader = Cursor::new(yaml_config);
let args = Args {
help: false,
version: false,
configfile: None,
print_only: false,
refresh_cache: false,
no_icons: false,
default_script_shell: None,
ui_type: None,
app_name: None,
initial_query: None,
select_1: false,
theme: None,
generate_schemas: false,
check_config: false,
debug_file: None,
install_browser_host: false,
uninstall_browser_host: false,
force: false,
};
let parsed_config = read_config_from_reader(reader, &args).unwrap();
assert_eq!(parsed_config.general.theme, Some("light".to_string()));
}
#[test]
fn test_config_window_dimensions_are_used() {
let general = GeneralConfig {
window_width: Some(700.0),
window_height: Some(450.0),
..Default::default()
};
assert_eq!(resolve_window_dimensions(&general), (700.0, 450.0));
assert_eq!(
resolve_window_dimensions(&GeneralConfig::default()),
(800.0, 600.0)
);
}
#[test]
fn test_window_geometry_flags_are_not_accepted() {
for flag in ["--width", "--height", "--x", "--y"] {
assert!(
Args::parse_args_default::<&str>(&[flag, "100"]).is_err(),
"{flag} should not be accepted"
);
}
}
#[test]
fn test_app_name_flag_is_parsed() {
let args = Args::parse_args_default::<&str>(&["--app-name", "org.example.raffi"]).unwrap();
assert_eq!(args.app_name.as_deref(), Some("org.example.raffi"));
}
#[test]
fn test_theme_mode_from_str() {
assert_eq!("dark".parse::<ThemeMode>().unwrap(), ThemeMode::Dark);
assert_eq!("Dark".parse::<ThemeMode>().unwrap(), ThemeMode::Dark);
assert_eq!("DARK".parse::<ThemeMode>().unwrap(), ThemeMode::Dark);
assert_eq!("light".parse::<ThemeMode>().unwrap(), ThemeMode::Light);
assert_eq!("Light".parse::<ThemeMode>().unwrap(), ThemeMode::Light);
assert_eq!("LIGHT".parse::<ThemeMode>().unwrap(), ThemeMode::Light);
assert_eq!(
"catppuccin".parse::<ThemeMode>().unwrap(),
ThemeMode::CatppuccinMocha
);
assert_eq!(
"catppuccin-mocha".parse::<ThemeMode>().unwrap(),
ThemeMode::CatppuccinMocha
);
assert_eq!(
"catppuccin-latte".parse::<ThemeMode>().unwrap(),
ThemeMode::CatppuccinLatte
);
assert_eq!(
"tokyo-night".parse::<ThemeMode>().unwrap(),
ThemeMode::TokyoNight
);
assert_eq!(
"tokyonight".parse::<ThemeMode>().unwrap(),
ThemeMode::TokyoNight
);
assert_eq!("nord".parse::<ThemeMode>().unwrap(), ThemeMode::Nord);
assert_eq!("gruvbox".parse::<ThemeMode>().unwrap(), ThemeMode::Gruvbox);
assert_eq!("dracula".parse::<ThemeMode>().unwrap(), ThemeMode::Dracula);
assert_eq!(
"rose-pine".parse::<ThemeMode>().unwrap(),
ThemeMode::RosePine
);
assert_eq!(
"rosepine".parse::<ThemeMode>().unwrap(),
ThemeMode::RosePine
);
assert_eq!(
"kanagawa".parse::<ThemeMode>().unwrap(),
ThemeMode::Kanagawa
);
assert!(!ThemeMode::Dark.is_light());
assert!(ThemeMode::Light.is_light());
assert!(ThemeMode::CatppuccinLatte.is_light());
assert!(!ThemeMode::CatppuccinMocha.is_light());
assert!("invalid".parse::<ThemeMode>().is_err());
}
#[test]
fn test_url_encode_query() {
assert_eq!(url_encode_query("hello"), "hello");
assert_eq!(url_encode_query("hello world"), "hello%20world");
assert_eq!(url_encode_query("rust traits"), "rust%20traits");
assert_eq!(url_encode_query("a+b"), "a%2Bb");
assert_eq!(url_encode_query("foo&bar=baz"), "foo%26bar%3Dbaz");
assert_eq!(url_encode_query(""), "");
assert_eq!(url_encode_query("A-Z_0.9~"), "A-Z_0.9~");
}
#[test]
fn test_web_search_config_parsing() {
let yaml_config = r#"
addons:
web_searches:
- name: "Google"
keyword: "g"
url: "https://google.com/search?q={query}"
icon: "google"
- name: "DuckDuckGo"
keyword: "ddg"
url: "https://duckduckgo.com/?q={query}"
shell:
binary: sh
description: "Shell"
"#;
let reader = Cursor::new(yaml_config);
let args = Args {
help: false,
version: false,
configfile: None,
print_only: false,
refresh_cache: false,
no_icons: true,
default_script_shell: None,
ui_type: None,
app_name: None,
initial_query: None,
select_1: false,
theme: None,
generate_schemas: false,
check_config: false,
debug_file: None,
install_browser_host: false,
uninstall_browser_host: false,
force: false,
};
let parsed_config = read_config_from_reader(reader, &args).unwrap();
assert_eq!(parsed_config.addons.web_searches.len(), 2);
let google = &parsed_config.addons.web_searches[0];
assert_eq!(google.name, "Google");
assert_eq!(google.keyword, "g");
assert_eq!(google.url, "https://google.com/search?q={query}");
assert_eq!(google.icon, Some("google".to_string()));
let ddg = &parsed_config.addons.web_searches[1];
assert_eq!(ddg.name, "DuckDuckGo");
assert_eq!(ddg.keyword, "ddg");
assert_eq!(ddg.url, "https://duckduckgo.com/?q={query}");
assert!(ddg.icon.is_none());
}
#[test]
fn test_text_snippet_config_parsing() {
let yaml_config = r#"
addons:
text_snippets:
- name: "Emails"
keyword: "em"
icon: "mail"
snippets:
- name: "Personal Email"
value: "user@example.com"
icon: "📨"
- name: "Work Email"
value: "user@company.com"
keyword: "!work"
- name: "Templates"
keyword: "tpl"
file: "~/.config/raffi/snippets.yaml"
placeholders: false
restore_clipboard: true
- name: "Dynamic"
keyword: "dyn"
command: "my-snippet-gen"
args: ["-j"]
shell:
binary: sh
description: "Shell"
"#;
let reader = Cursor::new(yaml_config);
let args = Args {
help: false,
version: false,
configfile: None,
print_only: false,
refresh_cache: false,
no_icons: true,
default_script_shell: None,
ui_type: None,
app_name: None,
initial_query: None,
select_1: false,
theme: None,
generate_schemas: false,
check_config: false,
debug_file: None,
install_browser_host: false,
uninstall_browser_host: false,
force: false,
};
let parsed_config = read_config_from_reader(reader, &args).unwrap();
assert_eq!(parsed_config.addons.text_snippets.len(), 3);
let emails = &parsed_config.addons.text_snippets[0];
assert_eq!(emails.name, "Emails");
assert_eq!(emails.keyword, "em");
assert_eq!(emails.icon, Some("mail".to_string()));
let snippets = emails.snippets.as_ref().unwrap();
assert_eq!(snippets.len(), 2);
assert_eq!(snippets[0].name, "Personal Email");
assert_eq!(snippets[0].value, "user@example.com");
assert_eq!(snippets[0].icon.as_deref(), Some("📨"));
assert_eq!(snippets[1].icon, None);
assert_eq!(snippets[1].name, "Work Email");
assert_eq!(snippets[1].value, "user@company.com");
assert_eq!(snippets[0].keyword, None);
assert_eq!(snippets[1].keyword.as_deref(), Some("!work"));
assert!(emails.placeholders);
assert!(!emails.restore_clipboard);
let templates = &parsed_config.addons.text_snippets[1];
assert_eq!(templates.name, "Templates");
assert_eq!(templates.keyword, "tpl");
let home = std::env::var("HOME").unwrap();
assert_eq!(
templates.file,
Some(format!("{home}/.config/raffi/snippets.yaml"))
);
assert!(templates.snippets.is_none());
assert!(templates.command.is_none());
assert!(!templates.placeholders);
assert!(templates.restore_clipboard);
let dynamic = &parsed_config.addons.text_snippets[2];
assert_eq!(dynamic.name, "Dynamic");
assert_eq!(dynamic.keyword, "dyn");
assert_eq!(dynamic.command, Some("my-snippet-gen".to_string()));
assert_eq!(dynamic.args, vec!["-j".to_string()]);
assert!(dynamic.snippets.is_none());
assert!(dynamic.file.is_none());
}
#[test]
fn test_text_snippet_directory_config_parsing() {
let yaml_config = r#"
addons:
text_snippets:
- name: "Snippets"
keyword: "sn"
icon: "snippets"
directory: "~/.local/share/snippets"
shell:
binary: sh
description: "Shell"
"#;
let reader = Cursor::new(yaml_config);
let args = Args {
help: false,
version: false,
configfile: None,
print_only: false,
refresh_cache: false,
no_icons: true,
default_script_shell: None,
ui_type: None,
app_name: None,
initial_query: None,
select_1: false,
theme: None,
generate_schemas: false,
check_config: false,
debug_file: None,
install_browser_host: false,
uninstall_browser_host: false,
force: false,
};
let parsed_config = read_config_from_reader(reader, &args).unwrap();
assert_eq!(parsed_config.addons.text_snippets.len(), 1);
let snippets_dir = &parsed_config.addons.text_snippets[0];
assert_eq!(snippets_dir.name, "Snippets");
assert_eq!(snippets_dir.keyword, "sn");
assert_eq!(snippets_dir.icon, Some("snippets".to_string()));
let home = std::env::var("HOME").unwrap();
assert_eq!(
snippets_dir.directory,
Some(format!("{home}/.local/share/snippets"))
);
assert!(snippets_dir.snippets.is_none());
assert!(snippets_dir.file.is_none());
assert!(snippets_dir.command.is_none());
}
#[test]
fn test_text_snippet_defaults_to_empty() {
let yaml_config = r#"
shell:
binary: sh
description: "Shell"
"#;
let reader = Cursor::new(yaml_config);
let args = Args {
help: false,
version: false,
configfile: None,
print_only: false,
refresh_cache: false,
no_icons: true,
default_script_shell: None,
ui_type: None,
app_name: None,
initial_query: None,
select_1: false,
theme: None,
generate_schemas: false,
check_config: false,
debug_file: None,
install_browser_host: false,
uninstall_browser_host: false,
force: false,
};
let parsed_config = read_config_from_reader(reader, &args).unwrap();
assert!(parsed_config.addons.text_snippets.is_empty());
}
#[test]
fn test_emoji_addon_config_parsing() {
let yaml_config = r#"
addons:
emoji:
enabled: true
trigger: "em"
action: "insert"
secondary_action: "copy"
data_files:
- emojis_smileys_emotion
- nerd_font
shell:
binary: sh
description: "Shell"
"#;
let reader = Cursor::new(yaml_config);
let args = Args {
help: false,
version: false,
configfile: None,
print_only: false,
refresh_cache: false,
no_icons: true,
default_script_shell: None,
ui_type: None,
app_name: None,
initial_query: None,
select_1: false,
theme: None,
generate_schemas: false,
check_config: false,
debug_file: None,
install_browser_host: false,
uninstall_browser_host: false,
force: false,
};
let parsed_config = read_config_from_reader(reader, &args).unwrap();
assert!(parsed_config.addons.emoji.enabled);
assert_eq!(parsed_config.addons.emoji.trigger, Some("em".to_string()));
assert_eq!(
parsed_config.addons.emoji.action,
Some("insert".to_string())
);
assert_eq!(
parsed_config.addons.emoji.secondary_action,
Some("copy".to_string())
);
assert_eq!(
parsed_config.addons.emoji.data_files,
Some(vec![
"emojis_smileys_emotion".to_string(),
"nerd_font".to_string()
])
);
}
#[test]
fn test_clipboard_addon_config() {
let parse = |yaml: &str| {
let args = Args {
help: false,
version: false,
configfile: None,
print_only: false,
refresh_cache: false,
no_icons: true,
default_script_shell: None,
ui_type: None,
app_name: None,
initial_query: None,
select_1: false,
theme: None,
generate_schemas: false,
check_config: false,
debug_file: None,
install_browser_host: false,
uninstall_browser_host: false,
force: false,
};
read_config_from_reader(Cursor::new(yaml), &args)
};
let defaults = parse("shell:\n binary: sh\n").unwrap().addons.clipboard;
assert!(!defaults.enabled);
assert_eq!(defaults.trigger(), "clip");
assert_eq!(defaults.image_trigger(), "clipimg");
assert_eq!(defaults.min_chars(), 3);
assert_eq!(defaults.backend, ClipboardBackend::Copyq);
assert_eq!(defaults.action, ClipboardAction::Copy);
assert_eq!(defaults.secondary_action, ClipboardAction::HyprPaste);
assert!(!defaults.uses_hypr_paste());
let config = parse(
r#"
addons:
clipboard:
enabled: true
trigger: cb
image_trigger: cbi
min_chars: 1
backend: copyq
action: hypr_paste
secondary_action: copy
shell:
binary: sh
"#,
)
.unwrap()
.addons
.clipboard;
assert!(config.enabled);
assert_eq!(config.trigger(), "cb");
assert_eq!(config.image_trigger(), "cbi");
assert_eq!(config.min_chars(), 1);
assert_eq!(config.action, ClipboardAction::HyprPaste);
assert_eq!(config.secondary_action, ClipboardAction::Copy);
assert!(config.uses_hypr_paste());
assert!(parse("addons:\n clipboard:\n action: insert\n").is_err());
assert!(parse("addons:\n clipboard:\n backend: cliphist\n").is_err());
}
#[test]
fn test_devtools_addon_config_parsing() {
let defaults = AddonsConfig::default().devtools;
assert!(defaults.enabled);
assert_eq!(defaults.trigger(), "dev");
assert_eq!(defaults.password_length(), 20);
let config: DevtoolsAddonConfig =
serde_yaml::from_str("trigger: tx\npassword_length: 500\n").unwrap();
assert_eq!(config.trigger(), "tx");
assert_eq!(config.password_length(), 128);
}
#[test]
fn test_windows_addon_config_parsing() {
let defaults = AddonsConfig::default().windows;
assert!(defaults.enabled);
assert_eq!(defaults.trigger(), "win");
let config: WindowsAddonConfig =
serde_yaml::from_str("enabled: false\ntrigger: w\n").unwrap();
assert!(!config.enabled);
assert_eq!(config.trigger(), "w");
}
#[test]
fn test_bluetooth_addon_config_parsing() {
let defaults = AddonsConfig::default().bluetooth;
assert!(defaults.enabled);
assert_eq!(defaults.trigger(), "bt");
assert!(!defaults.close_on_action);
let config: BluetoothAddonConfig =
serde_yaml::from_str("enabled: false\ntrigger: blue\nclose_on_action: true\n").unwrap();
assert!(!config.enabled);
assert_eq!(config.trigger(), "blue");
assert!(config.close_on_action);
}
#[test]
fn test_password_addon_config_parsing() {
let defaults = AddonsConfig::default();
assert!(defaults.pass.enabled);
assert_eq!(defaults.pass.trigger(), "pass");
assert_eq!(defaults.pass.pinentry, PassPinentry::Raffi);
assert_eq!(
defaults.pass.username_fields(),
vec!["login", "user", "username"]
);
assert_eq!(defaults.pass.clipboard_timeout(), 45);
assert_eq!(defaults.bitwarden.trigger(), "bw");
let config: AddonsConfig = serde_yaml::from_str(
"pass:\n pinentry: agent\n store_dir: ~/secrets\n username_fields: [email]\n clipboard_timeout: 0\nbitwarden:\n trigger: vault\n type_delay_ms: 500\n",
)
.unwrap();
assert_eq!(config.pass.pinentry, PassPinentry::Agent);
assert_eq!(config.pass.store_dir.as_deref(), Some("~/secrets"));
assert_eq!(config.pass.username_fields(), vec!["email"]);
assert_eq!(config.pass.clipboard_timeout(), 0);
assert_eq!(config.bitwarden.trigger(), "vault");
assert_eq!(config.bitwarden.type_delay_ms(), 500);
assert!(serde_yaml::from_str::<PassAddonConfig>("pinentry: tty\n").is_err());
}
#[test]
fn test_kill_addon_config_parsing() {
let defaults = AddonsConfig::default().kill;
assert!(defaults.enabled);
assert_eq!(defaults.trigger(), "kill");
let config: KillAddonConfig = serde_yaml::from_str("enabled: false\ntrigger: k\n").unwrap();
assert!(!config.enabled);
assert_eq!(config.trigger(), "k");
}
#[test]
fn test_audio_addon_config_parsing() {
let defaults = AddonsConfig::default().audio;
assert!(defaults.enabled);
assert_eq!(defaults.trigger(), "audio");
assert!(!defaults.close_on_action);
let config: AudioAddonConfig =
serde_yaml::from_str("trigger: snd\nclose_on_action: true\n").unwrap();
assert_eq!(config.trigger(), "snd");
assert!(config.close_on_action);
}
#[test]
fn test_media_addon_config_parsing() {
let defaults = AddonsConfig::default().media;
assert!(defaults.enabled);
assert_eq!(defaults.trigger(), "media");
assert!(!defaults.close_on_action);
let config: MediaAddonConfig =
serde_yaml::from_str("trigger: mu\nclose_on_action: true\n").unwrap();
assert_eq!(config.trigger(), "mu");
assert!(config.close_on_action);
}
#[test]
fn test_system_addon_config_parsing() {
let defaults = AddonsConfig::default().system;
assert!(defaults.enabled);
assert_eq!(defaults.trigger(), "sys");
let config: SystemAddonConfig = serde_yaml::from_str(
"trigger: power\ncommands:\n lock: [hyprlock]\nhide: [hibernate, logout]\n",
)
.unwrap();
assert_eq!(config.trigger(), "power");
assert_eq!(
config.commands.get(SystemCommand::Lock),
Some(&vec!["hyprlock".to_string()])
);
assert_eq!(config.commands.get(SystemCommand::Reboot), None);
assert_eq!(
config.hide,
vec![SystemCommand::Hibernate, SystemCommand::Logout]
);
}
#[test]
fn test_emoji_addon_defaults_to_enabled() {
let yaml_config = r#"
shell:
binary: sh
description: "Shell"
"#;
let reader = Cursor::new(yaml_config);
let args = Args {
help: false,
version: false,
configfile: None,
print_only: false,
refresh_cache: false,
no_icons: true,
default_script_shell: None,
ui_type: None,
app_name: None,
initial_query: None,
select_1: false,
theme: None,
generate_schemas: false,
check_config: false,
debug_file: None,
install_browser_host: false,
uninstall_browser_host: false,
force: false,
};
let parsed_config = read_config_from_reader(reader, &args).unwrap();
assert!(parsed_config.addons.emoji.enabled);
assert!(parsed_config.addons.emoji.trigger.is_none());
assert!(parsed_config.addons.emoji.action.is_none());
assert!(parsed_config.addons.emoji.secondary_action.is_none());
assert!(parsed_config.addons.emoji.data_files.is_none());
}
#[test]
fn test_v0_config_migrated_in_memory() {
let yaml_config = r#"
general:
no_icons: true
addons:
calculator:
enabled: false
shell:
binary: sh
description: "Shell"
firefox:
binary: firefox
description: "Firefox"
"#;
let reader = Cursor::new(yaml_config);
let args = Args {
help: false,
version: false,
configfile: None,
print_only: false,
refresh_cache: false,
no_icons: true,
default_script_shell: None,
ui_type: None,
app_name: None,
initial_query: None,
select_1: false,
theme: None,
generate_schemas: false,
check_config: false,
debug_file: None,
install_browser_host: false,
uninstall_browser_host: false,
force: false,
};
let parsed_config = read_config_from_reader(reader, &args).unwrap();
assert_eq!(parsed_config.general.no_icons, Some(true));
assert!(!parsed_config.addons.calculator.enabled);
assert_eq!(parsed_config.entries.len(), 2);
}
#[test]
fn test_v1_config_passes_through() {
let yaml_config = r#"
version: 1
general:
no_icons: true
launchers:
shell:
binary: sh
description: "Shell"
firefox:
binary: firefox
description: "Firefox"
"#;
let reader = Cursor::new(yaml_config);
let args = Args {
help: false,
version: false,
configfile: None,
print_only: false,
refresh_cache: false,
no_icons: true,
default_script_shell: None,
ui_type: None,
app_name: None,
initial_query: None,
select_1: false,
theme: None,
generate_schemas: false,
check_config: false,
debug_file: None,
install_browser_host: false,
uninstall_browser_host: false,
force: false,
};
let parsed_config = read_config_from_reader(reader, &args).unwrap();
assert_eq!(parsed_config.general.no_icons, Some(true));
assert_eq!(parsed_config.entries.len(), 2);
}
#[test]
fn test_migrate_v0_to_v1_moves_keys() {
let yaml_str = r#"
general:
no_icons: true
addons:
calculator:
enabled: false
shell:
binary: sh
description: "Shell"
firefox:
binary: firefox
description: "Firefox"
"#;
let mut raw: Value = serde_yaml::from_str(yaml_str).unwrap();
let dir = std::env::temp_dir();
let config_path = dir.join("test_migrate_v0.yaml");
fs::write(&config_path, yaml_str).unwrap();
let migrated = migrate_config_v0_to_v1(config_path.to_str().unwrap(), &mut raw).unwrap();
assert!(migrated);
let mapping = raw.as_mapping().unwrap();
assert!(mapping.contains_key(Value::String("version".to_string())));
assert!(mapping.contains_key(Value::String("launchers".to_string())));
assert!(mapping.contains_key(Value::String("general".to_string())));
assert!(mapping.contains_key(Value::String("addons".to_string())));
assert!(!mapping.contains_key(Value::String("shell".to_string())));
assert!(!mapping.contains_key(Value::String("firefox".to_string())));
let launchers = mapping
.get(Value::String("launchers".to_string()))
.unwrap()
.as_mapping()
.unwrap();
assert!(launchers.contains_key(Value::String("shell".to_string())));
assert!(launchers.contains_key(Value::String("firefox".to_string())));
let backup_path = format!("{}.bak", config_path.to_str().unwrap());
assert!(Path::new(&backup_path).exists());
let _ = fs::remove_file(&config_path);
let _ = fs::remove_file(&backup_path);
}
#[test]
fn test_migrate_v1_is_noop() {
let yaml_str = r#"
version: 1
launchers:
shell:
binary: sh
"#;
let mut raw: Value = serde_yaml::from_str(yaml_str).unwrap();
let dir = std::env::temp_dir();
let config_path = dir.join("test_migrate_v1_noop.yaml");
fs::write(&config_path, yaml_str).unwrap();
let migrated = migrate_config_v0_to_v1(config_path.to_str().unwrap(), &mut raw).unwrap();
assert!(!migrated);
let backup_path = format!("{}.bak", config_path.to_str().unwrap());
assert!(!Path::new(&backup_path).exists());
let _ = fs::remove_file(&config_path);
}
#[test]
fn test_schema_generation() {
let schema = generate_schema();
let parsed: serde_json::Value = serde_json::from_str(&schema).unwrap();
assert_eq!(parsed["type"], "object");
let props = parsed["properties"].as_object().unwrap();
assert!(props.contains_key("version"));
assert!(props.contains_key("general"));
assert!(props.contains_key("addons"));
assert!(props.contains_key("launchers"));
}
#[test]
fn test_find_binary_accepts_paths() {
let dir = temp_test_dir("find-binary");
let binary = dir.join("custom-tool");
fs::write(&binary, "#!/bin/sh\n").unwrap();
assert!(find_binary(binary.to_str().unwrap()));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn test_parse_desktop_exec_strips_field_codes() {
let (binary, args) = parse_desktop_exec(
r#"/usr/bin/env BAMF_DESKTOP_FILE_HINT=%k /usr/bin/firefox --new-window %U"#,
)
.unwrap();
assert_eq!(binary, "/usr/bin/env");
assert_eq!(
args,
vec![
"BAMF_DESKTOP_FILE_HINT=".to_string(),
"/usr/bin/firefox".to_string(),
"--new-window".to_string()
]
);
}
#[test]
fn test_detect_desktop_entries_from_dirs() {
let dir = temp_test_dir("desktop-detect");
let apps_dir = dir.join("applications");
fs::create_dir_all(&apps_dir).unwrap();
fs::write(
apps_dir.join("firefox.desktop"),
r#"[Desktop Entry]
Type=Application
Name=Firefox
Exec=firefox %U
Icon=firefox
"#,
)
.unwrap();
fs::write(
apps_dir.join("hidden.desktop"),
r#"[Desktop Entry]
Type=Application
Name=Hidden App
Exec=hidden-app
Hidden=true
"#,
)
.unwrap();
fs::write(
apps_dir.join("missing-tryexec.desktop"),
r#"[Desktop Entry]
Type=Application
Name=Missing TryExec
Exec=missing-app
TryExec=missing-app
"#,
)
.unwrap();
fs::write(
apps_dir.join("terminal.desktop"),
r#"[Desktop Entry]
Type=Application
Name=Terminal App
Exec=terminal-app
Terminal=true
"#,
)
.unwrap();
let checker = MockBinaryChecker {
binaries: vec!["firefox".to_string()],
};
let detected = detect_desktop_entries_from_dirs(&[apps_dir], &plain_scan(), &checker);
assert_eq!(detected.len(), 1);
assert_eq!(detected[0].description.as_deref(), Some("Firefox"));
assert_eq!(detected[0].binary.as_deref(), Some("firefox"));
assert!(detected[0].args.as_deref().unwrap_or(&[]).is_empty());
let _ = fs::remove_dir_all(dir);
}
fn plain_scan() -> DesktopScan {
DesktopScan {
actions: false,
terminal: None,
desktops: Vec::new(),
}
}
fn descriptions(entries: &[RaffiConfig]) -> Vec<&str> {
entries
.iter()
.map(|entry| entry.description.as_deref().unwrap_or_default())
.collect()
}
#[test]
fn test_detect_desktop_entries_actions_desktops_and_terminal() {
let dir = temp_test_dir("desktop-scan");
let user_dir = dir.join("user/applications");
let system_dir = dir.join("system/applications");
fs::create_dir_all(&user_dir).unwrap();
fs::create_dir_all(&system_dir).unwrap();
let files = [
(
&system_dir,
"firefox.desktop",
"[Desktop Entry]\nType=Application\nName=Firefox\nExec=firefox %u\nIcon=firefox\n\
Actions=private;broken;\n\n[Desktop Action private]\nName=New Private Window\n\
Exec=firefox --private-window %u\n\n[Desktop Action broken]\nName=Broken\n",
),
(
&system_dir,
"gnome-only.desktop",
"[Desktop Entry]\nType=Application\nName=Gnome Only\nExec=firefox\nOnlyShowIn=GNOME;\n",
),
(
&system_dir,
"not-hypr.desktop",
"[Desktop Entry]\nType=Application\nName=Not Hyprland\nExec=firefox\nNotShowIn=Hyprland;\n",
),
(
&system_dir,
"htop.desktop",
"[Desktop Entry]\nType=Application\nName=Htop\nExec=htop -t\nTerminal=true\n",
),
(
&system_dir,
"overridden.desktop",
"[Desktop Entry]\nType=Application\nName=Overridden\nExec=firefox\n",
),
(
&user_dir,
"overridden.desktop",
"[Desktop Entry]\nType=Application\nName=Overridden\nExec=firefox\nNoDisplay=true\n",
),
];
for (dir, name, contents) in files {
fs::write(dir.join(name), contents).unwrap();
}
let checker = MockBinaryChecker {
binaries: vec!["firefox".to_string(), "htop".to_string()],
};
let dirs = [user_dir, system_dir];
let scan = DesktopScan {
actions: true,
terminal: Some(vec!["foot".to_string()]),
desktops: vec!["hyprland".to_string()],
};
let detected = detect_desktop_entries_from_dirs(&dirs, &scan, &checker);
assert_eq!(
descriptions(&detected),
["Firefox", "Firefox: New Private Window", "Htop"]
);
assert_eq!(
detected[1].args.as_deref(),
Some(&["--private-window".to_string()][..])
);
assert_eq!(detected[1].icon.as_deref(), Some("firefox"));
assert_eq!(detected[2].binary.as_deref(), Some("foot"));
assert_eq!(
detected[2].args.as_deref(),
Some(&["htop".to_string(), "-t".to_string()][..])
);
let detected = detect_desktop_entries_from_dirs(&dirs, &plain_scan(), &checker);
assert_eq!(descriptions(&detected), ["Firefox", "Not Hyprland"]);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn test_merge_apps_skips_launcher_duplicates() {
let launcher = |description: &str, binary: &str, args: Option<&[&str]>| RaffiConfig {
description: Some(description.to_string()),
binary: Some(binary.to_string()),
args: args.map(|args| args.iter().map(|arg| arg.to_string()).collect()),
..Default::default()
};
let mut entries = vec![
launcher("firefox", "firefox", None),
launcher("Files", "nautilus", Some(&["--new-window"])),
];
merge_apps(
&mut entries,
vec![
launcher("Firefox", "/usr/bin/firefox", None),
launcher("Nautilus", "nautilus", Some(&["--new-window"])),
launcher("Nautilus: Open", "nautilus", None),
launcher("Htop", "htop", Some(&[])),
],
);
assert_eq!(
descriptions(&entries),
["firefox", "Files", "Nautilus: Open", "Htop"]
);
}
#[test]
fn test_desktop_entry_icon_map_from_dirs_uses_real_exec_binary() {
let dir = temp_test_dir("desktop-icon-map");
let apps_dir = dir.join("applications");
fs::create_dir_all(&apps_dir).unwrap();
fs::write(
apps_dir.join("firefox.desktop"),
r#"[Desktop Entry]
Type=Application
Name=Firefox
Exec=/usr/bin/env BAMF_DESKTOP_FILE_HINT=%k /usr/bin/firefox --new-window %U
Icon=firefox
"#,
)
.unwrap();
let checker = MockBinaryChecker {
binaries: vec![
"/usr/bin/env".to_string(),
"/usr/bin/firefox".to_string(),
"firefox".to_string(),
],
};
let icon_map = desktop_entry_icon_map_from_dirs(&[apps_dir], &checker);
assert_eq!(
icon_map.get("/usr/bin/firefox"),
Some(&"firefox".to_string())
);
assert_eq!(icon_map.get("firefox"), Some(&"firefox".to_string()));
assert!(!icon_map.contains_key("/usr/bin/env"));
assert!(!icon_map.contains_key("env"));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn test_resolve_launcher_icon_path_uses_launcher_args_for_lookup() {
let config = RaffiConfig {
binary: Some("jumpapp".to_string()),
args: Some(vec!["-X".to_string(), "firefox".to_string()]),
description: Some("Firefox".to_string()),
..Default::default()
};
let icon_map = HashMap::from([("firefox".to_string(), "/icons/firefox.svg".to_string())]);
let resolved = resolve_launcher_icon_path(&config, &icon_map, &HashMap::new());
assert_eq!(resolved.as_deref(), Some("/icons/firefox.svg"));
}
#[test]
fn test_resolve_launcher_icon_path_uses_desktop_icon_map() {
let config = RaffiConfig {
binary: Some("custom-launcher".to_string()),
description: Some("Firefox".to_string()),
..Default::default()
};
let icon_map = HashMap::from([("firefox".to_string(), "/icons/firefox.svg".to_string())]);
let desktop_icon_map =
HashMap::from([("custom-launcher".to_string(), "firefox".to_string())]);
let resolved = resolve_launcher_icon_path(&config, &icon_map, &desktop_icon_map);
assert_eq!(resolved.as_deref(), Some("/icons/firefox.svg"));
}
#[test]
fn test_resolve_icon_reference_ignores_non_icon_files() {
let dir = temp_test_dir("resolve-icon-reference");
let binary_path = dir.join("firefox");
fs::write(&binary_path, "#!/bin/sh\n").unwrap();
let icon_map = HashMap::from([("firefox".to_string(), "/icons/firefox.svg".to_string())]);
let resolved = resolve_icon_reference(binary_path.to_str().unwrap(), &icon_map);
assert_eq!(resolved.as_deref(), Some("/icons/firefox.svg"));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn test_resolve_icon_reference_ignores_directories() {
let dir = temp_test_dir("resolve-icon-directory");
let resolved = resolve_icon_reference(dir.to_str().unwrap(), &HashMap::new());
assert!(resolved.is_none());
let _ = fs::remove_dir_all(dir);
}
#[test]
fn test_default_icon_path_materializes_bundled_icon_without_repo_cwd() {
let cache_root = temp_test_dir("default-icon-cache");
let cwd = temp_test_dir("default-icon-cwd");
let previous_cache_home = std::env::var_os("XDG_CACHE_HOME");
let previous_cwd = std::env::current_dir().unwrap();
std::env::set_var("XDG_CACHE_HOME", &cache_root);
std::env::set_current_dir(&cwd).unwrap();
let resolved = default_icon_path(&HashMap::new()).unwrap();
let resolved_path = PathBuf::from(&resolved);
assert!(resolved_path.exists());
assert_eq!(
resolved_path.file_name().and_then(|name| name.to_str()),
Some("default_icon.svg")
);
assert_eq!(fs::read(&resolved_path).unwrap(), DEFAULT_ICON_BYTES);
std::env::set_current_dir(previous_cwd).unwrap();
match previous_cache_home {
Some(value) => std::env::set_var("XDG_CACHE_HOME", value),
None => std::env::remove_var("XDG_CACHE_HOME"),
}
let _ = fs::remove_dir_all(cache_root);
let _ = fs::remove_dir_all(cwd);
}
#[cfg(feature = "config-validation")]
fn yaml(s: &str) -> Value {
serde_yaml::from_str(s).unwrap()
}
#[test]
#[cfg(feature = "config-validation")]
fn test_example_config_is_valid() {
let mut raw = yaml(include_str!("../examples/raffi.yaml"));
migrate_config_v0_in_memory(&mut raw);
assert_eq!(validate_config_value(&raw), Vec::<ConfigProblem>::new());
}
#[test]
fn test_example_schema_is_up_to_date() {
assert_eq!(
include_str!("../examples/raffi-schema.json").trim_end(),
generate_schema(),
"regenerate with: cargo run -- --generate-schemas --configfile examples/raffi.yaml"
);
}
#[test]
fn test_example_launchers_schema_is_up_to_date() {
assert_eq!(
include_str!("../examples/launchers-schema.json").trim_end(),
generate_launchers_schema(),
"regenerate with: cargo run -- --generate-schemas --configfile examples/raffi.yaml"
);
}
#[test]
fn test_generate_schemas_writes_both_next_to_config() {
let dir = temp_test_dir("generate-schemas");
let configfile = dir.join("raffi.yaml");
run(Args {
configfile: Some(configfile.to_string_lossy().into_owned()),
generate_schemas: true,
..Args::default()
})
.unwrap();
assert_eq!(
fs::read_to_string(dir.join("raffi-schema.json")).unwrap(),
generate_schema()
);
assert_eq!(
fs::read_to_string(dir.join("launchers-schema.json")).unwrap(),
generate_launchers_schema()
);
let _ = fs::remove_dir_all(dir);
}
#[test]
#[cfg(feature = "config-validation")]
fn test_validate_reports_unknown_and_mistyped_keys() {
let text = "version: 1\n\
general:\n them: dark\n\
addons:\n calculator:\n foo: 1\n\
launchers:\n ff:\n binnary: firefox\n args: firefox\n";
let mut problems = validate_config_value(&yaml(text));
locate_config_problems(&mut problems, text, false);
let has = |path: &str, needle: &str, line: usize| {
problems
.iter()
.any(|p| p.path == path && p.message.contains(needle) && p.line == Some(line))
};
assert_eq!(problems.len(), 4, "{problems:?}");
assert!(has("/general", "'them'", 3), "{problems:?}");
assert!(has("/addons/calculator", "'foo'", 6), "{problems:?}");
assert!(has("/launchers/ff", "'binnary'", 9), "{problems:?}");
assert!(has("/launchers/ff/args", "array", 10), "{problems:?}");
}
#[test]
#[cfg(feature = "config-validation")]
fn test_validate_accepts_file_browser_show_aliases() {
for show in ["all", "files", "directories", "dirs", "directory"] {
let raw = yaml(&format!(
"version: 1\naddons:\n file_browser:\n show: {show}\n"
));
assert_eq!(validate_config_value(&raw), Vec::<ConfigProblem>::new());
}
let raw = yaml("version: 1\naddons:\n file_browser:\n show: links\n");
assert_eq!(validate_config_value(&raw).len(), 1);
}
#[test]
#[cfg(feature = "config-validation")]
fn test_validate_v0_config_after_migration() {
let text = "firefox:\n binary: firefox\n descr: x\n";
let mut raw = yaml(text);
let v0 = migrate_config_v0_in_memory(&mut raw);
let mut problems = validate_config_value(&raw);
locate_config_problems(&mut problems, text, v0);
assert_eq!(problems.len(), 1, "{problems:?}");
assert_eq!(problems[0].path, "/launchers/firefox");
assert_eq!(problems[0].line, Some(3));
}
#[test]
fn test_find_yaml_line() {
let text = "\
# comment
version: 1
addons:
script_filters:
- name: one
command: a
- name: \"two\"
command: |
echo a: b
args:
- x
- y
web_searches: []
launchers:
\"my/app\":
binary: foo # trailing
";
let cases = [
("/version", Some(2)),
("/addons", Some(3)),
("/addons/script_filters/0", Some(5)),
("/addons/script_filters/0/name", Some(5)),
("/addons/script_filters/0/command", Some(6)),
("/addons/script_filters/1/command", Some(8)),
("/addons/script_filters/1/args/1", Some(12)),
("/addons/web_searches", Some(13)),
("/addons/web_searches/0", Some(13)),
("/launchers/my~1app/binary", Some(16)),
("/launchers/missing", Some(14)),
("/nope", None),
("/", None),
];
for (pointer, line) in cases {
assert_eq!(find_yaml_line(text, pointer), line, "{pointer}");
}
}
#[test]
#[cfg(feature = "config-validation")]
fn test_check_config_reports_lines() {
let dir = temp_test_dir("check-config-lines");
let path = dir.join("raffi.yaml");
fs::write(
&path,
"version: 1\nlaunchers:\n ff:\n binary: firefox\n ifexist: 1\n",
)
.unwrap();
let problems = check_config(path.to_str().unwrap()).unwrap();
assert_eq!(problems.len(), 1, "{problems:?}");
assert_eq!(problems[0].line, Some(5));
let shown = format_config_problem("raffi.yaml", &problems[0]);
assert!(
shown.starts_with("raffi.yaml:5: /launchers/ff/ifexist: "),
"{shown}"
);
let _ = fs::remove_dir_all(dir);
}
#[test]
#[cfg(feature = "config-validation")]
fn test_check_launchers_file() {
let example = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/launchers.yaml");
assert_eq!(check_launchers_file(&example).unwrap(), Vec::new());
let dir = temp_test_dir("check-launchers-file");
let path = dir.join("launchers.yaml");
fs::write(&path, "ff:\n binary: firefox\n ifexist: 1\n").unwrap();
let problems = check_launchers_file(&path).unwrap();
assert_eq!(problems.len(), 1, "{problems:?}");
assert_eq!(problems[0].path, "/ff/ifexist");
assert_eq!(problems[0].line, Some(3));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn test_read_config_uses_launchers_file() {
let dir = temp_test_dir("launchers-file");
let config = dir.join("raffi.yaml");
let config = config.to_str().unwrap();
fs::write(dir.join("launchers.yaml"), "from-file:\n binary: sh\n").unwrap();
let args = Args::parse_args_default::<&str>(&[]).unwrap();
fs::write(config, "version: 1\ngeneral:\n theme: light\n").unwrap();
let parsed = read_config(config, &args).unwrap();
assert_eq!(parsed.general.theme.as_deref(), Some("light"));
assert_eq!(parsed.entries.len(), 1);
assert_eq!(parsed.entries[0].binary.as_deref(), Some("sh"));
fs::write(
config,
"version: 1\nlaunchers:\n inline:\n binary: sh\n description: inline\n",
)
.unwrap();
let parsed = read_config(config, &args).unwrap();
assert_eq!(parsed.entries.len(), 1);
assert_eq!(parsed.entries[0].description.as_deref(), Some("inline"));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn test_unknown_keys_still_load() {
let config = "version: 1\nlaunchers:\n sh:\n binary: sh\n binnary: typo\n";
let args = Args::parse_args_default::<&str>(&[]).unwrap();
let parsed = read_config_from_reader(Cursor::new(config), &args).unwrap();
assert_eq!(parsed.entries.len(), 1);
assert_eq!(parsed.entries[0].binary.as_deref(), Some("sh"));
}
#[test]
#[cfg(feature = "config-validation")]
fn test_check_config_does_not_rewrite_v0_file() {
let dir = temp_test_dir("check-config");
let path = dir.join("raffi.yaml");
let contents = "firefox:\n binary: firefox\n";
fs::write(&path, contents).unwrap();
let problems = check_config(path.to_str().unwrap()).unwrap();
assert!(problems.is_empty(), "{problems:?}");
assert_eq!(fs::read_to_string(&path).unwrap(), contents);
assert!(!dir.join("raffi.yaml.bak").exists());
let _ = fs::remove_dir_all(dir);
}
#[test]
fn test_write_schema_if_changed_refreshes_stale_file() {
let dir = temp_test_dir("schema-refresh");
let path = dir.join("raffi-schema.json");
write_schema_if_changed(&path, &generate_schema()).unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), generate_schema());
fs::write(&path, "{}").unwrap();
write_schema_if_changed(&path, &generate_schema()).unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), generate_schema());
let _ = fs::remove_dir_all(dir);
}
}