1use std::{
2 collections::HashMap,
3 fmt::Write as _,
4 fs::{self, File},
5 io::{Read, Write},
6 path::Path,
7 process::Command,
8};
9
10use anyhow::{Context, Result};
11use gumdrop::Options;
12use schemars::JsonSchema;
13use serde::Deserialize;
14use serde_yaml::Value;
15
16pub mod ui;
17
18#[derive(Deserialize, JsonSchema, Debug, PartialEq, Clone, Default)]
20pub struct RaffiConfig {
21 pub binary: Option<String>,
22 pub args: Option<Vec<String>>,
23 pub icon: Option<String>,
24 pub description: Option<String>,
25 pub ifenveq: Option<Vec<String>>,
26 pub ifenvset: Option<String>,
27 pub ifenvnotset: Option<String>,
28 pub ifexist: Option<String>,
29 pub disabled: Option<bool>,
30 pub script: Option<String>,
31}
32
33#[derive(Deserialize, JsonSchema, Debug, Clone)]
35pub struct CurrencyAddonConfig {
36 #[serde(default = "default_true")]
37 pub enabled: bool,
38 #[serde(default)]
39 pub currencies: Option<Vec<String>>,
40 #[serde(default)]
41 pub default_currency: Option<String>,
42 #[serde(default)]
43 pub trigger: Option<String>,
44}
45
46impl Default for CurrencyAddonConfig {
47 fn default() -> Self {
48 Self {
49 enabled: true,
50 currencies: None,
51 default_currency: None,
52 trigger: None,
53 }
54 }
55}
56
57#[derive(Deserialize, JsonSchema, Debug, Clone)]
59pub struct CalculatorAddonConfig {
60 #[serde(default = "default_true")]
61 pub enabled: bool,
62}
63
64impl Default for CalculatorAddonConfig {
65 fn default() -> Self {
66 Self { enabled: true }
67 }
68}
69
70#[derive(Deserialize, JsonSchema, Debug, Clone)]
72pub struct FileBrowserAddonConfig {
73 #[serde(default = "default_true")]
74 pub enabled: bool,
75 #[serde(default)]
76 pub show_hidden: Option<bool>,
77}
78
79impl Default for FileBrowserAddonConfig {
80 fn default() -> Self {
81 Self {
82 enabled: true,
83 show_hidden: None,
84 }
85 }
86}
87
88pub const DEFAULT_EMOJI_FILES: &[&str] = &[
91 "emojis_smileys_emotion",
92 "emojis_people_body",
93 "emojis_animals_nature",
94 "emojis_food_drink",
95 "emojis_travel_places",
96 "emojis_activities",
97 "emojis_objects",
98 "emojis_symbols",
99 "emojis_flags",
100 "emojis_component",
101];
102
103#[derive(Deserialize, JsonSchema, Debug, Clone)]
105pub struct EmojiAddonConfig {
106 #[serde(default = "default_true")]
107 pub enabled: bool,
108 #[serde(default)]
109 pub trigger: Option<String>,
110 #[serde(default)]
111 pub action: Option<String>,
112 #[serde(default)]
113 pub secondary_action: Option<String>,
114 #[serde(default)]
115 pub data_files: Option<Vec<String>>,
116}
117
118impl Default for EmojiAddonConfig {
119 fn default() -> Self {
120 Self {
121 enabled: true,
122 trigger: None,
123 action: None,
124 secondary_action: None,
125 data_files: None,
126 }
127 }
128}
129
130#[derive(Deserialize, JsonSchema, Debug, Clone)]
132pub struct ScriptFilterConfig {
133 pub name: String,
134 pub command: String,
135 pub keyword: String,
136 pub icon: Option<String>,
137 #[serde(default)]
138 pub args: Vec<String>,
139 pub action: Option<String>,
140 pub secondary_action: Option<String>,
141}
142
143#[derive(Deserialize, JsonSchema, Debug, Clone)]
145pub struct WebSearchConfig {
146 pub name: String,
147 pub keyword: String,
148 pub url: String,
149 pub icon: Option<String>,
150}
151
152#[derive(Deserialize, JsonSchema, Debug, Clone, PartialEq)]
154pub struct TextSnippet {
155 pub name: String,
156 pub value: String,
157}
158
159#[derive(Deserialize, JsonSchema, Debug, Clone)]
161pub struct TextSnippetSourceConfig {
162 pub name: String,
163 pub keyword: String,
164 #[serde(default)]
165 pub icon: Option<String>,
166 #[serde(default)]
167 pub snippets: Option<Vec<TextSnippet>>,
168 #[serde(default)]
169 pub file: Option<String>,
170 #[serde(default)]
171 pub command: Option<String>,
172 #[serde(default)]
173 pub directory: Option<String>,
174 #[serde(default)]
175 pub args: Vec<String>,
176 #[serde(default)]
177 pub action: Option<String>,
178 #[serde(default)]
179 pub secondary_action: Option<String>,
180}
181
182#[derive(Deserialize, JsonSchema, Debug, Clone, Default)]
184pub struct AddonsConfig {
185 #[serde(default)]
186 pub currency: CurrencyAddonConfig,
187 #[serde(default)]
188 pub calculator: CalculatorAddonConfig,
189 #[serde(default)]
190 pub file_browser: FileBrowserAddonConfig,
191 #[serde(default)]
192 pub emoji: EmojiAddonConfig,
193 #[serde(default)]
194 pub script_filters: Vec<ScriptFilterConfig>,
195 #[serde(default)]
196 pub web_searches: Vec<WebSearchConfig>,
197 #[serde(default)]
198 pub text_snippets: Vec<TextSnippetSourceConfig>,
199}
200
201fn default_true() -> bool {
202 true
203}
204
205#[derive(Deserialize, JsonSchema, Debug, Clone, Default)]
207pub struct ThemeColorsConfig {
208 pub bg_base: Option<String>,
209 pub bg_input: Option<String>,
210 pub accent: Option<String>,
211 pub accent_hover: Option<String>,
212 pub text_main: Option<String>,
213 pub text_muted: Option<String>,
214 pub selection_bg: Option<String>,
215 pub border: Option<String>,
216}
217
218#[derive(Deserialize, JsonSchema, Debug, Clone, Default)]
220pub struct GeneralConfig {
221 #[serde(default)]
222 pub ui_type: Option<String>,
223 #[serde(default)]
224 pub default_script_shell: Option<String>,
225 #[serde(default)]
226 pub no_icons: Option<bool>,
227 #[serde(default)]
228 pub theme: Option<String>,
229 #[serde(default)]
230 pub theme_colors: Option<ThemeColorsConfig>,
231 #[serde(default)]
232 pub max_history: Option<u32>,
233 #[serde(default)]
234 pub font_size: Option<f32>,
235 #[serde(default)]
236 pub font_family: Option<String>,
237 #[serde(default)]
238 pub window_width: Option<f32>,
239 #[serde(default)]
240 pub window_height: Option<f32>,
241 #[serde(default)]
242 pub padding: Option<f32>,
243}
244
245pub struct ParsedConfig {
247 pub general: GeneralConfig,
248 pub addons: AddonsConfig,
249 pub entries: Vec<RaffiConfig>,
250}
251
252#[derive(Deserialize)]
254struct Config {
255 #[serde(default)]
256 #[allow(dead_code)]
257 version: u32,
258 #[serde(default)]
259 general: GeneralConfig,
260 #[serde(default)]
261 addons: AddonsConfig,
262 #[serde(default)]
263 launchers: HashMap<String, Value>,
264}
265
266#[derive(JsonSchema)]
268pub struct ConfigSchema {
269 pub version: u32,
271 #[serde(default)]
273 pub general: GeneralConfig,
274 #[serde(default)]
276 pub addons: AddonsConfig,
277 #[serde(default)]
279 pub launchers: HashMap<String, RaffiConfig>,
280}
281
282#[derive(Debug, Clone, PartialEq)]
284pub enum UIType {
285 Fuzzel,
286 #[cfg(feature = "wayland")]
287 Native,
288}
289
290#[derive(Debug, Clone, PartialEq)]
292pub enum ThemeMode {
293 Dark,
294 Light,
295}
296
297impl std::str::FromStr for ThemeMode {
298 type Err = String;
299
300 fn from_str(s: &str) -> Result<Self, Self::Err> {
301 match s.to_lowercase().as_str() {
302 "dark" => Ok(ThemeMode::Dark),
303 "light" => Ok(ThemeMode::Light),
304 _ => Err(format!(
305 "Invalid theme: {}. Valid options are: dark, light",
306 s
307 )),
308 }
309 }
310}
311
312impl std::str::FromStr for UIType {
313 type Err = String;
314
315 fn from_str(s: &str) -> Result<Self, Self::Err> {
316 match s.to_lowercase().as_str() {
317 "fuzzel" => Ok(UIType::Fuzzel),
318 #[cfg(feature = "wayland")]
319 "native" | "wayland" | "iced" => Ok(UIType::Native),
320 #[cfg(not(feature = "wayland"))]
321 "native" | "wayland" | "iced" => Err(
322 "Native UI is not available. Build with the 'wayland' feature to enable it."
323 .to_string(),
324 ),
325 _ => {
326 #[cfg(feature = "wayland")]
327 {
328 Err(format!(
329 "Invalid UI type: {}. Valid options are: fuzzel, native",
330 s
331 ))
332 }
333 #[cfg(not(feature = "wayland"))]
334 {
335 Err(format!("Invalid UI type: {}. Valid options are: fuzzel", s))
336 }
337 }
338 }
339 }
340}
341
342#[derive(Debug, Options, Clone)]
344pub struct Args {
345 #[options(help = "print help message")]
346 pub help: bool,
347 #[options(help = "print version")]
348 pub version: bool,
349 #[options(help = "config file location")]
350 pub configfile: Option<String>,
351 #[options(help = "print command to stdout, do not run it")]
352 pub print_only: bool,
353 #[options(help = "refresh cache")]
354 pub refresh_cache: bool,
355 #[options(help = "do not show icons", short = "I")]
356 pub no_icons: bool,
357 #[options(help = "default shell when using scripts", short = "P")]
358 pub default_script_shell: Option<String>,
359 #[options(help = "UI type to use: fuzzel, native (default: fuzzel)", short = "u")]
360 pub ui_type: Option<String>,
361 #[options(help = "initial search query (native mode only)", short = "i")]
362 pub initial_query: Option<String>,
363 #[options(help = "theme: dark, light (default: dark)", short = "t")]
364 pub theme: Option<String>,
365 #[options(help = "print JSON Schema for the config format to stdout")]
366 pub schema: bool,
367}
368
369pub trait EnvProvider {
371 fn var(&self, key: &str) -> Result<String, std::env::VarError>;
372}
373
374pub struct DefaultEnvProvider;
376
377impl EnvProvider for DefaultEnvProvider {
378 fn var(&self, key: &str) -> Result<String, std::env::VarError> {
379 std::env::var(key)
380 }
381}
382
383pub trait BinaryChecker {
385 fn exists(&self, binary: &str) -> bool;
386}
387
388pub struct DefaultBinaryChecker;
390
391impl BinaryChecker for DefaultBinaryChecker {
392 fn exists(&self, binary: &str) -> bool {
393 find_binary(binary)
394 }
395}
396
397pub trait IconMapProvider {
399 fn get_icon_map(&self) -> Result<HashMap<String, String>>;
400}
401
402pub struct DefaultIconMapProvider;
404
405impl IconMapProvider for DefaultIconMapProvider {
406 fn get_icon_map(&self) -> Result<HashMap<String, String>> {
407 read_icon_map()
408 }
409}
410
411fn extract_icon_size(path: &std::path::Path) -> u32 {
414 for component in path.components() {
415 if let std::path::Component::Normal(s) = component {
416 if let Some(s_str) = s.to_str() {
417 if s_str == "scalable" {
419 return 512; }
421 if let Some((w, _)) = s_str.split_once('x') {
422 if let Ok(size) = w.parse::<u32>() {
423 return size;
424 }
425 }
426 }
427 }
428 }
429 0
430}
431
432fn get_icon_map() -> Result<HashMap<String, String>> {
435 let mut icon_map: HashMap<String, String> = HashMap::new();
436 let mut icon_sizes: HashMap<String, u32> = HashMap::new();
437 let mut data_dirs =
438 std::env::var("XDG_DATA_DIRS").unwrap_or("/usr/local/share/:/usr/share/".to_string());
439 let data_home = std::env::var("XDG_DATA_HOME").unwrap_or_else(|_| {
440 format!(
441 "{}/.local/share/",
442 std::env::var("HOME").unwrap_or_default()
443 )
444 });
445 write!(&mut data_dirs, ":{data_home}")?;
446
447 for datadir in std::env::split_paths(&data_dirs) {
448 for subdir in &["icons", "pixmaps"] {
449 let mut dir = datadir.clone();
450 dir.push(subdir);
451 for entry in walkdir::WalkDir::new(dir)
452 .follow_links(true)
453 .into_iter()
454 .filter_map(Result::ok)
455 {
456 let fname = entry.file_name().to_string_lossy().to_string();
457 if let Some(ext) = entry.path().extension().and_then(|s| s.to_str()) {
458 if ext == "png" || ext == "svg" {
459 let icon_name = fname.rsplit_once('.').unwrap().0.to_string();
460 let icon_path = entry.path().to_string_lossy().to_string();
461 let new_size = extract_icon_size(entry.path());
462 let current_size = icon_sizes.get(&icon_name).copied().unwrap_or(0);
463
464 if new_size >= current_size || (new_size >= 48 && current_size < 48) {
466 icon_map.insert(icon_name.clone(), icon_path);
467 icon_sizes.insert(icon_name, new_size);
468 }
469 }
470 }
471 }
472 }
473 }
474 Ok(icon_map)
475}
476
477pub(crate) fn expand_tilde(s: &str) -> String {
479 if let Some(stripped) = s.strip_prefix("~/") {
480 format!("{}/{}", std::env::var("HOME").unwrap_or_default(), stripped)
481 } else {
482 s.to_string()
483 }
484}
485
486fn expand_env_vars(s: &str) -> String {
489 let mut result = String::with_capacity(s.len());
490 let mut chars = s.chars().peekable();
491 while let Some(c) = chars.next() {
492 if c == '$' && chars.peek() == Some(&'{') {
493 chars.next(); let var_name: String = chars.by_ref().take_while(|&c| c != '}').collect();
495 result.push_str(&std::env::var(&var_name).unwrap_or_default());
496 } else {
497 result.push(c);
498 }
499 }
500 result
501}
502
503pub(crate) fn expand_config_value(s: &str) -> String {
505 expand_env_vars(&expand_tilde(s))
506}
507
508pub fn migrate_config_v0_to_v1(config_path: &str, raw: &mut Value) -> Result<bool> {
512 let mapping = match raw.as_mapping_mut() {
513 Some(m) => m,
514 None => return Ok(false),
515 };
516
517 if mapping.contains_key(Value::String("version".to_string())) {
519 return Ok(false);
520 }
521
522 let reserved = ["general", "addons", "version"];
523 let mut launchers = serde_yaml::Mapping::new();
524 let mut keys_to_move = Vec::new();
525
526 for (key, _) in mapping.iter() {
527 if let Some(k) = key.as_str() {
528 if !reserved.contains(&k) {
529 keys_to_move.push(Value::String(k.to_string()));
530 }
531 }
532 }
533
534 for key in &keys_to_move {
535 if let Some(val) = mapping.remove(key) {
536 launchers.insert(key.clone(), val);
537 }
538 }
539
540 mapping.insert(
541 Value::String("version".to_string()),
542 Value::Number(serde_yaml::Number::from(1u64)),
543 );
544 mapping.insert(
545 Value::String("launchers".to_string()),
546 Value::Mapping(launchers),
547 );
548
549 let backup_path = format!("{config_path}.bak");
551 fs::copy(config_path, &backup_path)
552 .context(format!("Failed to create backup at {backup_path}"))?;
553
554 let migrated_yaml =
556 serde_yaml::to_string(&raw).context("Failed to serialize migrated config")?;
557 fs::write(config_path, &migrated_yaml)
558 .context(format!("Failed to write migrated config to {config_path}"))?;
559
560 eprintln!("Config migrated to v1 format. Backup saved as {backup_path}");
561 Ok(true)
562}
563
564pub fn read_config(filename: &str, args: &Args) -> Result<ParsedConfig> {
566 let contents =
567 fs::read_to_string(filename).context(format!("cannot open config file {filename}"))?;
568 let mut raw: Value = serde_yaml::from_str(&contents).context("cannot parse config as YAML")?;
569
570 migrate_config_v0_to_v1(filename, &mut raw)?;
571
572 let config: Config = serde_yaml::from_value(raw).context("cannot parse config")?;
573
574 process_config(config, args)
575}
576
577pub fn read_config_from_reader<R: Read>(reader: R, args: &Args) -> Result<ParsedConfig> {
580 let mut contents = String::new();
581 let mut reader = reader;
582 reader
583 .read_to_string(&mut contents)
584 .context("cannot read config")?;
585 let mut raw: Value = serde_yaml::from_str(&contents).context("cannot parse config")?;
586
587 if let Some(mapping) = raw.as_mapping_mut() {
589 if !mapping.contains_key(Value::String("version".to_string())) {
590 let reserved = ["general", "addons", "version"];
591 let mut launchers = serde_yaml::Mapping::new();
592 let mut keys_to_move = Vec::new();
593
594 for (key, _) in mapping.iter() {
595 if let Some(k) = key.as_str() {
596 if !reserved.contains(&k) {
597 keys_to_move.push(Value::String(k.to_string()));
598 }
599 }
600 }
601
602 for key in &keys_to_move {
603 if let Some(val) = mapping.remove(key) {
604 launchers.insert(key.clone(), val);
605 }
606 }
607
608 mapping.insert(
609 Value::String("version".to_string()),
610 Value::Number(serde_yaml::Number::from(1u64)),
611 );
612 mapping.insert(
613 Value::String("launchers".to_string()),
614 Value::Mapping(launchers),
615 );
616 }
617 }
618
619 let config: Config = serde_yaml::from_value(raw).context("cannot parse config")?;
620 process_config(config, args)
621}
622
623fn process_config(config: Config, args: &Args) -> Result<ParsedConfig> {
625 let mut rafficonfigs = Vec::new();
626
627 for value in config.launchers.values() {
628 if value.is_mapping() {
629 let mut mc: RaffiConfig = serde_yaml::from_value(value.clone())
630 .context("cannot parse config entry".to_string())?;
631 mc.binary = mc.binary.map(|s| expand_config_value(&s));
632 mc.icon = mc.icon.map(|s| expand_config_value(&s));
633 mc.ifexist = mc.ifexist.map(|s| expand_config_value(&s));
634 mc.args = mc
635 .args
636 .map(|v| v.into_iter().map(|s| expand_config_value(&s)).collect());
637 if mc.disabled.unwrap_or(false)
638 || !is_valid_config(&mut mc, args, &DefaultEnvProvider, &DefaultBinaryChecker)
639 {
640 continue;
641 }
642 rafficonfigs.push(mc);
643 }
644 }
645
646 let mut addons = config.addons;
647 for sf in &mut addons.script_filters {
648 sf.command = expand_config_value(&sf.command);
649 sf.icon = sf.icon.as_ref().map(|s| expand_config_value(s));
650 sf.action = sf.action.as_ref().map(|s| expand_config_value(s));
651 sf.secondary_action = sf.secondary_action.as_ref().map(|s| expand_config_value(s));
652 }
653 for ws in &mut addons.web_searches {
654 ws.url = expand_config_value(&ws.url);
655 ws.icon = ws.icon.as_ref().map(|s| expand_config_value(s));
656 }
657 for ts in &mut addons.text_snippets {
658 ts.icon = ts.icon.as_ref().map(|s| expand_config_value(s));
659 ts.file = ts.file.as_ref().map(|s| expand_config_value(s));
660 ts.command = ts.command.as_ref().map(|s| expand_config_value(s));
661 ts.directory = ts.directory.as_ref().map(|s| expand_config_value(s));
662 }
663
664 Ok(ParsedConfig {
665 general: config.general,
666 addons,
667 entries: rafficonfigs,
668 })
669}
670
671fn is_valid_config(
673 mc: &mut RaffiConfig,
674 args: &Args,
675 env_provider: &impl EnvProvider,
676 binary_checker: &impl BinaryChecker,
677) -> bool {
678 if let Some(_script) = &mc.script {
679 if !binary_checker.exists(
680 mc.binary
681 .as_deref()
682 .unwrap_or(args.default_script_shell.as_deref().unwrap_or("bash")),
683 ) {
684 return false;
685 }
686 } else if let Some(binary) = &mc.binary {
687 if !binary_checker.exists(binary) {
688 return false;
689 }
690 } else if let Some(description) = &mc.description {
691 mc.binary = Some(description.clone());
692 } else {
693 return false;
694 }
695
696 mc.ifenveq
697 .as_ref()
698 .is_none_or(|eq| eq.len() == 2 && env_provider.var(&eq[0]).unwrap_or_default() == eq[1])
699 && mc
700 .ifenvset
701 .as_ref()
702 .is_none_or(|var| env_provider.var(var).is_ok())
703 && mc
704 .ifenvnotset
705 .as_ref()
706 .is_none_or(|var| env_provider.var(var).is_err())
707 && mc
708 .ifexist
709 .as_ref()
710 .is_none_or(|exist| binary_checker.exists(exist))
711}
712
713fn find_binary(binary: &str) -> bool {
715 std::env::var("PATH")
716 .unwrap_or_default()
717 .split(':')
718 .any(|path| Path::new(&format!("{path}/{binary}")).exists())
719}
720
721fn save_to_cache_file(map: &HashMap<String, String>) -> Result<()> {
723 let cache_dir = format!(
724 "{}/raffi",
725 std::env::var("XDG_CACHE_HOME")
726 .unwrap_or_else(|_| format!("{}/.cache", std::env::var("HOME").unwrap_or_default()))
727 );
728
729 fs::create_dir_all(&cache_dir).context("Failed to create cache directory")?;
730
731 let cache_file_path = format!("{cache_dir}/icon.cache");
732 let mut cache_file = File::create(&cache_file_path).context("Failed to create cache file")?;
733 cache_file
734 .write_all(
735 serde_json::to_string(map)
736 .context("Failed to serialize icon map")?
737 .as_bytes(),
738 )
739 .context("Failed to write to cache file")?;
740 Ok(())
741}
742
743pub fn clear_icon_cache() -> Result<()> {
745 let cache_path = format!(
746 "{}/raffi/icon.cache",
747 std::env::var("XDG_CACHE_HOME")
748 .unwrap_or_else(|_| format!("{}/.cache", std::env::var("HOME").unwrap_or_default()))
749 );
750 if Path::new(&cache_path).exists() {
751 fs::remove_file(&cache_path).context("Failed to remove icon cache file")?;
752 }
753 Ok(())
754}
755
756pub fn clear_emoji_cache() -> Result<()> {
758 let emoji_dir = format!(
759 "{}/raffi/emoji",
760 std::env::var("XDG_CACHE_HOME")
761 .unwrap_or_else(|_| format!("{}/.cache", std::env::var("HOME").unwrap_or_default()))
762 );
763 if Path::new(&emoji_dir).exists() {
764 fs::remove_dir_all(&emoji_dir).context("Failed to remove emoji cache directory")?;
765 }
766 Ok(())
767}
768
769pub fn read_icon_map() -> Result<HashMap<String, String>> {
771 let cache_path = format!(
772 "{}/raffi/icon.cache",
773 std::env::var("XDG_CACHE_HOME")
774 .unwrap_or_else(|_| format!("{}/.cache", std::env::var("HOME").unwrap_or_default()))
775 );
776
777 if !Path::new(&cache_path).exists() {
778 let icon_map = get_icon_map()?;
779 save_to_cache_file(&icon_map)?;
780 return Ok(icon_map);
781 }
782
783 let mut cache_file = File::open(&cache_path).context("Failed to open cache file")?;
784 let mut contents = String::new();
785 cache_file
786 .read_to_string(&mut contents)
787 .context("Failed to read cache file")?;
788 serde_json::from_str(&contents).context("Failed to deserialize cache file")
789}
790
791pub fn execute_chosen_command(mc: &RaffiConfig, args: &Args, interpreter: &str) -> Result<()> {
793 let interpreter_with_args = mc.args.as_ref().map_or(interpreter.to_string(), |args| {
795 format!("{} {}", interpreter, args.join(" "))
796 });
797
798 if args.print_only {
799 if let Some(script) = &mc.script {
800 println!("#!/usr/bin/env -S {interpreter_with_args}\n{script}");
801 } else {
802 println!(
803 "{} {}",
804 mc.binary.as_deref().context("Binary not found")?,
805 mc.args.as_deref().unwrap_or(&[]).join(" ")
806 );
807 }
808 return Ok(());
809 }
810 if let Some(script) = &mc.script {
811 let mut command = Command::new(interpreter);
812 command.arg("-c").arg(script);
813 if let Some(args) = &mc.args {
814 command.arg(interpreter);
815 command.args(args);
816 }
817 command.spawn().context("cannot launch script")?;
818 } else {
819 Command::new(mc.binary.as_deref().context("Binary not found")?)
820 .args(mc.args.as_deref().unwrap_or(&[]))
821 .spawn()
822 .context("cannot launch command")?;
823 }
824 Ok(())
825}
826
827pub fn url_encode_query(query: &str) -> String {
830 let mut encoded = String::with_capacity(query.len() * 3);
831 for byte in query.bytes() {
832 match byte {
833 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
834 encoded.push(byte as char);
835 }
836 _ => {
837 write!(encoded, "%{:02X}", byte).unwrap();
838 }
839 }
840 }
841 encoded
842}
843
844pub fn execute_web_search_url(url_template: &str, query: &str) -> Result<()> {
847 let encoded = url_encode_query(query);
848 let url = url_template.replace("{query}", &encoded);
849 Command::new("xdg-open")
850 .arg(&url)
851 .spawn()
852 .context("cannot open web search URL")?;
853 Ok(())
854}
855
856fn generate_schema() -> String {
858 let schema = schemars::schema_for!(ConfigSchema);
859 serde_json::to_string_pretty(&schema).expect("Failed to serialize JSON Schema")
860}
861
862pub fn run(args: Args) -> Result<()> {
863 if args.version {
864 println!("raffi version 0.1.0");
865 return Ok(());
866 }
867
868 if args.schema {
869 println!("{}", generate_schema());
870 return Ok(());
871 }
872
873 if args.refresh_cache {
874 clear_icon_cache()?;
875 clear_emoji_cache()?;
876 }
877
878 let default_config_path = format!(
879 "{}/.config/raffi/raffi.yaml",
880 std::env::var("HOME").unwrap_or_default()
881 );
882 let configfile = args.configfile.as_deref().unwrap_or(&default_config_path);
883
884 let config_dir = Path::new(configfile).parent().unwrap_or(Path::new("."));
886 let schema_path = config_dir.join("raffi-schema.json");
887 if !schema_path.exists() {
888 if let Err(e) = fs::write(&schema_path, generate_schema()) {
889 eprintln!(
890 "Warning: could not write schema file {}: {e}",
891 schema_path.display()
892 );
893 }
894 }
895
896 let parsed_config = read_config(configfile, &args).context("Failed to read config")?;
897
898 if parsed_config.entries.is_empty() {
899 eprintln!("No valid configurations found in {configfile}");
900 std::process::exit(1);
901 }
902
903 let general = &parsed_config.general;
905 let no_icons = args.no_icons || general.no_icons.unwrap_or(false);
906 let ui_type_str = args.ui_type.as_ref().or(general.ui_type.as_ref());
907 let default_script_shell = args
908 .default_script_shell
909 .as_deref()
910 .or(general.default_script_shell.as_deref())
911 .unwrap_or("bash")
912 .to_string();
913
914 let theme_str = args.theme.as_ref().or(general.theme.as_ref());
916 let theme = if let Some(theme_str) = theme_str {
917 theme_str
918 .parse::<ThemeMode>()
919 .map_err(|e| anyhow::anyhow!(e))?
920 } else {
921 ThemeMode::Dark
922 };
923
924 let ui_type = if let Some(ui_type_str) = ui_type_str {
926 ui_type_str
927 .parse::<UIType>()
928 .map_err(|e| anyhow::anyhow!(e))?
929 } else if find_binary("fuzzel") {
930 UIType::Fuzzel
931 } else {
932 #[cfg(feature = "wayland")]
933 {
934 UIType::Native
935 }
936 #[cfg(not(feature = "wayland"))]
937 {
938 return Err(anyhow::anyhow!(
939 "No UI backend available. Install 'fuzzel' or build with the 'wayland' feature."
940 ));
941 }
942 };
943
944 let max_history = general.max_history.unwrap_or(10);
946
947 let mut font_sizes = if let Some(base) = general.font_size {
949 ui::FontSizes::from_base(base)
950 } else {
951 ui::FontSizes::default_sizes()
952 };
953
954 if let Some(padding) = general.padding {
956 font_sizes.outer_padding = padding;
957 }
958
959 let ui_settings = ui::UISettings {
961 no_icons,
962 initial_query: args.initial_query.clone(),
963 theme,
964 theme_colors: parsed_config.general.theme_colors.clone(),
965 max_history,
966 font_sizes,
967 font_family: general.font_family.clone(),
968 window_width: general.window_width.unwrap_or(800.0),
969 window_height: general.window_height.unwrap_or(600.0),
970 };
971
972 let ui = ui::get_ui(ui_type);
974 let chosen = ui
975 .show(&parsed_config.entries, &parsed_config.addons, &ui_settings)
976 .context("Failed to show UI")?;
977
978 let chosen_name = chosen.trim();
979 if chosen_name.is_empty() {
980 std::process::exit(0);
981 }
982 let mc = parsed_config
983 .entries
984 .iter()
985 .find(|mc| {
986 mc.description.as_deref() == Some(chosen_name)
987 || mc.binary.as_deref() == Some(chosen_name)
988 })
989 .context("No matching configuration found")?;
990
991 let interpreter = if mc.script.is_some() {
992 mc.binary.as_deref().unwrap_or(&default_script_shell)
993 } else {
994 ""
996 };
997 execute_chosen_command(mc, &args, interpreter).context("Failed to execute command")?;
998
999 Ok(())
1000}
1001
1002#[cfg(test)]
1003mod tests {
1004 use super::*;
1005 use std::io::Cursor;
1006
1007 #[test]
1008 fn test_read_config_from_reader() {
1009 let yaml_config = r#"
1010 shell:
1011 binary: sh
1012 description: "Shell"
1013 hello_script:
1014 script: "echo hello"
1015 description: "Hello script"
1016 "#;
1017 let reader = Cursor::new(yaml_config);
1018 let args = Args {
1019 help: false,
1020 version: false,
1021 configfile: None,
1022 print_only: false,
1023 refresh_cache: false,
1024 no_icons: true,
1025 default_script_shell: None,
1026 ui_type: None,
1027 initial_query: None,
1028 theme: None,
1029 schema: false,
1030 };
1031 let parsed_config = read_config_from_reader(reader, &args).unwrap();
1032 assert_eq!(parsed_config.entries.len(), 2);
1033
1034 assert!(parsed_config.addons.currency.enabled);
1036 assert!(parsed_config.addons.calculator.enabled);
1037
1038 let expected_configs = vec![
1039 RaffiConfig {
1040 binary: Some("sh".to_string()),
1041 description: Some("Shell".to_string()),
1042 ..Default::default()
1043 },
1044 RaffiConfig {
1045 description: Some("Hello script".to_string()),
1046 script: Some("echo hello".to_string()),
1047 ..Default::default()
1048 },
1049 ];
1050
1051 for expected_config in &expected_configs {
1052 assert!(parsed_config.entries.contains(expected_config));
1053 }
1054 }
1055
1056 #[test]
1057 fn test_addons_config_parsing() {
1058 let yaml_config = r#"
1059 addons:
1060 currency:
1061 enabled: true
1062 currencies: ["USD", "EUR", "GBP"]
1063 calculator:
1064 enabled: false
1065 shell:
1066 binary: sh
1067 description: "Shell"
1068 "#;
1069 let reader = Cursor::new(yaml_config);
1070 let args = Args {
1071 help: false,
1072 version: false,
1073 configfile: None,
1074 print_only: false,
1075 refresh_cache: false,
1076 no_icons: true,
1077 default_script_shell: None,
1078 ui_type: None,
1079 initial_query: None,
1080 theme: None,
1081 schema: false,
1082 };
1083 let parsed_config = read_config_from_reader(reader, &args).unwrap();
1084
1085 assert!(parsed_config.addons.currency.enabled);
1086 assert!(!parsed_config.addons.calculator.enabled);
1087 assert_eq!(
1088 parsed_config.addons.currency.currencies,
1089 Some(vec![
1090 "USD".to_string(),
1091 "EUR".to_string(),
1092 "GBP".to_string()
1093 ])
1094 );
1095 assert_eq!(parsed_config.entries.len(), 1);
1096 }
1097
1098 struct MockEnvProvider {
1099 vars: HashMap<String, String>,
1100 }
1101
1102 impl EnvProvider for MockEnvProvider {
1103 fn var(&self, key: &str) -> Result<String, std::env::VarError> {
1104 self.vars
1105 .get(key)
1106 .cloned()
1107 .ok_or(std::env::VarError::NotPresent)
1108 }
1109 }
1110
1111 struct MockBinaryChecker {
1112 binaries: Vec<String>,
1113 }
1114
1115 impl BinaryChecker for MockBinaryChecker {
1116 fn exists(&self, binary: &str) -> bool {
1117 self.binaries.contains(&binary.to_string())
1118 }
1119 }
1120
1121 #[test]
1122 fn test_is_valid_config() {
1123 let mut config = RaffiConfig {
1124 binary: Some("test-binary".to_string()),
1125 description: Some("Test Description".to_string()),
1126 script: None,
1127 args: None,
1128 icon: None,
1129 ifenveq: Some(vec!["TEST_VAR".to_string(), "true".to_string()]),
1130 ifenvset: Some("ANOTHER_VAR".to_string()),
1131 ifenvnotset: Some("MISSING_VAR".to_string()),
1132 ifexist: Some("another-binary".to_string()),
1133 disabled: None,
1134 };
1135 let args = Args {
1136 help: false,
1137 version: false,
1138 configfile: None,
1139 print_only: false,
1140 refresh_cache: false,
1141 no_icons: true,
1142 default_script_shell: None,
1143 ui_type: None,
1144 initial_query: None,
1145 theme: None,
1146 schema: false,
1147 };
1148 let env_provider = MockEnvProvider {
1149 vars: {
1150 let mut vars = HashMap::new();
1151 vars.insert("TEST_VAR".to_string(), "true".to_string());
1152 vars.insert("ANOTHER_VAR".to_string(), "some_value".to_string());
1153 vars
1154 },
1155 };
1156 let binary_checker = MockBinaryChecker {
1157 binaries: vec!["test-binary".to_string(), "another-binary".to_string()],
1158 };
1159
1160 assert!(is_valid_config(
1161 &mut config,
1162 &args,
1163 &env_provider,
1164 &binary_checker
1165 ));
1166 }
1167
1168 #[test]
1169 fn test_script_filter_action_parsing() {
1170 let yaml_config = r#"
1171 addons:
1172 script_filters:
1173 - name: "Bookmarks"
1174 keyword: "bm"
1175 command: "my-bookmark-script"
1176 args: ["-j"]
1177 action: "wl-copy {value}"
1178 secondary_action: "xdg-open {value}"
1179 - name: "Timezones"
1180 keyword: "tz"
1181 command: "batz"
1182 args: ["-j"]
1183 shell:
1184 binary: sh
1185 description: "Shell"
1186 "#;
1187 let reader = Cursor::new(yaml_config);
1188 let args = Args {
1189 help: false,
1190 version: false,
1191 configfile: None,
1192 print_only: false,
1193 refresh_cache: false,
1194 no_icons: true,
1195 default_script_shell: None,
1196 ui_type: None,
1197 initial_query: None,
1198 theme: None,
1199 schema: false,
1200 };
1201 let parsed_config = read_config_from_reader(reader, &args).unwrap();
1202
1203 assert_eq!(parsed_config.addons.script_filters.len(), 2);
1204
1205 let bm = &parsed_config.addons.script_filters[0];
1206 assert_eq!(bm.name, "Bookmarks");
1207 assert_eq!(bm.action, Some("wl-copy {value}".to_string()));
1208 assert_eq!(bm.secondary_action, Some("xdg-open {value}".to_string()));
1209
1210 let tz = &parsed_config.addons.script_filters[1];
1211 assert_eq!(tz.name, "Timezones");
1212 assert_eq!(tz.action, None);
1213 assert_eq!(tz.secondary_action, None);
1214 }
1215
1216 #[test]
1217 fn test_text_snippet_action_parsing() {
1218 let yaml_config = r#"
1219 addons:
1220 text_snippets:
1221 - name: "Emails"
1222 keyword: "em"
1223 action: "wl-copy {value}"
1224 secondary_action: "wtype {value}"
1225 snippets:
1226 - name: "Personal"
1227 value: "user@example.com"
1228 - name: "Plain"
1229 keyword: "pl"
1230 snippets:
1231 - name: "Hello"
1232 value: "hello"
1233 shell:
1234 binary: sh
1235 description: "Shell"
1236 "#;
1237 let reader = Cursor::new(yaml_config);
1238 let args = Args {
1239 help: false,
1240 version: false,
1241 configfile: None,
1242 print_only: false,
1243 refresh_cache: false,
1244 no_icons: true,
1245 default_script_shell: None,
1246 ui_type: None,
1247 initial_query: None,
1248 theme: None,
1249 schema: false,
1250 };
1251 let parsed_config = read_config_from_reader(reader, &args).unwrap();
1252
1253 assert_eq!(parsed_config.addons.text_snippets.len(), 2);
1254
1255 let em = &parsed_config.addons.text_snippets[0];
1256 assert_eq!(em.name, "Emails");
1257 assert_eq!(em.action, Some("wl-copy {value}".to_string()));
1258 assert_eq!(em.secondary_action, Some("wtype {value}".to_string()));
1259
1260 let pl = &parsed_config.addons.text_snippets[1];
1261 assert_eq!(pl.name, "Plain");
1262 assert_eq!(pl.action, None);
1263 assert_eq!(pl.secondary_action, None);
1264 }
1265
1266 #[test]
1267 fn test_expand_tilde() {
1268 let home = std::env::var("HOME").unwrap();
1269 assert_eq!(expand_tilde("~/foo/bar"), format!("{home}/foo/bar"));
1270 }
1271
1272 #[test]
1273 fn test_expand_tilde_no_tilde() {
1274 assert_eq!(expand_tilde("/usr/bin/foo"), "/usr/bin/foo");
1275 assert_eq!(expand_tilde("relative/path"), "relative/path");
1276 }
1277
1278 #[test]
1279 fn test_expand_env_vars() {
1280 let home = std::env::var("HOME").unwrap();
1281 assert_eq!(expand_env_vars("${HOME}/foo"), format!("{home}/foo"));
1282 }
1283
1284 #[test]
1285 fn test_expand_env_vars_unknown() {
1286 assert_eq!(expand_env_vars("${NONEXISTENT_VAR_12345}/foo"), "/foo");
1287 }
1288
1289 #[test]
1290 fn test_expand_env_vars_multiple() {
1291 let home = std::env::var("HOME").unwrap();
1292 let user = std::env::var("USER").unwrap_or_default();
1293 assert_eq!(
1294 expand_env_vars("${HOME}/stuff/${USER}/data"),
1295 format!("{home}/stuff/{user}/data")
1296 );
1297 }
1298
1299 #[test]
1300 fn test_expand_env_vars_no_vars() {
1301 assert_eq!(expand_env_vars("/usr/bin/foo"), "/usr/bin/foo");
1302 assert_eq!(expand_env_vars("plain text"), "plain text");
1303 }
1304
1305 #[test]
1306 fn test_expand_config_value_combined() {
1307 let home = std::env::var("HOME").unwrap();
1308 let user = std::env::var("USER").unwrap_or_default();
1309 assert_eq!(
1310 expand_config_value("~/foo/${USER}/bar"),
1311 format!("{home}/foo/{user}/bar")
1312 );
1313 }
1314
1315 #[test]
1316 fn test_config_expands_env_vars_in_fields() {
1317 let home = std::env::var("HOME").unwrap();
1318 let yaml_config = r#"
1319 version: 1
1320 launchers:
1321 myapp:
1322 binary: "${HOME}/bin/myapp"
1323 description: "My App"
1324 args: ["${HOME}/Downloads/file.txt", "--verbose"]
1325 icon: "${HOME}/icons/myapp.png"
1326 ifexist: "${HOME}/bin/myapp"
1327 "#;
1328 let reader = Cursor::new(yaml_config);
1329 let config: super::Config = serde_yaml::from_reader(reader).expect("cannot parse config");
1330 let mut rafficonfigs = Vec::new();
1331 for value in config.launchers.values() {
1332 if value.is_mapping() {
1333 let mut mc: RaffiConfig = serde_yaml::from_value(value.clone()).unwrap();
1334 mc.binary = mc.binary.map(|s| expand_config_value(&s));
1335 mc.icon = mc.icon.map(|s| expand_config_value(&s));
1336 mc.ifexist = mc.ifexist.map(|s| expand_config_value(&s));
1337 mc.args = mc
1338 .args
1339 .map(|v| v.into_iter().map(|s| expand_config_value(&s)).collect());
1340 rafficonfigs.push(mc);
1341 }
1342 }
1343
1344 assert_eq!(rafficonfigs.len(), 1);
1345 let mc = &rafficonfigs[0];
1346 assert_eq!(mc.binary, Some(format!("{home}/bin/myapp")));
1347 assert_eq!(mc.icon, Some(format!("{home}/icons/myapp.png")));
1348 assert_eq!(mc.ifexist, Some(format!("{home}/bin/myapp")));
1349 assert_eq!(
1350 mc.args,
1351 Some(vec![
1352 format!("{home}/Downloads/file.txt"),
1353 "--verbose".to_string()
1354 ])
1355 );
1356 }
1357
1358 #[test]
1359 fn test_config_expands_tilde_in_fields() {
1360 let home = std::env::var("HOME").unwrap();
1361 let yaml_config = r#"
1362 version: 1
1363 launchers:
1364 myapp:
1365 binary: "~/bin/myapp"
1366 description: "My App"
1367 args: ["~/Downloads/file.txt", "--verbose"]
1368 icon: "~/icons/myapp.png"
1369 ifexist: "~/bin/myapp"
1370 "#;
1371 let reader = Cursor::new(yaml_config);
1372 let config: super::Config = serde_yaml::from_reader(reader).expect("cannot parse config");
1374 let mut rafficonfigs = Vec::new();
1375 for value in config.launchers.values() {
1376 if value.is_mapping() {
1377 let mut mc: RaffiConfig = serde_yaml::from_value(value.clone()).unwrap();
1378 mc.binary = mc.binary.map(|s| expand_tilde(&s));
1379 mc.icon = mc.icon.map(|s| expand_tilde(&s));
1380 mc.ifexist = mc.ifexist.map(|s| expand_tilde(&s));
1381 mc.args = mc
1382 .args
1383 .map(|v| v.into_iter().map(|s| expand_tilde(&s)).collect());
1384 rafficonfigs.push(mc);
1385 }
1386 }
1387
1388 assert_eq!(rafficonfigs.len(), 1);
1389 let mc = &rafficonfigs[0];
1390 assert_eq!(mc.binary, Some(format!("{home}/bin/myapp")));
1391 assert_eq!(mc.icon, Some(format!("{home}/icons/myapp.png")));
1392 assert_eq!(mc.ifexist, Some(format!("{home}/bin/myapp")));
1393 assert_eq!(
1394 mc.args,
1395 Some(vec![
1396 format!("{home}/Downloads/file.txt"),
1397 "--verbose".to_string()
1398 ])
1399 );
1400 }
1401
1402 #[test]
1403 fn test_general_config_parsing() {
1404 let yaml_config = r#"
1405 general:
1406 ui_type: native
1407 default_script_shell: zsh
1408 no_icons: true
1409 shell:
1410 binary: sh
1411 description: "Shell"
1412 "#;
1413 let reader = Cursor::new(yaml_config);
1414 let args = Args {
1415 help: false,
1416 version: false,
1417 configfile: None,
1418 print_only: false,
1419 refresh_cache: false,
1420 no_icons: false,
1421 default_script_shell: None,
1422 ui_type: None,
1423 initial_query: None,
1424 theme: None,
1425 schema: false,
1426 };
1427 let parsed_config = read_config_from_reader(reader, &args).unwrap();
1428
1429 assert_eq!(parsed_config.general.ui_type, Some("native".to_string()));
1430 assert_eq!(
1431 parsed_config.general.default_script_shell,
1432 Some("zsh".to_string())
1433 );
1434 assert_eq!(parsed_config.general.no_icons, Some(true));
1435 assert_eq!(parsed_config.entries.len(), 1);
1436 }
1437
1438 #[test]
1439 fn test_config_without_general_section() {
1440 let yaml_config = r#"
1441 shell:
1442 binary: sh
1443 description: "Shell"
1444 "#;
1445 let reader = Cursor::new(yaml_config);
1446 let args = Args {
1447 help: false,
1448 version: false,
1449 configfile: None,
1450 print_only: false,
1451 refresh_cache: false,
1452 no_icons: false,
1453 default_script_shell: None,
1454 ui_type: None,
1455 initial_query: None,
1456 theme: None,
1457 schema: false,
1458 };
1459 let parsed_config = read_config_from_reader(reader, &args).unwrap();
1460
1461 assert!(parsed_config.general.ui_type.is_none());
1462 assert!(parsed_config.general.default_script_shell.is_none());
1463 assert!(parsed_config.general.no_icons.is_none());
1464 assert_eq!(parsed_config.entries.len(), 1);
1465 }
1466
1467 #[test]
1468 fn test_partial_general_config() {
1469 let yaml_config = r#"
1470 general:
1471 no_icons: true
1472 shell:
1473 binary: sh
1474 description: "Shell"
1475 "#;
1476 let reader = Cursor::new(yaml_config);
1477 let args = Args {
1478 help: false,
1479 version: false,
1480 configfile: None,
1481 print_only: false,
1482 refresh_cache: false,
1483 no_icons: false,
1484 default_script_shell: None,
1485 ui_type: None,
1486 initial_query: None,
1487 theme: None,
1488 schema: false,
1489 };
1490 let parsed_config = read_config_from_reader(reader, &args).unwrap();
1491
1492 assert!(parsed_config.general.ui_type.is_none());
1493 assert!(parsed_config.general.default_script_shell.is_none());
1494 assert_eq!(parsed_config.general.no_icons, Some(true));
1495 assert_eq!(parsed_config.entries.len(), 1);
1496 }
1497
1498 #[test]
1499 fn test_general_config_ui_settings_parsing() {
1500 let yaml_config = r#"
1501 general:
1502 font_size: 16
1503 font_family: "Inter"
1504 window_width: 900
1505 window_height: 500
1506 shell:
1507 binary: sh
1508 description: "Shell"
1509 "#;
1510 let reader = Cursor::new(yaml_config);
1511 let args = Args {
1512 help: false,
1513 version: false,
1514 configfile: None,
1515 print_only: false,
1516 refresh_cache: false,
1517 no_icons: false,
1518 default_script_shell: None,
1519 ui_type: None,
1520 initial_query: None,
1521 theme: None,
1522 schema: false,
1523 };
1524 let parsed_config = read_config_from_reader(reader, &args).unwrap();
1525 assert_eq!(parsed_config.general.font_size, Some(16.0));
1526 assert_eq!(parsed_config.general.font_family, Some("Inter".to_string()));
1527 assert_eq!(parsed_config.general.window_width, Some(900.0));
1528 assert_eq!(parsed_config.general.window_height, Some(500.0));
1529 }
1530
1531 #[test]
1532 fn test_general_config_theme_parsing() {
1533 let yaml_config = r#"
1534 general:
1535 theme: light
1536 shell:
1537 binary: sh
1538 description: "Shell"
1539 "#;
1540 let reader = Cursor::new(yaml_config);
1541 let args = Args {
1542 help: false,
1543 version: false,
1544 configfile: None,
1545 print_only: false,
1546 refresh_cache: false,
1547 no_icons: false,
1548 default_script_shell: None,
1549 ui_type: None,
1550 initial_query: None,
1551 theme: None,
1552 schema: false,
1553 };
1554 let parsed_config = read_config_from_reader(reader, &args).unwrap();
1555 assert_eq!(parsed_config.general.theme, Some("light".to_string()));
1556 }
1557
1558 #[test]
1559 fn test_theme_mode_from_str() {
1560 assert_eq!("dark".parse::<ThemeMode>().unwrap(), ThemeMode::Dark);
1561 assert_eq!("Dark".parse::<ThemeMode>().unwrap(), ThemeMode::Dark);
1562 assert_eq!("DARK".parse::<ThemeMode>().unwrap(), ThemeMode::Dark);
1563 assert_eq!("light".parse::<ThemeMode>().unwrap(), ThemeMode::Light);
1564 assert_eq!("Light".parse::<ThemeMode>().unwrap(), ThemeMode::Light);
1565 assert_eq!("LIGHT".parse::<ThemeMode>().unwrap(), ThemeMode::Light);
1566 assert!("invalid".parse::<ThemeMode>().is_err());
1567 }
1568
1569 #[test]
1570 fn test_url_encode_query() {
1571 assert_eq!(url_encode_query("hello"), "hello");
1572 assert_eq!(url_encode_query("hello world"), "hello%20world");
1573 assert_eq!(url_encode_query("rust traits"), "rust%20traits");
1574 assert_eq!(url_encode_query("a+b"), "a%2Bb");
1575 assert_eq!(url_encode_query("foo&bar=baz"), "foo%26bar%3Dbaz");
1576 assert_eq!(url_encode_query(""), "");
1577 assert_eq!(url_encode_query("A-Z_0.9~"), "A-Z_0.9~");
1578 }
1579
1580 #[test]
1581 fn test_web_search_config_parsing() {
1582 let yaml_config = r#"
1583 addons:
1584 web_searches:
1585 - name: "Google"
1586 keyword: "g"
1587 url: "https://google.com/search?q={query}"
1588 icon: "google"
1589 - name: "DuckDuckGo"
1590 keyword: "ddg"
1591 url: "https://duckduckgo.com/?q={query}"
1592 shell:
1593 binary: sh
1594 description: "Shell"
1595 "#;
1596 let reader = Cursor::new(yaml_config);
1597 let args = Args {
1598 help: false,
1599 version: false,
1600 configfile: None,
1601 print_only: false,
1602 refresh_cache: false,
1603 no_icons: true,
1604 default_script_shell: None,
1605 ui_type: None,
1606 initial_query: None,
1607 theme: None,
1608 schema: false,
1609 };
1610 let parsed_config = read_config_from_reader(reader, &args).unwrap();
1611
1612 assert_eq!(parsed_config.addons.web_searches.len(), 2);
1613
1614 let google = &parsed_config.addons.web_searches[0];
1615 assert_eq!(google.name, "Google");
1616 assert_eq!(google.keyword, "g");
1617 assert_eq!(google.url, "https://google.com/search?q={query}");
1618 assert_eq!(google.icon, Some("google".to_string()));
1619
1620 let ddg = &parsed_config.addons.web_searches[1];
1621 assert_eq!(ddg.name, "DuckDuckGo");
1622 assert_eq!(ddg.keyword, "ddg");
1623 assert_eq!(ddg.url, "https://duckduckgo.com/?q={query}");
1624 assert!(ddg.icon.is_none());
1625 }
1626
1627 #[test]
1628 fn test_text_snippet_config_parsing() {
1629 let yaml_config = r#"
1630 addons:
1631 text_snippets:
1632 - name: "Emails"
1633 keyword: "em"
1634 icon: "mail"
1635 snippets:
1636 - name: "Personal Email"
1637 value: "user@example.com"
1638 - name: "Work Email"
1639 value: "user@company.com"
1640 - name: "Templates"
1641 keyword: "tpl"
1642 file: "~/.config/raffi/snippets.yaml"
1643 - name: "Dynamic"
1644 keyword: "dyn"
1645 command: "my-snippet-gen"
1646 args: ["-j"]
1647 shell:
1648 binary: sh
1649 description: "Shell"
1650 "#;
1651 let reader = Cursor::new(yaml_config);
1652 let args = Args {
1653 help: false,
1654 version: false,
1655 configfile: None,
1656 print_only: false,
1657 refresh_cache: false,
1658 no_icons: true,
1659 default_script_shell: None,
1660 ui_type: None,
1661 initial_query: None,
1662 theme: None,
1663 schema: false,
1664 };
1665 let parsed_config = read_config_from_reader(reader, &args).unwrap();
1666
1667 assert_eq!(parsed_config.addons.text_snippets.len(), 3);
1668
1669 let emails = &parsed_config.addons.text_snippets[0];
1671 assert_eq!(emails.name, "Emails");
1672 assert_eq!(emails.keyword, "em");
1673 assert_eq!(emails.icon, Some("mail".to_string()));
1674 let snippets = emails.snippets.as_ref().unwrap();
1675 assert_eq!(snippets.len(), 2);
1676 assert_eq!(snippets[0].name, "Personal Email");
1677 assert_eq!(snippets[0].value, "user@example.com");
1678 assert_eq!(snippets[1].name, "Work Email");
1679 assert_eq!(snippets[1].value, "user@company.com");
1680
1681 let templates = &parsed_config.addons.text_snippets[1];
1683 assert_eq!(templates.name, "Templates");
1684 assert_eq!(templates.keyword, "tpl");
1685 let home = std::env::var("HOME").unwrap();
1686 assert_eq!(
1687 templates.file,
1688 Some(format!("{home}/.config/raffi/snippets.yaml"))
1689 );
1690 assert!(templates.snippets.is_none());
1691 assert!(templates.command.is_none());
1692
1693 let dynamic = &parsed_config.addons.text_snippets[2];
1695 assert_eq!(dynamic.name, "Dynamic");
1696 assert_eq!(dynamic.keyword, "dyn");
1697 assert_eq!(dynamic.command, Some("my-snippet-gen".to_string()));
1698 assert_eq!(dynamic.args, vec!["-j".to_string()]);
1699 assert!(dynamic.snippets.is_none());
1700 assert!(dynamic.file.is_none());
1701 }
1702
1703 #[test]
1704 fn test_text_snippet_directory_config_parsing() {
1705 let yaml_config = r#"
1706 addons:
1707 text_snippets:
1708 - name: "Snippets"
1709 keyword: "sn"
1710 icon: "snippets"
1711 directory: "~/.local/share/snippets"
1712 shell:
1713 binary: sh
1714 description: "Shell"
1715 "#;
1716 let reader = Cursor::new(yaml_config);
1717 let args = Args {
1718 help: false,
1719 version: false,
1720 configfile: None,
1721 print_only: false,
1722 refresh_cache: false,
1723 no_icons: true,
1724 default_script_shell: None,
1725 ui_type: None,
1726 initial_query: None,
1727 theme: None,
1728 schema: false,
1729 };
1730 let parsed_config = read_config_from_reader(reader, &args).unwrap();
1731
1732 assert_eq!(parsed_config.addons.text_snippets.len(), 1);
1733 let snippets_dir = &parsed_config.addons.text_snippets[0];
1734 assert_eq!(snippets_dir.name, "Snippets");
1735 assert_eq!(snippets_dir.keyword, "sn");
1736 assert_eq!(snippets_dir.icon, Some("snippets".to_string()));
1737 let home = std::env::var("HOME").unwrap();
1738 assert_eq!(
1739 snippets_dir.directory,
1740 Some(format!("{home}/.local/share/snippets"))
1741 );
1742 assert!(snippets_dir.snippets.is_none());
1743 assert!(snippets_dir.file.is_none());
1744 assert!(snippets_dir.command.is_none());
1745 }
1746
1747 #[test]
1748 fn test_text_snippet_defaults_to_empty() {
1749 let yaml_config = r#"
1750 shell:
1751 binary: sh
1752 description: "Shell"
1753 "#;
1754 let reader = Cursor::new(yaml_config);
1755 let args = Args {
1756 help: false,
1757 version: false,
1758 configfile: None,
1759 print_only: false,
1760 refresh_cache: false,
1761 no_icons: true,
1762 default_script_shell: None,
1763 ui_type: None,
1764 initial_query: None,
1765 theme: None,
1766 schema: false,
1767 };
1768 let parsed_config = read_config_from_reader(reader, &args).unwrap();
1769 assert!(parsed_config.addons.text_snippets.is_empty());
1770 }
1771
1772 #[test]
1773 fn test_emoji_addon_config_parsing() {
1774 let yaml_config = r#"
1775 addons:
1776 emoji:
1777 enabled: true
1778 trigger: "em"
1779 action: "insert"
1780 secondary_action: "copy"
1781 data_files:
1782 - emojis_smileys_emotion
1783 - nerd_font
1784 shell:
1785 binary: sh
1786 description: "Shell"
1787 "#;
1788 let reader = Cursor::new(yaml_config);
1789 let args = Args {
1790 help: false,
1791 version: false,
1792 configfile: None,
1793 print_only: false,
1794 refresh_cache: false,
1795 no_icons: true,
1796 default_script_shell: None,
1797 ui_type: None,
1798 initial_query: None,
1799 theme: None,
1800 schema: false,
1801 };
1802 let parsed_config = read_config_from_reader(reader, &args).unwrap();
1803
1804 assert!(parsed_config.addons.emoji.enabled);
1805 assert_eq!(parsed_config.addons.emoji.trigger, Some("em".to_string()));
1806 assert_eq!(
1807 parsed_config.addons.emoji.action,
1808 Some("insert".to_string())
1809 );
1810 assert_eq!(
1811 parsed_config.addons.emoji.secondary_action,
1812 Some("copy".to_string())
1813 );
1814 assert_eq!(
1815 parsed_config.addons.emoji.data_files,
1816 Some(vec![
1817 "emojis_smileys_emotion".to_string(),
1818 "nerd_font".to_string()
1819 ])
1820 );
1821 }
1822
1823 #[test]
1824 fn test_emoji_addon_defaults_to_enabled() {
1825 let yaml_config = r#"
1826 shell:
1827 binary: sh
1828 description: "Shell"
1829 "#;
1830 let reader = Cursor::new(yaml_config);
1831 let args = Args {
1832 help: false,
1833 version: false,
1834 configfile: None,
1835 print_only: false,
1836 refresh_cache: false,
1837 no_icons: true,
1838 default_script_shell: None,
1839 ui_type: None,
1840 initial_query: None,
1841 theme: None,
1842 schema: false,
1843 };
1844 let parsed_config = read_config_from_reader(reader, &args).unwrap();
1845
1846 assert!(parsed_config.addons.emoji.enabled);
1848 assert!(parsed_config.addons.emoji.trigger.is_none());
1849 assert!(parsed_config.addons.emoji.action.is_none());
1850 assert!(parsed_config.addons.emoji.secondary_action.is_none());
1851 assert!(parsed_config.addons.emoji.data_files.is_none());
1852 }
1853
1854 #[test]
1855 fn test_v0_config_migrated_in_memory() {
1856 let yaml_config = r#"
1858 general:
1859 no_icons: true
1860 addons:
1861 calculator:
1862 enabled: false
1863 shell:
1864 binary: sh
1865 description: "Shell"
1866 firefox:
1867 binary: firefox
1868 description: "Firefox"
1869 "#;
1870 let reader = Cursor::new(yaml_config);
1871 let args = Args {
1872 help: false,
1873 version: false,
1874 configfile: None,
1875 print_only: false,
1876 refresh_cache: false,
1877 no_icons: true,
1878 default_script_shell: None,
1879 ui_type: None,
1880 initial_query: None,
1881 theme: None,
1882 schema: false,
1883 };
1884 let parsed_config = read_config_from_reader(reader, &args).unwrap();
1885
1886 assert_eq!(parsed_config.general.no_icons, Some(true));
1888 assert!(!parsed_config.addons.calculator.enabled);
1889
1890 assert_eq!(parsed_config.entries.len(), 2);
1892 }
1893
1894 #[test]
1895 fn test_v1_config_passes_through() {
1896 let yaml_config = r#"
1898 version: 1
1899 general:
1900 no_icons: true
1901 launchers:
1902 shell:
1903 binary: sh
1904 description: "Shell"
1905 firefox:
1906 binary: firefox
1907 description: "Firefox"
1908 "#;
1909 let reader = Cursor::new(yaml_config);
1910 let args = Args {
1911 help: false,
1912 version: false,
1913 configfile: None,
1914 print_only: false,
1915 refresh_cache: false,
1916 no_icons: true,
1917 default_script_shell: None,
1918 ui_type: None,
1919 initial_query: None,
1920 theme: None,
1921 schema: false,
1922 };
1923 let parsed_config = read_config_from_reader(reader, &args).unwrap();
1924
1925 assert_eq!(parsed_config.general.no_icons, Some(true));
1926 assert_eq!(parsed_config.entries.len(), 2);
1927 }
1928
1929 #[test]
1930 fn test_migrate_v0_to_v1_moves_keys() {
1931 let yaml_str = r#"
1932 general:
1933 no_icons: true
1934 addons:
1935 calculator:
1936 enabled: false
1937 shell:
1938 binary: sh
1939 description: "Shell"
1940 firefox:
1941 binary: firefox
1942 description: "Firefox"
1943 "#;
1944 let mut raw: Value = serde_yaml::from_str(yaml_str).unwrap();
1945
1946 let dir = std::env::temp_dir();
1948 let config_path = dir.join("test_migrate_v0.yaml");
1949 fs::write(&config_path, yaml_str).unwrap();
1950
1951 let migrated = migrate_config_v0_to_v1(config_path.to_str().unwrap(), &mut raw).unwrap();
1952 assert!(migrated);
1953
1954 let mapping = raw.as_mapping().unwrap();
1956 assert!(mapping.contains_key(&Value::String("version".to_string())));
1957 assert!(mapping.contains_key(&Value::String("launchers".to_string())));
1958 assert!(mapping.contains_key(&Value::String("general".to_string())));
1959 assert!(mapping.contains_key(&Value::String("addons".to_string())));
1960
1961 assert!(!mapping.contains_key(&Value::String("shell".to_string())));
1963 assert!(!mapping.contains_key(&Value::String("firefox".to_string())));
1964
1965 let launchers = mapping
1967 .get(&Value::String("launchers".to_string()))
1968 .unwrap()
1969 .as_mapping()
1970 .unwrap();
1971 assert!(launchers.contains_key(&Value::String("shell".to_string())));
1972 assert!(launchers.contains_key(&Value::String("firefox".to_string())));
1973
1974 let backup_path = format!("{}.bak", config_path.to_str().unwrap());
1976 assert!(Path::new(&backup_path).exists());
1977
1978 let _ = fs::remove_file(&config_path);
1980 let _ = fs::remove_file(&backup_path);
1981 }
1982
1983 #[test]
1984 fn test_migrate_v1_is_noop() {
1985 let yaml_str = r#"
1986 version: 1
1987 launchers:
1988 shell:
1989 binary: sh
1990 "#;
1991 let mut raw: Value = serde_yaml::from_str(yaml_str).unwrap();
1992
1993 let dir = std::env::temp_dir();
1994 let config_path = dir.join("test_migrate_v1_noop.yaml");
1995 fs::write(&config_path, yaml_str).unwrap();
1996
1997 let migrated = migrate_config_v0_to_v1(config_path.to_str().unwrap(), &mut raw).unwrap();
1998 assert!(!migrated);
1999
2000 let backup_path = format!("{}.bak", config_path.to_str().unwrap());
2002 assert!(!Path::new(&backup_path).exists());
2003
2004 let _ = fs::remove_file(&config_path);
2006 }
2007
2008 #[test]
2009 fn test_schema_generation() {
2010 let schema = generate_schema();
2011 let parsed: serde_json::Value = serde_json::from_str(&schema).unwrap();
2012
2013 assert_eq!(parsed["type"], "object");
2015
2016 let props = parsed["properties"].as_object().unwrap();
2018 assert!(props.contains_key("version"));
2019 assert!(props.contains_key("general"));
2020 assert!(props.contains_key("addons"));
2021 assert!(props.contains_key("launchers"));
2022 }
2023}