use figment::Figment; use figment::providers::{Format, Toml}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; use std::time::Duration; use async_trait::async_trait; use crate::search_engines::{EngineError, ResultEntry, SearchEngine}; /// Configuration for a single search engine instance. #[derive(Debug, Deserialize, Clone)] #[serde(tag = "type", rename_all = "lowercase")] pub enum EngineConfig { /// crates.io registry (public or private) #[serde(rename = "crates_io")] CratesIo { #[serde(default)] url: Option<String>, #[serde(default)] timeout_secs: Option<f32>, // Per-engine shortcut overrides (see `auto_shortcode()`, `bang_url()`, `is_bang_enabled()`) #[serde(default)] shortcode: Option<String>, #[serde(default)] bang_enabled: Option<bool>, #[serde(default)] bang_url: Option<String>, }, /// SearXNG meta-search engine SearXng { instance: String, #[serde(default)] timeout_secs: Option<f32>, #[serde(default)] shortcode: Option<String>, #[serde(default)] bang_enabled: Option<bool>, #[serde(default)] bang_url: Option<String>, }, /// Wikipedia search (language-specific) Wikipedia { /// Language code (e.g. "en", "fr", "de"). Defaults to "en" if omitted. #[serde(default)] lang: Option<String>, #[serde(default)] timeout_secs: Option<f32>, #[serde(default)] shortcode: Option<String>, #[serde(default)] bang_enabled: Option<bool>, #[serde(default)] bang_url: Option<String>, }, /// MDN Web Docs search (language-specific) Mdn { /// Locale code (e.g. "en-US", "fr", "de"). Defaults to "en-US" if omitted. #[serde(default)] locale: Option<String>, #[serde(default)] timeout_secs: Option<f32>, #[serde(default)] shortcode: Option<String>, #[serde(default)] bang_enabled: Option<bool>, #[serde(default)] bang_url: Option<String>, }, /// Generic HTML-scraped search engine (configurable URL + CSS selector) Generic { /// Display name for this engine (e.g. "DuckDuckGo", "Stack Overflow"). name: String, /// URL template with `{}` placeholder for the query. url: String, /// CSS selector targeting the result container. selector: String, #[serde(default)] timeout_secs: Option<f32>, #[serde(default)] shortcode: Option<String>, #[serde(default)] bang_enabled: Option<bool>, #[serde(default)] bang_url: Option<String>, }, } #[async_trait] impl SearchEngine for EngineConfig { fn id(&self) -> &str { match self { EngineConfig::CratesIo { .. } => "crates.io", EngineConfig::SearXng { .. } => "searxng", EngineConfig::Wikipedia { .. } => "wikipedia", EngineConfig::Mdn { .. } => "mdn", EngineConfig::Generic { .. } => "generic", } } fn name(&self) -> &str { match self { EngineConfig::CratesIo { .. } => "crates.io", EngineConfig::SearXng { .. } => "SearXNG", EngineConfig::Wikipedia { .. } => "Wikipedia", EngineConfig::Mdn { .. } => "MDN", EngineConfig::Generic { name, .. } => name, } } fn url_template(&self) -> &str { match self { EngineConfig::Generic { url, .. } => url, _ => "", } } fn selector(&self) -> &str { match self { EngineConfig::Generic { selector, .. } => selector, _ => "", } } fn timeout(&self) -> Duration { let secs = match self { EngineConfig::CratesIo { timeout_secs, .. } | EngineConfig::SearXng { timeout_secs, .. } | EngineConfig::Wikipedia { timeout_secs, .. } | EngineConfig::Mdn { timeout_secs, .. } | EngineConfig::Generic { timeout_secs, .. } => timeout_secs, }; secs.map(Duration::from_secs_f32).unwrap_or(Duration::from_secs(5)) } async fn fetch_results( &self, query: &str, client: &reqwest::Client, ) -> Result<Vec<ResultEntry>, EngineError> { match self { EngineConfig::CratesIo { url, timeout_secs, .. } => { let engine = crate::search_engines::crates_io::CratesIo { timeout_secs: *timeout_secs, api_url: url.clone().unwrap_or_else(|| crate::search_engines::crates_io::DEFAULT_API_URL.into()), }; engine.fetch_results(query, client).await } EngineConfig::SearXng { instance, timeout_secs, .. } => { let engine = crate::search_engines::searxng::SearXng { instance: instance.clone(), url_tpl: format!("{}/search?format=json&q={{}}", instance.trim_end_matches('/')), timeout_secs: *timeout_secs, }; engine.fetch_results(query, client).await } EngineConfig::Wikipedia { lang, timeout_secs, .. } => { let engine = crate::search_engines::wikipedia::Wikipedia { lang: lang.clone().unwrap_or_else(|| crate::search_engines::wikipedia::DEFAULT_LANG.into()), timeout_secs: *timeout_secs, }; engine.fetch_results(query, client).await } EngineConfig::Mdn { locale, timeout_secs, .. } => { let engine = crate::search_engines::mdn::Mdn { locale: locale.clone().unwrap_or_else(|| crate::search_engines::mdn::DEFAULT_LOCALE.into()), timeout_secs: *timeout_secs, }; engine.fetch_results(query, client).await } EngineConfig::Generic { name, url, selector, timeout_secs, .. } => { let engine = crate::search_engines::generic::Generic { name: name.clone(), url: url.clone(), selector: selector.clone(), timeout_secs: *timeout_secs, }; engine.fetch_results(query, client).await } } } } /// A resolved shortcut linking a trigger string (!/@) to a bang URL and /// optionally to a specific search engine. #[derive(Debug, Clone, Serialize)] pub struct Shortcut { /// The trigger keyword (e.g. "w" for !w). pub trigger: String, /// URL template with `{}` placeholder for the query. pub bang_url: String, /// Human-readable display name. pub name: String, /// If `Some(i)`, this shortcut originated from `engines[i]` and `@` works. pub engine_index: Option<usize>, } /// User-defined override or addition to auto-generated shortcuts. #[derive(Debug, Deserialize, Clone)] pub struct BangOverride { /// The trigger keyword (e.g. "w" for !w). pub trigger: String, /// Optional override URL template with `{}` placeholder. pub url: Option<String>, /// Optional display name override. pub name: Option<String>, /// Set to `false` to suppress an auto-generated shortcut. #[serde(default)] pub enabled: Option<bool>, } impl EngineConfig { /// Human-facing search-page URL for bang redirects (not the API URL). pub fn bang_url(&self) -> String { match self { EngineConfig::Wikipedia { lang, .. } => { let lang = lang.clone().unwrap_or_else(|| "en".into()); format!("https://{lang}.wikipedia.org/w/index.php?search={{}}") } EngineConfig::Mdn { locale, .. } => { let locale = locale.clone().unwrap_or_else(|| "en-US".into()); format!("https://developer.mozilla.org/{locale}/search?q={{}}") } EngineConfig::CratesIo { .. } => "https://crates.io/search?q={}".into(), EngineConfig::SearXng { instance, .. } => { format!("{}/search?q={{}}", instance.trim_end_matches('/')) } EngineConfig::Generic { url, .. } => url.clone(), } } /// Auto-generated shortcode derived from engine type and locale. pub fn auto_shortcode(&self) -> String { match self { EngineConfig::Wikipedia { lang, .. } => match lang.as_deref() { None | Some("en") => "w".into(), Some(l) => format!("w{l}"), }, EngineConfig::Mdn { locale, .. } => match locale.as_deref() { None | Some("en-US") => "mdn".into(), Some(l) => { let short = l.split('-').next().unwrap_or(l); format!("mdn{short}") } }, EngineConfig::CratesIo { .. } => "crates".into(), EngineConfig::SearXng { .. } => "sx".into(), EngineConfig::Generic { name, .. } => name .to_lowercase() .replace(|c: char| !c.is_alphanumeric() && c != '-', "-") .trim_matches('-') .to_string(), } } /// Effective shortcode, respecting user override on the engine. pub fn effective_shortcode(&self) -> String { match self { EngineConfig::CratesIo { shortcode, .. } | EngineConfig::SearXng { shortcode, .. } | EngineConfig::Wikipedia { shortcode, .. } | EngineConfig::Mdn { shortcode, .. } | EngineConfig::Generic { shortcode, .. } => { shortcode.clone().unwrap_or_else(|| self.auto_shortcode()) } } } /// Effective bang URL, respecting user override on the engine. pub fn effective_bang_url(&self) -> String { match self { EngineConfig::CratesIo { bang_url, .. } | EngineConfig::SearXng { bang_url, .. } | EngineConfig::Wikipedia { bang_url, .. } | EngineConfig::Mdn { bang_url, .. } | EngineConfig::Generic { bang_url, .. } => { bang_url.clone().unwrap_or_else(|| self.bang_url()) } } } /// Whether `!` bang redirect is enabled for this engine. pub fn is_bang_enabled(&self) -> bool { match self { EngineConfig::CratesIo { bang_enabled, .. } | EngineConfig::SearXng { bang_enabled, .. } | EngineConfig::Wikipedia { bang_enabled, .. } | EngineConfig::Mdn { bang_enabled, .. } | EngineConfig::Generic { bang_enabled, .. } => { bang_enabled.unwrap_or(true) } } } } /// Application configuration loaded from the TOML config file. /// /// # Example /// /// ```ignore /// let cfg = search_hub::config::Config::load(); /// let engines = cfg.engines.clone(); /// println!("{} engines enabled", engines.len()); /// ``` #[derive(Debug, Default, Deserialize)] pub struct Config { /// Custom tag definitions. If non-empty, these replace the hardcoded defaults. #[serde(default)] pub tags: Vec<crate::tagging::TagDef>, /// Whether auto-tagging is enabled. Defaults to `false` if not set. #[serde(default)] pub tagging_enabled: Option<bool>, /// Tagging threshold (0.0 to 1.0). Defaults to 0.60 if not set. #[serde(default)] pub tagging_threshold: Option<f64>, /// Hostnames to exclude from content fetching. #[serde(default)] pub exclude_urls: Option<Vec<String>>, /// Per-engine configuration. Each entry defines an enabled search engine. #[serde(default)] pub engines: Vec<EngineConfig>, /// User-defined shortcut overrides and additions. #[serde(default)] pub bangs: Vec<BangOverride>, /// Default bookmark database path. #[serde(default)] pub db_path: Option<String>, /// Server bind address (default: "127.0.0.1"). #[serde(default)] pub bind_address: Option<String>, /// Results per page (default: 20). #[serde(default)] pub page_size: Option<usize>, /// Actix worker threads (default: 2). #[serde(default)] pub workers: Option<usize>, /// ONNX embedding model name (default: "BGESmallENV15"). #[serde(default)] pub onnx_model: Option<String>, /// Max characters to use from page content for tagging (default: 2000). #[serde(default)] pub truncation: Option<usize>, /// Max tags to assign per bookmark (default: 5). #[serde(default)] pub max_tags: Option<usize>, } impl Config { /// Load configuration from the default config file path. /// /// Returns a default (empty) `Config` if the file doesn't exist or can't be parsed. /// Parse errors are printed to stderr. /// /// # Example /// /// ```ignore /// let cfg = search_hub::config::Config::load(); /// ``` pub fn load() -> Self { Self::load_from(&config_file_path()) } /// Load configuration from a specific file path. /// /// Returns a default (empty) `Config` if the file doesn't exist or can't be parsed. /// Parse errors are printed to stderr. /// /// # Example /// /// ```ignore /// let cfg = search_hub::config::Config::load_from(&PathBuf::from("/tmp/test.toml")); /// ``` pub fn load_from(path: &PathBuf) -> Self { if path.exists() { Figment::new() .merge(Toml::file(path)) .extract() .unwrap_or_else(|e| { eprintln!("Warning: failed to parse config file {path:?}: {e}"); Config::default() }) } else { Config::default() } } /// Resolve all shortcuts by combining auto-generated engine shortcodes /// with user-defined `[[bangs]]` overrides. /// /// Two-phase resolution: /// 1. Walk engines, auto-generate or use per-engine `shortcode` override. /// 2. Walk `[[bangs]]` entries: disable existing, add new, or merge /// (replace url/name on an auto-generated entry). /// /// # Panics /// /// Panics if two engines produce the same shortcode. Set `shortcode` on /// one of them to resolve the conflict. pub fn resolve_shortcuts(&self) -> HashMap<String, Shortcut> { let mut map: HashMap<String, Shortcut> = HashMap::new(); // Phase 1: auto-generate from configured engines for (i, engine) in self.engines.iter().enumerate() { if !engine.is_bang_enabled() { continue; } let trigger = engine.effective_shortcode(); let bang_url = engine.effective_bang_url(); let name = engine.name().to_string(); if let Some(existing) = map.get(&trigger) { panic!( "Shortcode conflict: '{trigger}' is used by engine {existing_idx:?} \ ({existing_name}) and engine #{i} ({name}). \ Set `shortcode` on one of them to resolve.", existing_idx = existing.engine_index, existing_name = existing.name, ); } map.insert( trigger.clone(), Shortcut { trigger, bang_url, name, engine_index: Some(i), }, ); } // Phase 2: apply user-defined [[bangs]] overrides/additions for bang in &self.bangs { let enabled = bang.enabled.unwrap_or(true); if !enabled { map.remove(&bang.trigger); continue; } let entry = map.entry(bang.trigger.clone()).or_insert_with(|| Shortcut { trigger: bang.trigger.clone(), bang_url: String::new(), name: String::new(), engine_index: None, }); if let Some(url) = &bang.url { entry.bang_url = url.clone(); } if let Some(name) = &bang.name { entry.name = name.clone(); } } map } } /// Return the expected config file path (e.g. `~/.config/search_hub/config.toml` on Linux). /// /// # Example /// /// ```ignore /// let path = search_hub::config::config_file_path(); /// ``` /// /// # Panics /// /// Panics if the platform has no valid config directory. pub fn config_file_path() -> PathBuf { let dirs = directories::ProjectDirs::from("com", "search_hub", "search_hub") .expect("no valid config directory"); let config_dir = dirs.config_dir(); config_dir.join("config.toml") } #[cfg(test)] mod tests { use super::*; use tempfile::NamedTempFile; use std::io::Write; #[test] fn load_from_missing_file_returns_default() { let cfg = Config::load_from(&PathBuf::from("/nonexistent/path.toml")); assert!(cfg.tags.is_empty()); assert!(cfg.engines.is_empty()); } #[test] fn load_from_valid_file_with_engines() { let mut file = NamedTempFile::new().unwrap(); write!(file, r#" [[engines]] type = "searxng" instance = "https://search.example.com" "#).unwrap(); let cfg = Config::load_from(&file.path().to_path_buf()); assert!(!cfg.engines.is_empty()); assert!(matches!(cfg.engines[0], EngineConfig::SearXng { .. })); if let EngineConfig::SearXng { instance, .. } = &cfg.engines[0] { assert_eq!(instance, "https://search.example.com"); } else { panic!("expected SearXng"); } } #[test] fn resolve_engines_includes_searxng_from_engines_vec() { let mut file = NamedTempFile::new().unwrap(); write!(file, r#" [[engines]] type = "searxng" instance = "https://search.example.com" "#).unwrap(); let cfg = Config::load_from(&file.path().to_path_buf()); let engines = cfg.engines.clone(); assert!(engines.iter().any(|e| e.id() == "searxng")); } #[test] fn resolve_engines_empty_by_default() { let cfg = Config::default(); assert!(cfg.engines.is_empty()); } #[test] fn resolve_engines_includes_crates_io_when_configured() { let mut file = NamedTempFile::new().unwrap(); write!(file, r#" [[engines]] type = "crates_io" "#).unwrap(); let cfg = Config::load_from(&file.path().to_path_buf()); let engines = cfg.engines.clone(); assert!(engines.iter().any(|e| e.id() == "crates.io")); } #[test] fn resolve_engines_includes_wikipedia_when_configured() { let mut file = NamedTempFile::new().unwrap(); write!(file, r#" [[engines]] type = "wikipedia" lang = "fr" "#).unwrap(); let cfg = Config::load_from(&file.path().to_path_buf()); let engines = cfg.engines.clone(); assert!(engines.iter().any(|e| e.id() == "wikipedia")); } #[test] fn resolve_engines_respects_multiple_engines() { let mut file = NamedTempFile::new().unwrap(); write!(file, r#" [[engines]] type = "crates_io" [[engines]] type = "searxng" instance = "https://search.example.com" "#).unwrap(); let cfg = Config::load_from(&file.path().to_path_buf()); let engines = cfg.engines.clone(); assert!(engines.iter().any(|e| e.id() == "crates.io")); assert!(engines.iter().any(|e| e.id() == "searxng")); } #[test] fn parse_error_returns_default() { let mut file = NamedTempFile::new().unwrap(); write!(file, "invalid toml [[[").unwrap(); let cfg = Config::load_from(&file.path().to_path_buf()); assert!(cfg.tags.is_empty()); assert!(cfg.engines.is_empty()); } #[test] fn tagging_enabled_defaults_to_false() { let cfg = Config::default(); assert!(!cfg.tagging_enabled.unwrap_or(false)); } #[test] fn tagging_enabled_can_be_false() { let mut file = NamedTempFile::new().unwrap(); write!(file, r#"tagging_enabled = false"#).unwrap(); let cfg = Config::load_from(&file.path().to_path_buf()); assert_eq!(cfg.tagging_enabled, Some(false)); } #[test] fn tagging_enabled_can_be_true() { let mut file = NamedTempFile::new().unwrap(); write!(file, r#"tagging_enabled = true"#).unwrap(); let cfg = Config::load_from(&file.path().to_path_buf()); assert_eq!(cfg.tagging_enabled, Some(true)); } // -- shortcut (bang/@) tests -- #[test] fn auto_shortcode_wikipedia_defaults_to_w() { let e = EngineConfig::Wikipedia { lang: None, timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None }; assert_eq!(e.auto_shortcode(), "w"); } #[test] fn auto_shortcode_wikipedia_lang_appended() { let e = EngineConfig::Wikipedia { lang: Some("fr".into()), timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None }; assert_eq!(e.auto_shortcode(), "wfr"); } #[test] fn auto_shortcode_wikipedia_en_is_w() { let e = EngineConfig::Wikipedia { lang: Some("en".into()), timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None }; assert_eq!(e.auto_shortcode(), "w"); } #[test] fn auto_shortcode_mdn_defaults_to_mdn() { let e = EngineConfig::Mdn { locale: None, timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None }; assert_eq!(e.auto_shortcode(), "mdn"); } #[test] fn auto_shortcode_mdn_locale_appended() { let e = EngineConfig::Mdn { locale: Some("fr".into()), timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None }; assert_eq!(e.auto_shortcode(), "mdnfr"); } #[test] fn auto_shortcode_crates_io() { let e = EngineConfig::CratesIo { url: None, timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None }; assert_eq!(e.auto_shortcode(), "crates"); } #[test] fn auto_shortcode_searxng() { let e = EngineConfig::SearXng { instance: "https://example.com".into(), timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None }; assert_eq!(e.auto_shortcode(), "sx"); } #[test] fn auto_shortcode_generic_slugifies_name() { let e = EngineConfig::Generic { name: "Stack Overflow".into(), url: "".into(), selector: "".into(), timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None }; assert_eq!(e.auto_shortcode(), "stack-overflow"); } #[test] fn effective_shortcode_respects_override() { let e = EngineConfig::Wikipedia { lang: Some("fr".into()), timeout_secs: None, shortcode: Some("wikifr".into()), bang_enabled: None, bang_url: None }; assert_eq!(e.effective_shortcode(), "wikifr"); } #[test] fn bang_url_wikipedia() { let e = EngineConfig::Wikipedia { lang: None, timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None }; assert_eq!(e.bang_url(), "https://en.wikipedia.org/w/index.php?search={}"); } #[test] fn bang_url_wikipedia_fr() { let e = EngineConfig::Wikipedia { lang: Some("fr".into()), timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None }; assert_eq!(e.bang_url(), "https://fr.wikipedia.org/w/index.php?search={}"); } #[test] fn bang_url_crates_io() { let e = EngineConfig::CratesIo { url: None, timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None }; assert_eq!(e.bang_url(), "https://crates.io/search?q={}"); } #[test] fn bang_url_searxng() { let e = EngineConfig::SearXng { instance: "https://search.example.com".into(), timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None }; assert_eq!(e.bang_url(), "https://search.example.com/search?q={}"); } #[test] fn bang_url_mdn() { let e = EngineConfig::Mdn { locale: None, timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None }; assert_eq!(e.bang_url(), "https://developer.mozilla.org/en-US/search?q={}"); } #[test] fn bang_url_generic_uses_provided_url() { let e = EngineConfig::Generic { name: "Test".into(), url: "https://example.com/search?q={}".into(), selector: "".into(), timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None }; assert_eq!(e.bang_url(), "https://example.com/search?q={}"); } #[test] fn effective_bang_url_respects_override() { let e = EngineConfig::Wikipedia { lang: None, timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: Some("https://custom.example.com/?q={}".into()) }; assert_eq!(e.effective_bang_url(), "https://custom.example.com/?q={}"); } #[test] fn is_bang_enabled_defaults_true() { let e = EngineConfig::Wikipedia { lang: None, timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None }; assert!(e.is_bang_enabled()); } #[test] fn is_bang_enabled_false_when_set() { let e = EngineConfig::Wikipedia { lang: None, timeout_secs: None, shortcode: None, bang_enabled: Some(false), bang_url: None }; assert!(!e.is_bang_enabled()); } #[test] fn resolve_shortcuts_from_single_engine() { let cfg = Config { engines: vec![ EngineConfig::CratesIo { url: None, timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None }, ], ..Config::default() }; let sc = cfg.resolve_shortcuts(); assert_eq!(sc.len(), 1); assert!(sc.contains_key("crates")); assert_eq!(sc["crates"].engine_index, Some(0)); } #[test] fn resolve_shortcuts_multiple_engines() { let cfg = Config { engines: vec![ EngineConfig::Wikipedia { lang: None, timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None }, EngineConfig::CratesIo { url: None, timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None }, ], ..Config::default() }; let sc = cfg.resolve_shortcuts(); assert_eq!(sc.len(), 2); assert!(sc.contains_key("w")); assert!(sc.contains_key("crates")); } #[test] fn resolve_shortcuts_respects_engine_shortcode_override() { let cfg = Config { engines: vec![ EngineConfig::Wikipedia { lang: None, timeout_secs: None, shortcode: Some("wiki".into()), bang_enabled: None, bang_url: None }, ], ..Config::default() }; let sc = cfg.resolve_shortcuts(); assert!(sc.contains_key("wiki")); assert!(!sc.contains_key("w")); } #[test] fn resolve_shortcuts_respects_engine_bang_disabled() { let cfg = Config { engines: vec![ EngineConfig::Wikipedia { lang: None, timeout_secs: None, shortcode: None, bang_enabled: Some(false), bang_url: None }, ], ..Config::default() }; let sc = cfg.resolve_shortcuts(); assert!(sc.is_empty()); } #[test] fn resolve_shortcuts_user_bang_override_replaces_url() { let cfg = Config { engines: vec![ EngineConfig::Wikipedia { lang: None, timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None }, ], bangs: vec![ BangOverride { trigger: "w".into(), url: Some("https://custom.example.com/?q={}".into()), name: None, enabled: None }, ], ..Config::default() }; let sc = cfg.resolve_shortcuts(); assert_eq!(sc["w"].bang_url, "https://custom.example.com/?q={}"); } #[test] fn resolve_shortcuts_user_bang_disables_auto() { let cfg = Config { engines: vec![ EngineConfig::Wikipedia { lang: None, timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None }, ], bangs: vec![ BangOverride { trigger: "w".into(), url: None, name: None, enabled: Some(false) }, ], ..Config::default() }; let sc = cfg.resolve_shortcuts(); assert!(!sc.contains_key("w")); } #[test] fn resolve_shortcuts_user_bang_adds_new() { let cfg = Config { bangs: vec![ BangOverride { trigger: "gh".into(), url: Some("https://github.com/search?q={}".into()), name: Some("GitHub".into()), enabled: None }, ], ..Config::default() }; let sc = cfg.resolve_shortcuts(); assert!(sc.contains_key("gh")); assert_eq!(sc["gh"].engine_index, None); assert_eq!(sc["gh"].bang_url, "https://github.com/search?q={}"); } #[test] fn resolve_shortcuts_parses_bangs_from_config_file() { let mut file = NamedTempFile::new().unwrap(); write!(file, r#" [[engines]] type = "wikipedia" [[bangs]] trigger = "gh" url = "https://github.com/search?q={{}}" name = "GitHub" "#).unwrap(); let cfg = Config::load_from(&file.path().to_path_buf()); let sc = cfg.resolve_shortcuts(); assert!(sc.contains_key("w")); assert!(sc.contains_key("gh")); assert_eq!(sc["gh"].engine_index, None); } #[test] #[should_panic(expected = "Shortcode conflict")] fn resolve_shortcuts_panics_on_collision() { let cfg = Config { engines: vec![ EngineConfig::Wikipedia { lang: Some("en".into()), timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None }, EngineConfig::Wikipedia { lang: Some("en".into()), timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None }, ], ..Config::default() }; cfg.resolve_shortcuts(); } }