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 debug;
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>,
}
#[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 FileBrowserAddonConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub show_hidden: Option<bool>,
}
impl Default for FileBrowserAddonConfig {
fn default() -> Self {
Self {
enabled: true,
show_hidden: 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>,
}
#[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, Default)]
#[schemars(deny_unknown_fields)]
pub struct AddonsConfig {
#[serde(default)]
pub currency: CurrencyAddonConfig,
#[serde(default)]
pub calculator: CalculatorAddonConfig,
#[serde(default)]
pub file_browser: FileBrowserAddonConfig,
#[serde(default)]
pub emoji: EmojiAddonConfig,
#[serde(default)]
pub script_filters: Vec<ScriptFilterConfig>,
#[serde(default)]
pub web_searches: Vec<WebSearchConfig>,
#[serde(default)]
pub text_snippets: Vec<TextSnippetSourceConfig>,
}
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)]
#[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 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 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 clipboard_history: ClipboardHistory,
#[serde(default)]
pub terminal_classes: Vec<String>,
}
pub struct ParsedConfig {
pub general: GeneralConfig,
pub addons: AddonsConfig,
pub entries: Vec<RaffiConfig>,
}
#[derive(Deserialize)]
struct Config {
#[serde(default)]
#[allow(dead_code)]
version: u32,
#[serde(default)]
general: GeneralConfig,
#[serde(default)]
addons: AddonsConfig,
#[serde(default)]
launchers: HashMap<String, Value>,
}
#[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,
}
impl std::str::FromStr for ThemeMode {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"dark" => Ok(ThemeMode::Dark),
"light" => Ok(ThemeMode::Light),
_ => Err(format!(
"Invalid theme: {}. Valid options are: dark, light",
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)]
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: fuzzel)", short = "u")]
pub ui_type: Option<String>,
#[options(help = "initial search query (native mode only)", short = "i")]
pub initial_query: Option<String>,
#[options(help = "theme: dark, light (default: dark)", short = "t")]
pub theme: Option<String>,
#[options(help = "print JSON Schema for the config format to stdout")]
pub schema: 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>,
}
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)
}
}
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);
for problem in &problems {
let problem = format_config_problem(source, problem);
debug_log!("config warning: {problem}");
eprintln!("raffi: config warning: {problem}");
}
}
pub fn check_config(filename: &str) -> Result<Vec<ConfigProblem>> {
if !cfg!(feature = "config-validation") {
anyhow::bail!(
"config validation is not available in this build; rebuild with the `config-validation` feature"
);
}
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 config: Config = serde_yaml::from_value(raw).context("cannot parse config")?;
process_config(config, args)
}
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;
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))
}
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())
}
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))
}
fn parse_desktop_entry_file(
path: &Path,
binary_checker: &impl BinaryChecker,
) -> Option<RaffiConfig> {
let contents = fs::read_to_string(path).ok()?;
let keys = parse_desktop_entry_keys(&contents);
if keys.get("Type").map(String::as_str) != Some("Application") {
return None;
}
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))
{
return None;
}
let exec = keys.get("Exec")?;
let name = keys.get("Name")?;
let (binary, args) = parse_desktop_exec(exec)?;
if let Some(try_exec) = keys.get("TryExec") {
if !binary_checker.exists(try_exec) {
return None;
}
}
if !binary_checker.exists(&binary) {
return None;
}
Some(RaffiConfig {
binary: Some(binary),
args: (!args.is_empty()).then_some(args),
icon: keys.get("Icon").cloned(),
description: Some(name.clone()),
..Default::default()
})
}
fn detect_desktop_entries_from_dirs(
dirs: &[PathBuf],
binary_checker: &impl BinaryChecker,
) -> Vec<RaffiConfig> {
let mut seen = 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)
.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 Some(config) = parse_desktop_entry_file(entry.path(), binary_checker) else {
continue;
};
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(binary_checker: &impl BinaryChecker) -> Vec<RaffiConfig> {
detect_desktop_entries_from_dirs(&desktop_entry_dirs(), binary_checker)
}
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",
];
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 {
format!(
"No valid launchers found.\nChecked: {configfile}\nExpected a YAML config using `version: 1` and a `launchers:` section.\nYou can copy `examples/raffi.yaml` as a starting point, or install desktop applications so Raffi can auto-detect them."
)
}
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")?;
}
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 write_schema_if_changed(schema_path: &Path) {
let schema = generate_schema();
if fs::read_to_string(schema_path).is_ok_and(|current| current == schema) {
return;
}
let dir = schema_path.parent().unwrap_or(Path::new("."));
if fs::create_dir_all(dir).is_err() {
return;
}
if let Err(e) = fs::write(schema_path, schema) {
eprintln!(
"Warning: could not write schema file {}: {e}",
schema_path.display()
);
}
}
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 0.1.0");
return Ok(());
}
if args.schema {
println!("{}", generate_schema());
return Ok(());
}
if args.refresh_cache {
clear_icon_cache()?;
clear_emoji_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();
if args.check_config {
let problems = check_config(configfile)?;
if problems.is_empty() {
println!("{configfile}: config OK");
return Ok(());
}
for problem in &problems {
eprintln!("{}", format_config_problem(configfile, problem));
}
anyhow::bail!("{} problem(s) found in {configfile}", problems.len());
}
let config_dir = Path::new(configfile).parent().unwrap_or(Path::new("."));
write_schema_if_changed(&config_dir.join("raffi-schema.json"));
let mut parsed_config = if config_exists {
read_config(configfile, &args).context("Failed to read config")?
} 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 detected_entries = detect_desktop_entries(&DefaultBinaryChecker);
if detected_entries.is_empty() {
eprintln!("{}", no_valid_config_message(configfile));
std::process::exit(1);
}
eprintln!(
"No valid launchers found in {configfile}; using {} auto-detected desktop applications.",
detected_entries.len()
);
parsed_config.entries = detected_entries;
}
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:?}");
let ui_type = if let Some(ui_type_str) = ui_type_str {
ui_type_str
.parse::<UIType>()
.map_err(|e| anyhow::anyhow!(e))?
} else if find_binary("fuzzel") && general.fallbacks.is_empty() {
UIType::Fuzzel
} else {
#[cfg(feature = "wayland")]
{
UIType::Native
}
#[cfg(not(feature = "wayland"))]
{
return Err(anyhow::anyhow!(
"No UI backend available. Install 'fuzzel' or build with the 'wayland' feature."
));
}
};
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.");
}
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 ui_settings = ui::UISettings {
no_icons,
initial_query: args.initial_query.clone(),
theme,
theme_colors: parsed_config.general.theme_colors.clone(),
max_history,
font_sizes,
font_family: general.font_family.clone(),
window_width: general.window_width.unwrap_or(800.0),
window_height: general.window_height.unwrap_or(600.0),
sort_mode: general.sort_mode.clone(),
fallbacks: general.fallbacks.clone(),
clipboard_history: general.clipboard_history,
terminal_classes: general.terminal_classes.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,
initial_query: None,
theme: None,
schema: false,
check_config: false,
debug_file: None,
};
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_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,
initial_query: None,
theme: None,
schema: false,
check_config: false,
debug_file: None,
};
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,
};
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,
initial_query: None,
theme: None,
schema: false,
check_config: false,
debug_file: None,
};
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,
initial_query: None,
theme: None,
schema: false,
check_config: false,
debug_file: None,
};
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_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,
initial_query: None,
theme: None,
schema: false,
check_config: false,
debug_file: None,
};
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()
])
);
}
#[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,
initial_query: None,
theme: None,
schema: false,
check_config: false,
debug_file: None,
};
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,
initial_query: None,
theme: None,
schema: false,
check_config: false,
debug_file: None,
};
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,
initial_query: None,
theme: None,
schema: false,
check_config: false,
debug_file: None,
};
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,
initial_query: None,
theme: None,
schema: false,
check_config: false,
debug_file: None,
};
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,
initial_query: None,
theme: None,
schema: false,
check_config: false,
debug_file: None,
};
let parsed_config = read_config_from_reader(reader, &args).unwrap();
assert_eq!(parsed_config.general.theme, Some("light".to_string()));
}
#[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!("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,
initial_query: None,
theme: None,
schema: false,
check_config: false,
debug_file: None,
};
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,
initial_query: None,
theme: None,
schema: false,
check_config: false,
debug_file: None,
};
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,
initial_query: None,
theme: None,
schema: false,
check_config: false,
debug_file: None,
};
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,
initial_query: None,
theme: None,
schema: false,
check_config: false,
debug_file: None,
};
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,
initial_query: None,
theme: None,
schema: false,
check_config: false,
debug_file: None,
};
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_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,
initial_query: None,
theme: None,
schema: false,
check_config: false,
debug_file: None,
};
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,
initial_query: None,
theme: None,
schema: false,
check_config: false,
debug_file: None,
};
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,
initial_query: None,
theme: None,
schema: false,
check_config: false,
debug_file: None,
};
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], &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);
}
#[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 result = (|| {
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);
result
}
#[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 -- --schema > examples/raffi-schema.json"
);
}
#[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_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]
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);
assert_eq!(fs::read_to_string(&path).unwrap(), generate_schema());
fs::write(&path, "{}").unwrap();
write_schema_if_changed(&path);
assert_eq!(fs::read_to_string(&path).unwrap(), generate_schema());
let _ = fs::remove_dir_all(dir);
}
}