Skip to main content

raffi/
lib.rs

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/// Represents the configuration for each Raffi entry.
19#[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/// Configuration for the currency addon
34#[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/// Configuration for the calculator addon
58#[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/// Configuration for the file browser addon
71#[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
88/// Default data file names downloaded from the rofimoji project when
89/// the user does not provide an explicit `data_files` list.
90pub 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/// Configuration for the emoji picker addon
104#[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/// Configuration for a script filter addon
131#[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/// Configuration for a web search addon
144#[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/// A single text snippet entry
153#[derive(Deserialize, JsonSchema, Debug, Clone, PartialEq)]
154pub struct TextSnippet {
155    pub name: String,
156    pub value: String,
157}
158
159/// Configuration for a text snippet source
160#[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/// Container for all addon configurations
183#[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/// Per-colour overrides for the native UI theme.
206#[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/// General configuration for persistent defaults
219#[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
245/// Complete parsed configuration
246pub struct ParsedConfig {
247    pub general: GeneralConfig,
248    pub addons: AddonsConfig,
249    pub entries: Vec<RaffiConfig>,
250}
251
252/// Represents the top-level configuration structure (v1 format).
253#[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/// Public schema representation of the v1 config format, used for JSON Schema generation.
267#[derive(JsonSchema)]
268pub struct ConfigSchema {
269    /// Config format version (currently 1)
270    pub version: u32,
271    /// General settings (UI, theme, font, etc.)
272    #[serde(default)]
273    pub general: GeneralConfig,
274    /// Addon configurations (script filters, web searches, etc.)
275    #[serde(default)]
276    pub addons: AddonsConfig,
277    /// Launcher entries keyed by name
278    #[serde(default)]
279    pub launchers: HashMap<String, RaffiConfig>,
280}
281
282/// UI type selection
283#[derive(Debug, Clone, PartialEq)]
284pub enum UIType {
285    Fuzzel,
286    #[cfg(feature = "wayland")]
287    Native,
288}
289
290/// Theme mode selection
291#[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/// Command-line arguments structure.
343#[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
369/// A trait for checking environment variables.
370pub trait EnvProvider {
371    fn var(&self, key: &str) -> Result<String, std::env::VarError>;
372}
373
374/// The default environment provider.
375pub 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
383/// A trait for checking if a binary exists.
384pub trait BinaryChecker {
385    fn exists(&self, binary: &str) -> bool;
386}
387
388/// The default binary checker.
389pub struct DefaultBinaryChecker;
390
391impl BinaryChecker for DefaultBinaryChecker {
392    fn exists(&self, binary: &str) -> bool {
393        find_binary(binary)
394    }
395}
396
397/// A trait for providing an icon map.
398pub trait IconMapProvider {
399    fn get_icon_map(&self) -> Result<HashMap<String, String>>;
400}
401
402/// The default icon map provider.
403pub struct DefaultIconMapProvider;
404
405impl IconMapProvider for DefaultIconMapProvider {
406    fn get_icon_map(&self) -> Result<HashMap<String, String>> {
407        read_icon_map()
408    }
409}
410
411/// Extract icon size from path (e.g., "/usr/share/icons/Papirus/48x48/apps/icon.svg" -> 48).
412/// Returns 0 if size cannot be determined.
413fn 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                // Match patterns like "48x48", "64x64", "scalable"
418                if s_str == "scalable" {
419                    return 512; // Treat scalable as large
420                }
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
432/// Get the icon mapping from system directories.
433/// Prefers larger icons (48x48+) since raffi renders at 48x48.
434fn 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                        // Prefer icons >= 48px, or larger than current
465                        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
477/// Expand `~/` prefix to the user's HOME directory.
478pub(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
486/// Expand `${VAR}` references to their environment variable values.
487/// Unknown or unset variables expand to an empty string.
488fn 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(); // consume '{'
494            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
503/// Expand both `~/` and `${VAR}` in a config value.
504pub(crate) fn expand_config_value(s: &str) -> String {
505    expand_env_vars(&expand_tilde(s))
506}
507
508/// Migrate a v0 config (launcher entries as top-level keys) to v1 format
509/// (entries under a `launchers` key with an explicit `version` field).
510/// Returns `Ok(true)` if migration was performed, `Ok(false)` if already v1+.
511pub 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 version key exists, no migration needed
518    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    // Back up original file
550    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    // Write migrated config
555    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
564/// Read the configuration file and return a ParsedConfig.
565pub 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
577/// Read config from a reader, accepting both v0 (flat) and v1 (launchers) formats.
578/// Used primarily for tests where no file path is available for migration.
579pub 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    // For reader-based configs, do an in-memory migration (no file write)
588    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
623/// Common config processing logic shared by read_config and read_config_from_reader.
624fn 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
671/// Validate the RaffiConfig based on various conditions.
672fn 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
713/// Check if a binary exists in the PATH.
714fn 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
721/// Save the icon map to a cache file.
722fn 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
743/// Clear the icon cache file to force regeneration.
744pub 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
756/// Clear the cached emoji data files to force re-download.
757pub 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
769/// Read the icon map from the cache file or generate it if it doesn't exist.
770pub 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
791/// Execute the chosen command or script.
792pub fn execute_chosen_command(mc: &RaffiConfig, args: &Args, interpreter: &str) -> Result<()> {
793    // make interepreter with mc.binary and mc.args on the same line
794    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
827/// Percent-encode a query string for use in URLs.
828/// Encodes all characters except unreserved ones (A-Z, a-z, 0-9, '-', '.', '_', '~').
829pub 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
844/// Build a web search URL by replacing `{query}` in the template with the percent-encoded query,
845/// then open it with `xdg-open`.
846pub 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
856/// Generate the JSON Schema for the raffi config format.
857fn 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    // Write schema file if it doesn't exist yet
885    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    // Merge general config: CLI flags override config values
904    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    // Determine theme
915    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    // Determine UI type
925    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    // Determine max history size
945    let max_history = general.max_history.unwrap_or(10);
946
947    // Determine font sizes (and proportional paddings)
948    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    // Override outer padding if explicitly set
955    if let Some(padding) = general.padding {
956        font_sizes.outer_padding = padding;
957    }
958
959    // Build UI settings
960    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    // Get the appropriate UI implementation
973    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        // Not used for binary commands
995        ""
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        // Addons should default to enabled
1035        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        // Parse and expand manually to avoid is_valid_config filtering out non-existent paths
1373        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        // Inline snippets source
1670        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        // File source
1682        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        // Command source
1694        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        // Emoji addon is enabled by default
1847        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        // v0 format: launcher entries at top level (no version field)
1857        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        // general and addons should be preserved
1887        assert_eq!(parsed_config.general.no_icons, Some(true));
1888        assert!(!parsed_config.addons.calculator.enabled);
1889
1890        // launcher entries should be found
1891        assert_eq!(parsed_config.entries.len(), 2);
1892    }
1893
1894    #[test]
1895    fn test_v1_config_passes_through() {
1896        // v1 format: entries under launchers key
1897        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        // Create a temp file for migration
1947        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        // Check structure after migration
1955        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        // shell and firefox should NOT be top-level anymore
1962        assert!(!mapping.contains_key(&Value::String("shell".to_string())));
1963        assert!(!mapping.contains_key(&Value::String("firefox".to_string())));
1964
1965        // They should be inside launchers
1966        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        // Backup should exist
1975        let backup_path = format!("{}.bak", config_path.to_str().unwrap());
1976        assert!(Path::new(&backup_path).exists());
1977
1978        // Cleanup
1979        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        // No backup should be created
2001        let backup_path = format!("{}.bak", config_path.to_str().unwrap());
2002        assert!(!Path::new(&backup_path).exists());
2003
2004        // Cleanup
2005        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        // Should be a valid JSON Schema
2014        assert_eq!(parsed["type"], "object");
2015
2016        // Should have our top-level properties
2017        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}